navigator/.hermes/plans/2026-08-10_145052-directions-rework.md

12 KiB

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 + <a download>). 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.)