Merge current main into S2 GhostMaps and preserve updated import and voice behavior

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-26 16:25:49 +01:00
commit 4da002bd42
515 files changed
+17681 -2202

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) {
+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`,
);
});
}
+28
View File
@@ -1011,6 +1011,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' });
+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) {
+7 -3
View File
@@ -412,14 +412,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();
}
/**
@@ -1029,7 +1033,7 @@ function createPanel(event, handlers) {
<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>
<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);
+1 -5
View File
@@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } 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;
@@ -1248,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({
},
};
}
const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer();
export default bhoteKoshiLocatorLayer;
-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 -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,
+3 -3
View File
@@ -94,7 +94,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 +172,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
@@ -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\//);
+2 -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>',
};
-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 }),
+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,
+47
View File
@@ -846,9 +846,13 @@ test('route numbers follow the page language and never break from their unit', (
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(994), '990\u00a0m');
assert.equal(routeDistanceText(996), '1.0\u00a0km');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(59.4), '59\u00a0s');
assert.equal(routeDurationText(59.6), '1\u00a0min');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
@@ -872,3 +876,46 @@ 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м');
});
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',
],
[
'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,
);
}
});
-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([]), []);
+15
View File
@@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => {
assert.equal(card.details[0], 'high · N20');
});
test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
const crossed = buildFireCard(
{ fire: fire({ frp: 9.96 }), position: {} },
NOW,
);
assert.equal(crossed.title, '▲ 10 MW');
const below = buildFireCard(
{ fire: fire({ frp: 9.94 }), position: {} },
NOW,
);
assert.equal(below.title, '▲ 9.9 MW');
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
+24
View File
@@ -8,6 +8,9 @@ import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { getSelectedEntityContext } from './contextStore.js';
import { setLocale } from '../i18n/index.js';
import { shownEntityLabel } from '../voice/speech.js';
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
@@ -234,6 +237,27 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
}
});
// The voice model reads these labels as data (track_entity, get_entity_context,
// pointer_context); the voice card translates them where it shows them.
test('fire labels stay English data in a translated interface', () => {
const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } });
try {
setLocale('ru');
h.click();
assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW');
// The card's pattern must match what the producer actually writes.
assert.equal(
shownEntityLabel(getSelectedEntityContext().label),
'Пожар · FRP 1520 МВт',
);
assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW');
assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW');
} finally {
h.cleanup();
setLocale('en');
}
});
test('a click on neither sprite nor card clears without moving the camera', () => {
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
try {
+11
View File
@@ -39,6 +39,17 @@ export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
}
/**
* Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once
* one-decimal rounding reaches 10. "10.0" would sit beside a whole "10".
* @param {number} frp Megawatts.
* @returns {string}
*/
export function formatFrpMw(frp) {
const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1;
return Number(frp).toFixed(digits);
}
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '')
+9
View File
@@ -6,6 +6,7 @@ import {
FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID,
satelliteShortName,
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
@@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
assert.equal(formatFrpMw(9.96), '10');
assert.equal(formatFrpMw(9.94), '9.9');
assert.equal(formatFrpMw(0.8), '0.8');
assert.equal(formatFrpMw(1520.27), '1520');
assert.equal(formatFrpMw(210.4), '210');
});
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
assert.equal(satelliteShortName('N20'), 'N20');
assert.equal(satelliteShortName('N21'), 'N21');
-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();
}
});
+100 -28
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';
@@ -1040,32 +1040,104 @@ export function detachImportedTime(dataSource) {
return dataSource;
}
/**
* Parse KML or GPX text with the namespace repairs Cesium applies.
* @param {string} text
* @param {string} what Format name for the error message.
* @returns {Document}
*/
/** 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',
+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.
+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);
});
-20
View File
@@ -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,
+60 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/**
* LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/
export class LayerLifecycle {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
{
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
}
}
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval
: 0;
if (refreshInterval > 0) {
entry.intervalId = setInterval(() => {
const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval);
};
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) {
entry.intervalId = setInterval(() => {
if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle();
if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
this._publishActivity({ type: 'status' });
if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
}
if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null;
const cancelEnable = () => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
await abortCleanup;
let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
};
const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
let cleanupConfirmed = false;
try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling.
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Manager-owned periodic refresh work has one normalized loading/error
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
return false;
}
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
}
+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 -1
View File
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
/** Compatibility facade for callers that construct a manager with its panel. */
export class DataLayerManager extends LayerLifecycle {
constructor(viewer, options) {
super(viewer, options);
super(viewer, { visibilityTarget: globalThis.document, ...options });
this._presentation = new LayerPresentation(this);
}
buildTogglePanel(container) {
+89
View File
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
lifecycle.refreshLayerStats();
assert.deepEqual(changes, ['status']);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('hidden-poll');
manager.register(module);
await manager.setEnabled('hidden-poll', true);
assert.equal(calls.update, 1);
doc.hidden = true;
t.mock.timers.tick(10000);
assert.equal(calls.update, 1);
assert.equal(await manager.refreshLayer('hidden-poll'), true);
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
doc.hidden = false;
for (const fn of listeners) fn();
for (const fn of listeners) fn();
assert.equal(calls.update, 3);
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(10000);
assert.equal(calls.update, 3);
});
async function resumeBetweenTicks(t, { failResume = false } = {}) {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('resume-poll');
const updates = [];
module.update = async () => {
calls.update++;
updates.push(Date.now());
await Promise.resolve();
if (failResume && updates.length === 2) throw new Error('resume failed');
};
manager.register(module);
await manager.setEnabled('resume-poll', true);
assert.deepEqual(updates, [1000]);
doc.hidden = true;
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
t.mock.timers.tick(500); // 2500: return between ticks
doc.hidden = false;
for (const fn of listeners) fn();
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
if (failResume) assert.equal(managerRefreshError, 'resume failed');
else assert.equal(managerRefreshError, null);
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
assert.deepEqual(updates, [1000, 2500]);
t.mock.timers.tick(1); // 3500: one interval after the resume poll
assert.deepEqual(updates, [1000, 2500, 3500]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(5000);
assert.equal(updates.length, 4);
}
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
await resumeBetweenTicks(t);
});
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
await resumeBetweenTicks(t, { failResume: true });
});
+10 -2
View File
@@ -1,7 +1,6 @@
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,
@@ -43,7 +42,10 @@ import {
formatAwarenessLabel,
getAwarenessNavigationTargets,
} from './militaryAwarenessEngine.js';
import { formatAwarenessCount } from '../layers/awareness/panel.js';
import {
formatAwarenessCount,
presentAwarenessDistance,
} from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
const militaryAwarenessSource = readLayerSource(
@@ -70,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => {
assert.equal(formatAwarenessCount({ count: null }), '?');
});
test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => {
assert.equal(presentAwarenessDistance(999.4), '999\u00a0m');
assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km');
assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km');
});
function installAwarenessRuntime({
isEnabled = () => true,
isEffectivelyEnabled = isEnabled,
-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();
}
});
+25 -8
View File
@@ -54,6 +54,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 +118,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 +135,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}`;
+20
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',
-1
View File
@@ -28,4 +28,3 @@ export {
createCableReferenceSweepGate,
updateCableReferenceStem,
} from '../layers/submarineCables/index.js';
export default createTeleGeographySubmarineCableLayer();
+3
View File
@@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({
for (let i = 0; i < missing.length; i += 1) {
const result = fetched[i];
if (validTerrainResult(result)) {
// Re-insert rather than overwrite: a Map keeps an existing key in
// its old position, and the proxy evicts from the front.
cache.delete(missing[i].key);
cache.set(missing[i].key, { at: fetchedAt, result });
cacheChanged = true;
} else if (absentTerrainDatum(result)) {
+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,
-8
View File
@@ -1,5 +1,4 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
@@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot(
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();