diff --git a/web/src/guidance/camera.ts b/web/src/guidance/camera.ts new file mode 100644 index 0000000..f485e01 --- /dev/null +++ b/web/src/guidance/camera.ts @@ -0,0 +1,79 @@ +/* + * camera.ts — the D-10 locked-follow guidance camera. + * ----------------------------------------------------------------------------- + * Guidance's camera rides the SNAPPED line (one authority — D-05): every + * accepted fix eases to the puck's foot on the route, never the raw GPS + * fix. The pose is the UI-SPEC camera table, pinned here as named + * constants (RS11 asserts the values): + * + * padding {top 320, bottom 64, left 64, right 64} — map-space px; + * T − B = 256 ≈ 0.30 × 844 reference viewport → center ≈65% down-screen + * pitch 35 — the standing pitch; guidance never changes it (D-10) + * zoom 16 — GUIDANCE_ZOOM, urban detail (replay-tunable) + * 400 ms — GUIDANCE_EASE_MS, the RECENTER_DURATION_MS easing family + * + * BEARING IS NEVER PASSED — north-up is a recorded call (UI-SPEC §Camera + * Behavior): the heading cone already communicates direction and a rotating + * map contradicts the calm-instrument posture. RS11 pins the absence of the + * bearing key on every ease. + * + * The ease rides the Phase 3 follow controller (ONE easing discipline, ONE + * unlock law): follow.ts's originalEvent rule is the whole mechanism — a + * programmatic ease never reads as the user panning; a user gesture unlocks + * and the recenter control's notch clears (RS12). onRerouteApplied is a + * DOCUMENTED camera no-op: the rerouted line snaps in via setData in the + * route layer and the next per-fix ease settles the viewport — no camera + * jump (UI-SPEC camera table, "Reroute snaps in" row). + * + * Node-testable: maplibre appears only as erased types; the ease is a call + * on the injected follow controller (the route-surface fakeMap pattern). + */ +import type { Map as MapLibreMap } from "maplibre-gl"; +import type { RoutePoint } from "@araharah/routing/src/index.ts"; +import type { FollowOptions } from "../map/follow.ts"; + +/** The UI-SPEC guidance padding — center ≈65% down-screen (D-10). */ +export const GUIDANCE_CAMERA_PADDING: NonNullable = { + top: 320, + bottom: 64, + left: 64, + right: 64, +}; +/** Urban-detail zoom (the UI-SPEC camera table's GUIDANCE_ZOOM). */ +export const GUIDANCE_ZOOM = 16; +/** The one easing discipline — 400 ms (the follow.ts family). */ +export const GUIDANCE_EASE_MS = 400; +/** The standing pitch — guidance never changes it (D-10). */ +export const GUIDANCE_CAMERA_PITCH = 35; + +export interface GuidanceCamera { + /** Ease to the SNAPPED foot on the route line at the guidance pose. */ + easeToSnapped(point: RoutePoint): void; + /** The reroute snap-in: a documented camera no-op — the new line's setData + * happens in the route layer; the next per-fix ease settles the viewport. */ + onRerouteApplied(): void; +} + +/** The pose's executor is the follow controller (one easing discipline; the + * `map` parameter names the viewport the pose applies to and keeps the + * constructor symmetric with createFollowController). */ +export function createGuidanceCamera( + map: MapLibreMap | null, + followController: { follow(point: { lat: number; lng: number }, opts?: FollowOptions): void }, +): GuidanceCamera { + return { + easeToSnapped(point: RoutePoint): void { + followController.follow(point, { + padding: GUIDANCE_CAMERA_PADDING, + pitch: GUIDANCE_CAMERA_PITCH, + zoom: GUIDANCE_ZOOM, + durationMs: GUIDANCE_EASE_MS, + }); + }, + onRerouteApplied(): void { + // camera no-op by contract — RS11 pins zero moves (map motion settles + // naturally on the next per-fix ease; UI-SPEC "Reroute snaps in"). + void map; + }, + }; +} diff --git a/web/src/map/follow.ts b/web/src/map/follow.ts index f39a024..db6c5cb 100644 --- a/web/src/map/follow.ts +++ b/web/src/map/follow.ts @@ -7,6 +7,12 @@ * Micro-Behaviors). A USER gesture (movestart carrying originalEvent) flips * to free — a programmatic move never does (T-03-16: the fix update must * never read as the user having panned). + * + * Phase 6 (D-10): follow() takes optional pose overrides so the guidance + * camera rides the SAME easing discipline and the SAME unlock law with the + * guidance pose (padding {320,64,64,64}, pitch 35, zoom 16, 400ms — the + * constants live in ../guidance/camera.ts). Absent fields fall back to the + * Phase 3/5 defaults — existing callers stay byte-identical (RS13). */ import type { Map as MapLibreMap } from "maplibre-gl"; import type { Fix } from "../location/types.ts"; @@ -16,6 +22,16 @@ export const RECENTER_DURATION_MS = 400; export type FollowMode = "following" | "free"; +/** Optional camera-pose overrides (D-10's guidance pose rides these). + * Padding accepts MapLibre's number | per-edge form; pitch/zoom are only + * passed when provided (the no-opts call keeps its exact key set). */ +export interface FollowOptions { + padding?: number | { top: number; bottom: number; left: number; right: number }; + pitch?: number; + zoom?: number; + durationMs?: number; +} + export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) { let mode: FollowMode = "following"; let lock = false; @@ -33,10 +49,17 @@ export function createFollowController(map: MapLibreMap, onModeChange: (m: Follo get mode(): FollowMode { return mode; }, - follow(fix: Fix): void { + follow(point: Fix | { lat: number; lng: number }, opts: FollowOptions = {}): void { lock = true; - map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS }); - set("following"); + map.flyTo({ + center: [point.lng, point.lat], + padding: opts.padding ?? CAMERA_PADDING_PX, + duration: opts.durationMs ?? RECENTER_DURATION_MS, + ...(opts.pitch !== undefined ? { pitch: opts.pitch } : {}), + ...(opts.zoom !== undefined ? { zoom: opts.zoom } : {}), + }); + mode = "following"; + onModeChange(mode); // the re-lock is announced (the recenter notch's return signal) lock = false; }, }; diff --git a/web/src/telegram/backbutton.ts b/web/src/telegram/backbutton.ts index 3fd8dd6..cb62a30 100644 --- a/web/src/telegram/backbutton.ts +++ b/web/src/telegram/backbutton.ts @@ -2,26 +2,76 @@ * backbutton.ts — D-10 / UI-SPEC §BackButton Contract (the no-dead-ends wire). * ----------------------------------------------------------------------------- * The button is visible exactly when a PUSHED state exists — its predicate - * and its handler ship as ONE change so Phase 6 extends the state graph - * rather than reinventing the wire. Phase 5 gives the predicate its first - * real states (route or pending); Phase 6 extends the graph. + * and its handler ship as ONE change so the state graph extends the wire + * rather than reinventing it. Phase 5 gave the predicate its first real + * states (route or pending); Phase 6 (PX-01) grows the graph to its full + * form and the wire grows with it: * - * With an overlay (a pushed state) the click invokes the injected - * walkBack — the SAME total function SEARCH AGAIN binds — walking to the - * root, never closing the app from a pushed state (T-05-11). With no - * overlay (at the root) it performs Telegram's own semantics: - * `WebApp.close()`. An empty / no-op handler is forbidden (no dead-ends); - * the copy-gate test greps for `close()` in this file. + * search(root) → routes → guidance → (abandon confirm) → routes → search + * + * The CONTEXT-AWARE form: `wireBackButton(wa, resolveAction)` where + * `BackAction = { kind: "hidden" } | { kind: "close" } | { kind: "callback" }` + * decides, PER CLICK and PER STATE CHANGE (`wire.sync()`), what back means: + * + * hidden — nothing pushed: the button hides (the root law) + * close — visible + actionable; the click is Telegram's own semantics + * (`WebApp.close()` — the true root's exit) + * callback — visible + actionable; the click runs the state's own walk + * (route → the Phase 5 walk; guidance → opens the D-09 abandon + * confirm card — NEVER teardown; confirm open → CONTINUE, + * guidance alive; trip-ended → the walk to the search root) + * + * The laws hold for EVERY state: visible iff actionable, the handler is + * never a no-op, `close()` fires only where the resolver says so — never + * from a pushed state (T-05-11). An empty / no-op handler is forbidden + * (no dead ends); the copy-gate test greps for `close()` in this file. + * + * The PHASE 5 call shape — `wireBackButton(wa, hasOverlay, walkBack)` — + * composes into the resolver internally (overlay ? callback(walkBack) : + * close) so existing call sites and RS7 stay valid unchanged. */ import type { TelegramWebAppLike } from "./webapp.ts"; -export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean, walkBack?: () => void): void { - wa.BackButton?.hide(); // boot starts at the root — nothing pushed yet +/** What Back means in the CURRENT app state — the resolver's whole vocabulary. */ +export type BackAction = { kind: "hidden" } | { kind: "close" } | { kind: "callback"; fn: () => void }; + +export interface BackButtonWire { + /** Re-apply the visibility law after an app-state change (the shell calls + * this from onStateChange): visible exactly when the action is actionable. */ + sync(): void; +} + +export function wireBackButton(wa: TelegramWebAppLike, resolveAction: () => BackAction): BackButtonWire; +export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean, walkBack: () => void): BackButtonWire; +export function wireBackButton( + wa: TelegramWebAppLike, + resolveOrOverlay: (() => BackAction) | (() => boolean), + walkBack?: () => void, +): BackButtonWire { + const resolve: () => BackAction = + typeof walkBack === "function" + ? () => ((resolveOrOverlay as () => boolean)() ? { kind: "callback", fn: walkBack } : { kind: "close" }) + : (resolveOrOverlay as () => BackAction); + const sync = (): void => { + if (resolve().kind === "hidden") wa.BackButton?.hide(); + else wa.BackButton?.show(); // visible exactly when the action is actionable + }; + if (typeof walkBack === "function") { + wa.BackButton?.hide(); // legacy Phase 5 law: boot starts at the root — nothing pushed yet + } else { + sync(); // resolver form: the resolver is the truth-sayer — apply it once at the wire + } wa.BackButton?.onClick(() => { - if (hasOverlay()) { - walkBack?.(); // a pushed state: walk to the root, never close() + const action = resolve(); + if (action.kind === "close") { + wa.close(); // Telegram semantics: back at the true root closes the app return; } - wa.close(); // Telegram semantics: back at the root closes the app + if (action.kind === "callback") { + action.fn(); // the pushed state's own walk — never close() from a pushed state + return; + } + wa.BackButton?.hide(); // a click in a hidden state is defensive — re-sync the chrome }); + return { sync }; }