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

+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>