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:
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user