mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
492b719f19
commit
3c391482f6
21 files changed
+838
-428
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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*\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user