mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
489 lines
16 KiB
JavaScript
489 lines
16 KiB
JavaScript
import { installationFeedback } from './data/installationFeedback.js';
|
|
|
|
export const LOADING_REVEAL_DELAY_MS = 160;
|
|
export const LOADING_TERMINAL_DWELL_MS = 2200;
|
|
export const LOADING_FAILURE_DWELL_MS = 5000;
|
|
export const LOADING_LONG_THRESHOLD_MS = 30000;
|
|
export const TRAFFIC_SYNC_CONFIRM_MS = 1500;
|
|
/** Layer statuses that are user guidance, not feed faults (see manager.js layerFeedState). */
|
|
export const GUIDANCE_STATUSES = Object.freeze(['zoom-in', 'empty', 'idle']);
|
|
|
|
function finiteCount(value) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number >= 0 ? number : 0;
|
|
}
|
|
|
|
/** Normalize one manager layer into a small loading-feedback record. */
|
|
export function normalizeLayerLoading(layer = {}) {
|
|
const stats = layer.stats || {};
|
|
const lifecycleState = String(
|
|
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'),
|
|
);
|
|
const status = String(stats.status || '').toLowerCase();
|
|
const disabling = lifecycleState === 'disabling';
|
|
const loading =
|
|
lifecycleState === 'enabling' ||
|
|
disabling ||
|
|
stats.loading === true ||
|
|
stats.refreshing === true;
|
|
const count = finiteCount(stats.count);
|
|
const stoppingInstallations =
|
|
['military-installations', 'alpr-cameras'].includes(layer.id) && disabling;
|
|
// Guidance statuses ask the user to act (zoom in, run a search). They are
|
|
// normal operation, never a batch failure — mirrors layerFeedState's carve-out
|
|
// so a prompt stored alongside the status cannot turn the chip red.
|
|
const guidance = GUIDANCE_STATUSES.includes(status);
|
|
const error = stoppingInstallations
|
|
? null
|
|
: (!guidance && stats.error) ||
|
|
stats.lastError ||
|
|
stats.managerRefreshError ||
|
|
null;
|
|
const unavailable =
|
|
!stoppingInstallations &&
|
|
(stats.unavailable === true ||
|
|
stats.available === false ||
|
|
['unavailable', 'offline', 'down', 'error'].includes(status));
|
|
const keyRequired = stats.keyRequired === true || stats.missingKey === true;
|
|
const degraded = stats.degraded === true || Boolean(error);
|
|
const accepted = Boolean(stats.lastUpdate) || count > 0;
|
|
return {
|
|
id: String(layer.id || ''),
|
|
label: String(layer.name || layer.id || 'Layer'),
|
|
loading,
|
|
disabling,
|
|
refresh:
|
|
loading && layer.enabled && (stats.refreshing === true || accepted),
|
|
lifecycleState,
|
|
count,
|
|
accepted,
|
|
error,
|
|
unavailable,
|
|
keyRequired,
|
|
degraded,
|
|
cameraRetry:
|
|
layer.id === 'alpr-cameras' && layer.enabled && !disabling
|
|
? {
|
|
retryAt: Number(stats.retryAt) || 0,
|
|
retrying: stats.retrying === true,
|
|
error,
|
|
}
|
|
: null,
|
|
installationRetry:
|
|
layer.id === 'military-installations' && layer.enabled && !disabling
|
|
? {
|
|
retryAt: Number(stats.retryAt) || 0,
|
|
retrying: stats.retrying === true,
|
|
failureReason: stats.failureReason,
|
|
loading,
|
|
status: stats.status,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function terminalFromParticipantStats(summary, participantIds) {
|
|
if (!participantIds?.length) return null;
|
|
const participants = new Set(participantIds);
|
|
return summary.records.some(
|
|
(record) =>
|
|
participants.has(record.id) &&
|
|
(record.error || record.unavailable || record.keyRequired),
|
|
)
|
|
? 'error'
|
|
: null;
|
|
}
|
|
|
|
/** Aggregate all manager layers without changing their lifecycle authority. */
|
|
export function aggregateLayerLoading(layers = []) {
|
|
const records = layers.map(normalizeLayerLoading);
|
|
const active = records.filter((record) => record.loading);
|
|
const disabling =
|
|
active.length > 0 && active.every((record) => record.disabling);
|
|
return {
|
|
records,
|
|
active,
|
|
activeIds: active.map((record) => record.id),
|
|
disabling,
|
|
refresh:
|
|
!disabling &&
|
|
active.length > 0 &&
|
|
active.every((record) => record.refresh),
|
|
};
|
|
}
|
|
|
|
export function createLoadingFeedbackState() {
|
|
return {
|
|
phase: 'idle',
|
|
visible: false,
|
|
startedAt: 0,
|
|
showAt: 0,
|
|
hideAt: 0,
|
|
activeIds: [],
|
|
batchOutcome: null,
|
|
terminal: null,
|
|
operation: null,
|
|
};
|
|
}
|
|
|
|
/** Create a top-center status notice, optionally persistent until explicitly cleared. */
|
|
export function createGlobalStatusNotice(
|
|
message,
|
|
nowMs = 0,
|
|
{ state = 'error', detail = '', persistent = false } = {},
|
|
) {
|
|
const label = String(message || '').trim();
|
|
if (!label) return null;
|
|
return {
|
|
state,
|
|
label,
|
|
detail: String(detail || '').trim(),
|
|
persistent: !!persistent,
|
|
dwellMs: persistent ? null : LOADING_FAILURE_DWELL_MS,
|
|
// A finite notice starts its dwell only when it first wins presentation.
|
|
// Otherwise a higher-priority manager failure could consume the whole
|
|
// deadline while this notice remained queued and invisible.
|
|
hideAt: null,
|
|
};
|
|
}
|
|
|
|
/** Present a top-center status notice until its deadline or explicit clearing. */
|
|
export function presentGlobalStatusNotice(notice, nowMs = 0) {
|
|
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
|
if (!notice?.label) return null;
|
|
if (!notice.persistent && !Number.isFinite(notice.hideAt)) {
|
|
notice.hideAt =
|
|
now +
|
|
(Number.isFinite(notice.dwellMs)
|
|
? notice.dwellMs
|
|
: LOADING_FAILURE_DWELL_MS);
|
|
}
|
|
if (Number.isFinite(notice.hideAt) && now >= notice.hideAt) return null;
|
|
return {
|
|
state: notice.state || 'error',
|
|
label: notice.label,
|
|
detail: notice.detail || '',
|
|
};
|
|
}
|
|
|
|
/** Whether deferred notice work still owns the current presentation epoch. */
|
|
export function canPresentDeferredStatusNotice(
|
|
expectedGeneration,
|
|
currentGeneration,
|
|
disposed = false,
|
|
) {
|
|
return (
|
|
!disposed &&
|
|
Number.isSafeInteger(expectedGeneration) &&
|
|
expectedGeneration === currentGeneration
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Present the shared status surface without allowing a persistent notice to
|
|
* hide a terminal manager failure. Failure dwell starts when the manager
|
|
* reports it, so it must remain the highest-priority presentation while live.
|
|
*/
|
|
export function presentGlobalLoadingStatus(
|
|
notice,
|
|
loadingState,
|
|
summary,
|
|
nowMs = 0,
|
|
) {
|
|
const loadingPresentation = presentLoadingFeedback(
|
|
loadingState,
|
|
summary,
|
|
nowMs,
|
|
);
|
|
if (['error', 'retry'].includes(loadingPresentation?.state))
|
|
return loadingPresentation;
|
|
return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation;
|
|
}
|
|
|
|
/** Create the sampled Street Traffic chip state. */
|
|
export function createTrafficSyncFeedbackState() {
|
|
return {
|
|
busy: false,
|
|
visible: false,
|
|
confirmationUntil: 0,
|
|
label: '',
|
|
progressText: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reduce one sampled Street Traffic status without extending completion on
|
|
* every animation-loop poll. Coverage describes accepted data, not work.
|
|
*/
|
|
export function reduceTrafficSyncFeedback(
|
|
previous,
|
|
{ enabled = false, stats = {}, forceShow = false } = {},
|
|
nowMs = 0,
|
|
) {
|
|
const state = previous || createTrafficSyncFeedbackState();
|
|
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
|
if (!enabled) return createTrafficSyncFeedbackState();
|
|
|
|
const hasProgress = Number.isFinite(stats.phaseProgressPct);
|
|
const progressPct = hasProgress
|
|
? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct)))
|
|
: stats.loading
|
|
? 1
|
|
: 100;
|
|
const busy =
|
|
stats.loading === true ||
|
|
stats.worldJumping === true ||
|
|
(hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0));
|
|
const label = String(stats.phaseLabel || stats.loadingLabel || '').trim();
|
|
|
|
if (busy) {
|
|
return {
|
|
busy: true,
|
|
visible: true,
|
|
confirmationUntil: 0,
|
|
// Neutral default: the layer always supplies its own LIVE/SIMULATED
|
|
// label, and a fallback string must never claim a live feed on a
|
|
// keyless build.
|
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
|
label: label || 'syncing road network',
|
|
progressText: hasProgress ? `${progressPct}%` : '...',
|
|
};
|
|
}
|
|
|
|
const existingConfirmation =
|
|
state.confirmationUntil > now ? state.confirmationUntil : 0;
|
|
const confirmationUntil =
|
|
existingConfirmation ||
|
|
(state.busy || forceShow ? now + TRAFFIC_SYNC_CONFIRM_MS : 0);
|
|
const visible =
|
|
confirmationUntil > now && progressPct >= 100 && Boolean(label);
|
|
return {
|
|
busy: false,
|
|
visible,
|
|
confirmationUntil: visible ? confirmationUntil : 0,
|
|
label: visible ? label : '',
|
|
// The settled flash carries NO progress number. A settled chip is 100% by
|
|
// definition — the value never varied — and printing it beside a label
|
|
// that already ends in a real measurement produced the self-contradicting
|
|
// "LIVE · TomTom flow · 0% cov 100%". Coverage is the honest number, so
|
|
// it is the only one left standing; the progress slot belongs to work in
|
|
// flight.
|
|
progressText: '',
|
|
};
|
|
}
|
|
|
|
function terminalFromEvent(event) {
|
|
const type = String(event?.type || '');
|
|
if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error)
|
|
return 'error';
|
|
if (type === 'visibility-cancelled' || event?.cancelled) return 'cancelled';
|
|
if (type === 'visibility' || type === 'refresh') return 'complete';
|
|
return null;
|
|
}
|
|
|
|
function mergeTerminalOutcome(current, next) {
|
|
const severity = { complete: 1, cancelled: 2, error: 3 };
|
|
if (!next) return current || null;
|
|
if (!current || severity[next] > severity[current]) return next;
|
|
return current;
|
|
}
|
|
|
|
/** Reduce a sampled manager summary into delayed, non-flashing UI state. */
|
|
export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
|
|
const state = previous || createLoadingFeedbackState();
|
|
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
|
if (summary.active.length) {
|
|
const beginning = state.phase !== 'loading';
|
|
const startedAt = beginning ? now : state.startedAt;
|
|
const priorParticipants = beginning ? [] : state.activeIds;
|
|
const activeIds = [
|
|
...new Set([...priorParticipants, ...summary.activeIds]),
|
|
];
|
|
const eventLayerId = String(event?.layerId || '');
|
|
const eventParticipates = eventLayerId && activeIds.includes(eventLayerId);
|
|
const batchOutcome = mergeTerminalOutcome(
|
|
mergeTerminalOutcome(
|
|
beginning ? null : state.batchOutcome,
|
|
terminalFromParticipantStats(summary, activeIds),
|
|
),
|
|
eventParticipates ? terminalFromEvent(event) : null,
|
|
);
|
|
return {
|
|
phase: 'loading',
|
|
visible: !beginning && now >= state.showAt,
|
|
startedAt,
|
|
showAt: beginning ? now + LOADING_REVEAL_DELAY_MS : state.showAt,
|
|
hideAt: 0,
|
|
activeIds,
|
|
batchOutcome,
|
|
terminal: null,
|
|
operation: summary.disabling
|
|
? 'disabling'
|
|
: summary.refresh
|
|
? 'refresh'
|
|
: 'loading',
|
|
failedEventIds: [
|
|
...new Set([
|
|
...(beginning ? [] : state.failedEventIds || []),
|
|
...(eventParticipates && terminalFromEvent(event) === 'error'
|
|
? [eventLayerId]
|
|
: []),
|
|
]),
|
|
],
|
|
};
|
|
}
|
|
|
|
if (state.phase === 'loading') {
|
|
const eventLayerId = String(event?.layerId || '');
|
|
const eventParticipates =
|
|
eventLayerId && state.activeIds.includes(eventLayerId);
|
|
const terminal =
|
|
mergeTerminalOutcome(
|
|
mergeTerminalOutcome(
|
|
state.batchOutcome,
|
|
terminalFromParticipantStats(summary, state.activeIds),
|
|
),
|
|
eventParticipates ? terminalFromEvent(event) : null,
|
|
) || 'complete';
|
|
const wasVisible = state.visible || now >= state.showAt;
|
|
if (!wasVisible && terminal === 'complete')
|
|
return createLoadingFeedbackState();
|
|
const dwell =
|
|
terminal === 'error'
|
|
? LOADING_FAILURE_DWELL_MS
|
|
: LOADING_TERMINAL_DWELL_MS;
|
|
return {
|
|
...state,
|
|
phase: 'terminal',
|
|
visible: true,
|
|
hideAt: now + dwell,
|
|
batchOutcome: terminal,
|
|
terminal,
|
|
failedEventIds: [
|
|
...new Set([
|
|
...(state.failedEventIds || []),
|
|
...(eventParticipates && terminalFromEvent(event) === 'error'
|
|
? [eventLayerId]
|
|
: []),
|
|
]),
|
|
],
|
|
};
|
|
}
|
|
|
|
if (state.phase === 'terminal' && now < state.hideAt) return state;
|
|
return createLoadingFeedbackState();
|
|
}
|
|
|
|
/** Build the user-facing status copy for the current loading state. */
|
|
export function presentLoadingFeedback(state, summary, nowMs) {
|
|
const camera = summary.records.find(
|
|
(record) => record.cameraRetry?.retryAt > 0,
|
|
);
|
|
const otherCameraFailure =
|
|
summary.records.some(
|
|
(record) =>
|
|
record.id !== 'alpr-cameras' &&
|
|
(state?.activeIds || []).includes(record.id) &&
|
|
(record.error || record.unavailable || record.keyRequired),
|
|
) || (state?.failedEventIds || []).some((id) => id !== 'alpr-cameras');
|
|
if (camera && !summary.active.length && !otherCameraFailure) {
|
|
const seconds = Math.max(
|
|
0,
|
|
Math.ceil((camera.cameraRetry.retryAt - Date.now()) / 1000),
|
|
);
|
|
return {
|
|
state: 'retry',
|
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
|
label: (
|
|
camera.cameraRetry.error || 'Overpass temporarily unavailable'
|
|
).toUpperCase(),
|
|
detail: `ALPR cameras · ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`,
|
|
};
|
|
}
|
|
|
|
const site = summary.records.find(
|
|
(record) => record.installationRetry?.retryAt > 0,
|
|
);
|
|
const otherFailure =
|
|
summary.records.some(
|
|
(record) =>
|
|
record.id !== 'military-installations' &&
|
|
(state?.activeIds || []).includes(record.id) &&
|
|
(record.error || record.unavailable || record.keyRequired),
|
|
) ||
|
|
(state?.failedEventIds || []).some((id) => id !== 'military-installations');
|
|
// Keep the actual retry visible between attempts, without hiding another
|
|
// participant's failure or pretending that a scheduled retry is fetching.
|
|
if (site && !summary.active.length && !otherFailure) {
|
|
const message = installationFeedback(site.installationRetry);
|
|
const [label, detail] = message.split(' — ');
|
|
return { state: 'retry', label: label.toUpperCase(), detail: detail || '' };
|
|
}
|
|
if (!state?.visible) return null;
|
|
if (state.phase === 'terminal') {
|
|
const labels = {
|
|
complete: 'LOAD COMPLETE',
|
|
cancelled: 'LOAD CANCELLED',
|
|
error: 'LOAD FAILED',
|
|
};
|
|
const label =
|
|
state.operation === 'disabling' && state.terminal === 'complete'
|
|
? 'LIVE DATA OFF'
|
|
: state.terminal === 'complete' &&
|
|
state.activeIds?.length === 1 &&
|
|
state.activeIds[0] === 'military-installations'
|
|
? 'MAPPED SITES LOADED'
|
|
: labels[state.terminal] || 'LOAD COMPLETE';
|
|
return { state: state.terminal, label, detail: '' };
|
|
}
|
|
const active = summary.active;
|
|
if (active.length === 1 && active[0].cameraRetry && !summary.disabling) {
|
|
return {
|
|
state: 'loading',
|
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
|
label: active[0].cameraRetry.retrying
|
|
? 'RETRYING ALPR CAMERAS'
|
|
: 'FETCHING ALPR CAMERAS',
|
|
detail: 'OpenStreetMap · Overpass',
|
|
};
|
|
}
|
|
|
|
if (
|
|
active.length === 1 &&
|
|
active[0].installationRetry &&
|
|
!summary.disabling
|
|
) {
|
|
return {
|
|
state: 'loading',
|
|
// i18n-ignore: portable node/browser status vocabulary; src/ui/shellFeedback.js translates it at the presentation boundary
|
|
label: active[0].installationRetry.retrying
|
|
? 'RETRYING MAPPED SITES'
|
|
: 'FETCHING MAPPED SITES',
|
|
detail: 'OpenStreetMap · Overpass',
|
|
};
|
|
}
|
|
const elapsed = Math.max(0, nowMs - state.startedAt);
|
|
const label = summary.disabling
|
|
? 'TURNING OFF LIVE DATA'
|
|
: summary.refresh
|
|
? 'REFRESHING LIVE DATA'
|
|
: 'LOADING LIVE DATA';
|
|
const names = active
|
|
.slice(0, 2)
|
|
.map((record) => record.label)
|
|
.join(' · ');
|
|
const suffix = active.length > 2 ? ` +${active.length - 2}` : '';
|
|
return {
|
|
state:
|
|
elapsed >= LOADING_LONG_THRESHOLD_MS
|
|
? 'long'
|
|
: summary.disabling
|
|
? 'disabling'
|
|
: summary.refresh
|
|
? 'refresh'
|
|
: 'loading',
|
|
label,
|
|
detail: `${names}${suffix}`,
|
|
};
|
|
}
|