diff --git a/README.md b/README.md index 2ebcc1d..30bb0c3 100644 --- a/README.md +++ b/README.md @@ -222,42 +222,53 @@ The matcher: matches still hit (e.g. `philbrick` finds `Philbrook`). - Ranks name matches above address matches above description matches. -## Preferences & Units +## Preferences -Click **⚙ Preferences** in the top bar to choose the **unit system** — **Metric** -or **Imperial**. The choice is saved to `localStorage`, so it persists across -reloads and is shared by the Directions panel. +Click **⚙ Preferences** in the top bar: -Units are auto-selected within the chosen system: **m/ft under a kilometer/mile, -otherwise km/mi** (e.g. `820 m`, `1.9 km`; `900 ft`, `1.2 mi`). +- **Unit system** — **Metric** or **Imperial** (saved to `localStorage`). Units + are auto-selected within the chosen system: **m/ft under a kilometer/mile, + otherwise km/mi** (e.g. `820 m`, `1.9 km`; `900 ft`, `1.2 mi`). +- **Saved Points** — **Export / Import** your browser-local saved points as a + JSON file (see below). Imported points are **merged** (deduplicated by id); + existing points are kept. ## Directions & Routing -When a dump layer is present (it carries the routable graph), a **Directions** -button appears in the top bar. Opening it lets you: +The flow is: pick an **origin**, open **Directions**, pick a **destination**, +then **Calculate**. -- **Set start (A)** and **end (B)** by clicking the map. -- **Avoid certain POI categories** along the route (schools, fire stations, - fuel stations, parking, restaurants, …). -- **Compute** a route and **draw it** on the map. -- Choose a **unit system** (Metric or Imperial; distances auto-switch between - m/ft and km/mi at the km/mile boundary), plus a **travel mode** (Drive or - Walk). -- See the route's **distance** and an **estimated travel time** (computed from - the mode's average speed). -- **Turn-by-turn directions** (*Head north*, *Turn right*, *Arrive at destination*, - …) are generated for **Drive** mode and listed beneath the result; a live - progress bar shows while the route computes. -- **Robustness:** if A/B are far from the routable road network, or the destination - is disconnected / fully blocked by avoided categories, the app reports *why* and - still draws the **closest reachable** portion of a route (flagged as a partial - path) instead of failing silently. +1. **Pick the origin (start point)** — select any POI from **search**, or click + directly on the map. (Clicking empty space creates a browser-local *Saved + Point* and uses it as the origin.) +2. The **Directions** button in the top bar is enabled only when an origin is + selected. +3. In the dialog, pick a **destination** — by clicking the map, **searching** a + POI (local or online via Nominatim), or choosing a **Saved Point**. +4. **Calculate route** enables once both points are set. The computation runs in + a **Web Worker** (off the UI thread) with a live **progress bar**. +5. On completion the panel shows the **turn-by-turn** directions (*Head north*, + *Turn right*, *Arrive at destination*, …) for **Drive** mode, plus distance, + estimated travel time, and the drawn route. -Routing is done **entirely in the browser** (`src/lib/routing.ts`): an -A*-search over the graph built from the OSM dump. Point A/B are snapped to the -nearest graph node. When you enable a category to avoid, edges within a radius -of POIs in that category are heavily penalized (or blocked) so the route routes -around them. +Other behaviors: + +- **Route options** (collapsed): **avoid certain POI categories** (schools, fire + stations, fuel stations, parking, restaurants, …) and choose the **travel + mode** (Drive / Walk). +- **Robustness:** if the origin/destination are far from the routable road + network, or the destination is disconnected / fully blocked by avoided + categories, the app reports *why* and still draws the **closest reachable** + portion of a route (flagged as a partial path) instead of failing silently. +- **On-demand layer loading:** if the road-data layer needed for routing isn't + loaded, the dialog prompts you to load it ("Road data layer needed for route + computation") rather than silently disabling. + +Routing is done **entirely in the browser** (`src/lib/routing.ts`, invoked from +`src/lib/routing.worker.ts`): an A*-search over the graph built from the OSM +dump. Points are snapped to the nearest graph node. When you enable a category +to avoid, edges within a radius of POIs in that category are heavily penalized +(or blocked) so the route routes around them. ```ts import { route } from '$lib/routing'; @@ -271,6 +282,17 @@ const r = route(graph, [latA, lonB], [latB, lonB], pois, [ // r.turns (turn-by-turn steps for driving mode) ``` +## Saved Points (browser-local) + +Clicking an empty spot on the map creates a **Saved Point** — a location you +want to remember. Saved points are stored only in your browser's `localStorage` +and shown as a toggleable **"Saved Points"** layer in the legend, with a +distinct purple pin. They can be used as route origins or destinations. + +Because they live only in the browser, **Export / Import** (in ⚙ Preferences) +lets you back them up as a JSON file and restore them (merged, deduplicated by +id). + ## Deployment The `build/` directory is entirely static. Serve it directly: diff --git a/scripts/test.mjs b/scripts/test.mjs index 2a43998..3ce40cf 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -162,6 +162,52 @@ async function run() { t.expect('claremore', 'claremore'); t.expectNone('zzzzqqqq'); // gibberish yields nothing + // 4. Routing: turn-by-turn generation + partial/robustness behavior. + { + const { buildTurnInstructions } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts')); + // Straight north, then a 90° right turn east. + const path = []; + for (let i = 0; i <= 10; i++) path.push([36.1 + i * 0.000017, -95.9]); // north + for (let i = 1; i <= 20; i++) path.push([36.10017, -95.9 + i * 0.000017]); // east + const turns = buildTurnInstructions(path); + check(turns.length >= 3, `buildTurnInstructions emits head + turn + arrive (got ${turns.length})`); + check(turns.some((t) => t.instruction.toLowerCase().includes('head')), 'has a "Head …" first step'); + check(turns.some((t) => t.instruction.toLowerCase().includes('turn right')), 'has a "Turn right" maneuver for 90° turn'); + check(turns[turns.length - 1].instruction === 'Arrive at destination', 'last step is "Arrive at destination"'); + + const { route } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts')); + // Disconnected destination -> partial path. + const g2 = { + meta: { nodeCount: 3, edgeCount: 1 }, + coords: [[36.1, -95.9], [36.1005, -95.9], [36.2, -95.8]], + adj: [[[1, 55]], [[0, 55]], []] + }; + const rp = route(g2, [36.1, -95.9], [36.2, -95.8]); + check(rp.found === false, 'disconnected destination -> found=false'); + check(rp.partial === true, 'disconnected destination -> partial=true'); + check(rp.path.length >= 2, 'partial path reaches the closest reachable node'); + check(!!rp.reason, 'partial route has a human-readable reason'); + + // Empty graph -> clear "no road network" message, no crash. + const rg = route({ meta: { nodeCount: 0, edgeCount: 0 }, coords: [], adj: [] }, [36.1, -95.9], [36.2, -95.8]); + check(rg.found === false && !!rg.reason, 'empty graph -> found=false with reason'); + } + + // 5. Saved Points: import merge + dedupe by id. + { + const { addSavedPoint, clearSavedPoints, importSavedPoints, exportSavedPoints } = + await import(resolve(__dirname, '..', 'src', 'lib', 'savedPoints.ts')); + clearSavedPoints(); + addSavedPoint({ lat: 1, lon: 2, label: 'A' }); + const existing = JSON.parse(exportSavedPoints()); + const newPoint = { id: 'B', lat: 5, lon: 6, label: 'B' }; + const res = importSavedPoints(JSON.stringify({ type: 'navigator.savedPoints', version: 1, points: [...existing.points, newPoint] })); + check(res.added === 1 && res.skipped === 1, `import merges + dedupes by id (added=${res.added}, skipped=${res.skipped})`); + try { importSavedPoints('not json'); check(false, 'invalid json rejected'); } + catch { check(true, 'invalid json rejected'); } + clearSavedPoints(); + } + console.log(`\n${checks - failures}/${checks} checks passed`); if (failures > 0) { console.error(`${failures} check(s) FAILED`);