diff --git a/Cargo.lock b/Cargo.lock index 6f7f1a6..fe1781e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1590,7 +1590,7 @@ dependencies = [ [[package]] name = "rs_maps" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 85c16d9..6b344e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Cargo.toml [package] name = "rs_maps" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] diff --git a/frontend/app.js b/frontend/app.js index 1a008ed..1ee4de4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -50,7 +50,8 @@ const state = { draftLatLng: null, colour: DEFAULT_COLOUR, searchPin: null, // transient pin for the current search hit - draw: { active: false, points: [], markers: [], line: null }, + draw: { active: false, points: [], markers: [], line: null, guide: null, + timer: null, seq: 0, snapped: null }, }; /* ───────────────────────────── api ───────────────────────────── */ @@ -468,10 +469,17 @@ $('#marker-delete').addEventListener('click', async () => { const DRAW_COLOUR = '#4FA3D1'; +/* brouter.de is donated infrastructure, so previews are debounced rather than + fired on every click and every pixel of a drag. */ +const PREVIEW_DEBOUNCE_MS = 1000; + function drawReset() { + if (state.draw.timer) clearTimeout(state.draw.timer); state.draw.markers.forEach((m) => state.map.removeLayer(m)); if (state.draw.line) state.map.removeLayer(state.draw.line); - state.draw = { active: false, points: [], markers: [], line: null }; + if (state.draw.guide) state.map.removeLayer(state.draw.guide); + state.draw = { active: false, points: [], markers: [], line: null, guide: null, + timer: null, seq: 0, snapped: null }; } function drawSetActive(on) { @@ -481,44 +489,102 @@ function drawSetActive(on) { $('#draw-panel').hidden = !on; $('#draw-start').textContent = on ? 'Drawing…' : 'New route'; $('#draw-start').disabled = on; - // Crosshair makes the mode obvious; without it the map looks unchanged. state.map.getContainer().style.cursor = on ? 'crosshair' : ''; if (on) drawUpdate(); } -/* Straight-line distance through the waypoints. Not the routed distance — the - real figure is only known once brouter has answered, so this is labelled as - a direct measurement rather than presented as the route length. */ -function directDistance(points) { - let metres = 0; - for (let i = 1; i < points.length; i += 1) { - metres += state.map.distance(points[i - 1], points[i]); - } - return metres; -} - function fmtDistance(metres) { if (!metres) return '—'; return metres < 1000 - ? `${Math.round(metres)} m direct` - : `${(metres / 1000).toFixed(1)} km direct`; + ? `${Math.round(metres)} m` + : `${(metres / 1000).toFixed(1)} km`; +} + +/* The dashed guide shows the order of waypoints. It's replaced visually by the + snapped line once brouter answers, but kept underneath so there's always + something on screen while a preview is in flight. */ +function drawGuide() { + const latlngs = state.draw.points.map((p) => [p.lat, p.lng]); + if (state.draw.guide) { + state.draw.guide.setLatLngs(latlngs); + } else { + state.draw.guide = L.polyline(latlngs, { + color: DRAW_COLOUR, weight: 1, opacity: .35, dashArray: '3 6', interactive: false, + }).addTo(state.map); + } } function drawUpdate() { const points = state.draw.points; $('#draw-n').textContent = points.length; - $('#draw-dist').textContent = fmtDistance(directDistance(points)); $('#draw-save').disabled = points.length < 2; $('#draw-undo').disabled = points.length === 0; - const latlngs = points.map((p) => [p.lat, p.lng]); - if (state.draw.line) { - state.draw.line.setLatLngs(latlngs); - } else { - state.draw.line = L.polyline(latlngs, { - color: DRAW_COLOUR, weight: 2, dashArray: '4 5', interactive: false, - }).addTo(state.map); + drawGuide(); + schedulePreview(); +} + +function setPreviewStatus(text) { + $('#draw-dist').textContent = text; +} + +function schedulePreview() { + if (state.draw.timer) clearTimeout(state.draw.timer); + + if (state.draw.points.length < 2) { + if (state.draw.line) { state.map.removeLayer(state.draw.line); state.draw.line = null; } + state.draw.snapped = null; + setPreviewStatus('—'); + return; } + + setPreviewStatus('snapping…'); + state.draw.timer = setTimeout(runPreview, PREVIEW_DEBOUNCE_MS); +} + +async function runPreview() { + const points = state.draw.points.slice(); + // Responses can arrive out of order after a fast edit; only the newest counts. + const seq = (state.draw.seq += 1); + + try { + const body = { name: 'preview.gpx', waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })) }; + const result = await api('api/routes/preview', { method: 'POST', ...json(body) }); + + if (seq !== state.draw.seq || !state.draw.active) return; + + state.draw.snapped = result; + const latlngs = result.points.map((p) => [p[0], p[1]]); + + if (state.draw.line) { + state.draw.line.setLatLngs(latlngs); + } else { + state.draw.line = L.polyline(latlngs, { + color: DRAW_COLOUR, weight: 4, opacity: .9, + }).addTo(state.map); + } + + const ascend = result.ascend_m ? ` · ${Math.round(result.ascend_m)} m ascent` : ''; + setPreviewStatus(fmtDistance(result.length_m) + ascend); + markUnsnappable(false); + } catch (err) { + if (seq !== state.draw.seq || !state.draw.active) return; + + // Keep the last good line rather than clearing the map — usually only the + // most recent point is the problem, and it's about to be moved or undone. + setPreviewStatus('no route'); + markUnsnappable(true); + toast(err.message || 'Could not snap that route', true); + } +} + +/* Flags the most recently added waypoint, which is nearly always the one that + can't be reached — brouter doesn't say which point failed. */ +function markUnsnappable(bad) { + const last = state.draw.markers[state.draw.markers.length - 1]; + if (!last) return; + const el = last.getElement(); + if (el) el.classList.toggle('wp-bad', bad); } function addWaypoint(latlng) { @@ -534,12 +600,12 @@ function addWaypoint(latlng) { }), }).addTo(state.map); - // Dragging rewrites the point in place; numbering is unaffected because the - // marker's position in the array doesn't change. + // Guide follows the drag live; the snapped preview waits for the debounce. marker.on('drag', (e) => { const i = state.draw.markers.indexOf(marker); - if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawUpdate(); } + if (i !== -1) { state.draw.points[i] = e.target.getLatLng(); drawGuide(); } }); + marker.on('dragend', drawUpdate); state.draw.markers.push(marker); drawUpdate(); diff --git a/frontend/index.html b/frontend/index.html index 81a8a5b..da7972d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -139,7 +139,7 @@
0 waypoints · —
+0 waypoints · — routed