/* * watch.ts — the guidance position stream: watchPosition behind the D-15 * --------------------------------------------------------------------------- * distance throttle. D-05's gated-streaming law flips its watch ON here: * guidance subscribes to navigator.geolocation.watchPosition at HIGH * ACCURACY (maximumAge: 0 — a cached position would poison progress), but * the web platform has NO native distance filter, so the throttle is * app-side by design: a fix does guidance WORK only when the car moved * >= THROTTLE_MIN_MOVE_M (10 m) OR >= THROTTLE_MIN_ELAPSED_MS (5 s) * elapsed since the last accepted fix — whichever first. Everything else * is dropped before it can touch the state machine, the projection, or * the ETA (battery cadence, D-15). * * Each payload normalizes into the six-field Fix (the telegram.ts * normalization precedent): accuracyM/headingDeg null when the platform * omits them, source "webview". Rejected fixes leave NO trace — the * machine never sees them. * * Privacy (T-06-05/T-06-05b): fixes are consumed in the caller's frame; * nothing is stored, logged, or transmitted (no fetch/console/storage — * gated by the replay suite's structural case). stop() clears the * watcher id — the privacy-minimal trip end (D-14's "the watch stops"); * calling it twice is harmless. */ import type { Fix } from "../location/types.ts"; import { haversineM } from "@araharah/routing/src/geo.ts"; /** D-15: guidance work runs on >= 10 m moved. */ export const THROTTLE_MIN_MOVE_M = 10; /** D-15: ...or >= 5 s elapsed, whichever first (stationary cars still progress clocks). */ export const THROTTLE_MIN_ELAPSED_MS = 5000; /** * The pure accept rule: first fix always accepted; afterwards accept when * the car moved >= 10 m OR >= 5 s elapsed since the last ACCEPTED fix. */ export function shouldAcceptFix(last: Fix | null, fix: Fix, lastAcceptedTs: number): boolean { if (last === null) return true; if (haversineM(last, fix) >= THROTTLE_MIN_MOVE_M) return true; return fix.ts - lastAcceptedTs >= THROTTLE_MIN_ELAPSED_MS; } /** The watch handle: stop() clears the watcher (idempotent; D-14). */ export interface GuidanceWatch { stop(): void; } /** * Start the guidance watch. `geo` is injected (the location-chain law) so * the whole throttle is Node-testable against a stub. Only ACCEPTED fixes * reach `onFix`; rejections die here silently. */ export function createGuidanceWatch(geo: Geolocation, onFix: (fix: Fix) => void): GuidanceWatch { let last: Fix | null = null; let lastAcceptedTs = 0; let watchId: number | null = null; let stopped = false; watchId = geo.watchPosition( (pos) => { if (stopped) return; const raw = pos.timestamp; const fix: Fix = { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracyM: pos.coords.accuracy ?? null, headingDeg: pos.coords.heading ?? null, ts: typeof raw === "number" && raw > 0 ? raw : Date.now(), source: "webview", }; if (!shouldAcceptFix(last, fix, lastAcceptedTs)) return; // dropped pre-work (D-15) last = fix; lastAcceptedTs = fix.ts; onFix(fix); }, () => { // a mid-drive error (denial/unavailable) is not a crash: the watch // keeps running and the chain's staleness axis reports the silence }, { enableHighAccuracy: true, maximumAge: 0 }, ); return { stop(): void { if (stopped || watchId === null) return; stopped = true; geo.clearWatch(watchId); watchId = null; }, }; }