SkateSpots/src/lib/routeCache.ts
SkateSpots 5a819d5a31 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).
2026-09-27 19:11:14 +07:00

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`;
}