feat: preferences dialog, default-unchecked layers, robust routing + turn-by-turn
Some checks failed
CI / test-and-build (push) Has been cancelled
Some checks failed
CI / test-and-build (push) Has been cancelled
This commit is contained in:
parent
71db0da5aa
commit
d786ee3151
@ -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 & 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>
|
||||
@ -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
61
src/lib/prefs.ts
Normal 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();
|
||||
@ -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 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 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<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.' };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user