Merge branch 'main' into codex/fix-seek-layer-state

This commit is contained in:
Alex O'Neill authored and GitHub committed 2026-10-25 19:39:36 +00:00
commit cdb6bd0716
111 files changed
+2857 -200

No files matched your search

+4 -3
View File
@@ -208,9 +208,10 @@ export function formatMeasure(session) {
if (!session) return '';
if (session.shape === 'line') {
const m = pathLengthM(session.vertices);
return m >= 1000
? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`
: `${Math.round(m)} m`;
// Round before the kilometre cut. 999.6 m was printed as "1000 m".
const whole = Math.round(m);
if (whole < 1000) return `${whole} m`;
return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`;
}
if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices);
+15
View File
@@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', (
assert.equal(wrapLongitude(180), -180);
assert.equal(unwrapLongitudes([]).length, 0);
});
test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => {
const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180;
const lat = 999.6 / metresPerDegree;
const line = createDrawSession('line');
addVertex(line, { lon: 0, lat: 0 });
addVertex(line, { lon: 0, lat });
const metres = pathLengthM(line.vertices);
assert.ok(
metres >= 999.5 && metres < 1000,
`length should round to 1000 m, got ${metres}`,
);
assert.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(line), /^1\.0 km · /);
});
+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+10
View File
@@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
@@ -110,6 +111,15 @@ export function createApplicationTools({
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
// alive in reused per-pass arrays; trim them after each disable.
defer(
installLayerReleaseTrim({
scene: viewer.scene,
dataManager,
requestRender: governorRequestRender,
}),
);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
+5
View File
@@ -137,6 +137,11 @@ export function createApplicationViewer({ container, creditContainer }) {
);
try {
viewer.targetFrameRate = 60;
// A phone pinch spans fewer screen pixels than desktop zoom gestures.
// Keep Cesium's distance-aware zoom and collision limits, with less travel
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
+9
View File
@@ -6,6 +6,15 @@ export function normalizeHeading(value) {
return ((value % 360) + 360) % 360;
}
/**
* Three-digit heading readout, 000–359.
* Rounding 359.5° and above lands on north (000), not 360.
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
export function slewHeading(current, target, maxStepDeg) {
const from = normalizeHeading(current);
+9
View File
@@ -15,6 +15,7 @@ import {
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(30), '030');
});
test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.6), '000');
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
test('great-circle bearing resolves cardinal directions', () => {
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
+17
View File
@@ -0,0 +1,17 @@
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
*
* Cesium gives each Model a dynamic environment map: it renders the sky around
* the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour
* (`colorBlendAmount`), so that lighting contributes little; without it they
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false,
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
/**
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
* map (a sky render and GPU readback per model). The preload calls only warm
* the glTF cache and are never added to the scene, so they are left out:
* drawn models are the calls that set a minimum pixel size.
*/
const SOURCES = [
{
name: 'flights',
source: readLayerSource(new URL('./flights.js', import.meta.url)),
},
{
name: 'militaryFlights',
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
},
{
name: 'localAdsb',
source: readFileSync(
new URL('../layers/localAdsb/models.js', import.meta.url),
'utf8',
),
},
];
test('the shared option turns the dynamic environment map off', () => {
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
});
for (const { name, source } of SOURCES) {
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
const calls = [
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
]
.map((match) => match[1])
.filter((options) => /\bminimumPixelSize:/.test(options));
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
for (const [index, options] of calls.entries())
assert.match(
options,
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
);
});
}
+195
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
+25
View File
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
} from './alprCameras.js';
import { createAlprTileSource } from '../layers/alpr/source.js';
import { DATA_CREDITS } from './dataCredits.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import {
clearSelectedEntityContextForLayer,
getContextStore,
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
}
});
// The voice model reads the label as data; the voice card translates it.
test('the camera label stays English data in a translated interface', async () => {
const h = cameraHarness();
try {
setLocale('ru');
h.setFetch(async () =>
cameraResponse([
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
]),
);
await alprCamerasLayer.update();
h.click(h.source.entities.values[0].id);
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Камера ALPR',
);
} finally {
setLocale('en');
h.restore();
}
});
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
+2 -1
View File
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
+4
View File
@@ -846,9 +846,13 @@ test('route numbers follow the page language and never break from their unit', (
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
assert.equal(routeDurationText(59.6), '1\u00a0min');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
+15
View File
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
assert.equal(card.details[0], 'high · N20');
});
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
const crossed = buildFireCard(
{ fire: fire({ frp: 9.96 }), position: {} },
NOW,
);
assert.equal(crossed.title, '▲ 10 MW');
const below = buildFireCard(
{ fire: fire({ frp: 9.94 }), position: {} },
NOW,
);
assert.equal(below.title, '▲ 9.9 MW');
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
+24
View File
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { getSelectedEntityContext } from './contextStore.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
}
});
// The voice model reads these labels as data (track_entity, get_entity_context,
// pointer_context); the voice card translates them where it shows them.
test('fire labels stay English data in a translated interface', () => {
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
try {
setLocale('ru');
h.click();
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
// The card's pattern must match what the producer actually writes.
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Пожар · FRP 1520 МВт',
);
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
} finally {
h.cleanup();
setLocale('en');
}
});
test('a click on neither sprite nor card clears without moving the camera', () => {
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
try {
+11
View File
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
}
/**
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
* @param {number} frp Megawatts.
* @returns {string}
*/
export function formatFrpMw(frp) {
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
return Number(frp).toFixed(digits);
}
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '')
+9
View File
@@ -6,6 +6,7 @@ import {
FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID,
satelliteShortName,
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
assert.equal(formatFrpMw(9.96), '10');
assert.equal(formatFrpMw(9.94), '9.9');
assert.equal(formatFrpMw(0.8), '0.8');
assert.equal(formatFrpMw(1520.27), '1520');
assert.equal(formatFrpMw(210.4), '210');
});
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
assert.equal(satelliteShortName('N20'), 'N20');
assert.equal(satelliteShortName('N21'), 'N21');
+60 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/**
* LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/
export class LayerLifecycle {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
{
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
}
}
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval
: 0;
if (refreshInterval > 0) {
entry.intervalId = setInterval(() => {
const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval);
};
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) {
entry.intervalId = setInterval(() => {
if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle();
if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
this._publishActivity({ type: 'status' });
if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
}
if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null;
const cancelEnable = () => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
await abortCleanup;
let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
};
const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
let cleanupConfirmed = false;
try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling.
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Manager-owned periodic refresh work has one normalized loading/error
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
return false;
}
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
/** Compatibility facade for callers that construct a manager with its panel. */
export class DataLayerManager extends LayerLifecycle {
constructor(viewer, options) {
super(viewer, options);
super(viewer, { visibilityTarget: globalThis.document, ...options });
this._presentation = new LayerPresentation(this);
}
buildTogglePanel(container) {
+89
View File
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
lifecycle.refreshLayerStats();
assert.deepEqual(changes, ['status']);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('hidden-poll');
manager.register(module);
await manager.setEnabled('hidden-poll', true);
assert.equal(calls.update, 1);
doc.hidden = true;
t.mock.timers.tick(10000);
assert.equal(calls.update, 1);
assert.equal(await manager.refreshLayer('hidden-poll'), true);
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
doc.hidden = false;
for (const fn of listeners) fn();
for (const fn of listeners) fn();
assert.equal(calls.update, 3);
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(10000);
assert.equal(calls.update, 3);
});
async function resumeBetweenTicks(t, { failResume = false } = {}) {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('resume-poll');
const updates = [];
module.update = async () => {
calls.update++;
updates.push(Date.now());
await Promise.resolve();
if (failResume && updates.length === 2) throw new Error('resume failed');
};
manager.register(module);
await manager.setEnabled('resume-poll', true);
assert.deepEqual(updates, [1000]);
doc.hidden = true;
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
t.mock.timers.tick(500); // 2500: return between ticks
doc.hidden = false;
for (const fn of listeners) fn();
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
if (failResume) assert.equal(managerRefreshError, 'resume failed');
else assert.equal(managerRefreshError, null);
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
assert.deepEqual(updates, [1000, 2500]);
t.mock.timers.tick(1); // 3500: one interval after the resume poll
assert.deepEqual(updates, [1000, 2500, 3500]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(5000);
assert.equal(updates.length, 4);
}
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
await resumeBetweenTicks(t);
});
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
await resumeBetweenTicks(t, { failResume: true });
});
+10 -1
View File
@@ -42,7 +42,10 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import {
formatAwarenessCount,
presentAwarenessDistance,
} from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -69,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(formatAwarenessCount({ count: null }), '?');
});
test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
+3
View File
@@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({
for (let i = 0; i < missing.length; i += 1) {
const result = fetched[i];
if (validTerrainResult(result)) {
// Re-insert rather than overwrite: a Map keeps an existing key in
// its old position, and the proxy evicts from the front.
cache.delete(missing[i].key);
cache.set(missing[i].key, { at: fetchedAt, result });
cacheChanged = true;
} else if (absentTerrainDatum(result)) {
+4
View File
@@ -543,6 +543,10 @@ export function initFirstRunExperience({
});
for (const button of buttons) button.addEventListener('click', onChoice);
// The same dismissal as ESC, for screens without a keyboard.
root
.querySelector('[data-first-run-dismiss]')
?.addEventListener('click', () => dismiss());
suppressBox?.addEventListener('change', onSuppressChange);
// Capture phase: the app binds its own global hotkeys (including bare letters
// that cycle detection and styles), and the launcher owns the keyboard first.
+42
View File
@@ -913,6 +913,48 @@ test('markup, startup ordering and accessibility remain pinned', () => {
assert.match(choicesBlock, /overflow-y: auto;/);
});
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', () => {
const html = expandApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
);
// The whole stylesheet, so a hide rule added in any other file is caught too.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const footer = html.slice(
html.indexOf('class="first-run-footer"'),
html.indexOf('data-first-run-status'),
);
assert.match(
footer,
/<button type="button" class="first-run-dismiss" data-first-run-dismiss>/,
);
assert.match(footer, /data-i18n>ESC to dismiss</);
assert.match(footer, /data-i18n>DISMISS</);
// It closes the way ESC does, through the one dismiss path, so the session
// flag and focus handling are the same.
assert.match(
module,
/querySelector\('\[data-first-run-dismiss\]'\)\s*\?\.addEventListener\('click', \(\) => dismiss\(\)\)/,
);
// Phones used to hide the hint, leaving no way out but picking a mission.
assert.doesNotMatch(css, /first-run-footer > span:last-child/);
assert.doesNotMatch(css, /\.first-run-dismiss\s*\{[^}]*display:\s*none/);
// A 44px touch target that does not add to the footer row's height.
assert.match(
css,
/\.first-run-dismiss::before \{[^}]*position: absolute;[^}]*height: 44px;/,
);
assert.doesNotMatch(css, /\.first-run-dismiss \{[^}]*min-height/);
// Touch screens read DISMISS rather than a key they do not have.
assert.match(
css,
/@media \(hover: none\) and \(pointer: coarse\) \{\s*\.first-run-dismiss-key \{\s*display: none;/,
);
});
test('the launcher keeps focus, restores it, and never disables the focused button', () => {
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
+42 -5
View File
@@ -224,6 +224,20 @@ export class IntelHUD {
// session, so this flips once — and both altitude readouts have to move
// together when it does (see the repaint in _updateCameraData).
this._geoidCorrectionApplied = false;
// A summary built for one view must not land on the next: a request in
// flight when the camera starts moving is aborted and nothing it ends
// with paints (the revision moves), a reply still typing stops, and the
// summary is marked dirty so the next periodic tick asks about the view
// the camera has moved to.
this._onCameraMoveStart = () => {
this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) {
// Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null;
}
this._summaryRequest?.abort();
this._stopSummaryTyping();
};
this._onCameraMoveEnd = () => {
this._markSummaryDirty();
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
@@ -249,6 +263,7 @@ export class IntelHUD {
this._passNum = 100 + Math.floor(Math.random() * 200);
this._buildDOM();
this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
this._startTimers();
}
@@ -766,6 +781,12 @@ export class IntelHUD {
return provenance ? `${line} | ${provenance}` : line;
}
/** Stop a summary still typing out, leaving what it has typed so far. */
_stopSummaryTyping() {
clearInterval(this._summaryTypingInterval);
this._summaryTypingInterval = null;
}
/**
* Animate the summary text into the DOM using a typewriter effect
* (2 characters every 24ms).
@@ -816,8 +837,10 @@ export class IntelHUD {
} catch (error) {
console.warn('[HUD] summary context unavailable:', error);
// Left dirty on purpose: the next periodic tick retries instead of
// sticking on the fallback line for the rest of the session.
this._setSummaryText(fallbackText, animate);
// sticking on the fallback line for the rest of the session. The
// fallback was composed for this view; a moved camera keeps its own.
if (revision === this._summaryRevision)
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
@@ -832,7 +855,11 @@ export class IntelHUD {
this._summaryDirty = false;
this._lastSummarySignature = signature;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5000);
let timedOut = false;
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, 5000);
this._summaryRequest = controller;
try {
this.summaryPolicy.onRequest?.();
@@ -855,17 +882,24 @@ export class IntelHUD {
animate,
);
} catch (error) {
if (error?.name !== 'AbortError') {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
const moved = revision !== this._summaryRevision;
if (error?.name !== 'AbortError' || timedOut) {
console.warn('[HUD] AI summary unavailable:', error);
// Invalidate the committed signature so the next periodic tick
// retries instead of sticking on the fallback line forever.
this._lastSummarySignature = null;
this._summaryDirty = true;
}
this._setSummaryText(fallbackText, animate);
if (!moved) this._setSummaryText(fallbackText, animate);
} finally {
window.clearTimeout(timeout);
if (this._summaryRequest === controller) this._summaryRequest = null;
// A request whose answer was dropped (aborted by a move, or a reply
// that came back after one) committed a signature it never painted;
// forget it, or the next tick over the same place would skip asking.
if (revision !== this._summaryRevision) this._lastSummarySignature = null;
}
}
@@ -874,6 +908,8 @@ export class IntelHUD {
this._typeSummary(text);
return;
}
// A plain paint replaces any reply still typing.
this._stopSummaryTyping();
const el = document.getElementById('hud-summary');
if (el) el.textContent = text;
}
@@ -1044,6 +1080,7 @@ export class IntelHUD {
clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
this._dataManagerUnsubscribe?.();
this._summaryRequest?.abort();
+335 -8
View File
@@ -16,6 +16,9 @@
// globals exist: Cesium's widget bundle probes for a real `document` at module
// scope and a partial stub sends it down the browser path. Hence hook →
// import → install DOM, in that order.
//
// The same hook is why the live AI-summary test lives here too: this is the
// one file that can construct a real IntelHUD.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -33,7 +36,8 @@ registerHooks({
return {
format: 'module',
shortCircuit: true,
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
source:
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
};
}
return next(url, context);
@@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15;
*/
function installHudEnvironment() {
const elements = new Map(
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
.map((id) => [id, { textContent: '' }]),
[
'hud-alt',
'hud-summary',
'hud-mgrs',
'hud-latlon',
'hud-bottom-line',
'hud-gsd',
'hud-coll',
'hud-ona',
'hud-mode',
].map((id) => [id, { textContent: '' }]),
);
const previousDocument = globalThis.document;
const cameraListeners = {};
globalThis.document = {
getElementById: (id) => elements.get(id) ?? null,
querySelectorAll: () => [],
@@ -78,12 +92,24 @@ function installHudEnvironment() {
height: SFO_ELLIPSOIDAL_M,
},
computeViewRectangle: () => undefined,
moveEnd: { addEventListener() {}, removeEventListener() {} },
moveStart: {
addEventListener(listener) {
cameraListeners.moveStart = listener;
},
removeEventListener() {},
},
moveEnd: {
addEventListener(listener) {
cameraListeners.moveEnd = listener;
},
removeEventListener() {},
},
},
};
return {
elements,
viewer,
cameraListeners,
restore() {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
@@ -93,7 +119,9 @@ function installHudEnvironment() {
test('hud.js corrects the camera height to MSL through the geoid module', () => {
assert.equal(
has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s),
has(
/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s,
),
true,
'hud.js must take the datum correction from ./data/geoid.js, not re-derive one',
);
@@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () =>
'the ~2.7 MB grid must be requested once, on demand — not at HUD construction',
);
assert.equal(
has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/),
has(
/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/,
),
true,
'a failed geoid load must leave the readout uncorrected, not unhandled',
);
@@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => {
// Both are on screen together; a viewer reading "ALT -15M" in one corner and
// "ALT: 17m" in the other has found a bug, not a distinction.
assert.equal(
has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/),
has(
/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/,
),
true,
'the summary altitude tag must prefer the MSL datum and fall back to the raw height',
);
@@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
// Tick 1 — cold. This is also the tick that requests the grid.
hud._updateCameraData();
assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`);
assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`);
assert.match(
summary(),
/\| ALT -15M \|/,
`cold summary tag, got ${summary()}`,
);
// The HUD registered its own continuation on this same shared promise
// during tick 1, and it registered first, so awaiting here means its
@@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
env.restore();
}
});
test('a summary asked for one view is dropped once the camera moves', async () => {
const env = installHudEnvironment();
const previousWindow = globalThis.window;
globalThis.window = globalThis;
const pending = [];
const summaryService = {
summarize: (context) =>
new Promise((resolve) => {
pending.push({
context,
reply: (summary) =>
resolve({ ok: true, status: 200, data: { summary, error: null } }),
});
}),
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
let place = 'Yellowstone';
hud._summaryContext = async () => ({ place });
const summary = () => env.elements.get('hud-summary').textContent;
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
// The periodic tick's call (force=false): it asks only for a dirty
// summary. Asked over Yellowstone; the camera leaves before the reply
// lands.
const first = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 1);
env.cameraListeners.moveStart();
pending[0].reply('Canyon Junction Wyoming');
await first;
assert.doesNotMatch(
summary(),
/Canyon Junction/,
'a reply for the old view is dropped',
);
// The next tick, with the camera still moving (a follow camera or an
// orbit emits no moveEnd), asks about where it is now and paints it.
place = 'Atlantic';
const moving = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 2, 'the move marked the summary dirty');
assert.deepEqual(pending[1].context, { place: 'Atlantic' });
pending[1].reply('North Atlantic Ocean');
await moving;
assert.equal(summary(), 'North Atlantic Ocean');
} finally {
for (const request of pending) request.reply('');
hud?.destroy();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
env.restore();
}
});
test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => {
const env = installHudEnvironment();
const previousWindow = globalThis.window;
globalThis.window = globalThis;
const pending = [];
// Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`.
let ignoreAbort = false;
const summaryService = {
summarize: (context, { signal }) =>
new Promise((resolve, reject) => {
pending.push({ signal, reject });
if (!ignoreAbort)
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'Yellowstone' });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
// The move aborts the request in flight, and the abort paints nothing.
let update = hud._updateSummary(false, true);
await settle();
element.textContent = 'shown before the move';
env.cameraListeners.moveStart();
assert.equal(pending[0].signal.aborted, true, 'the request is aborted');
await update;
assert.equal(element.textContent, 'shown before the move');
// A request that fails after the move (an error, the 5 s timeout) does
// not paint the old view's fallback line either.
ignoreAbort = true;
update = hud._updateSummary(false, true);
await settle();
element.textContent = 'shown before the move';
env.cameraListeners.moveStart();
pending[1].reject(new Error('network'));
await update;
assert.equal(element.textContent, 'shown before the move');
// Nor does a context build that throws once the camera has moved.
hud._summaryContext = async () => {
env.cameraListeners.moveStart();
throw new Error('view target unavailable');
};
element.textContent = 'shown before the move';
await hud._updateSummary(false, true);
assert.equal(element.textContent, 'shown before the move');
} finally {
hud?.destroy();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
env.restore();
}
});
test('a cancelled summary retries the same context but completed answers stay reusable', async () => {
const env = installHudEnvironment();
const previousWindow = globalThis.window;
globalThis.window = globalThis;
const pending = [];
const summaryService = {
summarize: (context, { signal }) =>
new Promise((resolve, reject) => {
pending.push({
context,
reply: (summary) =>
resolve({ ok: true, status: 200, data: { summary } }),
});
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._updateCameraData();
const settle = () => new Promise((resolve) => setImmediate(resolve));
const first = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 1);
env.cameraListeners.moveStart();
await first;
// An orbit can leave all semantic place/layer labels unchanged. The next
// periodic tick must retry even though its context equals the aborted one.
const retry = hud._updateSummary(false);
await settle();
assert.equal(
pending.length,
2,
'the cancelled signature cannot suppress a retry',
);
assert.deepEqual(pending[1].context, pending[0].context);
pending[1].reply('San Francisco Bay Urban Landscape');
await retry;
assert.equal(
env.elements.get('hud-summary').textContent,
'San Francisco Bay Urban Landscape',
);
// Movement without an active request must not discard a completed answer.
env.cameraListeners.moveStart();
await hud._updateSummary(false);
assert.equal(pending.length, 2, 'completed context still deduplicates');
} finally {
for (const request of pending) request.reply('');
hud?.destroy();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
env.restore();
}
});
test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const env = installHudEnvironment();
const previousWindow = globalThis.window;
globalThis.window = globalThis;
const pending = [];
const summaryService = {
summarize: (context, { signal }) =>
new Promise((resolve, reject) => {
pending.push({ resolve });
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
// The same place before and after: a slow follow camera, a small pan.
hud._summaryContext = async () => ({ place: 'Lisboa' });
const element = env.elements.get('hud-summary');
const settle = () => new Promise((resolve) => setImmediate(resolve));
hud._updateCameraData();
// A move aborts the request; the next tick over the same place asks again.
let update = hud._updateSummary(false);
await settle();
env.cameraListeners.moveStart();
await update;
update = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 2, 'asked again after the abort');
// That one times out at 5 s: retried like any failure.
t.mock.timers.tick(5_000);
await update;
update = hud._updateSummary(false);
await settle();
assert.equal(pending.length, 3, 'asked again after the timeout');
pending[2].resolve({
ok: true,
status: 200,
data: { summary: 'Lisboa Rua Augusta', error: null },
});
await update;
// A reply still typing stops when the camera starts moving.
hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park');
t.mock.timers.tick(48);
const typed = element.textContent;
env.cameraListeners.moveStart();
t.mock.timers.tick(2_000);
assert.equal(
element.textContent,
typed,
'no more characters after the move',
);
} finally {
hud?.destroy();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
env.restore();
}
});
test('a summary interrupted while typing can retry unchanged context', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const env = installHudEnvironment();
const previousWindow = globalThis.window;
globalThis.window = globalThis;
let calls = 0;
const text = 'San Francisco Bay Urban Landscape';
const summaryService = {
summarize: async () => {
calls++;
return { ok: true, status: 200, data: { summary: text } };
},
};
let hud;
try {
hud = new IntelHUD(env.viewer, { summaryService });
hud._summaryContext = async () => ({ place: 'San Francisco' });
hud._updateCameraData();
await hud._updateSummary(true);
t.mock.timers.tick(48);
const element = env.elements.get('hud-summary');
assert.notEqual(element.textContent, text, 'the answer is still typing');
assert.equal(
hud._summaryRequest,
null,
'the network request already finished',
);
env.cameraListeners.moveStart();
assert.equal(hud._summaryTypingInterval, null);
await hud._updateSummary(false);
assert.equal(
calls,
2,
'a partially displayed answer must not suppress retry',
);
assert.equal(element.textContent, text);
env.cameraListeners.moveStart();
await hud._updateSummary(false);
assert.equal(calls, 2, 'the fully displayed answer remains reusable');
} finally {
hud?.destroy();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
env.restore();
}
});
+1
View File
@@ -1153,6 +1153,7 @@
"OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad",
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
"Potential tropical cyclone": "Posible ciclón tropical",
"Post-tropical cyclone": "Ciclón postropical",
"Hurricane": "Huracán",
"Tropical storm": "Tormenta tropical",
"Tropical depression": "Depresión tropical",
+1
View File
@@ -1169,6 +1169,7 @@
"OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества",
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.",
"Potential tropical cyclone": "Потенциальный тропический циклон",
"Post-tropical cyclone": "Посттропический циклон",
"Hurricane": "Ураган",
"Tropical storm": "Тропический шторм",
"Tropical depression": "Тропическая депрессия",
+1
View File
@@ -530,6 +530,7 @@
"Cyclone advisories unavailable": "气旋公告不可用",
"Cyclone data unavailable": "气旋数据不可用",
"Potential tropical cyclone": "潜在热带气旋",
"Post-tropical cyclone": "后热带气旋",
"Hurricane": "飓风",
"Tropical storm": "热带风暴",
"Tropical depression": "热带低压",
+2 -2
View File
@@ -13,7 +13,7 @@ import {
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { msg } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
@@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: t('ALPR camera'),
label: msg('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
+3 -2
View File
@@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) {
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
+6 -3
View File
@@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a
// potential tropical cyclone, and subtropical systems are STS and STD.
const CLASSIFICATION_NAMES = Object.freeze({
PTC: msg('Potential tropical cyclone'),
PC: msg('Potential tropical cyclone'),
PTC: msg('Post-tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
STS: msg('Subtropical storm'),
STD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code)
+4 -3
View File
@@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['PC', 'Potential tropical cyclone'],
['PTC', 'Post-tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['STS', 'Subtropical storm'],
['STD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
+4 -2
View File
@@ -112,7 +112,8 @@ export const FLIGHT_PROGRESS_MS = 250;
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
@@ -130,7 +131,8 @@ export function routeDistanceText(meters) {
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
const wholeSeconds = Math.round(seconds);
if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
+5 -3
View File
@@ -4,6 +4,7 @@ import {
satelliteShortName,
fireDetectionKey,
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import {
VIEW_PADDING,
@@ -480,14 +481,15 @@ export function createModel({
}
function formatFrp(frp) {
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
return formatFrpMw(frp);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
const text = formatFrpMw(frp);
const digits = text.includes('.') ? 1 : 0;
return formatNumber(Number(text), {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
+4 -6
View File
@@ -18,9 +18,8 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -118,9 +117,8 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
};
},
+2 -3
View File
@@ -193,9 +193,8 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
// i18n-ignore: English entity label; the voice card translates it (shownEntityLabel)
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
latitude: fire.lat,
longitude: fire.lon,
properties: {
+36 -11
View File
@@ -60,6 +60,10 @@ export function createMotion({
publishFocusTargetFromCachedPosition,
} = services.focus;
// Fixes leave the cache with their history; corrected positions invalidate
// the local frame even when the existing Cartesian is mutated in place.
const fixFrames = new WeakMap();
// Round 5: the old `_warmGroundedAircraftSurfaceCache` (global exact-5-decimal
// warm for every grounded contact on Earth) is GONE. Parked-aircraft GPS
// jitter minted fresh keys every poll → thousands of upstream points per
@@ -124,7 +128,7 @@ export function createMotion({
* @returns {Cesium.Cartesian3|null} Estimated ECEF position, or null if no history exists.
*/
function _deadReckon(icao24, result) {
function _deadReckon(icao24, result, sampledRenderTime) {
const info = flightState.records.data.get(icao24);
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
const position = flightState._billboards.get(icao24)?.position;
@@ -149,11 +153,13 @@ export function createMotion({
const out = result || new Cesium.Cartesian3();
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
const renderTime =
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
// Bracketing pair: interpolate — no extrapolation error, no snap-back.
for (let i = history.length - 1; i >= 1; i--) {
@@ -286,12 +292,31 @@ export function createMotion({
0,
flightState._scratchOffset,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
flightState._scratchEnu,
let frame = fixFrames.get(fix);
if (!frame) {
frame = {
position: new Cesium.Cartesian3(),
matrix: new Cesium.Matrix4(),
};
fixFrames.set(fix, frame);
}
if (
!frame.valid ||
!Cesium.Cartesian3.equals(frame.position, fix.position)
) {
Cesium.Transforms.eastNorthUpToFixedFrame(
fix.position,
Cesium.Ellipsoid.WGS84,
frame.matrix,
);
Cesium.Cartesian3.clone(fix.position, frame.position);
frame.valid = true;
}
return Cesium.Matrix4.multiplyByPoint(
frame.matrix,
flightState._scratchOffset,
out,
);
return Cesium.Matrix4.multiplyByPoint(enu, flightState._scratchOffset, out);
}
/**
+184
View File
@@ -0,0 +1,184 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { arcOffsetEnu } from '../../data/motionModel.js';
function fixture() {
const flightState = {
records: { data: new Map() },
_positionHistory: new Map(),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
};
const motion = createMotion({
flightState,
services: { groundFloor: {}, camera: {}, focus: {} },
parts: {},
});
return { flightState, motion };
}
function expectedPosition(fix, dt, turnRate, transform) {
const arc = {};
arcOffsetEnu(fix.velocity, fix.track, turnRate, dt, arc);
return Cesium.Matrix4.multiplyByPoint(
transform(fix.position, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()),
new Cesium.Cartesian3(arc.east, arc.north, 0),
new Cesium.Cartesian3(),
);
}
function close(actual, expected) {
assert.ok(Cesium.Cartesian3.distance(actual, expected) < 1e-8);
}
test('extrapolation reuses fixed frames and invalidates corrected coordinates', (t) => {
const { motion } = fixture();
const transform = Cesium.Transforms.eastNorthUpToFixedFrame;
const spy = t.mock.method(
Cesium.Transforms,
'eastNorthUpToFixedFrame',
transform,
);
const fix = {
position: Cesium.Cartesian3.fromDegrees(-97, 30, 200),
velocity: 90,
track: 70,
};
const out = new Cesium.Cartesian3();
for (const dt of [-30, 5, 30, 60]) {
motion._extrapolateFix(fix, {}, dt, out, 1.5);
close(out, expectedPosition(fix, dt, 1.5, transform));
}
assert.equal(spy.mock.callCount(), 1);
// Ground reflooring mutates an existing fix's Cartesian in place.
Cesium.Cartesian3.fromDegrees(-97, 30, 350, undefined, fix.position);
motion._extrapolateFix(fix, {}, 30, out, 1.5);
close(out, expectedPosition(fix, 30, 1.5, transform));
assert.equal(spy.mock.callCount(), 2);
fix.position = Cesium.Cartesian3.fromDegrees(140, 80, 1000);
motion._extrapolateFix(fix, {}, 30, out, -2);
close(out, expectedPosition(fix, 30, -2, transform));
assert.equal(spy.mock.callCount(), 3);
fix.velocity = 210;
fix.track = 359;
motion._extrapolateFix(fix, {}, 15, out, -1);
close(out, expectedPosition(fix, 15, -1, transform));
assert.equal(spy.mock.callCount(), 3);
const replacement = { ...fix };
motion._extrapolateFix(replacement, {}, 15, out, -1);
close(out, expectedPosition(replacement, 15, -1, transform));
assert.equal(spy.mock.callCount(), 4);
});
test('stationary fixes do not build a frame until motion needs it', (t) => {
const { motion } = fixture();
const spy = t.mock.method(Cesium.Transforms, 'eastNorthUpToFixedFrame');
const fix = {
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
velocity: 0,
track: 90,
};
const out = new Cesium.Cartesian3();
close(motion._extrapolateFix(fix, {}, 30, out), fix.position);
fix.velocity = 10;
close(motion._extrapolateFix(fix, {}, 0, out), fix.position);
assert.equal(spy.mock.callCount(), 0);
motion._extrapolateFix(fix, {}, 30, out);
assert.equal(spy.mock.callCount(), 1);
});
test('fleet render time is shared without resampling or mutating the supplied instant', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const end = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
const renderTime = Cesium.JulianDate.addSeconds(
start,
30,
new Cesium.JulianDate(),
);
const savedTime = Cesium.JulianDate.clone(renderTime);
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: end,
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
for (const id of ['one', 'two']) {
flightState.records.data.set(id, { velocity: 100, true_track: 90 });
flightState._positionHistory.set(id, history);
}
t.mock.method(Cesium.JulianDate, 'now', () => {
throw new Error('unexpected per-contact clock read');
});
const expected = Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
);
for (const id of ['one', 'two'])
close(
motion._deadReckon(id, new Cesium.Cartesian3(), renderTime),
expected,
);
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
});
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [
{
time: start,
position: Cesium.Cartesian3.fromDegrees(-97, 30, 1000),
track: 90,
},
{
time: Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate()),
position: Cesium.Cartesian3.fromDegrees(-96.99, 30, 1000),
track: 90,
},
];
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history);
let elapsed = 60;
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
assert.equal(result, flightState._scratchRenderTime);
return Cesium.JulianDate.addSeconds(start, elapsed, result);
});
const out = new Cesium.Cartesian3();
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.5,
new Cesium.Cartesian3(),
),
);
elapsed = 75;
close(
motion._deadReckon('tracked', out),
Cesium.Cartesian3.lerp(
history[0].position,
history[1].position,
0.75,
new Cesium.Cartesian3(),
),
);
assert.equal(spy.mock.callCount(), 2);
});
+15 -4
View File
@@ -452,8 +452,8 @@ export function createQueries({
const isTracked = icao24 === flightState._trackedIcao;
// Keep planes rendered as a 3D model (billboard hidden) so the detection box
// doesn't vanish on the 2D→3D handoff; bb.position stays current while hidden.
const model = flightState._models.get(icao24);
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
const model = flightState._models.get(icao24) ?? null;
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24, model);
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
const info = flightState.records.data.get(icao24);
let object = flightState._detectionObjects.get(icao24);
@@ -491,8 +491,19 @@ export function createQueries({
object.position = pos;
object.skipLabel = isTracked;
// Card text only — declutter/cohort identity is `object.sourceId` (icao24).
const id = _contactLabel(icao24, info);
if (object.id !== id) object.id = id;
const callsign = info?.callsign;
const registration = info?.registration;
// Records and enrichment may change in place. Cache by field values,
// while keeping the live visual anchor above independent of text.
if (
object.id === undefined ||
object._labelCallsign !== callsign ||
object._labelRegistration !== registration
) {
object.id = _contactLabel(icao24, info);
object._labelCallsign = callsign;
object._labelRegistration = registration;
}
const altitude = info?.altitude;
if (object._altitude !== altitude) {
object._altitude = altitude;
+149
View File
@@ -0,0 +1,149 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createRendering } from './rendering.js';
function fixture() {
const info = {
callsign: ' TEST123 ',
registration: ' N123 ',
altitude: 1000,
};
const bb = { show: true, position: new Cesium.Cartesian3(1, 2, 3) };
const flightState = {
feed: {},
_billboardCollection: { show: true },
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_detectionObjects: new Map(),
records: { data: new Map([['abc123', info]]) },
};
let formats = 0;
const services = {
aircraftPresentation: {},
militaryRegistry: {},
camera: {},
focus: {},
recession: {},
labels: {
formatFlightLevel: (altitude) => {
formats++;
return String(altitude);
},
},
};
const parts = {
testing: {},
tracking: { _trackedModelRegimeActive: () => true },
motion: { _trackedVisualCached: () => null },
};
parts.rendering = createRendering({ flightState, services, parts });
const queries = createQueries({ flightState, services, parts }).methods;
return { flightState, info, bb, parts, queries, formats: () => formats };
}
test('candidate text follows in-place enrichment without freezing identity or positions', () => {
const f = fixture();
const [first] = f.queries.getDetectableObjects();
assert.equal(first.id, 'TEST123');
assert.equal(first.sourceId, 'abc123');
f.bb.position = new Cesium.Cartesian3(4, 5, 6);
assert.equal(f.queries.getDetectableObjects()[0], first);
assert.equal(first.position, f.bb.position);
assert.equal(f.formats(), 1);
f.info.callsign = ' NEW456 ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'NEW456');
f.info.callsign = ' ';
assert.equal(f.queries.getDetectableObjects()[0].id, 'N123');
f.info.registration = ' N456 ';
f.info.altitude = 2000;
assert.equal(f.queries.getDetectableObjects()[0].id, 'N456');
assert.equal(first.metric, '2000');
assert.equal(f.formats(), 2);
f.flightState.records.data.set('abc123', { ...f.info });
assert.equal(f.queries.getDetectableObjects()[0], first);
f.flightState.records.data.delete('abc123');
assert.equal(f.queries.getDetectableObjects()[0].id, 'abc123');
assert.equal(first.sourceId, 'abc123');
});
test('unchanged candidate text is normalized once across paints', (t) => {
const f = fixture();
const trim = String.prototype.trim;
let calls = 0;
t.mock.method(String.prototype, 'trim', function () {
if (String(this) === ' TEST123 ') calls++;
return trim.call(this);
});
for (let i = 0; i < 20; i++) f.queries.getDetectableObjects();
assert.equal(calls, 1);
});
test('candidate collection resolves models once and keeps handoffs live', (t) => {
const f = fixture();
const get = t.mock.method(f.flightState._models, 'get');
const [candidate] = f.queries.getDetectableObjects();
assert.equal(get.mock.callCount(), 1);
const model = {
ready: false,
show: true,
modelMatrix: Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(10, 20, 30),
),
};
f.parts.rendering._modelSpec = () => ({
visualCenterNative: Cesium.Cartesian3.ZERO,
scale: 1,
});
f.flightState._models.set('abc123', model);
f.bb.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
model.ready = true;
assert.equal(f.queries.getDetectableObjects()[0], candidate);
assert.deepEqual(candidate.position, new Cesium.Cartesian3(10, 20, 30));
Cesium.Matrix4.setTranslation(
model.modelMatrix,
new Cesium.Cartesian3(40, 50, 60),
model.modelMatrix,
);
f.queries.getDetectableObjects();
assert.deepEqual(candidate.position, new Cesium.Cartesian3(40, 50, 60));
model.show = false;
assert.deepEqual(f.queries.getDetectableObjects(), []);
f.bb.show = true;
assert.equal(f.queries.getDetectableObjects()[0].position, f.bb.position);
assert.equal(get.mock.callCount(), 6);
model.show = true;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123'), true);
assert.equal(get.mock.callCount(), 7);
});
test('tracked ownership and live anchors remain independent of fleet models', () => {
const f = fixture();
f.flightState._trackedIcao = 'abc123';
f.flightState._trackedModel = { ready: true, show: true };
f.parts.rendering._modelSpec = () => ({});
let anchor = new Cesium.Cartesian3(10, 20, 30);
f.parts.motion._trackedVisualCached = () => anchor;
const [candidate] = f.queries.getDetectableObjects();
assert.equal(candidate.position, anchor);
assert.equal(candidate.skipLabel, true);
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), true);
anchor = new Cesium.Cartesian3(40, 50, 60);
assert.equal(f.queries.getDetectableObjects()[0].position, anchor);
for (const tracked of [
null,
{ ready: false, show: true },
{ ready: true, show: false },
]) {
f.flightState._trackedModel = tracked;
assert.equal(
f.parts.rendering._modelOwnsVisual('abc123', { ready: true, show: true }),
false,
);
}
f.flightState._trackedModel = { ready: true, show: true };
f.parts.tracking._trackedModelRegimeActive = () => false;
assert.equal(f.parts.rendering._modelOwnsVisual('abc123', null), false);
});
+20 -3
View File
@@ -32,10 +32,12 @@ import {
TRACKED_MODEL_MIN_PX,
TRACKED_MODEL_MAX_PX,
FLEET_DR_INTERVAL_MS,
RENDER_DELAY_SEC,
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -382,17 +384,20 @@ export function createRendering({
* null or still-loading tracked model from claiming a visual it is not
* drawing yet.
* @param {string} icao24
* @param {Cesium.Model|null} [model] Resolved fleet model; null means absent.
* @returns {boolean}
*/
function _modelOwnsVisual(icao24) {
function _modelOwnsVisual(icao24, model) {
if (icao24 === flightState._trackedIcao) {
return (
parts.tracking._trackedModelRegimeActive() &&
_modelIsRendering(flightState._trackedModel)
);
}
return _modelIsRendering(flightState._models.get(icao24));
return _modelIsRendering(
model === undefined ? flightState._models.get(icao24) : model,
);
}
/** A model is the visual only once it actually draws — loaded AND shown.
@@ -503,6 +508,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -709,6 +715,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
@@ -854,6 +861,12 @@ export function createRendering({
)
: 0.08;
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
_drainIrReloadQueue(); // bounded per-tick slice of any pending boost-flip reload
if (flightState._cockpitContactMode)
@@ -957,7 +970,11 @@ export function createRendering({
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
const dr = parts.motion._deadReckon(
icao24,
flightState._scratchFleetPos,
renderTime,
);
// The dead-reckoned point drifts away from the fix whose cell supplied the
// height, so a grounded contact's sprite ends up under the mesh it taxied
// (or coasted) over. Re-floor at the DISPLAYED coordinate — read-only, and
+2 -2
View File
@@ -242,12 +242,12 @@ export function createFlightState({ source, services }) {
flightState._scratchCarto = new Cesium.Cartographic();
flightState._scratchEnu = new Cesium.Matrix4();
flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 };
flightState._scratchRenderTime = new Cesium.JulianDate();
flightState._scratchFleetRenderTime = new Cesium.JulianDate();
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
+2
View File
@@ -14,6 +14,7 @@ import {
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
@@ -87,6 +88,7 @@ export function createLocalAdsbModels({
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
+3
View File
@@ -53,6 +53,7 @@ import {
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
export function createRendering({
flightState,
@@ -473,6 +474,7 @@ export function createRendering({
model = await Cesium.Model.fromGltfAsync({
url: resolveAsset(spec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
@@ -676,6 +678,7 @@ export function createRendering({
Cesium.Model.fromGltfAsync({
url: resolveAsset(trackedSpec.url),
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize: TRACKED_MODEL_MIN_PX,
scale: trackedSpec.scale,
color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR,
+23 -3
View File
@@ -105,6 +105,25 @@ function pointsOf(sequence) {
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
*/
/**
* The tile zoom a provider's `zoomFor` answered, or null. Providers written
* before the core went slim return `{zoom, kind}`: its zoom is read, and an
* `'overview'` level draws nothing, since nothing is drawn from orbit. Any
* other answer that is not a whole zoom draws nothing too, rather than
* asking for no tiles without a hint.
* @param {unknown} level
* @returns {number|null}
*/
export function coverageZoom(level) {
const zoom =
level && typeof level === 'object'
? level.kind === 'overview'
? null
: level.zoom
: level;
return Number.isInteger(zoom) && zoom >= 0 ? zoom : null;
}
/**
* Camera-driven sequence lines from any XYZ line-tile source, near the
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
@@ -115,8 +134,9 @@ function pointsOf(sequence) {
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
* @param {(heightM: number|null) => number|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing.
* @param {(heightM: number|null) => number|{zoom: number, kind?: string}|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing. The
* older `{zoom, kind}` shape is still read (see `coverageZoom`).
* @param {{coverage: string, selected: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the lines.
* @param {string} options.renderKey Render-governor reason.
@@ -380,7 +400,7 @@ export function createLineCoverage({
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const zoom = zoomFor(height);
const zoom = coverageZoom(zoomFor(height));
if (zoom == null) return { hint: messages.tooHigh || ZOOM_IN_HINT };
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
// short of the horizon, which would stretch the box to the world.
+36 -4
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
coverageZoom,
createLineCoverage,
freshCoverage,
ZOOM_IN_HINT,
@@ -28,7 +29,7 @@ const { maxSequences: COVERAGE_MAX_SEQUENCES, maxTiles: COVERAGE_MAX_TILES } =
COVERAGE_TUNING;
/** Line coverage as the Mapillary provider builds it, over `source`. */
function createCoverage({ state, source }) {
function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
return createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
@@ -36,7 +37,7 @@ function createCoverage({ state, source }) {
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile,
),
zoomFor: coverageZoomForHeight,
zoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
@@ -123,7 +124,7 @@ function deferredSource() {
};
}
function setup() {
function setup({ zoomFor } = {}) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
@@ -146,7 +147,7 @@ function setup() {
},
sequence: { selectedId: null },
};
const coverage = createCoverage({ state, source });
const coverage = createCoverage({ state, source, zoomFor });
const bytes = encodeCoverageTile(centre.tile, {
sequences: [
{
@@ -239,6 +240,37 @@ test('panning off a tile that is still loading stops counting it as loading', as
coverage.clear();
});
test('a zoomFor in the older {zoom, kind} shape still draws its lines', async () => {
// Providers written before the core went slim (#1139's Vegbilder) answer
// an object: it must not ask for no tiles and say nothing.
const { viewer, source, coverage, bytes } = setup({
zoomFor: (height) => {
const zoom = coverageZoomForHeight(height);
return zoom == null ? null : { zoom, kind: 'sequence' };
},
});
coverage.refresh();
assert.equal(source.calls.length, 1, 'the tile is asked for');
assert.equal(source.calls[0].z, 14);
source.calls[0].resolve(bytes);
await settle();
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn');
assert.equal(coverage.stats().hint, '');
coverage.clear();
});
test('a zoom level is read from either shape; an overview or a bad answer draws nothing', () => {
assert.equal(coverageZoom(14), 14);
assert.equal(coverageZoom({ zoom: 15, kind: 'sequence' }), 15);
assert.equal(coverageZoom({ zoom: 12 }), 12);
assert.equal(coverageZoom({ zoom: 5, kind: 'overview' }), null, 'orbit');
assert.equal(coverageZoom(null), null);
assert.equal(coverageZoom(undefined), null);
assert.equal(coverageZoom({}), null);
assert.equal(coverageZoom(14.5), null);
assert.equal(coverageZoom('14'), null);
});
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
+69 -8
View File
@@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js';
import { tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
/**
* Whether the proxy answered a tile from its cache past the refresh window
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
* fails), and when the proxy fetched it.
*/
function tileFreshness(response) {
const status = response.headers?.get?.('x-tomtom-cache') || '';
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
return {
stale: status.startsWith('STALE'),
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
};
}
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
export function createFlowTileSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
// Freshness travels in response headers, but the tile cache keeps only
// decoded segments: hold each response's freshness until its tile decodes,
// then key it by the decoded tile, which the cache hands back on a hit.
const pending = new Map();
const freshness = new WeakMap();
const tiles = createVectorTileSource({
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
allowedOrigin: 'http://localhost',
decode: (bytes, z, x, y) =>
decodeFlowTile(bytes, z, x, y, { strict: true })
decode: (bytes, z, x, y) => {
// Take the mark before decoding, so a tile that fails to decode
// leaves nothing behind.
const path = FLOW_TILE_PATH(z, x, y);
const entry = pending.get(path);
const mark = entry?.mark;
entry?.release();
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
.filter(isDrivableFlowSegment)
.flatMap((segment) =>
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
...segment,
coords,
})),
),
fetchImpl,
);
if (mark) freshness.set(segments, mark);
return segments;
},
fetchImpl: async (url, init) => {
const response = await fetchImpl(url, init);
if (response.ok && !init.signal.aborted) {
const path = String(url);
const entry = {
mark: tileFreshness(response),
release() {
if (pending.get(path) === entry) pending.delete(path);
init.signal.removeEventListener('abort', entry.release);
},
};
pending.get(path)?.release();
pending.set(path, entry);
// The vector source aborts this signal for failed/cancelled body reads,
// including size-limit failures that never reach decode.
init.signal.addEventListener('abort', entry.release, { once: true });
}
return response;
},
ttlMs: 120_000,
maxTiles: 16,
});
let partial = false;
/**
* Flow segments for bounds, with whether any covering tile failed to load
* for this request.
* for this request, whether any came from the proxy's stale cache, and when
* the oldest tile was fetched.
*/
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
@@ -44,7 +93,16 @@ export function createFlowTileSource({
coords,
})),
);
return { segments, partial: result.partial === true };
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
const times = marks
.map((mark) => mark.fetchedAt)
.filter((at) => Number.isFinite(at));
return {
segments,
partial: result.partial === true,
stale: marks.some((mark) => mark.stale === true),
oldestTileAt: times.length ? Math.min(...times) : null,
};
}
return {
fetchFlowDetail,
@@ -52,6 +110,9 @@ export function createFlowTileSource({
return (await fetchFlowDetail(bounds, options)).segments;
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
resetFlowTileCache: () => {
for (const entry of pending.values()) entry.release();
tiles.clear();
},
};
}
+153
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { getEventListeners } from 'node:events';
import { createFlowTileSource } from './flowSource.js';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import { decodeFlowTile } from './flowDecode.js';
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
@@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti
assert.ok(publicUrls.length >= 4);
assert.ok(apiUrls.length >= 4);
});
test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => {
const fetchedAt = Date.UTC(2026, 9, 16, 8);
const reply = (status, at) => async () =>
new Response(fixture, {
headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) },
});
const stale = createTrafficSource({
fetchImpl: reply('STALE-BUDGET', fetchedAt),
});
const first = await stale.fetchFlowDetail(bounds);
assert.ok(first.segments.length > 0);
assert.equal(first.stale, true);
assert.equal(first.oldestTileAt, fetchedAt);
// The decoded tile is served from cache now, and keeps its mark.
const again = await stale.fetchFlowDetail(bounds);
assert.equal(stale.getFlowSessionStats().tilesFetched, 1);
assert.equal(again.stale, true);
assert.equal(again.oldestTileAt, fetchedAt);
const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) });
const live = await fresh.fetchFlowDetail(bounds);
assert.equal(live.stale, false);
assert.equal(live.oldestTileAt, fetchedAt);
const unmarked = createTrafficSource({
fetchImpl: async () => new Response(fixture),
});
const plain = await unmarked.fetchFlowDetail(bounds);
assert.equal(plain.stale, false);
assert.equal(plain.oldestTileAt, null);
});
// Capture constructor-owned Maps only while constructing the source, so tests
// can assert retained metadata without adding a diagnostic API to production.
function flowSourceWithMaps(fetchImpl) {
const maps = [];
const NativeMap = globalThis.Map;
let source;
try {
globalThis.Map = class extends NativeMap {
constructor(...args) {
super(...args);
maps.push(this);
}
};
source = createFlowTileSource({ fetchImpl });
} finally {
globalThis.Map = NativeMap;
}
return { source, pending: maps[0] };
}
test('flow freshness is released after body failures, size rejection, decode and reset', async () => {
for (const failure of ['body', 'size', 'decode']) {
let requestSignal;
let fail = true;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({
'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS',
'x-tomtom-fetched-at': fail ? '1000' : '2000',
...(fail && failure === 'size'
? { 'content-length': '99999999' }
: {}),
}),
async arrayBuffer() {
if (fail && failure === 'body') throw new Error('body disconnected');
return fail && failure === 'decode' ? new Uint8Array([255]) : fixture;
},
};
});
await assert.rejects(source.fetchFlowDetail(bounds));
assert.equal(pending.size, 0, `${failure} leaves no pending metadata`);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
fail = false;
const fresh = await source.fetchFlowDetail(bounds);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal(pending.size, 0, 'successful decode releases metadata');
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
source.resetFlowTileCache();
assert.equal(pending.size, 0);
}
});
test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => {
let resolveOld;
let oldSignal;
let calls = 0;
const { source, pending } = flowSourceWithMaps((url, { signal }) => {
if (++calls === 1) {
oldSignal = signal;
return new Promise((resolve) => {
resolveOld = resolve;
});
}
return Promise.resolve(
new Response(fixture, {
headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' },
}),
);
});
const old = source.fetchFlowDetail(bounds);
const rejected = assert.rejects(old, { name: 'AbortError' });
while (!resolveOld) await new Promise((resolve) => setImmediate(resolve));
source.resetFlowTileCache();
const fresh = await source.fetchFlowDetail(bounds);
resolveOld(
new Response(fixture, {
headers: {
'x-tomtom-cache': 'STALE-ERROR',
'x-tomtom-fetched-at': '1000',
},
}),
);
await rejected;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(pending.size, 0);
assert.equal(getEventListeners(oldSignal, 'abort').length, 0);
assert.equal(fresh.stale, false);
assert.equal(fresh.oldestTileAt, 2000);
assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000);
});
test('cancelling a flow body releases its freshness before a new request', async () => {
const controller = new AbortController();
let requestSignal;
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
requestSignal = signal;
return {
ok: true,
headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }),
async arrayBuffer() {
assert.equal(pending.size, 1);
controller.abort();
return fixture;
},
};
});
await assert.rejects(
source.fetchFlowDetail(bounds, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(pending.size, 0);
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
});
+1
View File
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
else if (hidden) {
hidden = false;
parts.rendering.resumePlayback();
parts.viewport.runProximityCheck();
}
}
function bindVisibility() {
+3 -1
View File
@@ -442,10 +442,12 @@ export function buildTransitSelectionCopy(
? entry.sample.latestReportAgeMs
: vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000,
);
// Seconds through 89s, then whole minutes. Rounding 90s up would skip
// "1 min" and call a minute-and-a-half-old report two minutes old.
const reported =
ageS < 90
? t('Reported {seconds} s ago', { seconds: ageS })
: t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) });
: t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) });
// A time the feed did not give for THIS vehicle is labelled, so the card
// never presents a borrowed or inferred time as the vehicle's own report.
const ownTime =
@@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => {
]);
});
test('a report 90 seconds old reads as 1 minute, not 2', () => {
setLocale('en');
const line = (ageMs) =>
buildTransitSelectionCopy(
feed,
{
id: 'age',
lat: 1,
lon: 1,
timestamp: Math.floor((now - ageMs) / 1000),
timestampSource: 'vehicle',
},
'bus',
now,
).details[1];
assert.equal(line(89_000), 'Reported 89 s ago');
assert.equal(line(90_000), 'Reported 1 min ago');
assert.equal(line(120_000), 'Reported 2 min ago');
});
for (const locale of ['es', 'ru']) {
test(`${locale}: the Transit card is in the UI language`, () => {
setLocale(locale);
+5
View File
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
}
function runProximityCheck() {
if (
globalThis.document?.hidden ||
globalThis.document?.visibilityState === 'hidden'
)
return;
if (!state._enabled || !state._viewer) return;
refreshViewBounds();
const altitude = getCameraAltitude();
+74
View File
@@ -0,0 +1,74 @@
/**
* Let a turned-off layer's memory go.
*
* Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command
* arrays: every frame resets `frustumCommands.indices[pass]` to 0 and
* overwrites `commands[pass][i]` from the start. `Scene.js` truncates the
* voxel, Gaussian-splat and translucent passes to their counts; the opaque
* and other passes keep the slots past the new count. When a layer is turned
* off the next frame has fewer commands, so that tail keeps the destroyed
* primitives' DrawCommands, and through `_owner` → primitive → entity →
* EntityCollection the whole data source stays reachable. The offscreen view
* `scene.sampleHeight` / `pickFromRay` render into keeps its last list the
* same way, until the next height sample.
*
* Measured (production build, heap after GC, Data Centers on → off): 116 MiB
* kept on main, 13 MiB with this module; heap snapshots traced the entities
* through `View.frustumCommandsList` and `Picking._pickOffscreenView`.
*
* After a layer finishes turning off, this renders one frame, trims the main
* view's arrays to that frame's counts, and empties the offscreen pick view's
* lists (it rebuilds them in full on every pick). It reads private Cesium
* fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed
* or if Cesium changes which passes it truncates, and once Cesium clears the
* tail itself this module can go.
*/
/** Drop command references a view no longer draws. */
function trimView(view, { keepCurrent }) {
for (const frustum of view?.frustumCommandsList || []) {
const { commands, indices } = frustum;
for (let pass = 0; pass < commands.length; pass += 1) {
const keep = keepCurrent ? indices[pass] : 0;
if (commands[pass].length > keep) commands[pass].length = keep;
if (!keepCurrent) indices[pass] = 0;
}
}
if (!keepCurrent)
for (const extent of view?._commandExtents || [])
extent.command = undefined;
}
/**
* Release stale command references in the scene's main view (keeping what
* the last frame drew) and in its offscreen pick view (all of it).
* @param {object} scene Cesium Scene, right after a render.
*/
export function trimStaleSceneCommands(scene) {
trimView(scene._defaultView, { keepCurrent: true });
trimView(scene._picking?._pickOffscreenView, { keepCurrent: false });
}
/**
* After every layer disable, render one frame and trim once it is drawn.
* @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps
* @returns {() => void} uninstall
*/
export function installLayerReleaseTrim({ scene, dataManager, requestRender }) {
let removeOneShot = null;
const unsubscribe = dataManager.subscribe((change) => {
if (change?.type !== 'visibility' || change.enabled !== false) return;
if (removeOneShot) return; // a trim is already waiting for its frame
removeOneShot = scene.postRender.addEventListener(() => {
removeOneShot();
removeOneShot = null;
trimStaleSceneCommands(scene);
});
requestRender('layer-release-trim');
});
return () => {
unsubscribe();
removeOneShot?.();
removeOneShot = null;
};
}
+137
View File
@@ -0,0 +1,137 @@
// A turned-off layer's draw commands must not stay reachable through Cesium's
// reused per-pass command arrays (src/sceneCommandRelease.js).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import {
installLayerReleaseTrim,
trimStaleSceneCommands,
} from './sceneCommandRelease.js';
const frustum = (lists, counts) => ({
commands: lists.map((list) => [...list]),
indices: [...counts],
});
test('the main view keeps what the last frame drew and drops the stale tail', () => {
const view = {
frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])],
};
trimStaleSceneCommands({ _defaultView: view });
assert.deepEqual(view.frustumCommandsList[0].commands, [
['a', 'b'],
['c'],
[],
]);
assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]);
});
test('the offscreen pick view is emptied, since every pick rebuilds it', () => {
const extents = [{ command: 'x' }, { command: 'y' }];
const pick = {
frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])],
_commandExtents: extents,
};
trimStaleSceneCommands({
_defaultView: { frustumCommandsList: [] },
_picking: { _pickOffscreenView: pick },
});
assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]);
assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]);
assert.deepEqual(
extents.map((e) => e.command),
[undefined, undefined],
);
});
test('a layer disable renders one frame and trims after it', () => {
const listeners = new Set();
const postRender = new Set();
const requests = [];
const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] };
const scene = {
_defaultView: view,
postRender: {
addEventListener(fn) {
postRender.add(fn);
return () => postRender.delete(fn);
},
},
};
const uninstall = installLayerReleaseTrim({
scene,
dataManager: {
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
},
requestRender: (reason) => requests.push(reason),
});
const emit = (change) => listeners.forEach((fn) => fn(change));
emit({ type: 'visibility', layerId: 'x', enabled: true });
assert.equal(requests.length, 0, 'enabling trims nothing');
emit({ type: 'visibility', layerId: 'x', enabled: false });
emit({ type: 'visibility', layerId: 'y', enabled: false });
assert.deepEqual(
requests,
['layer-release-trim'],
'one frame for back-to-back disables',
);
assert.equal(
view.frustumCommandsList[0].commands[0].length,
2,
'not before the frame',
);
postRender.forEach((fn) => fn());
assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]);
assert.equal(postRender.size, 0, 'the frame listener is one-shot');
uninstall();
assert.equal(listeners.size, 0);
});
// The workaround reads private Cesium fields. Pin the shape it relies on, and
// fail when Cesium starts clearing the tail itself so the module can go.
test('Cesium still reuses per-pass command arrays without clearing their tail', () => {
const require = createRequire(import.meta.url);
const enginePkg = require.resolve('@cesium/engine/package.json');
const read = (rel) =>
readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8');
const scene = read('./Source/Scene/Scene.js');
const view = read('./Source/Scene/View.js');
const frustumCommands = read('./Source/Scene/FrustumCommands.js');
const picking = read('./Source/Scene/Picking.js');
// Every private field the trim reads. A rename would otherwise turn the
// trim into a silent no-op (it reaches them through `?.` and `|| []`).
assert.match(scene, /this\._picking = new Picking\(this\);/);
assert.match(scene, /this\._defaultView = new View\(/);
assert.match(picking, /this\._pickOffscreenView = new View\(/);
assert.match(view, /this\.frustumCommandsList = \[\];/);
assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/);
assert.match(view, /this\._commandExtents = \[\];/);
assert.match(
frustumCommands,
/this\.commands = commands;\s*this\.indices = indices;/,
);
// Cesium truncates only the voxel, Gaussian-splat and translucent passes,
// each in its own draw step; the opaque and other passes keep their tail.
// A change here means Cesium's handling moved: re-check whether this
// module is still needed.
assert.doesNotMatch(
view,
/\.length\s*=\s*[^;]*indices\[/,
'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js',
);
assert.deepEqual(
[
...scene.matchAll(
/commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g,
),
].map((match) => match[1]),
['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'],
'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js',
);
});
+28 -6
View File
@@ -47,15 +47,37 @@ function readComponent(text) {
};
}
/** Split on a comma, a semicolon, or whitespace — never on anything else. */
/**
* Split on a comma, a semicolon, or whitespace — never on anything else.
* A hemisphere letter stays with the number it names, so `40.7 N 74.0 W`
* is two components. Splitting on the space before that letter used to
* leave three pieces and the search declined the coordinate.
*/
function splitComponents(query) {
const text = String(query).trim();
if (!text) return null;
const parts = /[,;]/.test(text)
? text.split(/[,;]/)
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
const trimmed = parts.map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
if (/[,;]/.test(text)) {
const trimmed = text.split(/[,;]/).map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
}
const words = text.split(/\s+/);
// Each component has at most a number, a degree sign and one hemisphere.
if (words.length > 6) return null;
// Keep every character: extracting tokens would turn `1e3` or `1.2.3`
// into invented coordinates. A lone letter between numbers can name either
// component; require an unambiguous split rather than guessing its owner.
let result = null;
for (let boundary = words.length - 1; boundary > 0; boundary -= 1) {
const parts = [
words.slice(0, boundary).join(' '),
words.slice(boundary).join(' '),
];
if (parts.every((part) => readComponent(part))) {
if (result) return null;
result = parts;
}
}
return result;
}
/** Present a parsed pair the way a coordinate is normally written. */
+50
View File
@@ -52,6 +52,19 @@ test('hemisphere letters fix the axis, in either order', () => {
label: '40.0000° N, 74.0000° W',
});
assert.deepEqual(parseCoordinateQuery('40° N, 74° W')?.lat, 40);
// Written the way a coordinate is usually pasted, with no comma.
assert.deepEqual(parseCoordinateQuery('40.7128 N 74.0060 W'), {
lat: 40.7128,
lon: -74.006,
label: '40.7128° N, 74.0060° W',
});
assert.deepEqual(parseCoordinateQuery('40.7128° N 74.0060° W')?.lon, -74.006);
assert.deepEqual(parseCoordinateQuery('N 40.7128 W 74.0060'), {
lat: 40.7128,
lon: -74.006,
label: '40.7128° N, 74.0060° W',
});
assert.deepEqual(parseCoordinateQuery('74.0060 W 40.7128 N')?.lat, 40.7128);
// One letter is enough: it fixes its own axis, the other takes what is left.
assert.deepEqual(parseCoordinateQuery('40 N, -74')?.lon, -74);
assert.deepEqual(parseCoordinateQuery('-74, 40 N'), {
@@ -66,6 +79,21 @@ test('malformed and ambiguous coordinates are refused, never guessed', () => {
// The reviewer's case: trailing text must not be discarded.
'12junk, 34oops',
'12junk,34',
'12junk 34',
'1e3 5',
'1e3',
'1E3',
'1e-3',
'1.2.3',
'12-34',
'40 NW 74',
'40 N 74',
'40 W 74',
'40 E 74',
'°40 74',
'40°° 74',
'40 74°°',
'40 26 46 N 79 58 56 W',
'43.1731, -79.0384x',
'abc, def',
'Paris',
@@ -196,3 +224,25 @@ test('malformed or unsupported geo URIs are refused without losing their meaning
assert.equal(parseCoordinateQuery(query), null, query);
}
});
test('whitespace boundaries preserve degree signs and hemisphere ownership', () => {
for (const query of ['40 ° 74', '40° 74', '40 N, 74', 'N 40 74']) {
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
assert.equal(parseCoordinateQuery(query)?.lon, 74, query);
}
for (const query of ['40 ° N 74 ° W', 'N 40 ° W 74 °', '40\tN\n74\tW']) {
assert.equal(parseCoordinateQuery(query)?.lat, 40, query);
assert.equal(parseCoordinateQuery(query)?.lon, -74, query);
}
});
test('a signed plain component leaves a spaced letter with the other component', () => {
assert.deepEqual(
parseCoordinateQuery('-40 N 74'),
parseCoordinateQuery('-40, N 74'),
);
assert.deepEqual(
parseCoordinateQuery('40 N -74'),
parseCoordinateQuery('40 N, -74'),
);
});
+75 -12
View File
@@ -99,6 +99,18 @@ function stubTerrainUpstream(t) {
return asked;
}
/**
* A cache file of the test's own, removed when the test ends. Without one the
* proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a
* point the app has already cached there would answer a request the test
* expects to reach the stubbed upstream (#1242).
*/
async function isolatedCachePath(t) {
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
t.after(() => rm(dir, { recursive: true, force: true }));
return path.join(dir, 'terrain-heights.json');
}
test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [
@@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
upstreamCalls += 1;
return new Response('[]', { status: 200 });
});
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400);
@@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache',
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
});
@@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => {
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
'/api/terrain/heights',
);
const route = install(
terrainHeightsProxy({
cachePath: await isolatedCachePath(t),
cacheMaxEntries: 3,
}),
).get('/api/terrain/heights');
const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1)
@@ -165,12 +184,48 @@ test('the point cache stops growing, and an evicted point is simply refetched',
assert.equal(asked[4], point(1));
});
test('a refetched point counts as new when the cache evicts', async (t) => {
const root = await mkdtemp(path.join(tmpdir(), 'terrain-evict-order-'));
t.after(() => rm(root, { recursive: true, force: true }));
const cachePath = path.join(root, 'cache.json');
const day = 24 * 3600_000;
const now = Date.now();
const point = (i) => `-97.7${i}000,30.26000`;
const entry = (at) => ({ at, result: { ellipsoid: 150 } });
// Written newest first: the file's order is not the age order. Point 1 is
// past the 30-day TTL, so the next request for it refetches.
await writeFile(
cachePath,
JSON.stringify({
version: 2,
points: {
[point(3)]: entry(now - 3600_000),
[point(2)]: entry(now - day),
[point(1)]: entry(now - 31 * day),
},
}),
);
const asked = stubTerrainUpstream(t);
const route = install(
terrainHeightsProxy({ cacheMaxEntries: 3, cachePath }),
).get('/api/terrain/heights');
await request(route, { url: `/?points=${point(1)}` });
assert.deepEqual(asked, [point(1)], 'the stale point is refetched');
await request(route, { url: `/?points=${point(4)}` });
assert.equal(asked.length, 2);
// Point 2 is now the oldest fetch; point 1 was just refreshed.
await request(route, { url: `/?points=${point(1)};${point(3)}` });
assert.equal(asked.length, 2, 'the refreshed and the recent point stay');
await request(route, { url: `/?points=${point(2)}` });
assert.deepEqual(asked.slice(2), [point(2)], 'the oldest fetch was evicted');
});
test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
const cachePath = await isolatedCachePath(t);
// A cache file holding the answer, but larger than the ceiling allows.
const padded = {
@@ -201,7 +256,9 @@ test('an over-large cache file is not read back into memory', async (t) => {
test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) {
@@ -218,7 +275,9 @@ test('the route refuses a flood of requests from one client', async (t) => {
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
@@ -253,7 +312,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async
test('the app served behind a reverse proxy still gets terrain', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
const host = 'globe.example.lan';
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
// cost-bearing routes refuse these; terrain is keyless and needed to render,
@@ -347,7 +408,9 @@ test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const route = install(
terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }),
).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1)
await request(route, {
+5 -4
View File
@@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
let now = Date.UTC(2026, 8, 12, 12);
const fetchedAt = String(now);
t.mock.method(Date, 'now', () => now);
let calls = 0;
t.mock.method(globalThis, 'fetch', async (raw) => {
@@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(json(await request('/status')).dailyCount, 1);
assert.equal(calls, 1);
now += 120001;
assert.equal(
(await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],
'STALE-BUDGET',
);
const staleTile = await request('/flow/8/1/1.pbf');
assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET');
// The stale copy still says when TomTom produced it.
assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt);
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
assert.equal(calls, 1);
now += 86400000;
+13
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getTransitFeed } from '../data/transitFeeds.js';
import {
chooseLiveCities,
selectStyle,
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
const cities = chooseLiveCities(now);
assert.ok(cities.length >= 1);
for (const city of cities) {
const feed = getTransitFeed(city.feedId);
assert.ok(feed, `${city.id} has a registered feed`);
const distanceKm =
111.32 *
Math.hypot(
feed.center.lat - city.lat,
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
);
assert.ok(
distanceKm < feed.loadRadiusKm,
`${city.id} is inside its feed's admission region`,
);
const local = Number(
new Intl.DateTimeFormat('en', {
timeZone: city.zone,
+6 -1
View File
@@ -204,10 +204,14 @@ export const getWind = defineTool({
const speed = windSpeed(vector.u, vector.v);
const from = windFrom(vector.u, vector.v);
const kph = speed * 3.6;
// When the model upstream fails, the proxy keeps serving the last run it
// has, however old, marked stale.
const stale = snapshot.stale === true;
return {
summary:
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
(from === 'Calm' ? ', calm' : ` from the ${from}`) +
(stale ? ' (forecast may be stale).' : '.'),
data: {
view: suggestView(services, {
point,
@@ -224,6 +228,7 @@ export const getWind = defineTool({
level: snapshot.level ?? '10 m above ground',
model,
cycle: snapshot.cycle ?? null,
stale,
},
};
},
+28
View File
@@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => {
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
assert.equal(calm.data.calm, true);
assert.equal(calm.data.stale, false);
// The proxy's last run, served while the model upstream fails.
const cached = composeCatalog({
tools: coreTools,
services: {
wind: {
getSnapshot: async (options) => ({
...(await wind.getSnapshot(options)),
stale: true,
reason: 'Wind upstream unavailable',
}),
},
},
});
const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } });
assert.equal(
stale.summary,
'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).',
);
assert.equal(stale.data.stale, true);
u.fill(10);
const staleWest = await cached.call('get_wind', {
location: { lat: 30.27, lon: -97.74 },
});
assert.equal(
staleWest.summary,
'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).',
);
const down = composeCatalog({
tools: coreTools,
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },
+11
View File
@@ -473,11 +473,22 @@ export const getTrafficFlow = defineTool({
: `No live traffic is reported in ${area.label}.`) +
(flow.partial
? ' Some map tiles did not load, so these figures are partial.'
: '') +
// Past the daily TomTom budget, or while TomTom fails, the proxy
// serves its last copy of a tile however old it is.
(flow.stale
? ' Some tiles are cached copies' +
(isoTime(flow.oldestTileAt)
? `, the oldest from ${isoTime(flow.oldestTileAt)}`
: '') +
', so traffic may be out of date.'
: ''),
data: {
view: suggestView(services, { area, layers: ['traffic'] }),
area: area.label,
partial: flow.partial,
stale: flow.stale === true,
oldest_tile_at: isoTime(flow.oldestTileAt),
speed_pct_of_free_flow: speedPct,
measured_km: round(measuredKm, 1),
congested_km: round(congestedKm, 1),
+41
View File
@@ -555,6 +555,47 @@ test('traffic counts only roads inside a radius area and reports missing tiles',
assert.match(partial.summary, /Some map tiles did not load/);
});
test('traffic says when the proxy served cached tiles, and how old the oldest is', async () => {
const road = [
{
coords: [
[-0.2, 0],
[0.2, 0],
],
trafficLevel: 0.9,
},
];
const call = (detail) =>
composeCatalog({
tools: coreTools,
services: {
traffic: {
getStatus: async () => ({ hasKey: true }),
fetchFlowDetail: async () => ({
segments: road,
partial: false,
...detail,
}),
},
},
}).call('get_traffic_flow', { area: { lat: 0, lon: 0, radius_km: 10 } });
const oldest = Date.UTC(2026, 9, 16, 8);
const stale = await call({ stale: true, oldestTileAt: oldest });
assert.equal(stale.data.stale, true);
assert.equal(stale.data.oldest_tile_at, '2026-10-16T08:00:00.000Z');
assert.match(
stale.summary,
/cached copies, the oldest from 2026-10-16T08:00:00\.000Z, so traffic may be out of date\./,
);
const live = await call({ stale: false, oldestTileAt: oldest });
assert.equal(live.data.stale, false);
assert.doesNotMatch(live.summary, /out of date/);
// A source that can't tell (no freshness fields) isn't reported as stale.
const unknown = await call({});
assert.equal(unknown.data.stale, false);
assert.equal(unknown.data.oldest_tile_at, null);
});
test('a whole-world bike-share search names every system it skipped', async () => {
const empty = { data: { stations: [] } };
const result = await composeCatalog({
+2 -1
View File
@@ -14,6 +14,7 @@ import {
formatAltitudeRulerTick,
formatCompassDivision,
formatCockpitEmergency,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
@@ -238,7 +239,7 @@ export function updateRoute(info) {
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
bearing: `${formatHeadingReadout(destinationBearing)}°`,
});
}
}
+3 -1
View File
@@ -156,7 +156,9 @@ export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
// 359.5° and above is north. Modulo keeps that from printing 360°.
const degrees = Math.round(normalized) % 360;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
+10
View File
@@ -0,0 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatCockpitWindDirection } from './cockpitPresentation.js';
test('wind just west of north reads 0°, not 360°', () => {
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
});
+46 -5
View File
@@ -209,6 +209,49 @@
letter-spacing: 0.055rem;
}
.first-run-dismiss {
position: relative;
padding: 0 0 0 0.75rem;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
/* A 44px touch target drawn outside the layout, so the footer row keeps the
height of its one line of text; a 44px button box would nearly treble it. */
.first-run-dismiss::before {
content: '';
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 44px;
transform: translateY(-50%);
}
.first-run-dismiss:hover,
.first-run-dismiss:focus-visible {
color: rgba(54, 220, 255, 0.86);
}
.first-run-dismiss-touch {
display: none;
}
/* No hover and a coarse pointer: a phone or tablet, where ESC means nothing. */
@media (hover: none) and (pointer: coarse) {
.first-run-dismiss-key {
display: none;
}
.first-run-dismiss-touch {
display: inline;
}
}
.first-run-suppress {
display: flex;
align-items: center;
@@ -270,8 +313,9 @@ body.scene-playback-mode #first-run-launcher {
#first-run-launcher.visible {
transform: translate(-50%, 0) scale(1);
}
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows tighten
and the redundant "ESC to dismiss" hint (no ESC key on a phone) drops. */
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows
tighten. The dismiss button stays: it is the only way to close the card
without choosing a mission on a phone, which has no ESC key. */
#first-run-launcher h2 {
margin-top: 0.45rem;
}
@@ -289,9 +333,6 @@ body.scene-playback-mode #first-run-launcher {
.first-run-choices small {
font-size: 0.62rem;
}
.first-run-footer > span:last-child {
display: none;
}
}
/* Short viewports — landscape phones, small laptop windows. The chrome tightens
+7 -1
View File
@@ -44,7 +44,13 @@
<input type="checkbox" data-first-run-suppress />
<span data-i18n>Don't show this again</span>
</label>
<span data-i18n>ESC to dismiss</span>
<!-- A button, not just a hint: phones and tablets have no ESC key. It
closes exactly as ESC does, for this session only. Touch screens
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
<button type="button" class="first-run-dismiss" data-first-run-dismiss>
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
</button>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
+13 -1
View File
@@ -115,6 +115,18 @@ function shownSources(source) {
return source ? [{ label: sourceLabel(source) }] : [];
}
// Entity labels that layers register as English data (firms/, alpr/); keep in
// step with the producers. Other names (callsigns, places) show as they came.
const ENTITY_LABELS = new Set([msg('ALPR camera')]);
const FIRE_LABEL = /^Fire · FRP (\S+) MW$/;
/** An entity label as the card shows it, in the active language. */
export function shownEntityLabel(label) {
if (ENTITY_LABELS.has(label)) return t(label);
const fire = FIRE_LABEL.exec(label ?? '');
return fire ? t('Fire · FRP {frp} MW', { frp: fire[1] }) : label;
}
/*
* Tool errors stay English in the result (the model reads them). The card
* translates the known ones; the English must match gevActions exactly.
@@ -592,7 +604,7 @@ function getEntityContext(result) {
return {
say: null,
display: {
title,
title: spokenLabel(shownEntityLabel(title)),
lines: [
selected.layerName
? shownLayerName(selected.layerName, selected.layerId)
+56 -2
View File
@@ -3,8 +3,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { attachVoiceResult, planStepLabel } from './speech.js';
import { voiceCardView } from './voiceCardPresentation.js';
import {
attachVoiceResult,
planStepLabel,
shownEntityLabel,
} from './speech.js';
import {
initialVoiceCardState,
reduceVoiceCard,
voiceCardView,
} from './voiceCardPresentation.js';
for (const locale of ['ru', 'es']) {
test(`voice card is in ${locale} for a layer switch`, (t) => {
@@ -86,3 +94,49 @@ test('en: layer switch, distance and plan labels in English', () => {
'Earthquakes (24h) on',
);
});
// FIRMS and ALPR register English entity labels: the model reads them as data
// (get_entity_context, pointer_context), the card shows them translated.
const ENTITY_LABELS = {
ru: { fire: 'Пожар · FRP 12 МВт', alpr: 'Камера ALPR' },
es: { fire: 'Incendio · FRP 12 MW', alpr: 'Cámara ALPR' },
};
for (const [locale, shown] of Object.entries(ENTITY_LABELS)) {
test(`entity labels stay English in the result and show in ${locale}`, (t) => {
t.after(() => setLocale('en'));
setLocale(locale);
const out = attachVoiceResult('get_entity_context', {
ok: true,
selected: {
id: 'fire-1',
name: 'Fire · FRP 12 MW',
layerId: 'local-firms',
layerName: 'NASA FIRMS',
},
});
assert.equal(out.selected.name, 'Fire · FRP 12 MW');
assert.equal(out.display.title, shown.fire);
const state = reduceVoiceCard(initialVoiceCardState(), {
type: 'pointer',
pointer: { kind: 'this', label: 'ALPR camera' },
});
assert.equal(state.pointer.label, 'ALPR camera');
assert.equal(voiceCardView(state).pointer.text, shown.alpr);
assert.equal(state.announce, `This: ${shown.alpr}`);
});
}
test('only the registered entity labels are translated', () => {
assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Fire · FRP 4.9 MW');
assert.equal(shownEntityLabel('ALPR camera'), 'ALPR camera');
setLocale('ru');
try {
assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Пожар · FRP 4.9 МВт');
assert.equal(shownEntityLabel('UPS793'), 'UPS793');
assert.equal(shownEntityLabel('Fire Station 5'), 'Fire Station 5');
} finally {
setLocale('en');
}
});
+8 -3
View File
@@ -1,4 +1,9 @@
import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js';
import {
planStepLabel,
progressStepLabel,
shownEntityLabel,
spokenLabel,
} from './speech.js';
import { presentResult } from './resultDisplay.js';
import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js';
@@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) {
return reveal({
...state,
pointer,
announce: `${kind}: ${spokenLabel(pointer.label, 32)}`,
announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`,
});
}
case 'transcript': {
@@ -288,7 +293,7 @@ export function voiceCardView(state) {
pointer: {
visible: Boolean(state.pointer),
kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'),
text: spokenLabel(state.pointer?.label || '', 32),
text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32),
},
assistant: {
visible: Boolean(state.assistant),