Directions: smart distance units (m/km auto + toggle) and travel-time estimate
All checks were successful
CI / test-and-build (push) Successful in 39s
All checks were successful
CI / test-and-build (push) Successful in 39s
- DirectionsPanel: unit selector (Auto = m under 1km else km, or force m/km) and a Drive/Walk mode that estimates travel time from distance + avg speed - Distance shows meters for short routes and km for longer ones; est. time in seconds/minutes/hours as appropriate - README: document the new units and travel-time options
This commit is contained in:
parent
db292409f1
commit
4b00704735
@ -229,7 +229,11 @@ button appears in the top bar. Opening it lets you:
|
||||
- **Set start (A)** and **end (B)** by clicking the map.
|
||||
- **Avoid certain POI categories** along the route (schools, fire stations,
|
||||
fuel stations, parking, restaurants, …).
|
||||
- **Compute** a route and **draw it** on the map, with total driving distance.
|
||||
- **Compute** a route and **draw it** on the map.
|
||||
- Choose **distance units** (`m` / `km`, or `Auto` — meters under 1 km,
|
||||
kilometers otherwise) and a **travel mode** (Drive or Walk).
|
||||
- See the route's **distance** and an **estimated travel time** (computed from
|
||||
the mode's average speed).
|
||||
|
||||
Routing is done **entirely in the browser** (`src/lib/routing.ts`): an
|
||||
A*-search over the graph built from the OSM dump. Point A/B are snapped to the
|
||||
|
||||
@ -18,10 +18,16 @@
|
||||
|
||||
let from = $state<[number, number] | null>(null);
|
||||
let to = $state<[number, number] | null>(null);
|
||||
let result = $state<RouteResult | null>(null);
|
||||
let result = $state<RouteResult | null>(null);
|
||||
let computing = $state(false);
|
||||
let picking: 'from' | 'to' | null = $state(null);
|
||||
|
||||
// Units for distance output: 'auto' (m if < 1km else km), 'm', or 'km'.
|
||||
let unit = $state<'auto' | 'm' | 'km'>('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 = [
|
||||
@ -34,6 +40,29 @@
|
||||
];
|
||||
const selected = $state<Record<string, boolean>>({});
|
||||
|
||||
// Format distance in meters/km according to the selected unit.
|
||||
function formatDistance(m: number): string {
|
||||
if (unit === 'm' || (unit === 'auto' && m < 1000)) {
|
||||
const v = m < 10 ? m.toFixed(1) : Math.round(m).toString();
|
||||
return `${v} m`;
|
||||
}
|
||||
// km
|
||||
const km = m / 1000;
|
||||
const v = km < 10 ? km.toFixed(2) : km.toFixed(1);
|
||||
return `${v} km`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@ -145,6 +174,24 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label for="dir-unit">Units</label>
|
||||
<select id="dir-unit" bind:value={unit}>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="m">m</option>
|
||||
<option value="km">km</option>
|
||||
</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'}
|
||||
@ -154,8 +201,18 @@
|
||||
{#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 class="resline">
|
||||
<span class="lab">Distance</span>
|
||||
<strong>{formatDistance(result.distanceM)}</strong>
|
||||
</div>
|
||||
{#if result.distanceM >= 1000 && unit === 'km'}
|
||||
<div class="mute">({Math.round(result.distanceM)} m)</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}
|
||||
@ -168,7 +225,7 @@
|
||||
</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; }
|
||||
.dirs { background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,.25); padding: 12px; width: 260px; 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%; }
|
||||
@ -181,8 +238,15 @@
|
||||
.avoid { margin: 8px 0; display: grid; gap: 3px; }
|
||||
.avoid strong { display: block; margin-bottom: 2px; }
|
||||
.avoid label { display: flex; align-items: center; gap: 6px; }
|
||||
.row2 { display: flex; gap: 8px; 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.8rem; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user