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
the watcher — only `denied` does (headless CDP testing made this obvious:
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
modes. **No external map-app handoff anywhere** (product decision) and no
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from

View file

@ -1,6 +1,9 @@
v1.5.3
🧭 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
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

View file

@ -94,6 +94,27 @@ interface RouteDialogProps {
* 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.
*/
/** 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. */
function etaClock(durationS: number): string {
return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], {
@ -322,6 +343,9 @@ export function RouteDialog({
className: 'skatespots-dark-tiles',
}).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
const destIcon = L.divIcon({
className: 'route-dest-icon',
@ -489,8 +513,10 @@ export function RouteDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
<DialogHeader className="p-4 pb-3">
{/* App-like shell: header + modes + map are pinned, the details in the
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">
<Navigation className="h-5 w-5 text-[#ccff00]" />
Route to {destinationLabel}
@ -503,7 +529,7 @@ export function RouteDialog({
</DialogHeader>
{/* 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) => (
<button
key={m.mode}
@ -524,7 +550,7 @@ export function RouteDialog({
</div>
{/* 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">
From
</span>
@ -562,7 +588,7 @@ export function RouteDialog({
`.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to
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
ref={setMapDiv}
className="h-full w-full bg-muted"
@ -659,6 +685,9 @@ export function RouteDialog({
)}
</div>
{/* Scrollable details */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Summary bar */}
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
{error ? (
@ -701,31 +730,35 @@ export function RouteDialog({
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
</div>
) : route ? (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</div>
<div>
<div className="text-xl font-black tabular-nums">
{formatDistance(route.distanceM / 1000)}
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.durationS)}
<span className="ml-1">· arrive ~{etaClock(route.durationS)}</span>
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<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 className="w-full space-y-2">
<div className="grid grid-cols-3 gap-2">
<StatTile label="Distance" value={formatDistance(route.distanceM / 1000)} />
<StatTile
label="Time"
value={formatDuration(route.durationS)}
sub={
mode === 'foot'
? `${skateTime(route.distanceM / 1000)} on a board`
: speedLabel(route.distanceM, route.durationS)
}
/>
<StatTile label="Arrive" value={etaClock(route.durationS)} />
</div>
{routing && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
<p className="flex flex-wrap items-center gap-x-1.5 text-[10px] 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 className="text-sm text-muted-foreground">
@ -734,49 +767,21 @@ export function RouteDialog({
)}
</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. */}
{mode !== 'transit' && route?.steps && route.steps.length > 1 && (
<div className="border-t bg-muted/20">
<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
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{route.steps.length}
</span>
</p>
{mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
)}
</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) => (
<li key={`${i}-${s.instruction}`}>
<button
@ -812,6 +817,40 @@ export function RouteDialog({
</p>
</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>
</Dialog>
);