feat(directions): bigger map + app-like layout and richer route details

- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
  Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
  ONLY scroll region, and Start/Stop pinned at the bottom so it's always
  reachable. Previously the dialog was a single overflow-hidden box, so once the
  map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
  step count, honest 'uses driving roads'/'driving estimate' notes, and the
  average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
  estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).

Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
This commit is contained in:
SkateSpots 2026-09-27 19:42:06 +07:00
parent 5a28b3e576
commit 4474cc26e9
3 changed files with 115 additions and 63 deletions

View file

@ -221,7 +221,17 @@ session with **no other memory of this project**. So:
transient watch error (timeout/temporarily unavailable) must **not** tear down transient watch error (timeout/temporarily unavailable) must **not** tear down
the watcher — only `denied` does (headless CDP testing made this obvious: the watcher — only `denied` does (headless CDP testing made this obvious:
moving the mocked position emits a transient error that recovered). moving the mocked position emits a transient error that recovered).
Still deliberately absent: voice guidance and re-routing if you leave the route. **Transit** mode uses **Transitous** (`api.transitous.org`, Still deliberately absent: voice guidance and re-routing if you leave the route.
- **Dialog layout (2026-09-27):** app-like flex column —
`flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`: header, mode row,
"From" row and the **map** are `shrink-0` (map `h-[38vh] sm:h-[400px]`), the
middle (stat tiles + steps) is the only scroll region (`min-h-0 flex-1
overflow-y-auto`), and the **Start/Stop bar is pinned** at the bottom so it's
always reachable. ⚠️ Don't go back to a single `overflow-hidden` content box:
that made everything below the fold **unreachable** once the map grew. The
summary is three `StatTile`s (distance / time / arrive) + a detail line
(steps, driving-roads note, avg speed) and the map carries a metric
`L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`,
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no modes. **No external map-app handoff anywhere** (product decision) and no
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from

View file

@ -1,6 +1,9 @@
v1.5.3 v1.5.3
🧭 Real navigation, not just a route preview 🧭 Real navigation, not just a route preview
- A much bigger map (with a metric scale bar), and the plan now reads at a
glance: distance, time and arrival time as clear stats, plus step count and
the average speed the route assumes. Start/Stop stays pinned at the bottom.
- Travel modes finally give genuinely different answers: walking 13 min, biking - Travel modes finally give genuinely different answers: walking 13 min, biking
5, driving 2 on the same trip. Before, every mode showed the same number. 5, driving 2 on the same trip. Before, every mode showed the same number.
- Route steps — the actual turns and street names. Tap a step to jump to it on - Route steps — the actual turns and street names. Tap a step to jump to it on

View file

@ -94,6 +94,27 @@ interface RouteDialogProps {
* maps app is opened — the route comes from the keyless public OSRM server and * maps app is opened — the route comes from the keyless public OSRM server and
* is drawn here. See src/lib/routing.ts for the honest scope notes. * is drawn here. See src/lib/routing.ts for the honest scope notes.
*/ */
/** Big number + label tile used in the route summary. */
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-2 py-2.5 text-center">
<p className="text-lg font-black leading-tight tabular-nums text-white">{value}</p>
<p className="mt-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
{sub && <p className="mt-0.5 text-[10px] leading-tight text-muted-foreground/70">{sub}</p>}
</div>
);
}
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
function speedLabel(distanceM: number, durationS: number): string | undefined {
if (!(distanceM > 0) || !(durationS > 0)) return undefined;
const kmh = distanceM / 1000 / (durationS / 3600);
if (!Number.isFinite(kmh) || kmh <= 0) return undefined;
return `${kmh < 10 ? kmh.toFixed(1) : Math.round(kmh)} km/h avg`;
}
/** Local clock time you'd arrive if you set off now. */ /** Local clock time you'd arrive if you set off now. */
function etaClock(durationS: number): string { function etaClock(durationS: number): string {
return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], { return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], {
@ -322,6 +343,9 @@ export function RouteDialog({
className: 'skatespots-dark-tiles', className: 'skatespots-dark-tiles',
}).addTo(map); }).addTo(map);
// Metric scale bar — genuinely useful on a route map (how long is that line?).
L.control.scale({ imperial: false, position: 'topleft' }).addTo(map);
// Destination marker // Destination marker
const destIcon = L.divIcon({ const destIcon = L.divIcon({
className: 'route-dest-icon', className: 'route-dest-icon',
@ -489,8 +513,10 @@ export function RouteDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden"> {/* App-like shell: header + modes + map are pinned, the details in the
<DialogHeader className="p-4 pb-3"> middle scroll, and the Start/Stop bar stays reachable at the bottom. */}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="shrink-0 p-4 pb-3">
<DialogTitle className="flex items-center gap-2 text-lg"> <DialogTitle className="flex items-center gap-2 text-lg">
<Navigation className="h-5 w-5 text-[#ccff00]" /> <Navigation className="h-5 w-5 text-[#ccff00]" />
Route to {destinationLabel} Route to {destinationLabel}
@ -503,7 +529,7 @@ export function RouteDialog({
</DialogHeader> </DialogHeader>
{/* Mode toggle */} {/* Mode toggle */}
<div className="px-4 pb-3 flex gap-1.5"> <div className="flex shrink-0 gap-1.5 px-4 pb-3">
{TRAVEL_MODES.map((m) => ( {TRAVEL_MODES.map((m) => (
<button <button
key={m.mode} key={m.mode}
@ -524,7 +550,7 @@ export function RouteDialog({
</div> </div>
{/* Start point — default is where you are; you can pick another on the map */} {/* Start point — default is where you are; you can pick another on the map */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-3"> <div className="flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
From From
</span> </span>
@ -562,7 +588,7 @@ export function RouteDialog({
`.leaflet-container { height: 100% }` override beats a `h-64` on the `.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to map element itself, and against a height-less parent that resolved to
0 — a fully invisible map with no route line. */} 0 — a fully invisible map with no route line. */}
<div className="relative h-64 sm:h-80"> <div className="relative h-[38vh] min-h-[220px] shrink-0 sm:h-[400px]">
<div <div
ref={setMapDiv} ref={setMapDiv}
className="h-full w-full bg-muted" className="h-full w-full bg-muted"
@ -659,6 +685,9 @@ export function RouteDialog({
)} )}
</div> </div>
{/* Scrollable details */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Summary bar */} {/* Summary bar */}
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center"> <div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
{error ? ( {error ? (
@ -701,31 +730,35 @@ export function RouteDialog({
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p> <p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
</div> </div>
) : route ? ( ) : route ? (
<div className="flex items-center justify-between w-full"> <div className="w-full space-y-2">
<div className="flex items-center gap-3"> <div className="grid grid-cols-3 gap-2">
<div className="text-2xl leading-none"> <StatTile label="Distance" value={formatDistance(route.distanceM / 1000)} />
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji} <StatTile
</div> label="Time"
<div> value={formatDuration(route.durationS)}
<div className="text-xl font-black tabular-nums"> sub={
{formatDistance(route.distanceM / 1000)} mode === 'foot'
</div> ? `${skateTime(route.distanceM / 1000)} on a board`
<div className="text-xs text-muted-foreground"> : speedLabel(route.distanceM, route.durationS)
{formatDuration(route.durationS)} }
<span className="ml-1">· arrive ~{etaClock(route.durationS)}</span> />
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( <StatTile label="Arrive" value={etaClock(route.durationS)} />
<span className="ml-1">· uses driving roads</span>
)}
{route.approximate && <span className="ml-1">· driving estimate</span>}
{mode === 'foot' && (
<span className="ml-1">· {skateTime(route.distanceM / 1000)} on a board</span>
)}
</div>
</div>
</div> </div>
{routing && ( <p className="flex flex-wrap items-center gap-x-1.5 text-[10px] text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <span className="text-base leading-none">
)} {TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</span>
<span>
{route.steps && route.steps.length > 1
? `${route.steps.length} steps`
: 'direct'}
</span>
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span>· uses driving roads</span>
)}
{route.approximate && <span>· driving estimate</span>}
{routing && <span>· updating…</span>}
</p>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@ -734,49 +767,21 @@ export function RouteDialog({
)} )}
</div> </div>
{/* Follow mode: explicit, and honest that there's no voice guidance. */}
{route && !route.transit && (
<div className="flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{following ? (
<Button
size="sm"
variant="outline"
onClick={stopFollowing}
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
>
<Square className="h-3.5 w-3.5" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={startFollowing}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Play className="h-3.5 w-3.5" />
Start
</Button>
)}
<p className="min-w-0 flex-1 text-[10px] leading-snug text-muted-foreground">
{following
? 'Following your position and the next turn. No voice guidance.'
: 'Follows your position and shows the next turn as you roll.'}
</p>
</div>
)}
{/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */} {/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */}
{mode !== 'transit' && route?.steps && route.steps.length > 1 && ( {mode !== 'transit' && route?.steps && route.steps.length > 1 && (
<div className="border-t bg-muted/20"> <div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3"> <div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground"> <p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Route steps Route steps
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{route.steps.length}
</span>
</p> </p>
{mode === 'foot' && ( {mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p> <p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
)} )}
</div> </div>
<ol className="max-h-56 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5"> <ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
{route.steps.map((s, i) => ( {route.steps.map((s, i) => (
<li key={`${i}-${s.instruction}`}> <li key={`${i}-${s.instruction}`}>
<button <button
@ -812,6 +817,40 @@ export function RouteDialog({
</p> </p>
</div> </div>
)} )}
</div>{/* /scrollable details */}
{/* Follow mode: explicit, and honest that there's no voice guidance. */}
{route && !route.transit && (
<div className="flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{following ? (
<Button
size="sm"
variant="outline"
onClick={stopFollowing}
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
>
<Square className="h-3.5 w-3.5" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={startFollowing}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Play className="h-3.5 w-3.5" />
Start
</Button>
)}
<p className="min-w-0 flex-1 text-[10px] leading-snug text-muted-foreground">
{following
? 'Following your position and the next turn. No voice guidance.'
: 'Follows your position and shows the next turn as you roll.'}
</p>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );