Merge branch 'main' into fix/multipart-cable-feature-ownership

This commit is contained in:
Sameh Khamis committed 2026-11-02 07:00:05 -08:00
commit c1b12a6aa4
1214 files changed
+106591 -22945

No files matched your search

+1 -1
View File
@@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) {
}
/** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */
export function pointInRing(ring, lat, lon) {
function pointInRing(ring, lat, lon) {
if (!Array.isArray(ring) || ring.length < 3) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
+3 -2
View File
@@ -1,3 +1,5 @@
import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js';
const KNOT_TO_MPS = 0.514444;
const FOOT_TO_M = 0.3048;
const FPM_TO_MPS = 0.00508;
@@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm =
finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
return [
+17
View File
@@ -0,0 +1,17 @@
/**
* Lighting options shared by every aircraft 3D model (Flights, Military
* Flights, Local ADS-B).
*
* Cesium gives each Model a dynamic environment map: it renders the sky around
* the model and reads the result back from the GPU (`readPixels`) to light it,
* when the model is first placed and again when it moves more than 1 km, when
* the scene's atmosphere settings change, or every scene hour under sunlight
* lighting. Aircraft models take 94% of their surface from the class colour
* (`colorBlendAmount`), so that lighting contributes little; without it they
* are lit from Cesium's default spherical harmonics. Measured 2026-10-24
* (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest
* 14.3-14.8 -> 12.1-12.5 ms.
*/
export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({
enabled: false,
});
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js';
/**
* Every aircraft model drawn in the scene skips Cesium's dynamic environment
* map (a sky render and GPU readback per model). The preload calls only warm
* the glTF cache and are never added to the scene, so they are left out:
* drawn models are the calls that set a minimum pixel size.
*/
const SOURCES = [
{
name: 'flights',
source: readLayerSource(new URL('./flights.js', import.meta.url)),
},
{
name: 'militaryFlights',
source: readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
},
{
name: 'localAdsb',
source: readFileSync(
new URL('../layers/localAdsb/models.js', import.meta.url),
'utf8',
),
},
];
test('the shared option turns the dynamic environment map off', () => {
assert.deepEqual({ ...AIRCRAFT_ENVIRONMENT_MAP_OPTIONS }, { enabled: false });
assert.ok(Object.isFrozen(AIRCRAFT_ENVIRONMENT_MAP_OPTIONS));
});
for (const { name, source } of SOURCES) {
test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => {
const calls = [
...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g),
]
.map((match) => match[1])
.filter((options) => /\bminimumPixelSize:/.test(options));
assert.ok(calls.length >= 1, `expected drawn model calls in ${name}`);
for (const [index, options] of calls.entries())
assert.match(
options,
/\benvironmentMapOptions:\s*AIRCRAFT_ENVIRONMENT_MAP_OPTIONS\b/,
`${name} model call ${index + 1} must pass AIRCRAFT_ENVIRONMENT_MAP_OPTIONS`,
);
});
}
+113 -18
View File
@@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { applyVesselOverlayPolicy } from './vesselLabels.js';
import { layerFeedState } from './manager.js';
import {
presentSelectedVessel,
subscribeSelectedVessel,
} from '../hudReadouts.js';
test('open feed with vessels is healthy (null)', () => {
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
@@ -856,15 +860,6 @@ function makeTrailSpy() {
};
}
function makeClassList(...initial) {
const classes = new Set(initial);
return {
add(value) { classes.add(value); },
remove(value) { classes.delete(value); },
contains(value) { return classes.has(value); },
};
}
function makeInteractionHandler() {
return {
click: null,
@@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) {
};
const record = makeRecord();
const trail = makeTrailSpy();
presentSelectedVessel({ text: `AIS: ${record.name}` });
let vesselReadout;
const stopReadout = subscribeSelectedVessel(
(readout) => (vesselReadout = readout),
);
const hud = {
textContent: `AIS: ${record.name}`,
classList: makeClassList('active'),
get readout() {
return vesselReadout;
},
};
globalThis.window = windowTarget;
globalThis.document = {
getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null),
getElementById: () => null,
};
_setVesselStateForTest({
records: [record],
@@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) {
reinstalledHandlers,
interactionHandlerFactory,
cleanup() {
stopReadout();
aisLiveVesselsLayer.destroy();
if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window;
@@ -1011,6 +1013,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', (
}
});
test('vessel touch interaction yields to the first nearby flight behind map tiles', () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
Object.defineProperty(globalThis, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
});
registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3');
const tile = { primitive: { isCesium3DTileset: true } };
const harness = installWireHarness(tile);
let drills = 0;
harness.viewer.scene.drillPick = (_position, limit, width, height) => {
drills++;
assert.deepEqual([limit, width, height], [8, 14, 14]);
return [tile, { id: 'a1b2c3' }];
};
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(drills, 1);
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
} finally {
harness.cleanup();
unregisterPickOwner('flights');
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
else delete globalThis.matchMedia;
}
});
test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => {
registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3');
const harness = installWireHarness({ id: 'atx-cam-3' });
@@ -1063,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Clicking away is a deliberate deselect, not the vessel aging out of the
// feed. Consumers that keep a readout on screen (the Cockpit Contact
// panel) tear down on 'deliberate' and hold last-known on 'evicted'.
@@ -1091,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
// Empty pick is a deliberate deselect — see the note above.
assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]);
} finally {
@@ -1105,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => {
try {
harness.keyTarget.dispatch({ key: 'Escape' });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
assert.equal(harness.hud.textContent, 'AIS: --');
assert.equal(harness.hud.classList.contains('active'), false);
assert.equal(harness.hud.readout, null);
} finally {
harness.cleanup();
}
@@ -1294,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted',
assert.deepEqual(bare.details, []);
});
test('vessel cards read a course that rounds to north as 0°, not 360°', () => {
const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0];
assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°');
assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°');
assert.equal(
buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0],
'CONTAINER SHIP · 14.5KT · 0°',
);
});
test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => {
const harness = installWireHarness(undefined, { selectedRecord: null });
try {
Object.assign(harness.record, { heading: null, course: 359.7 });
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
assert.match(harness.hud.readout.text, /HDG: 0DEG/);
} finally {
harness.cleanup();
}
});
test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => {
const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' }));
assert.equal(unnamed.title, 'MMSI 353136000');
@@ -1691,3 +1739,50 @@ test('vessel selection passes the opaque source reference to optional history',
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+1 -1
View File
@@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(
* @param {Object} body
* @returns {string|null}
*/
export function aisEnvelopeMmsi(envelope, body = {}) {
function aisEnvelopeMmsi(envelope, body = {}) {
const metadata = envelope?.MetaData || envelope?.Metadata || {};
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
if (raw === null || raw === undefined) return null;
+195
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
@@ -63,3 +65,196 @@ test('the fastest encodable reading below the unavailable code is kept', () => {
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});
+1 -21
View File
@@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
authProbeMs: 3_600_000,
});
/**
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
* pre-watchdog meaning so a keyless install still reads as "feature off".
*/
export const AIS_WATCHDOG_STATUSES = Object.freeze([
'idle',
'missing-key',
'unsupported',
'connecting',
'live',
'stale',
'reconnecting',
'down',
'auth-failed',
]);
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
export const AIS_FAILURE_KINDS = Object.freeze([
'transport',
'auth',
'rate-limit',
]);
const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
+25
View File
@@ -9,6 +9,8 @@ import alprCamerasLayer, {
} from './alprCameras.js';
import { createAlprTileSource } from '../layers/alpr/source.js';
import { DATA_CREDITS } from './dataCredits.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import {
clearSelectedEntityContextForLayer,
getContextStore,
@@ -473,6 +475,29 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
}
});
// The voice model reads the label as data; the voice card translates it.
test('the camera label stays English data in a translated interface', async () => {
const h = cameraHarness();
try {
setLocale('ru');
h.setFetch(async () =>
cameraResponse([
cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }),
]),
);
await alprCamerasLayer.update();
h.click(h.source.entities.values[0].id);
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Камера ALPR',
);
} finally {
setLocale('en');
h.restore();
}
});
test('invalid OSM ids and out-of-range coordinates never become camera entities', () => {
for (const id of [undefined, null, 0, -1, 1.5, '42', NaN])
assert.equal(normalizeAlprNode(cameraNode(id, { id })), null);
+3 -63
View File
@@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']);
/** Derived per-record field for layers with a time field (hours before now). */
const AGE_FIELD = 'ageHours';
/** Spatial scope kinds `query()` accepts. */
export const ANALYST_SCOPE_KINDS = Object.freeze([
const ANALYST_SCOPE_KINDS = Object.freeze([
'view',
'region',
'radius',
@@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([
* @param {string[]} layerKeys Queried layer ids.
* @returns {string[]} Field names, common fields first.
*/
export function analystFieldsFor(layerKeys) {
function analystFieldsFor(layerKeys) {
const fields = new Set(COMMON_FIELDS);
for (const key of layerKeys) {
const layer = ANALYST_LAYERS[key];
@@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
* One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}.
* With `time: true`, both sides compare as epoch ms (ISO strings accepted).
*/
export function applyFilter(records, filter, { time = false } = {}) {
const { field, op } = filter || {};
if (!field || !op) return records;
const value = time ? toEpochMs(filter.value) : filter.value;
return records.filter((r) => {
const got = time ? toEpochMs(r[field]) : r[field];
if (got === null || got === undefined || (time && !Number.isFinite(got)))
return false;
if (time && (op === 'eq' || op === 'neq'))
return (got === value) === (op === 'eq');
switch (op) {
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return false;
}
});
}
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
@@ -167,28 +129,6 @@ export function applyScope(records, scope, resolved) {
return records;
}
/** Numeric summary for the narration layer. */
function summarize(items, sortField) {
const summary = { count: items.length };
if (sortField && items.length) {
// A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past
// the engine's argument limit.
let min = Infinity;
let max = -Infinity;
for (const item of items) {
const value = Number(item[sortField]);
if (!Number.isFinite(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
if (Number.isFinite(min)) {
summary[`${sortField}Min`] = min;
summary[`${sortField}Max`] = max;
}
}
return summary;
}
const OPS_BY_TYPE = Object.freeze({
number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'],
@@ -213,7 +153,7 @@ const COMMON_UNITS = Object.freeze({
* @param {string} field Field name.
* @returns {'number'|'text'|'flag'|'time'|null} Field type.
*/
export function analystFieldType(layerKeys, field) {
function analystFieldType(layerKeys, field) {
if (!analystFieldsFor(layerKeys).includes(field)) return null;
if (COMMON_TYPES[field]) return COMMON_TYPES[field];
for (const key of layerKeys) {
+103 -53
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
import { t, msg, onLocaleChange } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
function placeCard(card, leader, anchor) {
/** Place a media card beside its map anchor, in the lane between the panels. */
export function placeCard(card, leader, anchor) {
const margin = 18;
const gap = 42;
const width = card.offsetWidth || 392;
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef
.querySelector('#scene-panel')
?.getBoundingClientRect?.();
const rightRail = documentRef
.querySelector('#right-context-rail')
?.getBoundingClientRect?.();
const commandDock = documentRef
.querySelector('#command-dock')
?.getBoundingClientRect?.();
if (leftPanel?.width > 0)
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
if (rightRail?.width > 0)
laneRight = Math.min(
laneRight,
rightRail.left - rootBounds.left - margin,
);
// The scene panel and the context rail swap sides in right-to-left
// languages: each narrows the lane from the side it sits on.
for (const selector of ['#scene-panel', '#right-context-rail']) {
const panel = documentRef
.querySelector(selector)
?.getBoundingClientRect?.();
if (!(panel?.width > 0)) continue;
const start = panel.left - rootBounds.left;
const end = panel.right - rootBounds.left;
if (start + end < rootBounds.width)
laneLeft = Math.max(laneLeft, end + margin);
else laneRight = Math.min(laneRight, start - margin);
}
if (commandDock?.height > 0)
laneBottom = Math.min(
laneBottom,
@@ -674,6 +675,30 @@ export function createBhoteKoshiEmbeddedMedia({
let record = null;
let warmRecord = null;
let generation = 0;
// Text the cards show, kept as the calls that word it so a locale change
// words it again. A node is forgotten once it has left the page.
const worded = new Map();
function forgetDetached() {
for (const node of worded.keys())
if (!node.isConnected) worded.delete(node);
}
function word(node, property, text) {
let properties = worded.get(node);
if (!properties) worded.set(node, (properties = new Map()));
properties.set(property, text);
writeWord(node, property, text());
}
function writeWord(node, property, value) {
if (property === 'textContent' || property === 'title')
node[property] = value;
else node.setAttribute(property, value);
}
const offLocale = onLocaleChange(() => {
forgetDetached();
for (const [node, properties] of worded)
for (const [property, text] of properties)
writeWord(node, property, text());
});
const leavingRecords = new Set();
const prefersReducedMotion = () =>
@@ -818,6 +843,7 @@ export function createBhoteKoshiEmbeddedMedia({
current.youtubeSession?.destroy();
current.card?.remove?.();
leavingRecords.delete(current);
forgetDetached();
}
const position = () => {
@@ -871,6 +897,7 @@ export function createBhoteKoshiEmbeddedMedia({
destroyFacebookSession(warmRecord.facebookSession);
else warmRecord.iframe?.remove?.();
warmRecord = null;
forgetDetached();
}
function takeWarmFacebook(key, host) {
@@ -917,9 +944,11 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
word(iframe, 'title', () =>
t('Preloading {provider} media', {
provider: providerLabel(source.provider),
}),
);
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -930,7 +959,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = t('LOADING X POST…');
word(current.status, 'textContent', () => t('LOADING X POST…'));
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -950,14 +979,18 @@ export function createBhoteKoshiEmbeddedMedia({
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('X POST · PROVIDER CONTROLS'),
);
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
}),
);
position();
}
}
@@ -970,22 +1003,28 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
word(current.status, 'textContent', () =>
t('FACEBOOK · PROVIDER CONTROLS'),
);
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
word(current.status, 'textContent', () =>
t('{reason} OPEN ORIGINAL.', {
reason: t(
String(error?.message || msg('Facebook embed unavailable')),
),
}),
);
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
word(current.status, 'textContent', () =>
t('AUTOPLAY BLOCKED · USE PROVIDER CONTROLS'),
);
};
if (session.ready) session.onReady();
@@ -1036,8 +1075,7 @@ export function createBhoteKoshiEmbeddedMedia({
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
word(card, 'aria-label', () =>
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
@@ -1054,22 +1092,26 @@ export function createBhoteKoshiEmbeddedMedia({
leader.innerHTML =
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
player.className = 'bhote-embedded-callout-player ui-scroll';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
word(title, 'textContent', () =>
bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase(),
);
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
}),
);
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = t('OPEN ORIGINAL ↗');
word(link, 'textContent', () => t('OPEN ORIGINAL ↗'));
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1109,12 +1151,14 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
word(iframe, 'title', () =>
t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1131,9 +1175,11 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
word(status, 'textContent', () =>
t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
}),
);
}
position();
},
@@ -1151,11 +1197,13 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
word(status, 'textContent', () =>
t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
}),
);
},
});
}
@@ -1165,6 +1213,8 @@ export function createBhoteKoshiEmbeddedMedia({
}
function destroy() {
offLocale();
worded.clear();
hide({ immediate: true });
for (const current of [...leavingRecords]) removeRecord(current);
clearWarm();
+24 -1
View File
@@ -6,6 +6,7 @@ import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
placeCard,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
@@ -348,7 +349,7 @@ test('camera updates retain a preload until the visible player loads', () => {
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
assert.deepEqual(playerCalls, []);
media.destroy();
});
test('a media card stays between the panels when right-to-left swaps their sides', () => {
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
// Arabic: the scene panel sits on the right, the context rail on the left.
const panels = {
'#scene-panel': rect(1040, 1420),
'#right-context-rail': rect(20, 340),
'#command-dock': rect(500, 940, 820, 880),
};
const card = {
offsetWidth: 392,
offsetHeight: 360,
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
classList: { contains: () => false },
style: {},
};
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
const left = parseFloat(card.style.left);
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
});
+164 -203
View File
@@ -25,7 +25,9 @@ import {
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
import { formatDate, t, tn, msg, onLocaleChange } from '../i18n/index.js';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { applicationPinia } from '../ui/vueRuntime.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -412,14 +414,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
const year = part({ year: 'numeric' });
// Year-first languages (2021年10月16日) keep their own order and units.
const whole = part({ day: 'numeric', month: 'short', year: 'numeric' });
if (whole.startsWith(year)) return whole;
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase();
}
/**
@@ -445,18 +451,6 @@ function imageryEndpointText(entry) {
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
@@ -925,185 +919,70 @@ function setElementProperty(element, property, value) {
}
function setElementAttribute(element, attribute, value) {
if (!element || element.getAttribute?.(attribute) === value) return false;
element.setAttribute(attribute, value);
if (!element || element.attributes[attribute] === value) return false;
element.attributes[attribute] = value;
return true;
}
function collectPanelReferences(panel) {
/** One panel control's state, as the panel view renders it. */
function panelRecord(initial = {}) {
return {
textContent: '',
hidden: false,
disabled: false,
value: '',
...initial,
attributes: { ...initial.attributes },
};
}
/**
* The panel's controls as the layer writes them, starting from the panel's
* markup. The panel view renders what the layer publishes from them.
*/
function createPanelRecords(event) {
return Object.freeze({
imagerySection: panel.querySelector('[data-role="imagery-comparison"]'),
observedStatus: panel.querySelector('.bhote-event-status.observed'),
beforeDate: panel.querySelector('[data-role="before-date"]'),
afterDate: panel.querySelector('[data-role="after-date"]'),
time: panel.querySelector('[data-role="time"]'),
caveat: panel.querySelector('[data-role="caveat"]'),
imageryCloudNote: panel.querySelector('[data-role="imagery-cloud-note"]'),
reportList: panel.querySelector('.bhote-event-report-list'),
playButton: panel.querySelector('[data-action="play"]'),
playSceneButton: panel.querySelector('[data-action="play-scene"]'),
cinematicButton: panel.querySelector('[data-action="cinematic"]'),
storyReplayButton: panel.querySelector('[data-action="story-replay"]'),
corridorButton: panel.querySelector('[data-action="corridor"]'),
previousButton: panel.querySelector('[data-action="previous-beat"]'),
nextButton: panel.querySelector('[data-action="next-beat"]'),
openSourceButton: panel.querySelector('[data-action="open-source"]'),
progressInput: panel.querySelector('[data-role="progress"]'),
splitInput: panel.querySelector('[data-role="split"]'),
splitReadout: panel.querySelector('[data-role="split-readout"]'),
timelineLabel: panel.querySelector('[data-role="timeline-label"]'),
beatIndex: panel.querySelector('[data-role="beat-index"]'),
beatTitle: panel.querySelector('[data-role="beat-title"]'),
beatMeta: panel.querySelector('[data-role="beat-meta"]'),
root: { sceneBeat: false },
imagerySection: panelRecord(),
observedStatus: panelRecord(),
time: panelRecord({
textContent: `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`,
}),
caveat: panelRecord({
textContent: bhoteEventText(event.reconstruction.caveat),
}),
imageryCloudNote: panelRecord(),
playButton: panelRecord({ textContent: t('▶ PLAY') }),
playSceneButton: panelRecord({
textContent: t('▶ PLAY SCENE'),
hidden: true,
}),
cinematicButton: panelRecord({
textContent: t('◉ CINEMATIC'),
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: panelRecord({ textContent: t('↺ FULL STORY') }),
corridorButton: panelRecord(),
previousButton: panelRecord({
attributes: { 'aria-label': t('Previous story beat') },
}),
nextButton: panelRecord({
attributes: { 'aria-label': t('Next story beat') },
}),
openSourceButton: panelRecord(),
progressInput: panelRecord({
value: '0',
attributes: { 'aria-label': t('Schematic downstream progression') },
}),
splitInput: panelRecord({ value: '50' }),
splitReadout: panelRecord({ textContent: '50 / 50' }),
timelineLabel: panelRecord({ textContent: t('RECONSTRUCTION CLOCK') }),
beatIndex: panelRecord({ textContent: '01 / 06' }),
beatTitle: panelRecord({ textContent: t('CAUSE') }),
beatMeta: panelRecord({ textContent: t('CAPTURE TIME UNVERIFIED') }),
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
</div>
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
const label = document.createElement('span');
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
}
panel
.querySelector('[data-action="close"]')
.addEventListener('click', handlers.close);
panel
.querySelector('[data-action="play"]')
.addEventListener('click', handlers.play);
panel
.querySelector('[data-action="play-scene"]')
.addEventListener('click', handlers.playScene);
panel
.querySelector('[data-action="cinematic"]')
.addEventListener('click', handlers.toggleCinematic);
panel
.querySelector('[data-action="previous-beat"]')
.addEventListener('click', handlers.previousBeat);
panel
.querySelector('[data-action="next-beat"]')
.addEventListener('click', handlers.nextBeat);
panel
.querySelector('[data-action="story-replay"]')
.addEventListener('click', handlers.replayCinematic);
panel
.querySelector('[data-action="open-source"]')
.addEventListener('click', handlers.openActiveSource);
panel
.querySelector('[data-action="geolocation-map"]')
.addEventListener('click', handlers.openGeolocationMap);
panel
.querySelector('[data-action="corridor"]')
.addEventListener('click', handlers.focusCorridor);
panel
.querySelector('[data-role="split"]')
.addEventListener('input', (inputEvent) => {
handlers.setSplit(Number(inputEvent.target.value) / 100);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('input', (inputEvent) => {
handlers.previewProgress(Number(inputEvent.target.value) / 1000);
});
panel
.querySelector('[data-role="progress"]')
.addEventListener('change', (inputEvent) => {
handlers.commitProgress(Number(inputEvent.target.value) / 1000, {
final: true,
});
});
const rightRail = document.getElementById?.('right-context-rail');
rightRail?.classList.add('bhote-event-active');
(rightRail || document.body).appendChild(panel);
return { panel, refs };
}
export function createBhoteKoshiEventLayer({
overlayHost = DEFAULT_OVERLAY_HOST,
renderHost = DEFAULT_RENDER_HOST,
@@ -1115,14 +994,22 @@ export function createBhoteKoshiEventLayer({
evidenceFrameUpdater = updateEvidenceFrame,
videoFactory = DEFAULT_VIDEO_FACTORY,
embeddedMediaFactory = createBhoteKoshiEmbeddedMedia,
panelStore = useBhoteKoshiPanelStore(applicationPinia()),
} = {}) {
let _viewer = null;
let _dataManager = null;
let _mapStackController = null;
let _event = null;
let _enabled = false;
let _panel = null;
// The panel's controls; null while the layer is off. Their state is
// published to the panel store, which the context rail renders.
let _panelRefs = null;
let _panelOwner = null;
let _panelPublished = '';
// The dates and field reports, which stay as they were when the panel
// opened, until a locale change words them again.
let _panelFixed = null;
let _offPanelLocale = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
@@ -2719,9 +2606,9 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
beforeTitle: () => imageryEndpointText(event.imagery.before),
afterTitle: () => imageryEndpointText(event.imagery.after),
ariaLabel: () => t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
t(
'A historical reference {before} percent, B post-event {after} percent',
@@ -2752,15 +2639,63 @@ export function createBhoteKoshiEventLayer({
_lastCinematicProgress = _progress;
}
/** Present the panel's controls as they stand; an unchanged panel is not sent again. */
function publishPanel() {
if (!_panelRefs) return;
const { root, ...controls } = _panelRefs;
const refs = Object.fromEntries(
Object.entries(controls).map(([key, record]) => [
key,
{ ...record, attributes: { ...record.attributes } },
]),
);
const signature = JSON.stringify([root.sceneBeat, refs]);
if (signature === _panelPublished) return;
_panelPublished = signature;
_panelFixed ??= {
beforeDate: imageryEndpointLabel(_event.imagery.before),
afterDate: imageryEndpointLabel(_event.imagery.after),
reports: _event.fieldReports.map((report) => ({
label: bhoteEventText(report.label),
status: bhoteEventText(report.status),
})),
};
panelStore.publish({
owner: _panelOwner,
sceneBeat: root.sceneBeat,
refs,
..._panelFixed,
});
}
/**
* Word the panel again in the current locale: fresh records, written by
* the same syncs that keep them current.
*/
function relocalizePanel() {
if (!_panelRefs || !_event) return;
_panelRefs = createPanelRecords(_event);
_panelFixed = null;
writeCinematicButton();
syncPanel();
syncPresentationMode();
}
function syncCinematicButton() {
if (writeCinematicButton()) publishPanel();
}
/** Write the cinematic button's label and state; false without a panel. */
function writeCinematicButton() {
const button = _panelRefs?.cinematicButton;
if (!button) return;
if (!button) return false;
setElementProperty(
button,
'textContent',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
return true;
}
function claimCinematicCamera() {
@@ -3025,6 +2960,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`~${elapsedLabel(_sceneScrubProgress, sceneDurationSec)} / ${elapsedLabel(1, sceneDurationSec)}`,
);
publishPanel();
if (_sceneSeekFrame != null) return;
const schedule =
globalThis.requestAnimationFrame ||
@@ -3298,7 +3234,7 @@ export function createBhoteKoshiEventLayer({
'disabled',
!observation?.media?.sourceUrl,
);
syncCinematicButton();
writeCinematicButton();
setElementProperty(
_panelRefs.splitInput,
'value',
@@ -3310,6 +3246,7 @@ export function createBhoteKoshiEventLayer({
'textContent',
`${beforePercent} / ${100 - beforePercent}`,
);
publishPanel();
}
function syncPresentationMode() {
@@ -3326,7 +3263,7 @@ export function createBhoteKoshiEventLayer({
const evidenceAvailable =
!sceneDirected ||
(_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT && knownSceneBeat);
_panel?.classList?.toggle('bhote-event-scene-beat', sceneDirected);
if (_panelRefs) _panelRefs.root.sceneBeat = sceneDirected;
if (_panelRefs?.playSceneButton)
_panelRefs.playSceneButton.hidden = !sceneDirected;
if (_panelRefs?.imagerySection)
@@ -3360,6 +3297,7 @@ export function createBhoteKoshiEventLayer({
}
if (_panelRefs?.openSourceButton)
_panelRefs.openSourceButton.hidden = !evidenceAvailable;
publishPanel();
overlayHost.setVisible(BHOTE_KOSHI_OVERLAY_SOURCE_ID, evidenceAvailable);
}
@@ -3557,7 +3495,12 @@ export function createBhoteKoshiEventLayer({
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
createSplitControl(event);
const panelView = createPanel(event, {
_panelRefs = createPanelRecords(event);
_panelOwner = {};
_panelPublished = '';
_panelFixed = null;
const owner = _panelOwner;
const controls = {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
origin: 'user',
@@ -3574,9 +3517,23 @@ export function createBhoteKoshiEventLayer({
commitProgress,
setSplit,
focusCorridor,
});
_panel = panelView.panel;
_panelRefs = panelView.refs;
openReport: (index) => {
const url = event.fieldReports[index]?.url;
if (url) openExternal(url);
},
};
// Only the panel this enable published may act.
panelStore.connect(
Object.fromEntries(
Object.entries(controls).map(([name, control]) => [
name,
(...args) => (_panelOwner === owner ? control(...args) : undefined),
]),
),
);
publishPanel();
_offPanelLocale?.();
_offPanelLocale = onLocaleChange(relocalizePanel);
syncPresentationMode();
overlayHost.setVisible(
BHOTE_KOSHI_OVERLAY_SOURCE_ID,
@@ -3669,12 +3626,16 @@ export function createBhoteKoshiEventLayer({
_surgePoint = null;
_lastSurgePosition = null;
_lastSurgeVisible = null;
_panel?.remove();
document
.getElementById?.('right-context-rail')
?.classList.remove('bhote-event-active');
_panel = null;
if (_panelOwner && panelStore.panel?.owner === _panelOwner) {
panelStore.connect(null);
panelStore.publish(null);
}
_offPanelLocale?.();
_offPanelLocale = null;
_panelOwner = null;
_panelRefs = null;
_panelPublished = '';
_panelFixed = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
+125 -44
View File
@@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { claimPointer, releasePointer } from './inputOwnership.js';
import * as Cesium from 'cesium';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
import { applicationPinia } from '../ui/vueRuntime.js';
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import {
BHOTE_KOSHI_LAYER_ID,
@@ -36,6 +39,95 @@ import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
/** The event's divider as the imagery split store holds it, or undefined. */
const eventSplit = () =>
useImagerySplitStore(applicationPinia()).splits.findLast(
(split) => split.view.id === 'bhote-koshi-split-line',
);
// The panel controls the layer presents, by the selector the panel view gives each.
const PANEL_REFS = {
'[data-role="imagery-comparison"]': 'imagerySection',
'[data-role="time"]': 'time',
'[data-role="caveat"]': 'caveat',
'[data-role="imagery-cloud-note"]': 'imageryCloudNote',
'[data-role="progress"]': 'progressInput',
'[data-role="split"]': 'splitInput',
'[data-role="timeline-label"]': 'timelineLabel',
'[data-role="beat-index"]': 'beatIndex',
'[data-role="beat-title"]': 'beatTitle',
'[data-role="beat-meta"]': 'beatMeta',
'[data-action="play"]': 'playButton',
'[data-action="play-scene"]': 'playSceneButton',
'[data-action="cinematic"]': 'cinematicButton',
'[data-action="story-replay"]': 'storyReplayButton',
'[data-action="corridor"]': 'corridorButton',
'[data-action="previous-beat"]': 'previousButton',
'[data-action="next-beat"]': 'nextButton',
'[data-action="open-source"]': 'openSourceButton',
};
// What each control calls, as the panel view wires it.
const PANEL_EVENTS = {
playButton: { click: 'play' },
playSceneButton: { click: 'playScene' },
cinematicButton: { click: 'toggleCinematic' },
storyReplayButton: { click: 'replayCinematic' },
corridorButton: { click: 'focusCorridor' },
previousButton: { click: 'previousBeat' },
nextButton: { click: 'nextBeat' },
openSourceButton: { click: 'openActiveSource' },
progressInput: {
input: (controls, value) => controls.previewProgress(Number(value) / 1000),
change: (controls, value) => controls.commitProgress(Number(value) / 1000, { final: true }),
},
splitInput: { input: (controls, value) => controls.setSplit(Number(value) / 100) },
};
/**
* The event panel as the layer presents it to the panel store, or undefined
* while it is off. Controls read the latest presentation, and dispatching an
* event calls the connected controls as the panel view does.
*/
function eventPanel() {
const store = useBhoteKoshiPanelStore(applicationPinia());
if (!store.panel) return undefined;
const control = (key) => {
let typed = null;
const record = () => store.panel.refs[key];
return {
get textContent() { return record().textContent; },
get hidden() { return record().hidden; },
get disabled() { return record().disabled; },
get value() { return typed ?? record().value; },
set value(value) { typed = value; },
getAttribute: (name) => record().attributes[name] ?? null,
dispatch(type) {
const value = typed ?? record().value;
typed = null;
const handler = PANEL_EVENTS[key]?.[type];
return typeof handler === 'function'
? handler(store.controls, value)
: store.controls[handler]?.();
},
};
};
const named = (name) => ({ dispatch: () => store.controls[name]() });
return {
querySelector(selector) {
if (selector === '.bhote-event-report-list') {
return {
children: store.panel.reports.map((_, index) => ({
dispatch: () => store.controls.openReport(index),
})),
};
}
if (selector === '[data-action="geolocation-map"]') return named('openGeolocationMap');
if (selector === '[data-action="close"]') return named('close');
return control(PANEL_REFS[selector]);
},
};
}
class FakeElement {
constructor(tagName = 'div') {
this.tagName = tagName;
@@ -840,6 +932,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const { body, removedProperties } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const clearedTimeouts = [];
globalThis.window = {
setTimeout: () => 71,
@@ -879,7 +972,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.imagery.length, 2);
assert.equal(viewer._test.dataSources.length, 1);
assert.equal(body.children.length, 2);
assert.ok(eventPanel());
assert.ok(eventSplit());
activeMap = 'operator-choice';
generation += 1;
@@ -888,7 +982,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
assert.deepEqual(mapCalls, ['esri-imagery']);
assert.equal(viewer._test.removedImagery.length, 2);
assert.equal(viewer._test.removedDataSources.length, 1);
assert.ok(body.children.every((element) => element.removed));
assert.equal(eventPanel(), undefined);
assert.equal(eventSplit(), undefined);
assert.equal(viewer.scene.splitPosition, 0.37);
assert.deepEqual(clearedTimeouts, [71]);
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
@@ -934,7 +1029,7 @@ test('Bhote Koshi disable stops active playback and releases its global render h
try {
await layer.enable(viewer, { origin: 'scene' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.equal(panel.querySelector('[data-action="play-scene"]').hidden, true);
assert.equal(await panel.querySelector('[data-action="play-scene"]').dispatch('click'), false);
panel.querySelector('[data-action="play"]').dispatch('click');
@@ -1075,6 +1170,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const { body } = installEventDom();
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
const timers = [];
const openedUrls = [];
const cancelledFrames = [];
@@ -1181,11 +1277,10 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.deepEqual(timers, []);
assert.equal(viewer._test.imagery.length, 2, 'the event surface keeps its cached imagery pair');
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
assert.ok(panel);
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
assert.ok(splitLine);
assert.equal(splitLine.hidden, true);
assert.ok(eventSplit());
assert.equal(eventSplit().view.hidden, true);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
@@ -1220,8 +1315,6 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(panel.querySelector('[data-action="cinematic"]').disabled, true);
assert.equal(panel.querySelector('[data-action="corridor"]').hidden, false);
assert.equal(panel.querySelector('[data-action="corridor"]').disabled, false);
assert.doesNotMatch(panel.innerHTML, /data-action="witness"|◎ WITNESS/);
assert.match(panel.innerHTML, /data-action="open-source"/);
const fullStory = panel.querySelector('[data-action="story-replay"]');
assert.equal(fullStory.textContent, '↺ FULL STORY');
assert.equal(fullStory.hidden, true, 'PLAY SCENE owns authored scene playback');
@@ -1240,30 +1333,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
beatId: 'timure-cluster',
sceneControls: { imageryComparison: true },
}, { origin: 'scene' });
assert.equal(splitLine.hidden, false);
assert.equal(eventSplit().view.hidden, false);
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
const splitHandle = splitLine.children.find(
(element) => element.className === 'bhote-koshi-split-handle',
);
assert.ok(splitHandle);
assert.equal(splitHandle.textContent, '↔');
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
splitHandle.dispatch('pointerdown', {
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
assert.equal(eventSplit().view.valueNow, 62);
const splitHandle = eventSplit().controls;
splitHandle.pointerdown({
button: 0,
clientX: 250,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointermove', {
splitHandle.pointermove({
clientX: 750,
pointerId: 4,
preventDefault() {},
});
splitHandle.dispatch('pointerup', { pointerId: 4 });
splitHandle.pointerup({ pointerId: 4 });
assert.equal(viewer.scene.splitPosition, 0.75);
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
splitHandle.dispatch('keydown', {
assert.equal(eventSplit().view.valueNow, 75);
splitHandle.keydown({
key: 'ArrowLeft',
shiftKey: true,
preventDefault() {},
@@ -1276,7 +1366,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
split: 0.62,
sceneControls: {},
}, { origin: 'scene' });
assert.equal(splitLine.hidden, true);
assert.equal(eventSplit().view.hidden, true);
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
assert.equal(viewer.scene.splitPosition, 0.62);
assert.equal(overlayVisibility.at(-1).visible, false);
@@ -1347,7 +1437,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
assert.equal(layer.getCinematicState().active, false);
assert.equal(viewer._test.cameraLooks.length, 0);
assert.deepEqual(mapCalls, []);
assert.equal(body.children.filter((element) => element.tagName === 'aside').length, 1);
assert.ok(eventPanel());
assert.equal(viewer.scene.splitPosition, 0.62);
for (const beatId of [
'debris-dammed-lake',
@@ -1594,7 +1684,7 @@ test('Bhote Koshi publishes the evidence spine once and scrubs through cards and
assert.equal(breadcrumbs.filter((entry) => entry.position()).length, 0);
assert.ok(cards.every((entry) => entry.requireImage && entry.image.stamp === 1));
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1685,7 +1775,7 @@ test('Bhote Koshi keeps one local fallback video active and unloads it on beat c
videos[0].dispatch('loadeddata');
assert.equal(layer.getCinematicState().mediaReady, true);
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const progress = panel.querySelector('[data-role="progress"]');
progress.value = '285';
progress.dispatch('input');
@@ -1808,7 +1898,7 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
assert.ok(video.pauseCalls >= 1);
assert.ok(video.cancelledFrameHandles.length >= 1);
assert.equal(layer.getCinematicState().mediaEvidenceId, null);
assert.equal(body.children.find((element) => element.tagName === 'aside')?.removed, false);
assert.ok(eventPanel());
await layer.disable();
} finally {
globalThis.document = originalDocument;
@@ -2121,10 +2211,8 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
assert.match(panel.innerHTML, /2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/);
assert.match(panel.innerHTML, /RECONSTRUCTION CLOCK/);
assert.match(panel.innerHTML, /OPEN PUBLIC MAP/);
const panel = eventPanel();
assert.equal(panel.querySelector('[data-role="timeline-label"]').textContent, 'RECONSTRUCTION CLOCK');
const entries = publications[0].entries;
const cards = entries.filter((entry) => entry.variant === 'thumbnail');
const breadcrumbs = entries.filter((entry) => entry.variant === 'label');
@@ -2203,7 +2291,7 @@ test('Bhote Koshi beat controls keep cards, flood, camera, and panel on the one
}
});
test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or camera churn', async () => {
test('Bhote Koshi repeated event frames republish no panel and churn no camera', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -2238,26 +2326,19 @@ test('Bhote Koshi repeated event frames perform no panel lookup, DOM write, or c
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
const cinematic = panel.querySelector('[data-action="cinematic"]');
const progress = panel.querySelector('[data-role="progress"]');
cinematic.dispatch('click');
progress.value = '500';
progress.dispatch('input');
const queryCount = panel._queryCount;
const writeCount = [...panel._selectors.values()].reduce((sum, element) => (
sum + element._writeCount
), 0);
const panelStore = useBhoteKoshiPanelStore(applicationPinia());
const published = panelStore.panel;
const cameraLookCount = viewer._test.cameraLooks.length;
for (let index = 0; index < 20; index += 1) progress.dispatch('input');
assert.equal(panel._queryCount, queryCount, 'hot path uses cached panel references');
assert.equal(
[...panel._selectors.values()].reduce((sum, element) => sum + element._writeCount, 0),
writeCount,
'unchanged frames do not rewrite panel state',
);
assert.ok(panelStore.panel === published, 'unchanged frames do not republish the panel');
assert.equal(
viewer._test.cameraLooks.length,
cameraLookCount,
@@ -2328,7 +2409,7 @@ test('Bhote Koshi cinematic camera stays synchronized through pause and determin
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
assert.equal(layer.getCinematicState().active, true);
assert.equal(layer.getPlaybackState().playing, true);
@@ -2413,7 +2494,7 @@ test('Bhote Koshi replay restores cinematic ownership unless the operator releas
try {
await layer.enable(viewer, { origin: 'local-restore' });
const panel = body.children.find((element) => element.tagName === 'aside');
const panel = eventPanel();
panel.querySelector('[data-action="cinematic"]').dispatch('click');
animationCallbacks[0](91001);
assert.equal(layer.getPlaybackState().progress, 1);
+6 -7
View File
@@ -12,9 +12,9 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
import { t, msg, getLocale, isolate } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
'bhote-koshi-locator-callout';
export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context';
@@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700;
const INCIDENT_CALLOUT_GAP_MS = 200;
const CALLOUT_REVEAL_DURATION_MS = 1200;
const CALLOUT_PULSE_DURATION_MS = 1800;
const CALLOUT_DOT_SIZE_PX = 7;
const CALLOUT_RING_MIN_SIZE_PX = 16;
const CALLOUT_RING_SIZE_RANGE_PX = 14;
const CITY_CALLOUT_START_DELAY_MS = 350;
@@ -68,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
// Isolated: the card draws left to right after its "11 · " counter, so in
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
// DANDAGAUN») must keep its own right-to-left order.
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
}
const INCIDENT = Object.freeze({
@@ -1248,6 +1250,3 @@ export function createBhoteKoshiLocatorLayer({
},
};
}
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
export default bhoteKoshiLocatorLayer;
+2
View File
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_bumpSceneSeekGeneration:
SceneDirector.prototype._bumpSceneSeekGeneration,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
+212
View File
@@ -0,0 +1,212 @@
// src/data/bhoteKoshiPanelView.test.mjs — the context rail renders the Bhote
// Koshi reconstruction panel the event layer presents and sends its clicks
// and slider moves back.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useBhoteKoshiPanelStore } from '../ui/stores/bhoteKoshiPanel.js';
import ContextView from '../ui/views/ContextView.vue';
const record = (initial = {}) => ({
textContent: '',
hidden: false,
disabled: false,
value: '',
attributes: {},
...initial,
});
function presentation(patch = {}) {
return {
owner: {},
sceneBeat: false,
beforeDate: { name: 'Historical reference', date: '2021-05-01' },
afterDate: { name: 'Post-event', date: '' },
reports: [
{ label: 'Report A', status: 'Verified' },
{ label: 'Report B', status: 'Unverified' },
],
refs: {
imagerySection: record(),
observedStatus: record(),
time: record({ textContent: '~0:00 / 2:00' }),
caveat: record({ textContent: 'Schematic only.' }),
imageryCloudNote: record(),
playButton: record({ textContent: '▶ PLAY' }),
playSceneButton: record({ textContent: '▶ PLAY SCENE', hidden: true }),
cinematicButton: record({
textContent: '◉ CINEMATIC',
attributes: { 'aria-pressed': 'false' },
}),
storyReplayButton: record({ textContent: '↺ FULL STORY' }),
corridorButton: record(),
previousButton: record({
disabled: true,
attributes: { 'aria-label': 'Previous story beat' },
}),
nextButton: record({ attributes: { 'aria-label': 'Next story beat' } }),
openSourceButton: record(),
progressInput: record({
value: '0',
attributes: { 'aria-label': 'Schematic downstream progression' },
}),
splitInput: record({ value: '50' }),
splitReadout: record({ textContent: '50 / 50' }),
timelineLabel: record({ textContent: 'RECONSTRUCTION CLOCK' }),
beatIndex: record({ textContent: '01 / 06' }),
beatTitle: record({ textContent: 'CAUSE' }),
beatMeta: record({ textContent: 'CAPTURE TIME UNVERIFIED' }),
},
...patch,
};
}
function fixture(t) {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(ContextView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useBhoteKoshiPanelStore(pinia);
const calls = [];
store.connect(
new Proxy(
{},
{
get:
(_, name) =>
(...args) =>
calls.push([name, ...args]),
},
),
);
const rail = document.getElementById('right-context-rail');
const $ = (selector) => rail.querySelector(selector);
return { store, calls, rail, $ };
}
test('the panel renders last in the context rail and marks the rail while on', async (t) => {
const { store, rail, $ } = fixture(t);
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
store.publish(presentation());
await nextTick();
const panel = $('#bhote-koshi-event-panel');
assert.equal(panel.tagName, 'ASIDE');
assert.ok(rail.lastElementChild === panel);
assert.ok(rail.classList.contains('ui-scroll'));
assert.ok(rail.classList.contains('bhote-event-active'));
assert.equal(
panel.getAttribute('aria-label'),
'Bhote Koshi flood reconstruction controls',
);
assert.match(
panel.textContent,
/2021 HISTORICAL REFERENCE \/ 2026 POST-EVENT/,
);
assert.match(panel.textContent, /RECONSTRUCTION CLOCK/);
assert.match(panel.textContent, /OPEN PUBLIC MAP/);
assert.equal(
$('[data-role="before-date"]').textContent,
'Historical reference · 2021-05-01',
);
assert.equal(
$('[data-role="before-date"] .bhote-event-date').textContent,
'2021-05-01',
);
assert.equal($('[data-role="after-date"]').textContent, 'Post-event');
assert.equal($('[data-role="after-date"] .bhote-event-date'), null);
assert.equal($('[data-action="play-scene"]').hidden, true);
assert.ok($('[data-action="open-source"]'));
assert.equal($('[data-action="witness"]'), null);
assert.doesNotMatch(panel.innerHTML, /witness/i);
assert.equal($('[data-action="previous-beat"]').disabled, true);
assert.equal($('[data-action="previous-beat"]').title, 'Previous story beat');
assert.equal(
$('[data-action="cinematic"]').getAttribute('aria-pressed'),
'false',
);
assert.equal($('[data-action="play"]').hasAttribute('aria-label'), false);
assert.equal($('[data-role="progress"]').value, '0');
assert.equal($('[data-role="split"]').value, '50');
assert.equal($('summary').textContent, 'FIELD REPORTS · 2');
assert.deepEqual(
[...$('.bhote-event-report-list').children].map(
(button) => button.textContent,
),
['Report AVerified', 'Report BUnverified'],
);
store.publish(null);
await nextTick();
assert.equal($('#bhote-koshi-event-panel'), null);
assert.equal(rail.classList.contains('bhote-event-active'), false);
assert.ok(rail.classList.contains('ui-scroll'));
});
test('a later presentation updates the controls in place', async (t) => {
const { store, $ } = fixture(t);
store.publish(presentation());
await nextTick();
const play = $('[data-action="play"]');
const next = presentation({ sceneBeat: true });
next.refs.playButton = record({
textContent: '▶ PLAY SHOT',
disabled: true,
attributes: { 'aria-label': 'Play Global context' },
});
next.refs.progressInput = record({ value: '250', hidden: true });
next.refs.imagerySection = record({ hidden: true });
store.publish(next);
await nextTick();
assert.ok($('[data-action="play"]') === play);
assert.equal(play.textContent, '▶ PLAY SHOT');
assert.equal(play.disabled, true);
assert.equal(play.getAttribute('aria-label'), 'Play Global context');
assert.ok(
$('#bhote-koshi-event-panel').classList.contains('bhote-event-scene-beat'),
);
assert.equal($('[data-role="progress"]').value, '250');
assert.equal($('[data-role="progress"]').hidden, true);
assert.equal($('[data-role="imagery-comparison"]').hidden, true);
});
test('buttons, sliders and reports call the connected controls', async (t) => {
const { store, calls, $ } = fixture(t);
store.publish(presentation());
await nextTick();
for (const action of [
'close',
'play',
'cinematic',
'story-replay',
'open-source',
'corridor',
'next-beat',
'geolocation-map',
])
$(`[data-action="${action}"]`).click();
$('.bhote-event-report-list').children[1].click();
const progress = $('[data-role="progress"]');
progress.value = '500';
progress.dispatchEvent(new Event('input'));
progress.dispatchEvent(new Event('change'));
const split = $('[data-role="split"]');
split.value = '25';
split.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
['close'],
['play'],
['toggleCinematic'],
['replayCinematic'],
['openActiveSource'],
['focusCorridor'],
['nextBeat'],
['openGeolocationMap'],
['openReport', 1],
['previewProgress', 0.5],
['commitProgress', 0.5, { final: true }],
['setSplit', 0.25],
]);
});
-1
View File
@@ -23,4 +23,3 @@ export {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
} from '../layers/bikeshare/index.js';
export default layer;
+2
View File
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
export const processGeometryBatch = layer.processGeometryBatch;
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
export const refreshCoverageStyles = layer.refreshCoverageStyles;
export const refreshHorizonCulling = layer.refreshHorizonCulling;
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
export const clearProbeClampOnDeactivation =
layer.clearProbeClampOnDeactivation;
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
-26
View File
@@ -25,9 +25,6 @@ function componentFunctionSource(fn) {
.replace(/\(\s+/g, '(');
}
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as Cesium from 'cesium';
import cctvLayer, {
CCTV_PROJECTION_OVERLAY_SOURCE_OPTIONS,
@@ -83,16 +80,6 @@ import {
LIVE_CAMERA_COLOR,
} from '../layers/cctv/policy.js';
const CCTV_PRESENTATION_SOURCE = fs.readFileSync(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'ui',
'cctvPresentation.js',
),
'utf8',
);
const ASPECT = 16 / 9;
const toRad = (deg) => (deg * Math.PI) / 180;
const GESTURE_TYPES = {
@@ -1302,19 +1289,6 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
assert.equal(cctvCycleIndex(-1, -1, records.length), records.length - 1);
assert.equal(cctvCycleIndex(2, 1, records.length), 0);
assert.equal(cctvCycleIndex(0, -1, records.length), records.length - 1);
const renderer = CCTV_PRESENTATION_SOURCE.match(
/_renderCctvState\(state\) \{[\s\S]*?\n\}\n/,
);
assert.ok(renderer, '_renderCctvState is missing');
assert.match(
renderer[0],
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
);
assert.match(
renderer[0],
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
);
} finally {
cctvLayer.setParams({ autoHop: false });
_setCctvOverlayHostForTest();
+226
View File
@@ -0,0 +1,226 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
a22WebcamToSource,
loadA22SourcesFromOpenDataHub,
} from '../../server/providers/cctv/sources.js';
import {
A22_DEFAULT_GROUND_ELEVATION_M,
A22_IMAGE_ORIGIN,
DEFAULT_A22_CCTV_URL,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { staticFrameRefreshMs } from './cctvLod.js';
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
const item = (
patch = {},
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
) => ({
Id: `A22_${km}`,
Active: true,
Source: 'a22',
Shortname: 'ADS Sciliar',
Webcamname: { it: 'ADS Sciliar' },
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
Mapping: { a22: { km } },
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
LicenseInfo: {
ClosedData: false,
LicenseHolder: 'https://www.autobrennero.it/',
},
...patch,
});
test('an A22 webcam maps to a source on the pinned operator frame', () => {
const source = a22WebcamToSource(item());
assert.equal(source.id, 'it-a22-km68');
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
assert.equal(source.city, 'A22 Brennero');
assert.equal(source.cityId, 'a22');
assert.equal(source.provider, 'Autostrada del Brennero');
assert.equal(source.lat, 46.53591);
assert.equal(source.lon, 11.49772);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'a22-opendatahub');
assert.equal(source.code, 'ADS SCILIAR');
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
assert.equal(source.snapshotUrl, source.url);
});
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
const brenner = a22WebcamToSource(
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
);
const ponte = a22WebcamToSource(
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
);
assert.ok(brenner.groundElevationM > 1300);
assert.ok(ponte.groundElevationM < 50);
// A kilometre the table does not know falls back to the corridor prior.
const unknown = a22WebcamToSource(
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
);
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
});
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(a22WebcamToSource(item({ Active: false })), null);
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
),
null,
);
// The frame must be exactly the camera's own kilometre image.
assert.equal(
a22WebcamToSource(
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
),
null,
);
assert.equal(
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
null,
);
// Milan: plausible, but off the Brenner–Modena corridor.
assert.equal(
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
null,
);
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
assert.equal(a22WebcamToSource(null), null);
});
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
TotalResults: 3,
Items: [
item(
{ Shortname: 'Ponte Po' },
{ km: '272', lat: 45.03145, lon: 10.85369 },
),
item(),
item(),
item(
{ Shortname: 'Brennero' },
{ km: '1', lat: 46.99682, lon: 11.50218 },
),
],
});
});
const cameras = await loadA22SourcesFromOpenDataHub();
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
assert.deepEqual(
cameras.map((c) => c.id),
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(
async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://evil.test/' },
}),
);
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_A22_CCTV_URL) {
return Response.json({ Items: [item()] });
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_A22_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the A22 lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
['it-a22-km68'],
);
});
});
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'a22'),
[],
);
});
});
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
const frame = `${A22_IMAGE_ORIGIN}km68.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/WebCamImg/km77.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
const source = a22WebcamToSource(item());
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
});
+2 -2
View File
@@ -17,7 +17,7 @@
*/
const EARTH_RADIUS_M = 6371000;
export const PLANE_VERT_ASPECT = 16 / 9;
const PLANE_VERT_ASPECT = 16 / 9;
export const SUPPORT_KEYS = Object.freeze([
'bl',
'bm',
@@ -128,7 +128,7 @@ export function planeSupportPoints(pose) {
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
* @returns {number}
*/
export function supportAltitude(capAltM, row, dims) {
function supportAltitude(capAltM, row, dims) {
return capAltM + row * dims.upVert;
}
+1 -1
View File
@@ -316,7 +316,7 @@ export function createCalibrationGizmo({
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(
const { forwardHoriz } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
+6 -3
View File
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
// matches the other highway packs' 3-minute cadence.
'servei català de trànsit': 3 * 60 * 1000,
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
// tracks the minute); 3 min matches the other highway packs.
'autostrada del brennero': 3 * 60 * 1000,
});
/**
@@ -94,7 +97,7 @@ export function incumbentRankKm(
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
/** Robust pool-distance percentile used to scale the screen-space term. */
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
/** True only when both viewport dimensions are finite positive pixels. */
export function hasFiniteCctvViewport(viewW, viewH) {
@@ -172,8 +175,8 @@ export function blendCenterRankKm(
// viewport is bucketed into this grid and every occupied cell gets its best
// candidate before global rank fills the rest. Owner-tunable together with
// the budgets above.
export const CCTV_CARD_GRID_COLS = 5;
export const CCTV_CARD_GRID_ROWS = 4;
const CCTV_CARD_GRID_COLS = 5;
const CCTV_CARD_GRID_ROWS = 4;
/**
* Screen-space distribution pass (owner round 2, item C). Buckets the
+251
View File
@@ -0,0 +1,251 @@
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
// near the screen show, a zoomed-out view keeps one camera per world-anchored
// cell, and the layer's UI state never rebuilds every camera for a listener
// that does not read the list.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import cctvLayer, {
_setCctvCoverageStateForTest,
_viewRecordIdsForTest,
refreshHorizonCulling,
} from './cctv.js';
const WIDTH = 800;
const HEIGHT = 600;
/**
* Records at the given [lon, lat] degrees near the origin, each drawn at the
* screen point `screenAt` gives it (default: the middle of the screen).
*/
function recordsAt(
lonLats,
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
) {
const screenByPosition = new Map();
const records = lonLats.map(([lon, lat], index) => {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
screenByPosition.set(position, screenAt(index));
return {
camera: {
id: `cam-${index}`,
name: `Camera ${index}`,
city: 'Test',
lat,
lon,
headingDeg: 0,
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
},
position,
billboard: { position, show: true },
coverageEntities: [],
projection: null,
};
});
return { records, screenByPosition };
}
function viewerAt(heightM, screenByPosition, shiftX = 0) {
return {
isDestroyed: () => false,
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
frustum: { fovy: Math.PI / 3 },
},
scene: {
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
cartesianToCanvasCoordinates: (position) => {
const screen = screenByPosition.get(position);
return screen && { x: screen.x + shiftX, y: screen.y };
},
},
};
}
function shownIds(records) {
return records
.filter((record) => record.billboard.show)
.map((record) => record.camera.id)
.sort();
}
// Three cameras within ~110 m, and one ~22 km away.
const CLUSTER = [
[0, 0],
[0.001, 0],
[0.0005, 0.0005],
[0.2, 0],
];
test('zoomed out, cameras sharing a ground cell show one icon', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
assert.equal(
refreshHorizonCulling(),
true,
'the first pass changes the view',
);
const shown = shownIds(records);
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
});
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
x: 100 + index * 7,
y: 300,
}));
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
});
refreshHorizonCulling();
const before = shownIds(records);
const list = _viewRecordIdsForTest();
for (const shiftX of [3, 11, 20, 37]) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition, shiftX),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
}
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
assert.deepEqual(_viewRecordIdsForTest(), list);
});
test('a small zoom within one cell size keeps the same winners', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
const shownAt = (heightM) => {
_setCctvCoverageStateForTest({
viewer: viewerAt(heightM, screenByPosition),
records,
});
refreshHorizonCulling();
return shownIds(records);
};
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
// these heights fall in the same step, so the same cameras win.
assert.deepEqual(shownAt(20_500), shownAt(20_000));
});
test('a healthy camera wins its cell over an unknown one', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
// Whichever camera the id order would pick, the healthy one must win.
for (const id of ['cam-0', 'cam-1']) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
healthById: new Map([[id, { status: 'ok' }]]),
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), [id]);
}
});
test('the active camera always shows, even in a crowded cell', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
for (const active of records) {
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
activeCameraId: active.camera.id,
});
refreshHorizonCulling();
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
}
});
test('close up, every camera near the screen shows and far-off ones hide', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) =>
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
});
test('a disabled layer skips the view pass', () => {
const { records, screenByPosition } = recordsAt(CLUSTER);
_setCctvCoverageStateForTest({
viewer: viewerAt(20_000, screenByPosition),
records,
enabled: false,
});
assert.equal(refreshHorizonCulling(), false);
assert.deepEqual(_viewRecordIdsForTest(), []);
});
test('UI state builds the full camera list only when it is read', () => {
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
});
refreshHorizonCulling();
let built = 0;
for (const record of records) {
const camera = record.camera;
record.camera = new Proxy(camera, {
get(target, key) {
if (key === 'calibration') built += 1;
return target[key];
},
});
}
const first = cctvLayer.getUIState();
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
assert.deepEqual(
first.cameraIndex.map((camera) => camera.id),
['cam-0', 'cam-1'],
);
assert.equal(
cctvLayer.getUIState().cameraIndex,
first.cameraIndex,
'the index keeps its identity while the view does',
);
assert.equal(first.cameras.length, 2);
assert.ok(built > 0, 'reading `cameras` builds it');
assert.equal(first.cameras, first.cameras, 'and builds it once');
});
test('the camera picker lists the view, with the active camera first', () => {
const { records, screenByPosition } = recordsAt(
[
[0, 0],
[0.0001, 0],
[0.0002, 0],
],
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
);
_setCctvCoverageStateForTest({
viewer: viewerAt(300, screenByPosition),
records,
activeCameraId: 'cam-1',
});
refreshHorizonCulling();
const state = cctvLayer.getUIState();
assert.deepEqual(
state.cameraIndex.map((camera) => camera.id),
['cam-1', 'cam-0', 'cam-2'],
'the off-screen active camera leads, then what is in view',
);
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
});
@@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import flightsLayer, {
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
/**
* Cockpit 3D aircraft policy.
+1 -1
View File
@@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) {
for (const id of track.epochs.keys())
if (!used.has(id)) track.epochs.delete(id);
}
export function validFix(fix) {
function validFix(fix) {
return (
Number.isFinite(fix?.t) &&
fix.t >= 0 &&
+2 -1
View File
@@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
// NHC publishes movementSpeed in mph: 12 mph is 10 kt.
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
+17 -2
View File
@@ -25,11 +25,11 @@ import * as Cesium from 'cesium';
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
* @type {{ key: string, html: string }[]}
*/
export const OSM_CREDIT = {
const OSM_CREDIT = {
key: 'openstreetmap',
html: 'Map and place data <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL</a>)',
};
export const OPENMAPTILES_CREDIT = {
const OPENMAPTILES_CREDIT = {
key: 'openfreemap',
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'a22-cctv',
html:
'Motorway webcams (A22 Brennero–Modena): images © ' +
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
},
{
key: 'gbfs',
html:
@@ -448,6 +455,14 @@ export const DATA_CREDITS = [
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
},
{
key: 's2-ghostmaps',
html:
'S2 GhostMaps intel products: S2 Underground © by S2A1, ' +
'<a href="https://github.com/s2underground/GhostMaps" target="_blank" rel="noopener">github.com/s2underground/GhostMaps</a>, ' +
'licensed under <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener">CC BY-NC-SA 4.0</a>. ' +
'Shown as published; reports are S2 assessments, not verified fact.',
},
{
key: 'drivebc-cctv',
html:
-1
View File
@@ -43,7 +43,6 @@ import {
detectionHorizontalSector,
labelBudgetFor,
normalizeAllocationStrategy,
normalizeProfile,
profileForDensity,
viewScaleForAltitude,
} from './detectionPolicy.js';
+1 -1
View File
@@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) {
}
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
function compareCohortContenders(a, b) {
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
+1 -2
View File
@@ -5,7 +5,6 @@
* module so density cannot drift into contradictory mode semantics again.
*/
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([
@@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
return 'front';
}
export const VIEW_SCALE_BUDGETS = Object.freeze({
const VIEW_SCALE_BUDGETS = Object.freeze({
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
+2 -2
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -19,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0);
+1 -5
View File
@@ -24,7 +24,7 @@ import * as input from './inputOwnership.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
/** The shared scene owners every Directions instance runs on. */
export const directionsServices = Object.freeze({
const directionsServices = Object.freeze({
credits,
render,
sprites,
@@ -49,15 +49,11 @@ export {
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
FLIGHT_PROGRESS_MS,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
createDirectionsLayer,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
+78
View File
@@ -846,9 +846,15 @@ 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(9940), '9.9\u00a0km');
assert.equal(routeDistanceText(9960), '10\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), '');
@@ -871,4 +877,76 @@ test('route numbers follow the page language and never break from their unit', (
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
setLocale('ja');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDurationText(39), '39秒');
assert.equal(routeDurationText(3900), '1時間5分');
assert.equal(routeDurationText(7200), '2時間');
});
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
t.after(() => setLocale('en'));
const cases = [
[
'ru',
'Въезжайте по съезду плавно налево на I-95',
'Сверните на съезд резко направо',
'Займите левую полосу',
],
[
'es',
'Toma la rampa ligeramente a la izquierda hacia I-95',
'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo',
],
[
'ja',
'ランプを斜め左に進んでI-95に入る',
'出口を急角度で右に出る',
'左車線を通行',
],
[
'zh-CN',
'向左前方驶入匝道,进入 I-95',
'从出口向右后方驶出',
'使用左转车道',
],
];
for (const [locale, ramp, exit, lane] of cases) {
setLocale(locale);
assert.equal(
directionsInstruction({
type: 'on ramp',
modifier: 'slight left',
name: 'I-95',
}),
ramp,
);
assert.equal(
directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }),
exit,
);
assert.equal(
directionsInstruction({ type: 'use lane', modifier: 'sharp left' }),
lane,
);
}
});
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
t.after(() => setLocale('en'));
const title = (id) =>
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
setLocale('de');
assert.equal(
title('mode-foot'),
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
);
assert.equal(
title('mode-bike'),
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
);
});
+43
View File
@@ -0,0 +1,43 @@
/**
* Kilometre decimals for a distance: one below 10 km, none from there on.
* The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0.
* @param {number} meters
* @returns {0|1}
*/
export function kilometreDigits(meters) {
return Math.round(meters / 100) < 100 ? 1 : 0;
}
/**
* The unit and precision a distance prints with: metres below 1 km, then
* kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens),
* except below `exactBelowM`, where they stay whole. Each cut is taken after
* rounding, so 999.6 m reads 1.0 km rather than 1000 m.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a
* distance that is negative or not finite. The value is already rounded to
* `digits`, so toFixed and Intl render it the same way.
*/
export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) {
if (!Number.isFinite(meters) || meters < 0) return null;
const step = meters < exactBelowM ? 1 : metreStep;
const shownMeters = Math.round(meters / step) * step;
if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 };
const digits = kilometreDigits(meters);
const value =
digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000);
return { value, unit: 'km', digits };
}
/**
* Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized.
* @param {number} meters
* @param {{metreStep?: number, exactBelowM?: number}} [options]
* @returns {string|null}
*/
export function formatDistanceText(meters, options) {
const parts = distanceParts(meters, options);
if (!parts) return null;
return `${parts.value.toFixed(parts.digits)} ${parts.unit}`;
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
distanceParts,
formatDistanceText,
kilometreDigits,
} from './distanceFormat.js';
test('a distance switches to kilometres where its metres round to 1,000', () => {
assert.equal(formatDistanceText(999.4), '999 m');
assert.equal(formatDistanceText(999.5), '1.0 km');
assert.equal(formatDistanceText(999.6), '1.0 km');
assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m');
assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km');
});
test('kilometres drop their decimal where the tenth rounds to 10.0', () => {
assert.equal(kilometreDigits(9949), 1);
assert.equal(kilometreDigits(9950), 0);
assert.equal(formatDistanceText(9949), '9.9 km');
assert.equal(formatDistanceText(9950), '10 km');
assert.equal(formatDistanceText(9960), '10 km');
assert.equal(formatDistanceText(99950), '100 km');
});
test('metres below exactBelowM stay whole while the rest round to the step', () => {
const options = { metreStep: 10, exactBelowM: 100 };
assert.equal(formatDistanceText(8, options), '8 m');
assert.equal(formatDistanceText(99.4, options), '99 m');
assert.equal(formatDistanceText(846, options), '850 m');
assert.deepEqual(distanceParts(846, options), {
value: 850,
unit: 'm',
digits: 0,
});
});
test('a negative or non-finite distance has no parts', () => {
for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) {
assert.equal(distanceParts(value), null);
assert.equal(formatDistanceText(value), null);
}
assert.equal(formatDistanceText(0), '0 m');
});
test('kilometres come back rounded, so toFixed and Intl agree', () => {
assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 });
assert.equal(formatDistanceText(1450), '1.5 km');
assert.equal(formatDistanceText(1150), '1.2 km');
});
-1
View File
@@ -8,4 +8,3 @@ export function createEarthquakesLayer({
} = {}) {
return createApplicationEarthquakes({ source, ...options });
}
export default createEarthquakesLayer();
+33 -10
View File
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const cached = cache.get(key);
if (cached !== undefined) return cached;
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
// into March, and the fire's age would describe a detection that never happened.
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
cache.set(key, 0);
return 0;
}
const timeText = String(time ?? '').trim();
if (!/^\d{1,4}$/.test(timeText)) {
cache.set(key, 0);
return 0;
}
const hhmm = timeText.padStart(4, '0');
const year = Number(date.slice(0, 4));
const month = Number(date.slice(5, 7));
const day = Number(date.slice(8, 10));
const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4));
const valid =
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms);
return ms;
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hours > 23 ||
minutes > 59
) {
cache.set(key, 0);
return 0;
}
const ms = Date.UTC(year, month - 1, day, hours, minutes);
const parsed = new Date(ms);
const result =
parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month - 1 &&
parsed.getUTCDate() === day
? ms
: 0;
cache.set(key, result);
return result;
}
/**
+36 -4
View File
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
const cache = new Map();
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
assert.equal(
parseAcquisitionMs('2026-07-16', '1006', cache),
Date.UTC(2026, 6, 16, 10, 6),
);
assert.equal(cache.size, 1);
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
assert.equal(
parseAcquisitionMs('2026-07-16', '1006', cache),
Date.UTC(2026, 6, 16, 10, 6),
);
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
});
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
for (const [date, time] of [
['2026-02-30', '1200'],
['2026-04-31', '1200'],
['2026-13-01', '1200'],
['2026-07-16', '2400'],
['2026-07-16', '1260'],
]) {
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
const [fire] = adaptFirmsRecords([
proxyRecord({ acqDate: date, acqTime: time }),
]);
assert.equal(fire.acqMs, 0, `${date}:${time}`);
assert.equal(fire.lat, 38.99488);
}
const [leap] = adaptFirmsRecords([
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
]);
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
});
test('daynight "D" → night false', () => {
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
assert.equal(fire.night, false);
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
proxyRecord({ lat: 40.1 }),
]);
assert.equal(fires.length, 2);
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
assert.deepEqual(
fires.map((f) => f.index),
[0, 1],
);
});
test('non-finite frp/brightness → 0; empty input → []', () => {
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
const [fire] = adaptFirmsRecords([
proxyRecord({ frp: 'n/a', brightness: undefined }),
]);
assert.equal(fire.frp, 0);
assert.equal(fire.brightness, 0);
assert.deepEqual(adaptFirmsRecords([]), []);
+40
View File
@@ -14,6 +14,7 @@ import {
FIRMS_OVERLAY_SOURCE_ID,
} from './firmsLabels.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
const NOW = Date.UTC(2026, 6, 17, 4, 0);
@@ -53,6 +54,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$/);
@@ -85,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
assert.equal(card.accent, accentForSeverity('orange'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
const title = (count) =>
buildCellCard(
{
cell: { count, maxFrp: 10, newestAcqMs: 0 },
position: { x: 0, y: 0, z: 0 },
},
NOW,
).title;
const previous = getLocale();
try {
assert.equal(title(1), '1 FIRE');
setLocale('ar');
assert.deepEqual(
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
['حريقان (2)', '3 حرائق', '14 حريقًا'],
);
setLocale('ru');
assert.equal(title(14), '14 ПОЖАРОВ');
} finally {
setLocale(previous);
}
});
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
// ground floor is warm — and the cached per-fire position must re-anchor when
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
+24
View File
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { getSelectedEntityContext } from './contextStore.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
}
});
// The voice model reads these labels as data (track_entity, get_entity_context,
// pointer_context); the voice card translates them where it shows them.
test('fire labels stay English data in a translated interface', () => {
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
try {
setLocale('ru');
h.click();
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
// The card's pattern must match what the producer actually writes.
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Пожар · FRP 1520 МВт',
);
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
} finally {
h.cleanup();
setLocale('en');
}
});
test('a click on neither sprite nor card clears without moving the camera', () => {
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
try {
+11
View File
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
}
/**
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
* @param {number} frp Megawatts.
* @returns {string}
*/
export function formatFrpMw(frp) {
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
return Number(frp).toFixed(digits);
}
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '')
+9
View File
@@ -6,6 +6,7 @@ import {
FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID,
satelliteShortName,
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
assert.equal(formatFrpMw(9.96), '10');
assert.equal(formatFrpMw(9.94), '9.9');
assert.equal(formatFrpMw(0.8), '0.8');
assert.equal(formatFrpMw(1520.27), '1520');
assert.equal(formatFrpMw(210.4), '210');
});
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
assert.equal(satelliteShortName('N20'), 'N20');
assert.equal(satelliteShortName('N21'), 'N21');
+231 -2
View File
@@ -1,7 +1,19 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https';
import net from 'node:net';
import { Readable } from 'node:stream';
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
import {
describeFetchError,
fetchFirms,
fetchIpv4,
isAddressSelectionFailure,
} from '../../server/providers/firms.js';
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
const start = config.indexOf(' async function refreshUpstream(key) {');
@@ -17,8 +29,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
// Inject only its upstream, clock and filter dependencies; keep its aggregation
// and source-status code intact, including failures while consuming records.
function createRefresh(fetchSource, filter = filterTrailing24h) {
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
}
test('FIRMS retains large sources in order and filters expired rows', async () => {
@@ -78,3 +90,220 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
assert.deepEqual(result.fires, []);
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
});
// #68: Node's network-family auto-selection gives each address a short attempt
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
// reports only `fetch failed`. This is the shape Node 24 throws for it.
function addressSelectionFailure() {
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
return new TypeError('fetch failed', { cause });
}
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
function wrappedAddressSelectionFailure() {
const inner = addressSelectionFailure().cause;
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
return new TypeError('fetch failed', { cause });
}
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
test('FIRMS failures log the socket cause, never the keyed URL', () => {
const described = describeFetchError(addressSelectionFailure());
assert.equal(described,
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
assert.ok(!described.includes('SECRET'));
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
});
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
const retried = [];
await fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw shape(); },
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
warn() {},
});
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
}
const signal = new AbortController().signal;
const ok = new Response('csv');
const retried = [];
const warnings = [];
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
warn: (message) => warnings.push(message),
});
assert.equal(response, ok);
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
assert.ok(!warnings[0].includes('SECRET'));
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
fetchImpl: async () => { throw addressSelectionFailure(); },
fetchIpv4Impl: async () => { throw failure; },
warn() {},
}), (err) => err === failure, 'a failed retry reports its own error');
});
test('FIRMS does not retry anything but an address-selection failure', async () => {
const deps = (fetchImpl) => ({
fetchImpl,
fetchIpv4Impl: async () => assert.fail('must not retry'),
warn: () => assert.fail('must not warn'),
});
const serverError = new Response('down', { status: 503 });
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
for (const thrown of [
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
new TypeError('fetch failed', { cause: new AggregateError([]) }),
new TypeError('fetch failed'),
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
]) {
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
}
const controller = new AbortController();
controller.abort();
const aborted = addressSelectionFailure();
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
});
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
// same port and record which one the retry reaches; the TLS handshake then
// fails against a plain socket, which is fine — only the connection matters.
const reached = [];
const listen = (host, port) => new Promise((resolve, reject) => {
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
server.once('error', reject).listen(port, host, () => resolve(server));
});
const v4 = await listen('127.0.0.1', 0);
t.after(() => v4.close());
const { port } = v4.address();
let v6;
try {
v6 = await listen('::1', port);
} catch {
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
return;
}
t.after(() => v6.close());
const order = await dns.lookup('localhost', { all: true });
if (order[0]?.family !== 6) {
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
return;
}
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
assert.deepEqual(reached, ['127.0.0.1']);
});
// Answer the IPv4 retry with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of crashing the test runner.
function mockIpv4Status(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'text/csv' },
}),
),
);
return request;
});
return crashed;
}
for (const status of [204, 205, 304]) {
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockIpv4Status(t, status);
await assert.rejects(
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
});
// The fixtures above copy Node's error shape; check it against a real fetch so
// a Node or undici change that stops matching fails here instead of silently
// disabling the retry. A closed port refuses both loopback addresses; resolve
// `localhost` to both here, since some hosts map it to 127.0.0.1 only.
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer();
try {
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
} catch {
t.skip('no IPv6 loopback on this host');
return;
}
const { port } = probe.address();
await new Promise((resolve) => probe.close(resolve));
const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(resolve));
const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }];
const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback));
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup');
assert.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
});
// #68 only stays fixed while both upstream calls go through fetchFirms; the
// refresh harness injects fetchSource, so guard the call sites directly.
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
const calls = [];
const response = new Response('latitude,longitude\n');
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
() => [],
1024,
AbortSignal,
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
);
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'fetchFirms');
assert.ok(calls[0][2] instanceof AbortSignal);
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
});
-6
View File
@@ -1,6 +0,0 @@
import { createFlowTileSource } from '../layers/traffic/flowSource.js';
export { tilesForBounds } from './tomtomTiles.js';
export { decodeFlowTile } from '../layers/traffic/flowDecode.js';
const source = createFlowTileSource();
export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } =
source;
-162
View File
@@ -1,162 +0,0 @@
// src/data/flowTiles.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
decodeFlowTile,
fetchFlowForBounds,
tilesForBounds,
getFlowSessionStats,
resetFlowTileCache,
} from './flowTiles.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16).
const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf');
const FIXTURE_TILE = { z: 12, x: 935, y: 1686 };
function loadFixture() {
return fs.readFileSync(FIXTURE);
}
// ── decodeFlowTile against the real fixture ─────────────────
test('fixture decode: more than 50 flow segments', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
assert.ok(segments.length > 50, `got ${segments.length}`);
});
test('fixture decode: every trafficLevel is within [0, 1]', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`);
assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`);
}
});
test('fixture decode: all coordinates land in downtown Austin', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short');
for (const [lon, lat] of s.coords) {
assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`);
assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`);
}
}
});
test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found');
});
test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => {
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
for (const s of segments) {
assert.equal(typeof s.roadType, 'string');
assert.equal(typeof s.closure, 'boolean');
}
// The fixture carries real closures — closure decoding is exercised, not vacuous.
assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture');
});
test('decode of a non-MVT buffer returns [] (defensive)', () => {
assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []);
});
// ── tilesForBounds (re-exported slippy math) ────────────────
test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => {
const tiles = tilesForBounds({
south: 30.2672 - 0.001, north: 30.2672 + 0.001,
west: -97.7431 - 0.001, east: -97.7431 + 0.001,
}, 12);
assert.ok(
tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686),
`fixture tile missing: ${JSON.stringify(tiles)}`
);
});
// ── fetchFlowForBounds (stubbed fetch: cache + abort) ───────
/** Bounds fully inside the fixture tile. */
const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 };
function stubFetch(impl) {
const original = globalThis.fetch;
globalThis.fetch = impl;
return () => { globalThis.fetch = original; };
}
test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => {
resetFlowTileCache();
const calls = [];
const restore = stubFetch(async (url) => {
calls.push(String(url));
return new Response(loadFixture(), {
status: 200,
headers: { 'Content-Type': 'application/x-protobuf' },
});
});
try {
const segments = await fetchFlowForBounds(FIXTURE_BOUNDS);
assert.equal(calls.length, 1);
assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/);
assert.ok(segments.length > 50);
assert.ok(getFlowSessionStats().tilesFetched >= 1);
} finally {
restore();
}
});
test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => {
resetFlowTileCache();
let calls = 0;
const restore = stubFetch(async () => {
calls += 1;
return new Response(loadFixture(), { status: 200 });
});
try {
const first = await fetchFlowForBounds(FIXTURE_BOUNDS);
const second = await fetchFlowForBounds(FIXTURE_BOUNDS);
assert.equal(calls, 1, 'second call must be served from the decode cache');
assert.equal(second.length, first.length);
} finally {
restore();
}
});
test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => {
resetFlowTileCache();
const restore = stubFetch(async (url, opts) => {
// Mimic real fetch abort semantics.
if (opts?.signal?.aborted) {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}
return new Response(loadFixture(), { status: 200 });
});
try {
const controller = new AbortController();
controller.abort();
await assert.rejects(
fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }),
(err) => err.name === 'AbortError'
);
} finally {
restore();
}
});
test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => {
resetFlowTileCache();
const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 }));
try {
await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS));
} finally {
restore();
}
});
+15
View File
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
return _focusEvidenceNowMs;
}
/**
* True when a sprite holds no dimming state, or has settled at full emphasis
* with nothing pending. Advancing such a sprite without a screen position (a
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
* skip it.
* @param {object} sprite
* @returns {boolean}
*/
export function spriteFocusAtRest(sprite) {
const state = _spriteStates.get(sprite);
return (
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
);
}
/** Forget state when a primitive is removed from its owning collection. */
export function forgetSpriteFocus(sprite) {
if (sprite) _spriteStates.delete(sprite);
+112 -32
View File
@@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
/** Largest total a KMZ may inflate to — a zip bomb stops here. */
export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024;
/** Most files on the globe at once. */
export const MAX_IMPORTED_FILES = 12;
/** Most entities one file may create before it is refused. */
@@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000;
/** Most vertices (points of every line, ring and marker) in one file. */
export const MAX_IMPORT_POSITIONS = 250_000;
/** Most normalized style definitions and local image links in one KML/KMZ. */
export const MAX_KML_LINKS = 2_000;
const MAX_KML_LINKS = 2_000;
/** Longest plain-text name kept from a file's features. */
export const MAX_IMPORT_NAME_LENGTH = 120;
const MAX_IMPORT_NAME_LENGTH = 120;
/** What the file picker offers. */
export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx';
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
* it in the active language. Numbers stay numbers in `values`: the UI formats
* them in that language's numerals.
*/
export class GeoImportError extends Error {
constructor(message, template = message, values = {}) {
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
if (over === 'links')
return new GeoImportError(
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
{ limit: MAX_KML_LINKS },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
{ limit: MAX_IMPORT_POSITIONS, features },
);
return new GeoImportError(
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
{ limit: MAX_IMPORT_POSITIONS },
);
}
@@ -1040,26 +1041,104 @@ export function detachImportedTime(dataSource) {
return dataSource;
}
function parseXml(text, what) {
/** Repair only the first KML opening tag, scanning its delimiter once. */
export function repairKmlRootNamespaces(value, what = 'KML') {
const text = String(value);
// Skip XML comments and processing instructions without treating their
// literal '<kml' text as an opening root tag.
let start = 0;
for (;;) {
start = text.indexOf('<', start);
if (start < 0) return text;
if (text.slice(start, start + 9).toUpperCase() === '<!DOCTYPE') {
let quote = null;
let subsetDepth = 0;
let end = start + 9;
for (; end < text.length; end++) {
const char = text[end];
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") quote = char;
else if (char === '<' && text.startsWith('<!--', end)) {
// Comments and processing instructions inside the internal subset
// carry no declaration syntax; jump past them as a whole.
const skip = text.indexOf('-->', end + 4);
if (skip < 0) return text;
end = skip + 2;
} else if (char === '<' && text.startsWith('<?', end)) {
const skip = text.indexOf('?>', end + 2);
if (skip < 0) return text;
end = skip + 1;
} else if (char === '[') subsetDepth++;
else if (char === ']') subsetDepth--;
else if (char === '>' && subsetDepth === 0) break;
}
if (end === text.length) return text;
start = end + 1;
continue;
}
const close = text.startsWith('<!--', start)
? '-->'
: text.startsWith('<?', start)
? '?>'
: null;
if (!close) break;
const end = text.indexOf(close, start + 2);
if (end < 0) return text; // The XML parser reports an unterminated preamble.
start = end + close.length;
}
const match = /^<kml\b/i.exec(text.slice(start, start + 5));
if (!match) return text;
let quote = null;
let end = -1;
for (let index = start + match[0].length; index < text.length; index++) {
const char = text[index];
if (char === '<') break; // Raw '<' is invalid even inside an XML attribute.
if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === '>') {
end = index + 1;
break;
}
}
if (end < 0)
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
// Walk the root's attributes in order so quoted values are never read as
// declarations. Anything the walk cannot tokenize is kept verbatim for the
// XML parser to judge.
const seen = new Set();
const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y;
let position = start + match[0].length;
let root = text.slice(start, position);
for (;;) {
attribute.lastIndex = position;
const found = attribute.exec(text);
if (!found || attribute.lastIndex > end) break;
position = attribute.lastIndex;
const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1];
if (prefix && seen.has(prefix)) continue;
if (prefix) seen.add(prefix);
root += found[0];
}
root += text.slice(position, end);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/(\/?)>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>',
);
return text.slice(0, start) + root + text.slice(end);
}
/** Parse KML or GPX with the shared namespace repairs and XML validation. */
export function parseXml(text, what) {
// Match the namespace repairs Cesium applies to ordinary exported KML.
if (what !== 'GPX')
text = String(text).replace(/<kml\b[^>]*>/i, (root) => {
const seen = new Set();
root = root.replace(
/\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g,
(declaration, prefix) => {
if (seen.has(prefix)) return '';
seen.add(prefix);
return declaration;
},
);
if (!seen.has('xsi') && /[<\s]xsi:/.test(text))
root = root.replace(
/>$/,
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
);
return root;
});
if (what !== 'GPX') text = repairKmlRootNamespaces(text, what);
const doc = new DOMParser().parseFromString(
String(text).replace(/^/, ''),
'application/xml',
@@ -1073,7 +1152,8 @@ function parseXml(text, what) {
return doc;
}
function decodeXml(bytes) {
/** Decode XML bytes, honouring a UTF-16 byte-order mark. */
export function decodeXml(bytes) {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
? 'utf-16le'
@@ -1387,8 +1467,8 @@ export async function loadGeoFile(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
count: entityCount,
limit: MAX_IMPORT_ENTITIES,
},
);
}
+91
View File
@@ -1,9 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import { spawnSync } from 'node:child_process';
import * as Cesium from 'cesium';
import {
GeoImportError,
repairKmlRootNamespaces,
loadGeoFile,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
@@ -1171,3 +1173,92 @@ test('a dated import exposes no clock and draws for all time', () => {
assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2);
assert.ok(track.polyline.clampToGround.getValue());
});
test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => {
const text =
'<KML note="a > b" xmlns:x="first" xmlns:x="second"><Placemark xsi:type="test"/></KML>';
const result = repairKmlRootNamespaces(text);
assert.match(result, /note="a > b"/);
assert.match(result, /xmlns:x="first"/);
assert.doesNotMatch(result, /second/);
assert.match(
result,
/xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/,
);
assert.ok(result.endsWith('<Placemark xsi:type="test"/></KML>'));
assert.match(
repairKmlRootNamespaces('<kml xsi:type="test"/>'),
/XMLSchema-instance"\/>$/,
);
for (const malformed of ['<kml ', '<kml name="unterminated>', '<kml <kml >'])
assert.throws(() => repairKmlRootNamespaces(malformed), GeoImportError);
});
test('repeated incomplete KML roots finish within an isolated process deadline', () => {
const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)};
try { repairKmlRootNamespaces('<kml '.repeat(1000000)); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
try { repairKmlRootNamespaces('<!DOCTYPE kml [' + ${JSON.stringify("<!-- ' --><?pi ' ?>")}.repeat(100000) + ']><kml '); process.exit(1); }
catch (error) { if (error.name !== 'GeoImportError') throw error; }
const wide = '<kml' + ${JSON.stringify(` xmlns:gx="a" note='xmlns:gx="b"'`)}.repeat(20000) + '/>';
if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`;
const child = spawnSync(
process.execPath,
['--input-type=module', '-e', source],
{ timeout: 5000, encoding: 'utf8' },
);
assert.ifError(child.error);
assert.equal(child.status, 0, child.stderr);
});
test('KML root repair ignores incomplete tag text inside preamble comments', () => {
const input =
'<?xml version="1.0"?><!-- example <kml incomplete -->\n<kml xmlns:gx="a" xmlns:gx="b"><Document/></kml>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair follows a doctype with a quoted root-like entity', () => {
const input =
'<!DOCTYPE kml [<!ENTITY example "<kml incomplete >">]><kml xmlns:gx="a" xmlns:gx="b"/>';
assert.equal(
repairKmlRootNamespaces(input),
input.replace(' xmlns:gx="b"', ''),
);
});
test('KML namespace repair skips doctype comments and processing instructions whole', () => {
for (const prolog of [
"<!DOCTYPE kml [<!-- don't count this quote -->]>",
'<!DOCTYPE kml [<!-- a ] or > here --><!ENTITY x "y">]>',
"<!DOCTYPE kml [<?note it's ] > fine?>]>",
'<!DOCTYPE kml [<!ENTITY x "<!-- \' -->">]>',
]) {
const input = `${prolog}<kml xmlns:gx="a" xmlns:gx="b"><Placemark xsi:type="t"/></kml>`;
assert.equal(
repairKmlRootNamespaces(input),
input.replace(
' xmlns:gx="b">',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
),
prolog,
);
}
const unterminated = '<!DOCTYPE kml [<!-- open <kml xmlns:gx="a">';
assert.equal(repairKmlRootNamespaces(unterminated), unterminated);
});
test('KML namespace repair ignores declaration text inside quoted attribute values', () => {
const input =
'<kml note=\'a > b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup"><gx:Track/></kml>';
const result = repairKmlRootNamespaces(input);
assert.equal(result, input.replace(' xmlns:gx="dup"', ''));
assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/);
const xsi = '<kml note="xmlns:xsi=\'x\'"><Placemark xsi:type="t"/></kml>';
assert.match(
repairKmlRootNamespaces(xsi),
/note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/,
);
});
+1 -1
View File
@@ -1,5 +1,5 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
+2 -3
View File
@@ -35,14 +35,14 @@
import { PbfReader } from 'pbf';
/** VehiclePosition.VehicleStopStatus enum → label. */
export const VEHICLE_STOP_STATUS = Object.freeze({
const VEHICLE_STOP_STATUS = Object.freeze({
0: 'INCOMING_AT',
1: 'STOPPED_AT',
2: 'IN_TRANSIT_TO',
});
/** VehiclePosition.OccupancyStatus enum → label. */
export const OCCUPANCY_STATUS = Object.freeze({
const OCCUPANCY_STATUS = Object.freeze({
0: 'EMPTY',
1: 'MANY_SEATS_AVAILABLE',
2: 'FEW_SEATS_AVAILABLE',
@@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000;
export const GTFS_MAX_STRING_CHARS = 256;
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
/**
* Read a length-delimited string, refusing one that is implausibly long.
+19
View File
@@ -0,0 +1,19 @@
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
// Only a negative remainder takes the extra turn, so a positive bearing
// keeps its precision (0.49999999999999 must not become 360.5).
const remainder = value % 360;
const wrapped = remainder < 0 ? remainder + 360 : remainder;
return wrapped === 360 ? 0 : wrapped;
}
/**
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
* and negative or multi-turn values wrap. Null when the value is not finite,
* so each caller keeps its own placeholder.
*/
export function roundHeadingDegrees(value) {
if (!Number.isFinite(value)) return null;
return Math.round(normalizeHeading(value)) % 360;
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
assert.equal(normalizeHeading(370), 10);
assert.equal(normalizeHeading(-10), 350);
assert.equal(normalizeHeading(720), 0);
assert.equal(normalizeHeading(Number.NaN), 0);
assert.equal(normalizeHeading(Infinity), 0);
});
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
assert.equal(roundHeadingDegrees(359.4), 359);
assert.equal(roundHeadingDegrees(359.5), 0);
assert.equal(roundHeadingDegrees(359.6), 0);
assert.equal(roundHeadingDegrees(360), 0);
assert.equal(roundHeadingDegrees(0), 0);
assert.equal(roundHeadingDegrees(194.2), 194);
});
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
assert.equal(roundHeadingDegrees(-0.6), 359);
assert.equal(roundHeadingDegrees(-1), 359);
assert.equal(roundHeadingDegrees(719.6), 0);
assert.equal(roundHeadingDegrees(-360.6), 359);
});
test('roundHeadingDegrees returns null for a value that is not finite', () => {
for (const value of [
Number.NaN,
Infinity,
-Infinity,
null,
undefined,
'12',
]) {
assert.equal(roundHeadingDegrees(value), null);
}
});
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
assert.equal(normalizeHeading(-1e-15) < 360, true);
});
+4 -1
View File
@@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => {
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
[
'src/layers/vessels/selection.js',
'pickContact(viewer.scene, click.position)',
],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
-46
View File
@@ -1,46 +0,0 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
-42
View File
@@ -1,42 +0,0 @@
// src/data/issPass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch
test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 });
assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Independent re-check: elevation at the reported peak really is the max-ish
// and ≥ threshold; one minute before rise it is below threshold.
const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg);
assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5);
const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg);
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
assert.equal(pass, null);
});
+6 -21
View File
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
* out-of-grammar ID is rejected outright, because half an address is a
* DIFFERENT aircraft, not a shorter name for the same one.
*/
const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/;
/**
* Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* complete reserved one-character space plus every two-character base-36
@@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) {
});
}
function stringOption(key, token, defaultValue) {
return Object.freeze({
key,
token,
defaultValue,
normalize: (value) => {
if (typeof value === 'number' && Number.isFinite(value))
return String(value).trim().toLowerCase();
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return normalized ? normalized : null;
},
encode: (value) => String(value),
decode: (value) =>
typeof value === 'string' && value.trim()
? value.trim().toLowerCase()
: null,
});
}
function enumOption(
key,
token,
@@ -675,6 +655,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'x',
disposition: 'enabled-only',
}),
Object.freeze({
id: 's2-ghostmaps',
token: '5',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'satellites',
token: 's',
+159 -18
View File
@@ -1,6 +1,6 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 31);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
assert.equal(REGISTERED_LAYER_IDS.length, 32);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '5');
assert.equal(nextLayerStateToken(), '6');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '5',
bravo: '6',
alpha: '6',
bravo: '7',
}),
'7',
'8',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
[...'6789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '5',
next: '6',
future: '6',
next: '7',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 5/,
/next free token 6/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' },
),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -734,18 +734,27 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity',
);
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
);
assert.match(
html,
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
);
assert.match(
ui,
/this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
'ui.js: and keeps aria-pressed synchronized, so the lit state is not colour-only',
/this\._models3dPressedShown = this\._models3dEnabled;/,
'ui.js: and keeps the pressed state synchronized, so the lit state is not colour-only',
);
const panel = await readFile(
new URL('../ui/views/panels/DisplayPanel.vue', import.meta.url),
'utf8',
);
assert.match(
panel,
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
);
assert.match(
html,
@@ -2858,3 +2867,135 @@ test('explicit provider switches reach durable state without adopting unrelated
coordinator.destroy();
}
});
function enabledLayerParam(sink) {
return new URLSearchParams(encode(sink.provider())).get('l');
}
test('Clear All drops a saved layer a context mode had already switched off', async () => {
const manager = productionManager();
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
// Context isolation is a temporary, passive takeover: the saved choice stays.
await manager.setEnabled('earthquakes', false, { origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
'flights',
]);
const updatesBefore = sink.updates;
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(result.targetIds, ['flights']);
assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh');
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers');
assert.deepEqual(
parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY))
.enabledLayerIds,
[],
'a reload restores nothing',
);
coordinator.destroy();
});
test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => {
let manager;
manager = productionManager({
// Contacts-style owner: its own teardown releases a borrowed dependency
// without awaiting it, which supersedes Clear All's turn for that layer.
'military-awareness': {
disable() {
void manager.setEnabled('flights', false, { origin: 'programmatic' });
return true;
},
},
});
const storage = memoryStorage();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('military-awareness', true, { origin: 'user' });
const result = await manager.clearSelectedLayers({ origin: 'user' });
assert.equal(
result.items.find((item) => item.id === 'flights')?.superseded,
true,
'the fixture reproduces the superseded flights turn',
);
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
test('Clear All keeps a layer a newer explicit request turned back on', async () => {
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
});
await coordinator.start();
await manager.setEnabled('earthquakes', true, { origin: 'user' });
await manager.setEnabled('flights', true, { origin: 'user' });
const clearing = manager.clearSelectedLayers({ origin: 'user' });
// A direct request issued during the batch owns its layer.
const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' });
await Promise.all([clearing, reenable]);
assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']);
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
'earthquakes',
]);
assert.equal(enabledLayerParam(sink), 'e');
coordinator.destroy();
});
test('a passive clear never rewrites saved layers', async () => {
const manager = productionManager();
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
await coordinator.start();
await manager.setEnabled('flights', true, { origin: 'user' });
await manager.setEnabled('flights', false, { origin: 'programmatic' });
const writes = storage.writes.length;
await manager.clearSelectedLayers({ origin: 'programmatic' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']);
assert.equal(storage.writes.length, writes);
coordinator.destroy();
});
test('Clear All cancels a saved restore still waiting to start', async () => {
const gate = deferred();
const manager = productionManager();
const sink = shareSink();
const coordinator = new LayerStateCoordinator(manager, sink, {
storage: memoryStorage(),
restoreGate: gate.promise,
});
const params = new URLSearchParams('v=2&l=e.f');
const restore = coordinator.start({
shareLayerState: decodeLayerStateParams(params),
});
await manager.clearSelectedLayers({ origin: 'user' });
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []);
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
+67 -5
View File
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
return this.restorePromise;
}
/**
* Replace the running layer state with a share opened later in this tab
* (hash navigation). Every in-flight restore and pending shared Follow is
* superseded; the new state restores through the same passive share origin
* as start-up, so recipient-local preferences are never written. A share
* without a layer payload restores the defaults, as a reload of it would.
*/
applyShareLayerState({
shareLayerState = null,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed) return Promise.resolve([]);
for (const controller of this._restoreControllers.values())
controller.abort('superseded-by-share-navigation');
this._revokePendingTrackingWatch('superseded-by-share-navigation');
const selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
this._source = selected ? 'share' : 'legacy-share';
this._shareCreatedAtMs =
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
this.restorePromise = this._restoreSelectedState(
LAYER_RESTORE_ORIGINS.share,
);
return this.restorePromise;
}
get source() {
return this._source;
}
@@ -220,6 +250,30 @@ export class LayerStateCoordinator {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'selection-cleared') {
// Only layers a newer request turned back on survive Clear All. Any
// other saved layer would reappear from the share link or a reload.
const stillEnabled = new Set(change.enabledLayerIds || []);
const dropped = this._durableState.enabledLayerIds.filter(
(id) => !stillEnabled.has(id),
);
if (dropped.length === 0) return;
for (const layerId of dropped) {
this._restoreControllers
.get(layerId)
?.abort('superseded-by-explicit-visibility');
}
if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
this._commitExplicit({
...this._durableState,
enabledLayerIds: this._durableState.enabledLayerIds.filter((id) =>
stillEnabled.has(id),
),
});
return;
}
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
@@ -312,15 +366,19 @@ export class LayerStateCoordinator {
}
async _restoreSelectedState(origin) {
// One controller set per run: a superseded run must not clear the newer
// run's controllers when it settles.
const controllers = new Map();
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
controllers.set(entry.id, new AbortController());
}
this._restoreControllers = controllers;
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const controller = controllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
@@ -370,7 +428,7 @@ export class LayerStateCoordinator {
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
const results = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
@@ -387,9 +445,13 @@ export class LayerStateCoordinator {
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
// A run superseded by a newer share keeps the newer run's results.
if (this._restoreControllers === controllers)
this.lastRestoreResults = results;
return results.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
if (this._restoreControllers === controllers)
this._restoreControllers.clear();
this._notifyDurableState();
}
}
+29 -28
View File
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'street-level': '0',
'gnss-integrity': '3',
'air-quality': '4',
's2-ghostmaps': '5',
},
);
assert.throws(
@@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
parseLayerStateTokenReservations([...reservationRows, ['future', '6']])
.future,
'5',
'6',
);
assert.throws(
() =>
@@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '5']] },
{ rows: [...reservationRows, ['retired-layer', '6']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
assert.equal(nextLayerStateToken(published), '7');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '6',
newcomer: '7',
}),
true,
);
@@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '5',
competing: '6',
}),
/next free token 6/,
/next free token 7/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '5',
newcomer: '6',
}),
/changed or removed: retired-layer/,
);
@@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '5']],
[{ id: 'pr-b', token: '5' }],
[...reservationRows, ['pr-b', '6']],
[{ id: 'pr-b', token: '6' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '6']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '5' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '6' },
],
);
const staleRebase = runCheck();
@@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '6');
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
assert.equal(nextLayerStateToken(published), '7');
const validBaselineRows = [...reservationRows, ['pr-a', '6']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 6/);
assert.match(helper.stdout, /pr-b: 7/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
[...reservationRows, ['pr-a', '6'], ['pr-b', '7']],
[
{ id: 'pr-a', token: '5' },
{ id: 'pr-b', token: '6' },
{ id: 'pr-a', token: '6' },
{ id: 'pr-b', token: '7' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /32 published, 1 new/);
assert.match(corrected.stdout, /33 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'56789'].map((digit) => [
const priorDigits = [...'6789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 73);
assert.equal(params.get('l').length, 75);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 32);
assert.equal(Object.keys(published).length, 33);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -20,6 +20,7 @@
["radio", "r"],
["recent-imagery", "1"],
["rocket-launches", "x"],
["s2-ghostmaps", "5"],
["satellites", "s"],
["street-level", "0"],
["telegeography-submarine-cables", "u"],
+69 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/**
* LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/
export class LayerLifecycle {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
{
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
}
}
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval
: 0;
if (refreshInterval > 0) {
entry.intervalId = setInterval(() => {
const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval);
};
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) {
entry.intervalId = setInterval(() => {
if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle();
if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
this._publishActivity({ type: 'status' });
if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
}
if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null;
const cancelEnable = () => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
await abortCleanup;
let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
};
const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
let cleanupConfirmed = false;
try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling.
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Manager-owned periodic refresh work has one normalized loading/error
@@ -1606,6 +1650,15 @@ export class LayerLifecycle {
}
}
}
// Clear All owns the whole selection, not only the layers it switched off
// itself. A layer already off for a passive reason (isolated by a context
// mode, released by a dependency owner, or superseded mid-batch) gets no
// OFF of its own here, so publish the set that is still on afterwards.
this._notifyListeners({
type: 'selection-cleared',
origin,
enabledLayerIds: [...this.getEnabledLayerIds()],
});
const items = targetIds.map((id) => {
const state = this.getLayerLifecycleState(id) || {
enabled: false,
@@ -2158,8 +2211,7 @@ export class LayerLifecycle {
return false;
}
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
}
+12 -1
View File
@@ -2,7 +2,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
mapAnalystRecord,
} from './localGeojsonCore.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost,
});
const DC_RAW = {
id: '1176042553',
-30
View File
@@ -1,30 +0,0 @@
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayPublisher as createPublisher,
} from './localGeojsonCore.js';
export * from './localGeojsonCore.js';
/** Create a layer using the standalone app's operations; retain overlay overrides. */
export function createLocalGeoJsonLayer(options) {
return createLayer(options, {
...localGeoJsonServices,
overlayHost:
options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
});
}
/** Create an overlay publisher using the standalone app's host by default. */
export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({
...options,
host:
options.host === undefined
? localGeoJsonServices.overlayHost
: options.host,
});
}
+10 -13
View File
@@ -6,13 +6,14 @@ import {
GROUND_SAMPLE_MAX_ARMED_RETRIES,
LOCAL_OVERLAY_COHORT_LIMIT,
LOCAL_STEM_TIP_EPSILON_M,
createLocalGeoJsonLayer,
createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayEntry,
createLocalInfrastructureOverlayPublisher,
localDatasetError,
localInfrastructureOverlayCopy,
selectLocalInfrastructureOverlayCohort,
} from './localGeojson.js';
} from './localGeojsonCore.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import { layerFeedState } from './manager.js';
import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js';
import {
@@ -21,6 +22,13 @@ import {
_resetRenderGovernorForTest,
} from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
@@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
});
test('standalone publisher retains its default host for an undefined override', () => {
const publisher = createLocalInfrastructureOverlayPublisher({
sourceId: 'local-default-host-test', host: undefined,
});
assert.doesNotThrow(() => {
publisher.show();
publisher.hide();
publisher.destroy();
});
});
+9 -1
View File
@@ -1,9 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer } from './localGeojson.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js';
import { getContextStore } from './contextStore.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost,
});
const dataset = JSON.stringify({
type: 'Feature', id: 'dam', properties: { name: 'Test dam' },
geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] },
+4 -4
View File
@@ -5,7 +5,7 @@
* Pure selection policy for the bundled local-infrastructure layers
* (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer`
* dataset). Cesium-specific projection, occlusion, and stem geometry stay in
* `localGeojson.js`; this module turns the resulting in-view records into a
* `localGeojsonCore.js`; this module turns the resulting in-view records into a
* bounded "active" set — the only records that layer should spend per-frame
* stem-geometry and ground-sample work on.
*
@@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250;
/**
* Distance at or beyond which the proximity penalty is fully applied (m).
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js.
* Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js.
*/
export const INFRA_LOD_FAR_M = 12_000_000;
@@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200;
* (callers only compare it), but every input is normalized so the output is
* always a finite number.
*
* @param {number} priority Existing label-priority score (localGeojson.js).
* @param {number} priority Existing label-priority score (localGeojsonCore.js).
* @param {number} distanceM Camera→feature distance in metres.
* @param {boolean} isIncumbent Record currently holds an active stem.
* @param {object} [options]
@@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({
* ------------------------------------------------------------------ */
/**
* Probe window for the motion fallback. `localGeojson.js` marks its selection
* Probe window for the motion fallback. `localGeojsonCore.js` marks its selection
* dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice
* `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route
* flight, or a continuous orbit never emits one. Without a fallback the
-18
View File
@@ -1,18 +0,0 @@
import { localGeoJsonServices } from './localGeojson.js';
import { createInfrastructureLayers } from './infrastructure.js';
import { createFirmsHeatmapLayer } from './firmsHeatmap.js';
import submarineCablesLayer from './telegeographySubmarineCables.js';
const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices);
// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps
// the historical `local-` prefix for persistence + voice-tool-enum compat,
// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side.
const fires = createFirmsHeatmapLayer({
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
source: 'NASA FIRMS · LIVE',
});
export default [datacenters, dams, submarineCablesLayer, fires];
+1 -2
View File
@@ -14,5 +14,4 @@ this derived database.
The public-release snapshot removes contact-oriented fields and note values
that contain email or phone identifiers. The runtime layer does not display or
depend on those fields. Both `dams.geojson` and the runtime JSONL form carry the
same privacy transform.
depend on those fields.
-1
View File
@@ -1 +0,0 @@
{"type":"FeatureCollection","features":[{"id":-19953989,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[14.292076717,46.16897504],[14.292076711,46.168976415],[14.292080818,46.169315044],[14.292082791,46.169327497],[14.292088326,46.169339424],[14.292097199,46.169350341],[14.292109051,46.169359805],[14.2921234,46.169367433],[14.292139665,46.169372916],[14.292454909,46.16945313],[14.292472725,46.169456276],[14.292491094,46.169456843],[14.292509244,46.169454808],[14.293490178,46.169271915],[14.293505685,46.169267932],[14.293519874,46.169262048],[14.293532266,46.169254461],[14.293542439,46.16924543],[14.293550049,46.169235261],[14.293554837,46.169224298],[14.293556643,46.169212912],[14.293555403,46.16920149],[14.293552972,46.169191516],[14.293548058,46.169179216],[14.293539623,46.169167869],[14.293528021,46.169157953],[14.293513742,46.169149885],[14.293497386,46.169144005],[14.293479642,46.16914056],[14.29260928,46.169036741],[14.292221224,46.168859348],[14.292205414,46.168853594],[14.292188274,46.16885012],[14.29217048,46.168849063],[14.292152735,46.168850464],[14.292135742,46.168854269],[14.292120171,46.168860327],[14.292106638,46.168868398],[14.292095679,46.168878164],[14.292087726,46.168889239],[14.292083093,46.168901184],[14.292078818,46.168919205],[14.292077639,46.168928437],[14.292076717,46.16897504]]]},"properties":{"name":"HE Podlubnik - Centrala","tags":{"name":"HE Podlubnik - Centrala","type":"site","power":"plant","alt_name":"HE Škofja Loka","operator":"Elektro Gorenjska","waterway":"dam","start_date":"1969","plant:method":"run-of-the-river","plant:source":"hydro","plant:output:electricity":"330KW"},"osm_id":-19953989,"output":"330KW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19754263,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[69.715342348,34.641127032],[69.71550043,34.641135916],[69.715570922,34.64110658],[69.717521056,34.641462978],[69.71812514,34.641577224],[69.718373076,34.641638579],[69.718438371,34.641451327],[69.718438036,34.641377064],[69.718220274,34.641368766],[69.718144586,34.64121412],[69.718014247,34.640948246],[69.717587357,34.640673654],[69.71740899,34.64063493],[69.717223833,34.640595284],[69.716953182,34.640548261],[69.716766517,34.640531246],[69.716362928,34.640542142],[69.716212976,34.640950425],[69.715856829,34.640920669],[69.715476123,34.640943636],[69.715342348,34.641127032]]]},"properties":{"name":"نغلو برېښناکوټ","tags":{"name":"نغلو برېښناکوټ","type":"multipolygon","power":"plant","name:de":"Naghlu-Staudamm","name:en":"Naghlu Dam","name:ja":"ナグルーダム","name:no":"Naghludemningen","name:ru":"ГЭС Наглу","name:uk":"ГЕС Наглу","waterway":"dam","wikidata":"Q4755783","wikipedia":"en:Naghlu Dam","start_date":"1967","plant:source":"hydro","plant:output:electricity":"100 MW"},"osm_id":-19754263,"output":"100 MW","source":"hydro","construction":"","source_layer":"power_plant"}},{"id":-19685440,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-54.597119846,-25.41383667],[-54.597234845,-25.413703733],[-54.597107524,-25.413586135],[-54.59730056,-25.412625569],[-54.597341379,-25.412122152],[-54.597361664,-25.411634241],[-54.597330064,-25.410920019],[-54.597191679,-25.410193224],[-54.597057568,-25.409737668],[-54.59698146,-25.409498365],[-54.596806446,-25.409085472],[-54.596524311,-25.408510306],[-54.596161124,-25.407928853],[-54.59586826,-25.40753591],[-54.595651001,-25.407280513],[-54.595510101,-25.407146654],[-54.595350007,-25.407013549],[-54.595191002,-25.406910368],[-54.595019173,-25.406815317],[-54.594639221,-25.40665405],[-54.594135721,-25.406544833],[-54.593702209,-25.406530333],[-54.593262159,-25.406579954],[-54.592841471,-25.406631754],[-54.592785815,-25.406430001],[-54.588964338,-25.407187725],[-54.584364349,-25.408041757],[-54.584397877,-25.408225908],[-54.58083188,-25.40887408],[-54.580662817,-25.408794033],[-54.578582764,-25.409343216],[-54.577176783,-25.409921902],[-54.574835215,-25.411356968],[-54.573420266,-25.412660605],[-54.56995393,-25.41756251],[-54.565822741,-25.423818762],[-54.564863516,-25.426166114],[-54.561372454,-25.436599237],[-54.55977612,-25.441166117],[-54.559956834,-25.441255635],[-54.560411552,-25.441039718],[-54.561530369,-25.438307217],[-54.56325352,-25.43362584],[-54.564224731,-25.430147434],[-54.564559924,-25.42936314],[-54.564880364,-25.429084861],[-54.565817042,-25.428049779],[-54.566482565,-25.427226006],[-54.567271218,-25.426135353],[-54.568109324,-25.425411827],[-54.569107525,-25.423653136],[-54.568448121,-25.422704724],[-54.568378384,-25.422578744],[-54.568555074,-25.42190861],[-54.568801502,-25.421307544],[-54.569317157,-25.42054873],[-54.570266659,-25.419124226],[-54.57099622,-25.417694944],[-54.571731145,-25.416653241],[-54.572525079,-25.415601815],[-54.573302919,-25.414637645],[-54.573759146,-25.414053929],[-54.574652741,-25.413023961],[-54.574928338,-25.412760769],[-54.575345505,-25.412317702],[-54.57570995,-25.4Line truncated
+1 -4
View File
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
/** Compatibility facade for callers that construct a manager with its panel. */
export class DataLayerManager extends LayerLifecycle {
constructor(viewer, options) {
super(viewer, options);
super(viewer, { visibilityTarget: globalThis.document, ...options });
this._presentation = new LayerPresentation(this);
}
buildTogglePanel(container) {
@@ -28,9 +28,6 @@ export class DataLayerManager extends LayerLifecycle {
_buildMetaText(layer) {
return this._presentation.panel._buildMetaText(layer);
}
_syncToggleButton(button, layer) {
return this._presentation.panel._syncToggleButton(button, layer);
}
get _layerPanel() {
return this._presentation._panel;
}
+97 -824
View File
@@ -12,6 +12,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager, layerFeedState } from './manager.js';
import { presentLayerToggle } from '../ui/layerPanel.js';
import {
contextSnapshotLayerIds,
shouldCaptureContextSession,
@@ -90,68 +91,6 @@ test('adopts settled visibility without re-running lifecycle work', async () =>
assert.equal(manager.adoptLayerVisibility('flights', false, { origin: 'user' }), false);
});
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async () => {
const originalDocument = globalThis.document;
const makeElement = () => {
const element = {
children: [],
className: '',
dataset: {},
textContent: '',
disabled: false,
attributes: {},
classList: {
toggle() {},
},
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
removeEventListener() {},
setAttribute(name, value) { this.attributes[name] = String(value); },
querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) {
const id = selector.slice(16, -2);
return this.children.find((child) => child.dataset.layerId === id) || null;
}
const className = selector.startsWith('.') ? selector.slice(1) : '';
const visit = (node) => {
if (String(node.className).split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = visit(child);
if (found) return found;
}
return null;
};
return visit(this);
},
set innerHTML(value) { if (value === '') this.children = []; },
get innerHTML() { return ''; },
};
return element;
};
globalThis.document = { createElement: makeElement };
const mgr = new DataLayerManager({});
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
const coordinator = makeSlowLayer('military-awareness', { updateInterval: -1 });
coordinator.module.showInTogglePanel = false;
mgr.register(ordinary.module);
mgr.register(coordinator.module);
const container = makeElement();
try {
mgr.buildTogglePanel(container);
assert.ok(container.querySelector('[data-layer-id="flights"]'));
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
assert.equal(await mgr.setEnabled('military-awareness', true), true);
mgr._refreshTogglePanel();
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
assert.equal(mgr.getAll().find(({ id }) => id === 'military-awareness').enabled, true);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('clearSelectedLayers includes hidden coordinators and preserves newer dependency restoration', async () => {
const mgr = new DataLayerManager({});
const order = [];
@@ -1861,17 +1800,6 @@ test('layer metadata names degraded state instead of presenting an ordinary age'
test('uncertain lifecycle state overrides ordinary feed status without disabling reconciliation', () => {
const mgr = new DataLayerManager({});
const classes = new Map();
const attributes = new Map();
const button = {
classList: {
toggle(name, active) { classes.set(name, Boolean(active)); },
},
dataset: {},
disabled: false,
textContent: '',
setAttribute(name, value) { attributes.set(name, String(value)); },
};
const layer = {
name: 'Radio',
source: 'Radio Browser',
@@ -1881,14 +1809,14 @@ test('uncertain lifecycle state overrides ordinary feed status without disabling
stats: { count: 750, lastUpdate: Date.now() },
};
mgr._syncToggleButton(button, layer);
const toggle = presentLayerToggle(layer);
assert.equal(button.textContent, 'UNCERTAIN');
assert.equal(button.dataset.feedState, 'uncertain');
assert.equal(button.disabled, false, 'the lifecycle toggle remains available to reconcile authority');
assert.equal(attributes.get('aria-label'), 'Radio: UNCERTAIN');
assert.equal(classes.get('lifecycle-uncertain'), true);
assert.equal(classes.get('feed-nominal'), false);
assert.equal(toggle.text, 'UNCERTAIN');
assert.equal(toggle.feedState, 'uncertain');
assert.equal(toggle.ariaDisabled, false, 'the lifecycle toggle remains available to reconcile authority');
assert.equal(toggle.ariaLabel, 'Radio: UNCERTAIN');
assert.equal(toggle.classes['lifecycle-uncertain'], true);
assert.equal(toggle.classes['feed-nominal'], false);
assert.equal(
mgr._buildMetaText(layer),
'UNCERTAIN · Radio Browser · lifecycle state requires reconciliation',
@@ -2729,549 +2657,6 @@ test('destroy settles an explicit refresh waiting behind invalidated periodic wo
assert.equal(await destroy, true);
});
// ── Per-layer row controls (chips + color legend) ───────────────────────────
// The satellites layer is the first consumer: a DENSE catalog chip and a class
// legend rendered under its row. The manager owns the DOM and the param write;
// the layer only declares what it wants, so the chip can never disagree with
// the layer's real state.
/** DOM double rich enough for the row-controls render path. */
function makeControlElement() {
const element = {
children: [],
className: '',
dataset: {},
style: {},
attributes: {},
listeners: {},
textContent: '',
hidden: false,
disabled: false,
title: '',
type: '',
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
if (siblings) this.parent.children = siblings.filter((n) => n !== this);
// Browsers blur a node the moment it leaves the document. Modelling that
// is the whole point: it is exactly what in-place chip reconciliation
// exists to avoid, so a regression to rebuild-everything must fail here.
if (globalThis.document?.activeElement === this) globalThis.document.activeElement = null;
},
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
const className = selector.slice(1);
return String(this.className).split(/\s+/).includes(className) ? this : null;
},
querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) {
const id = selector.slice(16, -2);
return this.children.find((child) => child.dataset.layerId === id) || null;
}
const className = selector.startsWith('.') ? selector.slice(1) : '';
const visit = (node) => {
if (String(node.className).split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = visit(child);
if (found) return found;
}
return null;
};
return visit(this);
},
set innerHTML(value) { if (value === '') this.children = []; },
get innerHTML() { return ''; },
};
return element;
}
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => { releaseEnable = resolve; });
},
disable() {
calls.disable += 1;
return new Promise((resolve) => { releaseDisable = resolve; });
},
update() {},
destroy() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
});
const container = makeControlElement();
const waitFor = async (predicate, label) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
assert.fail(`timed out waiting for ${label}`);
};
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="focus-layer"]');
const button = row.querySelector('.data-toggle-btn');
button.focus();
const enablingClick = button.listeners.click();
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
await button.listeners.click();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await enablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), true);
const disablingClick = button.listeners.click();
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
await button.listeners.click();
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await disablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), false);
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
const originalDocument = globalThis.document;
const originalWarn = console.warn;
globalThis.document = { createElement: makeControlElement, activeElement: null };
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => { rejectEnable = reject; });
},
disable() { return true; },
update() {},
destroy() {},
getStats() { return { count: 0, lastUpdate: null }; },
});
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const button = container
.querySelector('[data-layer-id="focus-failure-layer"]')
.querySelector('.data-toggle-btn');
button.focus();
const failedClick = button.listeners.click();
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
await Promise.resolve();
}
assert.equal(typeof rejectEnable, 'function');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await failedClick;
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
await button.listeners.click();
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assert.equal(globalThis.document.activeElement, button);
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
/** Collect every node in a rendered subtree carrying `className`. */
function collectByClass(node, className) {
const found = [];
const visit = (current) => {
if (String(current.className).split(/\s+/).includes(className)) found.push(current);
for (const child of current.children || []) visit(child);
};
visit(node);
return found;
}
/** A layer that declares a two-state chip plus a legend, like satellites. */
function makeRowControlLayer() {
let mode = 'core';
return {
get mode() { return mode; },
module: {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() { return { count: 3, lastUpdate: Date.now() }; },
setParams(params) { if (params.catalog) mode = params.catalog; },
getParams() { return { catalog: mode }; },
getRowControls() {
const dense = mode === 'dense';
return {
chips: [{
id: 'catalog',
label: 'DENSE',
active: dense,
title: 'toggle the dense catalog',
params: { catalog: dense ? 'core' : 'dense' },
}],
legend: [
{ klass: 'nav', label: 'NAV', color: '#4fd8ff', blurb: 'GNSS', count: 2 },
{ klass: 'geo', label: 'GEO', color: '#c89bff', blurb: 'belt', count: 5 },
],
};
},
},
};
}
test('a layer that declares row controls renders its chips and color legend', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
assert.ok(controls, 'the row gained a controls block');
// Disabled layers stay quiet — no chip, no legend.
assert.equal(controls.hidden, true);
assert.equal(await mgr.setEnabled('satellites', true), true);
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false);
const chips = collectByClass(controls, 'data-toggle-chip');
assert.equal(chips.length, 1);
assert.equal(chips[0].textContent, 'DENSE');
assert.equal(chips[0].dataset.chipId, 'catalog');
assert.equal(chips[0].attributes['aria-pressed'], 'false');
assert.equal(chips[0].title, 'toggle the dense catalog');
const swatches = collectByClass(controls, 'data-toggle-legend-swatch');
assert.deepEqual(swatches.map((s) => s.style.background), ['#4fd8ff', '#c89bff'],
'each legend swatch is painted the exact class color');
const items = collectByClass(controls, 'data-toggle-legend-item');
assert.deepEqual(items.map((i) => i.title), ['GNSS', 'belt']);
assert.equal(items.length, 2);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('clicking a row chip applies the params it declared and re-renders', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
// setLayerParams refreshes the panel, so the chip already reflects the flip.
const afterOn = collectByClass(controls, 'data-toggle-chip')[0];
assert.equal(afterOn.attributes['aria-pressed'], 'true');
assert.equal(afterOn.className.includes('active'), true);
controls.listeners.click({ target: afterOn });
assert.equal(layer.mode, 'core', 'the chip toggles back rather than latching');
assert.equal(collectByClass(controls, 'data-toggle-chip')[0].attributes['aria-pressed'], 'false');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const warn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const row = container.querySelector('[data-layer-id="satellites"]');
const controls = row.querySelector('.data-toggle-controls');
const legendItem = collectByClass(controls, 'data-toggle-legend-item')[0];
controls.listeners.click({ target: legendItem });
assert.equal(layer.mode, 'core', 'the legend is not a control');
controls.listeners.click({ target: { closest: () => null } });
assert.equal(layer.mode, 'core');
layer.module.getRowControls = () => { throw new Error('boom'); };
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true, 'a throwing layer collapses to an empty block');
assert.ok(container.querySelector('[data-layer-id="satellites"]'), 'the row itself survives');
} finally {
console.warn = warn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('keyboard focus on a chip survives the refresh its own click triggers', async () => {
// _syncRowControls runs on EVERY panel refresh, including the one the chip's
// own click triggers. Rebuilding the button would blur it every time, so a
// keyboard user loses their place on activation.
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
chip.focus();
assert.equal(globalThis.document.activeElement, chip, 'the chip starts focused');
controls.listeners.click({ target: chip });
assert.equal(globalThis.document.activeElement, chip,
'activating the chip does not blur it');
assert.equal(collectByClass(controls, 'data-toggle-chip')[0], chip,
'the SAME button node is reused across the click-driven refresh');
mgr._refreshTogglePanel();
mgr._refreshTogglePanel();
assert.equal(globalThis.document.activeElement, chip,
'repeated refreshes never steal focus');
// Legend entries hold no focus, so they may be replaced — never duplicated.
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 2);
// ...and a chip that genuinely goes away still releases focus.
layer.module.getRowControls = () => ({ chips: [], legend: [] });
mgr._refreshTogglePanel();
assert.equal(globalThis.document.activeElement, null);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
let settled = false;
let writes = 0;
const module = {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
setParams() { writes += 1; },
getRowControls() {
return {
chips: [{
id: 'catalog',
label: settled ? 'DENSE' : 'DENSE ···',
active: settled,
busy: !settled,
disabled: !settled,
state: settled ? 'active' : 'loading',
title: 'x',
params: { catalog: 'core' },
}],
legend: [],
};
},
setRowControlsListener(fn) { module._listener = fn; },
};
mgr.register(module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
assert.equal(typeof module._listener, 'function', 'the manager installed its listener');
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
assert.equal(chip.textContent, 'DENSE ···');
assert.equal(chip.disabled, true);
assert.equal(chip.attributes['aria-busy'], 'true');
assert.equal(chip.attributes['aria-pressed'], 'false', 'busy is never reported as active');
assert.equal(chip.className.includes('chip-loading'), true);
controls.listeners.click({ target: chip });
assert.equal(writes, 0, 'a disabled chip is inert');
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
assert.equal(chip.attributes['aria-busy'], 'false');
controls.listeners.click({ target: chip });
assert.equal(writes, 1, 'the settled chip writes again');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a layer that surrenders its row controls hides the block entirely', async () => {
// Space Missions borrows the satellite layer with showPoints:false; the row
// must not advertise a legend for an empty sky or offer a chip whose write
// the dependency owner will silently revert.
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
let surrendered = false;
const layer = makeRowControlLayer();
const inner = layer.module.getRowControls;
layer.module.getRowControls = () => (surrendered ? { chips: [], legend: [] } : inner());
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = container
.querySelector('[data-layer-id="satellites"]')
.querySelector('.data-toggle-controls');
assert.equal(controls.hidden, false);
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
surrendered = true;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true);
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 0,
'the chip is removed, not merely hidden behind a style');
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 0);
surrendered = false;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false, 'the row returns when the owner releases it');
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('layer metadata shows a guidance prompt without reporting it as a fault', () => {
const mgr = new DataLayerManager({});
const text = mgr._buildMetaText({
@@ -3283,207 +2668,6 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
});
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
manager.register(layer.module);
try {
const first = makeControlElement();
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const retainedClick = oldButton.listeners.click;
const second = makeControlElement();
manager.buildTogglePanel(second);
assert.equal(oldButton.listeners.click, undefined);
await retainedClick();
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
const currentButton = second.querySelector('.data-toggle-btn');
assert.equal(typeof currentButton.listeners.click, 'function');
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(currentButton.listeners.click, undefined);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0, blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const row = container.querySelector('[data-layer-id="satellites"]');
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(calls, 1);
blocked = true;
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
@@ -3492,3 +2676,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 });
});
+933
View File
@@ -0,0 +1,933 @@
// src/data/managerPanel.test.mjs — the manager's layer rows, rendered into a
// real DOM: toggles keep keyboard focus through transitions, row chips write
// the params they declare, and legends, info lines and lists follow the layer.
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from './manager.js';
import { assertSameNode } from '../testSupport/domAssert.mjs';
/** Build a mock layer whose init/update resolve on the next microtask. */
function makeSlowLayer(id, { updateInterval = 1000 } = {}) {
const calls = { enable: 0, disable: 0, update: 0, init: 0 };
return {
calls,
module: {
id,
name: id,
icon: '',
source: 'test',
updateInterval,
async init() {
calls.init++;
await Promise.resolve();
},
enable() {
calls.enable++;
},
disable() {
calls.disable++;
},
async update() {
calls.update++;
await Promise.resolve();
},
getStats() {
return { count: 0, lastUpdate: null };
},
},
};
}
/** A layer that declares a two-state chip plus a legend, like satellites. */
function makeRowControlLayer() {
let mode = 'core';
return {
get mode() {
return mode;
},
module: {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() {
return { count: 3, lastUpdate: Date.now() };
},
setParams(params) {
if (params.catalog) mode = params.catalog;
},
getParams() {
return { catalog: mode };
},
getRowControls() {
const dense = mode === 'dense';
return {
chips: [
{
id: 'catalog',
label: 'DENSE',
active: dense,
title: 'toggle the dense catalog',
params: { catalog: dense ? 'core' : 'dense' },
},
],
legend: [
{
klass: 'nav',
label: 'NAV',
color: '#4fd8ff',
blurb: 'GNSS',
count: 2,
},
{
klass: 'geo',
label: 'GEO',
color: '#c89bff',
blurb: 'belt',
count: 5,
},
],
};
},
},
};
}
function panelHost(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.id = 'data-toggles';
t.after(() => document.body.replaceChildren());
return container;
}
const row = (container, id) =>
container.querySelector(`[data-layer-id="${id}"]`);
const chipsIn = (node) => [...node.querySelectorAll('.data-toggle-chip')];
const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitFor(predicate, label) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (predicate()) return;
await macrotask();
}
assert.fail(`timed out waiting for ${label}`);
}
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
const coordinator = makeSlowLayer('military-awareness', {
updateInterval: -1,
});
coordinator.module.showInTogglePanel = false;
mgr.register(ordinary.module);
mgr.register(coordinator.module);
try {
mgr.buildTogglePanel(container);
assert.ok(row(container, 'flights'));
assert.ok(!row(container, 'military-awareness'));
assert.equal(await mgr.setEnabled('military-awareness', true), true);
mgr._refreshTogglePanel();
assert.ok(!row(container, 'military-awareness'));
assert.equal(
mgr.getAll().find(({ id }) => id === 'military-awareness').enabled,
true,
);
} finally {
await mgr.destroyAll();
}
});
test('keyboard focus survives Data Layer enabling and disabling transitions', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => {
releaseEnable = resolve;
});
},
disable() {
calls.disable += 1;
return new Promise((resolve) => {
releaseDisable = resolve;
});
},
update() {},
destroy() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
});
try {
mgr.buildTogglePanel(container);
const button = row(container, 'focus-layer').querySelector(
'.data-toggle-btn',
);
button.focus();
button.click();
await waitFor(
() => typeof releaseEnable === 'function',
'enable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
button.click();
await macrotask();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await waitFor(
() =>
mgr.isEnabled('focus-layer') &&
button.getAttribute('aria-busy') === 'false',
'enabled',
);
assertSameNode(document.activeElement, button);
assertSameNode(
row(container, 'focus-layer').querySelector('.data-toggle-btn'),
button,
'the SAME button node is reused across the transition',
);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
button.click();
await waitFor(
() => typeof releaseDisable === 'function',
'disable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(
button.disabled,
false,
'DISABLING never uses native disabled',
);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
button.click();
await macrotask();
assert.equal(
calls.disable,
1,
'repeat activation is inert while disabling',
);
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await waitFor(
() =>
!mgr.isEnabled('focus-layer') &&
button.getAttribute('aria-busy') === 'false',
'disabled',
);
assertSameNode(document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async (t) => {
const container = panelHost(t);
const originalWarn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => {
rejectEnable = reject;
});
},
disable() {
return true;
},
update() {},
destroy() {},
getStats() {
return { count: 0, lastUpdate: null };
},
});
try {
mgr.buildTogglePanel(container);
const button = row(container, 'focus-failure-layer').querySelector(
'.data-toggle-btn',
);
button.focus();
button.click();
await waitFor(
() => typeof rejectEnable === 'function',
'enable transition',
);
assertSameNode(document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await waitFor(
() => button.getAttribute('aria-busy') === 'false',
'failure settlement',
);
assertSameNode(
document.activeElement,
button,
'failure settlement preserves focus',
);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
button.click();
await waitFor(() => mgr.isEnabled('focus-failure-layer'), 'retry');
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assertSameNode(document.activeElement, button);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
}
});
test('a layer that declares row controls renders its chips and color legend', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
assert.ok(controls, 'the row gained a controls block');
// Disabled layers stay quiet — no chip, no legend.
assert.equal(controls.hidden, true);
assert.equal(await mgr.setEnabled('satellites', true), true);
mgr._refreshTogglePanel();
assert.equal(controls.hidden, false);
const chips = chipsIn(controls);
assert.equal(chips.length, 1);
assert.equal(chips[0].textContent, 'DENSE');
assert.equal(chips[0].dataset.chipId, 'catalog');
assert.equal(chips[0].getAttribute('aria-pressed'), 'false');
assert.equal(chips[0].title, 'toggle the dense catalog');
const swatches = [
...controls.querySelectorAll('.data-toggle-legend-swatch'),
];
assert.deepEqual(
swatches.map((s) => s.style.background),
['#4fd8ff', '#c89bff'],
'each legend swatch is painted the exact class color',
);
const items = [...controls.querySelectorAll('.data-toggle-legend-item')];
assert.deepEqual(
items.map((i) => i.title),
['GNSS', 'belt'],
);
assert.deepEqual(
items.map((i) => i.textContent),
['NAV 2', 'GEO 5'],
);
} finally {
await mgr.destroyAll();
}
});
test('clicking a row chip applies the params it declared and re-renders', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
chip.click();
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
// setLayerParams refreshes the panel, so the chip already reflects the flip.
assert.equal(chip.getAttribute('aria-pressed'), 'true');
assert.equal(chip.classList.contains('active'), true);
chip.click();
assert.equal(
layer.mode,
'core',
'the chip toggles back rather than latching',
);
assert.equal(chip.getAttribute('aria-pressed'), 'false');
} finally {
await mgr.destroyAll();
}
});
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async (t) => {
const container = panelHost(t);
const warn = console.warn;
console.warn = () => {};
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
controls.querySelector('.data-toggle-legend-item').click();
assert.equal(layer.mode, 'core', 'the legend is not a control');
controls.click();
assert.equal(layer.mode, 'core');
layer.module.getRowControls = () => {
throw new Error('boom');
};
mgr._refreshTogglePanel();
assert.equal(
controls.hidden,
true,
'a throwing layer collapses to an empty block',
);
assert.ok(row(container, 'satellites'), 'the row itself survives');
} finally {
console.warn = warn;
await mgr.destroyAll();
}
});
test('keyboard focus on a chip survives the refresh its own click triggers', async (t) => {
// The panel repaints on EVERY refresh, including the one the chip's own
// click triggers. Rebuilding the button would blur it every time, so a
// keyboard user loses their place on activation.
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
chip.focus();
assertSameNode(document.activeElement, chip, 'the chip starts focused');
chip.click();
assertSameNode(
document.activeElement,
chip,
'activating the chip does not blur it',
);
assertSameNode(
chipsIn(controls)[0],
chip,
'the SAME button node is reused across the click-driven refresh',
);
mgr._refreshTogglePanel();
mgr._refreshTogglePanel();
assertSameNode(
document.activeElement,
chip,
'repeated refreshes never steal focus',
);
assert.equal(
controls.querySelectorAll('.data-toggle-legend-item').length,
2,
);
// ...and a chip that genuinely goes away is removed from the row.
layer.module.getRowControls = () => ({ chips: [], legend: [] });
mgr._refreshTogglePanel();
assert.equal(chip.isConnected, false);
} finally {
await mgr.destroyAll();
}
});
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
let settled = false;
let writes = 0;
const module = {
id: 'satellites',
name: 'Satellites',
icon: '',
source: 'CelesTrak',
updateInterval: -1,
async init() {},
enable() {},
disable() {},
async update() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
setParams() {
writes += 1;
},
getRowControls() {
return {
chips: [
{
id: 'catalog',
label: settled ? 'DENSE' : 'DENSE ···',
active: settled,
busy: !settled,
disabled: !settled,
state: settled ? 'active' : 'loading',
title: 'x',
params: { catalog: 'core' },
},
],
legend: [],
};
},
setRowControlsListener(fn) {
module._listener = fn;
},
};
mgr.register(module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
assert.equal(
typeof module._listener,
'function',
'the manager installed its listener',
);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
const chip = chipsIn(controls)[0];
assert.equal(chip.textContent, 'DENSE ···');
assert.equal(chip.disabled, true);
assert.equal(chip.getAttribute('aria-busy'), 'true');
assert.equal(
chip.getAttribute('aria-pressed'),
'false',
'busy is never reported as active',
);
assert.equal(chip.classList.contains('chip-loading'), true);
chip.click();
assert.equal(writes, 0, 'a disabled chip is inert');
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.getAttribute('aria-pressed'), 'true');
assert.equal(chip.getAttribute('aria-busy'), 'false');
chip.click();
assert.equal(writes, 1, 'the settled chip writes again');
} finally {
await mgr.destroyAll();
}
});
test('a layer that surrenders its row controls hides the block entirely', async (t) => {
// Space Missions borrows the satellite layer with showPoints:false; the row
// must not advertise a legend for an empty sky or offer a chip whose write
// the dependency owner will silently revert.
const container = panelHost(t);
const mgr = new DataLayerManager({});
let surrendered = false;
const layer = makeRowControlLayer();
const inner = layer.module.getRowControls;
layer.module.getRowControls = () =>
surrendered ? { chips: [], legend: [] } : inner();
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
assert.equal(await mgr.setEnabled('satellites', true), true);
const controls = row(container, 'satellites').querySelector(
'.data-toggle-controls',
);
assert.equal(controls.hidden, false);
assert.equal(chipsIn(controls).length, 1);
surrendered = true;
mgr._refreshTogglePanel();
assert.equal(controls.hidden, true);
assert.equal(
chipsIn(controls).length,
0,
'the chip is removed, not merely hidden behind a style',
);
assert.equal(
controls.querySelectorAll('.data-toggle-legend-item').length,
0,
);
surrendered = false;
mgr._refreshTogglePanel();
assert.equal(
controls.hidden,
false,
'the row returns when the owner releases it',
);
assert.equal(chipsIn(controls).length, 1);
} finally {
await mgr.destroyAll();
}
});
test('panel remount releases old listeners and destruction revokes retained controls', async (t) => {
panelHost(t);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => {
rowListener = listener;
};
manager.register(layer.module);
try {
const first = document.body.appendChild(document.createElement('div'));
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const second = document.body.appendChild(document.createElement('div'));
manager.buildTogglePanel(second);
assert.equal(first.children.length, 0, 'the old container is emptied');
oldButton.click();
await macrotask();
assert.equal(
layer.calls.enable,
0,
'a revoked generation cannot issue actions',
);
const currentButton = second.querySelector('.data-toggle-btn');
assert.ok(currentButton);
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(second.children.length, 0, 'destruction removes the rows');
currentButton.click();
await macrotask();
assert.equal(
layer.calls.enable,
0,
'a retained control cannot act after destruction',
);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
}
});
test('a retained chip from a remounted or destroyed panel writes nothing', async (t) => {
panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
mgr.register(layer.module);
try {
const first = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(first);
assert.equal(await mgr.setEnabled('satellites', true), true);
const oldChip = chipsIn(first)[0];
const second = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(second);
oldChip.click();
assert.equal(
layer.mode,
'core',
'a released generation cannot write params',
);
const chip = chipsIn(second)[0];
mgr._layerPanel.destroy();
chip.click();
assert.equal(layer.mode, 'core', 'a destroyed panel cannot write params');
} finally {
await mgr.destroyAll();
}
});
test('a toggle that settles after a remount repaints the new row idle', async (t) => {
panelHost(t);
const mgr = new DataLayerManager({});
let releaseEnable;
mgr.register({
id: 'remount-layer',
name: 'Remount layer',
icon: '',
source: 'Local fixture',
updateInterval: -1,
init() {},
enable() {
return new Promise((resolve) => {
releaseEnable = resolve;
});
},
disable() {},
update() {},
destroy() {},
getStats() {
return { count: 1, lastUpdate: Date.now() };
},
});
try {
const first = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(first);
first.querySelector('.data-toggle-btn').click();
await waitFor(
() => typeof releaseEnable === 'function',
'enable transition',
);
const second = document.body.appendChild(document.createElement('div'));
mgr.buildTogglePanel(second);
releaseEnable();
await waitFor(() => mgr.isEnabled('remount-layer'), 'enabled');
await macrotask();
const button = second.querySelector('.data-toggle-btn');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.textContent, 'ON');
} finally {
releaseEnable?.();
await mgr.destroyAll();
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async (t) => {
const container = panelHost(t);
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0,
blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({
chips: [
{
id: 'find',
label: 'SHOW NEAREST',
disabled: blocked,
onClick: () => calls++,
},
],
});
layer.module.setParams = () => {
throw new Error('navigation must not write persistent params');
};
mgr.register(layer.module);
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const layerRow = row(container, 'satellites');
assert.equal(layerRow.querySelector('.data-count').textContent, '3 nearby');
const chip = chipsIn(layerRow)[0];
chip.click();
assert.equal(calls, 1);
blocked = true;
chip.click();
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
assert.equal(chip.isConnected, false, 'the off row drops its chips');
mgr._layerPanel._selectChip('satellites', 'find');
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
const container = panelHost(t);
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame =
scheduler === 'animation frame'
? (callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
}
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => {
if (listener) notify = listener;
};
manager.register(layer.module);
try {
manager.buildTogglePanel(container);
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => {
refreshes++;
refresh();
};
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
await macrotask();
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else await macrotask();
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
await macrotask();
assert.equal(
refreshes,
2,
'retained notifications after destroy are inert',
);
} finally {
await manager.destroyAll();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async (t) => {
const container = panelHost(t);
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const block = container.querySelector('.data-toggle-controls');
const chip = block.children[0];
const info = block.children[2];
assert.equal(info.className, 'data-toggle-controls-info');
assert.equal(info.textContent, 'Forecast');
assert.equal(info.title, 'Source time');
chip.focus();
for (const change of [
{ label: 'High' },
{ color: '#ff0000' },
{ count: 2 },
{ blurb: 'High values' },
]) {
controls = structuredClone(controls);
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assertSameNode(block.children[2], info);
assertSameNode(document.activeElement, chip);
}
const legend = block.children[1];
assert.equal(legend.title, 'High values');
assert.equal(legend.textContent, 'High 2');
assert.equal(legend.firstElementChild.style.background, '#ff0000');
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(
[...block.children].map((node) =>
[...node.classList].filter((name) => !name.startsWith('ui-')).join(' '),
),
[
'data-toggle-chip chip-idle',
'data-toggle-chip chip-idle',
'data-toggle-legend-item',
'data-toggle-controls-info',
],
);
assertSameNode(block.children[0], chip);
assertSameNode(block.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(block.hidden, true);
assert.equal(info.hidden, true);
assert.equal(block.children.length, 1);
assertSameNode(block.children[0], info);
controls = {
info: 'Restored',
legend: [{ label: 'New', color: '#ffffff' }],
};
manager._refreshTogglePanel();
assertSameNode(block.lastElementChild, info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
}
});
+2 -3
View File
@@ -25,12 +25,11 @@ export const collectAircraftProximityWindow =
layer.collectAircraftProximityWindow;
export const _getAwarenessNavigationStateForTest =
layer._getAwarenessNavigationStateForTest;
export const _getAwarenessPanelRendersForTest =
layer._getAwarenessPanelRendersForTest;
export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext;
export const historySubjectSnapshot = layer.historySubjectSnapshot;
export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex;
export const awarenessPanelControlKey = layer.awarenessPanelControlKey;
export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus;
export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus;
export const awarenessResultsAreLive = layer.awarenessResultsAreLive;
export const awarenessNeedsContinuousRender =
layer.awarenessNeedsContinuousRender;
+79 -195
View File
@@ -1,27 +1,25 @@
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import * as Cesium from 'cesium';
import militaryAwarenessLayer, {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
awarenessClearMatchesSubject,
awarenessNeedsContinuousRender,
awarenessRefreshDecision,
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -43,13 +41,20 @@ 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(
new URL('./militaryAwareness.js', import.meta.url),
'utf8',
);
const awarenessPanelSource = readFileSync(
new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url),
'utf8',
);
const AWARENESS_DEPENDENCIES = [
'flights',
@@ -70,6 +75,15 @@ 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');
assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km');
assert.equal(presentAwarenessDistance(9950), '10\u00a0km');
assert.equal(presentAwarenessDistance(9960), '10\u00a0km');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
@@ -90,27 +104,12 @@ function installAwarenessRuntime({
const listeners = new Map();
let nextTimer = 1;
let preRenderListener = null;
const createdElements = [];
class FakeElement {
constructor() {
this.classList = { contains: () => false };
this.style = { setProperty() {} };
this._hidden = false;
// renderResults() writes `hidden = false` on EVERY call, ahead of its
// markup diff, so counting these writes observes "was the panel
// repaint path entered?" independently of whether the markup changed.
this.hiddenWrites = 0;
this.innerHTML = '';
}
get hidden() {
return this._hidden;
}
set hidden(value) {
this._hidden = value;
this.hiddenWrites += 1;
this.hidden = false;
}
setAttribute() {}
@@ -205,9 +204,7 @@ function installAwarenessRuntime({
return null;
},
createElement() {
const element = new FakeElement();
createdElements.push(element);
return element;
return new FakeElement();
},
addEventListener() {},
removeEventListener() {},
@@ -221,6 +218,10 @@ function installAwarenessRuntime({
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
const panelViews = [];
const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) =>
panelViews.push(view),
);
militaryAwarenessLayer.enable();
return {
@@ -240,15 +241,16 @@ function installAwarenessRuntime({
);
return taken;
},
/** The standalone Context panel element renderResults() paints into. */
/** The Contacts panel as the layer publishes it to the view. */
panel() {
return (
createdElements.find(
(element) => element.id === 'military-awareness-panel',
) || null
);
return {
view: panelViews.at(-1) ?? null,
publishes: panelViews.length,
renders: _getAwarenessPanelRendersForTest(),
};
},
restore() {
unsubscribePanel();
militaryAwarenessLayer.destroy();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
@@ -256,144 +258,24 @@ function installAwarenessRuntime({
};
}
function awarenessControl(dataset, { disabled = false } = {}) {
return {
dataset,
disabled,
focusCalls: 0,
closest(selector) {
if (selector === '[data-awareness-focus-continuation]') {
return this.isContinuation ? this : null;
}
return selector.includes('button') && !this.isContinuation ? this : null;
},
matches(selector) {
return (
selector === '[data-awareness-focus-continuation]' &&
this.isContinuation
);
},
focus(options) {
this.focusCalls += 1;
this.focusOptions = options;
},
};
/** The count a published Contacts panel shows for one cohort row. */
function panelCount(runtime, label) {
return runtime.panel().view?.rows?.find((row) => row.label === label)?.count;
}
function awarenessPanel(controls) {
const continuation = awarenessControl({});
continuation.isContinuation = true;
return {
controls,
continuation,
contains(element) {
return controls.includes(element) || element === continuation;
},
querySelectorAll() {
return this.controls;
},
querySelector(selector) {
return selector === '[data-awareness-focus-continuation]'
? continuation
: null;
},
};
}
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
const previous = awarenessControl({ awarenessAction: 'previous' });
const focus = awarenessControl({ awarenessAction: 'focus' });
const next = awarenessControl({ awarenessAction: 'next' });
const alpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const bravo = awarenessControl({
awarenessLayer: 'military',
awarenessId: 'def456',
});
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
assert.equal(awarenessPanelControlKey(next), 'action:next');
const retainedAlpha = awarenessControl({
awarenessLayer: 'flights',
awarenessId: 'abc123',
});
const retained = awarenessPanel([
previous,
focus,
next,
bravo,
retainedAlpha,
]);
assert.equal(
restoreAwarenessPanelFocus(retained, alphaSnapshot),
retainedAlpha,
);
assert.equal(
retainedAlpha.focusCalls,
1,
'identity wins even when live distance reorders rows',
);
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
const forward = awarenessControl({
awarenessLayer: 'ais-live-vessels',
awarenessId: '789',
});
const departed = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(departed, alphaSnapshot),
departed.continuation,
);
assert.equal(
forward.focusCalls,
0,
'automatic paging must not transfer focus to another contact',
);
assert.equal(departed.continuation.focusCalls, 1);
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
assert.equal(
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
noRemainingRow.continuation,
);
assert.equal(
noRemainingRow.continuation.focusCalls,
1,
'the end sentinel continues Tab beyond the list',
);
const continuationSnapshot = captureAwarenessPanelFocus(
departed,
departed.continuation,
);
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
repaintedAgain.continuation,
);
assert.equal(
repaintedAgain.continuation.focusCalls,
1,
'later live repaints keep focus on the continuation point until the user tabs onward',
);
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
assert.match(
awarenessPanelSource,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\{\{ view\.note \}\}<\/p>/,
);
assert.doesNotMatch(
awarenessPanelSource,
/<span[^>]*data-awareness-focus-continuation/,
);
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>\$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
militaryAwarenessSource,
/'Open-source mapped\/observed context\./,
);
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
@@ -591,9 +473,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
'loading is not an observed empty cohort',
);
assert.match(installations?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/Mapped installations<\/strong><b aria-live="polite">\?<\/b>/,
assert.equal(
panelCount(runtime, 'Mapped installations'),
'?',
'the operator sees ? rather than an all-clear 0 while installations load',
);
} finally {
@@ -733,15 +615,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
);
assert.match(vessels?.reason || '', /unavailable/i);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">\?<\/b>/,
'the panel prints ? for the whole settled-but-connecting window',
);
assert.doesNotMatch(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
'the panel must never print an all-clear 0 before the feed has answered once',
assert.equal(
panelCount(runtime, 'AIS vessels'),
'?',
'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
);
} finally {
restoreCollections();
@@ -781,9 +658,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
0,
'an answered empty viewport is a real observation',
);
assert.match(
runtime.panel()?.innerHTML || '',
/AIS vessels<\/strong><b aria-live="polite">0<\/b>/,
assert.equal(
panelCount(runtime, 'AIS vessels'),
'0',
'the operator sees the real count once the feed answers',
);
} finally {
@@ -1246,17 +1123,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
id,
);
const panel = runtime.panel();
assert.ok(panel, 'the Context panel must exist');
assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
const settledWrites = panel.hiddenWrites;
const settledRenders = runtime.panel().renders;
const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(
panel.hiddenWrites,
settledWrites,
runtime.panel().renders,
settledRenders,
'an unchanged label must not trigger a panel repaint',
);
assert.equal(
runtime.panel().publishes,
settledPublishes,
'an unchanged refresh publishes no new panel, results or standby',
);
// Enrichment answers. The contact has NOT moved and no source revision
// changed, so the label is the only thing making this render-worthy.
@@ -1275,17 +1158,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
'subject identity must stay the ICAO hex',
);
// The RENDERED panel is the surface the operator actually reads.
const repainted = runtime.panel();
assert.ok(
panel.hiddenWrites > settledWrites,
repainted.publishes > settledPublishes,
'a changed label must repaint the standalone Context panel',
);
assert.match(
panel.innerHTML,
repainted.view.subject,
/N123AB/,
'the rendered Context panel must show the registration without a position change',
);
assert.doesNotMatch(
panel.innerHTML,
JSON.stringify(repainted.view),
/ae1fa4/,
'the hex must be gone from the panel',
);
@@ -2232,9 +2116,10 @@ test('expanded Context results omit the redundant status heading', () => {
militaryAwarenessSource,
/GLOBAL CONTEXT <span>CONTEXT ONLY<\/span>/,
);
assert.match(militaryAwarenessSource, /military-awareness-subject/);
assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
assert.match(awarenessPanelSource, /military-awareness-subject/);
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
assert.match(militaryAwarenessSource, /military-awareness-controls/);
assert.match(awarenessPanelSource, /military-awareness-controls/);
});
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
@@ -4221,7 +4106,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
}
});
test('a selected installation card keeps every member name and escapes source text', () => {
test('a selected installation card keeps every member name as text', () => {
const runtime = installAwarenessRuntime();
const restores = [];
try {
@@ -4244,16 +4129,15 @@ test('a selected installation card keeps every member name and escapes source te
},
});
militaryAwarenessLayer.update();
const html = runtime.panel().innerHTML;
assert.match(html, /Named areas \(4\)/);
for (const name of [
// The view renders names as text; ui/awarenessPanel.test.mjs covers it.
const names = runtime.panel().view?.subjectNames;
assert.equal(names?.summary, 'Named areas (4)');
assert.deepEqual(names?.names, [
'Area one',
'Area two',
'Area three',
'&lt;Area four&gt;',
])
assert.ok(html.includes(name));
assert.ok(!html.includes('<Area four>'));
'<Area four>',
]);
} finally {
restores.reverse().forEach((restore) => restore());
runtime.restore();
+9 -10
View File
@@ -1,7 +1,8 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { msg, t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
import { formatDistanceText } from './distanceFormat.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
@@ -46,10 +47,7 @@ export function findByDoublingRadius(
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return formatDistanceText(meters) ?? '—';
}
/**
@@ -84,7 +82,8 @@ export function formatAwarenessLabel(value) {
/**
* Summarize sorted nearby results without promoting missing/stale feeds to a
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
* claim in this MVP. `reason` is English data; the awareness panel shows it
* in the UI language.
*/
export function summarizeAwarenessCohort(
items,
@@ -95,14 +94,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed unavailable'),
reason: msg('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: t('feed stale'),
reason: msg('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -119,8 +118,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
? msg('observed or mapped nearby context')
: msg('no observed or mapped objects in current feeds'),
};
}
+29
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import {
AWARENESS_RELATIONSHIP,
findByDoublingRadius,
@@ -24,6 +25,15 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid
assert.equal(formatAwarenessLabel(undefined), '—');
});
test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(formatAwarenessDistance(999.4), '999 m');
assert.equal(formatAwarenessDistance(999.6), '1.0 km');
assert.equal(formatAwarenessDistance(4600), '4.6 km');
assert.equal(formatAwarenessDistance(9949), '9.9 km');
assert.equal(formatAwarenessDistance(9950), '10 km');
assert.equal(formatAwarenessDistance(9960), '10 km');
});
test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => {
const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]);
assert.equal(result.relationship, 'NEARBY');
@@ -47,6 +57,25 @@ test('unavailable and stale feeds remain unknown', () => {
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
});
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const reasons = [
summarizeAwarenessCohort([], { available: false }).reason,
summarizeAwarenessCohort([], { stale: true }).reason,
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
summarizeAwarenessCohort([]).reason,
];
assert.deepEqual(reasons, [
'feed unavailable',
'feed stale',
'observed or mapped nearby context',
'no observed or mapped objects in current feeds',
]);
// The awareness panel shows a reason through translateKnown().
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
});
test('navigation prioritizes nearby targets from the selected cohort', () => {
const cohorts = [
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
-1
View File
@@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui
// by source probes against the shipped file, the same technique the HUD datum
// tests use where a full boot is impractical.
import { installationRetryDelayMs } from './militaryInstallations.js';
import fs from 'node:fs';
const installationsSource = readLayerSource(
new URL('./militaryInstallations.js', import.meta.url), 'utf8');
+1 -1
View File
@@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js';
export const MILITARY_POINT_CAP = 512;
export const MILITARY_LABEL_CAP = 24;
export const MILITARY_NAMES_TIMEOUT_MS = 10_000;
const MILITARY_NAMES_TIMEOUT_MS = 10_000;
/** Share a bounded acquisition; failed assets become retryable after cooldown. */
export function createMilitaryNamesLoader({
+69 -3
View File
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
const memoryHit = await resolveOverpassPreflight({
cacheKey: key,
memoryCache: new Map([[key, fresh]]),
inFlight: new Map([[key, Promise.resolve(joined)]]),
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
readDisk: async () => { diskReads += 1; return disk; },
allowUpstream,
now: 1000,
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
const inFlightHit = await resolveOverpassPreflight({
cacheKey: key,
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
inFlight: new Map([[key, Promise.resolve(joined)]]),
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
readDisk: async () => { diskReads += 1; return disk; },
allowUpstream,
now: 1000,
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
const diskMemory = new Map();
const diskHit = await resolveOverpassPreflight({
cacheKey: key,
memoryCache: new Map(),
memoryCache: diskMemory,
inFlight: new Map(),
readDisk: async () => { diskReads += 1; return disk; },
allowUpstream,
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
assert.equal(diskHit.payload, disk);
assert.equal(diskReads, 1);
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
assert.equal(publishedDisk.source, 'HIT');
const upstreamMiss = await resolveOverpassPreflight({
cacheKey: key,
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
const t = c1 / c2;
return Math.hypot(wx - t * vx, wy - t * vy);
}
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
const inFlight = new Map();
let release;
const disk = new Promise((resolve) => { release = resolve; });
let reads = 0, admissions = 0, upstreams = 0;
const payload = { status: 200, body: '{"elements":[]}' };
const options = {
cacheKey: 'cold', memoryCache: new Map(), inFlight,
readDisk: () => { reads++; return disk; },
allowUpstream: () => { admissions++; return true; },
loadUpstream: async () => { upstreams++; return payload; },
};
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
await Promise.resolve();
assert.equal(inFlight.size, 1);
release(null);
const results = await Promise.all(pending);
assert.equal(reads, 1);
assert.equal(admissions, 1);
assert.equal(upstreams, 1);
assert.ok(results.every((result) => result.payload === payload));
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
assert.equal(inFlight.size, 0);
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
assert.ok(rejected.every((result) => result.status === 'rejected'));
assert.equal(inFlight.size, 0);
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
});
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
const inFlight = new Map();
let release;
const disk = new Promise((resolve) => { release = resolve; });
let reads = 0, upstreams = 0;
const admissions = { a: 0, b: 0, c: 0 };
const quota = { a: false, b: true, c: false };
const payload = { status: 200, body: '{"elements":[]}' };
const client = (name) => resolveOverpassPreflight({
cacheKey: 'quota', memoryCache: new Map(), inFlight,
readDisk: () => { reads++; return reads === 1 ? disk : null; },
allowUpstream: () => { admissions[name]++; return quota[name]; },
loadUpstream: async () => { upstreams++; return payload; },
});
// A (quota exhausted) owns the cold reservation; B (quota available) and
// C (quota exhausted) join it while the disk lookup is pending.
const pending = [client('a'), client('b'), client('c')];
await Promise.resolve();
assert.equal(inFlight.size, 1);
release(null);
const [a, b, c] = await Promise.all(pending);
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
assert.equal(b.payload, payload);
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
assert.equal(c.payload, payload);
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
assert.equal(upstreams, 1);
assert.equal(inFlight.size, 0);
});
+35
View File
@@ -11,6 +11,8 @@
* that belong to someone else and leave them alone.
*/
import { isSurfacePick } from './scenePick.js';
/** @type {Map<string, (pickedId: string) => boolean>} layerId -> ownership predicate */
const _owners = new Map();
@@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) {
}
return false;
}
/**
* Resolve a moving-contact click without letting map tiles hide a nearby touch
* target. Exact picks always win; only a coarse primary pointer may expand a
* surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers
* do not each choose their own contact under one tap.
* Unowned non-surface objects remain barriers, not permission to pick through.
*
* @param {object} scene Cesium scene with pick and optional drillPick.
* @param {{x: number, y: number}} position Screen position in CSS pixels.
* @returns {object|null|undefined} Original pick or the first registered touch hit.
*/
export function pickContact(scene, position) {
const picked = scene.pick(position);
if (
!globalThis.matchMedia?.('(pointer: coarse)').matches ||
findPickOwner(resolvePickId(picked)) ||
!isSurfacePick(picked) ||
typeof scene.drillPick !== 'function'
)
return picked;
let hits;
try {
hits = scene.drillPick(position, 8, 14, 14) || [];
} catch {
return picked;
}
for (const hit of hits.slice(0, 8)) {
if (findPickOwner(resolvePickId(hit))) return hit;
if (!isSurfacePick(hit)) break;
}
return picked;
}
+120
View File
@@ -16,6 +16,7 @@ import {
unregisterPickOwner,
isOwnedByOtherLayer,
resolvePickId,
pickContact,
} from './pickRegistry.js';
// ---------------------------------------------------------------------------
@@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => {
unregisterPickOwner('bikeshare');
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
});
function primaryPointer(t, coarse) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia');
Object.defineProperty(globalThis, 'matchMedia', {
configurable: true,
value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }),
});
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'matchMedia', previous);
else delete globalThis.matchMedia;
});
}
function contactOwners(t) {
registerPickOwner('flights', (id) => id === 'civil');
registerPickOwner('military', (id) => id === 'military');
registerPickOwner('ais-live-vessels', (id) => id === '123456789');
t.after(() => {
for (const id of ['flights', 'military', 'ais-live-vessels'])
unregisterPickOwner(id);
});
}
const mapTile = { primitive: { isCesium3DTileset: true } };
test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => {
primaryPointer(t, true);
contactOwners(t);
const position = { x: 30, y: 50 };
const contact = { primitive: { id: 'civil' } };
let drills = 0;
const scene = {
pick(point) {
assert.equal(point, position);
return mapTile;
},
drillPick(...args) {
drills++;
assert.deepEqual(args, [position, 8, 14, 14]);
return [mapTile, contact];
},
};
assert.equal(pickContact(scene, position), contact);
assert.equal(drills, 1);
});
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
const scene = {
pick: () => mapTile,
drillPick: () => [mapTile, first, { id: 'civil' }],
};
assert.equal(pickContact(scene, { x: 1, y: 1 }), first);
}
});
test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const exact of [
{ id: 'military' },
{ id: 'custom-annotation' },
{ primitive: { id: 'custom-gizmo' } },
{ primitive: { id: 'civil', isCesium3DTileset: true } },
]) {
const scene = {
pick: () => exact,
drillPick: () => assert.fail('must keep exact interactive hit'),
};
assert.equal(pickContact(scene, { x: 1, y: 1 }), exact);
}
});
test('touch expansion stops at a non-surface object and respects the drill bound', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const hits of [
[mapTile, { id: 'custom-annotation' }, { id: 'civil' }],
[...Array(8).fill(mapTile), { id: 'civil' }],
[mapTile],
]) {
assert.equal(
pickContact(
{ pick: () => mapTile, drillPick: () => hits },
{ x: 1, y: 1 },
),
mapTile,
);
}
});
test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => {
primaryPointer(t, false);
contactOwners(t);
const scene = {
pick: () => mapTile,
drillPick: () => assert.fail('mouse must not expand'),
};
assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile);
});
test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const drillPick of [
undefined,
() => {
throw new Error('context lost');
},
() => [],
]) {
assert.equal(
pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }),
undefined,
);
}
});
+4 -1
View File
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
return Number.MAX_SAFE_INTEGER;
const latitudeScale = 111320;
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
// The short way round, so places either side of 180° stay close.
let dLon = lonB - lonA;
dLon -= 360 * Math.round(dLon / 360);
return Math.round(
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
);
}
-24
View File
@@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand;
export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay;
export const getRadioAcceptedCatalogSnapshot =
layer.getRadioAcceptedCatalogSnapshot;
export const radioStationResolutionMatches =
layer.radioStationResolutionMatches;
export const getRadioUIState = layer.getRadioUIState;
export const subscribeToRadio = layer.subscribeToRadio;
export const subscribeToRadioPlaybackControls =
layer.subscribeToRadioPlaybackControls;
export const getRadioTunerStations = layer.getRadioTunerStations;
export const beginRadioTuning = layer.beginRadioTuning;
export const setRadioTuningStatic = layer.setRadioTuningStatic;
export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed;
export const previewRadioTuningStation = layer.previewRadioTuningStation;
export const endRadioTuning = layer.endRadioTuning;
export const cancelRadioTuning = layer.cancelRadioTuning;
export const commitRadioTuningStation = layer.commitRadioTuningStation;
export const playSelectedRadio = layer.playSelectedRadio;
export const confirmRadioPlayback = layer.confirmRadioPlayback;
export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice;
export const stopRadioPlayback = layer.stopRadioPlayback;
export const toggleRadioPlayback = layer.toggleRadioPlayback;
export const pauseRadioPlayback = layer.pauseRadioPlayback;
export const setRadioVolume = layer.setRadioVolume;
export const setRadioParams = layer.setRadioParams;
export const getRadioParams = layer.getRadioParams;
export const setRadioVoiceDucking = layer.setRadioVoiceDucking;
export const selectRadioStation = layer.selectRadioStation;
export const cycleRadioStation = layer.cycleRadioStation;
export const selectRequestedRadioStation = layer.selectRequestedRadioStation;
export const setRadioFilter = layer.setRadioFilter;
export const retainRadioClusterIdentitiesForStations =
layer.retainRadioClusterIdentitiesForStations;
export {
@@ -86,10 +64,8 @@ export {
RADIO_SINGLETON_MID_LIMIT,
RADIO_SINGLETON_NEAR_LIMIT,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
RADIO_OVERLAY_SOURCE_OPTIONS,
DEFAULT_RADIO_FILTER,
GLOBAL_RADIO_ALTITUDE_M,
} from '../layers/radio/index.js';
export default layer;
+53 -3
View File
@@ -712,6 +712,58 @@ test('unusable directory responses preserve warm client state atomically', async
assert.equal(getRadioUIState().stale, true);
assert.equal(getRadioUIState().degraded, true);
}
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now,
}));
await radioLayer.update();
assert.equal(getRadioUIState().stationCount, 1);
assert.equal(getRadioUIState().rejectedStationCount, 1);
assert.equal(getRadioUIState().degraded, true);
assert.equal(getRadioUIState().stale, false);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2);
assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id);
// Server-side structural rejections are combined with client rejections
// after admission; the healthy generation is still accepted.
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement, { id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount: 2,
}));
await radioLayer.update();
assert.equal(getRadioUIState().rejectedStationCount, 3);
assert.equal(getRadioUIState().degraded, true);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3);
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement], stale: false, degraded: false,
acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount: 10_000,
}));
await radioLayer.update();
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) {
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [replacement], stale: false, degraded: false,
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
rejectedStationCount,
}));
await radioLayer.update();
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
assert.equal(getRadioUIState().stale, true);
}
globalThis.fetch = async () => new Response(JSON.stringify({
stations: [{ id: 'malformed' }], stale: false, degraded: false,
acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now,
}));
await radioLayer.update();
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4);
assert.equal(getRadioUIState().stale, true);
assert.equal(getRadioUIState().rejectedStationCount, 10_000);
radioLayer.destroy();
radioLayer.init(viewer);
assert.equal(getRadioUIState().rejectedStationCount, 0);
assert.equal(getRadioUIState().degraded, false);
} finally {
globalThis.fetch = originalFetch;
radioLayer.destroy();
@@ -1779,7 +1831,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a
});
test('voice playback confirmation accepts fallback buffering but times out safely', async () => {
let listener = () => {};
const state = {
audioState: 'loading',
playingStationId: 'fallback',
@@ -1789,9 +1840,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel
const confirmed = await confirmRadioPlayback({
startPlayback: async () => false,
subscribe: (next) => {
listener = next;
next(state);
return () => { listener = () => {}; };
return () => {};
},
getState: () => state,
timeoutMs: 10,
-5
View File
@@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) {
name: canonicalCountryName(code),
});
}
/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */
export function isRadioCountryCode(value) {
return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase());
}
+67 -1
View File
@@ -11,7 +11,14 @@ import {
publicRadioStation,
publicRadioHttpsUrl,
} from '../../vite.config.js';
import { rankRadioStationsForRequest } from './radio.js';
import {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
radioLayer,
rankRadioStationsForRequest,
} from './radio.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js';
import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js';
const UUID = '12345678-1234-4234-8234-123456789abc';
const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }];
@@ -533,3 +540,62 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing',
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
test('case expansion is capped after tag normalization in the portable and server station path', () => {
const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'}));
assert.equal(row.tags[0].length, 80);
assert.equal(row.tags[0], 'i\u0307'.repeat(40));
assert.deepEqual(row.tags.slice(1), ['news radio']);
});
test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => {
const originalFetch = globalThis.fetch;
const middleware = createProxy({
now: () => Date.now(),
fetchImpl: async (url) => {
if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]);
const rows = healthyRowsForQuery(url);
if (queryTag(url)) return responseJson(rows);
// 400 valid broad rows, one row missing its required name, and one
// schema-valid row that normalization filters by policy (not counted).
const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' });
return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]);
},
});
const response = await invoke(middleware, '/stations');
assert.equal(response.status, 200);
const body = JSON.parse(response.body);
assert.equal(body.degraded, false);
assert.equal(body.degradedReason, null);
assert.equal(body.acceptedGeneration, 1);
assert.equal(body.rejectedStationCount, 1);
assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX);
const viewer = {
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
dataSources: { add() {}, remove() {} },
entities: { add(entity) { return entity; }, remove() {} },
};
radioLayer.destroy();
try {
globalThis.fetch = async () => new Response(response.body, {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
radioLayer.init(viewer);
radioLayer.enable();
await radioLayer.update();
const state = getRadioUIState();
assert.equal(state.stationCount, body.stations.length);
assert.equal(state.rejectedStationCount, 1);
assert.equal(state.degraded, true);
assert.equal(state.stale, false);
assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1);
assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance);
} finally {
globalThis.fetch = originalFetch;
radioLayer.destroy();
}
});
-4
View File
@@ -39,10 +39,6 @@ export const missionRosterEntries = layer.missionRosterEntries;
export const missionHoverPreviewRange = layer.missionHoverPreviewRange;
export const createMissionRosterPreviewOwnership =
layer.createMissionRosterPreviewOwnership;
export const bindMissionRosterItemKeyboardPreview =
layer.bindMissionRosterItemKeyboardPreview;
export const captureMissionRosterFocus = layer.captureMissionRosterFocus;
export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus;
export const resolveMissionRosterPreviewLaunch =
layer.resolveMissionRosterPreviewLaunch;
export const formatMissionEventTime = layer.formatMissionEventTime;
+12 -83
View File
@@ -7,8 +7,6 @@ import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
approximateOrbitPath,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
buildMissionPaths,
cameraHeadingForPath,
compactLaunchSiteName,
@@ -46,7 +44,6 @@ import rocketLaunchesLayer, {
replayStartAfterPause,
replayVehicleScreenRotation,
resolveMissionRosterPreviewLaunch,
restoreMissionRosterFocus,
releaseAircraftTracking,
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
@@ -448,6 +445,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp
assert.ok(liftoff.phaseProgress > 0);
});
test('runs the five-second tile hold before a full T-10 countdown', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
// REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead.
const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5);
assert.equal(hold.preCountdownActive, true);
assert.equal(hold.countdownActive, false);
const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5);
assert.equal(countdown.preCountdownActive, false);
assert.equal(countdown.countdownActive, true);
assert.equal(countdown.countdownSeconds, 10);
});
test('hands replay directly from ascent into orbit', () => {
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000);
@@ -514,42 +523,6 @@ test('keyboard focus previews roster missions and clears without selecting them'
]);
});
test('rendered mission row focus and blur drive the preview ownership controller', () => {
const listeners = new Map();
const calls = [];
const button = {
addEventListener(type, listener) {
listeners.set(type, listener);
},
};
const nextButton = {
dataset: { missionRosterIndex: '4' },
closest: () => nextButton,
};
const roster = { contains: (candidate) => candidate === nextButton };
const ownership = {
pointerEnter: (index) => calls.push(['pointer-enter', index]),
pointerLeave: () => calls.push(['pointer-leave']),
focus: (index) => calls.push(['focus', index]),
blur: (index) => calls.push(['blur', index]),
};
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
listeners.get('mouseenter')();
listeners.get('mouseleave')();
listeners.get('focus')();
listeners.get('blur')({ relatedTarget: nextButton });
listeners.get('blur')({ relatedTarget: null });
assert.deepEqual(calls, [
['pointer-enter', 2],
['pointer-leave'],
['focus', 2],
['blur', 4],
['blur', null],
]);
});
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
@@ -568,50 +541,6 @@ test('leaving a hovered row for an internal roster gap restores keyboard preview
]);
});
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
const focused = {
dataset: { missionRosterId: 'mission-b' },
closest: () => focused,
};
let restored = false;
const replacement = {
dataset: { missionRosterId: 'mission-b' },
focus: ({ preventScroll }) => { restored = preventScroll; },
};
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [
{ dataset: { missionRosterId: 'mission-c' } },
replacement,
{ dataset: { missionRosterId: 'mission-a' } },
],
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.deepEqual(snapshot, { launchId: 'mission-b' });
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
assert.equal(restored, true);
});
test('mission roster refresh continues after the list when the focused mission departed', () => {
const focused = {
dataset: { missionRosterId: 'departed' },
closest: () => focused,
};
let continuationFocused = false;
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
};
const continuation = {
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
assert.equal(continuationFocused, true);
});
test('pending mission preview resolves the current refreshed record by identity', () => {
const stale = { id: 'mission-a', name: 'Stale' };
const current = { id: 'mission-a', name: 'Current' };
+28 -13
View File
@@ -13,6 +13,8 @@
* roundabout turn, exit roundabout, exit rotary, notification, use lane.
*/
import { formatDistanceText } from './distanceFormat.js';
/** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */
export const ROUTE_STEPS_MAX = 200;
@@ -54,6 +56,21 @@ function direction(modifier) {
return DIRECTION_WORDS[key] || key;
}
/**
* "on the left" only fits a bare side. "slight left" is the adverb
* "slightly left", and "on the slightly left" is not an instruction.
* @param {string} prefix "Take the ramp" or "Take the exit".
* @param {string} modifier OSRM maneuver modifier.
* @param {string} onto " onto …", or "".
*/
function directedWay(prefix, modifier, onto) {
const dir = direction(modifier);
if (dir === 'left' || dir === 'right')
return `${prefix} on the ${dir}${onto}`;
if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`;
return `${prefix}${onto}`;
}
function ordinal(n) {
if (!Number.isInteger(n) || n < 1) return null;
const suffix =
@@ -103,13 +120,9 @@ export function instructionFor(step) {
case 'merge':
return dir ? `Merge ${dir}${onto}` : `Merge${onto}`;
case 'on ramp':
return dir
? `Take the ramp on the ${dir}${onto}`
: `Take the ramp${onto}`;
return directedWay('Take the ramp', modifier, onto);
case 'off ramp':
return dir
? `Take the exit on the ${dir}${onto}`
: `Take the exit${onto}`;
return directedWay('Take the exit', modifier, onto);
case 'roundabout':
case 'rotary': {
const exit = ordinal(step?.exit);
@@ -124,8 +137,14 @@ export function instructionFor(step) {
case 'exit roundabout':
case 'exit rotary':
return `Exit the roundabout${onto}`;
case 'use lane':
return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`;
case 'use lane': {
const lane = modifier.includes('left')
? 'left'
: modifier.includes('right')
? 'right'
: '';
return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`;
}
case 'notification':
default:
return `Continue${onto}`;
@@ -199,11 +218,7 @@ export function normalizeOsrmSteps(route) {
* @returns {string}
*/
export function formatRouteDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return `${Math.round(meters)} m`;
if (meters < 1000) return `${Math.round(meters / 10) * 10} m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? '';
}
/**
+22
View File
@@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () =>
instructionFor({ type: 'on ramp', modifier: 'left' }),
'Take the ramp on the left',
);
assert.equal(
instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }),
'Take the ramp slightly left onto I-95',
);
assert.equal(
instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }),
'Take the exit sharply right onto I-280',
);
assert.equal(
instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }),
'Take the ramp onto US 101',
);
assert.equal(
instructionFor({ type: 'use lane', modifier: 'slight left' }),
'Use the left lane',
);
assert.equal(
instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }),
'Use the right lane onto Main St',
);
assert.equal(
instructionFor({
type: 'merge',
@@ -216,6 +236,8 @@ test('distance and duration formatting', () => {
assert.equal(formatRouteDistance(8), '8 m');
assert.equal(formatRouteDistance(846), '850 m');
assert.equal(formatRouteDistance(1234), '1.2 km');
assert.equal(formatRouteDistance(996), '1.0 km');
assert.equal(formatRouteDistance(9960), '10 km');
assert.equal(formatRouteDistance(21479), '21 km');
assert.equal(formatRouteDistance(-1), '');
assert.equal(formatRouteDuration(40), '40 s');
-1
View File
@@ -28,4 +28,3 @@ export {
createCableReferenceSweepGate,
updateCableReferenceStem,
} from '../layers/submarineCables/index.js';
export default createTeleGeographySubmarineCableLayer();
+9 -1
View File
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
/**
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
* Retry sleeps and retry attempts share a 10s budget after attempt one.
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
* Retry-After longer than the budget left ends the retries at once: sleeping
* out the budget cannot reach the time the upstream asked for, and it would
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
*
* Dependencies are injectable for deterministic offline tests.
* @param {Array<[number, number]>} points
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
const res = await fetchImpl(url, signal ? { signal } : {});
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.retryable = res.status === 429 || res.status >= 500;
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
throw error;
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
if (remaining <= 0) break;
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
if (retryAfterMs != null && retryAfterMs > remaining) break;
const backoffMs = Math.round(
350 * 2 ** attempt * (0.75 + random() * 0.5),
);
@@ -233,6 +238,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)) {
+30
View File
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
assert.equal(results[0].id, 'recovered');
});
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
let clock = 3_000_000;
let attempts = 0;
const sleeps = [];
await assert.rejects(
fetchTerrainChunkWithRetry([[1, 1]], {
now: () => clock,
random: () => 0.5,
makeSignal: () => undefined,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
fetchImpl: async () => {
attempts += 1;
return {
ok: false,
status: 429,
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
},
};
},
}),
(error) => error.status === 429 && error.retryAfter === '30',
);
assert.equal(attempts, 1);
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
});
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
let clock = 2_000_000;
let attempts = 0;
+1 -1
View File
@@ -2,7 +2,7 @@
* @file Pure tile math + budget accounting for the TomTom traffic-flow proxy.
*
* Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation,
* daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles
* daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles
* cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so
* both sides can unit-test against it with node:test.
*
-2
View File
@@ -930,7 +930,6 @@ for (const fixture of LAYERS) {
}
test('both layers gate the tracked-model load and record its failures', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/,
@@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async ()
});
test('the tracked regime never consults the fleet models3d toggle', async () => {
const { readFile } = await import('node:fs/promises');
for (const name of ['flights.js', 'militaryFlights.js']) {
const source = readLayerSource(new URL(`./${name}`, import.meta.url));
const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0];
-1
View File
@@ -1,7 +1,6 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
bindTrackingClickGesture,
isTrackingClickGesture,
+9
View File
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache: () => {},
// The layer reads stale cached tiles through this; a source without it
// answers with fresh segments only.
fetchFlowDetail: async (...args) => ({
segments: await sourceSlot.source.fetchFlowForBounds(...args),
stale: false,
oldestTileAt: null,
}),
},
);
export const configureTrafficSource = sourceSlot.configure;
/** The source the traffic layer reads, as configured. */
export const trafficSource = sourceSlot.source;
const layer = createApplicationTraffic({
source: sourceSlot.source,
});
Loaded 100 of 1214 files, more files were not shown because too many files have changed in this diff. Show more