test+docs: add routing & savedPoints regressions; document new Directions flow, Saved Points, worker
All checks were successful
CI / test-and-build (push) Successful in 41s

This commit is contained in:
hermes-explorigin 2026-08-10 15:31:39 +00:00
parent 9ef58e62c8
commit 2e0269b865
2 changed files with 97 additions and 29 deletions

View File

@ -222,42 +222,53 @@ The matcher:
matches still hit (e.g. `philbrick` finds `Philbrook`). matches still hit (e.g. `philbrick` finds `Philbrook`).
- Ranks name matches above address matches above description matches. - Ranks name matches above address matches above description matches.
## Preferences & Units ## Preferences
Click **⚙ Preferences** in the top bar to choose the **unit system** — **Metric** Click **⚙ Preferences** in the top bar:
or **Imperial**. The choice is saved to `localStorage`, so it persists across
reloads and is shared by the Directions panel.
Units are auto-selected within the chosen system: **m/ft under a kilometer/mile, - **Unit system****Metric** or **Imperial** (saved to `localStorage`). Units
otherwise km/mi** (e.g. `820 m`, `1.9 km`; `900 ft`, `1.2 mi`). 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 ## Directions & Routing
When a dump layer is present (it carries the routable graph), a **Directions** The flow is: pick an **origin**, open **Directions**, pick a **destination**,
button appears in the top bar. Opening it lets you: then **Calculate**.
- **Set start (A)** and **end (B)** by clicking the map. 1. **Pick the origin (start point)** — select any POI from **search**, or click
- **Avoid certain POI categories** along the route (schools, fire stations, directly on the map. (Clicking empty space creates a browser-local *Saved
fuel stations, parking, restaurants, …). Point* and uses it as the origin.)
- **Compute** a route and **draw it** on the map. 2. The **Directions** button in the top bar is enabled only when an origin is
- Choose a **unit system** (Metric or Imperial; distances auto-switch between selected.
m/ft and km/mi at the km/mile boundary), plus a **travel mode** (Drive or 3. In the dialog, pick a **destination** — by clicking the map, **searching** a
Walk). POI (local or online via Nominatim), or choosing a **Saved Point**.
- See the route's **distance** and an **estimated travel time** (computed from 4. **Calculate route** enables once both points are set. The computation runs in
the mode's average speed). a **Web Worker** (off the UI thread) with a live **progress bar**.
- **Turn-by-turn directions** (*Head north*, *Turn right*, *Arrive at destination*, 5. On completion the panel shows the **turn-by-turn** directions (*Head north*,
…) are generated for **Drive** mode and listed beneath the result; a live *Turn right*, *Arrive at destination*, …) for **Drive** mode, plus distance,
progress bar shows while the route computes. estimated travel time, and the drawn route.
- **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.
Routing is done **entirely in the browser** (`src/lib/routing.ts`): an Other behaviors:
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 - **Route options** (collapsed): **avoid certain POI categories** (schools, fire
of POIs in that category are heavily penalized (or blocked) so the route routes stations, fuel stations, parking, restaurants, …) and choose the **travel
around them. 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 ```ts
import { route } from '$lib/routing'; 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) // 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 ## Deployment
The `build/` directory is entirely static. Serve it directly: The `build/` directory is entirely static. Serve it directly:

View File

@ -162,6 +162,52 @@ async function run() {
t.expect('claremore', 'claremore'); t.expect('claremore', 'claremore');
t.expectNone('zzzzqqqq'); // gibberish yields nothing 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`); console.log(`\n${checks - failures}/${checks} checks passed`);
if (failures > 0) { if (failures > 0) {
console.error(`${failures} check(s) FAILED`); console.error(`${failures} check(s) FAILED`);