From 89f76829b78e047025a6ae6359cbad359a7dc177 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Thu, 14 May 2026 10:10:57 +0200 Subject: [PATCH] Route insertion: larger snap marker, wider hit-test, thicker route line --- src/Planner.svelte | 97 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/Planner.svelte b/src/Planner.svelte index 710c135..c70d059 100644 --- a/src/Planner.svelte +++ b/src/Planner.svelte @@ -24,6 +24,10 @@ let saveName = $state(''); let saving = $state(false); + // Route insertion (hover snap) + let hoveringRoute = false; // plain JS — no need to render + let snapMarker; // maplibre Marker used as snap indicator + const PROFILES = [ { id: 'fastbike', label: 'Road' }, { id: 'gravel', label: 'Gravel' }, @@ -102,11 +106,16 @@ id: 'route-line', type: 'line', source: 'route', - paint: { 'line-color': '#e879a0', 'line-width': 3, 'line-opacity': 0.9 }, + paint: { 'line-color': '#e879a0', 'line-width': 4, 'line-opacity': 0.9 }, }); }); + const snapEl = document.createElement('div'); + snapEl.className = 'snap-marker'; + snapMarker = new maplibregl.Marker({ element: snapEl, draggable: false }); + map.on('click', onMapClick); + map.on('mousemove', onMapMouseMove); loadPlans(); }); @@ -114,8 +123,79 @@ // ── Waypoint management ──────────────────────────────────────────────────── function onMapClick(e) { + if (hoveringRoute && route && waypoints.length >= 2) { + const snapped = nearestOnLine(route.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); + const idx = insertionIndex(snapped.frac, route.geometry.coordinates); + insertWaypointAt(snapped.lng, snapped.lat, idx); + } else { + activePlanId = null; + addWaypoint(e.lngLat.lng, e.lngLat.lat); + } + } + + function onMapMouseMove(e) { + if (!route || waypoints.length < 2) { + if (hoveringRoute) { hoveringRoute = false; snapMarker.remove(); map.getCanvas().style.cursor = ''; } + return; + } + const b = 14; + const features = map.queryRenderedFeatures( + [[e.point.x - b, e.point.y - b], [e.point.x + b, e.point.y + b]], + { layers: ['route-line'] } + ); + if (features.length > 0) { + hoveringRoute = true; + map.getCanvas().style.cursor = 'crosshair'; + const snapped = nearestOnLine(route.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); + snapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map); + } else { + hoveringRoute = false; + snapMarker.remove(); + map.getCanvas().style.cursor = ''; + } + } + + // ── Route insertion helpers ──────────────────────────────────────────────── + function nearestOnLine(coords, [px, py]) { + let bestDist = Infinity, bestLng = px, bestLat = py, bestFrac = 0; + for (let i = 0; i < coords.length - 1; i++) { + const [x1, y1] = coords[i]; + const [x2, y2] = coords[i + 1]; + const dx = x2 - x1, dy = y2 - y1; + const lenSq = dx * dx + dy * dy; + const t = lenSq > 0 ? Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq)) : 0; + const nx = x1 + t * dx, ny = y1 + t * dy; + const d = Math.hypot(px - nx, py - ny); + if (d < bestDist) { bestDist = d; bestLng = nx; bestLat = ny; bestFrac = i + t; } + } + return { lng: bestLng, lat: bestLat, frac: bestFrac }; + } + + function insertionIndex(clickFrac, coords) { + const fracs = waypoints.map(wp => nearestOnLine(coords, [wp.lng, wp.lat]).frac); + for (let i = 0; i < fracs.length - 1; i++) { + if (clickFrac >= fracs[i] && clickFrac <= fracs[i + 1]) return i + 1; + } + return waypoints.length; + } + + function insertWaypointAt(lng, lat, idx) { + const el = document.createElement('div'); + el.className = 'wp-marker'; + const marker = new maplibregl.Marker({ element: el, draggable: true }) + .setLngLat([lng, lat]) + .addTo(map); + marker.on('dragend', () => { + const { lng: newLng, lat: newLat } = marker.getLngLat(); + const i = waypoints.findIndex(w => w.marker === marker); + if (i >= 0) { waypoints[i].lng = newLng; waypoints[i].lat = newLat; } + activePlanId = null; + fetchRoute(); + }); + waypoints = [...waypoints.slice(0, idx), { lng, lat, marker }, ...waypoints.slice(idx)]; + waypoints.forEach((w, i) => { w.marker.getElement().textContent = i + 1; }); activePlanId = null; - addWaypoint(e.lngLat.lng, e.lngLat.lat); + fetchRoute(); } function addWaypoint(lng, lat) { @@ -162,6 +242,9 @@ waypoints = []; route = null; error = ''; + hoveringRoute = false; + snapMarker?.remove(); + map.getCanvas().style.cursor = ''; if (map.getSource('route')) map.getSource('route').setData(emptyGeoJSON()); } @@ -583,6 +666,16 @@ ${trkpts} .plan-name { font-size: 0.8rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .plan-meta { font-size: 0.7rem; color: var(--text-5); } + :global(.snap-marker) { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2.5px solid var(--accent); + background: rgba(232, 121, 160, 0.25); + box-shadow: 0 0 0 2px rgba(232, 121, 160, 0.15); + pointer-events: none; + } + :global(.wp-marker) { width: 1.5rem; height: 1.5rem;