mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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