navigator/src/lib/components/DirectionsPanel.svelte
hermes-explorigin db292409f1
All checks were successful
CI / test-and-build (push) Successful in 1m3s
Add OSM data-dump ingestion + in-browser routing with POI avoidance
- scripts/osm-dump.mjs: download & stream-parse regional .osm.pbf extracts
  (Geofabrik/BBBike etc). Clips to bbox, extracts POI features (poiTags),
  and builds a routable road graph (coords + weighted adj) from road ways.
  Caches the downloaded .pbf under scripts/.cache/ (git-ignored). Default:
  Geofabrik Oklahoma extract -> tulsa-dump layer (12.8k POIs, 55k-node graph).
- src/lib/routing.ts: client-side A* routing over the graph. Snaps A/B to
  nearest nodes, supports avoid-rules (category + radius, block or penalty)
  built from nearby POIs; returns waypoints + distance.
- src/lib/components/DirectionsPanel.svelte: Directions UI — pick A/B by
  clicking the map, choose POI categories to avoid, compute & draw the route.
- MapView: adds a Directions toggle when a dump layer with a graph is present.
- package.json: add 'build:dump'; build now runs data + dump + static.
- scripts/test.mjs: validate dump manifest entries + routing graph structure.
- README: document dump ingestion and the routing/avoid feature.
2026-08-09 15:19:35 +00:00

190 lines
6.9 KiB
Svelte

<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';
interface Props {
map: LeafletMap;
/** Dump sources that carry a graphPath (from the manifest). */
dumpLayers: { name: string; label: string; graphPath: string; poiPath: string }[];
}
let { map, dumpLayers = [] }: Props = $props();
let graph = $state<RouteGraph | null>(null);
let pois = $state<GeoJSON.Feature[]>([]);
let loadingData = $state(false);
let loadError = $state<string | null>(null);
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 picking: 'from' | 'to' | null = $state(null);
let routeLayer: L.LayerGroup | null = null;
const CATEGORIES = [
{ key: 'school', value: 'school', label: 'Schools' },
{ key: 'fire_station', value: 'fire_station', label: 'Fire stations' },
{ key: 'hospital', value: 'hospital', label: 'Hospitals' },
{ key: 'fuel', value: 'fuel', label: 'Fuel stations' },
{ key: 'parking', value: 'parking', label: 'Parking' },
{ key: 'restaurant', value: 'restaurant', label: 'Restaurants' }
];
const selected = $state<Record<string, boolean>>({});
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;
} catch (e) {
loadError = (e as Error).message;
}
loadingData = false;
}
// Replace the map click handler when picking A/B.
$effect(() => {
if (!map || !picking) 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();
};
map.on('click', onClick);
return () => map.off('click', onClick);
});
function drawEndpoints() {
// Recompute/refresh route if both set.
if (from && to && !computing) compute();
}
function clearRoute() {
if (routeLayer) { routeLayer.remove(); routeLayer = null; }
result = null;
}
async function drawResult(r: RouteResult) {
const L = (await import('leaflet')).default;
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);
}
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 (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
}
}
async function compute() {
if (!graph || !from || !to) return;
computing = true;
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);
} finally {
computing = false;
}
}
onMount(loadData);
</script>
<div class="dirs">
<div class="dir-head">Directions</div>
{#if loadError}
<div class="err">{loadError}</div>
{:else if !graph}
<div class="hint">{loadingData ? 'Loading road network…' : 'No network source.'}</div>
{/if}
{#if graph}
{#if from}
<div class="point"><span class="dot a"></span> A · {from[0].toFixed(4)}, {from[1].toFixed(4)}</div>
{: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>
{#if from && to}
<button class="go" onclick={() => compute()} disabled={computing}>
{computing ? 'Computing…' : 'Compute route'}
</button>
{/if}
{#if result}
{#if result.found}
<div class="res">
Distance: <strong>{(result.distanceM / 1000).toFixed(2)} km</strong>
<br /><em>{result.nodesVisited.toLocaleString()} nodes searched</em>
</div>
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
{:else}
<div class="res warn">No route found (blocked or disconnected).</div>
{/if}
{/if}
<div class="hint small">Set A &amp; B by clicking the map, pick categories to avoid, then compute.</div>
{/if}
</div>
<style>
.dirs { background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.25); padding: 12px; width: 250px; font: 0.8rem system-ui, sans-serif; color: #0f172a; }
.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.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; }
.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; }
.avoid label { display: flex; align-items: center; gap: 6px; }
.res { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px; border-radius: 6px; margin-top: 6px; }
.res em { display: block; color: #64748b; font-size: 0.7rem; margin-top: 2px; }
.res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.err { color: #b91c1c; margin: 6px 0; }
.hint { color: #64748b; }
.hint.small { font-size: 0.7rem; margin-top: 6px; color: #94a3b8; }
</style>