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)}
+ -
+ {formatDistance(t.distanceM)}
+ {t.instruction}
+
+ {/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; } }}>
+
e.stopPropagation()}>
+
Preferences
+
+ Unit system
+
+
+
+
+
+
+ Default units
+
+
+ {#if $prefs.system === 'metric'}
+
+
+ {:else}
+
+
+ {/if}
+
+
+
Saved to this browser (localStorage).
+
+
+
+ {/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.' };
+}