feat(06-06): the D-10 locked-follow guidance camera + the context-aware BackButton resolver (GREEN)

- guidance/camera.ts: GUIDANCE_CAMERA_PADDING {320,64,64,64} / ZOOM 16 /
  EASE_MS 400 / PITCH 35; easeToSnapped rides the follow controller (ONE
  easing discipline) and never passes bearing — north-up, recorded call;
  onRerouteApplied is the documented camera no-op (setData lives in the
  route layer)
- map/follow.ts: follow(point, opts?) pose overrides spread over the
  Phase 3/5 defaults — no-opts calls stay byte-identical (RS13); the
  re-lock announces the mode (the recenter notch's return signal, RS12);
  the originalEvent unlock law untouched
- telegram/backbutton.ts: BackAction resolver ({hidden|close|callback})
  consulted per click + wire.sync() visibility re-apply (visible iff
  actionable); the legacy (hasOverlay, walkBack) shape composes internally
  so Phase 5 call sites and RS7 stay valid; close() only at the true root
This commit is contained in:
araharah-executor
2026-10-24 14:47:50 +08:00
parent 77b62da111
commit 2f12c41060
3 changed files with 169 additions and 17 deletions
+79
View File
@@ -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<FollowOptions["padding"]> = {
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;
},
};
}
+26 -3
View File
@@ -7,6 +7,12 @@
* Micro-Behaviors). A USER gesture (movestart carrying originalEvent) flips * Micro-Behaviors). A USER gesture (movestart carrying originalEvent) flips
* to free — a programmatic move never does (T-03-16: the fix update must * to free — a programmatic move never does (T-03-16: the fix update must
* never read as the user having panned). * 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 { Map as MapLibreMap } from "maplibre-gl";
import type { Fix } from "../location/types.ts"; import type { Fix } from "../location/types.ts";
@@ -16,6 +22,16 @@ export const RECENTER_DURATION_MS = 400;
export type FollowMode = "following" | "free"; 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) { export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) {
let mode: FollowMode = "following"; let mode: FollowMode = "following";
let lock = false; let lock = false;
@@ -33,10 +49,17 @@ export function createFollowController(map: MapLibreMap, onModeChange: (m: Follo
get mode(): FollowMode { get mode(): FollowMode {
return mode; return mode;
}, },
follow(fix: Fix): void { follow(point: Fix | { lat: number; lng: number }, opts: FollowOptions = {}): void {
lock = true; lock = true;
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS }); map.flyTo({
set("following"); 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; lock = false;
}, },
}; };
+65 -15
View File
@@ -2,26 +2,76 @@
* backbutton.ts — D-10 / UI-SPEC §BackButton Contract (the no-dead-ends wire). * 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 * 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 * and its handler ship as ONE change so the state graph extends the wire
* rather than reinventing the wire. Phase 5 gives the predicate its first * rather than reinventing it. Phase 5 gave the predicate its first real
* real states (route or pending); Phase 6 extends the graph. * 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 * search(root) → routes → guidance → (abandon confirm) → routes → search
* 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 * The CONTEXT-AWARE form: `wireBackButton(wa, resolveAction)` where
* overlay (at the root) it performs Telegram's own semantics: * `BackAction = { kind: "hidden" } | { kind: "close" } | { kind: "callback" }`
* `WebApp.close()`. An empty / no-op handler is forbidden (no dead-ends); * decides, PER CLICK and PER STATE CHANGE (`wire.sync()`), what back means:
* the copy-gate test greps for `close()` in this file. *
* 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"; import type { TelegramWebAppLike } from "./webapp.ts";
export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean, walkBack?: () => void): void { /** What Back means in the CURRENT app state — the resolver's whole vocabulary. */
wa.BackButton?.hide(); // boot starts at the root — nothing pushed yet 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(() => { wa.BackButton?.onClick(() => {
if (hasOverlay()) { const action = resolve();
walkBack?.(); // a pushed state: walk to the root, never close() if (action.kind === "close") {
wa.close(); // Telegram semantics: back at the true root closes the app
return; 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 };
} }