feat: preferences dialog, default-unchecked layers, robust routing + turn-by-turn
Some checks failed
CI / test-and-build (push) Has been cancelled

This commit is contained in:
hermes-explorigin 2026-08-10 13:12:20 +00:00
parent 71db0da5aa
commit d786ee3151
4 changed files with 626 additions and 82 deletions

View File

@ -1,7 +1,8 @@
<script lang="ts">
import type { Map as LeafletMap } from 'leaflet';
import { onMount } from 'svelte';
import { route, type RouteGraph, type AvoidRule, type RouteResult } from '$lib/routing';
import { routeWithProgress, type RouteGraph, type AvoidRule, type RouteResult, type TurnStep } from '$lib/routing';
import { prefs } from '$lib/prefs';
interface Props {
map: LeafletMap;
@ -20,14 +21,12 @@
let to = $state<[number, number] | null>(null);
let result = $state<RouteResult | null>(null);
let computing = $state(false);
let progress = $state(0);
let picking: 'from' | 'to' | null = $state(null);
// Unit system (Metric or Imperial) and a granular unit override.
// - system 'metric' -> units m / km ; Auto = meters if < 1 km else km
// - system 'imperial'-> units ft / mi ; Auto = feet if < 1/10 mi else mi
let system = $state<'metric' | 'imperial'>('metric');
let unit = $state<'auto' | 'm' | 'km' | 'ft' | 'mi'>('auto');
// Travel-time mode: average speed (km/h) used for the estimate.
// Unit system + granular override come from the shared preferences store
// (persisted to localStorage). See src/lib/prefs.ts.
// Svelte 5 runes: `$prefs` auto-subscribes and writes back to the store.
let mode = $state<'drive' | 'walk'>('drive');
const SPEEDS = { drive: 45, walk: 4.8 }; // km/h
@ -45,45 +44,38 @@
// Format a metric distance (meters) into the chosen unit system.
function formatDistance(m: number): string {
// Determine effective unit.
if (system === 'imperial') {
if ($prefs.system === 'imperial') {
const ft = m * 3.28084;
const mi = m / 1609.344;
const useFt = unit === 'ft' || (unit === 'auto' && ft < 528);
const useFt = $prefs.unit === 'ft' || ($prefs.unit === 'auto' && ft < 528);
if (useFt) {
const v = ft < 10 ? ft.toFixed(1) : Math.round(ft).toString();
return `${v} ft`;
}
// miles
return mi < 10 ? `${mi.toFixed(2)} mi` : `${mi.toFixed(1)} mi`;
}
// metric
const useM = unit === 'm' || (unit === 'auto' && m < 1000);
const useM = $prefs.unit === 'm' || ($prefs.unit === 'auto' && m < 1000);
if (useM) {
const v = m < 10 ? m.toFixed(1) : Math.round(m).toString();
return `${v} m`;
}
// km
const km = m / 1000;
return km < 10 ? `${km.toFixed(2)} km` : `${km.toFixed(1)} km`;
}
// Show the alternate unit alongside the chosen one (e.g. "3.31 mi (5.33 km)").
function formatAlt(m: number): string | null {
if (unit !== 'auto') {
// mirror in the other system once
if (system === 'metric') {
if (unit === 'm') return `${(m / 1000).toFixed(2)} km`;
if (unit === 'km') return m < 1000 ? `${Math.round(m)} m` : null;
if ($prefs.unit !== 'auto') {
if ($prefs.system === 'metric') {
if ($prefs.unit === 'm') return `${(m / 1000).toFixed(2)} km`;
if ($prefs.unit === 'km') return m < 1000 ? `${Math.round(m)} m` : null;
return null;
} else {
if (unit === 'ft') return `${(m / 1609.344).toFixed(2)} mi`;
if (unit === 'mi') return ftAlt(m);
if ($prefs.unit === 'ft') return `${(m / 1609.344).toFixed(2)} mi`;
if ($prefs.unit === 'mi') return ftAlt(m);
return null;
}
}
// Auto: show the other cheaply for long routes
if (system === 'metric') return m >= 1000 ? `${Math.round(m)} m` : null;
if ($prefs.system === 'metric') return m >= 1000 ? `${Math.round(m)} m` : null;
return m >= 160.934 ? `${Math.round(m * 3.28084)} ft` : null;
}
function ftAlt(m: number): string | null {
@ -91,7 +83,6 @@
return ft >= 528 ? null : `${Math.round(ft)} ft`;
}
// Estimate travel time (minutes) from distance + mode speed.
function formatTime(m: number): string {
const speedKmh = SPEEDS[mode];
const minutes = m / 1000 / speedKmh * 60;
@ -102,6 +93,7 @@
return min ? `${h} h ${min} min` : `${h} h`;
}
// --- load the routing graph + POIs for the first dump source ---
async function loadData() {
if (loadingData || graph) return;
loadingData = true;
@ -115,13 +107,14 @@
graph = await g.json();
const pc: { features: GeoJSON.Feature[] } = await p.json();
pois = pc.features;
if (!graph?.coords?.length) throw new Error('Road network is empty.');
} catch (e) {
loadError = (e as Error).message;
graph = null;
}
loadingData = false;
}
// Replace the map click handler when picking A/B.
$effect(() => {
if (!map || !picking) return;
const onClick = (e: L.LeafletMouseEvent) => {
@ -136,7 +129,6 @@
});
function drawEndpoints() {
// Recompute/refresh route if both set.
if (from && to && !computing) compute();
}
@ -150,33 +142,47 @@
clearRoute();
if (!map) return;
routeLayer = L.layerGroup().addTo(map);
if (r.found && r.path.length >= 2) {
const latlngs = r.path.map(([lat, lon]) => [lat, lon] as [number, number]);
L.polyline(latlngs, { color: '#0ea5e9', weight: 5, opacity: 0.85 }).addTo(routeLayer);
const drawPath = r.path ?? [];
if (drawPath.length >= 2) {
const latlngs = drawPath.map(([lat, lon]) => [lat, lon] as [number, number]);
const style: L.PolylineOptions = r.found
? { color: '#0ea5e9', weight: 5, opacity: 0.85 }
: { color: '#f59e0b', weight: 4, dashArray: '7 5', opacity: 0.9 };
L.polyline(latlngs, style).addTo(routeLayer);
}
if (r.from) L.circleMarker(r.from as [number, number], { radius: 8, color: '#16a34a', fillColor: '#16a34a', fillOpacity: 1, weight: 2 })
.addTo(routeLayer).bindPopup('Start');
if (r.to) L.circleMarker(r.to as [number, number], { radius: 8, color: '#dc2626', fillColor: '#dc2626', fillOpacity: 1, weight: 2 })
.addTo(routeLayer).bindPopup('End');
if (r.found && r.path.length) {
const b = L.latLngBounds(r.path.map(([lat, lon]) => [lat, lon] as [number, number]));
if (drawPath.length) {
const b = L.latLngBounds(drawPath.map(([lat, lon]) => [lat, lon] as [number, number]));
if (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
}
}
async function compute() {
if (!graph || !from || !to) return;
if (!graph || !from || !to || computing) return;
computing = true;
progress = 0;
const rules = CATEGORIES.filter((c) => selected[c.key])
.map((c) => ({ category: 'amenity', value: c.value, radiusM: 300, block: true } as AvoidRule));
try {
result = route(graph, from, to, pois, rules);
await drawResult(result);
result = await routeWithProgress(graph, from, to, pois, rules, (f) => (progress = f));
void drawResult(result);
} finally {
progress = 1;
computing = false;
}
}
// Turn-by-turn is shown for driving mode (geometry-derived maneuvers).
let showTurns = $state(true);
const turnsShown = $derived(
mode === 'drive' && result?.turns?.length
? { list: result.turns, total: result.turns.reduce((a, t) => a + t.distanceM, 0) }
: null
);
onMount(loadData);
</script>
@ -184,9 +190,9 @@
<div class="dir-head">Directions</div>
{#if loadError}
<div class="err">{loadError}</div>
<div class="err">Routing unavailable: {loadError}</div>
{:else if !graph}
<div class="hint">{loadingData ? 'Loading road network…' : 'No network source.'}</div>
<div class="hint">{loadingData ? 'Loading road network…' : 'No network source. Load a dump layer to enable directions.'}</div>
{/if}
{#if graph}
@ -216,16 +222,16 @@
<div class="row3">
<div class="field">
<label for="dir-system">System</label>
<select id="dir-system" bind:value={system}>
<select id="dir-system" bind:value={$prefs.system}>
<option value="metric">Metric</option>
<option value="imperial">Imperial</option>
</select>
</div>
<div class="field">
<label for="dir-unit">Units</label>
<select id="dir-unit" bind:value={unit}>
<select id="dir-unit" bind:value={$prefs.unit}>
<option value="auto">Auto</option>
{#if system === 'metric'}
{#if $prefs.system === 'metric'}
<option value="m">m</option>
<option value="km">km</option>
{:else}
@ -244,9 +250,14 @@
</div>
{#if from && to}
<button class="go" onclick={() => compute()} disabled={computing}>
{computing ? 'Computing…' : 'Compute route'}
</button>
{#if computing}
<div class="progress-row">
<span class="prog-label">Computing route… {Math.round(progress * 100)}%</span>
<div class="bar"><div class="fill" style="width:{Math.round(progress * 100)}%"></div></div>
</div>
{:else}
<button class="go" onclick={() => compute()}>Compute route</button>
{/if}
{/if}
{#if result}
@ -262,13 +273,41 @@
<strong>{formatTime(result.distanceM)}</strong>
</div>
<em>{mode === 'drive' ? 'driving' : 'walking'} · {result.nodesVisited.toLocaleString()} nodes searched</em>
{#if result.reason}<div class="warnline">{result.reason}</div>{/if}
</div>
{:else if result.partial}
<div class="res warn">
<strong>No complete route.</strong> {result.reason ?? 'Showing the closest reachable point on the network.'}
</div>
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
{:else}
<div class="res warn">No route found (blocked or disconnected).</div>
<div class="res warn">No route found. {result.reason ?? 'The route may be disconnected or blocked.'}</div>
{/if}
{/if}
{#if turnsShown}
<div class="turns">
<div class="turns-head">
<span>Turn-by-turn</span>
<button class="min" onclick={() => (showTurns = !showTurns)}>{showTurns ? 'hide' : 'show'}</button>
</div>
{#if showTurns}
<ol class="turnlist">
{#each turnsShown.list as t (t.bearing + '-' + t.distanceM + '-' + t.instruction)}
<li>
<span class="tdist">{formatDistance(t.distanceM)}</span>
<span class="tins">{t.instruction}</span>
</li>
{/each}
</ol>
<div class="turns-total">{turnsShown.list.length} steps · {formatDistance(turnsShown.total)} total</div>
{/if}
</div>
{/if}
{#if result && (result.found || result.partial)}
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
{/if}
<div class="hint small">Set A &amp; B by clicking the map, pick categories to avoid, then compute.</div>
{/if}
</div>
@ -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; }
.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; }
</style>

View File

@ -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'}
</button>
{/if}
<button class="dirbtn" onclick={() => (showPrefs = !showPrefs)} aria-haspopup="dialog">
⚙ Preferences
</button>
<span class="meta">{totalFeatures} features</span>
</header>
@ -371,6 +380,39 @@
</div>
{/if}
{#if showPrefs}
<!-- svelte-ignore a11y_click_events_have_key_events -- keyboard handled by onkeydown -->
<div class="pref-backdrop" role="button" tabindex="0" aria-label="Close preferences"
onclick={() => (showPrefs = false)}
onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPrefs = false; } }}>
<div class="pref-dialog" role="dialog" aria-label="Preferences" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<div class="pref-title">Preferences</div>
<div class="pref-row">
<span class="pref-label">Unit system</span>
<select bind:value={$prefs.system}>
<option value="metric">Metric (m / km)</option>
<option value="imperial">Imperial (ft / mi)</option>
</select>
</div>
<div class="pref-row">
<span class="pref-label">Default units</span>
<select bind:value={$prefs.unit}>
<option value="auto">Auto</option>
{#if $prefs.system === 'metric'}
<option value="m">m</option>
<option value="km">km</option>
{:else}
<option value="ft">ft</option>
<option value="mi">mi</option>
{/if}
</select>
</div>
<div class="pref-hint">Saved to this browser (localStorage).</div>
<button class="pref-close" onclick={() => (showPrefs = false)}>Done</button>
</div>
</div>
{/if}
<div class="map" bind:this={container}></div>
</div>
@ -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; }
</style>

61
src/lib/prefs.ts Normal file
View File

@ -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<Prefs>;
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<Prefs>(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();

View File

@ -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<string, number>();
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<string, unknown>;
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<number, number>();
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<string, number>
): { 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 { 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)
};
// 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 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 {
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(res.distance),
nodesVisited: res.visited,
from: fromLatLon,
to: toLatLon
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<RouteResult> {
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.' };
}