Directions: smart distance units (m/km auto + toggle) and travel-time estimate
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:
hermes-explorigin 2026-08-09 15:31:59 +00:00
parent db292409f1
commit 4b00704735
2 changed files with 74 additions and 6 deletions

View File

@ -229,7 +229,11 @@ button appears in the top bar. Opening it lets you:
- **Set start (A)** and **end (B)** by clicking the map. - **Set start (A)** and **end (B)** by clicking the map.
- **Avoid certain POI categories** along the route (schools, fire stations, - **Avoid certain POI categories** along the route (schools, fire stations,
fuel stations, parking, restaurants, …). 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 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 A*-search over the graph built from the OSM dump. Point A/B are snapped to the

View File

@ -18,10 +18,16 @@
let from = $state<[number, number] | null>(null); let from = $state<[number, number] | null>(null);
let to = $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 computing = $state(false);
let picking: 'from' | 'to' | null = $state(null); 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; let routeLayer: L.LayerGroup | null = null;
const CATEGORIES = [ const CATEGORIES = [
@ -34,6 +40,29 @@
]; ];
const selected = $state<Record<string, boolean>>({}); 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() { async function loadData() {
if (loadingData || graph) return; if (loadingData || graph) return;
loadingData = true; loadingData = true;
@ -145,6 +174,24 @@
{/each} {/each}
</div> </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} {#if from && to}
<button class="go" onclick={() => compute()} disabled={computing}> <button class="go" onclick={() => compute()} disabled={computing}>
{computing ? 'Computing…' : 'Compute route'} {computing ? 'Computing…' : 'Compute route'}
@ -154,8 +201,18 @@
{#if result} {#if result}
{#if result.found} {#if result.found}
<div class="res"> <div class="res">
Distance: <strong>{(result.distanceM / 1000).toFixed(2)} km</strong> <div class="resline">
<br /><em>{result.nodesVisited.toLocaleString()} nodes searched</em> <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> </div>
<button class="clear" onclick={() => clearRoute()}>Clear route</button> <button class="clear" onclick={() => clearRoute()}>Clear route</button>
{:else} {:else}
@ -168,7 +225,7 @@
</div> </div>
<style> <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; } .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; } .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%; }
@ -181,8 +238,15 @@
.avoid { margin: 8px 0; display: grid; gap: 3px; } .avoid { margin: 8px 0; display: grid; gap: 3px; }
.avoid strong { display: block; margin-bottom: 2px; } .avoid strong { display: block; margin-bottom: 2px; }
.avoid label { display: flex; align-items: center; gap: 6px; } .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 { 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; } .res.warn { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.err { color: #b91c1c; margin: 6px 0; } .err { color: #b91c1c; margin: 6px 0; }
.hint { color: #64748b; } .hint { color: #64748b; }