feat(05-04): route controller + cards + boot wiring + two-state BackButton walk
- route-card.ts: buildRouteCard (register ROUTE{n/N}, verbatim-name
headline, telemetry-num MIN/KM row, difference line, N-1 chips with
aria labels + data-route-index, ONE btn-secondary) + buildRoutePendingCard
(register/body/hint/CTA, standard ink, no red) — createElement +
textContent only, strings-module copy, no padding overrides
- route.ts: createRouteController — idle|route|pending, consult once
(.catch(() => null) → pending, T-05-04), chip swap + ALWAYS refit,
walkBack total function (clear + destination card + refollow), camera
constants beside follow.ts, two ARAHARAH-ROUTE state tokens as the
module's ONLY console lines (D-15/T-05-13)
- main.ts: routePhase/routeWalkBack assignable closures, .route-stack +
showRouteCard, controller wired with null-tolerant map/follow/endpoints
(WebGL2-absent degrades to cards-only), consult-once guard in handleLive,
stale branch keeps the route card (strip lamp carries the signal)
- backbutton.ts: predicate + handler as ONE change — overlay click walks
to the root via the injected walkBack, root click keeps Telegram close()
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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<void>;
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user