All checks were successful
CI / test-and-build (push) Successful in 39s
- 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
303 lines
11 KiB
Svelte
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 & 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> |