Files
gods-eye-view/src/ui/shellFeedback.js
T
German KhokhlovandClaude Opus 5.5 9d01f9bfcd fix(i18n): keep English pixel-identical and stored data English
From the English-parity and breakage review against main:
- English wording back to main: "Configured Source", the "SHARED SUBJECT"
  fallback, CCTV calibration "RANGE 120m".
- Voice header and CCTV calibration chip/select tweaks for long labels now
  apply to non-English only (html:not([lang='en'])); English matches main.
  The scene select font change is scoped the same way.
- New scenes and shots store their default title in English ("Scene 3",
  "Shot 2") and translate it when shown, so a title captured in one language
  does not leak into projects, storage or shared JSON.
- Choosing a language with storage blocked keeps it in ?lang instead of
  silently doing nothing.
- One picked import file shows its name again.
- qa-l9-matrix reads the voice state from data-status, not the label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:03:54 +04:00

293 lines
11 KiB
JavaScript

/** Own loading notices, traffic status, toast timing and visibility cleanup. */
import {
aggregateLayerLoading,
createGlobalStatusNotice,
createLoadingFeedbackState,
createTrafficSyncFeedbackState,
presentGlobalLoadingStatus,
reduceLoadingFeedback,
reduceTrafficSyncFeedback,
} from '../loadingFeedback.js';
import { setSplitFlapText, disposeSplitFlap } from '../splitFlap.js';
import { t, msg } from '../i18n/index.js';
import { localizeLayerName } from './layerPanel.js';
// loadingFeedback.js composes the shared status copy inside the portable
// node/browser graph (the server-side HUD summary reads the same modules), so
// its labels stay English there. This chip is the presentation boundary: map
// the closed label vocabulary to the catalog, and pass anything unrecognized
// (a live provider error) through verbatim (docs/TRANSLATORS.md).
const STATUS_LABEL_KEYS = Object.freeze({
'LOADING LIVE DATA': msg('LOADING LIVE DATA'),
'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
'FETCHING MAPPED SITES': msg('FETCHING MAPPED SITES'),
'RETRYING ALPR CAMERAS': msg('RETRYING ALPR CAMERAS'),
'FETCHING ALPR CAMERAS': msg('FETCHING ALPR CAMERAS'),
'OVERPASS TEMPORARILY UNAVAILABLE': msg('OVERPASS TEMPORARILY UNAVAILABLE'),
'TURNING OFF LIVE DATA': msg('TURNING OFF LIVE DATA'),
'REFRESHING LIVE DATA': msg('REFRESHING LIVE DATA'),
'syncing road network': msg('syncing road network'),
});
function localizeStatusLabel(label) {
if (typeof label !== 'string' || !label) return label;
const key = STATUS_LABEL_KEYS[label.trim()];
return key ? t(key) : label;
}
const RETRY_DETAIL_RE = /^(?:ALPR cameras · )?retrying in (\d+)s$/;
function localizeStatusDetail(detail) {
if (typeof detail !== 'string' || !detail) return detail;
const match = detail.match(RETRY_DETAIL_RE);
if (match) return t('retrying in {seconds}s', { seconds: match[1] });
if (detail.trim() === 'retry pending') return t('retry pending');
return localizeLayerRoster(detail);
}
// The loading detail is the active layers' English names joined by " · ",
// with " +N" when more are loading. Translate each known layer name; anything
// else (a provider such as "OpenStreetMap · Overpass") passes through.
function localizeLayerRoster(detail) {
const [, names, suffix = ''] = detail.match(/^(.*?)( \+\d+)?$/s) || [];
if (!names) return detail;
return (
names
.split(' · ')
.map((name) => localizeLayerName(name))
.join(' · ') + suffix
);
}
function localizeLoadingPresentation(presentation) {
if (!presentation) return presentation;
return {
...presentation,
label: localizeStatusLabel(presentation.label),
detail: localizeStatusDetail(presentation.detail),
};
}
export class ShellFeedback {
constructor({ readLayers }) {
this.readLayers = readLayers;
this.destroyed = false;
this._loadingFeedbackState = createLoadingFeedbackState();
this._trafficSyncFeedbackState = createTrafficSyncFeedbackState();
this._loadingFeedbackEvent = null;
this._globalStatusNotice = null;
this._loadingFeedbackTicker =
this._trafficChipTicker =
this._toastTimer =
null;
this._loadingVisibilityHandler = null;
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',
);
this._toast = document.getElementById('toast');
}
observeVisibility() {
if (this.destroyed || this._loadingVisibilityHandler) return;
this._loadingVisibilityHandler = () => {
if (!document.hidden) this._updateGlobalLoadingFeedback();
else this._stopLoadingFeedbackTicker();
};
document.addEventListener(
'visibilitychange',
this._loadingVisibilityHandler,
);
}
_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')
: null;
this._trafficSyncFeedbackState = reduceTrafficSyncFeedback(
this._trafficSyncFeedbackState,
{
enabled: traffic?.enabled === true,
stats: traffic?.stats || {},
forceShow,
},
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);
}
_updateGlobalLoadingFeedback(now = performance.now()) {
if (this.destroyed) return;
if (!this._globalLoadingStatus) return;
const summary = aggregateLayerLoading(this.readLayers() || []);
this._loadingFeedbackState = reduceLoadingFeedback(
this._loadingFeedbackState,
summary,
now,
this._loadingFeedbackEvent,
);
this._loadingFeedbackEvent = null;
const presentation = presentGlobalLoadingStatus(
this._globalStatusNotice,
this._loadingFeedbackState,
summary,
now,
);
if (
this._globalStatusNotice?.persistent !== true &&
Number.isFinite(this._globalStatusNotice?.hideAt) &&
now >= this._globalStatusNotice.hideAt
) {
this._globalStatusNotice = null;
}
// Loading phases and universal notices both have time-driven transitions.
// Compute this after arbitration: a queued finite notice starts its dwell
// only on its first visible frame, then keeps the ticker alive to expiry.
const noticeNeedsTicker = Number.isFinite(this._globalStatusNotice?.hideAt);
if (this._loadingFeedbackState?.phase !== 'idle' || noticeNeedsTicker) {
this._armLoadingFeedbackTicker();
}
const localized = localizeLoadingPresentation(presentation);
this._globalLoadingStatus.hidden = !presentation;
if (!presentation) {
delete this._globalLoadingStatus.dataset.state;
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;
}
_showGlobalStatusNotice(message, options = {}) {
if (this.destroyed) return;
const now = performance.now();
this._globalStatusNotice = createGlobalStatusNotice(message, now, options);
this._updateGlobalLoadingFeedback(now);
}
_startTrafficChipTicker() {
if (this.destroyed) return;
if (this._trafficChipTicker) return;
this._trafficChipTicker = setInterval(() => {
if (document.hidden) return;
const now = performance.now();
// Poll busy traffic promptly; retain the idle/global 500 ms read budget.
if (
this._trafficSyncFeedbackState.busy ||
now - this._lastTrafficChipUpdateAt >= 500
) {
this._lastTrafficChipUpdateAt = now;
this._updateTrafficSyncChip(false, now);
}
if (now - this._lastTrafficGlobalUpdateAt >= 500) {
this._lastTrafficGlobalUpdateAt = now;
this._updateGlobalLoadingFeedback(now);
}
}, 100);
}
_armLoadingFeedbackTicker() {
if (this.destroyed) return;
// Never arm behind a hidden tab: the reducer cannot usefully advance a
// chip nobody can see, and the old `return` INSIDE the interval left the
// 60ms timer scheduled for the entire hidden period (a batch completing
// while hidden could never clear it — the idle check sat behind the
// hidden guard). visibilitychange resamples and re-arms on return.
if (this._loadingFeedbackTicker || document.hidden) return;
this._loadingFeedbackTicker = setInterval(() => {
if (document.hidden) {
this._stopLoadingFeedbackTicker();
return;
}
const now = performance.now();
this._lastLoadingFeedbackUpdateAt = now;
this._updateGlobalLoadingFeedback(now);
const noticeNeedsTicker = Number.isFinite(
this._globalStatusNotice?.hideAt,
);
if (this._loadingFeedbackState?.phase === 'idle' && !noticeNeedsTicker) {
this._stopLoadingFeedbackTicker();
}
}, 60);
}
_stopLoadingFeedbackTicker() {
if (!this._loadingFeedbackTicker) return;
clearInterval(this._loadingFeedbackTicker);
this._loadingFeedbackTicker = null;
}
_showToast(message) {
if (this.destroyed) return;
this._toast.textContent = message;
this._toast.classList.add('visible');
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => {
this._toast.classList.remove('visible');
}, 2000);
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._stopLoadingFeedbackTicker();
clearInterval(this._trafficChipTicker);
clearTimeout(this._toastTimer);
this._trafficChipTicker = this._toastTimer = null;
if (this._loadingVisibilityHandler)
document.removeEventListener(
'visibilitychange',
this._loadingVisibilityHandler,
);
this._loadingVisibilityHandler = null;
disposeSplitFlap(this._globalLoadingLabel);
disposeSplitFlap(this._trafficSyncLabel);
this._toast?.classList.remove('visible');
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
}
}