feat(directions): rework panel to destination-picker + collapsed route options; graph loaded on demand with sync fallback

This commit is contained in:
hermes-explorigin 2026-08-10 15:20:17 +00:00
parent 5e85c15798
commit 62ddbf160c
2 changed files with 338 additions and 192 deletions

View File

@ -1,37 +1,65 @@
<script lang="ts">
import type { Map as LeafletMap } from 'leaflet';
import { onMount } from 'svelte';
import { routeWithProgress, type RouteGraph, type AvoidRule, type RouteResult, type TurnStep } from '$lib/routing';
import type { RouteGraph, AvoidRule, RouteResult, TurnStep } from '$lib/routing';
import type { Searchable, SearchResult } from '$lib/search';
import { search } from '$lib/search';
import { geocode, type GeocodedPlace } from '$lib/geocode';
import { prefs } from '$lib/prefs';
interface Props {
map: LeafletMap;
/** Dump sources that carry a graphPath (from the manifest). */
dumpLayers: { name: string; label: string; graphPath: string; poiPath: string }[];
/** The selected origin (start point). Always set when the panel is open. */
origin: { lat: number; lon: number; label?: string };
/** Whether a routable graph is currently loaded. */
hasGraph: boolean;
/** Label of the graph source (for the generic load prompt). */
graphLabel?: string;
/** Called by the on-demand CTA to load the routable layer. */
onLoadGraph?: () => void;
/** Loaded POIs available for avoidance + search. */
searchables: Searchable[];
/** Live route progress (0..1) while computing. */
progress: number;
/** True while a route computation is in flight. */
computing: boolean;
/** Perform the route computation (worker-backed in MapView). */
runRoute: (
from: [number, number],
to: [number, number],
avoid: AvoidRule[]
) => Promise<RouteResult>;
}
let { map, dumpLayers = [] }: Props = $props();
let {
map,
origin,
hasGraph,
graphLabel = 'the road data layer',
onLoadGraph,
searchables = [],
progress = 0,
computing = false,
runRoute
}: Props = $props();
let graph = $state<RouteGraph | null>(null);
let pois = $state<GeoJSON.Feature[]>([]);
let loadingData = $state(false);
let loadError = $state<string | null>(null);
// --- destination ---
let destination = $state<{ lat: number; lon: number; label?: string } | null>(null);
let pickingDest = $state(false);
// broadcast message while waiting for the destination click
let destHint = $state<string>('');
let from = $state<[number, number] | null>(null);
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 + 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
let routeLayer: L.LayerGroup | null = null;
// --- search for destination ---
let destQuery = $state('');
let destResultQuery = $state('');
let destSearchResults = $state<SearchResult[]>([]);
let destGeoResults = $state<GeocodedPlace[]>([]);
let destSearching = $state(false);
let destSearchDone = $state(false);
let showDestResults = $state(false);
// --- avoid categories (collapsed "Route options") ---
let optionsOpen = $state(false);
const CATEGORIES = [
{ key: 'school', value: 'school', label: 'Schools' },
{ key: 'fire_station', value: 'fire_station', label: 'Fire stations' },
@ -42,8 +70,18 @@
];
const selected = $state<Record<string, boolean>>({});
// Format a metric distance (meters) for a given unit system, auto-selecting
// the small/large unit: use ft/m under a mile/km, otherwise mi/km.
// --- results ---
let result = $state<RouteResult | null>(null);
let status = $state<'idle' | 'running' | 'done' | 'error'>('idle');
let errorMsg = $state<string | null>(null);
// --- travel mode ---
let mode = $state<'drive' | 'walk'>('drive');
const SPEEDS = { drive: 45, walk: 4.8 };
let routeLayer: L.LayerGroup | null = null;
// Unit-system formatting (auto m/ft vs km/mi).
function fmtFor(m: number, sys: 'metric' | 'imperial'): string {
if (sys === 'imperial') {
const ft = m * 3.28084;
@ -55,20 +93,14 @@
const km = m / 1000;
return km < 10 ? `${km.toFixed(2)} km` : `${km.toFixed(1)} km`;
}
function formatDistance(m: number): string {
return fmtFor(m, $prefs.system);
}
// Show the same distance in the OTHER unit system (e.g. "3.31 mi (5.33 km)").
function formatAlt(m: number): string | null {
const other: 'metric' | 'imperial' = $prefs.system === 'metric' ? 'imperial' : 'metric';
return fmtFor(m, other);
function formatAlt(m: number): string {
return fmtFor(m, $prefs.system === 'metric' ? 'imperial' : 'metric');
}
function formatTime(m: number): string {
const speedKmh = SPEEDS[mode];
const minutes = m / 1000 / speedKmh * 60;
const minutes = (m / 1000 / SPEEDS[mode]) * 60;
if (minutes < 1) return `${Math.max(1, Math.round(minutes * 60))}s`;
if (minutes < 60) return `${Math.round(minutes)} min`;
const h = Math.floor(minutes / 60);
@ -76,50 +108,71 @@
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;
loadError = null;
try {
const layer = dumpLayers[0];
if (!layer) throw new Error('No dump/graph source configured.');
const [g, p] = await Promise.all([fetch(layer.graphPath), fetch(layer.poiPath)]);
if (!g.ok) throw new Error(`graph HTTP ${g.status}`);
if (!p.ok) throw new Error(`poi HTTP ${p.status}`);
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;
// --- destination by map click ---
function startPickingDest() {
pickingDest = true;
destHint = 'Click the map to choose the destination…';
}
function stopPickingDest() {
pickingDest = false;
destHint = '';
}
function setDestination(lat: number, lon: number, label?: string) {
destination = { lat, lon, label };
pickingDest = false;
destHint = '';
}
$effect(() => {
if (!map || !picking) return;
// Map clicks while the panel is open belong to destination selection.
onMount(() => {
if (!map) return;
const onClick = (e: L.LeafletMouseEvent) => {
const ll: [number, number] = [e.latlng.lat, e.latlng.lng];
if (picking === 'from') from = ll;
else to = ll;
picking = null;
drawEndpoints();
if (pickingDest) setDestination(e.latlng.lat, e.latlng.lng);
};
map.on('click', onClick);
return () => map.off('click', onClick);
});
function drawEndpoints() {
if (from && to && !computing) compute();
// --- destination by search ---
async function inputDestSearch() {
destResultQuery = destQuery;
const q = destQuery.trim();
if (q.length < 2) {
destSearchResults = [];
return;
}
// local
destSearchResults = search(q, searchables);
// online geocode fallback
destSearching = true;
destSearchDone = false;
try {
destGeoResults = await geocode(q, { countrycodes: 'us' });
} catch {
destGeoResults = [];
}
destSearching = false;
destSearchDone = true;
}
function onDestLocal(r: SearchResult) {
setDestination(r.item.lat, r.item.lon, r.item.name);
destQuery = r.item.name;
showDestResults = false;
}
function onDestGeo(g: GeocodedPlace) {
setDestination(g.lat, g.lon, g.displayName);
destQuery = g.displayName;
showDestResults = false;
}
// --- clear / route drawing ---
function clearRoute() {
if (routeLayer) { routeLayer.remove(); routeLayer = null; }
result = null;
status = 'idle';
errorMsg = null;
}
async function drawResult(r: RouteResult) {
const L = (await import('leaflet')).default;
clearRoute();
@ -143,180 +196,217 @@
}
}
async function compute() {
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));
// --- calculate ---
async function calculate() {
if (!origin || !destination || computing) return;
status = 'running';
errorMsg = null;
clearRoute();
const avoid: AvoidRule[] = CATEGORIES.filter((c) => selected[c.key])
.map((c) => ({ category: 'amenity', value: c.value, radiusM: 300, block: true }));
try {
result = await routeWithProgress(graph, from, to, pois, rules, (f) => (progress = f));
void drawResult(result);
} finally {
progress = 1;
computing = false;
const res = await runRoute(
[origin.lat, origin.lon],
[destination.lat, destination.lon],
avoid
);
result = res;
status = res.found ? 'done' : res.partial ? 'done' : 'error';
if (status === 'error') errorMsg = res.reason ?? 'No route could be computed.';
void drawResult(res);
} catch (e) {
status = 'error';
errorMsg = e instanceof Error ? e.message : String(e);
}
}
// Turn-by-turn is shown for driving mode (geometry-derived maneuvers).
let showTurns = $state(true);
const turnsShown = $derived(
const canCalculate = $derived(!!origin && !!destination && hasGraph && !computing);
const turns = $derived<{ list: TurnStep[]; total: number } | null>(
mode === 'drive' && result?.turns?.length
? { list: result.turns, total: result.turns.reduce((a, t) => a + t.distanceM, 0) }
: null
);
onMount(loadData);
</script>
<div class="dirs">
<div class="dirs" role="dialog" aria-label="Directions">
<div class="dir-head">Directions</div>
{#if loadError}
<div class="err">Routing unavailable: {loadError}</div>
{:else if !graph}
<div class="hint">{loadingData ? 'Loading road network…' : 'No network source. Load a dump layer to enable directions.'}</div>
<!-- Origin (already selected) -->
<div class="point"><span class="dot a"></span> A · {origin.label || `${origin.lat.toFixed(4)}, ${origin.lon.toFixed(4)}`}</div>
{#if !hasGraph}
<div class="cta">
<strong>Road data layer needed for route computation</strong>
<p>The {graphLabel} isn't loaded yet. Load it to enable routing.</p>
<button class="cta-btn" onclick={() => onLoadGraph?.()} disabled={computing}>Load road data</button>
</div>
{/if}
{#if graph}
{#if from}
<div class="point"><span class="dot a"></span> A · {from[0].toFixed(4)}, {from[1].toFixed(4)}</div>
{#if hasGraph}
<!-- Destination -->
{#if destination}
<div class="point"><span class="dot b"></span> B · {destination.label || `${destination.lat.toFixed(4)}, ${destination.lon.toFixed(4)}`}</div>
<button class="reset-dest" onclick={() => (destination = null)}>Change destination</button>
{:else}
<button class="pick" onclick={() => (picking = 'from')} disabled={!!picking}>
{picking === 'from' ? 'Click map for A…' : 'Set start (A)'}
</button>
{/if}
{#if to}
<div class="point"><span class="dot b"></span> B · {to[0].toFixed(4)}, {to[1].toFixed(4)}</div>
{:else}
<button class="pick" onclick={() => (picking = 'to')} disabled={!!picking}>
{picking === 'to' ? 'Click map for B…' : 'Set end (B)'}
</button>
{/if}
<div class="avoid">
<strong>Avoid</strong>
{#each CATEGORIES as c (c.key)}
<label><input type="checkbox" bind:checked={selected[c.key]} /> {c.label}</label>
{/each}
</div>
<div class="row3">
<div class="field">
<label for="dir-mode">Mode</label>
<select id="dir-mode" bind:value={mode}>
<option value="drive">Drive</option>
<option value="walk">Walk</option>
</select>
<div class="dest-pick">
<button class="pick" onclick={() => (pickingDest ? stopPickingDest() : startPickingDest())}>
{pickingDest ? 'Cancel map pick' : 'Pick destination on map'}
</button>
{#if destHint}<div class="hint">{destHint}</div>{/if}
<div class="dest-search">
<input
type="search"
placeholder="Search destination…"
bind:value={destQuery}
oninput={() => { showDestResults = true; void inputDestSearch(); }}
onfocus={() => (showDestResults = true)}
/>
{#if showDestResults && (destSearchResults.length || destGeoResults.length || destSearching)}
<ul class="dest-results">
{#if destSearchResults.length}
<li class="sept">From your data</li>
{#each destSearchResults as r (r.item.id)}
<li><button type="button" onclick={() => onDestLocal(r)}>{r.item.name}</button></li>
{/each}
{/if}
{#if destSearching}
<li class="sept loading">Searching the web for locations…</li>
{:else if destGeoResults.length}
<li class="sept">Online</li>
{#each destGeoResults as g, i (g.osmId + '-' + i)}
<li><button type="button" onclick={() => onDestGeo(g)}>{g.displayName}</button></li>
{/each}
{/if}
</ul>
{/if}
</div>
</div>
{/if}
<!-- Route options (collapsed) -->
<div class="options">
<button class="opt-toggle" onclick={() => (optionsOpen = !optionsOpen)} aria-expanded={optionsOpen}>
Route options {optionsOpen ? '▾' : '▸'}
</button>
{#if optionsOpen}
<div class="opt-body">
<div class="avoid">
<strong>Avoid</strong>
{#each CATEGORIES as c (c.key)}
<label><input type="checkbox" bind:checked={selected[c.key]} /> {c.label}</label>
{/each}
</div>
<div class="mode">
<label for="dir-mode">Travel mode</label>
<select id="dir-mode" bind:value={mode}>
<option value="drive">Drive</option>
<option value="walk">Walk</option>
</select>
</div>
</div>
{/if}
</div>
{#if from && to}
{#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}
<!-- Calculate -->
<button class="go" onclick={() => calculate()} disabled={!canCalculate}>
{computing ? 'Calculating…' : 'Calculate 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>
{/if}
{#if result}
{#if result.found}
<div class="res">
<div class="resline">
<span class="lab">Distance</span>
<strong>{formatDistance(result.distanceM)}</strong>
</div>
{#if formatAlt(result.distanceM)}<div class="mute">{formatAlt(result.distanceM)}</div>{/if}
<div class="resline">
<span class="lab">Est. time</span>
<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>
{:else}
<div class="res warn">No route found. {result.reason ?? 'The route may be disconnected or blocked.'}</div>
{/if}
{/if}
{#if status === 'error'}
<div class="res warn"><strong>No complete route.</strong> {errorMsg}</div>
{:else if result}
<div class="res">
<div class="resline"><span class="lab">Distance</span><strong>{formatDistance(result.distanceM)}</strong></div>
<div class="mute">{formatAlt(result.distanceM)}</div>
<div class="resline"><span class="lab">Est. time</span><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>
{#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}
{#if turns}
<div class="turns">
<div class="turns-head"><span>Turn-by-turn</span></div>
<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 turns.list as t, i (i + '-' + 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}
<div class="turns-total">{turns.list.length} steps · {formatDistance(turns.total)} total</div>
</div>
{/if}
{#if result && (result.found || result.partial)}
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
{/if}
<div class="hint small">Set A &amp; B by clicking the map, pick categories to avoid, then compute.</div>
{/if}
<div class="hint small">Pick a destination (map click or search), then calculate the route.</div>
</div>
<style>
.dirs {
background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.25);
padding: 12px; width: 268px; font: 0.8rem system-ui, sans-serif; color: #0f172a;
box-sizing: border-box; max-height: 100%; overflow-y: auto;
}
background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
padding: 12px; width: 280px; font: 0.8rem system-ui, sans-serif; color: #0f172a;
box-sizing: border-box; max-height: 100%; overflow-y: auto;
}
.dir-head { font-weight: 700; margin-bottom: 8px; }
.point { padding: 6px 8px; background: #f1f5f9; border-radius: 6px; margin: 4px 0; display: flex; align-items: center; gap: 6px; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.dot.a { background: #16a34a; } .dot.b { background: #dc2626; }
.pick, .go, .clear { width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1; background: #fff; cursor: pointer; margin: 4px 0; font-size: 0.8rem; }
.pick:hover, .go:hover, .clear:hover { background: #f1f5f9; }
.pick:disabled { opacity: .4; cursor: default; }
.pick, .go, .clear, .cta-btn, .reset-dest, .opt-toggle {
width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1;
background: #fff; cursor: pointer; margin: 4px 0; font-size: 0.8rem;
}
.pick:hover, .go:hover, .clear:hover, .reset-dest:hover, .opt-toggle:hover { background: #f1f5f9; }
.reset-dest { color: #475569; }
.go { background: #0f172a; color: #fff; border-color: #0f172a; font-weight: 600; }
.go:disabled { opacity: .5; cursor: default; }
.avoid { margin: 8px 0; display: grid; gap: 3px; }
.avoid strong { display: block; margin-bottom: 2px; }
.go:disabled { opacity: 0.5; cursor: default; }
.cta { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 10px; margin: 6px 0; }
.cta strong { color: #78350f; }
.cta p { margin: 4px 0 8px; color: #92400e; }
.cta-btn { background: #f59e0b; color: #fff; border-color: #d97706; font-weight: 600; }
.dest-pick { margin: 4px 0; }
.dest-search { position: relative; margin-top: 4px; }
.dest-search input { width: 100%; padding: 7px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 0.8rem; box-sizing: border-box; }
.dest-results { position: absolute; top: calc(100% + 2px); left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 6px 16px rgba(0,0,0,.15); list-style: none; margin: 0; padding: 4px; max-height: 220px; overflow: auto; z-index: 50; }
.dest-results li button { width: 100%; text-align: left; background: none; border: none; cursor: pointer; padding: 6px 8px; border-radius: 4px; font-size: 0.78rem; color: #0f172a; }
.dest-results li button:hover { background: #f1f5f9; }
.dest-results .sept { font-size: 0.7rem; color: #94a3b8; padding: 4px 8px 2px; }
.dest-results .sept.loading { font-style: italic; }
.hint { color: #64748b; font-size: 0.75rem; margin: 4px 0; }
.options { margin: 6px 0; }
.opt-toggle { background: #f8fafc; color: #334155; font-weight: 600; }
.opt-body { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px; margin-top: 2px; }
.avoid { display: grid; gap: 3px; margin-bottom: 6px; }
.avoid strong { display: block; margin-bottom: 2px; font-size: 0.72rem; color: #475569; }
.avoid label { display: flex; align-items: center; gap: 6px; }
.row3 { display: flex; gap: 6px; margin: 8px 0; }
.field { flex: 1; }
.field label { display: block; font-size: 0.7rem; color: #64748b; margin-bottom: 2px; }
.field select { width: 100%; padding: 5px; border-radius: 6px; border: 1px solid #cbd5e1; background: #fff; font-size: 0.78rem; }
.mode { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.mode label { font-size: 0.72rem; color: #475569; }
.mode select { padding: 5px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 0.78rem; background: #fff; }
.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 0.15s ease; }
.res { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px; border-radius: 6px; margin-top: 6px; }
.res.warn { background: #fef2f2; border-color: #fecaca; }
.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; }
.warn { color: #b91c1c; }
.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; }
.turns-head { font-weight: 700; font-size: 0.72rem; color: #475569; margin-bottom: 4px; }
.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>

View File

@ -6,6 +6,7 @@
import { geocode, type GeocodedPlace } from '$lib/geocode';
import DirectionsPanel from '$lib/components/DirectionsPanel.svelte';
import { prefs } from '$lib/prefs';
import { route, type RouteGraph, type AvoidRule, type RouteResult } from '$lib/routing';
type FeatureCollection = {
type: string;
@ -64,6 +65,12 @@
// --- preferences dialog state ---
let showPrefs = $state(false);
let computingRoute = $state(false);
// --- route graph + POIs loaded from a dump layer (for directions) ---
let routeGraph = $state<RouteGraph | null>(null);
let routePois = $state<GeoJSON.Feature[]>([]);
let routeLoading = $state(false);
let routeProgress = $state(0);
let graphLabel = $derived(dumpLayers[0]?.label ?? '');
// --- search state ---
const searchables: Searchable[] = [];
let query = $state('');
@ -149,6 +156,45 @@
}
}
// Load the routable graph + POIs from the first dump layer on demand.
async function loadRouteGraph(fresh = false) {
if ((routeGraph && !fresh) || routeLoading) return;
const layer = dumpLayers[0];
if (!layer || !layer.graphPath || !layer.poiPath) { routeGraph = null; return; }
routeLoading = true;
try {
const [g, p] = await Promise.all([fetch(layer.graphPath), fetch(layer.poiPath)]);
if (!g.ok) throw new Error(`graph HTTP ${g.status}`);
if (!p.ok) throw new Error(`poi HTTP ${p.status}`);
routeGraph = await g.json();
const pc: { features: GeoJSON.Feature[] } = await p.json();
routePois = pc.features;
} catch (e) {
routeGraph = null;
console.warn('route graph load failed:', e);
} finally {
routeLoading = false;
}
}
// Run a route. Task 4 replaces this with a Web Worker call; for now a
// synchronous in-thread computation keeps the panel functional.
async function runRoute(
from: [number, number],
to: [number, number],
avoid: AvoidRule[]
): Promise<RouteResult> {
try {
if (!routeGraph) await loadRouteGraph();
if (!routeGraph) throw new Error('Road data layer is not available.');
computingRoute = true;
routeProgress = 1; // sync: no live progress in fallback
return route(routeGraph, from, to, routePois, avoid);
} finally {
computingRoute = false;
}
}
function toggle(name: string) {
const g = layerGroups[name];
if (!g) return;
@ -402,9 +448,19 @@
{/each}
</div>
{#if showDirections && dumpLayers.length}
{#if showDirections && origin}
<div class="dirs-wrap">
<DirectionsPanel {map} dumpLayers={dumpLayers as { name: string; label: string; graphPath: string; poiPath: string }[]} />
<DirectionsPanel
{map}
origin={origin}
hasGraph={!!routeGraph}
graphLabel={graphLabel}
onLoadGraph={() => loadRouteGraph(true)}
searchables={searchables}
progress={routeProgress}
computing={computingRoute}
{runRoute}
/>
</div>
{/if}