feat(directions): make the route dialog usable as guidance, not just a preview
Answering 'are we giving the best navigation experience?': no, it was a preview with dead ends. Fixes the concrete gaps: - ETA: 'arrive ~08:10 PM' alongside the duration. - Mode memory: the last travel mode is remembered (skatespots:route-mode), so cyclists/drivers aren't reset to Skate/Walk every trip. - Tappable steps: each step pans the map and highlights; added a 'Whole route' re-fit button to zoom back out. - Taller route map on desktop (h-64 sm:h-80). - min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making every step a 44px slab (rows now 29px). Still out of scope (documented, not faked): live turn-by-turn following, voice guidance and offline routing.
This commit is contained in:
parent
e69bace784
commit
59e5829b2a
3 changed files with 93 additions and 14 deletions
|
|
@ -201,7 +201,14 @@ session with **no other memory of this project**. So:
|
|||
last-resort fallback and its result is flagged `approximate` ("driving
|
||||
estimate") so we never pass driving numbers off as walking. Moto≈car is
|
||||
labelled honestly; the foot summary also shows a ~10 km/h `skateTime` cruise
|
||||
estimate ("~6 min on a board") next to the walking time. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
||||
estimate ("~6 min on a board") next to the walking time. The dialog also
|
||||
remembers the last mode (`localStorage: skatespots:route-mode`), shows an ETA
|
||||
("arrive ~08:10 PM"), and each step row is **tappable** (pans the map + highlights)
|
||||
with a "Whole route" re-fit button.
|
||||
- ⚠️ `index.css` has a global mobile rule `button, [role=button] { min-height:
|
||||
44px; min-width: 44px }` for touch targets. Any dense list of `<button>` rows
|
||||
(like route steps) must add **`min-h-0`** or every row becomes a 44px slab —
|
||||
a class beats the element selector, so `min-h-0` is enough. **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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ v1.5.2
|
|||
different routes and times (walking 13 min vs biking 5 vs driving 2 on the same
|
||||
trip) instead of one number copy-pasted across every mode.
|
||||
- Added turn-by-turn route steps for orientation — the actual turns and street
|
||||
names, not just a line on a map.
|
||||
names, not just a line on a map. Tap any step to jump to it on the map, and use
|
||||
"Whole route" to zoom back out.
|
||||
- Shows an arrival time ("arrive ~08:10 PM") and remembers the mode you picked
|
||||
last time, so you're not sent back to walking on every trip.
|
||||
- Skating gets its own honest estimate ("~6 min cruise on a board") next to the
|
||||
walking time, and moto is clearly labelled as using driving roads.
|
||||
- Opens instantly from your last known location; "Near Me" sorts by distance.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ 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.
|
||||
*/
|
||||
/** Local clock time you'd arrive if you set off now. */
|
||||
function etaClock(durationS: number): string {
|
||||
return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function RouteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -104,13 +112,44 @@ export function RouteDialog({
|
|||
const destMarkerRef = useRef<L.Marker | null>(null);
|
||||
|
||||
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [mode, setMode] = useState<TravelMode>('foot');
|
||||
// Remember the last mode used — a cyclist or driver shouldn't be reset to
|
||||
// "Skate / Walk" every time they open Directions.
|
||||
const [mode, setMode] = useState<TravelMode>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('skatespots:route-mode') as TravelMode | null;
|
||||
return saved && TRAVEL_MODES.some((m) => m.mode === saved) ? saved : 'foot';
|
||||
} catch {
|
||||
return 'foot';
|
||||
}
|
||||
});
|
||||
const chooseMode = useCallback((next: TravelMode) => {
|
||||
setMode(next);
|
||||
try {
|
||||
localStorage.setItem('skatespots:route-mode', next);
|
||||
} catch {
|
||||
// ignore (private mode / quota)
|
||||
}
|
||||
}, []);
|
||||
/** Which step the user tapped (highlights + pans the map). */
|
||||
const [activeStep, setActiveStep] = useState<number | null>(null);
|
||||
const [route, setRoute] = useState<RouteResult | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [routing, setRouting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [locError, setLocError] = useState<string | null>(null);
|
||||
|
||||
/** Re-fit the map to the whole route (after tapping a step pans it away). */
|
||||
const fitRoute = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !route || route.coordinates.length < 2) return;
|
||||
map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 });
|
||||
}, [route]);
|
||||
|
||||
// A new route (mode/origin change) invalidates the tapped-step focus.
|
||||
useEffect(() => {
|
||||
setActiveStep(null);
|
||||
}, [route]);
|
||||
|
||||
// ─── Get the user's location when the dialog opens ─────────────────────────
|
||||
const locate = useCallback(async () => {
|
||||
setLocating(true);
|
||||
|
|
@ -322,7 +361,7 @@ export function RouteDialog({
|
|||
<button
|
||||
key={m.mode}
|
||||
type="button"
|
||||
onClick={() => setMode(m.mode)}
|
||||
onClick={() => chooseMode(m.mode)}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
|
||||
mode === m.mode
|
||||
|
|
@ -341,7 +380,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">
|
||||
<div className="relative h-64 sm:h-80">
|
||||
<div
|
||||
ref={setMapDiv}
|
||||
className="h-full w-full bg-muted"
|
||||
|
|
@ -379,6 +418,19 @@ export function RouteDialog({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Re-fit the whole route — handy after tapping a step pans the map. */}
|
||||
{route && route.coordinates.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fitRoute}
|
||||
className="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
|
||||
aria-label="Fit the whole route on the map"
|
||||
>
|
||||
<LocateFixed className="h-3.5 w-3.5" />
|
||||
Whole route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary bar */}
|
||||
|
|
@ -434,6 +486,7 @@ export function RouteDialog({
|
|||
</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>
|
||||
)}
|
||||
|
|
@ -468,16 +521,32 @@ export function RouteDialog({
|
|||
</div>
|
||||
<ol className="max-h-56 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
|
||||
{route.steps.map((s, i) => (
|
||||
<li key={`${i}-${s.instruction}`} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
|
||||
{s.distanceM >= 10 && (
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70">
|
||||
{formatDistance(s.distanceM / 1000)}
|
||||
<li key={`${i}-${s.instruction}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveStep(i);
|
||||
const map = mapRef.current;
|
||||
if (map && s.lat && s.lng) map.panTo([s.lat, s.lng]);
|
||||
}}
|
||||
className={cn(
|
||||
// `min-h-0` overrides the app-wide 44px mobile touch-target
|
||||
// rule for `button`, which otherwise made every step row a
|
||||
// giant 44px slab.
|
||||
'flex min-h-0 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors',
|
||||
activeStep === i ? 'bg-[#ccff00]/10 text-white' : 'hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
|
||||
{s.distanceM >= 10 && (
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/70">
|
||||
{formatDistance(s.distanceM / 1000)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
|
|||
Loading…
Reference in a new issue