From 5e85c157987e786e79c4427fbe6e587ef2e1c67a Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Mon, 10 Aug 2026 15:16:38 +0000 Subject: [PATCH] feat(directions): origin selection via search or map click; Directions button gated on origin --- .../2026-08-10_145052-directions-rework.md | 197 ++++++++++++++++++ src/lib/components/MapView.svelte | 39 +++- src/lib/routing.worker.ts | 53 +++++ 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 .hermes/plans/2026-08-10_145052-directions-rework.md create mode 100644 src/lib/routing.worker.ts diff --git a/.hermes/plans/2026-08-10_145052-directions-rework.md b/.hermes/plans/2026-08-10_145052-directions-rework.md new file mode 100644 index 0000000..549e1a9 --- /dev/null +++ b/.hermes/plans/2026-08-10_145052-directions-rework.md @@ -0,0 +1,197 @@ +# Directions Rework — Implementation Plan + +> **For Hermes:** Planning only. Do not implement yet. + +**Goal:** Rebuild the Navigator Directions feature around a clear user flow: pick an origin (point or POI) → open Directions → pick a destination (map click or POI via search) → compute the route → show turn-by-turn directions. Route calculation runs in a **Web Worker** so the UI never blocks, with explicit running/finished/errored feedback. Adds a browser-local **Saved Points** layer and **on-demand layer loading**. + +**Architecture:** A single `DirectionsFlow` state machine lives in `MapView.svelte`. The origin is "whatever point/POI the user most recently selected" (search result, direct map click on a POI, or a click on empty space that creates a Saved Point). The `DirectionsPanel` becomes a destination-picker + options + results area. All route math stays in the pure `routing.ts` and is invoked from a **dedicated Web Worker** (`routing.worker.ts`), which posts progress + final results back to the UI. + +**Tech Stack:** Svelte 5 (runes), SvelteKit static adapter, Leaflet, Vite (native worker support), `routing.ts` (pure, unchanged math), `prefs.ts` (unit system), `localStorage` (Saved Points). + +--- + +## Current state + +- `routing.ts` is already **pure** (no Leaflet, no DOM) — `route`, `routeWithProgress`, `buildTurnInstructions`, `buildEdgePenalties` are all Web-Worker-safe as-is. +- Today the user sets **A/B by clicking** inside the panel, and `routeWithProgress` yields every ~4000 nodes. +- Search (`search.ts` + `geocode.ts`) already gives `{lat, lon}` + `bounds` for local POIs and online results. Reuse for destination selection. +- Graph + POIs are loaded lazily from the first dump layer's `graphPath`/`poiPath`. +- Unit system now lives only in `prefs`. "Avoid categories" currently sits inline in the panel. + +### Confirmed product decisions (from discussion) +1. **Origin**: whatever point/POI the user most recently selected — search result, direct map click (POI or empty space), or a Saved Point. +2. **Directions button** is enabled **only when** an origin is selected. +3. Clicking **Directions** opens a destination-picker dialog: + - pick destination by **map click**, or + - search + select a **POI** (local or online geocode), or + - select an existing **Saved Point**. +4. **"Calculate route"** enables only once both points are set. +5. On calculate, the dialog switches form to show **turn-by-turn** instructions. +6. **Avoid categories** stays but moves to a secondary **"Route options"** collapsible section. +7. **Web Worker**: route computation runs off the main thread, with visible **running / finished / errored** status. +8. **Saved Points layer (new, browser-local):** clicking a **non-POI** spot creates a **Saved Point** stored in `localStorage`. A dedicated **"Saved Points"** layer appears in the legend and toggles on/off. The Preferences dialog gains **Export / Import** for this layer. Saved points are valid origins/destinations. +9. **On-demand layer loading:** if the routable graph (dump layer) isn't loaded, the app **prompts the user to load it** with a clear call-to-action, then enables routing. + +### Non-goals (YAGNI) +- No live re-routing while dragging. +- No server-side routing / turn lane guidance / spoken directions. +- No persistence of last route. +- Saved Points live only in the browser (no cloud sync). + +--- + +## Proposed approach + +Single-page state machine in `MapView.svelte`: + +``` +src/lib/routing.ts // pure logic (unchanged math) +src/lib/routing.worker.ts // NEW: {graph,pois,from,to,avoid} -> posts {progress|result|error} +src/lib/savedPoints.ts // NEW: localStorage-backed CRUD + export/import +src/lib/components/DirectionsPanel.svelte // REWORK destination-picker + options + results + status +src/lib/components/MapView.svelte // origin selection, Saved Points layer, worker lifecycle +src/routes/+page.svelte // host worker / pass origin (likely minimal) +``` + +Data flow: +- `MapView` owns: + - `origin: { lat, lon, label? } | null` — set by `onSelect`, a POI/empty-space map click, or a Saved Point pick (most recent wins). + - `worker: Worker | null` for the routing graph's lifetime. + - `savedPoints` layer state (from `savedPoints.ts` store). +- `DirectionsPanel` (opened only when `origin` exists) owns: + - `destination` (map click, POI search, or Saved Point). + - `avoid` selection (collapsed **Route options**). + - **"Calculate route"** button enabled when `origin` and `destination` are set. + - `status: 'idle' | 'running' | 'done' | 'error'` plus an on-demand layer CTA when the graph isn't loaded. + +Web Worker design: +- Entry `routing.worker.ts` receives a `RouteJob`; it calls `buildEdgePenalties` once, then runs a modified `route()` that periodically `postMessage({type:'progress', pct})` (yielding inside the worker; UI never blocks). +- Sends `{type:'done', result}` or `{type:'error', message}`. +- Worker created when the graph loads; jobs serialized (one at a time); a stale run token lets us ignore an obsolete in-flight job. + +--- + +## Step-by-step plan (bite-sized, TDD where practical) + +### Task 1 — Add a Web Worker entry for routing +**Files:** +- Create: `src/lib/routing.worker.ts` + +**Objective:** A worker that runs `route()` off-thread and posts progress/results/errors. + +**Step 1:** Add worker with a self-describing message protocol (run job, post progress/done/error). +**Step 2:** Wire Vite worker import: `new Worker(new URL('./routing.worker.ts', import.meta.url), { type: 'module' })`. +**Step 3:** Verify the worker runs (log a dummy job). + +### Task 2 — Origin selection in MapView +**Files:** +- Modify: `src/lib/components/MapView.svelte` + +**Objective:** Any search selection or map-click sets `origin` (most recent wins) and shows a candidate pin. + +**Step 1:** Add `let origin = $state<{lat:number; lon:number; label?:string}|null>(null)`. +**Step 2:** In `onSelect(r)` set `origin = { lat, lon, label }`. +**Step 3:** Add a leaflet click handler (when not picking destination): on empty space → create a Saved Point (see Task 5b) and set origin; on an existing POI → set origin. +**Step 4:** Enable the **Directions** button only when `origin != null`. + +### Task 3 — DirectionsPanel → destination picker + options + status +**Files:** +- Rewrite: `src/lib/components/DirectionsPanel.svelte` + +**Objective:** Rebuild the dialog: show origin, pick destination (map click, POI search, or Saved Point), hold avoid categories in a collapsed "Route options", gate "Calculate route" on both points. + +**Step 1:** Props: `origin`, `setPickingDestination()`, `onDestinationSelected`, loaded graph/pois, saved points list. +**Step 2:** Destination picker: map-click mode + search dropdown (reuse `search` + `geocode`) + Saved Point list. +**Step 3:** Collapsed "Route options" with the 6 avoid checkboxes. +**Step 4:** "Calculate route" button `disabled` unless both points are set. + +### Task 4 — Run route in the worker with status feedback +**Files:** +- Modify: `src/routes/+page.svelte` (host worker) +- Modify: `src/lib/components/DirectionsPanel.svelte` + +**Objective:** Post job to worker; show live progress %, then turn-by-turn, or an error. + +**Step 1:** Lift worker lifecycle into MapView (create after graph loads; destroy on unmount). +**Step 2:** `calculate()` posts `{graph, pois, from, to, avoid}` to the worker. +**Step 3:** Map worker messages → `status`: `running` (pct) → `done` (turn list + drawn route) / `error` (message). +**Step 4:** Visible status line: "Calculating route… 42%", "Route ready", or error text. + +### Task 5 — Wire state machine end-to-end +**Files:** +- Modify: `MapView.svelte`, `DirectionsPanel.svelte`, `src/routes/+page.svelte` + +**Objective:** Full flow works: select origin → Directions enabled → pick destination → Calculate → turn-by-turn. + +**Step 1:** CSS for origin pin, destination pin, active route polyline (reuse `drawResult`). +**Step 2:** "Clear"/close resets worker + pins. + +### Task 5b — Browser-local "Saved Points" layer +**Files:** +- Create: `src/lib/savedPoints.ts` (store: read/write `localStorage`, CRUD, export/import helpers) +- Modify: `src/lib/components/MapView.svelte` (click-on-empty → create Saved Point; register layer) +- Modify: `src/lib/components/DirectionsPanel.svelte` (Saved Points selectable as origin/destination) + +**Objective:** Clicking a non-POI spot creates a persistent, browser-local Saved Point; a toggleable "Saved Points" layer shows them. + +**Step 1:** `savedPoints.ts` store backed by `localStorage` (key `navigator.savedPoints.v1`); `{points: Array<{id, lat, lon, label?}>}`. +**Step 2:** In `MapView`, when a map click lands on empty space: prompt for an optional label, then `add(point)` and set it as origin. +**Step 3:** Register a "Saved Points" layer in the legend/layers list (toggled independently; distinct marker color). +**Step 4:** Saved Points are valid **origin** and **destination** candidates. + +### Task 5c — Export / Import of Saved Points (in Preferences dialog) +**Files:** +- Modify: `src/lib/components/MapView.svelte` (Preferences dialog) +- Modify: `src/lib/savedPoints.ts` (export/import helpers) + +**Objective:** Users can back up and restore their Saved Points. + +**Step 1:** `exportJson()` serializes the store → browser download (`Blob` + ``). +**Step 2:** `importJson(file)` validates the shape, **merges** into the store (dedupe by id, keep existing ids), re-renders the layer. +**Step 3:** Add "Export Saved Points" and "Import Saved Points" buttons to the Preferences dialog. + +### Task 6 — On-demand layer loading (prompt) +**Files:** +- Modify: `src/lib/components/DirectionsPanel.svelte`, `src/lib/components/MapView.svelte` + +**Objective:** If the routable graph (dump layer) isn't loaded, prompt the user instead of silently disabling. + +**Step 1:** Detect whether a dump layer with `graphPath`/`poiPath` is present in the loaded manifest. If not, the Directions panel shows a generic CTA: **"Road data layer needed for route computation"** + a button (no hardcoded location name — scale generically to whatever dump source is configured). +**Step 2:** The button loads the configured dump layer on demand (fetch graph+pois, register layer, enable Directions). +**Step 3:** Only after the graph is available does "Calculate route" become possible. + +### Task 7 — Docs & commit +**Files:** +- Modify: `README.md` + +**Objective:** Document the Directions flow, Web Worker routing, Saved Points layer, on-demand loading, options placement. + +**Step 1:** Update `README.md` Directions section + add Saved Points docs. +**Step 2:** Commit after each green task; final commit closes the feature. + +--- + +## Files likely to change +| File | Change | +|---|---| +| `src/lib/routing.worker.ts` | **New** — worker entry | +| `src/lib/savedPoints.ts` | **New** — localStorage store + CRUD + export/import | +| `src/lib/routing.ts` | minor: progress-callback route used by worker (or reuse `routeWithProgress` inside worker) | +| `src/lib/components/DirectionsPanel.svelte` | Rewrite (destination picker, options, status, CTA) | +| `src/lib/components/MapView.svelte` | Origin selection, Saved Points layer, worker lifecycle, Preferences export/import | +| `src/routes/+page.svelte` | Host worker / pass origin | +| `scripts/test.mjs` | Add routing + savedPoints regression checks | +| `README.md` | Document flow + worker + Saved Points | + +## Tests / validation +- `npm run check` → 0 errors, 0 warnings +- `npm test` → all pass (add routing + savedPoints edge cases) +- `npm run build:static` → bundles `routing.worker.js` and `savedPoints.ts` +- Manual: select POI via search → Directions enabled → pick destination by map click → Calculate → live progress → turn-by-turn; click empty space → Saved Point created and toggled; Export/Import round-trips in Preferences; missing-dump layer shows CTA. + +## Risks, tradeoffs & open questions +- **Web Worker + static adapter**: Vite bundles workers fine; verify the worker chunk emits to `build/` and resolves in the prerendered static site. +- **Saved Points persistence**: only `localStorage` — erased if site data is cleared; mitigated by **Export/Import**. +- **Graph load timing**: handled by the on-demand prompt; loading a large graph may take a moment (show a loading state on the CTA). +- **Avoid categories** in a collapsed section — slightly more clicks; acceptable for a cleaner primary flow. +- **Reimport strategy**: confirmed **merge** — merge imported points into the existing store, dedupe by id, and skip ids already present. (No replace/merge toggle.) \ No newline at end of file diff --git a/src/lib/components/MapView.svelte b/src/lib/components/MapView.svelte index 9cb8332..efb661c 100644 --- a/src/lib/components/MapView.svelte +++ b/src/lib/components/MapView.svelte @@ -57,8 +57,13 @@ layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath) ); let showDirections = $state(false); + // --- origin (start point) for directions --- + // Set by a search result or a direct map click; most recent wins. + let origin = $state<{ lat: number; lon: number; label?: string } | null>(null); + let originLayer: L.Layer | null = null; // --- preferences dialog state --- let showPrefs = $state(false); + let computingRoute = $state(false); // --- search state --- const searchables: Searchable[] = []; let query = $state(''); @@ -81,9 +86,25 @@ } } + // Draw a pin for the origin (start point) and store it. + async function setOrigin(lat: number, lon: number, label?: string) { + origin = { lat, lon, label }; + const L = (await import('leaflet')).default; + if (originLayer) originLayer.remove(); + originLayer = L.marker([lat, lon], { + icon: L.divIcon({ + className: 'origin-pin-wrap', + html: `
${label ? label.replace(/`, + iconSize: [0, 0] + }) + }).addTo(map); + } + function onSelect(r: SearchResult) { query = r.item.name; showResults = false; + // Selecting a search result sets the origin (the start point). + setOrigin(r.item.lat, r.item.lon, r.item.name); focusOn(r.item); } @@ -192,6 +213,13 @@ attribution: '© OpenStreetMap contributors' }).addTo(map); + // Click anywhere on the map to set the origin (start point), as long as + // we're not in the middle of picking a destination inside the panel. + map.on('click', (e: L.LeafletMouseEvent) => { + if (showDirections) return; // direction dialog owns clicks while open + setOrigin(e.latlng.lat, e.latlng.lng); + }); + try { for (const layer of layers) { try { @@ -349,8 +377,8 @@ {/if} - {#if dumpLayers.length} - {/if} @@ -511,4 +539,11 @@ background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem; } .pref-close:hover { background: #1e293b; } + /* origin pin */ + :global(.origin-pin-wrap) { background: transparent; border: none; } + :global(.origin-label) { + position: absolute; top: -30px; left: -34px; width: 68px; text-align: center; + font: 700 0.72rem system-ui, sans-serif; color: #16a34a; + text-shadow: 0 1px 2px #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } \ No newline at end of file diff --git a/src/lib/routing.worker.ts b/src/lib/routing.worker.ts new file mode 100644 index 0000000..a53e74f --- /dev/null +++ b/src/lib/routing.worker.ts @@ -0,0 +1,53 @@ +/** + * routing.worker.ts — runs route computation off the UI thread. + * ---------------------------------------------------------------------- + * The main thread posts a RouteJob; this worker runs `routeWithProgress` + * (which yields periodically and reports progress) and posts back: + * + * { type: 'progress', pct: number } + * { type: 'done', result: RouteResult, token: number } + * { type: 'error', message: string, token: number } + * + * A `token` distinguishes runs so a stale/obsolete in-flight job can be + * ignored by the pe; worker stays single-flight per post. + */ + +import { routeWithProgress } from './routing'; +import type { RouteGraph, AvoidRule, RouteResult } from './routing'; + +export interface RouteJob { + type: 'route'; + token: number; + graph: RouteGraph; + pois: Array<{ type: 'Feature'; properties: Record; geometry: { type: string; coordinates: number[] } }>; + from: [number, number]; + to: [number, number]; + avoid: AvoidRule[]; + /** Approximate fraction to send as progress during the search. */ + onEvery?: number; +} + +type WorkerReply = + | { type: 'progress'; pct: number; token: number } + | { type: 'done'; result: RouteResult; token: number } + | { type: 'error'; message: string; token: number }; + +self.onmessage = (e: MessageEvent) => { + const job = e.data; + if (!job || job.type !== 'route') return; + + const { token, graph, pois, from, to, avoid } = job; + const send = (msg: WorkerReply) => { + (self as unknown as { + postMessage: (m: WorkerReply, transfer?: Transferable[]) => void; + }).postMessage(msg); + }; + + void routeWithProgress(graph, from, to, pois as GeoJSON.Feature[], avoid, (pct) => { + send({ type: 'progress', pct, token }); + }) + .then((result) => send({ type: 'done', result, token })) + .catch((err) => + send({ type: 'error', message: err instanceof Error ? err.message : String(err), token }) + ); +}; \ No newline at end of file