Merge origin/main into claude/hal-676-gev-fire-intensity-742bd1

Locale conflicts (es, ru, zh-CN): keep both sides' new keys. The new uk
and zh-Hant catalogs get the fire filter strings; zh-Hant keeps
"≥ {value} MW" as in English, like zh-CN.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-29 17:22:30 +04:00
commit 520cf4cb4f
237 files changed
+10805 -944

No files matched your search

+19 -32
View File
@@ -38,33 +38,21 @@ export const DIRECTIONS_MODES = Object.freeze({
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
* The row error for a failed route request (createDirectionsSource errors),
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
* in src/ui/statusText.js).
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
`${error.refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
return `Routing unavailable (HTTP ${error.status})`;
return error?.message || msg('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
@@ -659,7 +647,9 @@ export function directionsStats(state) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: t('Another map tool is using clicks — close it, then SET A again'),
error: msg(
'Another map tool is using clicks — close it, then SET A again',
),
status: 'empty',
source,
};
@@ -678,7 +668,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || t('No route found'),
error: error || msg('No route found'),
status: 'empty',
source,
};
@@ -1160,13 +1150,10 @@ export function createDirectionsLayer({ services, source }) {
clearRouteGraphics();
_status = 'error';
_error =
payload?.error === 'no route found'
? t('No route found between A and B')
: payload?.error
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
payload?.error && payload.error !== 'no route found'
? `Routing failed: ${payload.error}`
: msg('No route found between A and B');
return;
}
_route = route;
@@ -1183,9 +1170,9 @@ export function createDirectionsLayer({ services, source }) {
// happened instead.
_error =
error?.name === 'AbortError'
? t('Routing timed out')
? msg('Routing timed out')
: error?.name === 'TypeError'
? t('Routing unavailable — no response from the routing service')
? msg('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
+121
View File
@@ -0,0 +1,121 @@
// Directions reports its fixed errors as English data (getStats(), and so the
// layer snapshot the voice tools and the analyst read); the Layers panel row
// shows them in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t as translate } from '../../i18n/index.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { directionsStats, presentRoutingFailure } from './index.js';
const idle = {
enabled: true,
mode: 'car',
armed: null,
a: null,
b: null,
status: 'idle',
error: null,
route: null,
lastUpdate: null,
};
const cases = [
[
{ ...idle, pointerBlocked: true },
'Another map tool is using clicks — close it, then SET A again',
],
[{ ...idle, status: 'error' }, 'No route found'],
[
{ ...idle, status: 'error', error: 'Routing timed out' },
'Routing timed out',
],
];
function rowFor(stats) {
return LayerPanel.prototype._buildMetaText({
id: 'directions',
enabled: true,
stats,
});
}
// Route request failures with the proxy's refusal or an HTTP status in them:
// [the request error, its English row error, the row text in the language]
const failures = () => [
[
{ status: 429, refusal: 'route leg too long' },
'route leg too long — try again in a moment',
translate('{error} — try again in a moment', {
error: translate('route leg too long'),
}),
],
[
{ status: 429, refusal: null },
'Routing is rate limited — try again in a moment',
translate('Routing is rate limited — try again in a moment'),
],
[
{ status: 503 },
'Routing unavailable (HTTP 503)',
translate('Routing unavailable (HTTP {status})', { status: 503 }),
],
[
{ status: 0, message: '' },
'Routing unavailable',
translate('Routing unavailable'),
],
];
for (const locale of ['es', 'ru']) {
test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [state, english] of cases) {
const stats = directionsStats(state);
assert.equal(stats.error, english);
const row = rowFor(stats);
assert.ok(row.includes(translate(english)), `${locale}: ${row}`);
assert.ok(!row.includes(english), `${locale}: ${row}`);
}
});
}
for (const locale of ['es', 'ru']) {
test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
for (const [failure, english, shown] of failures()) {
const error = presentRoutingFailure(failure);
assert.equal(error, english);
const stats = directionsStats({ ...idle, status: 'error', error });
assert.equal(stats.error, english);
assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`);
assert.notEqual(shown, english);
}
// The proxy's refusal after "Routing failed:", and a text it never sends.
for (const [refusal, shownRefusal] of [
['route too long', translate('route too long')],
['something else', 'something else'],
]) {
const row = rowFor(
directionsStats({
...idle,
status: 'error',
error: `Routing failed: ${refusal}`,
}),
);
assert.ok(
row.includes(
translate('Routing failed: {error}', { error: shownRefusal }),
),
`${locale}: ${row}`,
);
}
});
}
test('en: the Directions row shows the English error', () => {
setLocale('en');
const row = rowFor(directionsStats({ ...idle, status: 'error' }));
assert.match(row, /No route found/);
});
+5 -3
View File
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
*
* Two inputs feed it. The receiver is any object with `getState()` returning
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
* text of an `error` status (`message`, the UI-language one, stands in for a
* receiver without it). The browser WebUSB session is one such receiver.
* The optional `feeds` session (`./feeds.js`) supplies records read
* by the server from local 1090/978 MHz decoders; it polls only while this
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
+1 -1
View File
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
return { status: 'error', error: receiver.errorText ?? receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: msg('opening receiver') };
if (
+44 -1
View File
@@ -2,7 +2,8 @@
// "USB error", feed problems, even as the whole error) read in the UI language.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import { setLocale, t } from '../../i18n/index.js';
import { SdrController } from '../../sdr/controller.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { localAdsbStatus } from './status.js';
@@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) {
});
}
}
// A receiver error is English data for the layer (stats, and what the voice
// tools and the analyst read); the Radio card and the row show it translated.
for (const locale of ['es', 'ru']) {
test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => {
const receiver = new SdrController({
storage: null,
webUsb: {},
providerFactory: () => ({
get: async () => {
throw new Error('No device was selected.');
},
}),
});
setLocale(locale);
try {
assert.equal(await receiver.connect('adsb'), false);
const state = receiver.getState();
assert.equal(state.status, 'error');
assert.equal(state.errorText, 'No RTL-SDR device selected');
assert.equal(state.message, t('No RTL-SDR device selected'));
assert.notEqual(state.message, state.errorText);
const status = localAdsbStatus({
receiver: state,
feedState: null,
heard: 0,
});
assert.equal(status.error, 'No RTL-SDR device selected');
const row = rowFor(status, 0);
assert.ok(row.includes(state.message), row);
assert.doesNotMatch(row, /No RTL-SDR device selected/, row);
// The English text goes with the error: stop() clears it.
await receiver.stop();
assert.equal(receiver.getState().errorText, null);
} finally {
setLocale('en');
await receiver.destroy();
}
});
}
+22 -3
View File
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
// The proxy's last snapshot, served while WFIGS is unreachable.
let _stale = false;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
// The source contract is getSnapshot; a source that can also say how
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
const snapshot =
typeof source.getSnapshotDetail === 'function'
? await source.getSnapshotDetail({ signal: request.signal })
: { rows: await source.getSnapshot({ signal: request.signal }) };
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const { rows } = snapshot;
_stale = snapshot.stale === true;
// A stale snapshot is as old as its fetch, not this poll.
const updatedAt =
_stale && Number.isFinite(snapshot.fetchedAt)
? snapshot.fetchedAt
: Date.now();
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
return true;
}
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastUpdate = updatedAt;
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || msg('Perimeter source unavailable');
// A failure now outranks the stale snapshot still drawn.
_stale = false;
return false;
} finally {
if (_request === request) _request = null;
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
};
},
};
+33
View File
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
if (action !== 'destroy') h.layer.destroy();
});
}
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
let snapshot = { rows: [row], stale: true, fetchedAt };
const h = harness({
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
});
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => [row],
getSnapshotDetail: async () => {
if (fail) throw new Error('WFIGS HTTP 502');
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, true);
fail = true;
await h.layer.update(h.viewer);
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
});
+28 -13
View File
@@ -8,20 +8,35 @@ const msg = (text) => text;
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/**
* The rows with how fresh they are: the proxy answers from its last
* snapshot, marked stale, while WFIGS is unreachable.
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
*/
async function getSnapshotDetail({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return {
rows: payload.rows,
stale: payload.stale === true,
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
};
}
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed perimeter snapshot'));
return payload.rows;
/** The perimeter rows (see getSnapshotDetail for staleness). */
async getSnapshot(options = {}) {
return (await getSnapshotDetail(options)).rows;
},
getSnapshotDetail,
};
}
+64
View File
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
name: 'AbortError',
});
});
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
let clock = 1_000_000_000;
let up = true;
const plugin = firePerimetersProxy({
now: () => clock,
fetchImpl: async () => {
if (!up) throw new Error('WFIGS unreachable');
return Response.json(validPayload);
},
});
let handler;
plugin.configureServer({
middlewares: { use: (_path, callback) => (handler = callback) },
});
const ask = async () => {
const reply = {};
await handler(
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
{
writeHead(status, headers) {
reply.status = status;
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
},
end(body) {
reply.body = JSON.parse(body);
},
},
);
return reply;
};
const fresh = await ask();
assert.equal(fresh.status, 200);
assert.equal(fresh.body.stale, undefined);
up = false;
clock += 23 * 60 * 60 * 1000;
const stale = await ask();
assert.equal(stale.status, 200);
assert.equal(stale.staleHeader, true);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
clock += 2 * 60 * 60 * 1000;
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
});
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
const fetchedAt = Date.UTC(2026, 9, 25, 12);
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
});
assert.deepEqual(await source.getSnapshotDetail(), {
rows: [],
stale: true,
fetchedAt,
});
assert.deepEqual(await source.getSnapshot(), []);
});
test('getSnapshot works when taken off its source object', async () => {
const { getSnapshot } = createWfigsPerimeterSource({
fetchImpl: async () => Response.json({ rows: [] }),
});
assert.deepEqual(await getSnapshot(), []);
});
+3 -1
View File
@@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text;
return getLocale().startsWith('zh-')
? text.replace(/(\d)(?=天)/g, '$1 ')
: text;
},
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen).
+15
View File
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
liveMode: layerState._liveMode,
fetching: loading,
flowError: layerState._flowError,
stale: layerState._flowStale,
coveragePct: flowCoveragePct,
statusUnavailable: layerState._flowStatusUnavailable,
roadSource: layerState._roadSource,
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
lastUpdate: layerState._lastUpdate,
loading,
mode: feed.mode,
// Cached proxy tiles (TomTom budget spent or upstream failing): the
// manager reads this as STALE. Any error (flow, roads, detail) reads
// DEGRADED instead.
stale: Boolean(
layerState._liveMode &&
layerState._flowStale &&
!(
layerState._roadError ||
layerState._roadWarning ||
layerState._detailError ||
feed.error
),
),
oldestTileAt: layerState._flowOldestTileAt ?? null,
error:
layerState._roadError ||
layerState._roadWarning ||
+11 -2
View File
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
return layerState._warmFlow;
layerState._warmFlowGeneration = generation;
const signal = layerState._activeFetchAbort?.signal;
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
if (!layerState._liveMode || generation !== layerState._loadGeneration)
return [];
return fetchFlowForBounds(clamped, { signal });
if (typeof source.fetchFlowDetail !== 'function')
return fetchFlowForBounds(clamped, { signal });
// The proxy serves cached tiles once the TomTom budget is spent or the
// upstream fails; carry that mark so getStats() reads STALE, not LIVE.
const detail = await source.fetchFlowDetail(clamped, { signal });
if (generation === layerState._loadGeneration) {
layerState._flowStale = detail.stale === true;
layerState._flowOldestTileAt = detail.oldestTileAt ?? null;
}
return detail.segments;
});
layerState._warmFlow.catch(() => {});
return layerState._warmFlow;
+4
View File
@@ -42,6 +42,8 @@ export function createLifecycle({
layerState._lastViewCenter = null;
layerState._flowRoads = null;
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
if (TRAFFIC_TIMING_ENABLED) {
layerState._trafficTimingCurrentAnchor = null;
layerState._trafficTimingSequence = 0;
@@ -159,6 +161,8 @@ export function createLifecycle({
// A stale outage from the last session would misreport a fresh enable —
// the next load re-derives feed health from real evidence.
layerState._flowError = null;
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
layerState._surfaceFrameRemover?.();
layerState._surfaceFrameRemover = null;
+12
View File
@@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) {
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
* @param {boolean} [input.fetching] - A viewport load is in flight.
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
* @param {boolean} [input.stale] - The flow came from cached proxy tiles.
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
* @param {string} [input.roadSource] - Name of the geometry being drawn.
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
liveMode = false,
fetching = false,
flowError = null,
stale = false,
coveragePct = 0,
statusUnavailable = false,
roadSource = 'OpenStreetMap',
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
});
return { mode, error: degraded, loadingLabel: degraded };
}
// Cached proxy tiles (TomTom budget spent or upstream failing) are not live.
if (liveMode && stale && !fetching) {
return {
mode,
error: null,
loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', {
source: roadSource,
}),
};
}
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
if (liveMode && roadSource === 'TomTom') {
return {
+58
View File
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
assert.equal(state._flowPending, 0);
});
test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => {
const { createFlow } = await import('./flow.js');
const { createModel } = await import('./model.js');
const { createControls } = await import('./controls.js');
const { createState } = await import('./state.js');
const { layerFeedState } = await import('../../data/feedState.js');
let detail = {
segments: [flow],
partial: false,
stale: true,
oldestTileAt: 1000,
};
const source = {
fetchFlowDetail: async () => detail,
fetchFlowForBounds: async () => detail.segments,
getFlowSessionStats: () => ({ tilesFetched: 1 }),
};
const state = createState({ services: {} });
Object.assign(state, {
_enabled: true,
_liveMode: true,
_loadGeneration: 1,
_flowStatusPromise: Promise.resolve(),
_bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 },
});
const parts = {};
const context = { state, services: { credits: {} }, parts, source };
parts.model = createModel(context);
parts.flow = createFlow(context);
const controls = createControls(context);
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1);
const stale = controls.methods.getStats();
assert.equal(stale.mode, 'live');
assert.equal(stale.stale, true);
assert.equal(stale.oldestTileAt, 1000);
assert.doesNotMatch(stale.loadingLabel, /LIVE/);
assert.equal(layerFeedState(stale), 'stale');
// A road failure outranks cached flow: the row reads the failure.
state._roadError = 'Road geometry unavailable';
assert.equal(controls.methods.getStats().stale, false);
state._roadError = null;
detail = {
segments: [flow],
partial: false,
stale: false,
oldestTileAt: 2000,
};
state._loadGeneration = 2;
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2);
const live = controls.methods.getStats();
assert.equal(live.stale, false);
assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/);
assert.equal(layerFeedState(live), 'nominal');
});
test('status names the drawn road source for each mode', async () => {
const { createModel } = await import('./model.js');
const { trafficFeedPresentation: present } = createModel({
+10
View File
@@ -108,6 +108,16 @@ export function createState({ services }) {
layerState._flowError = null;
/**
* True while the drawn flow came from cached proxy tiles (TomTom budget
* spent or upstream failing); `_flowOldestTileAt` is the oldest tile's
* fetch time in ms, or null.
* @type {boolean}
*/
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
/**
* True when `/api/tomtom/status` itself could not be reached, so the layer is
* simulating because it could not ask — not because the server said "no key".
+3
View File
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
layerState._fetching = false;
layerState._flowPending = 0;
layerState._roadError = null;
// Nothing is drawn up here, cached or not.
layerState._flowStale = false;
layerState._flowOldestTileAt = null;
parts.animation.clearDots();
layerState._lastBounds = null;
layerState._lastViewCenter = null;
+4 -2
View File
@@ -11,7 +11,7 @@ export function createLifecycle({
}) {
const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -125,6 +125,7 @@ export function createLifecycle({
state.interactionHandlerFactory = null;
state.interactionKeyTarget = null;
state.preRenderRemover = null;
components.rendering.cancelTrailingVisibilityPass();
state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
@@ -164,7 +165,6 @@ export function createLifecycle({
const wasEnabled = state.feed.enabled;
state.feed.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer);
@@ -208,6 +208,7 @@ export function createLifecycle({
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass();
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -227,6 +228,7 @@ export function createLifecycle({
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass();
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
+5 -1
View File
@@ -29,7 +29,11 @@ function services() {
forgetSpriteFocus: noop,
},
worldFocus: {},
render: { releaseContinuousRender: noop },
render: {
holdContinuousRender: noop,
releaseContinuousRender: noop,
governorRequestRender: noop,
},
};
}
function setup(source, options = {}) {
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js';
// Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera
// stops, and never schedules a frame from a pass the camera did not cause.
const noop = () => {};
function setup() {
let clock = 10_000;
const requests = [];
const holds = new Set();
const timers = new Map();
let nextTimer = 1;
const layer = createVesselLayer({
source: { getSnapshot: async () => ({ records: [] }) },
services: {
context: {
clearSelectedEntityContextForLayer: noop,
registerEntityContext: () => null,
},
trails: {
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
},
labels: {},
picking: { unregisterPickOwner: noop },
overlay: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
geoid: {},
sprites: {},
focus: {
focusNowMs: () => clock,
getFocusTarget: () => null,
focusPassIsNeeded: () => false,
forgetSpriteFocus: noop,
},
worldFocus: {},
render: {
holdContinuousRender: (id) => holds.add(id),
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
},
});
const camera = {
positionWC: { x: 0, y: 0, z: 7_000_000 },
heading: 0,
pitch: -1.2,
roll: 0,
};
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
layer.testing._setAisRuntimeForTest({
now: () => clock,
setTimeout(fn, ms) {
const id = nextTimer++;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
});
const advance = (ms) => {
clock += ms;
for (const [id, timer] of [...timers]) {
if (timer.at > clock) continue;
timers.delete(id);
timer.fn();
}
};
const frame = () => layer.testing._runVisibilityPassForTest();
return { layer, camera, requests, holds, timers, advance, frame };
}
test('a parked camera schedules no frames after the cadence pass', () => {
const { requests, holds, timers, advance, frame } = setup();
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, []);
assert.equal(timers.size, 0);
assert.equal(holds.has('ais-vessels'), false);
});
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
advance(100);
frame();
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, ['ais-vessels-visibility']);
// The trailing frame runs the regular pass on the pose the camera stopped
// on. Its own frames must not schedule another one.
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, ['ais-vessels-visibility']);
assert.equal(timers.size, 0);
});
test('a forced pass requests its own frame', () => {
const { layer, requests } = setup();
layer.testing._runVisibilityPassForTest(true);
assert.deepEqual(requests, ['ais-vessels-update']);
});
test('destroy cancels a pending trailing frame', (t) => {
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []);
});
+66 -2
View File
@@ -75,7 +75,13 @@ export function createRendering({
focusPassIsNeeded,
advanceSpriteFocus,
focusAlphaNeedsWrite,
spriteFocusAtRest,
} = services.focus;
const {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} = services.render;
function renderRowLimit() {
const configured = Number(options.maxRows);
@@ -196,6 +202,14 @@ export function createRendering({
return icon;
}
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
// frame, a frame that moved the camera but fell inside the 800 ms cadence
// leaves a trailing frame so the view the camera stopped on is always
// evaluated, and the continuous hold is kept only while focus emphasis is
// animating. The trailing frame is keyed to camera motion alone: the pass's
// own label publication requests a frame, and scheduling on that would loop.
function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
@@ -203,8 +217,27 @@ export function createRendering({
);
}
function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return;
const waitMs = Math.max(
0,
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
);
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
state.trailingVisibilityTimer = null;
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
}, waitMs);
}
function cancelTrailingVisibilityPass() {
if (!state.trailingVisibilityTimer) return;
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
state.trailingVisibilityTimer = null;
}
function updateVisibility(force = false) {
if (!state.feed.enabled) return;
if (force) governorRequestRender('ais-vessels-update');
const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget();
const regularPass =
@@ -212,13 +245,30 @@ export function createRendering({
const focusPass =
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
if (
!regularPass &&
state.viewer?.camera &&
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
)
scheduleTrailingVisibilityPass(now);
if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now;
if (!state.records.all.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
// Still record the pose this pass evaluated, or every later frame that
// falls inside the cadence would schedule another trailing frame.
if (regularPass) {
if (state.viewer?.camera)
vesselState._lastCamPoseSig = cameraPoseSignature(
state.viewer.camera,
);
updateClusteredLabels([]);
}
if (focusPass) {
state.activeFocusCount = 0;
releaseContinuousRender('ais-vessels');
}
return;
}
@@ -272,6 +322,9 @@ export function createRendering({
Cesium.Cartesian3.distance(camera.positionWC, position),
});
state.activeFocusCount = result.activeCount;
if (result.activeCount > 0 || result.transitioning)
holdContinuousRender('ais-vessels');
else releaseContinuousRender('ais-vessels');
}
}
@@ -302,6 +355,16 @@ export function createRendering({
const bb = visual?.billboard;
const position = bb?.position || visual?.position;
if (!bb || !position) continue;
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
// no pending focus state and full alpha would advance to exactly what it
// already is, so skip it instead of paying its distance and state work
// every 80 ms while anything is tracked.
if (
bb.show === false &&
spriteFocusAtRest(bb) &&
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
)
continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha.
@@ -510,6 +573,7 @@ export function createRendering({
shipIcon,
installRuntime,
updateVisibility,
cancelTrailingVisibilityPass,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
+2
View File
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
/** Test-only key target paired with interactionHandlerFactory. */
interactionKeyTarget: null,
preRenderRemover: null,
/** Pending timer that renders one frame after a cadence-skipped pass. */
trailingVisibilityTimer: null,
lastVisibilityUpdate: 0,
lastFocusUpdate: 0,
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
+14
View File
@@ -151,6 +151,18 @@ export function createTesting({
};
}
/** Run one preRender visibility/focus pass. Test-only seam. */
function _runVisibilityPassForTest(force = false) {
components.rendering.updateVisibility(force);
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
return state.trailingVisibilityTimer !== null;
}
/**
* Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam.
@@ -176,5 +188,7 @@ export function createTesting({
_setAisRuntimeForTest,
_getVesselFeedStateForTest,
_getVesselStateForTest,
_runVisibilityPassForTest,
_hasTrailingVisibilityPassForTest,
};
}