Files
gods-eye-view/src/splitFlap.js
T
Sameh Khamis 3c391482f6 feat(ui): render the status chips and their split-flap labels from a store
ShellFeedback publishes the loading status and the road network chip, and
CctvControls the camera grid chip, to the gev-status store; the scene
chrome renders them. SplitFlapText plays the departure-board cascade that
splitFlap.js plans, writing only the label's one text node, and rests when
its owner is destroyed. setSplitFlapText and disposeSplitFlap are gone.
2026-10-31 19:51:39 -07:00

273 lines
11 KiB
JavaScript

/**
* Split-flap (departure-board) text animation for the status chips.
*
* When a chip's label changes — "LOADING LIVE DATA" → "LOAD COMPLETE",
* "loading frames" → "camera grid ready" — the characters flip over
* mechanically, left to right, like a Solari board at a ferry terminal.
*
* This module plans the cascade; the SplitFlapText view
* (src/ui/views/panels/SplitFlapText.vue) renders it. FOUR INVARIANTS HOLD
* THIS TOGETHER — do not "fix" any of them:
*
* 1. DOM TEXT IS THE TRUTH, AND ITS NODE NEVER MOVES. The label is a
* `.gev-flap-text` span holding one long-lived `Text` node, beside an
* `aria-hidden` `.gev-flap-cells` sibling. The ONLY text operation for the
* life of the label is a change to that node's data — a single
* `characterData` mutation. Nothing is ever reparented, so the label is
* never transiently empty and the `aria-live` region never sees a removal /
* reinsertion pair it could announce twice. `element.textContent` is the
* settled string at every instant, because the cells carry NO text at all:
* both glyphs are CSS generated content (`::before` from `data-flap-prev`,
* `::after` from `data-flap-next`), which never reaches `textContent`.
* QA pins therefore always read the truth; the flaps are purely visual.
*
* 2. NO ANIMATION LOOP, AND EXACTLY ONE TIMER PER CHANGE. The motion is CSS
* `animation`/`transition` only, triggered once per text change and
* staggered via a per-cell `--gev-flap-delay` custom property. A change
* schedules exactly ONE `setTimeout` — the settle that strips the cells.
* The width ease ends on a `transitionend`/`transitioncancel` listener,
* not a second timer. There is zero per-frame JS and zero periodic work;
* idle cost is nothing, and nothing here requests a Cesium render — the
* chips are DOM, outside the panel-stack observers, so the render governor
* is untouched.
*
* 3. ONLY WHAT WAS VISIBLE FLAPS AWAY. An interrupted cascade (A→B cut short
* by C) derives each column's outgoing glyph from what that column is
* actually SHOWING at that instant — which for a column whose stagger has
* not elapsed yet is still A, not the pending B. Deriving it from the
* pending target would flash a glyph that was never on screen.
*
* 4. COLUMNS NEVER RENUMBER MID-CASCADE. For the whole cascade the board keeps
* one column per index of the LONGER string, each holding its own width.
* A column the new string does not reach flaps its old glyph to a BLANK in
* place — exactly how a departure board clears a cell — rather than
* collapsing to zero width. Collapsing would stack the absolutely-positioned
* outgoing glyphs on top of each other and would let a later glyph slide
* into an earlier column, which in turn would make invariant 3 lie. The
* container only takes up the slack AFTER every flap has landed, as one
* smooth width ease.
*
* The motion vocabulary deliberately matches the cockpit odometer roll
* (`cockpitRollingValue.js`, style.css `.cockpit-roll-token`): ~200 ms per
* character on `cubic-bezier(0.2, 0.75, 0.25, 1)`, reduced-motion collapsing
* the duration rather than removing the animation. What this adds over the
* odometer is the CASCADE — the left-to-right sweep that makes a departure
* board read as a departure board.
*/
/**
* One-line kill switch. Flip to `false` and every chip goes back to a plain
* instant text swap with no other code change.
*/
export const SPLIT_FLAP_ENABLED = true;
/** Time one character spends flipping. */
export const FLAP_CHAR_MS = 190;
/** Nominal gap between consecutive characters starting their flip. */
export const FLAP_STAGGER_MS = 26;
/**
* Hard ceiling for a whole cascade. Long labels compress their stagger to
* fit rather than running on — a chip that flaps for a full second reads as
* a slot machine, not a quiet mechanical settle.
*/
export const FLAP_MAX_TOTAL_MS = 620;
/** Slack after the last character lands before the cells are stripped. */
export const FLAP_SETTLE_SLACK_MS = 60;
/**
* Fraction of a character's flip at which the outgoing glyph has rotated
* away and the incoming one takes over as what the eye reads. MUST track the
* `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css — it is what
* makes an interrupted cascade pick honest outgoing glyphs.
*/
export const FLAP_TURN_RATIO = 0.5;
/**
* What a reserved-but-empty column shows. A column the new string does not
* reach is not removed mid-cascade — it flaps to a blank and holds its place,
* which is both what a real board does and what keeps column indices stable.
*/
export const FLAP_BLANK = ' ';
function positiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
/**
* Diff two strings into per-character flap cells with staggered delays.
*
* Unchanged characters do not animate — a real split-flap cell that already
* shows the right glyph simply does not move, which is both authentic and
* free. The stagger is rebased on the FIRST changed column so a label whose
* head is stable ("LOAD COMPLETE" keeps "LOAD" from "LOADING LIVE DATA")
* starts flipping immediately instead of idling through untouched columns.
*
* Pure — no DOM, no clock. This is the unit-tested core.
*
* @param {string} fromText - Glyphs currently on screen.
* @param {string} toText - Text to settle on.
* @param {{charMs?: number, staggerMs?: number, maxTotalMs?: number}} [options]
* @returns {{
* cells: Array<{index: number, from: string, to: string, changed: boolean,
* vacating: boolean, delayMs: number}>,
* durationMs: number, staggerMs: number, changedCount: number,
* firstChanged: number, lastChanged: number,
* }}
*/
export function planSplitFlap(fromText, toText, options = {}) {
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
const baseStagger = positiveNumber(options.staggerMs, FLAP_STAGGER_MS);
const maxTotalMs = positiveNumber(options.maxTotalMs, FLAP_MAX_TOTAL_MS);
// Code-point safe: a chip label may carry a separator like '·'.
const fromChars = Array.from(String(fromText ?? ''));
const toChars = Array.from(String(toText ?? ''));
const width = Math.max(fromChars.length, toChars.length);
let firstChanged = -1;
let lastChanged = -1;
for (let index = 0; index < width; index += 1) {
if ((fromChars[index] ?? '') !== (toChars[index] ?? '')) {
if (firstChanged < 0) firstChanged = index;
lastChanged = index;
}
}
if (firstChanged < 0) {
return {
cells: [],
durationMs: 0,
staggerMs: 0,
changedCount: 0,
firstChanged: -1,
lastChanged: -1,
};
}
// Compress the stagger so the whole cascade fits the budget.
const span = lastChanged - firstChanged + 1;
const room = Math.max(0, maxTotalMs - charMs);
const staggerMs = span > 1 ? Math.min(baseStagger, room / (span - 1)) : 0;
const cells = [];
let changedCount = 0;
for (let index = 0; index < width; index += 1) {
const from = fromChars[index] ?? '';
const to = toChars[index] ?? '';
const changed = from !== to;
if (changed) changedCount += 1;
cells.push({
index,
from,
to,
changed,
// The string shrank: this column has an old glyph to flap away and no
// new one, so it flaps to a blank and holds its place (invariant 4).
vacating: changed && to === '' && from !== '',
delayMs: changed ? Math.round((index - firstChanged) * staggerMs) : 0,
});
}
return {
cells,
durationMs: Math.round((lastChanged - firstChanged) * staggerMs + charMs),
staggerMs,
changedCount,
firstChanged,
lastChanged,
};
}
/**
* What one column is showing, before or after it turns over.
*
* A column with no glyph on the side being asked for is a RESERVED BLANK, not
* an absence: it is still on the board, holding its width and its index. That
* is what keeps the returned string positionally true.
*/
function displayedGlyph(cell, turned) {
if (!cell.changed) return cell.to;
return (turned ? cell.to : cell.from) || FLAP_BLANK;
}
/**
* The glyphs a cascade is actually SHOWING at `elapsedMs`.
*
* A column that has not reached its stagger delay yet still displays its old
* glyph; one past the halfway turn displays its new one; unchanged columns
* never moved. This is what an interrupting change must flap away from, so
* no column ever shows a glyph that was never visually current.
*
* The result is positionally true by construction: it always has exactly one
* character per column, so index N of the string is always column N of the
* board. A cleared or not-yet-filled column reads as a blank rather than
* closing up — otherwise a later glyph would appear to occupy an earlier
* column and an interrupt would flap the wrong glyph away.
*
* Pure — no DOM.
*
* @param {{cells: Array<object>}} plan - A plan from `planSplitFlap`.
* @param {number} elapsedMs - Milliseconds since the cascade started.
* @param {{charMs?: number, turnRatio?: number}} [options]
* @returns {string} The currently displayed string, one character per column.
*/
export function visibleGlyphs(plan, elapsedMs, options = {}) {
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
const turnRatio = Number.isFinite(Number(options.turnRatio))
? Number(options.turnRatio)
: FLAP_TURN_RATIO;
const elapsed = Number.isFinite(Number(elapsedMs))
? Math.max(0, Number(elapsedMs))
: 0;
const turn = charMs * turnRatio;
let out = '';
for (const cell of plan?.cells || []) {
out += displayedGlyph(cell, elapsed >= cell.delayMs + turn);
}
return out;
}
/** Whether the viewer asked for reduced motion. */
export function prefersReducedMotion() {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/**
* Whether an element is actually on screen right now.
*
* `getClientRects()` alone is not enough: clean-UI and recording mode hide the
* chips with `opacity: 0 !important; visibility: hidden !important` on the
* chip CONTAINER, and the traffic/CCTV chips sit at `opacity: 0` until they
* get `.visible`. All of those still report client rects, so a naive check
* would animate a chip nobody can see. `checkVisibility` walks ancestors for
* exactly these properties; the fallback walks them by hand.
*/
export function isOnScreen(element) {
if (!element?.isConnected) return false;
if (typeof element.checkVisibility === 'function') {
return element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true,
});
}
if (typeof element.getClientRects !== 'function') return false;
if (element.getClientRects().length === 0) return false;
const view = element.ownerDocument?.defaultView;
if (typeof view?.getComputedStyle !== 'function') return true;
for (
let node = element;
node && node.nodeType === 1;
node = node.parentElement
) {
const style = view.getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none') return false;
if (Number(style.opacity) === 0) return false;
}
return true;
}