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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 19:51:39 -07:00
1 parent 492b719f19
commit 3c391482f6
21 files changed
+838 -428

No files matched your search

+4 -4
View File
@@ -5551,10 +5551,10 @@ pins and share links already supply complete boxes.
### Split-flap status chips (August 2026)
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). All three route through `setSplitFlapText()` in `src/splitFlap.js`; there is no other writer of those three elements. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: `ensureHost()` re-validates the shell on every call, and the long-lived `Text` node is never replaced.
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The first call upgrades a chip label into a permanent shell (`ensureHost`): a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling. After that the ONLY text operation for the life of the chip is `node.data = next`. Nothing is 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` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest and lets `_updateTrafficSyncChip`'s own `textContent !==` guard keep working. The shell is built on a tick where the text is NOT changing, so no real label change ever carries a structural mutation.
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. `setSplitFlapText` is a no-op on unchanged text, which is required — the chips are repainted by a 60 ms and a 500 ms ticker.
- The three status chips flip their LABELS over character by character when the text changes, like a departure board: `#global-loading-label` ("LOADING LIVE DATA" → "LOAD COMPLETE"), `#traffic-sync-label`, and `#cctv-sync-label` ("loading frames" → "camera grid ready"). ShellFeedback and CctvControls publish them to the `gev-status` store, and the scene chrome renders each label with `SplitFlapText.vue`, which plays the cascade `src/splitFlap.js` plans; there is no other writer of those three labels. When an owner is destroyed it publishes the label `stopped`, which rests it at once. The progress counters (`#*-sync-progress`, `#global-loading-detail`) are deliberately left as plain `textContent` — they tick several times a second, and flapping them reads as a slot machine.
- `#global-loading-label` doubles as the universal top-center status banner, so anything routed through `_showGlobalStatusNotice()` flaps as well — in particular the share-link restore notices, of which "Shared military flight could not be restored — feed unavailable" is the longest at 63 characters. That needs no special case: `planSplitFlap()` compresses the stagger to hold the 620 ms budget (26 ms → 6.9 ms per column at that length), every column is reserved for the whole cascade, and `element.textContent` is the complete notice at every instant, so the `aria-live` region announces the whole sentence rather than a fragment. A notice deferred minutes past boot is equally safe: the long-lived `Text` node is never replaced.
- **DOM text is the truth, and its node NEVER moves — do not "fix" this.** The label is a `.gev-flap-text` span holding one long-lived `Text` node, plus an `aria-hidden` `.gev-flap-cells` sibling; the component's `v-flap-text` directive writes it, because Vue would patch a lone `{{ text }}` child by setting `textContent` and swap the node. The ONLY text operation for the life of the chip is `node.data = next`. Nothing is 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` = outgoing, `::after` from `data-flap-next` = incoming), which never reaches `textContent`. This keeps QA pins honest. Whether the label is on screen is read once the change has rendered, after its chip shows or hides with it; the cells go in before the browser paints.
- **No animation loop, and exactly ONE `setTimeout` per change.** CSS `animation`/`transition` only, triggered once per text change and staggered through a per-cell `--gev-flap-delay`. The single timer is the settle that strips the cells; the width ease ends on a `transitionend`/`transitioncancel` listener, never a second timer. Idle cost is zero, there is no periodic work, and nothing requests a Cesium render or takes a render-governor hold. An unchanged label is not republished, which is required — the chips are recomputed by a 60 ms and a 500 ms ticker.
- **Only what was visible flaps away.** An interrupted cascade (A→B cut short by C) derives each column's outgoing glyph from `visibleGlyphs()` — what that column is actually SHOWING at that instant, which for a column whose stagger has not elapsed is still A, not the pending B. `FLAP_TURN_RATIO` must track the `gev-flap-out`/`gev-flap-in` keyframe crossover in style.css.
- **Columns never renumber mid-cascade — do not "optimise" this away.** 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 to a BLANK in place (`data-flap-next=" "`) rather than collapsing. Collapsing stacks the absolutely-positioned outgoing glyphs on one x AND lets a later glyph slide into an earlier column, which makes `visibleGlyphs()` lie and the interrupt rule flap the wrong glyph away. Pinned by "a cleared column holds its place instead of letting later glyphs slide left".
- Length changes are eased, never snapped, and the ease is placed so it never fights the flaps (`.gev-flap-sizing`): a GROWING label reserves its columns as the cells go in and eases at the START; a SHRINKING one holds full width for the whole cascade and eases at SETTLEMENT.
+5 -1
View File
@@ -143,7 +143,11 @@ a feature supplying only its accent.
imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each
before/after divider that `createImagerySplit` publishes to the
`gev-imagery-split` store to the body; the split keeps the
drag, keyboard, CSS position and scene split), then
drag, keyboard, CSS position and scene split), the status chips
(the top-center loading status and the road network and camera grid
chips render what ShellFeedback and CctvControls publish to the
`gev-status` store; `SplitFlapText` plays the departure-board flip that
`splitFlap.js` plans, writing only the label's one text node), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
+4 -1
View File
@@ -1127,5 +1127,8 @@
"build/vueLoader.js",
"build/registerVue.js",
"src/ui/panelNavigation.test.mjs",
"src/ui/stores/panels.test.mjs"
"src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs",
"src/ui/statusChipOwners.test.mjs"
]
+10 -5
View File
@@ -94,7 +94,10 @@
"src/ui/rail/statusText.js",
"src/voice/control.js",
"src/ui/views/panels/ImagerySplitView.vue",
"src/ui/stores/imagerySplit.js"
"src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js",
"src/ui/views/panels/SplitFlapText.vue",
"src/splitFlap.js"
],
"external": ["vue", "pinia"]
},
@@ -702,7 +705,8 @@
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/ui/vueRuntime.js",
"src/ui/stores/cctv.js"
"src/ui/stores/cctv.js",
"src/ui/stores/status.js"
],
"external": ["vue", "pinia"]
},
@@ -847,7 +851,6 @@
"src/navigationPolicy.js",
"src/panelStackLayout.js",
"src/rightRailPolicy.js",
"src/splitFlap.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
@@ -1012,7 +1015,8 @@
"src/ui/stores/sceneSharing.js",
"src/ui/stores/voice.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js"
"src/ui/stores/display.js",
"src/ui/stores/status.js"
],
"external": ["cesium", "vue", "pinia"]
},
@@ -2995,7 +2999,8 @@
"src/ui/stores/voice.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js",
"src/ui/stores/imagerySplit.js"
"src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js"
],
"external": [
"hls.js",
+12 -3
View File
@@ -118,10 +118,18 @@ try {
if (type === 'resize' && callback === resizeHandler) counts.resize++;
return removeEventListener.call(this, type, callback, options);
};
const { setSplitFlapText } = await import('/src/splitFlap.js');
// Through the owner, so its copy of the label and the store agree.
const feedbackLabel = document.getElementById('global-loading-label');
setSplitFlapText(feedbackLabel, 'CHECKING LIVE DATA');
setSplitFlapText(feedbackLabel, 'LOAD COMPLETE');
for (const label of ['CHECKING LIVE DATA', 'LOAD COMPLETE']) {
ui._feedback._publishLoading({
hidden: false,
state: 'loading',
label,
detail: '',
});
// Vue renders on a microtask; a task later the label shows it.
await new Promise((resolve) => setTimeout(resolve));
}
const permanentText =
feedbackLabel.querySelector('.gev-flap-text')?.firstChild;
let stateNotifications = 0;
@@ -202,6 +210,7 @@ try {
feedbackLabel.querySelector('.gev-flap-text')?.firstChild ===
permanentText &&
permanentText.data === 'LOAD COMPLETE' &&
document.getElementById('global-loading-status').hidden &&
!feedbackLabel.classList.contains('gev-flap-active') &&
feedbackLabel.querySelector('.gev-flap-cells')?.childNodes.length ===
0,
+4 -14
View File
@@ -209,7 +209,7 @@ test('universal notice lifecycle clears on dispose and uses the one top-center l
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
const dispose = ui.slice(disposeStart, disposeEnd);
assert.match(dispose, /this\._feedback\._globalStatusNotice = null;/);
assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/);
assert.match(dispose, /this\._shareRestoration\.destroy\(\)/);
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
assert.match(html, /<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>/);
@@ -539,20 +539,10 @@ test('the settled traffic chip shows exactly one percentage — the coverage it
assert.doesNotMatch(rendered, /100%/);
});
test('the chip renderer clears the progress slot instead of stranding the last value', () => {
const ui = readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8');
test('an emptied progress slot collapses rather than leaving a min-width stub', () => {
// ShellFeedback publishes the empty settled progress
// (src/ui/statusChipOwners.test.mjs); the slot must then take no room.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const start = ui.indexOf(' _updateTrafficSyncChip(');
assert.ok(start > 0, '_updateTrafficSyncChip is missing');
const body = ui.slice(start, ui.indexOf('\n }', start));
// A truthiness guard here would leave the busy "..." sitting beside the
// settled label, which is the contradiction wearing a different hat.
assert.doesNotMatch(body, /if \(presentation\.progressText\s*\n?\s*&&/);
assert.match(
body,
/if \(this\._trafficSyncProgress\.textContent !== presentation\.progressText\) \{/,
);
// …and the emptied slot must collapse rather than leave a min-width stub.
assert.match(css, /#traffic-sync-progress:empty \{\s*display: none;\s*\}/);
});
-1
View File
@@ -359,7 +359,6 @@ test('panel collapse is presentation-only and Radio exposes explicit voice playb
assert.doesNotMatch(enableMethod, /contextRadio/);
assert.match(enableMethod, /toggleRadio\(this\._radioEnableBtn\)/);
assert.match(ui, /setAttribute\('aria-expanded', String\(/);
assert.match(readFileSync(new URL('./ui/shellFeedback.js', import.meta.url), 'utf8'), /\.hidden = !/);
assert.doesNotMatch(method, /panelId === 'global-context-panel'[\s\S]*?this\._radioState\?\.enabled[\s\S]*?setPanelCollapsed\('radio-panel', false\)/);
});
+15 -289
View File
@@ -5,21 +5,21 @@
* "loading frames" → "camera grid ready" — the characters flip over
* mechanically, left to right, like a Solari board at a ferry terminal.
*
* FOUR INVARIANTS HOLD THIS TOGETHER — do not "fix" any of them:
* 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 first call upgrades
* a chip label into a permanent two-child shell: a `.gev-flap-text` span
* holding one long-lived `Text` node, and an `aria-hidden`
* `.gev-flap-cells` sibling. After that the ONLY text operation for the
* life of the chip is `node.data = next` — a single `characterData`
* mutation. Nothing is ever reparented, so the label is never
* transiently empty and the `aria-live` region never sees a removal /
* 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 and the traffic chip's own `textContent !==` guard in ui.js
* therefore always read the truth; the flaps are purely visual.
* 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
@@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26;
*/
export const FLAP_MAX_TOTAL_MS = 620;
/** Slack after the last character lands before the cells are stripped. */
const FLAP_SETTLE_SLACK_MS = 60;
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
@@ -81,38 +81,18 @@ const FLAP_SETTLE_SLACK_MS = 60;
*/
export const FLAP_TURN_RATIO = 0.5;
const HOST_CLASS = 'gev-flap-host';
const ACTIVE_CLASS = 'gev-flap-active';
const TEXT_CLASS = 'gev-flap-text';
const CELLS_CLASS = 'gev-flap-cells';
const CELL_CLASS = 'gev-flap-cell';
const FLAPPING_CLASS = 'is-flapping';
const SIZING_CLASS = 'gev-flap-sizing';
/**
* 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.
*/
const BLANK = ' ';
/** In-flight cascade state, keyed by element so nothing is stored on the node. */
const flapStates = new WeakMap();
/** Teardown for an in-flight width ease (listeners, not timers). */
const widthEases = new WeakMap();
export const FLAP_BLANK = ' ';
function positiveNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function nowMs() {
return typeof performance !== 'undefined' &&
typeof performance.now === 'function'
? performance.now()
: Date.now();
}
/**
* Diff two strings into per-character flap cells with staggered delays.
*
@@ -207,7 +187,7 @@ export function planSplitFlap(fromText, toText, options = {}) {
*/
function displayedGlyph(cell, turned) {
if (!cell.changed) return cell.to;
return (turned ? cell.to : cell.from) || BLANK;
return (turned ? cell.to : cell.from) || FLAP_BLANK;
}
/**
@@ -248,7 +228,7 @@ export function visibleGlyphs(plan, elapsedMs, options = {}) {
}
/** Whether the viewer asked for reduced motion. */
function prefersReducedMotion() {
export function prefersReducedMotion() {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
@@ -266,7 +246,7 @@ function prefersReducedMotion() {
* would animate a chip nobody can see. `checkVisibility` walks ancestors for
* exactly these properties; the fallback walks them by hand.
*/
function isVisible(element) {
export function isOnScreen(element) {
if (!element?.isConnected) return false;
if (typeof element.checkVisibility === 'function') {
return element.checkVisibility({
@@ -290,257 +270,3 @@ function isVisible(element) {
}
return true;
}
function measureWidth(element) {
return typeof element.getBoundingClientRect === 'function'
? element.getBoundingClientRect().width
: 0;
}
/**
* The chip label's permanent shell.
*
* Built once per element and then reused forever: the `Text` node inside
* `.gev-flap-text` is the same object for the life of the chip, so a label
* change never reparents anything (invariant 1). Rebuilt only if something
* outside this module has clobbered the label's children.
*/
function ensureHost(element) {
// O(1) happy path: this runs on every chip tick, including the ones where
// nothing changed.
const text = element.firstElementChild;
const cells = text?.nextElementSibling;
if (
text?.classList?.contains(TEXT_CLASS) &&
text.firstChild?.nodeType === 3 &&
cells?.classList?.contains(CELLS_CLASS) &&
!cells.nextElementSibling
) {
return { text, cells };
}
const doc = element.ownerDocument || globalThis.document;
const carried = element.textContent ?? '';
const nextText = doc.createElement('span');
nextText.className = TEXT_CLASS;
nextText.append(doc.createTextNode(carried));
const nextCells = doc.createElement('span');
nextCells.className = CELLS_CLASS;
// Decorative only. Assistive tech ignores this subtree entirely, which is
// why rebuilding it every change cannot disturb the aria-live region.
nextCells.setAttribute('aria-hidden', 'true');
element.classList.add(HOST_CLASS);
element.replaceChildren(nextText, nextCells);
return { text: nextText, cells: nextCells };
}
/** Drop the width transition, leaving the element at its natural width. */
function clearSizing(element) {
element.classList?.remove(SIZING_CLASS);
element.style?.removeProperty('width');
element.style?.removeProperty('--gev-flap-total');
}
function cancelWidthEase(element) {
const teardown = widthEases.get(element);
if (!teardown) return;
widthEases.delete(element);
teardown();
}
/**
* Ease the element between two measured widths.
*
* Called twice per change, and exactly one of the two ever has work to do: a
* GROWING label reserves its new columns the moment the cells go in, so the
* ease happens at the start; a SHRINKING label holds every column for the
* whole cascade, so the slack is only taken up once the flaps have landed.
*
* The end of the ease is a `transitionend` listener rather than a timer, so a
* change still schedules exactly one `setTimeout` (invariant 2). A transition
* that never fires is harmless: the pinned width equals the natural width it
* was easing to, and the next change clears it regardless.
*/
function easeWidth(element, fromWidth, toWidth, durationMs) {
cancelWidthEase(element);
if (!(fromWidth > 0.5) || Math.abs(toWidth - fromWidth) <= 0.5) {
clearSizing(element);
return false;
}
element.style.setProperty('--gev-flap-total', `${durationMs}ms`);
element.style.width = `${fromWidth}px`;
element.classList.add(SIZING_CLASS);
void element.offsetWidth; // flush the start value so the transition runs
element.style.width = `${toWidth}px`;
const finish = (event) => {
if (event && (event.target !== element || event.propertyName !== 'width'))
return;
cancelWidthEase(element);
clearSizing(element);
};
const teardown = () => {
element.removeEventListener('transitionend', finish);
element.removeEventListener('transitioncancel', finish);
};
element.addEventListener('transitionend', finish);
element.addEventListener('transitioncancel', finish);
widthEases.set(element, teardown);
return true;
}
function clearFlapTimer(element) {
const state = flapStates.get(element);
if (!state) return;
clearTimeout(state.timer);
flapStates.delete(element);
}
/**
* Put the label back in its resting state: real text visible, no cells.
*
* The `Text` node is untouched apart from its data, and neither permanent span
* is removed — only the decorative cells are emptied.
*/
function rest(element, host) {
element.classList.remove(ACTIVE_CLASS);
host.cells.replaceChildren();
element.style?.removeProperty('--gev-flap-dur');
// The real text is the accessible name; a stale aria-label from an earlier
// render would otherwise mask it.
element.removeAttribute?.('aria-label');
}
/**
* Strip the cells once the cascade lands, then take up any width slack.
*
* A shrinking label has been holding every column at full width for the whole
* cascade (invariant 4), so this is where the board narrows — as one eased
* transition, never a single-frame snap.
*/
function settle(element, expected, easeMs) {
flapStates.delete(element);
// A newer label won the race — leave its cells alone.
if (element.textContent !== expected) return;
const host = ensureHost(element);
const cascadeWidth = measureWidth(element);
cancelWidthEase(element);
clearSizing(element);
rest(element, host);
const naturalWidth = measureWidth(element);
easeWidth(element, cascadeWidth, naturalWidth, easeMs);
}
/**
* Set a chip label, flipping the changed characters into place.
*
* Safe to call on every tick: the chips are repainted by a 500 ms ticker and
* a 60 ms one, so an unchanged write must be a no-op or the animation would
* restart forever. Returns whether a flap was actually started.
*
* @param {HTMLElement|null} element - Chip label element.
* @param {string} text - Text to settle on.
* @param {{immediate?: boolean, charMs?: number, staggerMs?: number,
* maxTotalMs?: number}} [options]
* @returns {boolean} True when a flap animation was started.
*/
export function setSplitFlapText(element, text, options = {}) {
if (!element) return false;
const next = String(text ?? '');
// Upgrade to the permanent shell FIRST, so that one-time structural change
// happens on a tick where the text is not changing. Every subsequent label
// change is then a lone characterData mutation (invariant 1).
const host = ensureHost(element);
// textContent is the SETTLED string even mid-cascade, so this is the right
// idempotence check against the repeating chip tickers.
const settled = element.textContent ?? '';
if (settled === next) return false;
// What the viewer can SEE right now. Mid-cascade that is not the settled
// string: columns whose stagger has not elapsed are still showing the
// previous label's glyphs, and those are what must flap away.
const state = flapStates.get(element);
const displayed = state
? visibleGlyphs(state.plan, nowMs() - state.startedAt, {
charMs: state.charMs,
})
: settled;
clearFlapTimer(element);
const beforeWidth = measureWidth(element);
const animate =
SPLIT_FLAP_ENABLED &&
options.immediate !== true &&
!prefersReducedMotion() &&
isVisible(element);
const plan = animate ? planSplitFlap(displayed, next, options) : null;
// THE ONLY TEXT OPERATION. One characterData mutation, no reparenting, so
// the label is never transiently empty and the live region cannot see a
// removal/reinsertion pair (invariant 1).
host.text.firstChild.data = next;
if (!plan?.changedCount) {
cancelWidthEase(element);
clearSizing(element);
rest(element, host);
return false;
}
const doc = element.ownerDocument || globalThis.document;
const charMs = positiveNumber(options.charMs, FLAP_CHAR_MS);
const cellNodes = [];
for (const cell of plan.cells) {
const node = doc.createElement('span');
node.className = CELL_CLASS;
// Every column carries an in-flow incoming face, so every column holds a
// width and its index for the whole cascade (invariant 4). A column the
// new string does not reach flaps to a blank rather than collapsing.
node.dataset.flapNext = cell.to || BLANK;
if (cell.changed) {
node.classList.add(FLAPPING_CLASS);
node.dataset.flapPrev = cell.from || BLANK;
node.style.setProperty('--gev-flap-delay', `${cell.delayMs}ms`);
}
cellNodes.push(node);
}
element.removeAttribute('aria-label');
cancelWidthEase(element);
clearSizing(element);
// Only the decorative sibling is rebuilt; the text node is not involved.
host.cells.replaceChildren(...cellNodes);
element.classList.add(ACTIVE_CLASS);
element.style.setProperty('--gev-flap-dur', `${charMs}ms`);
// Width, phase one. A GROWING label reserves its new columns the instant the
// cells go in, so ease from what was on screen to the cascade width. A
// shrinking one is already at cascade width, so this is a no-op and the ease
// happens in settle() instead, once the flaps have landed.
easeWidth(element, beforeWidth, measureWidth(element), plan.durationMs);
// The one and only timer this change schedules (invariant 2).
flapStates.set(element, {
plan,
charMs,
startedAt: nowMs(),
timer: setTimeout(
() => settle(element, next, plan.durationMs),
plan.durationMs + FLAP_SETTLE_SLACK_MS,
),
});
return true;
}
/** Stop decoration without replacing the label's permanent accessible text. */
export function disposeSplitFlap(element) {
if (!element) return;
clearFlapTimer(element);
cancelWidthEase(element);
clearSizing(element);
const cells = element.querySelector?.('.gev-flap-cells');
if (cells) rest(element, { cells });
}
+1 -7
View File
@@ -10,7 +10,6 @@ import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
import { DisplayBindings } from './displayBindings.js';
import { createStateChannel } from '../app/stateChannel.js';
import { setSplitFlapText } from '../splitFlap.js';
import { UiLifetime } from './uiLifetime.js';
import { RecordingControls } from './recordingControls.js';
import { readShellElements } from './shellElements.js';
@@ -1196,9 +1195,6 @@ export class StyleManager extends ShellFacade {
_cctvSelect: this._cctvSelect,
_cctvSourceBadge: this._cctvSourceBadge,
_cctvSummary: this._cctvSummary,
_cctvSyncChip: this._cctvSyncChip,
_cctvSyncLabel: this._cctvSyncLabel,
_cctvSyncProgress: this._cctvSyncProgress,
},
cctv: cctvLayer,
actions: {
@@ -1210,7 +1206,6 @@ export class StyleManager extends ShellFacade {
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
showToast: (message) => this._showToast(message),
syncViewport: () => this._syncCctvPanelViewport(),
setSplitFlapText,
},
});
}
@@ -1760,8 +1755,7 @@ export class StyleManager extends ShellFacade {
this.services;
if (this._disposed) return;
this._shareRestoration.destroy();
this._feedback._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._feedback.clearLoadingStatus();
this._disposed = true;
this._navigation.stop();
this._shareState.destroy();
+12 -2
View File
@@ -14,17 +14,20 @@ import {
import {
_calBadgeLabel,
_renderCctvState,
_publishCctvChip,
_typeCctvSummary,
_updateCctvSyncChip,
} from './cctvPresentation.js';
import { _initCctvPanel } from './cctvBindings.js';
import { useCctvStore } from './stores/cctv.js';
import { useStatusStore } from './stores/status.js';
import { applicationPinia } from './vueRuntime.js';
/**
* Own camera-panel interaction and presentation; receive the camera port and
* application actions. The panel view renders the buttons, CAL chip and meta
* line from what this publishes (`gev-cctv` store).
* line from what this publishes (`gev-cctv` store), and the scene chrome the
* camera grid chip (`gev-status` store).
*/
export class CctvControls {
constructor({
@@ -32,9 +35,11 @@ export class CctvControls {
cctv,
actions,
store = useCctvStore(applicationPinia()),
statusStore = useStatusStore(applicationPinia()),
}) {
Object.assign(this, elements);
this.store = store;
this.statusStore = statusStore;
this.cctv = cctv;
this.actions = actions;
this.destroyed = false;
@@ -46,6 +51,8 @@ export class CctvControls {
this._lastSeenCctvActiveId = null;
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = false;
// The camera grid chip as last published; its label stays while hidden.
this._cctvChip = null;
this._cctvFrameRequestToken = 0;
this._cctvFramePreloader = null;
this._frame = EMPTY_CCTV_FRAME;
@@ -117,6 +124,9 @@ export class CctvControls {
_typeCctvSummary(...args) {
return _typeCctvSummary.call(this, ...args);
}
_publishCctvChip(...args) {
return _publishCctvChip.call(this, ...args);
}
_updateCctvSyncChip(...args) {
return _updateCctvSyncChip.call(this, ...args);
}
@@ -138,6 +148,6 @@ export class CctvControls {
clearTimeout(this._cctvChipHideTimer);
this._cctvSummaryTypingTimer = null;
this._cctvChipHideTimer = null;
this._cctvSyncChip?.classList.remove('visible');
this._publishCctvChip({ visible: false, stopped: true });
}
}
+31 -10
View File
@@ -301,10 +301,27 @@ export function _typeCctvSummary(text) {
}, 20);
}
/** Publish a change to the camera grid chip; its label stays while hidden. */
export function _publishCctvChip(patch) {
const current = this._cctvChip ?? {
visible: false,
label: t('loading frames'),
immediate: false,
progress: '0/0',
stopped: false,
};
const next = { ...current, ...patch };
if (
this._cctvChip &&
Object.keys(next).every((key) => next[key] === current[key])
)
return;
this._cctvChip = next;
this.statusStore.publishCctv(next);
}
export function _updateCctvSyncChip(loading, enabled) {
if (this.destroyed) return;
if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress)
return;
const total = Number(loading?.total) || 0;
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
const busy = !!enabled && !!loading?.active && total > 0;
@@ -313,31 +330,35 @@ export function _updateCctvSyncChip(loading, enabled) {
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = true;
this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
// The counter is left plain on purpose: it ticks every few frames
// during a grid load, and flapping it would read as a slot machine.
this._cctvSyncProgress.textContent = `${loaded}/${total}`;
this._cctvSyncChip.classList.add('visible');
this._publishCctvChip({
visible: true,
label: t('loading frames'),
progress: `${loaded}/${total}`,
});
return;
}
if (this._cctvChipWasBusy && enabled && total > 0) {
// Load just completed — flash the final count, then auto-hide.
this._cctvChipWasBusy = false;
this.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
this._cctvSyncProgress.textContent = `${total}/${total}`;
this._cctvSyncChip.classList.add('visible');
this._publishCctvChip({
visible: true,
label: t('camera grid ready'),
progress: `${total}/${total}`,
});
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = window.setTimeout(() => {
if (this.destroyed) return;
this._cctvChipHideTimer = null;
this._cctvSyncChip.classList.remove('visible');
this._publishCctvChip({ visible: false });
}, 1500);
return;
}
if (!this._cctvChipHideTimer) {
this._cctvChipWasBusy = false;
this._cctvSyncChip.classList.remove('visible');
this._publishCctvChip({ visible: false });
}
}
-9
View File
@@ -100,18 +100,9 @@ export function readShellElements(document = globalThis.document) {
_tiltMapBtn: document.getElementById('tilt-map-view'),
_northUpBtn: document.getElementById('north-up-view'),
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
_globalLoadingStatus: document.getElementById('global-loading-status'),
_globalLoadingLabel: document.getElementById('global-loading-label'),
_globalLoadingDetail: document.getElementById('global-loading-detail'),
_resetGlobeBtn: document.getElementById('reset-globe-view'),
_cockpitResetGlobeBtn: document.getElementById('cockpit-reset-globe'),
_styleButtons: document.getElementById('style-buttons'),
_trafficSyncChip: document.getElementById('traffic-sync-chip'),
_trafficSyncLabel: document.getElementById('traffic-sync-label'),
_trafficSyncProgress: document.getElementById('traffic-sync-progress'),
_cctvSyncChip: document.getElementById('cctv-sync-chip'),
_cctvSyncLabel: document.getElementById('cctv-sync-label'),
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
_toast: document.getElementById('toast'),
_locationSearch: document.getElementById('location-search'),
_searchToggle: document.getElementById('search-toggle'),
+73 -54
View File
@@ -8,10 +8,11 @@ import {
reduceLoadingFeedback,
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { getLocale, t, msg } from '../i18n/index.js';
import { localizeLayerName } from './layerPanel.js';
import { translateFeedError } from './statusText.js';
import { useStatusStore } from './stores/status.js';
import { applicationPinia } from './vueRuntime.js';
// The server also loads loadingFeedback.js (through the HUD summary), so its
// labels stay English. Translate the known ones; pass anything else (a live
@@ -116,8 +117,14 @@ export function localizeLoadingPresentation(presentation) {
};
}
export class ShellFeedback {
constructor({ readLayers }) {
/**
* @param {object} options
* @param {() => object[]} options.readLayers The data layers' states.
* @param {object} [options.store] The status store the chips render from.
*/
constructor({ readLayers, store = useStatusStore(applicationPinia()) }) {
this.readLayers = readLayers;
this.store = store;
this.destroyed = false;
this._loadingFeedbackState = createLoadingFeedbackState();
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
@@ -131,18 +138,21 @@ export class ShellFeedback {
this._lastLoadingFeedbackUpdateAt = 0;
this._lastTrafficChipUpdateAt = 0;
this._lastTrafficGlobalUpdateAt = 0;
this._globalLoadingStatus = document.getElementById(
'global-loading-status',
);
this._globalLoadingLabel = document.getElementById('global-loading-label');
this._globalLoadingDetail = document.getElementById(
'global-loading-detail',
);
this._trafficSyncChip = document.getElementById('traffic-sync-chip');
this._trafficSyncLabel = document.getElementById('traffic-sync-label');
this._trafficSyncProgress = document.getElementById(
'traffic-sync-progress',
);
// What the chips last showed; a label stays while its chip is hidden.
this._loadingView = {
hidden: true,
state: null,
label: t('LOADING LIVE DATA'),
detail: '',
stopped: false,
};
this._trafficView = {
visible: false,
label: t('syncing road network'),
immediate: false,
progress: '0%',
stopped: false,
};
this._toast = document.getElementById('toast');
}
observeVisibility() {
@@ -158,12 +168,6 @@ export class ShellFeedback {
}
_updateTrafficSyncChip(forceShow = false, now = performance.now()) {
if (this.destroyed) return;
if (
!this._trafficSyncChip ||
!this._trafficSyncLabel ||
!this._trafficSyncProgress
)
return;
const layers = this.readLayers();
const traffic = Array.isArray(layers)
? layers.find((layer) => layer.id === 'traffic')
@@ -178,30 +182,22 @@ export class ShellFeedback {
now,
);
const presentation = this._trafficSyncFeedbackState;
// setSplitFlapText carries the same unchanged-text guard internally, and
// the flap keeps textContent equal to the settled label throughout, so
// this stays a no-op on the repeat ticks exactly as it did before.
if (presentation.label)
setSplitFlapText(
this._trafficSyncLabel,
localizeStatusLabel(presentation.label),
{
// Completion is a timing signal, not another animated loading stage.
immediate: !presentation.busy,
},
);
// Written on every change INCLUDING the empty settled value — the reducer
// clears the progress number once the sync lands, and a truthiness guard
// here would strand the last "..." beside the settled label.
if (this._trafficSyncProgress.textContent !== presentation.progressText) {
this._trafficSyncProgress.textContent = presentation.progressText;
}
this._trafficSyncChip.classList.toggle('visible', presentation.visible);
// The label flaps only when it changes; completion swaps it in, a timing
// signal rather than another animated loading stage. The progress is
// written on every change INCLUDING the empty settled value: the reducer
// clears it once the sync lands.
this._publishTraffic({
visible: presentation.visible,
...(presentation.label && {
label: localizeStatusLabel(presentation.label),
immediate: !presentation.busy,
}),
progress: presentation.progressText,
});
}
_updateGlobalLoadingFeedback(now = performance.now()) {
if (this.destroyed) return;
if (!this._globalLoadingStatus) return;
const summary = aggregateLayerLoading(this.readLayers() || []);
this._loadingFeedbackState = reduceLoadingFeedback(
this._loadingFeedbackState,
@@ -230,20 +226,43 @@ export class ShellFeedback {
if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
this._armLoadingFeedbackTicker();
}
const localized = localizeLoadingPresentation(presentation);
this._globalLoadingStatus.hidden = !presentation;
if (!presentation) {
delete this._globalLoadingStatus.dataset.state;
this._publishLoading({ hidden: true, state: null });
return;
}
this._globalLoadingStatus.dataset.state = presentation.state;
// Split-flap the LABEL only ("LOADING LIVE DATA" -> "LOAD COMPLETE").
// setSplitFlapText is a no-op when the text is unchanged, which matters
// here: this runs on every 60 ms and 500 ms tick. The detail line is the
// live layer roster inside an ellipsised, width-capped span — flapping a
// list that churns as layers join would be noise, not delight.
setSplitFlapText(this._globalLoadingLabel, localized.label);
this._globalLoadingDetail.textContent = localized.detail;
const localized = localizeLoadingPresentation(presentation);
// The LABEL flaps ("LOADING LIVE DATA" -> "LOAD COMPLETE") only when it
// changes, which matters here: this runs on every 60 ms and 500 ms tick.
// The detail is the live layer roster inside an ellipsised, width-capped
// span; flapping a list that churns as layers join would be noise.
this._publishLoading({
hidden: false,
state: presentation.state,
label: localized.label,
detail: localized.detail,
});
}
_publishLoading(patch) {
const next = { ...this._loadingView, ...patch };
if (Object.keys(next).every((key) => next[key] === this._loadingView[key]))
return;
this._loadingView = next;
this.store.publishLoading(next);
}
_publishTraffic(patch) {
const next = { ...this._trafficView, ...patch };
if (Object.keys(next).every((key) => next[key] === this._trafficView[key]))
return;
this._trafficView = next;
this.store.publishTraffic(next);
}
/** Drop any retained notice and hide the loading status. */
clearLoadingStatus() {
this._globalStatusNotice = null;
this._publishLoading({ hidden: true, state: null });
}
_showGlobalStatusNotice(message, options = {}) {
@@ -327,9 +346,9 @@ export class ShellFeedback {
this._loadingVisibilityHandler,
);
this._loadingVisibilityHandler = null;
disposeSplitFlap(this._globalLoadingLabel);
disposeSplitFlap(this._trafficSyncLabel);
this._toast?.classList.remove('visible');
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
// The labels rest at once; a cascade does not outlive its owner.
this._publishLoading({ hidden: true, state: null, stopped: true });
this._publishTraffic({ stopped: true });
}
}
@@ -10,12 +10,8 @@ import {
reduceLoadingFeedback,
} from '../loadingFeedback.js';
const elements = {
'global-loading-status': { hidden: true, dataset: {} },
'global-loading-detail': { textContent: '' },
};
globalThis.document ??= {
getElementById: (id) => elements[id] ?? null,
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
// The layer panel reaches Vue, whose DOM runtime creates one element as it
@@ -38,10 +34,14 @@ function alprChipDetail(locale, retryInMs) {
retryAt: Date.now() + retryInMs,
},
};
const feedback = new ShellFeedback({ readLayers: () => [layer] });
let loading = null;
const feedback = new ShellFeedback({
readLayers: () => [layer],
store: { publishLoading: (view) => (loading = view) },
});
feedback._armLoadingFeedbackTicker = () => {};
feedback._updateGlobalLoadingFeedback(0);
return elements['global-loading-detail'].textContent;
return loading.detail;
}
test.afterEach(() => setLocale('en'));
+12 -4
View File
@@ -54,8 +54,6 @@ function fixture() {
'right-context-rail',
'pp-toggles',
'toast',
'global-loading-status',
'global-loading-detail',
'safe-frame-overlay',
'safe-frame-box',
'hud-toggle',
@@ -176,11 +174,20 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
const f = fixture();
try {
const owner = new ShellFeedback({ readLayers: () => [] });
const loading = [];
const owner = new ShellFeedback({
readLayers: () => [],
store: {
publishLoading: (view) => loading.push(view),
publishTraffic() {},
},
});
owner.observeVisibility();
owner._startTrafficChipTicker();
owner._showToast('Saved');
assert.equal(f.timers.size, 2);
owner._showGlobalStatusNotice('Saving');
assert.equal(loading.at(-1).hidden, false);
owner.destroy();
owner._showToast('Late');
owner._showGlobalStatusNotice('Late');
@@ -189,7 +196,8 @@ test('feedback disposal clears toast and polling work and rejects retained notic
assert.equal(f.timers.size, 0);
assert.equal(f.nodes.get('toast').textContent, 'Saved');
assert.equal(f.nodes.get('toast').classList.contains('visible'), false);
assert.equal(f.nodes.get('global-loading-status').hidden, true);
assert.equal(loading.at(-1).hidden, true);
assert.equal(loading.at(-1).label, 'Saving', 'the hidden label stays');
} finally {
f.restore();
}
+119
View File
@@ -0,0 +1,119 @@
// src/ui/statusChipOwners.test.mjs — what ShellFeedback and the CCTV
// presentation publish for the road network and camera grid chips.
import test from 'node:test';
import assert from 'node:assert/strict';
globalThis.document ??= {
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
// The layer panel reaches Vue, whose DOM runtime creates one element as it
// loads.
createElement: () => ({}),
hidden: false,
};
const { ShellFeedback } = await import('./shellFeedback.js');
const { _publishCctvChip, _updateCctvSyncChip } =
await import('./cctvPresentation.js');
test('the road network chip clears its progress once the sync lands and keeps its label', () => {
const published = [];
let stats = { loading: true, phaseProgressPct: 40, phaseLabel: 'LIVE' };
const feedback = new ShellFeedback({
readLayers: () => [{ id: 'traffic', enabled: true, stats }],
store: {
publishTraffic: (view) => published.push(view),
publishLoading() {},
},
});
feedback._updateTrafficSyncChip(false, 0);
assert.deepEqual(published.at(-1), {
visible: true,
label: 'LIVE',
immediate: false,
progress: '40%',
stopped: false,
});
feedback._updateTrafficSyncChip(false, 10);
assert.equal(published.length, 1, 'an unchanged chip is not republished');
stats = { phaseProgressPct: 100, phaseLabel: 'LIVE · 80% cov' };
feedback._updateTrafficSyncChip(false, 20);
assert.deepEqual(published.at(-1), {
visible: true,
label: 'LIVE · 80% cov',
immediate: true,
progress: '',
stopped: false,
});
feedback._updateTrafficSyncChip(false, 60_000);
assert.deepEqual(published.at(-1), {
visible: false,
label: 'LIVE · 80% cov',
immediate: true,
progress: '',
stopped: false,
});
feedback.destroy();
assert.equal(published.at(-1).stopped, true, 'destroy rests the label');
});
function cctvOwner() {
const published = [];
const owner = {
destroyed: false,
_cctvChip: null,
_cctvChipWasBusy: false,
_cctvChipHideTimer: null,
statusStore: { publishCctv: (view) => published.push(view) },
};
owner._publishCctvChip = _publishCctvChip.bind(owner);
const update = _updateCctvSyncChip.bind(owner);
return { owner, published, update };
}
test('the camera grid chip counts frames, flashes the total, then hides', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
globalThis.window ??= globalThis;
const { published, update } = cctvOwner();
update({ active: true, loaded: 3, total: 12 }, true);
assert.deepEqual(published.at(-1), {
visible: true,
label: 'loading frames',
immediate: false,
progress: '3/12',
stopped: false,
});
update({ active: false, loaded: 12, total: 12 }, true);
assert.deepEqual(published.at(-1), {
visible: true,
label: 'camera grid ready',
immediate: false,
progress: '12/12',
stopped: false,
});
update({ active: false, loaded: 12, total: 12 }, true);
assert.equal(published.length, 2, 'the flash holds until its timer');
t.mock.timers.tick(1500);
assert.deepEqual(published.at(-1), {
visible: false,
label: 'camera grid ready',
immediate: false,
progress: '12/12',
stopped: false,
});
});
test('a camera grid chip that never showed publishes its first hide once', () => {
const { published, update } = cctvOwner();
update(null, false);
update(null, false);
assert.deepEqual(published, [
{
visible: false,
label: 'loading frames',
immediate: false,
progress: '0/0',
stopped: false,
},
]);
});
+286
View File
@@ -0,0 +1,286 @@
// src/ui/statusChipsView.test.mjs — the split-flap label and the scene's
// status chips, rendered from the status store over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, h, nextTick, ref } from 'vue';
import { createPinia } from 'pinia';
import { FLAP_SETTLE_SLACK_MS, planSplitFlap } from '../splitFlap.js';
import { useStatusStore } from './stores/status.js';
import SceneChromeView from './views/SceneChromeView.vue';
import SplitFlapText from './views/panels/SplitFlapText.vue';
// happy-dom lays nothing out: the label counts as on screen unless a test
// says otherwise, and is wider while its cells stand in for the text.
let onScreen = true;
HTMLElement.prototype.checkVisibility = function () {
return onScreen && !this.closest('[hidden]');
};
HTMLElement.prototype.getBoundingClientRect = function () {
const width = this.querySelector('.gev-flap-cell') ? 80 : 50;
return { width, height: 10, left: 0, top: 0, right: width, bottom: 10 };
};
// A change renders, then its cells go in once the label is seen on screen.
const rendered = async () => {
for (let flush = 0; flush < 4; flush += 1) await nextTick();
};
function mountLabel(t, initial = 'LOADING LIVE DATA') {
onScreen = true;
document.body.replaceChildren();
const text = ref(initial);
const immediate = ref(false);
const app = createApp({
render: () =>
h(SplitFlapText, {
id: 'label',
text: text.value,
immediate: immediate.value,
}),
});
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const label = document.getElementById('label');
return {
text,
immediate,
label,
node: () => label.querySelector('.gev-flap-text').firstChild,
cells: () => [...label.querySelectorAll('.gev-flap-cell')],
};
}
test('the label is one text node beside empty decorative cells', (t) => {
const { label, node, cells } = mountLabel(t);
assert.equal(label.className, 'gev-flap-host');
assert.equal(node().nodeType, Node.TEXT_NODE);
assert.equal(node().data, 'LOADING LIVE DATA');
assert.equal(label.textContent, 'LOADING LIVE DATA');
assert.equal(
label.querySelector('.gev-flap-cells').getAttribute('aria-hidden'),
'true',
);
assert.equal(cells().length, 0);
});
test('a change flaps its changed columns over the same text node, then settles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { text, label, node, cells } = mountLabel(t);
const permanent = node();
text.value = 'LOAD COMPLETE';
await rendered();
assert.ok(node() === permanent, 'the text node never moves');
assert.equal(permanent.data, 'LOAD COMPLETE');
assert.equal(label.textContent, 'LOAD COMPLETE', 'cells carry no text');
assert.equal(label.classList.contains('gev-flap-active'), true);
assert.equal(label.style.getPropertyValue('--gev-flap-dur'), '190ms');
const plan = planSplitFlap('LOADING LIVE DATA', 'LOAD COMPLETE');
assert.equal(cells().length, plan.cells.length);
assert.deepEqual(
cells().map((cell) => cell.dataset.flapNext),
plan.cells.map((cell) => cell.to || ' '),
);
const flapping = cells().filter((cell) =>
cell.classList.contains('is-flapping'),
);
assert.equal(flapping.length, plan.changedCount);
const first = plan.cells.find((cell) => cell.changed);
assert.equal(cells()[first.index].dataset.flapPrev, first.from);
assert.equal(
cells()[first.index].style.getPropertyValue('--gev-flap-delay'),
`${first.delayMs}ms`,
);
assert.equal(cells()[0].dataset.flapPrev, undefined, 'L stays put');
t.mock.timers.tick(plan.durationMs + FLAP_SETTLE_SLACK_MS);
await rendered();
assert.equal(cells().length, 0);
assert.equal(label.classList.contains('gev-flap-active'), false);
assert.equal(label.style.getPropertyValue('--gev-flap-dur'), '');
assert.ok(node() === permanent);
});
test('an interrupted cascade rebuilds its cells so every flap restarts', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { text, cells } = mountLabel(t, 'AAAA');
text.value = 'BBBB';
await rendered();
const before = cells();
text.value = 'CCCC';
await rendered();
assert.equal(cells().length, 4);
assert.ok(cells().every((cell) => !before.includes(cell)));
// Nothing had turned yet, so the ORIGINAL label flaps away (invariant 3).
assert.deepEqual(
cells().map((cell) => cell.dataset.flapPrev),
['A', 'A', 'A', 'A'],
);
});
test('a completion, a hidden label or an unchanged label swaps without flaps', async (t) => {
const { text, immediate, label, cells } = mountLabel(t);
immediate.value = true;
text.value = 'LOAD COMPLETE';
await rendered();
assert.equal(label.textContent, 'LOAD COMPLETE');
assert.equal(cells().length, 0);
immediate.value = false;
onScreen = false;
text.value = 'LOAD FAILED';
await rendered();
assert.equal(label.textContent, 'LOAD FAILED');
assert.equal(cells().length, 0);
assert.equal(label.classList.contains('gev-flap-active'), false);
});
test('a growing label eases to its cascade width and ends on transitionend', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { text, label } = mountLabel(t, 'OFF');
text.value = 'LOADING';
// The ease renders its start width, lays it out, then renders the end.
for (let flush = 0; flush < 2; flush += 1) await rendered();
assert.equal(label.classList.contains('gev-flap-sizing'), true);
assert.equal(label.style.width, '80px');
const other = new Event('transitionend');
other.propertyName = 'opacity';
label.dispatchEvent(other);
await rendered();
assert.equal(label.style.width, '80px', 'only the width ease ends it');
const end = new Event('transitionend');
end.propertyName = 'width';
label.dispatchEvent(end);
await rendered();
assert.equal(label.classList.contains('gev-flap-sizing'), false);
assert.equal(label.style.width, '');
});
test('a stopped label rests at once and changes without flaps from then on', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const stopped = ref(false);
const text = ref('AAAA');
document.body.replaceChildren();
onScreen = true;
const app = createApp({
render: () =>
h(SplitFlapText, {
id: 'label',
text: text.value,
stopped: stopped.value,
}),
});
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const label = document.getElementById('label');
text.value = 'BBBB';
await rendered();
await rendered();
assert.equal(label.querySelectorAll('.gev-flap-cell').length, 4);
stopped.value = true;
await rendered();
assert.equal(label.querySelectorAll('.gev-flap-cell').length, 0);
assert.equal(label.classList.contains('gev-flap-active'), false);
text.value = 'CCCC';
await rendered();
await rendered();
assert.equal(label.textContent, 'CCCC');
assert.equal(label.querySelectorAll('.gev-flap-cell').length, 0);
});
function mountChrome(t) {
onScreen = false;
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return { store: useStatusStore(pinia) };
}
const $ = (selector) => document.querySelector(selector);
test('before anything publishes, the chips hold their resting labels', (t) => {
mountChrome(t);
assert.equal($('#global-loading-status').hidden, true);
assert.equal($('#global-loading-status').hasAttribute('data-state'), false);
assert.equal($('#global-loading-label').textContent, 'LOADING LIVE DATA');
assert.equal($('#global-loading-detail').textContent, '');
assert.equal($('#traffic-sync-chip').classList.contains('visible'), false);
assert.equal($('#traffic-sync-label').textContent, 'syncing road network');
assert.equal($('#traffic-sync-progress').textContent, '0%');
assert.equal($('#cctv-sync-chip').classList.contains('visible'), false);
assert.equal($('#cctv-sync-label').textContent, 'loading frames');
assert.equal($('#cctv-sync-progress').textContent, '0/0');
});
test('the chips render what the status store publishes', async (t) => {
const { store } = mountChrome(t);
store.publishLoading({
hidden: false,
state: 'complete',
label: 'LOAD COMPLETE',
detail: 'Flights · Ships',
});
store.publishTraffic({
visible: true,
label: 'road network ready',
immediate: true,
progress: '',
});
store.publishCctv({
visible: true,
label: 'camera grid ready',
immediate: false,
progress: '12/12',
});
await rendered();
assert.equal($('#global-loading-status').hidden, false);
assert.equal($('#global-loading-status').dataset.state, 'complete');
assert.equal($('#global-loading-label').textContent, 'LOAD COMPLETE');
assert.equal($('#global-loading-detail').textContent, 'Flights · Ships');
assert.equal($('#traffic-sync-chip').classList.contains('visible'), true);
assert.equal($('#traffic-sync-label').textContent, 'road network ready');
assert.equal($('#traffic-sync-progress').textContent, '');
assert.equal($('#cctv-sync-chip').classList.contains('visible'), true);
assert.equal($('#cctv-sync-progress').textContent, '12/12');
$('#traffic-sync-chip').classList.add('panel-avoid');
store.publishTraffic({
visible: false,
label: 'road network ready',
immediate: true,
progress: '',
});
store.publishLoading({
hidden: true,
state: null,
label: 'LOAD COMPLETE',
detail: 'Flights · Ships',
});
await rendered();
assert.equal($('#traffic-sync-chip').classList.contains('visible'), false);
assert.equal(
$('#traffic-sync-chip').classList.contains('panel-avoid'),
true,
'classes other code sets survive',
);
assert.equal($('#global-loading-status').hidden, true);
assert.equal($('#global-loading-status').hasAttribute('data-state'), false);
assert.equal($('#global-loading-label').textContent, 'LOAD COMPLETE');
});
test('a label that changes as its status shows flaps once the status is visible', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { store } = mountChrome(t);
onScreen = true;
store.publishLoading({
hidden: false,
state: 'error',
label: 'LOAD FAILED',
detail: '',
});
await rendered();
await rendered();
const label = $('#global-loading-label');
assert.equal($('#global-loading-status').hidden, false);
assert.equal(label.classList.contains('gev-flap-active'), true);
assert.ok(label.querySelectorAll('.gev-flap-cell.is-flapping').length > 0);
});
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The scene's status chips as their views render them. ShellFeedback
* (src/ui/shellFeedback.js) publishes the top-center loading status and the
* road network chip, CctvControls the camera grid chip. Each label keeps its
* last text while hidden, so the next change flaps from what was shown.
*/
export const useStatusStore = defineStore('gev-status', () => {
/**
* `{hidden, state, label, detail, stopped}`; null until first published.
* `stopped` rests a label for good once its owner is destroyed.
*/
const loading = shallowRef(null);
/** `{visible, label, immediate, progress, stopped}`, or null. */
const traffic = shallowRef(null);
/** `{visible, label, immediate, progress, stopped}`, or null. */
const cctv = shallowRef(null);
/** @param {?object} next A fresh presentation of the loading status. */
function publishLoading(next) {
loading.value = next && markRaw(next);
}
/** @param {?object} next A fresh presentation of the road network chip. */
function publishTraffic(next) {
traffic.value = next && markRaw(next);
}
/** @param {?object} next A fresh presentation of the camera grid chip. */
function publishCctv(next) {
cctv.value = next && markRaw(next);
}
return {
loading,
publishLoading,
traffic,
publishTraffic,
cctv,
publishCctv,
};
});
+8 -8
View File
@@ -243,20 +243,20 @@ html:not([lang='en']) #global-loading-detail {
/* ── Split-flap chip text ─────────────────────────────────────────
Per-character departure-board flip when a chip label changes.
Driven entirely by src/splitFlap.js, which rebuilds the label as
one span per character on the change and strips them again once
the cascade lands.
Planned by src/splitFlap.js and rendered by SplitFlapText.vue, which
lays out one cell per character on the change and strips them again
once the cascade lands.
The DOM text is the truth and its node never moves. A chip label is
upgraded once into a permanent shell — a .gev-flap-text span holding
one long-lived Text node, plus an aria-hidden .gev-flap-cells
sibling — and from then on a label change is only `node.data = next`.
a .gev-flap-text span holding one long-lived Text node, plus an
aria-hidden .gev-flap-cells sibling, and a label change is only
`node.data = next`.
During a cascade the host takes .gev-flap-active, which clips the
real text out of view and lets the cells stand in for it. The cells
carry no text at all: both glyphs are generated content (::before =
outgoing, ::after = incoming), and generated content never reaches
textContent. That is what keeps QA pins and the traffic chip's own
`textContent !==` guard honest, and it lets the cells be rebuilt
textContent. That is what keeps QA pins honest, and it lets the
cells be rebuilt
freely while the aria-live region sees only one characterData
change per label update.
+14 -9
View File
@@ -2,10 +2,15 @@
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { useDisplayStore } from '../stores/display.js';
import { useStatusStore } from '../stores/status.js';
import { vClassToggle } from '../rail/classToggle.js';
import ImagerySplitView from './panels/ImagerySplitView.vue';
import SplitFlapText from './panels/SplitFlapText.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
// The status chips show what ShellFeedback and CctvControls publish.
const { loading, traffic, cctv } = storeToRefs(useStatusStore());
</script>
<template>
@@ -50,17 +55,17 @@ const { style } = storeToRefs(useDisplayStore());
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
<span id="global-loading-label" data-i18n>LOADING LIVE DATA</span>
<span id="global-loading-detail"></span>
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" :hidden="loading ? loading.hidden : true" :data-state="loading?.state ?? undefined">
<SplitFlapText id="global-loading-label" :text="loading ? loading.label : t('LOADING LIVE DATA')" :stopped="!!loading?.stopped" />
<span id="global-loading-detail">{{ loading ? loading.detail : '' }}</span>
</div>
<div id="traffic-sync-chip" aria-live="polite">
<span id="traffic-sync-label" data-i18n>syncing road network</span>
<span id="traffic-sync-progress">0%</span>
<div id="traffic-sync-chip" aria-live="polite" v-class-toggle="{ visible: !!traffic?.visible }">
<SplitFlapText id="traffic-sync-label" :text="traffic ? traffic.label : t('syncing road network')" :immediate="!!traffic?.immediate" :stopped="!!traffic?.stopped" />
<span id="traffic-sync-progress">{{ traffic ? traffic.progress : '0%' }}</span>
</div>
<div id="cctv-sync-chip" aria-live="polite">
<span id="cctv-sync-label" data-i18n>loading frames</span>
<span id="cctv-sync-progress">0/0</span>
<div id="cctv-sync-chip" aria-live="polite" v-class-toggle="{ visible: !!cctv?.visible }">
<SplitFlapText id="cctv-sync-label" :text="cctv ? cctv.label : t('loading frames')" :immediate="!!cctv?.immediate" :stopped="!!cctv?.stopped" />
<span id="cctv-sync-progress">{{ cctv ? cctv.progress : '0/0' }}</span>
</div>
<!-- Toast notification -->
+177
View File
@@ -0,0 +1,177 @@
<script setup>
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
import {
FLAP_BLANK,
FLAP_CHAR_MS,
FLAP_SETTLE_SLACK_MS,
SPLIT_FLAP_ENABLED,
isOnScreen,
planSplitFlap,
prefersReducedMotion,
visibleGlyphs,
} from '../../../splitFlap.js';
// A status label that flips its changed characters into place, planned by
// src/splitFlap.js (read its invariants first). The text is one long-lived
// node whose data is the only thing a change writes; the aria-hidden cells
// stand in for it while a cascade plays and carry their glyphs as generated
// content, so textContent is the settled label at every instant.
const props = defineProps({
text: { type: String, default: '' },
// Swap the text without a cascade: a completion, not another stage.
immediate: { type: Boolean, default: false },
// Rest at once and stay still: the label's owner is gone.
stopped: { type: Boolean, default: false },
});
// The label's one Text node. Vue would patch a lone `{{ text }}` child by
// setting textContent, which swaps the node; this writes only its data.
const vFlapText = {
mounted(el, { value }) {
el.append(el.ownerDocument.createTextNode(value));
},
updated(el, { value }) {
if (el.firstChild.data !== value) el.firstChild.data = value;
},
};
const root = ref(null);
// The cascade's columns; each cascade keys its own, so the flaps restart.
const cells = shallowRef([]);
const active = ref(false);
// The width ease, `{width, totalMs}`, or null at the natural width.
const sizing = shallowRef(null);
// The cascade in flight: its plan, start and the one settle timer.
let flap = null;
// Bumped by every change; a stale continuation finds it moved on.
let run = 0;
const now = () => globalThis.performance?.now?.() ?? Date.now();
const measure = () => root.value?.getBoundingClientRect?.().width ?? 0;
function rest() {
active.value = false;
cells.value = [];
}
function clearFlap() {
if (flap) clearTimeout(flap.timer);
flap = null;
}
/**
* Ease between two measured widths. The start width lays out before the end
* width renders, so the transition runs; it ends on transitionend, not a
* timer. A growing label eases as its cells go in, a shrinking one once the
* flaps have landed.
*/
async function easeWidth(from, to, totalMs, change) {
if (!(from > 0.5) || Math.abs(to - from) <= 0.5) {
sizing.value = null;
return;
}
sizing.value = { width: from, totalMs };
await nextTick();
if (change !== run || !root.value) return;
void root.value.offsetWidth;
sizing.value = { width: to, totalMs };
}
function endEase(event) {
if (event.target !== root.value || event.propertyName !== 'width') return;
sizing.value = null;
}
/** Strip the cells once the cascade lands, then take up any width slack. */
async function settle(change, easeMs) {
flap = null;
if (change !== run) return;
const cascadeWidth = measure();
sizing.value = null;
rest();
await nextTick();
if (change !== run) return;
void easeWidth(cascadeWidth, measure(), easeMs, change);
}
// Runs before the label re-renders, while it still shows the old text: what
// was on screen and its width are read here. Whether the label is on screen
// is read once the change has rendered, after its chip shows or hides with it;
// the cells then go in before the browser paints.
watch(
() => props.text,
async (next, settled) => {
const change = ++run;
// What the viewer sees right now: mid-cascade, columns whose stagger has
// not elapsed still show the previous label (invariant 3).
const displayed = flap
? visibleGlyphs(flap.plan, now() - flap.startedAt, {
charMs: FLAP_CHAR_MS,
})
: settled;
clearFlap();
const beforeWidth = measure();
sizing.value = null;
rest();
if (
!SPLIT_FLAP_ENABLED ||
props.immediate ||
props.stopped ||
prefersReducedMotion()
)
return;
await nextTick();
if (change !== run || !isOnScreen(root.value)) return;
const plan = planSplitFlap(displayed, next);
if (!plan.changedCount) return;
// Every column holds its width and index for the whole cascade; one the
// new string does not reach flaps to a blank (invariant 4).
cells.value = plan.cells.map((cell) => ({
key: `${change}:${cell.index}`,
next: cell.to || FLAP_BLANK,
prev: cell.changed ? cell.from || FLAP_BLANK : null,
delay: cell.changed ? `${cell.delayMs}ms` : null,
}));
active.value = true;
flap = {
plan,
startedAt: now(),
timer: setTimeout(
() => settle(change, plan.durationMs),
plan.durationMs + FLAP_SETTLE_SLACK_MS,
),
};
await nextTick();
if (change !== run) return;
void easeWidth(beforeWidth, measure(), plan.durationMs, change);
},
{ flush: 'pre' },
);
function stop() {
run += 1;
clearFlap();
}
watch(
() => props.stopped,
(stopped) => {
if (!stopped) return;
stop();
sizing.value = null;
rest();
},
);
onBeforeUnmount(stop);
const hostStyle = () => ({
'--gev-flap-dur': active.value ? `${FLAP_CHAR_MS}ms` : null,
'--gev-flap-total': sizing.value ? `${sizing.value.totalMs}ms` : null,
width: sizing.value ? `${sizing.value.width}px` : null,
});
</script>
<template>
<span ref="root" class="gev-flap-host" :class="{ 'gev-flap-active': active, 'gev-flap-sizing': !!sizing }" :style="hostStyle()" @transitionend="endEase" @transitioncancel="endEase"><span v-flap-text="text" class="gev-flap-text"></span><span class="gev-flap-cells" aria-hidden="true"><span v-for="cell in cells" :key="cell.key" class="gev-flap-cell" :class="{ 'is-flapping': cell.prev != null }" :data-flap-next="cell.next" :data-flap-prev="cell.prev ?? undefined" :style="cell.delay ? { '--gev-flap-delay': cell.delay } : undefined"></span></span></span>
</template>