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;
},
};
}