From d786ee31514d3e12b43e60e7796ea36b9f87cb81 Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Mon, 10 Aug 2026 13:12:20 +0000 Subject: [PATCH] feat: preferences dialog, default-unchecked layers, robust routing + turn-by-turn --- src/lib/components/DirectionsPanel.svelte | 144 +++++--- src/lib/components/MapView.svelte | 71 +++- src/lib/prefs.ts | 61 +++ src/lib/routing.ts | 432 ++++++++++++++++++++-- 4 files changed, 626 insertions(+), 82 deletions(-) create mode 100644 src/lib/prefs.ts diff --git a/src/lib/components/DirectionsPanel.svelte b/src/lib/components/DirectionsPanel.svelte index fa469fe..6bdbd33 100644 --- a/src/lib/components/DirectionsPanel.svelte +++ b/src/lib/components/DirectionsPanel.svelte @@ -1,7 +1,8 @@ @@ -184,9 +190,9 @@
Directions
{#if loadError} -
{loadError}
+
Routing unavailable: {loadError}
{:else if !graph} -
{loadingData ? 'Loading road network…' : 'No network source.'}
+
{loadingData ? 'Loading road network…' : 'No network source. Load a dump layer to enable directions.'}
{/if} {#if graph} @@ -216,16 +222,16 @@
-
- - {#if system === 'metric'} + {#if $prefs.system === 'metric'} {:else} @@ -244,9 +250,14 @@
{#if from && to} - + {#if computing} +
+ Computing route… {Math.round(progress * 100)}% +
+
+ {:else} + + {/if} {/if} {#if result} @@ -262,13 +273,41 @@ {formatTime(result.distanceM)}
{mode === 'drive' ? 'driving' : 'walking'} · {result.nodesVisited.toLocaleString()} nodes searched + {#if result.reason}
{result.reason}
{/if} + + {:else if result.partial} +
+ No complete route. {result.reason ?? 'Showing the closest reachable point on the network.'}
- {:else} -
No route found (blocked or disconnected).
+
No route found. {result.reason ?? 'The route may be disconnected or blocked.'}
{/if} {/if} + {#if turnsShown} +
+
+ Turn-by-turn + +
+ {#if showTurns} +
    + {#each turnsShown.list as t (t.bearing + '-' + t.distanceM + '-' + t.instruction)} +
  1. + {formatDistance(t.distanceM)} + {t.instruction} +
  2. + {/each} +
+
{turnsShown.list.length} steps · {formatDistance(turnsShown.total)} total
+ {/if} +
+ {/if} + + {#if result && (result.found || result.partial)} + + {/if} +
Set A & B by clicking the map, pick categories to avoid, then compute.
{/if} @@ -299,9 +338,22 @@ .resline { display: flex; justify-content: space-between; align-items: baseline; margin: 2px 0; } .resline .lab { color: #475569; } .res em { display: block; color: #64748b; font-size: 0.7rem; margin-top: 3px; } + .warnline { color: #b45309; font-size: 0.72rem; margin-top: 4px; } .mute { color: #94a3b8; font-size: 0.7rem; } .res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; } .err { color: #b91c1c; margin: 6px 0; } + .progress-row { margin: 4px 0; } + .prog-label { font-size: 0.72rem; color: #475569; margin-bottom: 4px; display: block; } + .bar { height: 8px; background: #e2e8f0; border-radius: 999px; overflow: hidden; } + .fill { height: 100%; background: #0ea5e9; border-radius: 999px; transition: width .15s ease; } .hint { color: #64748b; } .hint.small { font-size: 0.7rem; margin-top: 6px; color: #94a3b8; } - \ No newline at end of file + .turns { margin-top: 8px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 8px; } + .turns-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.72rem; color: #475569; margin-bottom: 4px; } + .min { border: none; background: none; color: #0ea5e9; cursor: pointer; font-size: 0.7rem; padding: 0; } + .turnlist { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; } + .turnlist li { display: flex; gap: 8px; align-items: baseline; font-size: 0.74rem; } + .tdist { color: #0ea5e9; font-weight: 600; min-width: 52px; } + .tins { color: #0f172a; } + .turns-total { color: #94a3b8; font-size: 0.68rem; margin-top: 4px; } + diff --git a/src/lib/components/MapView.svelte b/src/lib/components/MapView.svelte index 6b73ae1..55b55da 100644 --- a/src/lib/components/MapView.svelte +++ b/src/lib/components/MapView.svelte @@ -5,6 +5,7 @@ import { search, type Searchable, type SearchResult } from '$lib/search'; import { geocode, type GeocodedPlace } from '$lib/geocode'; import DirectionsPanel from '$lib/components/DirectionsPanel.svelte'; + import { prefs } from '$lib/prefs'; type FeatureCollection = { type: string; @@ -56,6 +57,8 @@ layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath) ); let showDirections = $state(false); + // --- preferences dialog state --- + let showPrefs = $state(false); // --- search state --- const searchables: Searchable[] = []; let query = $state(''); @@ -279,15 +282,18 @@ }); layerGroups[layer.name] = geo; - visible[layer.name] = true; - geo.addTo(map); + // Default to none of the layers shown on load; the user toggles + // them via the legend. (Nothing is added to the map here.) + visible[layer.name] = false; } catch (e) { console.warn('layer load failed:', layer.name, e); } } const all: L.Layer[] = Object.values(layerGroups); - if (all.length) { + // Auto-fit only if the user has at least one layer switched on. + const anyVisible = Object.values(visible).some(Boolean); + if (all.length && anyVisible) { const group = L.featureGroup(all); if (typeof group.getBounds === 'function' && group.getBounds().isValid()) { map.fitBounds(group.getBounds(), { padding: [40, 40] }); @@ -348,6 +354,9 @@ {showDirections ? 'Hide Directions' : 'Directions'} {/if} + {totalFeatures} features @@ -371,6 +380,39 @@ {/if} + {#if showPrefs} + +
(showPrefs = false)} + onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPrefs = false; } }}> + +
+ {/if} +
@@ -459,4 +501,27 @@ } :global(.leaflet-container) { font: inherit; } + + .pref-backdrop { + position: fixed; inset: 0; z-index: 3000; + background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; + border: none; padding: 0; margin: 0; width: 100vw; height: 100vh; cursor: default; + } + .pref-dialog { + background: #fff; color: #0f172a; border-radius: 12px; + box-shadow: 0 12px 40px rgba(0,0,0,.35); width: 300px; max-width: 90vw; + padding: 18px 20px; font-family: system-ui, sans-serif; + } + .pref-title { font-weight: 700; font-size: 1rem; margin-bottom: 12px; } + .pref-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 10px 0; } + .pref-label { font-size: 0.82rem; color: #475569; } + .pref-row select { + padding: 6px 8px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 0.8rem; background: #fff; + } + .pref-hint { color: #94a3b8; font-size: 0.7rem; margin-top: 8px; } + .pref-close { + width: 100%; margin-top: 12px; padding: 8px; border: none; border-radius: 8px; + background: #0f172a; color: #fff; font-weight: 600; cursor: pointer; font-size: 0.85rem; + } + .pref-close:hover { background: #1e293b; } \ No newline at end of file diff --git a/src/lib/prefs.ts b/src/lib/prefs.ts new file mode 100644 index 0000000..ada451e --- /dev/null +++ b/src/lib/prefs.ts @@ -0,0 +1,61 @@ +/** + * prefs.ts — shared, persisted user preferences (Svelte writable store). + * ---------------------------------------------------------------------- + * Currently stores the unit system (Metric/Imperial) and the granular unit + * override used by the Directions panel. Values are persisted to localStorage + * so they survive reloads. + * + * Usage in runes mode: + * import { prefs } from '$lib/prefs'; + * $prefs.system // read + * $prefs.system = 'imperial' // write (auto-persists) + */ + +import { writable } from 'svelte/store'; + +export type UnitSystem = 'metric' | 'imperial'; +export type UnitOverride = 'auto' | 'm' | 'km' | 'ft' | 'mi'; + +export interface Prefs { + system: UnitSystem; + unit: UnitOverride; +} + +const KEY = 'navigator.prefs.v1'; +const DEFAULTS: Prefs = { system: 'metric', unit: 'auto' }; + +function isValidSystem(v: unknown): v is UnitSystem { + return v === 'metric' || v === 'imperial'; +} +function isValidUnit(v: unknown): v is UnitOverride { + return v === 'auto' || v === 'm' || v === 'km' || v === 'ft' || v === 'mi'; +} + +function load(): Prefs { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return { ...DEFAULTS }; + const parsed = JSON.parse(raw) as Partial; + return { + system: isValidSystem(parsed.system) ? parsed.system : DEFAULTS.system, + unit: isValidUnit(parsed.unit) ? parsed.unit : DEFAULTS.unit + }; + } catch { + return { ...DEFAULTS }; + } +} + +function createPrefs() { + const store = writable(load()); + // Persist on every change. + store.subscribe((p) => { + try { + localStorage.setItem(KEY, JSON.stringify(p)); + } catch { + /* storage unavailable (private mode, etc.) — ignore */ + } + }); + return store; +} + +export const prefs = createPrefs(); diff --git a/src/lib/routing.ts b/src/lib/routing.ts index a8962c6..b429d56 100644 --- a/src/lib/routing.ts +++ b/src/lib/routing.ts @@ -9,7 +9,10 @@ * - runs an A* / Dijkstra shortest-path search (great-circle heuristic), * - supports "avoid" categories: edges whose midpoint falls within a radius * of a POI in a chosen category get a heavy cost penalty (or are blocked), - * - returns the route as an ordered list of [lat, lon] waypoints + distance. + * - degrades gracefully: reports snap distances and returns a best-effort + * partial path when the full route cannot be found, + * - returns geometric turn-by-turn legs (driving), derived from heading + * changes along the route. */ export interface RouteGraph { @@ -33,6 +36,13 @@ export interface RouteOptions { avoid?: AvoidRule[]; } +/** A single driving leg with a human instruction. */ +export interface TurnStep { + distanceM: number; // length of this leg + instruction: string; // e.g. "Head south", "Turn left", "Arrive at destination" + bearing: number; // compass bearing (degrees) of this leg's heading +} + export interface RouteResult { found: boolean; path: [number, number][]; // [lat, lon] waypoints @@ -40,6 +50,13 @@ export interface RouteResult { nodesVisited: number; from: [number, number]; to: [number, number]; + // — robustness — + fromSnapM?: number; // distance from A to the nearest road node + toSnapM?: number; // distance from B to the nearest road node + partial?: boolean; // route is a best-effort partial path + reason?: string; // human explanation when not a clean full route + // — turn-by-turn (driving mode) — + turns?: TurnStep[]; } // --- geometry helpers --- @@ -56,8 +73,99 @@ function haversineM(aLonLat: number[], bLonLat: number[]): number { return 2 * R * Math.asin(Math.sqrt(s)); } -// Nearest node index to a [lat, lon] point (linear scan; fine for ~50k nodes). -export function nearestNode(graph: RouteGraph, lat: number, lon: number): number { +/** Initial bearing (degrees 0-360) from a to b, in [lon, lat] arrays. */ +function bearingDeg(aLonLat: number[], bLonLat: number[]): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const toDeg = (d: number) => (d * 180) / Math.PI; + const [lon1, lat1] = aLonLat; + const lon2 = bLonLat[0]; + const lat2 = bLonLat[1]; + const dLon = toRad(lon2 - lon1); + const y = Math.sin(dLon) * Math.cos(toRad(lat2)); + const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) - + Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLon); + return (toDeg(Math.atan2(y, x)) + 360) % 360; +} + +/** Signed shortest angular turn (-180..180): positive = right. */ +function turnAngle(before: number, after: number): number { + let d = (after - before) % 360; + if (d > 180) d -= 360; + if (d < -180) d += 360; + return d; +} + +function compassLabel(deg: number): string { + const dirs = ['north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest']; + const idx = Math.round(((deg % 360) + 360) % 360 / 45) % 8; + return dirs[idx]; +} + +function maneuverLabel(angle: number): string { + const a = Math.abs(angle); + if (a < 30) return 'Continue straight'; + if (a < 105) return angle > 0 ? 'Turn right' : 'Turn left'; + if (a < 160) return angle > 0 ? 'Sharp right' : 'Sharp left'; + return 'Make a U-turn'; +} + +/** + * Build turn-by-turn legs from an ordered [lat, lon] path. + * Merges near-collinear segments into legs; emits a maneuver at each vertex + * where the heading change is significant. + */ +export function buildTurnInstructions(path: [number, number][]): TurnStep[] { + if (path.length < 2) return []; + const MIN_TURN = 30; // degrees of heading change that count as a turn + + // Segment bearings (converted to [lon,lat]) and per-segment distances. + const segBearing: number[] = []; + const segDist: number[] = []; + for (let i = 0; i < path.length - 1; i++) { + segBearing.push(bearingDeg([path[i][1], path[i][0]], [path[i + 1][1], path[i + 1][0]])); + segDist.push(haversineM([path[i][1], path[i][0]], [path[i + 1][1], path[i + 1][0]])); + } + + // Group segments into "legs": a maximal run whose bearing deviates < MIN_TURN + // from the first segment of the leg. legStart is the first segment index. + const legs: { segStart: number; dist: number }[] = []; + let legStart = 0; + let legDist = 0; + for (let i = 0; i < segBearing.length; i++) { + legDist += segDist[i]; + // Detect a turn at vertex i+1 (compare next segment to this leg's start). + const isLast = i === segBearing.length - 1; + const delta = !isLast ? turnAngle(segBearing[legStart], segBearing[i + 1]) : 0; + if (isLast || Math.abs(delta) >= MIN_TURN) { + legs.push({ segStart: legStart, dist: legDist }); + legStart = i + 1; + legDist = 0; + } + } + if (!legs.length) return []; + + const steps: TurnStep[] = []; + for (let li = 0; li < legs.length; li++) { + const leg = legs[li]; + const bearing = segBearing[leg.segStart]; + let instruction: string; + if (li === 0) { + instruction = `Head ${compassLabel(bearing)}`; + } else { + // Turn angle from the previous leg's heading into this leg's heading. + const prevBearing = segBearing[legs[li - 1].segStart]; + const angle = turnAngle(prevBearing, bearing); + instruction = maneuverLabel(angle); + } + steps.push({ distanceM: Math.round(leg.dist), instruction, bearing }); + } + // Canonical final arrival step. + steps.push({ distanceM: 0, instruction: 'Arrive at destination', bearing: segBearing[legs[legs.length - 1].segStart] }); + return steps; +} + +// Nearest node + its distance to a [lat, lon] point (linear scan; fine for ~50k). +function nearestNodeInfo(graph: RouteGraph, lat: number, lon: number): { idx: number; dist: number } { let best = -1; let bestD = Infinity; const target = [lon, lat]; @@ -69,12 +177,15 @@ export function nearestNode(graph: RouteGraph, lat: number, lon: number): number best = i; } } - return best; + return { idx: best, dist: best === -1 ? Infinity : bestD }; +} + +// Nearest node index to a [lat, lon] point. +export function nearestNode(graph: RouteGraph, lat: number, lon: number): number { + return nearestNodeInfo(graph, lat, lon).idx; } // Precompute per-node avoid penalties once, given loaded POIs and avoid rules. -// Returns an edge-penalty map: "a|b" -> costMultiplier (>=1). Blocked edges are -// excluded from consideration entirely by returning Infinity. export function buildEdgePenalties( graph: RouteGraph, pois: GeoJSON.Feature[], @@ -83,7 +194,7 @@ export function buildEdgePenalties( const penalty = new Map(); if (!avoid?.length) return penalty; - const rules = avoid; // categories to penalize + const rules = avoid; const selectedPois = pois.filter((f) => { const p = (f.properties ?? {}) as Record; return rules.some((r) => { @@ -95,19 +206,16 @@ export function buildEdgePenalties( }); if (!selectedPois.length) return penalty; - // For each avoided POI, check edges near it. for (const poi of selectedPois) { const geom = poi.geometry as { type: string; coordinates: number[] }; if (!geom || geom.type !== 'Point') continue; const [plon, plat] = geom.coordinates; const R = Math.max(...rules.map((r) => r.radiusM)); - // Bounding-box prune over nodes. for (let a = 0; a < graph.coords.length; a++) { const [alat, alon] = graph.coords[a]; const dist = haversineM([plon, plat], [alon, alat]); if (dist > R) continue; for (const [b, base] of graph.adj[a]) { - // midpoint of a-b const [blat, blon] = graph.coords[b]; const mLat = (alat + blat) / 2; const mLon = (alon + blon) / 2; @@ -125,7 +233,7 @@ export function buildEdgePenalties( return penalty; } -// A* / Dijkstra shortest path. Returns list of node indices + distance. +// A* / Dijkstra shortest path. Returns node list + distance. function shortestPath( graph: RouteGraph, start: number, @@ -139,10 +247,8 @@ function shortestPath( const cameFrom = new Int32Array(n).fill(-1); const closed = new Uint8Array(n); const goalCoord = graph.coords[goal]; - const h = (i: number) => haversineM([graph.coords[i][1], graph.coords[i][0]], [goalCoord[1], goalCoord[0]]); - // Simple binary-min-heap keyed by fScore storing node indices. const heap: number[] = []; const pos = new Map(); const push = (idx: number) => { @@ -198,14 +304,13 @@ function shortestPath( if (closed[nb]) continue; const key = current < nb ? `${current}|${nb}` : `${nb}|${current}`; const mult = penalty.get(key) ?? 1; - if (mult === Infinity) continue; // blocked + if (mult === Infinity) continue; const cost = baseM * mult; const tentative = gScore[current] + cost; if (tentative < gScore[nb]) { cameFrom[nb] = current; gScore[nb] = tentative; fScore[nb] = tentative + h(nb); - // re-insert (heap allows duplicates; closed guard handles it) push(nb); } } @@ -213,6 +318,98 @@ function shortestPath( return null; } +/** + * Best-effort partial path: if the goal is unreachable (disconnected or fully + * blocked), compute the node reachable from start that gets closest to the + * goal and return the route to it. + */ +function partialPath( + graph: RouteGraph, + start: number, + goal: number, + penalty: Map +): { path: number[]; distance: number; visited: number } | null { + if (start === goal) return { path: [start], distance: 0, visited: 1 }; + const n = graph.coords.length; + // Dijkstra from start (ignore avoid penalties for the reachability probe — + // we want to know if ANY path exists, not merely an unblocked one). + const dist = new Float64Array(n).fill(Infinity); + const parent = new Int32Array(n).fill(-1); + const closed = new Uint8Array(n); + dist[start] = 0; + const heap: number[] = [start]; + const push = (idx: number) => { + heap.push(idx); + let i = heap.length - 1; + while (i > 0) { + const parentI = (i - 1) >> 1; + if (dist[heap[parentI]] <= dist[heap[i]]) break; + [heap[parentI], heap[i]] = [heap[i], heap[parentI]]; + i = parentI; + } + }; + const pop = () => { + const top = heap[0] as number; + const last = heap.pop() as number; + if (heap.length) { + heap[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1, r = 2 * i + 2; + let s = i; + if (l < heap.length && dist[heap[l]] < dist[heap[s]]) s = l; + if (r < heap.length && dist[heap[r]] < dist[heap[s]]) s = r; + if (s === i) break; + [heap[i], heap[s]] = [heap[s], heap[i]]; + i = s; + } + } + return top; + }; + let visited = 0; + while (heap.length) { + const cur = pop(); + if (closed[cur]) continue; + closed[cur] = 1; + visited++; + if (cur === goal) { + const path: number[] = []; + let c: number = goal; + while (c !== -1) { path.push(c); c = parent[c]; } + return { path: path.reverse(), distance: dist[goal], visited }; + } + for (const [nb, baseM] of graph.adj[cur]) { + if (closed[nb]) continue; + const nd = dist[cur] + baseM; + if (nd < dist[nb]) { + dist[nb] = nd; + parent[nb] = cur; + push(nb); + } + } + } + // Goal unreachable: pick the reachable node geographically closest to the + // goal so the partial route carries the user as far toward B as possible. + let best = -1, bestScore = Infinity; + const goalCoord = graph.coords[goal]; + for (let i = 0; i < n; i++) { + if (closed[i] && dist[i] < Infinity) { + const score = haversineM([graph.coords[i][1], graph.coords[i][0]], [goalCoord[1], goalCoord[0]]); + if (score < bestScore) { bestScore = score; best = i; } + } + } + if (best === -1) return null; + const path: number[] = []; + let c: number = best; + while (c !== -1) { path.push(c); c = parent[c]; } + return { path: path.reverse(), distance: dist[best], visited }; +} + +/** Small helper to rebuild a [lat,lon] path from node indices. */ +function coordsFromPath(graph: RouteGraph, nodePath: number[]): [number, number][] { + return nodePath.map((i) => graph.coords[i] as [number, number]); +} + export function route( graph: RouteGraph, fromLatLon: [number, number], @@ -220,23 +417,192 @@ export function route( pois: GeoJSON.Feature[] = [], avoid: AvoidRule[] = [] ): RouteResult { - const start = nearestNode(graph, fromLatLon[0], fromLatLon[1]); - const goal = nearestNode(graph, toLatLon[0], toLatLon[1]); - if (start < 0 || goal < 0) { - return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon }; - } - const penalty = buildEdgePenalties(graph, pois, avoid); - const res = shortestPath(graph, start, goal, penalty); - if (!res) { - return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon }; - } - const path = res.path.map((i) => graph.coords[i] as [number, number]); - return { - found: true, - path, - distanceM: Math.round(res.distance), - nodesVisited: res.visited, + const { idx: start, dist: fromSnapM } = nearestNodeInfo(graph, fromLatLon[0], fromLatLon[1]); + const { idx: goal, dist: toSnapM } = nearestNodeInfo(graph, toLatLon[0], toLatLon[1]); + + const base: RouteResult = { + found: false, + path: [], + distanceM: 0, + nodesVisited: 0, from: fromLatLon, - to: toLatLon + to: toLatLon, + fromSnapM: Math.round(fromSnapM), + toSnapM: Math.round(toSnapM) }; -} \ No newline at end of file + + // No usable road network at all. + if (start < 0 || goal < 0 || graph.coords.length === 0) { + return { ...base, reason: 'No road network is available for this location. Load a dump source to enable routing.' }; + } + + const penalty = buildEdgePenalties(graph, pois, avoid); + const full = shortestPath(graph, start, goal, penalty); + + if (full) { + const path = coordsFromPath(graph, full.path); + const result: RouteResult = { + ...base, + found: true, + path, + distanceM: Math.round(full.distance), + nodesVisited: full.visited, + turns: buildTurnInstructions(path) + }; + // Warn if the snap points are far from real roads (weak data near A/B). + if (fromSnapM > 800 || toSnapM > 800) { + result.partial = true; + result.reason = `The start/end is ${Math.max(fromSnapM, toSnapM) > 4000 ? 'far' : 'a bit'} from the routable road network (${Math.round(Math.max(fromSnapM, toSnapM))} m to nearest road). Route accuracy may be limited.`; + } + return result; + } + + // Full route blocked/unreachable — best effort. + const partial = partialPath(graph, start, goal, penalty); + if (partial) { + const path = coordsFromPath(graph, partial.path); + return { + ...base, + found: false, + partial: true, + path, + distanceM: Math.round(partial.distance), + nodesVisited: partial.visited, + reason: 'Could not find a complete route (the destination appears disconnected or fully blocked). Showing the closest reachable point on the network.', + turns: buildTurnInstructions(path) + }; + } + + return { ...base, reason: 'Could not find any route between these points.' }; +} + +/** + * Async variant of `route` that yields to the event loop while searching so the + * UI can stay responsive and show live progress. `onProgress(fraction)` is + * called periodically with an estimate in [0,1] (0 => just started, 1 => done). + */ +export async function routeWithProgress( + graph: RouteGraph, + fromLatLon: [number, number], + toLatLon: [number, number], + pois: GeoJSON.Feature[] = [], + avoid: AvoidRule[] = [], + onProgress?: (fraction: number) => void +): Promise { + const { idx: start, dist: fromSnapM } = nearestNodeInfo(graph, fromLatLon[0], fromLatLon[1]); + const { idx: goal, dist: toSnapM } = nearestNodeInfo(graph, toLatLon[0], toLatLon[1]); + + const base: RouteResult = { + found: false, path: [], distanceM: 0, nodesVisited: 0, + from: fromLatLon, to: toLatLon, + fromSnapM: Math.round(fromSnapM), toSnapM: Math.round(toSnapM) + }; + if (start < 0 || goal < 0 || graph.coords.length === 0) { + onProgress?.(1); + return { ...base, reason: 'No road network is available for this location. Load a dump source to enable routing.' }; + } + + onProgress?.(0.02); + const penalty = buildEdgePenalties(graph, pois, avoid); + const n = graph.coords.length; + const gScore = new Float64Array(n).fill(Infinity); + const fScore = new Float64Array(n).fill(Infinity); + const cameFrom = new Int32Array(n).fill(-1); + const closed = new Uint8Array(n); + const goalCoord = graph.coords[goal]; + const h = (i: number) => haversineM([graph.coords[i][1], graph.coords[i][0]], [goalCoord[1], goalCoord[0]]); + + const heap: number[] = []; + const push = (idx: number) => { + heap.push(idx); + let i = heap.length - 1; + while (i > 0) { + const parentI = (i - 1) >> 1; + if (fScore[heap[parentI]] <= fScore[heap[i]]) break; + [heap[parentI], heap[i]] = [heap[i], heap[parentI]]; + i = parentI; + } + }; + const pop = () => { + const top = heap[0] as number; + const last = heap.pop() as number; + if (heap.length) { + heap[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1, r = 2 * i + 2; + let s = i; + if (l < heap.length && fScore[heap[l]] < fScore[heap[s]]) s = l; + if (r < heap.length && fScore[heap[r]] < fScore[heap[s]]) s = r; + if (s === i) break; + [heap[i], heap[s]] = [heap[s], heap[i]]; + i = s; + } + } + return top; + }; + + gScore[start] = 0; + fScore[start] = h(start); + push(start); + let visited = 0; + let sinceYield = 0; + + const finish = (reached: boolean, nodePath: number[], distance: number): RouteResult => { + const path = coordsFromPath(graph, nodePath); + const res: RouteResult = { ...base, found: reached, path, distanceM: Math.round(distance), nodesVisited: visited }; + if (reached) res.turns = buildTurnInstructions(path); + if (fromSnapM > 800 || toSnapM > 800) { + res.partial = true; + res.reason = `The start/end is ${Math.max(fromSnapM, toSnapM) > 4000 ? 'far' : 'a bit'} from the routable road network (${Math.round(Math.max(fromSnapM, toSnapM))} m to nearest road). Route accuracy may be limited.`; + } + return res; + }; + + while (heap.length) { + const current = pop(); + if (closed[current]) continue; + closed[current] = 1; + visited++; + sinceYield++; + // Report progress + yield periodically so the UI stays responsive. + if (sinceYield >= 4000) { + sinceYield = 0; + onProgress?.(Math.min(0.98, 0.02 + (visited / Math.max(n, 1)) * 0.96)); + await new Promise((r) => setTimeout(r, 0)); + } + if (current === goal) { + const path: number[] = []; + let cur: number = goal; + while (cur !== -1) { path.push(cur); cur = cameFrom[cur]; } + onProgress?.(1); + return finish(true, path.reverse(), gScore[goal]); + } + for (const [nb, baseM] of graph.adj[current]) { + if (closed[nb]) continue; + const key = current < nb ? `${current}|${nb}` : `${nb}|${current}`; + const mult = penalty.get(key) ?? 1; + if (mult === Infinity) continue; + const tentative = gScore[current] + baseM * mult; + if (tentative < gScore[nb]) { + cameFrom[nb] = current; + gScore[nb] = tentative; + fScore[nb] = tentative + h(nb); + push(nb); + } + } + } + + // Goal unreachable via A* — best-effort partial. + // Re-run reachability (unpenalized) to find the closest reachable node. + const pd = partialPath(graph, start, goal, penalty); + onProgress?.(1); + if (pd) { + const res = finish(false, pd.path, pd.distance); + res.partial = true; + res.reason = 'Could not find a complete route (the destination appears disconnected or fully blocked). Showing the closest reachable point on the network.'; + res.turns = buildTurnInstructions(coordsFromPath(graph, pd.path)); + return res; + } + return { ...base, reason: 'Could not find any route between these points.' }; +}