feat(directions): origin selection via search or map click; Directions button gated on origin
This commit is contained in:
parent
4e0d82f416
commit
5e85c15798
197
.hermes/plans/2026-08-10_145052-directions-rework.md
Normal file
197
.hermes/plans/2026-08-10_145052-directions-rework.md
Normal file
@ -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` + `<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.)
|
||||||
@ -57,8 +57,13 @@
|
|||||||
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
|
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
|
||||||
);
|
);
|
||||||
let showDirections = $state(false);
|
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 ---
|
// --- preferences dialog state ---
|
||||||
let showPrefs = $state(false);
|
let showPrefs = $state(false);
|
||||||
|
let computingRoute = $state(false);
|
||||||
// --- search state ---
|
// --- search state ---
|
||||||
const searchables: Searchable[] = [];
|
const searchables: Searchable[] = [];
|
||||||
let query = $state('');
|
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: `<div class="origin-pin" style="border-left-color:#16a34a"></div><span class="origin-label">${label ? label.replace(/</g, '<') : 'Start'}</span>`,
|
||||||
|
iconSize: [0, 0]
|
||||||
|
})
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
function onSelect(r: SearchResult) {
|
function onSelect(r: SearchResult) {
|
||||||
query = r.item.name;
|
query = r.item.name;
|
||||||
showResults = false;
|
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);
|
focusOn(r.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,6 +213,13 @@
|
|||||||
attribution: '© OpenStreetMap contributors'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(map);
|
}).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 {
|
try {
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
try {
|
try {
|
||||||
@ -349,8 +377,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if dumpLayers.length}
|
{#if origin}
|
||||||
<button class="dirbtn" onclick={() => (showDirections = !showDirections)}>
|
<button class="dirbtn" onclick={() => (showDirections = !showDirections)} disabled={computingRoute}>
|
||||||
{showDirections ? 'Hide Directions' : 'Directions'}
|
{showDirections ? 'Hide Directions' : 'Directions'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@ -511,4 +539,11 @@
|
|||||||
background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem;
|
background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.pref-close:hover { background: #1e293b; }
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
53
src/lib/routing.worker.ts
Normal file
53
src/lib/routing.worker.ts
Normal file
@ -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<string, unknown>; 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<RouteJob>) => {
|
||||||
|
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 })
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user