feat(directions): follow mode, choose-your-start, offline route cache
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.
Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.
Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.
Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.
Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
This commit is contained in:
parent
59e5829b2a
commit
5a819d5a31
5 changed files with 499 additions and 19 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -208,7 +208,20 @@ session with **no other memory of this project**. So:
|
|||
- ⚠️ `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`,
|
||||
a class beats the element selector, so `min-h-0` is enough.
|
||||
- **Follow mode / start point / offline (2026-09-27):** the dialog also does
|
||||
**follow mode** (explicit **Start** → `watchPosition` in `geolocation.ts`,
|
||||
live dot, "NEXT · step N" banner with distance-to-turn + distance-to-go,
|
||||
current step highlighted), a **start point** choice (My location / Choose on
|
||||
map — tapping the Leaflet map sets it), and an **offline route cache**
|
||||
(`src/lib/routeCache.ts`, downsamples the line to ≤250 points, keeps the last
|
||||
8 destination+mode entries; the failed fetch falls back to it and shows
|
||||
"Offline · saved route {age}"). Rules: **never auto-start the watcher**
|
||||
(battery — user action only) and always `stop()` it on close/unmount; a
|
||||
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`,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ v1.5.2
|
|||
"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.
|
||||
- Tap Start and it follows you: your dot moves, the current step highlights, and
|
||||
it tells you the next turn and how far is left. (No voice guidance yet.)
|
||||
- Going somewhere else first? "From → Choose on map" lets you set the start
|
||||
point instead of routing from where you're standing.
|
||||
- Lose signal mid-route and it keeps showing your last route — clearly marked
|
||||
as saved, instead of going blank.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } from 'lucide-react';
|
||||
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -10,8 +10,16 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCurrentPosition, GeolocationError } from '@/lib/geolocation';
|
||||
import {
|
||||
getCurrentPosition,
|
||||
watchPosition,
|
||||
GeolocationError,
|
||||
type GeoCoords,
|
||||
type GeoWatch,
|
||||
} from '@/lib/geolocation';
|
||||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
|
||||
import { calculateDistance } from '@/lib/sortOptions';
|
||||
import {
|
||||
fetchRoute,
|
||||
fetchTransitRoute,
|
||||
|
|
@ -132,6 +140,36 @@ export function RouteDialog({
|
|||
}, []);
|
||||
/** Which step the user tapped (highlights + pans the map). */
|
||||
const [activeStep, setActiveStep] = useState<number | null>(null);
|
||||
|
||||
// ── Start point: my location, or a point the user picks on the map ─────────
|
||||
const [startMode, setStartMode] = useState<'me' | 'picked'>('me');
|
||||
const [picked, setPicked] = useState<GeoCoords | null>(null);
|
||||
const [pickingStart, setPickingStart] = useState(false);
|
||||
|
||||
// ── Follow mode (explicit; owns a GPS watcher) ─────────────────────────────
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [live, setLive] = useState<GeoCoords | null>(null);
|
||||
const [followError, setFollowError] = useState<string | null>(null);
|
||||
const watchRef = useRef<GeoWatch | null>(null);
|
||||
|
||||
/** Route shown while offline came from the cache (with its age). */
|
||||
const [cachedAge, setCachedAge] = useState<number | null>(null);
|
||||
|
||||
/** Where the route starts: a picked point wins, else the device location. */
|
||||
const effectiveOrigin = useMemo<GeoCoords | null>(
|
||||
() => (startMode === 'picked' && picked ? picked : origin),
|
||||
[startMode, picked, origin],
|
||||
);
|
||||
|
||||
/** Where to draw the "you are here" dot — live position while following. */
|
||||
const markerPos = useMemo<GeoCoords | null>(
|
||||
() => (following && live ? live : effectiveOrigin),
|
||||
[following, live, effectiveOrigin],
|
||||
);
|
||||
|
||||
// Keep the map-click handler current without rebuilding the map.
|
||||
const pickingStartRef = useRef(false);
|
||||
pickingStartRef.current = pickingStart;
|
||||
const [route, setRoute] = useState<RouteResult | null>(null);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [routing, setRouting] = useState(false);
|
||||
|
|
@ -150,6 +188,75 @@ export function RouteDialog({
|
|||
setActiveStep(null);
|
||||
}, [route]);
|
||||
|
||||
// ── Follow mode ────────────────────────────────────────────────────────────
|
||||
// Explicit only: the user taps Start, we watch their position, and we stop the
|
||||
// watcher the moment they stop or the dialog closes. No voice guidance.
|
||||
const stopFollowing = useCallback(() => {
|
||||
watchRef.current?.stop();
|
||||
watchRef.current = null;
|
||||
setFollowing(false);
|
||||
}, []);
|
||||
|
||||
const startFollowing = useCallback(() => {
|
||||
if (watchRef.current) return;
|
||||
setFollowError(null);
|
||||
setFollowing(true);
|
||||
watchRef.current = watchPosition(
|
||||
(coords) => {
|
||||
setLive(coords);
|
||||
setFollowError(null);
|
||||
},
|
||||
(err) => {
|
||||
// Only a real permission denial stops following. A timeout / temporary
|
||||
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
|
||||
// so we keep running and just say we're waiting.
|
||||
if (err.kind === 'denied') {
|
||||
setFollowError('Location permission denied.');
|
||||
stopFollowing();
|
||||
} else {
|
||||
setFollowError('Waiting for a GPS fix…');
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [stopFollowing]);
|
||||
|
||||
// Never leave the GPS running: stop on close and on unmount.
|
||||
useEffect(() => {
|
||||
if (!open && watchRef.current) stopFollowing();
|
||||
}, [open, stopFollowing]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
watchRef.current?.stop();
|
||||
watchRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// While following, keep the live dot centred.
|
||||
useEffect(() => {
|
||||
if (!following || !live) return;
|
||||
mapRef.current?.panTo([live.lat, live.lng]);
|
||||
}, [following, live]);
|
||||
|
||||
/** Live guidance: distance left, and which step you're on. */
|
||||
const nav = useMemo(() => {
|
||||
if (!live || !destination) return null;
|
||||
const toDestKm = calculateDistance(live.lat, live.lng, destination.lat, destination.lng);
|
||||
let bestI = -1;
|
||||
let bestKm = Infinity;
|
||||
(route?.steps ?? []).forEach((s, i) => {
|
||||
if (!s.lat && !s.lng) return;
|
||||
const km = calculateDistance(live.lat, live.lng, s.lat, s.lng);
|
||||
if (km < bestKm) {
|
||||
bestKm = km;
|
||||
bestI = i;
|
||||
}
|
||||
});
|
||||
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
|
||||
}, [live, destination, route]);
|
||||
|
||||
const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep;
|
||||
|
||||
// ─── Get the user's location when the dialog opens ─────────────────────────
|
||||
const locate = useCallback(async () => {
|
||||
setLocating(true);
|
||||
|
|
@ -191,6 +298,8 @@ export function RouteDialog({
|
|||
if (!open) {
|
||||
setRoute(null);
|
||||
setError(null);
|
||||
setPickingStart(false);
|
||||
setCachedAge(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -226,9 +335,19 @@ export function RouteDialog({
|
|||
|
||||
mapRef.current = map;
|
||||
|
||||
// "Choose on map" — the next tap sets the start point.
|
||||
const onMapClick = (e: L.LeafletMouseEvent) => {
|
||||
if (!pickingStartRef.current) return;
|
||||
setPicked({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
setStartMode('picked');
|
||||
setPickingStart(false);
|
||||
};
|
||||
map.on('click', onMapClick);
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 120);
|
||||
|
||||
return () => {
|
||||
map.off('click', onMapClick);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
routeLayersRef.current = null;
|
||||
|
|
@ -237,13 +356,13 @@ export function RouteDialog({
|
|||
};
|
||||
}, [open, mapDiv, destination]);
|
||||
|
||||
// ─── Draw / update the origin marker when we have the user's location ──────
|
||||
// ─── Draw / update the "from" marker (device location, picked point, live) ──
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !origin) return;
|
||||
if (!map || !markerPos) return;
|
||||
|
||||
if (originMarkerRef.current) {
|
||||
originMarkerRef.current.setLatLng([origin.lat, origin.lng]);
|
||||
originMarkerRef.current.setLatLng([markerPos.lat, markerPos.lng]);
|
||||
} else {
|
||||
const userIcon = L.divIcon({
|
||||
className: 'route-origin-icon',
|
||||
|
|
@ -251,15 +370,15 @@ export function RouteDialog({
|
|||
iconSize: [18, 18],
|
||||
iconAnchor: [9, 9],
|
||||
});
|
||||
originMarkerRef.current = L.marker([origin.lat, origin.lng], {
|
||||
originMarkerRef.current = L.marker([markerPos.lat, markerPos.lng], {
|
||||
icon: userIcon,
|
||||
}).addTo(map);
|
||||
}
|
||||
}, [origin]);
|
||||
}, [markerPos]);
|
||||
|
||||
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
|
||||
useEffect(() => {
|
||||
if (!open || !origin || !destination) return;
|
||||
if (!open || !effectiveOrigin || !destination) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
|
|
@ -269,10 +388,12 @@ export function RouteDialog({
|
|||
setError(null);
|
||||
try {
|
||||
const result = mode === 'transit'
|
||||
? await fetchTransitRoute(origin, destination, controller.signal)
|
||||
: await fetchRoute(origin, destination, mode, controller.signal);
|
||||
? await fetchTransitRoute(effectiveOrigin, destination, controller.signal)
|
||||
: await fetchRoute(effectiveOrigin, destination, mode, controller.signal);
|
||||
if (cancelled) return;
|
||||
setCachedAge(null);
|
||||
setRoute(result);
|
||||
if (mode !== 'transit') saveRoute(destination, mode, result);
|
||||
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
|
|
@ -329,6 +450,32 @@ export function RouteDialog({
|
|||
} else {
|
||||
setError("Couldn't reach the routing service. Check your connection and try again.");
|
||||
}
|
||||
|
||||
// Offline / server down: fall back to the last route we saved for this
|
||||
// destination + mode, clearly labelled, instead of a dead dialog.
|
||||
if (mode !== 'transit') {
|
||||
const hit = loadRoute(destination, mode);
|
||||
if (hit) {
|
||||
setRoute(hit.route);
|
||||
setCachedAge(hit.ageMs);
|
||||
setError(null);
|
||||
const map = mapRef.current;
|
||||
if (map && hit.route.coordinates.length > 1) {
|
||||
if (routeLayersRef.current) routeLayersRef.current.remove();
|
||||
const group = L.layerGroup().addTo(map);
|
||||
L.polyline(hit.route.coordinates, {
|
||||
color: '#ccff00',
|
||||
weight: 5,
|
||||
opacity: 0.7,
|
||||
dashArray: '8 8',
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
}).addTo(group);
|
||||
routeLayersRef.current = group;
|
||||
map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16 });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setRouting(false);
|
||||
}
|
||||
|
|
@ -338,7 +485,7 @@ export function RouteDialog({
|
|||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, origin, destination, mode]);
|
||||
}, [open, effectiveOrigin, destination, mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -376,6 +523,41 @@ 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">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||
From
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStartMode('me');
|
||||
setPickingStart(false);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
startMode === 'me'
|
||||
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
|
||||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
My location
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickingStart((v) => !v)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
pickingStart || startMode === 'picked'
|
||||
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
|
||||
: 'border-white/10 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Crosshair className="h-3 w-3" />
|
||||
{pickingStart ? 'Tap the map…' : 'Choose on map'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map. The height lives on the WRAPPER: the global
|
||||
`.leaflet-container { height: 100% }` override beats a `h-64` on the
|
||||
map element itself, and against a height-less parent that resolved to
|
||||
|
|
@ -399,10 +581,54 @@ export function RouteDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{locating && origin && (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Refining location…
|
||||
{/* Bottom-left stack: offline badge + "refining location" chip. Kept
|
||||
out of the top-left so it never covers Leaflet's zoom control. */}
|
||||
{(cachedAge !== null || (locating && origin)) && (
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
|
||||
{cachedAge !== null && (
|
||||
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
|
||||
Offline · saved route {formatAge(cachedAge)}
|
||||
</div>
|
||||
)}
|
||||
{locating && origin && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Refining location…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Choose on map" hint */}
|
||||
{pickingStart && (
|
||||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
|
||||
Tap the map to set your start point
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow mode: next turn + distance left */}
|
||||
{following && nav && (
|
||||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
|
||||
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
|
||||
</p>
|
||||
<p className="truncate text-sm font-bold text-white">
|
||||
{nav.stepIndex >= 0
|
||||
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
|
||||
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
|
||||
? `${formatDistance(nav.stepKm)} to the turn · `
|
||||
: ''}
|
||||
{formatDistance(nav.toDestKm)} to go
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{followError && (
|
||||
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 text-center text-[11px] font-bold text-amber-300 backdrop-blur-sm">
|
||||
{followError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -424,7 +650,7 @@ export function RouteDialog({
|
|||
<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"
|
||||
className="absolute bottom-2 right-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" />
|
||||
|
|
@ -508,6 +734,37 @@ 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">
|
||||
|
|
@ -534,7 +791,7 @@ export function RouteDialog({
|
|||
// 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',
|
||||
currentStepIndex === 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">
|
||||
|
|
|
|||
|
|
@ -171,6 +171,99 @@ export async function getCurrentPosition(): Promise<GeoCoords> {
|
|||
return coords;
|
||||
}
|
||||
|
||||
export interface GeoWatch {
|
||||
/** Stop watching. Safe to call more than once. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the device position (for "follow me" navigation).
|
||||
*
|
||||
* EXPLICIT ONLY — never call this without a user action: a watcher runs the GPS
|
||||
* continuously and drains battery. The caller owns the `GeoWatch` and must
|
||||
* `stop()` it on unmount / when the user stops following.
|
||||
*
|
||||
* Same platform posture as `getCurrentPosition`: on native we use the plugin
|
||||
* only to make sure the OS permission is granted, then take positions from the
|
||||
* WebView's `navigator.geolocation` (avoids the Play-Services path that crashes
|
||||
* de-Googled ROMs).
|
||||
*/
|
||||
export function watchPosition(
|
||||
onUpdate: (coords: GeoCoords) => void,
|
||||
onError?: (err: GeolocationError) => void,
|
||||
): GeoWatch {
|
||||
let watchId: number | null = null;
|
||||
let stopped = false;
|
||||
|
||||
const start = async () => {
|
||||
const native = getNativeGeo();
|
||||
if (native) {
|
||||
try {
|
||||
const safe = <T>(fn: () => Promise<T>) => Promise.resolve().then(fn);
|
||||
let status = await safe(() => native.checkPermissions()).catch(() => null);
|
||||
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
|
||||
status = await safe(() =>
|
||||
native.requestPermissions({ permissions: ['location', 'coarseLocation'] }),
|
||||
).catch(() => null);
|
||||
}
|
||||
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
|
||||
onError?.(new GeolocationError('denied', 'Location permission denied.'));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Permission bridge failed — fall through and let the WebView try.
|
||||
}
|
||||
}
|
||||
if (stopped) return;
|
||||
|
||||
if (!('geolocation' in navigator)) {
|
||||
onError?.(new GeolocationError('unavailable', 'Geolocation is not supported.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
watchId = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
||||
saveLastLocation(coords.lat, coords.lng);
|
||||
onUpdate(coords);
|
||||
},
|
||||
(err) => {
|
||||
// Distinguish a real denial from a transient one: a watcher commonly
|
||||
// emits TIMEOUT while waiting for the first fix (indoors, tunnels),
|
||||
// then recovers. Callers should NOT tear down the watcher for those.
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
onError?.(new GeolocationError('denied', 'Location permission denied.'));
|
||||
} else if (err.code === err.TIMEOUT) {
|
||||
onError?.(new GeolocationError('timeout', 'Waiting for a GPS fix.'));
|
||||
} else {
|
||||
onError?.(new GeolocationError('unavailable', 'Location temporarily unavailable.'));
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: false, maximumAge: 5000, timeout: 30000 },
|
||||
);
|
||||
} catch {
|
||||
onError?.(new GeolocationError('unavailable', 'Could not start location tracking.'));
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
if (watchId !== null) {
|
||||
try {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
watchId = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native path.
|
||||
*
|
||||
|
|
|
|||
111
src/lib/routeCache.ts
Normal file
111
src/lib/routeCache.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { RouteResult } from '@/lib/routing';
|
||||
|
||||
/**
|
||||
* Tiny localStorage cache of the last few routes.
|
||||
*
|
||||
* Why: routing needs the network. If you lose signal mid-skate (or the routing
|
||||
* server is briefly down), Directions would show nothing at all. Keeping the
|
||||
* last route per destination+mode means we can still show the line, the time and
|
||||
* the steps — clearly labelled as cached — instead of an error.
|
||||
*
|
||||
* The full OSRM geometry can be thousands of points, so the line is
|
||||
* DOWNSAMPLED before storing (a route line at this zoom doesn't need them all).
|
||||
* Steps are kept verbatim — they're what you actually read.
|
||||
*/
|
||||
const KEY = 'skatespots:route-cache:v1';
|
||||
const MAX_ENTRIES = 8;
|
||||
const MAX_POINTS = 250;
|
||||
|
||||
interface StoredRoute {
|
||||
at: number;
|
||||
distanceM: number;
|
||||
durationS: number;
|
||||
steps?: RouteResult['steps'];
|
||||
coordinates: Array<[number, number]>;
|
||||
}
|
||||
|
||||
type Store = Record<string, StoredRoute>;
|
||||
|
||||
function keyFor(to: { lat: number; lng: number }, mode: string): string {
|
||||
return `${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
|
||||
}
|
||||
|
||||
function read(): Store {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as Store) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(store: Store): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// quota / private mode — a cache is a nicety, never an error
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep every Nth point so the stored line stays small. */
|
||||
function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[number, number]> {
|
||||
if (coords.length <= max) return coords;
|
||||
const step = Math.ceil(coords.length / max);
|
||||
const out: Array<[number, number]> = [];
|
||||
for (let i = 0; i < coords.length; i += step) out.push(coords[i]);
|
||||
const last = coords[coords.length - 1];
|
||||
if (out[out.length - 1] !== last) out.push(last);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Remember a freshly-fetched route (best effort). */
|
||||
export function saveRoute(
|
||||
to: { lat: number; lng: number },
|
||||
mode: string,
|
||||
route: RouteResult,
|
||||
): void {
|
||||
if (!route.coordinates?.length) return;
|
||||
const store = read();
|
||||
store[keyFor(to, mode)] = {
|
||||
at: Date.now(),
|
||||
distanceM: route.distanceM,
|
||||
durationS: route.durationS,
|
||||
steps: route.steps,
|
||||
coordinates: downsample(route.coordinates),
|
||||
};
|
||||
// Keep only the newest few destinations so this can never grow unbounded.
|
||||
const newest = Object.entries(store)
|
||||
.sort((a, b) => b[1].at - a[1].at)
|
||||
.slice(0, MAX_ENTRIES);
|
||||
write(Object.fromEntries(newest));
|
||||
}
|
||||
|
||||
/** Read a cached route for this destination+mode, with how old it is. */
|
||||
export function loadRoute(
|
||||
to: { lat: number; lng: number },
|
||||
mode: string,
|
||||
): { route: RouteResult; ageMs: number } | null {
|
||||
const stored = read()[keyFor(to, mode)];
|
||||
if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null;
|
||||
return {
|
||||
route: {
|
||||
coordinates: stored.coordinates,
|
||||
distanceM: stored.distanceM,
|
||||
durationS: stored.durationS,
|
||||
steps: stored.steps,
|
||||
mode: mode as RouteResult['mode'],
|
||||
approximate: false,
|
||||
},
|
||||
ageMs: Date.now() - stored.at,
|
||||
};
|
||||
}
|
||||
|
||||
/** "just now" | "12 min ago" | "2 h ago" — for the cached-route badge. */
|
||||
export function formatAge(ageMs: number): string {
|
||||
const mins = Math.round(ageMs / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours} h ago`;
|
||||
return `${Math.round(hours / 24)} d ago`;
|
||||
}
|
||||
Loading…
Reference in a new issue