mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into codex/fix-seek-layer-state
This commit is contained in:
111 files changed
+2857
-200
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 · /);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
visibilityTarget: document,
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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\//);
|
||||
|
||||
@@ -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), '');
|
||||
|
||||
@@ -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$/);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Тропическая депрессия",
|
||||
|
||||
@@ -530,6 +530,7 @@
|
||||
"Cyclone advisories unavailable": "气旋公告不可用",
|
||||
"Cyclone data unavailable": "气旋数据不可用",
|
||||
"Potential tropical cyclone": "潜在热带气旋",
|
||||
"Post-tropical cyclone": "后热带气旋",
|
||||
"Hurricane": "飓风",
|
||||
"Tropical storm": "热带风暴",
|
||||
"Tropical depression": "热带低压",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
]) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
|
||||
else if (hidden) {
|
||||
hidden = false;
|
||||
parts.rendering.resumePlayback();
|
||||
parts.viewport.runProximityCheck();
|
||||
}
|
||||
}
|
||||
function bindVisibility() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 }) } },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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°');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user