diff --git a/web/src/guidance/guidance.ts b/web/src/guidance/guidance.ts index 6928d50..92f3849 100644 --- a/web/src/guidance/guidance.ts +++ b/web/src/guidance/guidance.ts @@ -46,6 +46,7 @@ import type { RoutePoint, RouteStep } from "@araharah/routing/src/types.ts"; import { haversineM } from "@araharah/routing/src/geo.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import type { Fix } from "../location/types.ts"; +import type { GlyphKind } from "./glyphs.ts"; /* ————— THE constants table (06-RESEARCH Pattern 3; replay-tuned, never inline) ————— */ @@ -436,3 +437,118 @@ function processEdges(next: GuidanceSnapshot, fix: Fix, effects: GuidanceEffect[ next.approachArmed = false; } } + +/* ————— the chrome's view data (06-06) ————— */ +/** + * guidanceView composes EVERYTHING the cockpit chrome renders from a + * snapshot — banner slots, card view fields, and the maneuver list rows — + * so the route controller never re-derives maneuver/progress data (the + * RS6 re-pin's law: guidance IMPLEMENTATION lives in web/src/guidance/; + * route/ imports and calls, never re-computes). Pure: snapshot in, plain + * data out — structurally the view types banner.ts's builders consume. + * + * The banner's distance slot is the model-derived distance TO THE NEXT + * MANEUVER MARK (D-07: never a raw fix-to-destination distance) — null + * (the honest dash) while REROUTING or in the NO REROUTE degradation; the + * RESUMING register legally shows the projection numbers. The street slot + * is the next maneuver's own step name (the road the maneuver puts you + * on); the banner builder swaps in the calm-state copy for the wait + * states regardless. + */ +export interface GuidanceListRow { + kind: GlyphKind; + street: string; + distanceM: number; + rowState: "next" | "future" | "past"; +} + +export interface GuidanceBannerData { + state: GuidanceState; + glyph: GlyphKind; + distanceM: number | null; + street: string | null; + listOpen: boolean; +} + +export interface GuidanceCardData { + destinationName: string; + progressPct: number; + showNoReroute: boolean; +} + +export interface GuidanceViewData { + banner: GuidanceBannerData; + card: GuidanceCardData; + rows: GuidanceListRow[]; +} + +export function guidanceView(snap: GuidanceSnapshot, listOpen: boolean): GuidanceViewData { + const { model } = snap; + const marks = maneuverMarksOf(model); + const next = marks[snap.nextManeuver] ?? null; + + // The road a mark names: a TURN mark names the road the turn puts you on + // (its owning step); the ARRIVE mark names the road being arrived on — + // the last NAMED step of the route (an arrive step itself carries no + // name; a blank slot would render the unnamed fallback and lie). + const arrivalRoad = (() => { + let named: string | null = null; + for (const step of model.steps) { + if (step.name !== undefined && step.name.length > 0) named = step.name; + } + return named; + })(); + + // the next maneuver's owning step: the step whose turn-kind AND prefix + // match the mark (a step runs FROM its maneuver TO the next boundary) + let street: string | null = null; + let along = 0; + if (next !== null) { + for (const step of model.steps) { + if (step.kind === next.kind && along === next.alongM) { + street = step.kind === "arrive" ? arrivalRoad : (step.name ?? null); + break; + } + along += step.distanceM ?? 0; + } + } + + const toManeuverM = next === null ? null : Math.max(0, Math.round(next.alongM - snap.alongM)); + const calm = snap.state === "rerouting" || snap.state === "no_reroute"; + const totalM = model.cumulative[model.cumulative.length - 1] ?? 0; + const progressPct = totalM > 0 ? Math.min(100, Math.max(0, (snap.alongM / totalM) * 100)) : 0; + + const rows: GuidanceListRow[] = marks.map((mark, i) => { + let rowStreet = ""; + let cursor = 0; + for (const step of model.steps) { + if (step.kind === mark.kind && cursor === mark.alongM) { + rowStreet = mark.kind === "arrive" ? (arrivalRoad ?? "") : (step.name ?? ""); + break; + } + cursor += step.distanceM ?? 0; + } + return { + kind: mark.kind ?? "straight", + street: rowStreet, + distanceM: Math.max(0, Math.round(mark.alongM - snap.alongM)), + rowState: i < snap.nextManeuver ? "past" : i === snap.nextManeuver ? "next" : "future", + }; + }); + + return { + banner: { + state: snap.state, + glyph: (next?.kind ?? "straight") as GlyphKind, + distanceM: calm ? null : toManeuverM, + street, + listOpen, + }, + card: { + destinationName: model.destinationName, + progressPct, + showNoReroute: snap.state === "no_reroute", + }, + rows, + }; +} diff --git a/web/src/route/route.ts b/web/src/route/route.ts index e442c9a..2ba6ae4 100644 --- a/web/src/route/route.ts +++ b/web/src/route/route.ts @@ -1,47 +1,85 @@ /* - * route.ts — the route controller: consult / select / walk-back state machine. + * route.ts — the route controller: consult / select / walk-back state machine, + * and (06-06) the guidance pump that turns it into the live cockpit. * ------------------------------------------------------------------------------ * States: idle → (auto-consult, exactly once per boot — main.ts guards the - * flag) → route | pending → walkBack → idle. + * flag) → route | pending → walkBack → idle. Guidance rides BESIDE that + * graph as an orthogonal phase (guidancePhase: none → driving → confirm → + * ended) — the RouteState stays "route" while driving; the phase is what + * the BackButton resolver reads. + * + * THE PUMP (06-06): when a route draws, the injected watch starts; every + * accepted fix opens the session (createGuidanceSession) and steps the + * 06-02 machine (stepGuidance). The start gate (≥ GUIDANCE_START_ALONG_M + * along) activates the cockpit: PX-03 close protection, [data-guidance= + * "active"] on the shell root, the 06-05 chrome (banner + card + list), + * and the 06-06 camera easing to the SNAPPED line. Effects dispatch to the + * guards (haptics) and the injected RoutingSource (reroute — the composed + * engine-a ladder): success snaps the new line in via setData with NO + * camera jump; failure lands the calm NO REROUTE degradation. Arrival and + * abandon both run the same teardown: watch stopped, protection released, + * chrome cleared, the trip-ended card in the center register (D-14). + * + * EVERY guidance behavior is delegated: the machine (web/src/guidance/ + * guidance.ts) decides states/effects/thresholds; guidanceView composes the + * chrome's view data; banner.ts builds the DOM; the eta module smooths the + * numbers; camera.ts owns the pose. THIS module only wires them — zero + * maneuver math, zero progress math inline (the RS6 re-pin's boundary). * * D-09: a drawn route is static furniture — only selection changes it. The * selected stack paints via setData on the "route" source; alternates on * "route-alt". Camera: fitBounds over the selected geometry with the * route's own padded constants (BESIDE follow.ts's CAMERA_PADDING_PX, the - * UI-SPEC's camera table): bottom 320 = the 64px control-clearance - * composite + the ≤240px card height budget + 16px slack. Pitch and - * bearing are passed EXPLICITLY at their current values — the fit stays - * center+zoom only (the camera law). - * - * D-08: alternatives are a bottom card + chips — tap → instant geometry - * swap + card re-render + camera refit (ALWAYS refits). Single-route draws - * render no chips; the pending state renders the center card. + * UI-SPEC's camera table). Pitch and bearing are passed EXPLICITLY at + * their current values — the fit stays center+zoom only. * * D-03: route() resolving null — or THROWING (T-05-04, .catch(() => null)) * — renders the designed pending card, never a blank map, never a fake - * route, never a half-drawn line. Fail-honest. + * route, never a half-drawn line. Fail-honest — and guidance can never + * start over a pending state (PX-03's precondition). * - * D-10: display-only terminal — selection redraws + refits and the single - * walkBack total function (shared with the BackButton). No start-guidance - * button, no turn instructions, no progress UI. - * - * D-07/D-12 memory law: the origin is the live fix, held in THIS closure - * for the session only — it never persists, never appears in a console - * line. The only console lines in web/src/route/ are the two ARAHARAH-ROUTE - * state tokens (a state signal, never coordinates — D-15/T-05-13; RS8). + * D-07/D-12 memory law: the origin and every fix are held in THIS closure + * for the session only — they never persist, never appear in a console + * line. The only console lines are ARAHARAH-ROUTE / ARAHARAH-GUIDE state + * tokens (a state signal, never coordinates — D-15/T-05-13; RS8). * * All DOM/map concerns are INJECTED so the RS gates run in Node: card - * builders, the register shower, the follow controller, and the endpoint - * marker attach/remove are deps — the module itself touches no DOM and no - * maplibre value import. + * builders, the register shower, the follow controller, the watch factory, + * the guidance hosts, and the endpoint marker attach/remove are deps — the + * module itself touches no DOM and no maplibre value import. */ import type { GazetteerEntry } from "@araharah/search/src/types.ts"; import type { RouteOption, RoutePoint, RouteResult, RoutingSource } from "@araharah/routing/src/index.ts"; import type { Fix } from "../location/types.ts"; import type { Locale } from "../i18n/strings.ts"; +import type { TelegramWebAppLike } from "../telegram/webapp.ts"; +import type { FollowOptions } from "../map/follow.ts"; import type { MapLike } from "./route-layers.ts"; import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts"; import type { buildRouteCard, buildRoutePendingCard } from "./route-card.ts"; +import { createGuidanceCamera } from "../guidance/camera.ts"; +import { engageCloseProtection, releaseCloseProtection } from "../guidance/close-protection.ts"; +import { fireHaptic } from "../guidance/haptics.ts"; +import { + buildConfirmCard, + buildGuidanceBanner, + buildGuidanceCard, + buildManeuverList, + buildTripEndCard, + startGantryBlink, + stopGantryBlink, +} from "../guidance/banner.ts"; +import { composeEtaClock, updateEta, type EtaState } from "../guidance/eta.ts"; +import { + createGuidanceSession, + guidanceView, + projectAlongRoute, + stepGuidance, + type GuidanceEffect, + type GuidanceRouteModel, + type GuidanceSnapshot, +} from "../guidance/guidance.ts"; +import type { GuidanceWatch } from "../guidance/watch.ts"; /** fitBounds padding over the route geometry — map-space px (MapLibre * padding, not CSS). The camera constants sit BESIDE follow.ts's. */ @@ -52,20 +90,24 @@ export const ROUTE_CAMERA_PITCH = 35; export type RouteState = "idle" | "route" | "pending"; +/** The guidance phase — the BackButton resolver's second axis (PX-01). */ +export type GuidancePhase = "none" | "driving" | "confirm" | "ended"; + export interface RouteControllerDeps { map: MapLike; - /** The RoutingSource seam — the fixture backend (backend #1) at the - * import site; a server backend slots in later without client rework. */ + /** The RoutingSource seam — the composed backend ladder (engine-a → + * fixture, 06-06 task 3) at the import site; reroutes ride it too. */ source: RoutingSource; locale: Locale; buildRouteCard: typeof buildRouteCard; buildPendingCard: typeof buildRoutePendingCard; - /** Shows the route/pending card in the route stack's register. */ + /** Shows the route/pending/guidance card in the route stack's register. */ showRouteCard: (card: HTMLElement | null, register: "center" | "bottom") => void; /** Restores the Phase 3 stack's destination card (the walk-back target). */ showDestinationCard: () => void; - /** The existing follow controller — the walk-back's camera return. */ - follow: { follow(fix: Fix): void }; + /** The existing follow controller — the walk-back's camera return and + * the guidance pose's executor (camera.ts rides it). */ + follow: { follow(point: Fix | { lat: number; lng: number }, opts?: FollowOptions): void }; getLatestFix: () => Fix | null; /** Endpoint markers (origin ring + destination pin) — injected DOM concern. */ attachEndpoints: (firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string) => void; @@ -73,17 +115,62 @@ export interface RouteControllerDeps { /** Fired on every state transition — main.ts derives the BackButton * predicate (visible exactly when a pushed state exists) from it. */ onStateChange: (state: RouteState) => void; + /* — guidance concerns (06-06; injected, the RS-gates-run-in-Node law). + * Optional in the type so a cards-only boot (no watch factory) degrades + * to exactly the Phase 5 controller. — */ + wa?: TelegramWebAppLike | null; + /** The element receiving [data-guidance="active"] (the shell root). */ + shellRoot?: HTMLElement; + /** The banner's mount (the top register band). */ + bannerHost?: HTMLElement; + /** The maneuver list's overlay mount. */ + listHost?: HTMLElement; + /** The guidance watch factory — the D-15 gated stream. Absent = no pump. */ + startWatch?: (onFix: (fix: Fix) => void) => GuidanceWatch; + /** Injected clock (deterministic tests; production Date.now). */ + nowMs?: () => number; + /** Finds the mounted banner's gantry element (a DOM-search concern). */ + findGantry?: (banner: HTMLElement) => HTMLElement | null; + /** Fired on guidance phase transitions — main.ts re-syncs the BackButton. */ + onGuidanceChange?: () => void; +} + +/** One live trip: the model guidance drives on, the machine's snapshot, + * the honest-ETA cell, and the cockpit's own flags. */ +interface GuidanceRun { + model: GuidanceRouteModel; + snap: GuidanceSnapshot; + durationS: number; + eta: EtaState | null; + listOpen: boolean; + confirmOpen: boolean; + active: boolean; + ended: boolean; + startedAtSec: number; + /** The last accepted fix's snapped foot (the camera rides THIS point). */ + lastSnapped: RoutePoint; } export function createRouteController(deps: RouteControllerDeps): { readonly state: RouteState; + readonly guidancePhase: GuidancePhase; consult(fix: Fix, destination: GazetteerEntry): Promise; walkBack(): void; + openConfirm(): void; + continueGuidance(): void; } { let state: RouteState = "idle"; let options: RouteOption[] = []; let selectedIndex = 0; let destinationName = ""; + let destinationId = ""; + let guidance: GuidanceRun | null = null; + let watch: GuidanceWatch | null = null; + let blinkEl: HTMLElement | null = null; + let lastLoggedGuideState: string | null = null; + + const now = (): number => (deps.nowMs !== undefined ? deps.nowMs() : Date.now()); + const camera = createGuidanceCamera(null, deps.follow); function setState(next: RouteState): void { state = next; @@ -94,6 +181,7 @@ export function createRouteController(deps: RouteControllerDeps): { * module stays trigger-agnostic). Auto: live fix + resolved destination. */ async function consult(fix: Fix, destination: GazetteerEntry): Promise { destinationName = destination.name; + destinationId = destination.id; const result: RouteResult | null = await deps.source .route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id }) .catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line @@ -107,9 +195,286 @@ export function createRouteController(deps: RouteControllerDeps): { selectedIndex = 0; drawSelection(); setState("route"); + ensureWatch(); // the pump arms with the route — guidance can never start without one console.log("ARAHARAH-ROUTE state=drawn"); } + /* ————— the guidance pump (06-06) ————— */ + + function selectedModel(): GuidanceRouteModel | null { + const option = options[selectedIndex]; + if (option === undefined) return null; + return { + points: option.geometry.points, + cumulative: option.geometry.cumulativeDistanceM, + steps: option.steps, + destinationId, + destinationName, + }; + } + + function selectedDurationS(): number { + const option = options[selectedIndex]; + return option !== undefined ? option.summary.durationS : 0; + } + + function ensureWatch(): void { + if (watch !== null || deps.startWatch === undefined) return; + watch = deps.startWatch(onWatchFix); + } + + function stopWatch(): void { + if (watch === null) return; + watch.stop(); + watch = null; + } + + /** The console law: ARAHARAH-GUIDE state= tokens, deduped — a + * state signal, never coordinates (RS8; T-06-04). */ + function logGuideState(next: string): void { + if (next === lastLoggedGuideState) return; + lastLoggedGuideState = next; + console.log("ARAHARAH-GUIDE state=" + next); + } + + /** The D-07 honest-ETA fold: numbers from the route model + the snapped + * projection only (never a raw fix-to-destination distance). */ + function foldEta(g: GuidanceRun): void { + const totalM = g.model.cumulative[g.model.cumulative.length - 1] ?? 0; + const remainingS = g.durationS > 0 ? g.durationS * (g.snap.remainingM / (totalM > 0 ? totalM : 1)) : 0; + g.eta = updateEta(g.eta, remainingS, g.snap.remainingM); + } + + /** Every accepted fix lands here (the watch already throttled it). The + * first fix after the route draw opens the session; the machine's start + * gate decides when the cockpit mounts. */ + function onWatchFix(fix: Fix): void { + if (state !== "route") return; // PX-03's precondition: never without a route + if (guidance === null) { + const model = selectedModel(); + if (model === null) return; + const snap = createGuidanceSession(model, fix, now()); + guidance = { + model, + snap, + durationS: selectedDurationS(), + eta: null, + listOpen: false, + confirmOpen: false, + active: false, + ended: false, + startedAtSec: Math.floor(now() / 1000), + lastSnapped: projectAlongRoute(model.points, model.cumulative, fix, snap.fromIdx).snapped, + }; + } + const g = guidance; + if (g.ended) return; + const proj = projectAlongRoute(g.model.points, g.model.cumulative, fix, g.snap.fromIdx); + g.lastSnapped = proj.snapped; + const stepped = stepGuidance(g.snap, { type: "fix", fix }, now()); + g.snap = stepped.snap; + if (!g.active && g.snap.state !== "idle") activateGuidance(); + if (!g.active || g.ended) return; // idle — the start gate has not opened + foldEta(g); + dispatchEffects(stepped.effects); + if (!g.ended) { + logGuideState(g.snap.state); + renderCockpit(); + } + } + + function activateGuidance(): void { + const g = guidance; + if (g === null || g.active) return; + g.active = true; + engageCloseProtection(deps.wa ?? null); + if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active"; + foldEta(g); + camera.easeToSnapped(g.lastSnapped); // the locked-follow pose over the SNAPPED line + renderCockpit(); + logGuideState(g.snap.state); + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + + function stopBlink(): void { + if (blinkEl !== null) { + stopGantryBlink(blinkEl); + blinkEl = null; + } + } + + /** The cockpit re-render: banner (with the gantry swap law — the outgoing + * element's blink stops before the replace), the collapsed-by-default + * list, and the guidance card in the bottom register. */ + function renderCockpit(): void { + const g = guidance; + if (g === null || !g.active || g.ended) return; + const view = guidanceView(g.snap, g.listOpen); + stopBlink(); + const banner = buildGuidanceBanner(deps.locale, view.banner, { onToggle: toggleList }); + if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(banner); + if ((g.snap.state === "rerouting" || g.snap.state === "resuming") && deps.findGantry !== undefined) { + const gantry = deps.findGantry(banner); + if (gantry !== null) { + startGantryBlink(gantry); + blinkEl = gantry; + } + } + if (deps.listHost !== undefined) { + if (g.listOpen) { + deps.listHost.replaceChildren(buildManeuverList(deps.locale, view.rows, { onDismiss: collapseList })); + } else { + deps.listHost.replaceChildren(); + } + } + const eta = g.eta ?? updateEta(null, 0, g.snap.remainingM); + deps.showRouteCard( + buildGuidanceCard( + deps.locale, + { + ...view.card, + minutesRemaining: eta.displayedEtaS / 60, + kmRemaining: eta.displayedRemainingM / 1000, + etaClock: composeEtaClock(eta.displayedEtaS, now()), + }, + { onEndTrip: openConfirm }, + ), + "bottom", + ); + } + + function toggleList(): void { + const g = guidance; + if (g === null || g.ended) return; + g.listOpen = !g.listOpen; + renderCockpit(); + } + + function collapseList(): void { + const g = guidance; + if (g === null || !g.listOpen) return; + g.listOpen = false; + renderCockpit(); + } + + function dispatchEffects(effects: readonly GuidanceEffect[]): void { + for (const effect of effects) { + if (effect.type === "haptic") fireHaptic(deps.wa ?? null, effect.cue); + else if (effect.type === "reroute-requested") void requestReroute(effect.origin); + else if (effect.type === "arrival") teardownGuidance("arrival"); + } + } + + /** The silent self-heal (GUID-02): one honest origin, the composed ladder, + * and either a snap-in (setData, NO camera jump) or the calm NO REROUTE + * degradation. The banner never blames. */ + async function requestReroute(origin: RoutePoint): Promise { + const g = guidance; + if (g === null || g.ended) return; + g.snap = stepGuidance(g.snap, { type: "reroute-started" }, now()).snap; + renderCockpit(); // REROUTING: register swap + dash + gantry start + logGuideState(g.snap.state); + let result: RouteResult | null = null; + try { + result = await deps.source.route({ origin, destinationId: g.model.destinationId }); + } catch { + result = null; // fail-honest (T-05-04, network edition) + } + if (guidance !== g || g.ended) return; // the trip ended mid-request — drop it + if (result === null || result.options.length === 0) { + g.snap = stepGuidance(g.snap, { type: "reroute-failed" }, now()).snap; + logGuideState(g.snap.state); + renderCockpit(); + return; + } + const option = result.options[0]; + if (option === undefined) return; + const model: GuidanceRouteModel = { + points: option.geometry.points, + cumulative: option.geometry.cumulativeDistanceM, + steps: option.steps, + destinationId: result.destinationId, + destinationName, + }; + g.durationS = option.summary.durationS; + g.model = model; + options = [option]; // the post-abandon route state carries the new line + selectedIndex = 0; + g.snap = stepGuidance(g.snap, { type: "reroute-applied", model }, now()).snap; + setRouteDrawn(deps.map, option.geometry, []); // the snap-in — the camera holds (UI-SPEC) + const first = option.geometry.points[0]; + const last = option.geometry.points[option.geometry.points.length - 1]; + if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, destinationName); + logGuideState(g.snap.state); + foldEta(g); + renderCockpit(); + } + + /** BOTH trip ends land here (D-14): the watch stops, the close protection + * releases, [data-guidance] clears (the strip returns), and the trip-end + * card takes the center register. */ + function teardownGuidance(reason: "arrival" | "abandon"): void { + const g = guidance; + if (g === null || g.ended) return; + g.ended = true; + g.confirmOpen = false; + stopWatch(); + releaseCloseProtection(deps.wa ?? null); + if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance; + stopBlink(); + if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(); + if (deps.listHost !== undefined) deps.listHost.replaceChildren(); + deps.showRouteCard(buildTripEndCard(deps.locale), "center"); + logGuideState("ended"); + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + + /** END TRIP and the BackButton-from-guidance land here (D-09): the ONE + * confirm surface; YES, ABANDON tears down, CONTINUE dismisses. */ + function openConfirm(): void { + const g = guidance; + if (g === null || !g.active || g.ended) return; + g.confirmOpen = true; + deps.showRouteCard( + buildConfirmCard( + deps.locale, + { onAbandon: () => teardownGuidance("abandon"), onContinue: () => continueGuidance() }, + ), + "bottom", + ); + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + + function continueGuidance(): void { + const g = guidance; + if (g === null) return; + g.confirmOpen = false; + renderCockpit(); // the guidance card returns to the bottom register + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + + function guidancePhase(): GuidancePhase { + const g = guidance; + if (g === null || !g.active) return "none"; + if (g.ended) return "ended"; + return g.confirmOpen ? "confirm" : "driving"; + } + + /** Guidance remnants stop here — the latent watch included. Never runs + * while the confirm flow owns the exit (walkBack refuses first). */ + function stopGuidanceArtifacts(): void { + stopWatch(); + if (guidance === null) return; + releaseCloseProtection(deps.wa ?? null); + if (deps.shellRoot !== undefined) delete deps.shellRoot.dataset.guidance; + stopBlink(); + if (deps.bannerHost !== undefined) deps.bannerHost.replaceChildren(); + if (deps.listHost !== undefined) deps.listHost.replaceChildren(); + guidance = null; + lastLoggedGuideState = null; + if (deps.onGuidanceChange !== undefined) deps.onGuidanceChange(); + } + /** Chip tap: swap geometries between the selected/alt sources, re-build * the card (new register/telemetry/difference/chips), refit the NEW * geometry's bounds — ALWAYS refits (D-10). Instant swaps, no easing. */ @@ -170,11 +535,16 @@ export function createRouteController(deps: RouteControllerDeps): { * here. route: clear layers + endpoints, restore the destination card, * camera refollows the user. pending: restore the destination card (no * layers exist). Both: state → idle, onStateChange fires (BackButton - * hides). Never a dead end. */ + * hides). While guidance OWNS the exit (driving/confirm) this refuses — + * the D-09 confirm card is the only door. Never a dead end. */ function walkBack(): void { + const phase = guidancePhase(); + if (phase === "driving" || phase === "confirm") return; // PX-03: the confirm owns every guidance exit + stopGuidanceArtifacts(); // the ended phase's remnants (or a latent watch) stop here if (state === "route") { clearRouteLayers(deps.map); deps.removeEndpoints(); + deps.showRouteCard(null, "center"); // the route stack vacates — the destination card replaces it deps.showDestinationCard(); const fix = deps.getLatestFix(); if (fix !== null) deps.follow.follow(fix); @@ -190,7 +560,12 @@ export function createRouteController(deps: RouteControllerDeps): { get state(): RouteState { return state; }, + get guidancePhase(): GuidancePhase { + return guidancePhase(); + }, consult, walkBack, + openConfirm, + continueGuidance, }; } diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs index 9535c14..5ec5b12 100644 --- a/web/test/route-surface.test.mjs +++ b/web/test/route-surface.test.mjs @@ -55,6 +55,9 @@ const { createRouteController, } = await import("../src/route/route.ts"); const { wireBackButton } = await import("../src/telegram/backbutton.ts"); +const { createFollowController } = await import("../src/map/follow.ts"); +/* 06-06 note: the guidance-camera module is imported INSIDE its tests (the + RED gate must fail per-test on the absent module, never break suite load). */ /* ————— shared helpers ————— */ @@ -963,6 +966,7 @@ function steppedDeps(opts = {}) { now: 1_000_000, guidanceChanges: 0, sourceCalls: [], + followModes: [], }; // every source.route call is recorded (the exactly-one-request gates) const baseRoute = deps.source.route.bind(deps.source); @@ -970,6 +974,16 @@ function steppedDeps(opts = {}) { guide.sourceCalls.push(req); return baseRoute(req); }; + // the follow dep rides the REAL follow controller over the fake map so the + // camera pose asserts see map.recorded.flyTo (the camera.ts integration) + const realFollow = createFollowController(deps.__map, (m) => guide.followModes.push(m)); + const recordedFollow = deps.follow.follow.bind(deps.follow); + deps.follow = { + follow(point, opts) { + recordedFollow(point); + realFollow.follow(point, opts); + }, + }; deps.wa = guide.wa; deps.shellRoot = guide.shellRoot; deps.bannerHost = guide.bannerHost; @@ -1021,6 +1035,14 @@ function guideDistance(deps) { return distance === null ? null : distance.textContent; } +/** Collected leaf text of a fake element tree (parents never accumulate). */ +function textOf(el) { + if (el === null || el === undefined) return ""; + let out = typeof el.textContent === "string" ? el.textContent : ""; + for (const child of el.children ?? []) out += textOf(child); + return out; +} + /** The standard live-driving setup: route drawn, guidance activated at * 500 m along, then an accepted fix at 700 m (6 s later). */ async function driveTo(deps, controller) { @@ -1086,7 +1108,8 @@ test("G2 pump updates — banner distance/street from the model, ETA card, stepp assert.ok(/\d/.test(findByClass(telemetry, "text-telemetry-num").textContent), "minutes remaining renders"); const progress = findByClass(card, "guide-progress"); assert.equal(progress.attributes["aria-valuenow"], "35", "progress pct = alongM/totalM (700/2000)"); - assert.equal(findByClass(card, "guide-progress-fill").style.width, "35%", "instant width per accepted fix"); + const widthPct = parseFloat(findByClass(card, "guide-progress-fill").style.width); + assert.ok(Math.abs(widthPct - 35) <= 1, `instant width per accepted fix (got ${widthPct}%)`); assert.ok(findByClass(card, "btn-secondary") !== null, "END TRIP is the one card action"); }); }); @@ -1096,13 +1119,14 @@ test("G3 OFF_ROUTE grace — the banner shows NO premature signal while the mach const deps = steppedDeps(); const controller = createRouteController(deps); await driveTo(deps, controller); + const callsBefore = deps.__guide.sourceCalls.length; // the consult's call // two far-off fixes 1 s apart — below the 3-fix + 8 s gate: the machine // has not acted, so the banner must not either feed(750, { deps, excursion: true, advanceMs: 1000 }); feed(800, { deps, excursion: true, advanceMs: 1000 }); assert.equal(guideRegister(deps), "GUIDE // NEXT", "no premature signal — the register holds"); assert.equal(findByClass(guideBanner(deps), "guide-street").textContent, "Jalan Dua", "the street slot holds — no calm copy"); - assert.equal(deps.__guide.sourceCalls.length, 0, "zero source.route calls during the grace"); + assert.equal(deps.__guide.sourceCalls.length, callsBefore, "zero reroute calls during the grace"); }); }); @@ -1111,14 +1135,25 @@ test("G4 REROUTING — the calm swap + dash + gantry; exactly ONE source.route c const deps = steppedDeps(); const controller = createRouteController(deps); await driveTo(deps, controller); - // the excursion: 3 consecutive off fixes spanning the 8 s grace - const e1 = feed(750, { deps, excursion: true }); + // the reroute request HANGS — REROUTING persists for the calm-state asserts. + // Installed AFTER the consult: every call through THIS wrapper is a reroute. + const recordedRoute = deps.source.route.bind(deps.source); + const rerouteRequests = []; + deps.source.route = (req) => { + rerouteRequests.push(req); + void recordedRoute; + return new Promise(() => {}); + }; + // the excursion: 3 consecutive off fixes spanning the 8 s grace — the + // request fires on the fix that completes the grace (the honest CURRENT + // position, D-04) + feed(750, { deps, excursion: true }); feed(800, { deps, excursion: true, advanceMs: 9000 }); - feed(850, { deps, excursion: true, advanceMs: 500 }); + const e3 = feed(850, { deps, excursion: true, advanceMs: 500 }); await drain(); - assert.equal(deps.__guide.sourceCalls.length, 1, "exactly ONE reroute request"); - assert.equal(deps.__guide.sourceCalls[0].destinationId, DEST.id, "the request carries the destination"); - assert.equal(deps.__guide.sourceCalls[0].origin.lat, e1.lat, "the origin is the honest raw excursion fix"); + assert.equal(rerouteRequests.length, 1, "exactly ONE reroute request"); + assert.equal(rerouteRequests[0].destinationId, DEST.id, "the request carries the destination"); + assert.equal(rerouteRequests[0].origin.lat, e3.lat, "the origin is the honest raw fix that completed the grace"); assert.equal(guideRegister(deps), "GUIDE // REROUTING", "the calm register swap"); assert.equal(guideDistance(deps), "—", "distance is the honest dash"); const gantry = findByClass(guideBanner(deps), "guide-gantry"); @@ -1131,20 +1166,25 @@ test("G4 REROUTING — the calm swap + dash + gantry; exactly ONE source.route c // an immediate second excursion inside the storm guard: silent feed(900, { deps, excursion: true, advanceMs: 2000 }); await drain(); - assert.equal(deps.__guide.sourceCalls.length, 1, "the storm guard holds — still exactly one call"); + assert.equal(rerouteRequests.length, 1, "the storm guard holds — still exactly one call"); assert.equal(guideRegister(deps), "GUIDE // REROUTING", "no state flap — banner calm"); + // the test ends here with the blink intentionally running — stop it so + // the interval never leaks into the runner's event loop (GS5's law) + const { stopGantryBlink } = await import("../src/guidance/banner.ts"); + const running = findByClass(guideBanner(deps), "guide-gantry"); + if (running !== null) stopGantryBlink(running); }); }); test("G5 pending precondition + reroute failure — guidance never without a route; NO REROUTE is fail-honest", async () => { await withDocumentAsync(async () => { - // (a) a null consult result = pending — the pump must never start guidance + // (a) a null consult result = pending — NO watch arms, guidance can never start const deps = steppedDeps({ result: null }); const controller = createRouteController(deps); await withConsole(() => controller.consult(FIX, DEST)); assert.equal(controller.state, "pending", "null result = pending"); - feed(500, { deps }); - assert.equal(guideBanner(deps), null, "no guidance over a pending state — never without a route"); + assert.equal(deps.__guide.watchStarted, 0, "no guidance watch over a pending state — never without a route"); + assert.equal(guideBanner(deps), null, "no guidance chrome over a pending state"); assert.equal(deps.__guide.wa.swipes.length, 0, "no PX-03 protections without a route"); // (b) a mid-drive reroute failure lands NO REROUTE, calm const deps2 = steppedDeps(); @@ -1188,7 +1228,8 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the geometry: { points: [req.origin, P2], cumulativeDistanceM: [0, 1800] }, summary: { distanceM: 1800, durationS: 200 }, steps: [ - { kind: "depart", distanceM: 1800, name: "Jalan Baru" }, + { kind: "depart", distanceM: 500 }, + { kind: "right", distanceM: 1300, name: "Jalan Baru" }, { kind: "arrive", distanceM: 0 }, ], }, @@ -1200,9 +1241,9 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the await driveTo(deps, controller); const flyBefore = deps.__map.recorded.flyTo.length; const setDataBefore = deps.__map.recorded.setData.length; - const e1 = feed(750, { deps, excursion: true }); + feed(750, { deps, excursion: true }); feed(800, { deps, excursion: true, advanceMs: 9000 }); - feed(850, { deps, excursion: true, advanceMs: 500 }); + const e3 = feed(850, { deps, excursion: true, advanceMs: 500 }); await drain(); assert.equal(guideRegister(deps), "GUIDE // NEXT", "back on the new route"); const swaps = deps.__map.recorded.setData.slice(setDataBefore); @@ -1210,8 +1251,8 @@ test("G6 reroute success — the new line setData snaps in, NO camera jump, the const routeSwap = swaps.find((s) => s.id === "route"); assert.deepEqual( routeSwap.data.features[0].geometry.coordinates[0], - [e1.lng, e1.lat], - "the new line starts at the excursion origin", + [e3.lng, e3.lat], + "the new line starts at the honest origin (the fix that completed the grace)", ); assert.equal(deps.__map.recorded.flyTo.length, flyBefore, "NO camera jump on reroute (setData only)"); assert.equal(findByClass(guideBanner(deps), "guide-gantry"), null, "the gantry stopped on resolve"); @@ -1246,7 +1287,7 @@ test("G7 END TRIP → confirm → YES ABANDON teardown; CONTINUE dismisses with assert.equal(findByClass(deps.__guide.bannerHost, "guide-banner"), null, "the banner cleared instantly"); const ended = deps.__recorded.shown.at(-1); assert.equal(ended.register, "center", "the trip-ended card holds the center register"); - assert.ok(ended.card.textContent.includes("END"), "the GUIDE // END register renders"); + assert.ok(textOf(ended.card).includes("END"), "the GUIDE // END register renders"); assert.ok(abandoned.lines.some((l) => l.includes("ARAHARAH-GUIDE state=ended")), "state=ended logged"); assert.equal(controller.guidancePhase, "ended", "the BackButton graph's ended state is exposed"); });