feat(directions): rework panel to destination-picker + collapsed route options; graph loaded on demand with sync fallback
This commit is contained in:
parent
5e85c15798
commit
62ddbf160c
@ -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;
|
||||
// --- destination by map click ---
|
||||
function startPickingDest() {
|
||||
pickingDest = true;
|
||||
destHint = 'Click the map to choose the destination…';
|
||||
}
|
||||
loadingData = false;
|
||||
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,177 +196,214 @@
|
||||
}
|
||||
}
|
||||
|
||||
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)'}
|
||||
<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}
|
||||
|
||||
{#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)'}
|
||||
<!-- Route options (collapsed) -->
|
||||
<div class="options">
|
||||
<button class="opt-toggle" onclick={() => (optionsOpen = !optionsOpen)} aria-expanded={optionsOpen}>
|
||||
Route options {optionsOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#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="row3">
|
||||
<div class="field">
|
||||
<label for="dir-mode">Mode</label>
|
||||
<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 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}
|
||||
{/if}
|
||||
|
||||
{#if result}
|
||||
{#if result.found}
|
||||
{#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>
|
||||
{#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>
|
||||
<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>
|
||||
{: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 turnsShown}
|
||||
{#if turns}
|
||||
<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}
|
||||
<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 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 & 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;
|
||||
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; }
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user