feat(06-07): PURE projectTurnCards + makeStreetAt + the 21 turn-card copy keys (GREEN)
- cron.ts: linear progress projection (clamped), stateless 180 s cadence (due = nowSec % 180 < 60), dead-trip gate, classifyTurn-authority next-turn detection over consecutive-leg bearings, 4-line HTML caption anatomy - D-12 street by DEFAULT: makeStreetAt hops snapToGraph → matched segment's nameId → nameAt (never the node index); snap miss → bare-verb degradation; matched-unnamed → cardStreetUnnamed - strings.ts: cardAction* (8) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton, en + ms verbatim - GC1-GC8 + CS7 green: 116 passed, 0 failed; bot:typecheck exit 0
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* cron.ts — the minimized turn-card projection (06-07, GUID-05; D-11/D-12/D-13).
|
||||
* ---------------------------------------------------------------------------
|
||||
* PURE LAW: projectTurnCards and makeStreetAt are PURE — injected nowSec and
|
||||
* graph, no KV, no R2, no Telegram, no clock reads — Node-testable. The
|
||||
* scheduled() wiring lives in webhook.ts (the ExportedHandler's scheduled
|
||||
* arm): it lists guidance:* keys, reads each envelope through the
|
||||
* defensively-validated getGuidanceSession, and pushes due cards. ALL logging
|
||||
* lives there too — this module has no console at all.
|
||||
*
|
||||
* D-12 (street by DEFAULT): the FROZEN envelope carries geometry only (GE2 —
|
||||
* no steps field, and no envelope extension: a 5th property means drift), so
|
||||
* the cron reconstructs the street itself. makeStreetAt snaps the CARD TURN's
|
||||
* vertex to the memoized R2 graph — snapToGraph → the matched segment's
|
||||
* nameId → graph.nameAt(nameId) — the SAME one-authority hop the app-side
|
||||
* banner relies on. Edges carry names; nodes don't: the node index is NEVER
|
||||
* fed to nameAt. DEGRADATION (D-03's designed-degradation pattern): the graph
|
||||
* absent/corrupt in the cron isolate, or a snap miss → the resolver returns
|
||||
* null → the card composes street-less with the bare verbs — honest absence,
|
||||
* never a guessed name. A MATCHED but unnamed edge composes with
|
||||
* cardStreetUnnamed ("UNNAMED ROAD") — still street-bearing, instrument voice.
|
||||
*
|
||||
* D-13 (honesty): the caption's strongest claim is "best-effort projection";
|
||||
* the copy never promises live tracking (banned-word gate in the cron suite).
|
||||
* CADENCE (D-11 discretion, RESEARCH Open Q 3): the crons config ticks every
|
||||
* 60 s; a card is DUE on the first tick of each CARD_CADENCE_S window —
|
||||
* stateless (zero cursor keys, zero KV writes; the 1,000-writes/day free
|
||||
* budget holds — pinned structurally in guidance-cron.test.mjs).
|
||||
*
|
||||
* Card language: the envelope contract carries NO language field (GE2), so
|
||||
* per pickLocale's fallback law the cards are EN; the ms table is kept in
|
||||
* parity by the bot copy gate (CS7) for the day the contract grows a
|
||||
* language surface.
|
||||
*
|
||||
* HTML-escape surface: ZERO. Every caption part is code-composed from
|
||||
* numbers and string-table constants — no envelope-derived string
|
||||
* (destinationName included) enters the caption; street text arrives only
|
||||
* through makeStreetAt's graph lookup (the repo's own OSM-derived graph).
|
||||
*/
|
||||
import {
|
||||
classifyTurn,
|
||||
decodePolyline6,
|
||||
deriveCumulativeDistanceM,
|
||||
haversineM,
|
||||
snapToGraph,
|
||||
type GuidanceTurnKind,
|
||||
type RoadGraph,
|
||||
type RoutePoint,
|
||||
} from "@araharah/routing/src/index.ts";
|
||||
import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts";
|
||||
import { strings } from "../i18n/strings.ts";
|
||||
|
||||
/** The chat card's cadence (D-11/D-13): one card window every 3 minutes;
|
||||
* the window's first tick is the due tick. */
|
||||
export const CARD_CADENCE_S = 180;
|
||||
|
||||
/** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */
|
||||
const SCAN_TICK_S = 60;
|
||||
|
||||
/** Segments shorter than this are GPS/codec noise — no bearing is derived from them. */
|
||||
const DEGENERATE_SEGMENT_M = 1;
|
||||
|
||||
/** v1 is KL-metro only and Malaysia has no DST — the card's ETA clock is KL
|
||||
* wall time (the envelope carries no driver timezone; the Worker's clock is
|
||||
* UTC). Named constant, replay-tunable. */
|
||||
const KL_UTC_OFFSET_S = 8 * 60 * 60;
|
||||
|
||||
/** The turn-card's glyph vocabulary — cron.ts's local union mirroring the 8
|
||||
* committed PNG names (web/public/glyphs/<kind>.png); structurally the
|
||||
* RouteStep.kind union (one authority with engine A's classifyTurn). */
|
||||
export type GlyphKindName = GuidanceTurnKind;
|
||||
|
||||
/** One projection outcome: due + the card to push, or all-null. */
|
||||
export interface TurnCard {
|
||||
due: boolean;
|
||||
glyph: GlyphKindName | null;
|
||||
caption: string | null;
|
||||
maneuverIdx: number | null;
|
||||
}
|
||||
|
||||
function noCard(): TurnCard {
|
||||
return { due: false, glyph: null, caption: null, maneuverIdx: null };
|
||||
}
|
||||
|
||||
/** The instrument register prefix — GUIDE // T-{nn} is code-composed (UI-SPEC). */
|
||||
const GUIDE_REGISTER = "GUIDE // T-";
|
||||
|
||||
/** kind → the strings key carrying that maneuver's street-bearing verb. */
|
||||
const CARD_ACTION_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
|
||||
depart: "cardActionDepart",
|
||||
straight: "cardActionStraight",
|
||||
left: "cardActionLeft",
|
||||
right: "cardActionRight",
|
||||
"slight-left": "cardActionSlightLeft",
|
||||
"slight-right": "cardActionSlightRight",
|
||||
uturn: "cardActionUturn",
|
||||
arrive: "cardActionArrive",
|
||||
};
|
||||
|
||||
/** kind → the strings key carrying that maneuver's street-less (bare) verb. */
|
||||
const CARD_ACTION_BARE_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
|
||||
depart: "cardActionBareDepart",
|
||||
straight: "cardActionBareStraight",
|
||||
left: "cardActionBareLeft",
|
||||
right: "cardActionBareRight",
|
||||
"slight-left": "cardActionBareSlightLeft",
|
||||
"slight-right": "cardActionBareSlightRight",
|
||||
uturn: "cardActionBareUturn",
|
||||
arrive: "cardActionBareArrive",
|
||||
};
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Initial compass bearing a → b, degrees clockwise (the astar.ts convention). */
|
||||
function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
|
||||
const lat1 = a.lat * DEG_TO_RAD;
|
||||
const lat2 = b.lat * DEG_TO_RAD;
|
||||
const dLng = (b.lng - a.lng) * DEG_TO_RAD;
|
||||
const y = Math.sin(dLng) * Math.cos(lat2);
|
||||
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
||||
return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360;
|
||||
}
|
||||
|
||||
/** A maneuver register entry: the carded kind, its geometry vertex (the
|
||||
* streetAt argument) and its meters-along-the-line. */
|
||||
interface TurnEntry {
|
||||
kind: GlyphKindName;
|
||||
vertexIdx: number;
|
||||
alongM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maneuver register from geometry ALONE (the envelope carries no steps
|
||||
* field): depart at the origin, one classified entry per bearing-change
|
||||
* vertex (classifyTurn is the ONE turn authority — "straight" results are
|
||||
* cardable CONTINUE-ON maneuvers), arrive at the drawn end.
|
||||
*/
|
||||
function buildTurnRegister(points: readonly RoutePoint[], cumulative: readonly number[]): TurnEntry[] {
|
||||
const register: TurnEntry[] = [{ kind: "depart", vertexIdx: 0, alongM: 0 }];
|
||||
let prevBearing: number | null = null;
|
||||
let prevSegEnd = -1;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const segM = cumulative[i + 1]! - cumulative[i]!;
|
||||
if (segM < DEGENERATE_SEGMENT_M) continue; // sub-m noise — no bearing, no maneuver
|
||||
const bearing = initialBearingDeg(points[i]!, points[i + 1]!);
|
||||
if (prevBearing !== null) {
|
||||
register.push({
|
||||
kind: classifyTurn(prevBearing, bearing),
|
||||
vertexIdx: prevSegEnd,
|
||||
alongM: cumulative[prevSegEnd]!,
|
||||
});
|
||||
}
|
||||
prevBearing = bearing;
|
||||
prevSegEnd = i + 1;
|
||||
}
|
||||
register.push({
|
||||
kind: "arrive",
|
||||
vertexIdx: points.length - 1,
|
||||
alongM: cumulative[cumulative.length - 1]!,
|
||||
});
|
||||
return register;
|
||||
}
|
||||
|
||||
/** "{n} M" / "{n.n} KM" — the app's instrument distance formats (UI-SPEC). */
|
||||
function formatDistance(m: number): string {
|
||||
const meters = Math.max(0, Math.round(m));
|
||||
if (meters < 1000) return `${meters} M`;
|
||||
return `${(meters / 1000).toFixed(1)} KM`;
|
||||
}
|
||||
|
||||
/** The 24-hour HH:MM arrival clock, KL wall time (see KL_UTC_OFFSET_S). */
|
||||
function etaClock(etaSec: number): string {
|
||||
const at = new Date((etaSec + KL_UTC_OFFSET_S) * 1000);
|
||||
const hh = String(at.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(at.getUTCMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one guidance envelope at `nowSec` into its turn-card decision —
|
||||
* PURE: no KV, no R2, no Telegram, no clock reads.
|
||||
*
|
||||
* - progress: linear along the snapshot (elapsed/duration → distanceM),
|
||||
* clamped to [0, distanceM]
|
||||
* - dead trip (progress ≥ distanceM) → due false, no card (D-13)
|
||||
* - cadence: nowSec % CARD_CADENCE_S < SCAN_TICK_S (D-11, stateless)
|
||||
* - next maneuver: the FIRST register entry whose along-distance ≥ progress
|
||||
* ("straight" results included — CONTINUE ON is a real card); none → the
|
||||
* "straight" fallback at register index 00 (geometry/distance drift)
|
||||
* - maneuver line: street present → "{verb} {STREET}"; null/absent resolver
|
||||
* → the bare verb (honest absence, D-03)
|
||||
*/
|
||||
export function projectTurnCards(
|
||||
envelope: GuidanceSessionEnvelope,
|
||||
nowSec: number,
|
||||
streetAt?: (vertexIdx: number) => string | null,
|
||||
): TurnCard {
|
||||
const snap = envelope.route_snapshot;
|
||||
let points: RoutePoint[];
|
||||
try {
|
||||
points = decodePolyline6(snap.geometry);
|
||||
} catch {
|
||||
return noCard(); // malformed geometry — the mint-side validation refuses it; honest skip
|
||||
}
|
||||
if (points.length < 2) return noCard();
|
||||
|
||||
const elapsedS = nowSec - snap.startedAtSec;
|
||||
const progressM =
|
||||
snap.durationS > 0
|
||||
? Math.max(0, Math.min(snap.distanceM, (snap.distanceM * elapsedS) / snap.durationS))
|
||||
: snap.distanceM;
|
||||
if (progressM >= snap.distanceM) return noCard(); // the trip would have ended — never card a dead trip
|
||||
const due = nowSec % CARD_CADENCE_S < SCAN_TICK_S;
|
||||
if (!due) return noCard(); // a non-due tick composes nothing — the card IS the due decision
|
||||
|
||||
const cumulative = deriveCumulativeDistanceM(points);
|
||||
const register = buildTurnRegister(points, cumulative);
|
||||
const entry = register.find((e) => e.alongM >= progressM) ?? null;
|
||||
const maneuverIdx = entry !== null ? register.indexOf(entry) : 0;
|
||||
const glyph: GlyphKindName = entry !== null ? entry.kind : "straight";
|
||||
const vertexIdx = entry !== null ? entry.vertexIdx : points.length - 1;
|
||||
|
||||
const loc = strings.en;
|
||||
const street = streetAt ? streetAt(vertexIdx) : null;
|
||||
const verb = street ? loc[CARD_ACTION_KEYS[glyph]] : loc[CARD_ACTION_BARE_KEYS[glyph]];
|
||||
// The line's number: the carded turn's remaining meters — or, on the
|
||||
// overshoot fallback, the trip's remaining meters (the only honest number).
|
||||
const distToTurn = entry !== null ? entry.alongM - progressM : snap.distanceM - progressM;
|
||||
|
||||
const remainingM = snap.distanceM - progressM;
|
||||
const remainingS = (snap.durationS * remainingM) / snap.distanceM;
|
||||
const caption = [
|
||||
`${GUIDE_REGISTER}${String(maneuverIdx).padStart(2, "0")}`,
|
||||
`<b>${formatDistance(distToTurn)} — ${verb}${street ? ` ${street.toUpperCase()}` : ""}</b>`,
|
||||
loc.cardEtaTemplate.replace("{time}", etaClock(nowSec + remainingS)).replace("{km}", (remainingM / 1000).toFixed(1)),
|
||||
loc.cardHonesty,
|
||||
].join("\n");
|
||||
return { due, glyph, caption, maneuverIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph→street resolver factory — PURE: graph + decoded points in,
|
||||
* resolver out; snapToGraph + nameAt imported from @araharah/routing, zero
|
||||
* I/O. The cron wiring builds ONE resolver per carded envelope (its own
|
||||
* geometry) and calls it ONLY for the card turn's vertex — one snap per
|
||||
* carded tick, never a walk of the whole line (the bounded-snapping law;
|
||||
* the CPU tradeoff is a named ratification item in 06-08's checkpoint).
|
||||
*/
|
||||
export function makeStreetAt(
|
||||
graph: RoadGraph,
|
||||
points: readonly RoutePoint[],
|
||||
): (vertexIdx: number) => string | null {
|
||||
return (vertexIdx: number): string | null => {
|
||||
const point = points[vertexIdx];
|
||||
if (!point) return null;
|
||||
const snapped = snapToGraph(graph, point);
|
||||
if (snapped === null) return null; // snap miss — honest absence (D-03), never a guessed name
|
||||
if (snapped.nameId === null) return strings.en.cardStreetUnnamed; // matched but unnamed — instrument fallback
|
||||
return graph.nameAt(snapped.nameId) ?? null; // the matched segment's nameId — NEVER the node index
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user