All checks were successful
CI / test-and-build (push) Successful in 42s
434 lines
18 KiB
Svelte
434 lines
18 KiB
Svelte
<script lang="ts">
|
|
import type { Map as LeafletMap } from 'leaflet';
|
|
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';
|
|
import type { SavedPoint } from '$lib/savedPoints';
|
|
|
|
interface Props {
|
|
map: LeafletMap;
|
|
/** 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;
|
|
/** True while the road data layer is being loaded on demand. */
|
|
graphLoading?: boolean;
|
|
/** 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>;
|
|
/** Browser-local saved points (selectable as destination). */
|
|
savedPoints?: SavedPoint[];
|
|
/** Controlled destination (set via the context dialog or the picker). */
|
|
destination: { lat: number; lon: number; label?: string } | null;
|
|
/** Called whenever the destination should change. */
|
|
onDestinationChange: (d: { lat: number; lon: number; label?: string } | null) => void;
|
|
}
|
|
|
|
let {
|
|
map,
|
|
origin,
|
|
hasGraph,
|
|
graphLabel = 'the road data layer',
|
|
onLoadGraph,
|
|
graphLoading = false,
|
|
searchables = [],
|
|
progress = 0,
|
|
computing = false,
|
|
runRoute,
|
|
savedPoints = [],
|
|
destination = null,
|
|
onDestinationChange
|
|
}: Props = $props();
|
|
|
|
// --- destination ---
|
|
|
|
// --- 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' },
|
|
{ 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>>({});
|
|
|
|
// --- 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;
|
|
if (ft < 5280) return `${ft < 10 ? ft.toFixed(1) : Math.round(ft)} ft`;
|
|
const mi = m / 1609.344;
|
|
return mi < 10 ? `${mi.toFixed(2)} mi` : `${mi.toFixed(1)} mi`;
|
|
}
|
|
if (m < 1000) return `${m < 10 ? m.toFixed(1) : Math.round(m)} m`;
|
|
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);
|
|
}
|
|
function formatAlt(m: number): string {
|
|
return fmtFor(m, $prefs.system === 'metric' ? 'imperial' : 'metric');
|
|
}
|
|
function formatTime(m: number): string {
|
|
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);
|
|
const min = Math.round(minutes % 60);
|
|
return min ? `${h} h ${min} min` : `${h} h`;
|
|
}
|
|
|
|
// --- destination (set via the context dialog or search/picked below) ---
|
|
function setDestination(lat: number, lon: number, label?: string) {
|
|
onDestinationChange({ lat, lon, label });
|
|
result = null;
|
|
status = 'idle';
|
|
}
|
|
|
|
// --- 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();
|
|
if (!map) return;
|
|
routeLayer = L.layerGroup().addTo(map);
|
|
const drawPath = r.path ?? [];
|
|
if (drawPath.length >= 2) {
|
|
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 })
|
|
.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 (drawPath.length) {
|
|
const b = L.latLngBounds(drawPath.map(([lat, lon]) => [lat, lon] as [number, number]));
|
|
if (map.getZoom() < 14) map.fitBounds(b, { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
// --- 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 {
|
|
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);
|
|
}
|
|
}
|
|
|
|
const canCalculate = $derived(!!origin && !!destination && hasGraph && !computing);
|
|
// Saved points filtered by the destination query (shown inside the results).
|
|
const savedMatches = $derived(() => {
|
|
const q = destQuery.trim().toLowerCase();
|
|
if (!q) return savedPoints.filter((p) => !!p.label); // with a label when not searching
|
|
return savedPoints.filter((p) => {
|
|
const hay = (p.label || `${p.lat}, ${p.lon}`).toLowerCase();
|
|
return hay.includes(q);
|
|
});
|
|
});
|
|
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
|
|
);
|
|
</script>
|
|
|
|
<div class="dirs" role="dialog" aria-label="Directions">
|
|
<div class="dir-head">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 || graphLoading}>
|
|
{graphLoading ? 'Loading road data…' : 'Load road data'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#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={() => { onDestinationChange(null); result = null; status = 'idle'; }}>Change destination</button>
|
|
{:else}
|
|
<div class="dest-pick">
|
|
<div class="dest-note">Click a point on the map, or search below.</div>
|
|
<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 || savedMatches().length)}
|
|
<ul class="dest-results">
|
|
{#if savedMatches().length}
|
|
<li class="sept">Saved points</li>
|
|
{#each savedMatches() as p (p.id)}
|
|
<li><button type="button" onclick={() => setDestination(p.lat, p.lon, p.label || 'Saved point')}>
|
|
<span class="saved-dot"></span>{p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`}
|
|
</button></li>
|
|
{/each}
|
|
{/if}
|
|
{#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>
|
|
{#if savedPoints.length && !destQuery.trim()}
|
|
<div class="dest-saved">
|
|
<span class="sept-label">— or a saved point —</span>
|
|
{#each savedPoints as p (p.id)}
|
|
<button type="button" class="saved-opt" onclick={() => setDestination(p.lat, p.lon, p.label || 'Saved point')}>
|
|
<span class="saved-dot"></span>{p.label || `${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</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>
|
|
|
|
<!-- 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 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 turns}
|
|
<div class="turns">
|
|
<div class="turns-head"><span>Turn-by-turn</span></div>
|
|
<ol class="turnlist">
|
|
{#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">{turns.list.length} steps · {formatDistance(turns.total)} total</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<button class="clear" onclick={() => clearRoute()}>Clear route</button>
|
|
{/if}
|
|
{/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, 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%; flex: none; }
|
|
.dot.a { background: #16a34a; } .dot.b { background: #dc2626; }
|
|
.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;
|
|
}
|
|
.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: 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; }
|
|
.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; }
|
|
.warn { color: #b91c1c; }
|
|
.dest-saved { margin-top: 6px; border-top: 1px solid #e2e8f0; padding-top: 6px; display: grid; gap: 3px; }
|
|
.sept-label { font-size: 0.68rem; color: #94a3b8; }
|
|
.saved-opt { width: 100%; text-align: left; background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 4px; font-size: 0.76rem; color: #0f172a; display: flex; align-items: center; gap: 6px; }
|
|
.saved-opt:hover { background: #f1f5f9; }
|
|
.saved-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b5cf6; flex: none; }
|
|
.dest-note { color: #64748b; font-size: 0.75rem; margin: 4px 0; }
|
|
.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 { 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> |