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">
|
<script lang="ts">
|
||||||
import type { Map as LeafletMap } from 'leaflet';
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
import { onMount } from 'svelte';
|
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 {
|
interface Props {
|
||||||
map: LeafletMap;
|
map: LeafletMap;
|
||||||
@ -20,14 +21,12 @@
|
|||||||
let to = $state<[number, number] | null>(null);
|
let to = $state<[number, number] | null>(null);
|
||||||
let result = $state<RouteResult | null>(null);
|
let result = $state<RouteResult | null>(null);
|
||||||
let computing = $state(false);
|
let computing = $state(false);
|
||||||
|
let progress = $state(0);
|
||||||
let picking: 'from' | 'to' | null = $state(null);
|
let picking: 'from' | 'to' | null = $state(null);
|
||||||
|
|
||||||
// Unit system (Metric or Imperial) and a granular unit override.
|
// Unit system + granular override come from the shared preferences store
|
||||||
// - system 'metric' -> units m / km ; Auto = meters if < 1 km else km
|
// (persisted to localStorage). See src/lib/prefs.ts.
|
||||||
// - system 'imperial'-> units ft / mi ; Auto = feet if < 1/10 mi else mi
|
// Svelte 5 runes: `$prefs` auto-subscribes and writes back to the store.
|
||||||
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.
|
|
||||||
let mode = $state<'drive' | 'walk'>('drive');
|
let mode = $state<'drive' | 'walk'>('drive');
|
||||||
const SPEEDS = { drive: 45, walk: 4.8 }; // km/h
|
const SPEEDS = { drive: 45, walk: 4.8 }; // km/h
|
||||||
|
|
||||||
@ -45,45 +44,38 @@
|
|||||||
|
|
||||||
// Format a metric distance (meters) into the chosen unit system.
|
// Format a metric distance (meters) into the chosen unit system.
|
||||||
function formatDistance(m: number): string {
|
function formatDistance(m: number): string {
|
||||||
// Determine effective unit.
|
if ($prefs.system === 'imperial') {
|
||||||
if (system === 'imperial') {
|
|
||||||
const ft = m * 3.28084;
|
const ft = m * 3.28084;
|
||||||
const mi = m / 1609.344;
|
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) {
|
if (useFt) {
|
||||||
const v = ft < 10 ? ft.toFixed(1) : Math.round(ft).toString();
|
const v = ft < 10 ? ft.toFixed(1) : Math.round(ft).toString();
|
||||||
return `${v} ft`;
|
return `${v} ft`;
|
||||||
}
|
}
|
||||||
// miles
|
|
||||||
return mi < 10 ? `${mi.toFixed(2)} mi` : `${mi.toFixed(1)} mi`;
|
return mi < 10 ? `${mi.toFixed(2)} mi` : `${mi.toFixed(1)} mi`;
|
||||||
}
|
}
|
||||||
// metric
|
const useM = $prefs.unit === 'm' || ($prefs.unit === 'auto' && m < 1000);
|
||||||
const useM = unit === 'm' || (unit === 'auto' && m < 1000);
|
|
||||||
if (useM) {
|
if (useM) {
|
||||||
const v = m < 10 ? m.toFixed(1) : Math.round(m).toString();
|
const v = m < 10 ? m.toFixed(1) : Math.round(m).toString();
|
||||||
return `${v} m`;
|
return `${v} m`;
|
||||||
}
|
}
|
||||||
// km
|
|
||||||
const km = m / 1000;
|
const km = m / 1000;
|
||||||
return km < 10 ? `${km.toFixed(2)} km` : `${km.toFixed(1)} km`;
|
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 {
|
function formatAlt(m: number): string | null {
|
||||||
if (unit !== 'auto') {
|
if ($prefs.unit !== 'auto') {
|
||||||
// mirror in the other system once
|
if ($prefs.system === 'metric') {
|
||||||
if (system === 'metric') {
|
if ($prefs.unit === 'm') return `${(m / 1000).toFixed(2)} km`;
|
||||||
if (unit === 'm') return `${(m / 1000).toFixed(2)} km`;
|
if ($prefs.unit === 'km') return m < 1000 ? `${Math.round(m)} m` : null;
|
||||||
if (unit === 'km') return m < 1000 ? `${Math.round(m)} m` : null;
|
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
if (unit === 'ft') return `${(m / 1609.344).toFixed(2)} mi`;
|
if ($prefs.unit === 'ft') return `${(m / 1609.344).toFixed(2)} mi`;
|
||||||
if (unit === 'mi') return ftAlt(m);
|
if ($prefs.unit === 'mi') return ftAlt(m);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto: show the other cheaply for long routes
|
if ($prefs.system === 'metric') return m >= 1000 ? `${Math.round(m)} m` : null;
|
||||||
if (system === 'metric') return m >= 1000 ? `${Math.round(m)} m` : null;
|
|
||||||
return m >= 160.934 ? `${Math.round(m * 3.28084)} ft` : null;
|
return m >= 160.934 ? `${Math.round(m * 3.28084)} ft` : null;
|
||||||
}
|
}
|
||||||
function ftAlt(m: number): string | null {
|
function ftAlt(m: number): string | null {
|
||||||
@ -91,7 +83,6 @@
|
|||||||
return ft >= 528 ? null : `${Math.round(ft)} ft`;
|
return ft >= 528 ? null : `${Math.round(ft)} ft`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate travel time (minutes) from distance + mode speed.
|
|
||||||
function formatTime(m: number): string {
|
function formatTime(m: number): string {
|
||||||
const speedKmh = SPEEDS[mode];
|
const speedKmh = SPEEDS[mode];
|
||||||
const minutes = m / 1000 / speedKmh * 60;
|
const minutes = m / 1000 / speedKmh * 60;
|
||||||
@ -102,6 +93,7 @@
|
|||||||
return min ? `${h} h ${min} min` : `${h} h`;
|
return min ? `${h} h ${min} min` : `${h} h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- load the routing graph + POIs for the first dump source ---
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (loadingData || graph) return;
|
if (loadingData || graph) return;
|
||||||
loadingData = true;
|
loadingData = true;
|
||||||
@ -115,13 +107,14 @@
|
|||||||
graph = await g.json();
|
graph = await g.json();
|
||||||
const pc: { features: GeoJSON.Feature[] } = await p.json();
|
const pc: { features: GeoJSON.Feature[] } = await p.json();
|
||||||
pois = pc.features;
|
pois = pc.features;
|
||||||
|
if (!graph?.coords?.length) throw new Error('Road network is empty.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = (e as Error).message;
|
loadError = (e as Error).message;
|
||||||
|
graph = null;
|
||||||
}
|
}
|
||||||
loadingData = false;
|
loadingData = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the map click handler when picking A/B.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!map || !picking) return;
|
if (!map || !picking) return;
|
||||||
const onClick = (e: L.LeafletMouseEvent) => {
|
const onClick = (e: L.LeafletMouseEvent) => {
|
||||||
@ -136,7 +129,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function drawEndpoints() {
|
function drawEndpoints() {
|
||||||
// Recompute/refresh route if both set.
|
|
||||||
if (from && to && !computing) compute();
|
if (from && to && !computing) compute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,33 +142,47 @@
|
|||||||
clearRoute();
|
clearRoute();
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
routeLayer = L.layerGroup().addTo(map);
|
routeLayer = L.layerGroup().addTo(map);
|
||||||
if (r.found && r.path.length >= 2) {
|
const drawPath = r.path ?? [];
|
||||||
const latlngs = r.path.map(([lat, lon]) => [lat, lon] as [number, number]);
|
if (drawPath.length >= 2) {
|
||||||
L.polyline(latlngs, { color: '#0ea5e9', weight: 5, opacity: 0.85 }).addTo(routeLayer);
|
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 })
|
if (r.from) L.circleMarker(r.from as [number, number], { radius: 8, color: '#16a34a', fillColor: '#16a34a', fillOpacity: 1, weight: 2 })
|
||||||
.addTo(routeLayer).bindPopup('Start');
|
.addTo(routeLayer).bindPopup('Start');
|
||||||
if (r.to) L.circleMarker(r.to as [number, number], { radius: 8, color: '#dc2626', fillColor: '#dc2626', fillOpacity: 1, weight: 2 })
|
if (r.to) L.circleMarker(r.to as [number, number], { radius: 8, color: '#dc2626', fillColor: '#dc2626', fillOpacity: 1, weight: 2 })
|
||||||
.addTo(routeLayer).bindPopup('End');
|
.addTo(routeLayer).bindPopup('End');
|
||||||
if (r.found && r.path.length) {
|
if (drawPath.length) {
|
||||||
const b = L.latLngBounds(r.path.map(([lat, lon]) => [lat, lon] as [number, number]));
|
const b = L.latLngBounds(drawPath.map(([lat, lon]) => [lat, lon] as [number, number]));
|
||||||
if (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
|
if (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compute() {
|
async function compute() {
|
||||||
if (!graph || !from || !to) return;
|
if (!graph || !from || !to || computing) return;
|
||||||
computing = true;
|
computing = true;
|
||||||
|
progress = 0;
|
||||||
const rules = CATEGORIES.filter((c) => selected[c.key])
|
const rules = CATEGORIES.filter((c) => selected[c.key])
|
||||||
.map((c) => ({ category: 'amenity', value: c.value, radiusM: 300, block: true } as AvoidRule));
|
.map((c) => ({ category: 'amenity', value: c.value, radiusM: 300, block: true } as AvoidRule));
|
||||||
try {
|
try {
|
||||||
result = route(graph, from, to, pois, rules);
|
result = await routeWithProgress(graph, from, to, pois, rules, (f) => (progress = f));
|
||||||
await drawResult(result);
|
void drawResult(result);
|
||||||
} finally {
|
} finally {
|
||||||
|
progress = 1;
|
||||||
computing = false;
|
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);
|
onMount(loadData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -184,9 +190,9 @@
|
|||||||
<div class="dir-head">Directions</div>
|
<div class="dir-head">Directions</div>
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<div class="err">{loadError}</div>
|
<div class="err">Routing unavailable: {loadError}</div>
|
||||||
{:else if !graph}
|
{: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}
|
||||||
|
|
||||||
{#if graph}
|
{#if graph}
|
||||||
@ -216,16 +222,16 @@
|
|||||||
<div class="row3">
|
<div class="row3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dir-system">System</label>
|
<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="metric">Metric</option>
|
||||||
<option value="imperial">Imperial</option>
|
<option value="imperial">Imperial</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dir-unit">Units</label>
|
<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>
|
<option value="auto">Auto</option>
|
||||||
{#if system === 'metric'}
|
{#if $prefs.system === 'metric'}
|
||||||
<option value="m">m</option>
|
<option value="m">m</option>
|
||||||
<option value="km">km</option>
|
<option value="km">km</option>
|
||||||
{:else}
|
{:else}
|
||||||
@ -244,9 +250,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if from && to}
|
{#if from && to}
|
||||||
<button class="go" onclick={() => compute()} disabled={computing}>
|
{#if computing}
|
||||||
{computing ? 'Computing…' : 'Compute route'}
|
<div class="progress-row">
|
||||||
</button>
|
<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}
|
||||||
|
|
||||||
{#if result}
|
{#if result}
|
||||||
@ -262,13 +273,41 @@
|
|||||||
<strong>{formatTime(result.distanceM)}</strong>
|
<strong>{formatTime(result.distanceM)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<em>{mode === 'drive' ? 'driving' : 'walking'} · {result.nodesVisited.toLocaleString()} nodes searched</em>
|
<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>
|
</div>
|
||||||
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
|
|
||||||
{:else}
|
{: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}
|
{/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>
|
<div class="hint small">Set A & B by clicking the map, pick categories to avoid, then compute.</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -299,9 +338,22 @@
|
|||||||
.resline { display: flex; justify-content: space-between; align-items: baseline; margin: 2px 0; }
|
.resline { display: flex; justify-content: space-between; align-items: baseline; margin: 2px 0; }
|
||||||
.resline .lab { color: #475569; }
|
.resline .lab { color: #475569; }
|
||||||
.res em { display: block; color: #64748b; font-size: 0.7rem; margin-top: 3px; }
|
.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; }
|
.mute { color: #94a3b8; font-size: 0.7rem; }
|
||||||
.res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
|
.res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
|
||||||
.err { color: #b91c1c; margin: 6px 0; }
|
.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 { color: #64748b; }
|
||||||
.hint.small { font-size: 0.7rem; margin-top: 6px; color: #94a3b8; }
|
.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>
|
</style>
|
||||||
@ -5,6 +5,7 @@
|
|||||||
import { search, type Searchable, type SearchResult } from '$lib/search';
|
import { search, type Searchable, type SearchResult } from '$lib/search';
|
||||||
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
import { geocode, type GeocodedPlace } from '$lib/geocode';
|
||||||
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
|
||||||
|
import { prefs } from '$lib/prefs';
|
||||||
|
|
||||||
type FeatureCollection = {
|
type FeatureCollection = {
|
||||||
type: string;
|
type: string;
|
||||||
@ -56,6 +57,8 @@
|
|||||||
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
|
layers.filter((l) => l.category === 'dump' && l.graphPath && l.poiPath)
|
||||||
);
|
);
|
||||||
let showDirections = $state(false);
|
let showDirections = $state(false);
|
||||||
|
// --- preferences dialog state ---
|
||||||
|
let showPrefs = $state(false);
|
||||||
// --- search state ---
|
// --- search state ---
|
||||||
const searchables: Searchable[] = [];
|
const searchables: Searchable[] = [];
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@ -279,15 +282,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
layerGroups[layer.name] = geo;
|
layerGroups[layer.name] = geo;
|
||||||
visible[layer.name] = true;
|
// Default to none of the layers shown on load; the user toggles
|
||||||
geo.addTo(map);
|
// them via the legend. (Nothing is added to the map here.)
|
||||||
|
visible[layer.name] = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('layer load failed:', layer.name, e);
|
console.warn('layer load failed:', layer.name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const all: L.Layer[] = Object.values(layerGroups);
|
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);
|
const group = L.featureGroup(all);
|
||||||
if (typeof group.getBounds === 'function' && group.getBounds().isValid()) {
|
if (typeof group.getBounds === 'function' && group.getBounds().isValid()) {
|
||||||
map.fitBounds(group.getBounds(), { padding: [40, 40] });
|
map.fitBounds(group.getBounds(), { padding: [40, 40] });
|
||||||
@ -348,6 +354,9 @@
|
|||||||
{showDirections ? 'Hide Directions' : 'Directions'}
|
{showDirections ? 'Hide Directions' : 'Directions'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="dirbtn" onclick={() => (showPrefs = !showPrefs)} aria-haspopup="dialog">
|
||||||
|
⚙ Preferences
|
||||||
|
</button>
|
||||||
<span class="meta">{totalFeatures} features</span>
|
<span class="meta">{totalFeatures} features</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -371,6 +380,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 class="map" bind:this={container}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -459,4 +501,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.leaflet-container) { font: inherit; }
|
: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>
|
</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),
|
* - runs an A* / Dijkstra shortest-path search (great-circle heuristic),
|
||||||
* - supports "avoid" categories: edges whose midpoint falls within a radius
|
* - 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),
|
* 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 {
|
export interface RouteGraph {
|
||||||
@ -33,6 +36,13 @@ export interface RouteOptions {
|
|||||||
avoid?: AvoidRule[];
|
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 {
|
export interface RouteResult {
|
||||||
found: boolean;
|
found: boolean;
|
||||||
path: [number, number][]; // [lat, lon] waypoints
|
path: [number, number][]; // [lat, lon] waypoints
|
||||||
@ -40,6 +50,13 @@ export interface RouteResult {
|
|||||||
nodesVisited: number;
|
nodesVisited: number;
|
||||||
from: [number, number];
|
from: [number, number];
|
||||||
to: [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 ---
|
// --- geometry helpers ---
|
||||||
@ -56,8 +73,99 @@ function haversineM(aLonLat: number[], bLonLat: number[]): number {
|
|||||||
return 2 * R * Math.asin(Math.sqrt(s));
|
return 2 * R * Math.asin(Math.sqrt(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nearest node index to a [lat, lon] point (linear scan; fine for ~50k nodes).
|
/** Initial bearing (degrees 0-360) from a to b, in [lon, lat] arrays. */
|
||||||
export function nearestNode(graph: RouteGraph, lat: number, lon: number): number {
|
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 best = -1;
|
||||||
let bestD = Infinity;
|
let bestD = Infinity;
|
||||||
const target = [lon, lat];
|
const target = [lon, lat];
|
||||||
@ -69,12 +177,15 @@ export function nearestNode(graph: RouteGraph, lat: number, lon: number): number
|
|||||||
best = i;
|
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.
|
// 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(
|
export function buildEdgePenalties(
|
||||||
graph: RouteGraph,
|
graph: RouteGraph,
|
||||||
pois: GeoJSON.Feature[],
|
pois: GeoJSON.Feature[],
|
||||||
@ -83,7 +194,7 @@ export function buildEdgePenalties(
|
|||||||
const penalty = new Map<string, number>();
|
const penalty = new Map<string, number>();
|
||||||
if (!avoid?.length) return penalty;
|
if (!avoid?.length) return penalty;
|
||||||
|
|
||||||
const rules = avoid; // categories to penalize
|
const rules = avoid;
|
||||||
const selectedPois = pois.filter((f) => {
|
const selectedPois = pois.filter((f) => {
|
||||||
const p = (f.properties ?? {}) as Record<string, unknown>;
|
const p = (f.properties ?? {}) as Record<string, unknown>;
|
||||||
return rules.some((r) => {
|
return rules.some((r) => {
|
||||||
@ -95,19 +206,16 @@ export function buildEdgePenalties(
|
|||||||
});
|
});
|
||||||
if (!selectedPois.length) return penalty;
|
if (!selectedPois.length) return penalty;
|
||||||
|
|
||||||
// For each avoided POI, check edges near it.
|
|
||||||
for (const poi of selectedPois) {
|
for (const poi of selectedPois) {
|
||||||
const geom = poi.geometry as { type: string; coordinates: number[] };
|
const geom = poi.geometry as { type: string; coordinates: number[] };
|
||||||
if (!geom || geom.type !== 'Point') continue;
|
if (!geom || geom.type !== 'Point') continue;
|
||||||
const [plon, plat] = geom.coordinates;
|
const [plon, plat] = geom.coordinates;
|
||||||
const R = Math.max(...rules.map((r) => r.radiusM));
|
const R = Math.max(...rules.map((r) => r.radiusM));
|
||||||
// Bounding-box prune over nodes.
|
|
||||||
for (let a = 0; a < graph.coords.length; a++) {
|
for (let a = 0; a < graph.coords.length; a++) {
|
||||||
const [alat, alon] = graph.coords[a];
|
const [alat, alon] = graph.coords[a];
|
||||||
const dist = haversineM([plon, plat], [alon, alat]);
|
const dist = haversineM([plon, plat], [alon, alat]);
|
||||||
if (dist > R) continue;
|
if (dist > R) continue;
|
||||||
for (const [b, base] of graph.adj[a]) {
|
for (const [b, base] of graph.adj[a]) {
|
||||||
// midpoint of a-b
|
|
||||||
const [blat, blon] = graph.coords[b];
|
const [blat, blon] = graph.coords[b];
|
||||||
const mLat = (alat + blat) / 2;
|
const mLat = (alat + blat) / 2;
|
||||||
const mLon = (alon + blon) / 2;
|
const mLon = (alon + blon) / 2;
|
||||||
@ -125,7 +233,7 @@ export function buildEdgePenalties(
|
|||||||
return penalty;
|
return penalty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A* / Dijkstra shortest path. Returns list of node indices + distance.
|
// A* / Dijkstra shortest path. Returns node list + distance.
|
||||||
function shortestPath(
|
function shortestPath(
|
||||||
graph: RouteGraph,
|
graph: RouteGraph,
|
||||||
start: number,
|
start: number,
|
||||||
@ -139,10 +247,8 @@ function shortestPath(
|
|||||||
const cameFrom = new Int32Array(n).fill(-1);
|
const cameFrom = new Int32Array(n).fill(-1);
|
||||||
const closed = new Uint8Array(n);
|
const closed = new Uint8Array(n);
|
||||||
const goalCoord = graph.coords[goal];
|
const goalCoord = graph.coords[goal];
|
||||||
|
|
||||||
const h = (i: number) => haversineM([graph.coords[i][1], graph.coords[i][0]], [goalCoord[1], goalCoord[0]]);
|
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 heap: number[] = [];
|
||||||
const pos = new Map<number, number>();
|
const pos = new Map<number, number>();
|
||||||
const push = (idx: number) => {
|
const push = (idx: number) => {
|
||||||
@ -198,14 +304,13 @@ function shortestPath(
|
|||||||
if (closed[nb]) continue;
|
if (closed[nb]) continue;
|
||||||
const key = current < nb ? `${current}|${nb}` : `${nb}|${current}`;
|
const key = current < nb ? `${current}|${nb}` : `${nb}|${current}`;
|
||||||
const mult = penalty.get(key) ?? 1;
|
const mult = penalty.get(key) ?? 1;
|
||||||
if (mult === Infinity) continue; // blocked
|
if (mult === Infinity) continue;
|
||||||
const cost = baseM * mult;
|
const cost = baseM * mult;
|
||||||
const tentative = gScore[current] + cost;
|
const tentative = gScore[current] + cost;
|
||||||
if (tentative < gScore[nb]) {
|
if (tentative < gScore[nb]) {
|
||||||
cameFrom[nb] = current;
|
cameFrom[nb] = current;
|
||||||
gScore[nb] = tentative;
|
gScore[nb] = tentative;
|
||||||
fScore[nb] = tentative + h(nb);
|
fScore[nb] = tentative + h(nb);
|
||||||
// re-insert (heap allows duplicates; closed guard handles it)
|
|
||||||
push(nb);
|
push(nb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,6 +318,98 @@ function shortestPath(
|
|||||||
return null;
|
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(
|
export function route(
|
||||||
graph: RouteGraph,
|
graph: RouteGraph,
|
||||||
fromLatLon: [number, number],
|
fromLatLon: [number, number],
|
||||||
@ -220,23 +417,192 @@ export function route(
|
|||||||
pois: GeoJSON.Feature[] = [],
|
pois: GeoJSON.Feature[] = [],
|
||||||
avoid: AvoidRule[] = []
|
avoid: AvoidRule[] = []
|
||||||
): RouteResult {
|
): RouteResult {
|
||||||
const start = nearestNode(graph, fromLatLon[0], fromLatLon[1]);
|
const { idx: start, dist: fromSnapM } = nearestNodeInfo(graph, fromLatLon[0], fromLatLon[1]);
|
||||||
const goal = nearestNode(graph, toLatLon[0], toLatLon[1]);
|
const { idx: goal, dist: toSnapM } = nearestNodeInfo(graph, toLatLon[0], toLatLon[1]);
|
||||||
if (start < 0 || goal < 0) {
|
|
||||||
return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon };
|
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 penalty = buildEdgePenalties(graph, pois, avoid);
|
||||||
const res = shortestPath(graph, start, goal, penalty);
|
const full = shortestPath(graph, start, goal, penalty);
|
||||||
if (!res) {
|
|
||||||
return { found: false, path: [], distanceM: 0, nodesVisited: 0, from: fromLatLon, to: toLatLon };
|
if (full) {
|
||||||
}
|
const path = coordsFromPath(graph, full.path);
|
||||||
const path = res.path.map((i) => graph.coords[i] as [number, number]);
|
const result: RouteResult = {
|
||||||
return {
|
...base,
|
||||||
found: true,
|
found: true,
|
||||||
path,
|
path,
|
||||||
distanceM: Math.round(res.distance),
|
distanceM: Math.round(full.distance),
|
||||||
nodesVisited: res.visited,
|
nodesVisited: full.visited,
|
||||||
from: fromLatLon,
|
turns: buildTurnInstructions(path)
|
||||||
to: toLatLon
|
|
||||||
};
|
};
|
||||||
|
// 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