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:
parent
5a28b3e576
commit
4474cc26e9
3 changed files with 115 additions and 63 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue