/* * route.ts — the route controller: consult / select / walk-back state machine. * ------------------------------------------------------------------------------ * States: idle → (auto-consult, exactly once per boot — main.ts guards the * flag) → route | pending → walkBack → idle. * * 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. * * 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. * * 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). * * 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. */ 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 { MapLike } from "./route-layers.ts"; import { clearRouteLayers, setRouteDrawn } from "./route-layers.ts"; import type { buildRouteCard, buildRoutePendingCard } from "./route-card.ts"; /** fitBounds padding over the route geometry — map-space px (MapLibre * padding, not CSS). The camera constants sit BESIDE follow.ts's. */ export const ROUTE_CAMERA_PADDING = { top: 96, bottom: 320, left: 64, right: 64 }; export const ROUTE_FIT_DURATION_MS = 400; /** The boot constant (map.ts), passed explicitly so fitBounds stays center+zoom. */ export const ROUTE_CAMERA_PITCH = 35; export type RouteState = "idle" | "route" | "pending"; 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. */ source: RoutingSource; locale: Locale; buildRouteCard: typeof buildRouteCard; buildPendingCard: typeof buildRoutePendingCard; /** Shows the route/pending 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 }; getLatestFix: () => Fix | null; /** Endpoint markers (origin ring + destination pin) — injected DOM concern. */ attachEndpoints: (firstPoint: RoutePoint, lastPoint: RoutePoint, destinationName: string) => void; removeEndpoints: () => void; /** Fired on every state transition — main.ts derives the BackButton * predicate (visible exactly when a pushed state exists) from it. */ onStateChange: (state: RouteState) => void; } export function createRouteController(deps: RouteControllerDeps): { readonly state: RouteState; consult(fix: Fix, destination: GazetteerEntry): Promise; walkBack(): void; } { let state: RouteState = "idle"; let options: RouteOption[] = []; let selectedIndex = 0; let destinationName = ""; function setState(next: RouteState): void { state = next; deps.onStateChange(next); } /** ONE call per boot (main.ts's consulted flag guards the trigger — this * module stays trigger-agnostic). Auto: live fix + resolved destination. */ async function consult(fix: Fix, destination: GazetteerEntry): Promise { destinationName = destination.name; 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 if (result === null || result.options.length === 0) { deps.showRouteCard(deps.buildPendingCard(deps.locale, { onSearchAgain: () => walkBack() }), "center"); setState("pending"); console.log("ARAHARAH-ROUTE state=pending"); return; } options = result.options; selectedIndex = 0; drawSelection(); setState("route"); console.log("ARAHARAH-ROUTE state=drawn"); } /** 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. */ function select(index: number): void { if (state !== "route") return; const next = options[index]; if (next === undefined) return; selectedIndex = index; drawSelection(); } function drawSelection(): void { const selected = options[selectedIndex]; if (selected === undefined) return; const others = options.filter((_, i) => i !== selectedIndex); setRouteDrawn(deps.map, selected.geometry, others.map((option) => option.geometry)); const points = selected.geometry.points; const first = points[0]; const last = points[points.length - 1]; if (first !== undefined && last !== undefined) deps.attachEndpoints(first, last, destinationName); deps.showRouteCard( deps.buildRouteCard(deps.locale, destinationName, options, selectedIndex, { onSelect: select, onSearchAgain: () => walkBack(), }), "bottom", ); fitSelection(points); } function fitSelection(points: readonly RoutePoint[]): void { if (points.length === 0) return; let lngMin = points[0]!.lng; let latMin = points[0]!.lat; let lngMax = lngMin; let latMax = latMin; for (const p of points) { if (p.lng < lngMin) lngMin = p.lng; if (p.lat < latMin) latMin = p.lat; if (p.lng > lngMax) lngMax = p.lng; if (p.lat > latMax) latMax = p.lat; } deps.map.fitBounds( [ [lngMin, latMin], [lngMax, latMax], ], { padding: ROUTE_CAMERA_PADDING, duration: ROUTE_FIT_DURATION_MS, pitch: ROUTE_CAMERA_PITCH, bearing: deps.map.getBearing(), }, ); } /** THE total function — the BackButton's click and SEARCH AGAIN both land * 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. */ function walkBack(): void { if (state === "route") { clearRouteLayers(deps.map); deps.removeEndpoints(); deps.showDestinationCard(); const fix = deps.getLatestFix(); if (fix !== null) deps.follow.follow(fix); } else if (state === "pending") { deps.showDestinationCard(); } else { return; // already at the root — nothing pushed } setState("idle"); } return { get state(): RouteState { return state; }, consult, walkBack, }; }