navigator/src/lib/components/DirectionsPanel.svelte
hermes-explorigin 8c2e7eb6ab
All checks were successful
CI / test-and-build (push) Successful in 39s
Directions: add Imperial unit system (feet/miles) alongside metric
- Add a System selector (Metric/Imperial) to the Directions panel; Units
  dropdown adapts (m/km vs ft/mi) with a smart Auto for each system
- Auto: short routes show the small unit (m or ft), long routes the large
  one (km or mi); shows the alternate unit as a sub-line
- README: document the unit system + units options
2026-08-09 16:01:47 +00:00

303 lines
11 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);
// 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.
let mode = $state<'drive' | 'walk'>('drive');
const SPEEDS = { drive: 45, walk: 4.8 }; // km/h
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>>({});
// Format a metric distance (meters) into the chosen unit system.
function formatDistance(m: number): string {
// Determine effective unit.
if (system === 'imperial') {
const ft = m * 3.28084;
const mi = m / 1609.344;
const useFt = unit === 'ft' || (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);
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;
return null;
} else {
if (unit === 'ft') return `${(m / 1609.344).toFixed(2)} mi`;
if (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;
return m >= 160.934 ? `${Math.round(m * 3.28084)} ft` : null;
}
function ftAlt(m: number): string | null {
const ft = m * 3.28084;
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;
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);
const min = Math.round(minutes % 60);
return min ? `${h} h ${min} min` : `${h} h`;
}
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>
<div class="row3">
<div class="field">
<label for="dir-system">System</label>
<select id="dir-system" bind:value={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}>
<option value="auto">Auto</option>
{#if 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="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>
</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">
<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>
</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: 268px; 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; }
.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; }
.res { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 8px; border-radius: 6px; margin-top: 6px; }
.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; }
.mute { color: #94a3b8; font-size: 0.7rem; }
.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>