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).
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
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`;
|
|
}
|