Merge branch 'main' into codex/shared-geodesy

This commit is contained in:
Sameh Khamis committed 2026-10-17 11:42:58 -07:00
commit 64d7ed3c61
1373 files changed
+245910 -10664

No files matched your search

@@ -0,0 +1,107 @@
// The Air quality layer (AQHI) reads in the UI language: layer name, Weather
// card (title, coverage, status, legend, risk) and its own failure text.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { translateFeedError } from '../../ui/statusText.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
function harness(source) {
const layer = createAirQualityLayer({
source,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
const viewer = { dataSources: { add() {}, remove() {} } };
layer.init(viewer);
layer.enable(viewer);
return layer;
}
const reading = {
id: 'eccc:A',
provider: 'eccc',
scale: 'AQHI',
value: 2,
band: 'low',
stationId: 'A',
name: 'Toronto',
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: '2',
risk: 'Low health risk',
note: null,
};
for (const locale of ['es', 'ru']) {
test(`${locale}: the layer name is translated`, () => {
inLocale(locale, () => {
assert.notEqual(t('Air quality'), 'Air quality', 'catalog has the entry');
assert.equal(layerNameForId('air-quality'), t('Air quality'));
assert.equal(localizeLayerName('Air quality'), t('Air quality'));
});
});
test(`${locale}: the Weather card of Air quality is translated`, async () => {
const layer = harness({ getSnapshot: async () => [reading] });
await layer.update();
const controls = inLocale(locale, () => layer.getRowControls());
const english = [
controls.summary.label,
controls.summary.coverage,
controls.summary.status,
...controls.legend.map((entry) => entry.label),
...controls.list.items.map((item) => item.text),
].filter((text) =>
/\b(Air quality|Canada only|stations reporting|Low|Moderate|High|health risk)\b/.test(
text,
),
);
assert.deepEqual(english, [], `English left in the ${locale} card`);
});
test(`${locale}: the Air quality failure text is translated in the row`, () => {
const row = inLocale(locale, () =>
LayerPanel.prototype._buildMetaText({
id: 'air-quality',
enabled: true,
stats: {
count: 0,
error: 'Air quality request failed',
source: 'ECCC',
lastUpdate: null,
},
}),
);
assert.ok(!row.includes('Air quality request failed'), row);
});
}
for (const locale of ['en', 'es', 'ru']) {
test(`${locale}: the Weather card shows an HTTP failure as the row does`, async () => {
const layer = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
const card = inLocale(locale, () => layer.getRowControls().summary.status);
assert.equal(
card,
inLocale(locale, () => translateFeedError('ECCC HTTP 503')),
);
});
}
+295
View File
@@ -0,0 +1,295 @@
import * as Cesium from 'cesium';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_OVERLAY_SOURCE_ID,
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
AIR_QUALITY_PROVIDERS,
createAirQualityOverlayEntry,
mapAnalystRecord,
newestObservation,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
} from './model.js';
export * from './model.js';
export { createEcccAirQualitySource } from './source.js';
/** Station markers are a point reading, not an extent. */
const STATION_MARKER_RADIUS_M = 6_000;
/** Readings listed in the readout card. */
const READOUT_LIMIT = 5;
/** Own one air-quality display, its refresh lifecycle, and its readout card. */
export function createAirQualityLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Air quality requires a snapshot source');
if (!overlayHost) throw new TypeError('Air quality requires an overlay host');
let _viewer = null;
let _request = null;
let _dataSource = null;
let _readings = [];
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let rowControlsListener = null;
const notify = () => rowControlsListener?.();
/** The camera's lon/lat rectangle in degrees, or null when unresolvable. */
const viewRectangle = () => {
const rectangle = _viewer?.camera?.computeViewRectangle?.();
if (!rectangle) return null;
const toDeg = Cesium.Math.toDegrees;
return {
west: toDeg(rectangle.west),
south: toDeg(rectangle.south),
east: toDeg(rectangle.east),
north: toDeg(rectangle.north),
};
};
const layer = {
id: 'air-quality',
name: 'Air quality',
icon: '◍',
source: 'ECCC MSC GeoMet',
updateInterval: 1_200_000,
init(viewer) {
if (_viewer) throw new Error('Air quality layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('air-quality');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true);
notify();
},
disable() {
// Abort in flight: a response arriving after disable must not redraw.
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
notify();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const readings = await source.getSnapshot({ signal: request.signal });
// A superseded or aborted request must not touch the scene, and neither
// must one that lands after the layer was switched off.
if (request.signal.aborted || _request !== request || !_enabled)
return false;
_readings = readings;
this._render();
_count = _readings.length;
_lastUpdate = Date.now();
_lastError = null;
notify();
return true;
} catch (error) {
if (request.signal.aborted || _request !== request) return false;
_lastError = error?.message || msg('Air quality request failed');
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
notify();
return false;
} finally {
if (_request === request) _request = null;
}
},
/** Rebuild entities and overlay labels from the current readings. */
_render() {
if (!_dataSource) return;
_dataSource.entities.removeAll();
const overlayEntries = [];
for (const reading of _readings) {
const position = Cesium.Cartesian3.fromDegrees(
reading.lon,
reading.lat,
);
const color = Cesium.Color.fromCssColorString(reading.color);
_dataSource.entities.add(
new Cesium.Entity({
id: `air-quality:${reading.id}`,
position,
ellipse: {
// Static axes, as in the earthquake layer: a per-frame axis
// re-tessellates the clamped ground primitive every frame.
semiMajorAxis: STATION_MARKER_RADIUS_M,
semiMinorAxis: STATION_MARKER_RADIUS_M,
material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)),
outline: true,
outlineColor: color.withAlpha(0.9),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
provider: reading.provider,
scale: reading.scale,
value: reading.value,
band: reading.band,
name: reading.name,
zone: reading.zone,
risk: reading.risk,
observedMs: reading.observedMs,
note: reading.note,
lat: reading.lat,
lon: reading.lon,
},
}),
);
overlayEntries.push(
createAirQualityOverlayEntry({
id: reading.id,
position,
title: `${reading.name} ${reading.label}`,
accent: reading.color,
value: reading.value,
}),
);
}
if (_enabled) {
overlayHost.setEntries(
AIR_QUALITY_OVERLAY_SOURCE_ID,
selectAirQualityOverlayCohort(overlayEntries),
{
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_enabled = false;
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
if (_dataSource) {
viewer?.dataSources?.remove(_dataSource, true);
_dataSource = null;
}
_viewer = null;
_readings = [];
_count = 0;
_lastUpdate = null;
_lastError = null;
rowControlsListener = null;
},
/**
* Readout descriptor for the Weather panel: the band legend, and the worst
* readings currently in view.
*
* `readout: true` renders the toggle and source line only; the card owns
* the rest. Coverage is stated plainly because this layer is not global.
*/
getRowControls() {
const provider = AIR_QUALITY_PROVIDERS.eccc;
const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT);
const newest = _readings.reduce(
(max, reading) => Math.max(max, reading.observedMs || 0),
0,
);
// Readings and errors stay English data (analyst records, stats); the
// card translates them as the Layers panel row does. A provider's own
// error text has no catalog entry and shows as it came.
return {
readout: true,
summary: {
label: t(provider.label),
coverage: t(provider.coverage),
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? translateFeedError(_lastError)
: _count === 0
? _lastUpdate === null
? t('No readings available')
: t('No current readings')
: tn(
_count,
'{count} station reporting',
'{count} stations reporting',
),
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: t(band.label),
})),
legendCategorical: true,
list: {
ariaLabel: t('Highest air-quality readings in view'),
items: inView.map((reading, index) => ({
id: reading.id,
ordinal: index + 1,
lead: `${reading.scale} ${reading.label}`,
text: `${reading.name} · ${t(reading.risk)}`,
accent: reading.color,
})),
},
};
},
setRowControlsListener(listener) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
/**
* Snapshot readings as JSON-safe records for the analyst query engine.
* @param {number} [maxCount=500]
* @returns {Array<object>}
*/
getAnalystRecords(maxCount = 500) {
if (!_enabled || !_readings.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 500;
return _readings
.slice(0, limit)
.map((reading, index) => mapAnalystRecord(reading, index));
},
getStats() {
// `lastUpdate` is when a response was received; `observedAt` is when the
// network actually measured. Freshness is judged on the latter, so a feed
// that keeps answering with hours-old observations reports STALE rather
// than being narrated as live by the HUD and voice provenance envelope.
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
observedAt: newestObservation(_readings),
// Only a completed refresh can be stale; before one there is no feed.
stale: _lastUpdate !== null && readingsAreStale(_readings),
};
},
};
return layer;
}
+399
View File
@@ -0,0 +1,399 @@
/**
* @module layers/airQuality/model
*
* Air-quality readings, normalized across networks. Pure — no Cesium, no
* network.
*
* Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to
* 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints;
* Europe's EAQI is a five-step category. A reading is therefore carried as
* `{ provider, scale, value, band }` — who measured it, which index it is, the
* value on that index's own terms, and the band it falls in — so a second
* network can be added without retrofitting a shared numeric meaning that does
* not exist.
*
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
* later provider registers against.
*/
// A portable export (package.json): no i18n here. The labels stay English data;
// the Weather card (index.js) shows them translated.
const msg = (text) => text;
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({
id: 'low',
label: msg('Low'),
risk: msg('Low health risk'),
color: '#4cc9f0',
}),
Object.freeze({
id: 'moderate',
label: msg('Moderate'),
risk: msg('Moderate health risk'),
color: '#ffd43b',
}),
Object.freeze({
id: 'high',
label: msg('High'),
risk: msg('High health risk'),
color: '#ff6b35',
}),
Object.freeze({
id: 'very-high',
label: msg('Very High'),
risk: msg('Very High health risk'),
color: '#c1121f',
}),
]);
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
/** Colour used when a station reports no usable reading. */
export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785';
export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality';
export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48;
export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32;
/**
* A reading this old is stale enough that displaying it would misinform.
* AQHI publishes hourly, so six hours is five missed publications.
*/
export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000;
/**
* How old the newest observation may be before the layer reports itself stale.
*
* This is deliberately separate from the age at which a reading is dropped.
* `lastUpdate` records when we last *received* a response, which stays current
* even when the network publishes nothing new; freshness has to be judged on
* the source's own observation time instead. AQHI publishes hourly, so a newest
* reading beyond two hours means at least one publication was missed and the
* feed should not be described as live.
*/
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
/**
* The newest observation time across a set of readings, or null when empty.
* @param {Array<object>} readings
* @returns {number|null} Epoch ms.
*/
export function newestObservation(readings) {
if (!Array.isArray(readings) || readings.length === 0) return null;
let newest = null;
for (const reading of readings) {
const at = Number(reading?.observedMs);
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at;
}
return newest;
}
/**
* Is a set of readings stale, judged on source observation time? An empty
* set is stale: a refresh that kept no current observation has nothing live.
* @param {Array<object>} readings
* @param {number} [now=Date.now()]
* @returns {boolean}
*/
export function readingsAreStale(readings, now = Date.now()) {
const newest = newestObservation(readings);
// No current observation at all — every row was dropped as too old or
// unusable — is the stalest a feed can be, not a nominal one.
if (newest === null) return true;
return now - newest > STALE_AFTER_MS;
}
/**
* Registered provider descriptors. `coverage` is stated plainly because the
* layer is not global and the row must say so.
*/
export const AIR_QUALITY_PROVIDERS = Object.freeze({
eccc: Object.freeze({
id: 'eccc',
scale: 'AQHI',
label: msg('Air quality'),
network: 'Environment and Climate Change Canada',
coverage: msg('Canada only · ECCC AQHI network'),
/** ECCC's published band thresholds, evaluated in order. */
bands: Object.freeze([
Object.freeze({ id: 'low', max: 3 }),
Object.freeze({ id: 'moderate', max: 6 }),
Object.freeze({ id: 'high', max: 10 }),
Object.freeze({ id: 'very-high', max: Infinity }),
]),
}),
});
/** Band descriptor for an id, or null. */
export function airQualityBandById(bandId) {
return BAND_BY_ID.get(String(bandId ?? '')) || null;
}
/** Accent colour for a band id, falling back to neutral grey. */
export function airQualityColor(bandId) {
return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR;
}
/**
* The published AQHI value for a raw ECCC reading: an integer from 1 up.
*
* ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no
* "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error,
* and is published as 1.
*
* @param {number|string|null|undefined} raw - Raw `aqhi` value from the API.
* @returns {number|null} Published integer AQHI, or null when unusable.
*/
export function aqhiDisplayValue(raw) {
// Guard the empty forms BEFORE coercing: Number(null) and Number('') are both
// 0, which would otherwise floor to a confident "AQHI 1" for a station that
// reported nothing at all.
if (raw === null || raw === undefined || raw === '') return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return null;
if (typeof raw === 'string' && raw.trim() === '') return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.max(1, Math.round(value));
}
/**
* The band id for a value on a provider's scale.
* @param {string} providerId
* @param {number|null} value - Already in the provider's published form.
* @returns {string|null}
*/
export function bandForValue(providerId, value) {
const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')];
if (!provider || !Number.isFinite(value)) return null;
return provider.bands.find((band) => value <= band.max)?.id ?? null;
}
/**
* Display text for a value on a provider's scale.
* AQHI collapses everything above ten into the open-ended "10+".
* @param {string} providerId
* @param {number|null} value
* @returns {string}
*/
export function valueLabel(providerId, value) {
if (!Number.isFinite(value)) return '--';
if (providerId === 'eccc') return value > 10 ? '10+' : String(value);
return String(value);
}
/** Plain-language risk phrase for a band id. */
export function bandRiskText(bandId) {
return airQualityBandById(bandId)?.risk ?? null;
}
/**
* Validate a station FeatureCollection into an id-keyed catalog.
*
* Atomic: a structurally broken response returns null so the caller keeps its
* last good catalog. A station missing coordinates is skipped, since one
* unusable station is not evidence the catalog is broken.
*
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
* @returns {Map<string, object>|null} Stations by location_id, or null.
*/
export function normalizeAqhiStations(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const stations = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
const [lon, lat] = coordinates.map(Number);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
const id = String(properties.location_id ?? feature.id ?? '').trim();
if (!id) continue;
stations.set(id, {
id,
name: String(properties.location_name_en || id).trim(),
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
zone: String(properties['eccc_administrative-zone'] || '').trim(),
lat,
lon,
});
}
return stations;
}
/**
* Join latest ECCC observations onto known stations into provider-shaped rows.
*
* The catalog is the only thing that supplies a reading its coordinates, so an
* observation without a matching station cannot be placed and is dropped.
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
* air-quality number presented as current is worse than no number.
*
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
* @param {Map<string, object>} stations - Known stations by location_id.
* @param {number} [now=Date.now()] - Clock seam for tests.
* @returns {Array<object>|null} Normalized readings, or null when malformed.
*/
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
if (!Array.isArray(geojson?.features)) return null;
if (!(stations instanceof Map)) return null;
const provider = AIR_QUALITY_PROVIDERS.eccc;
const newest = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const stationId = String(properties.location_id ?? '').trim();
const station = stations.get(stationId);
if (!station) continue;
const value = aqhiDisplayValue(properties.aqhi);
if (value === null) continue;
const observedMs = Date.parse(properties.observation_datetime);
if (!Number.isFinite(observedMs)) continue;
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
const previous = newest.get(stationId);
if (previous && previous.observedMs >= observedMs) continue;
const band = bandForValue(provider.id, value);
newest.set(stationId, {
id: `${provider.id}:${stationId}`,
provider: provider.id,
scale: provider.scale,
value,
band,
stationId,
name: station.name,
zone: station.zone,
lat: station.lat,
lon: station.lon,
observedMs,
color: airQualityColor(band),
label: valueLabel(provider.id, value),
risk: bandRiskText(band),
note: String(properties.special_notes_en || '').trim() || null,
});
}
return [...newest.values()].sort(
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
);
}
/**
* Build the source-owned presentation for one station label.
* @param {object} input
* @returns {object}
*/
export function createAirQualityOverlayEntry({
id,
position,
title,
accent,
value,
}) {
return {
id: String(id),
position,
variant: 'label',
title,
accent,
// Worse air wins the label budget — the reading people need to see first.
priority: Math.round(Number(value) || 0) * 1000,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the worst readings, with stable identity as the tie-break. */
export function selectAirQualityOverlayCohort(
entries,
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(
0,
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(entries) || cap === 0) return [];
return entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
)
.slice(0, cap);
}
/**
* The worst readings inside a lon/lat rectangle, for the readout card.
*
* A null rectangle means the camera could not resolve one (a fully oblique or
* space-level view); everything is then in scope rather than nothing, so the
* card degrades to the worst readings overall instead of going blank.
*
* @param {Array<object>} readings
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
* @param {number} [limit=5]
* @returns {Array<object>}
*/
export function readingsInView(readings, rect, limit = 5) {
if (!Array.isArray(readings)) return [];
const cap = Math.max(0, Math.floor(Number(limit) || 0));
if (cap === 0) return [];
const inside = !rect
? readings
: readings.filter((reading) => {
if (reading.lat < rect.south || reading.lat > rect.north) return false;
// A rectangle crossing the antimeridian has west > east.
return rect.west <= rect.east
? reading.lon >= rect.west && reading.lon <= rect.east
: reading.lon >= rect.west || reading.lon <= rect.east;
});
return inside
.slice()
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
.slice(0, cap);
}
/**
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
* @param {object|null|undefined} raw
* @param {number} [index=0]
* @returns {object}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
provider: text(raw?.provider),
scale: text(raw?.scale),
value: num(raw?.value),
band: text(raw?.band),
name: text(raw?.name),
zone: text(raw?.zone),
risk: text(raw?.risk),
lat: num(raw?.lat),
lon: num(raw?.lon),
observedMs: num(raw?.observedMs),
};
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_PROVIDERS,
AIR_QUALITY_UNKNOWN_COLOR,
OBSERVATION_MAX_AGE_MS,
STALE_AFTER_MS,
airQualityColor,
aqhiDisplayValue,
bandForValue,
bandRiskText,
mapAnalystRecord,
normalizeAqhiObservations,
normalizeAqhiStations,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
valueLabel,
} from './model.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const RECENT = '2026-09-24T18:00:00Z';
const collection = (features) => ({ type: 'FeatureCollection', features });
const station = (id, name, zone, lon, lat) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
location_id: id,
location_name_en: name,
'eccc_administrative-zone': zone,
},
});
const observation = (id, aqhi, when = RECENT) => ({
type: 'Feature',
properties: { location_id: id, aqhi, observation_datetime: when },
});
const STATIONS = collection([
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
]);
// ── provider-shaped records ────────────────────────────────────────────────
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
// US AQI is 0-500 on concentration. The shape names which index a value is on
// so a second network can be added without inventing a shared meaning.
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2.32)]),
stations,
NOW,
);
assert.equal(row.provider, 'eccc');
assert.equal(row.scale, 'AQHI');
assert.equal(row.value, 2);
assert.equal(row.band, 'low');
});
test('bands map from a value on a named provider scale', () => {
assert.equal(bandForValue('eccc', 1), 'low');
assert.equal(bandForValue('eccc', 3), 'low');
assert.equal(bandForValue('eccc', 4), 'moderate');
assert.equal(bandForValue('eccc', 7), 'high');
assert.equal(bandForValue('eccc', 11), 'very-high');
// An unregistered provider cannot be banded rather than being guessed at.
assert.equal(bandForValue('unknown-network', 5), null);
assert.equal(bandForValue('eccc', null), null);
});
test('the provider states its coverage plainly', () => {
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
});
test("values above ten collapse to the scale's open-ended label", () => {
assert.equal(valueLabel('eccc', 10), '10');
assert.equal(valueLabel('eccc', 11), '10+');
assert.equal(valueLabel('eccc', null), '--');
});
test('every band has a distinct colour, and an unknown band is neutral', () => {
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
assert.equal(new Set(colors).size, colors.length);
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
assert.equal(bandRiskText('high'), 'High health risk');
assert.equal(bandRiskText(null), null);
});
// ── published form ─────────────────────────────────────────────────────────
test('AQHI rounds to the published integer form and floors at 1', () => {
assert.equal(aqhiDisplayValue(1.08), 1);
assert.equal(aqhiDisplayValue(3.5), 4);
assert.equal(aqhiDisplayValue(0), 1);
assert.equal(aqhiDisplayValue('3'), 3);
});
test('an absent reading never becomes a confident AQHI 1', () => {
// Number(null) and Number('') are both 0, which would floor to 1.
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
}
});
// ── snapshot validation ────────────────────────────────────────────────────
test('stations normalize into an id-keyed catalog carrying their zone', () => {
const stations = normalizeAqhiStations(STATIONS);
assert.equal(stations.size, 3);
assert.equal(stations.get('OAAAA').zone, 'ont');
});
test('malformed payloads are rejected atomically', () => {
assert.equal(normalizeAqhiStations(null), null);
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
const stations = normalizeAqhiStations(STATIONS);
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
assert.equal(
normalizeAqhiObservations(collection([]), 'not a map', NOW),
null,
);
});
test('an observation with no matching station is dropped', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
stations,
NOW,
);
assert.equal(rows.length, 1);
});
test('a stale reading is dropped rather than shown as current', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.stationId),
['OAAAA'],
);
});
test('a snapshot whose every observation was too old is stale, not nominal', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
stations,
NOW,
);
assert.deepEqual(rows, []);
assert.equal(readingsAreStale(rows, NOW), true);
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
assert.equal(
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
true,
);
});
test('the newest reading per station wins and worst air sorts first', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
observation('OAAAA', 5),
]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.value),
[7, 5],
);
});
// ── readout selection ──────────────────────────────────────────────────────
const reading = (id, value, lat, lon) => ({
id,
stationId: id,
value,
lat,
lon,
name: id,
});
test('the readout lists the worst readings inside the view rectangle', () => {
const rows = [
reading('west', 9, 51.0, -114.0),
reading('east', 4, 43.6, -79.3),
reading('mild', 2, 51.1, -114.1),
];
const inView = readingsInView(
rows,
{ west: -115, south: 50, east: -113, north: 52 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['west', 'mild'],
);
});
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
// A space-level or fully oblique camera cannot produce a rectangle; the card
// should still say something useful.
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
assert.deepEqual(
readingsInView(rows, null, 5).map((r) => r.id),
['b', 'a'],
);
assert.deepEqual(readingsInView(rows, null, 0), []);
assert.deepEqual(readingsInView(null, null, 5), []);
});
test('a view rectangle crossing the antimeridian still selects correctly', () => {
// west > east is how a dateline-crossing rectangle is expressed.
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
const inView = readingsInView(
rows,
{ west: 170, south: -10, east: -170, north: 10 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['near'],
);
});
test('the overlay cohort is capped and deterministic', () => {
const entries = Array.from({ length: 120 }, (_, i) => ({
id: `s${i}`,
priority: i % 11,
}));
const cohort = selectAirQualityOverlayCohort(entries);
assert.ok(cohort.length <= 48);
assert.deepEqual(
cohort.map((e) => e.id),
selectAirQualityOverlayCohort(entries).map((e) => e.id),
);
});
test('analyst records are JSON-safe and name the provider and scale', () => {
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2)]),
stations,
NOW,
);
const record = mapAnalystRecord(row, 0);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(record.provider, 'eccc');
assert.equal(record.scale, 'AQHI');
const empty = mapAnalystRecord(undefined, 7);
assert.equal(empty.id, 'AIR-0007');
for (const [key, value] of Object.entries(empty)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
}
});
+275
View File
@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAirQualityLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createAirQualityLayer({
source,
overlayHost: {
setEntries(...args) {
events.push(args);
},
setVisible() {},
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
}
const reading = (id, value, overrides = {}) => ({
id: `eccc:${id}`,
provider: 'eccc',
scale: 'AQHI',
value,
band: 'low',
stationId: id,
name: id,
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: String(value),
risk: 'Low health risk',
note: null,
...overrides,
});
const staticSource = (rows) => ({ getSnapshot: async () => rows });
test('a layer requires both a snapshot source and an overlay host', () => {
assert.throws(() => createAirQualityLayer(), /snapshot source/);
assert.throws(
() => createAirQualityLayer({ source: staticSource([]) }),
/overlay host/,
);
});
test('initialization is single-use and owns exactly one data source', () => {
const { layer, viewer, sources } = harness(staticSource([]));
assert.equal(sources.length, 1);
assert.throws(() => layer.init(viewer), /already initialized/);
layer.destroy(viewer);
assert.equal(sources.length, 0);
});
test('a refresh renders readings and publishes an overlay cohort', async () => {
const { layer, events } = harness(
staticSource([reading('A', 2), reading('B', 7)]),
);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().error, null);
assert.equal(events.at(-1)[0], 'air-quality');
});
// ── the abort + enabled guard ──────────────────────────────────────────────
test('a response landing after disable cannot redraw the scene', async () => {
// The failure this prevents: disable() clears the overlay, then an in-flight
// response resolves and repopulates it behind the user's back.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const { layer, events } = harness({
getSnapshot: async ({ signal } = {}) => {
await gate;
signal?.throwIfAborted();
return [reading('A', 5)];
},
});
const pending = layer.update();
layer.disable();
const publishedBefore = events.length;
release();
assert.equal(await pending, false, 'a late response must not be applied');
assert.equal(
events.length,
publishedBefore,
'no overlay write after disable',
);
assert.equal(layer.getStats().count, 0);
});
test('disable aborts the request in flight', async () => {
let seenSignal = null;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
seenSignal = signal;
await new Promise((resolve) => setTimeout(resolve, 40));
signal?.throwIfAborted();
return [];
},
});
const pending = layer.update();
layer.disable();
await pending;
assert.ok(seenSignal, 'a signal was passed through to the source');
assert.equal(seenSignal.aborted, true);
});
test('a superseded refresh does not clobber the newer one', async () => {
let call = 0;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
call += 1;
const mine = call;
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
signal?.throwIfAborted();
return [reading(`call-${mine}`, mine)];
},
});
const first = layer.update();
const second = layer.update();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult, false, 'the superseded call reports no update');
assert.equal(secondResult, true);
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
});
test('update is a no-op while the layer is disabled', async () => {
const { layer } = harness(staticSource([reading('A', 2)]));
layer.disable();
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 0);
});
// ── readout descriptor ─────────────────────────────────────────────────────
test('the readout descriptor carries the band legend and coverage', async () => {
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
await layer.update();
const controls = layer.getRowControls();
assert.equal(controls.readout, true);
assert.match(controls.summary.coverage, /Canada only/);
// The Weather panel's legend shape: one {color, label} per band.
assert.equal(controls.legendCategorical, true);
assert.deepEqual(
controls.legend.map((entry) => entry.label),
['Low', 'Moderate', 'High', 'Very High'],
);
assert.ok(
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
);
// Worst first, and every item keyed.
assert.deepEqual(
controls.list.items.map((i) => i.text),
['B · Low health risk', 'A · Low health risk'],
);
assert.ok(controls.list.items.every((i) => i.id));
assert.match(controls.summary.status, /2 stations reporting/);
});
test('the readout reports an error rather than a silent empty card', async () => {
const { layer } = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
assert.match(layer.getRowControls().summary.status, /503/);
});
test('row-control listeners are notified on refresh and teardown-safe', async () => {
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
let calls = 0;
layer.setRowControlsListener(() => {
calls += 1;
});
await layer.update();
assert.ok(calls >= 1, 'a refresh notifies the card');
layer.destroy(viewer);
const after = calls;
layer.setRowControlsListener(() => {
calls += 1;
});
assert.equal(calls, after);
});
// ── feed-state honesty ─────────────────────────────────────────────────────
test('freshness is judged on observation time, not on when we last fetched', async () => {
// lastUpdate stays current every time the network answers, even when it
// answers with nothing new. Judging freshness on it would let the HUD and
// voice describe hours-old air quality as live.
const aged = Date.now() - STALE_AFTER_MS - 60_000;
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: aged })]),
);
await layer.update();
const stats = layer.getStats();
assert.ok(
stats.lastUpdate >= Date.now() - 5_000,
'we did just receive a response',
);
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
assert.equal(stats.stale, true);
assert.equal(
layerFeedState(stats),
'stale',
'provenance must not read this as nominal',
);
});
test('a current reading reports nominal', async () => {
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
);
await layer.update();
const stats = layer.getStats();
assert.equal(stats.stale, false);
assert.equal(layerFeedState(stats), 'nominal');
});
test('a refresh that kept no current observation reports stale, not nominal', async () => {
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
// that only has hours-old rows yields an empty snapshot. That is the
// stalest a feed can be; it must not read as a healthy empty layer.
const { layer } = harness(staticSource([]));
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
await layer.update();
const stats = layer.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.observedAt, null);
assert.equal(stats.stale, true);
assert.equal(layerFeedState(stats), 'stale');
assert.equal(layer.getRowControls().summary.status, 'No current readings');
});
test('a failed refresh over good readings reports degraded, not nominal', async () => {
let fail = false;
const { layer } = harness({
getSnapshot: async () => {
if (fail) throw new Error('ECCC HTTP 503');
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
},
});
await layer.update();
assert.equal(layerFeedState(layer.getStats()), 'nominal');
fail = true;
await layer.update();
const stats = layer.getStats();
assert.ok(stats.count > 0, 'the previous readings are still displayed');
assert.equal(
layerFeedState(stats),
'degraded',
'showing old data after a failure is degraded',
);
});
+110
View File
@@ -0,0 +1,110 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel and the Weather card show them translated.
const msg = (text) => text;
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
/** Comfortably above Canada's ~134 stations without inviting a huge page. */
const STATION_LIMIT = 500;
const OBSERVATION_LIMIT = 500;
/** Station geometry is near-static — a few changes a year. Refetch twice a day. */
const STATION_TTL_MS = 12 * 60 * 60 * 1000;
/** A full 500-row GeoJSON page is a few hundred KB; anything past this is not a page. */
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
/**
* Request and validate a complete ECCC AQHI snapshot before it can replace
* displayed readings. The first registered air-quality provider.
*
* Two collections are involved. `aqhi-stations` is the catalog — it supplies
* every reading its coordinates and is cached for twelve hours because it
* barely changes. `aqhi-observations-realtime` is the hourly reading feed,
* requested with `latest=true` so the response is current values rather than
* the full observation history, which is roughly 2,000 rows nationally.
*
* The station catalog is only replaced when a refresh actually returns
* stations, so a transient empty response cannot blank the layer.
*
* @param {object} [options]
* @param {Function} [options.fetchImpl] - Injected fetch, for tests.
* @param {() => number} [options.now] - Clock seam, for tests.
* @param {number} [options.timeoutMs=15000] - Deadline for each request.
* @returns {{getSnapshot: Function}}
*/
export function createEcccAirQualitySource({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
timeoutMs = 15_000,
} = {}) {
/** @type {Map<string, object>} */
let stations = new Map();
let stationsFetchedAt = 0;
/** One bounded GeoJSON page: deadline, caller abort, byte cap, row cap. */
const getJson = async (url, limit, signal) => {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error(msg('ECCC request timed out'))),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
controller.signal,
);
// The server's `limit` is a request, not a guarantee.
if (Array.isArray(payload?.features) && payload.features.length > limit)
return { ...payload, features: payload.features.slice(0, limit) };
return payload;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
};
return {
async getSnapshot({ signal } = {}) {
const clock = now();
if (stations.size === 0 || clock - stationsFetchedAt > STATION_TTL_MS) {
const payload = await getJson(
`${STATIONS_URL}?f=json&limit=${STATION_LIMIT}`,
STATION_LIMIT,
signal,
);
const next = normalizeAqhiStations(payload);
if (!next) throw new Error(msg('Malformed AQHI station response'));
// An empty refresh must not wipe a good catalog.
if (next.size > 0) {
stations = next;
stationsFetchedAt = clock;
}
}
if (stations.size === 0)
throw new Error(msg('No AQHI stations available'));
const payload = await getJson(
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
OBSERVATION_LIMIT,
signal,
);
const readings = normalizeAqhiObservations(payload, stations, clock);
if (!readings)
throw new Error(msg('Malformed AQHI observation response'));
return readings;
},
};
}
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEcccAirQualitySource } from './source.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const HOUR = 60 * 60 * 1000;
const station = (id) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
properties: { location_id: id, location_name_en: id },
});
const observation = (id, at = NOW, aqhi = 3) => ({
type: 'Feature',
properties: {
location_id: id,
aqhi,
observation_datetime: new Date(at - 10 * 60_000).toISOString(),
},
});
const json = (body, init) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
...init,
});
/** A fetch that serves the station catalog and observations from mutable state. */
function feed() {
const state = {
clock: NOW,
stations: [station('A')],
stationIds: null,
urls: [],
};
state.source = createEcccAirQualitySource({
now: () => state.clock,
fetchImpl: async (url) => {
state.urls.push(url);
return json({
type: 'FeatureCollection',
// Every known station reports ten minutes before the current clock.
features: url.includes('aqhi-stations')
? state.stations
: (state.stationIds || ['A']).map((id) =>
observation(id, state.clock),
),
});
},
});
state.stationRequests = () =>
state.urls.filter((url) => url.includes('aqhi-stations')).length;
return state;
}
test('the station catalog is cached for twelve hours, then refetched', async () => {
const state = feed();
await state.source.getSnapshot();
state.clock = NOW + 12 * HOUR;
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 1, 'within the TTL the cache serves');
state.clock = NOW + 12 * HOUR + 1;
await state.source.getSnapshot();
assert.equal(
state.stationRequests(),
2,
'past the TTL the catalog refreshes',
);
});
test('an empty catalog refresh keeps the previous catalog', async () => {
const state = feed();
await state.source.getSnapshot();
state.stations = [];
state.clock = NOW + 13 * HOUR;
const readings = await state.source.getSnapshot();
assert.deepEqual(
readings.map((reading) => reading.stationId),
['A'],
);
// The empty refresh did not count as fresh, so the next call retries it.
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 3);
});
test('an empty first catalog is an error, not an empty layer', async () => {
const state = feed();
state.stations = [];
await assert.rejects(state.source.getSnapshot(), /No AQHI stations/);
});
test('requests ask for JSON, the latest observations only, and a bounded page', async () => {
const state = feed();
await state.source.getSnapshot();
const [stations, observations] = state.urls.map((url) => new URL(url));
assert.equal(stations.pathname, '/collections/aqhi-stations/items');
assert.equal(stations.searchParams.get('f'), 'json');
assert.equal(stations.searchParams.get('limit'), '500');
assert.equal(
observations.pathname,
'/collections/aqhi-observations-realtime/items',
);
// Without latest=true the collection returns the full history (~2,000 rows).
assert.equal(observations.searchParams.get('latest'), 'true');
assert.equal(observations.searchParams.get('limit'), '500');
});
test('rows past the requested limit are ignored', async () => {
const state = feed();
state.stations = Array.from({ length: 600 }, (_, index) =>
station(`S${String(index).padStart(3, '0')}`),
);
state.stationIds = state.stations.map((row) => row.properties.location_id);
const readings = await state.source.getSnapshot();
assert.equal(readings.length, 500);
});
test('a malformed catalog and HTTP errors reject', async () => {
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({ features: 'no' }),
}).getSnapshot(),
/Malformed AQHI station/,
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({}, { status: 503 }),
}).getSnapshot(),
/ECCC HTTP 503/,
);
});
test('an oversized response is refused before it is parsed', async () => {
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
new Response(new Blob([big]).stream(), {
headers: { 'content-type': 'application/json' },
}),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
json({}, { headers: { 'content-length': String(5 * 1024 * 1024) } }),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
});
test('a caller abort cancels the request and detaches its listener', async () => {
const caller = new AbortController();
let requestSignal = null;
const source = createEcccAirQualitySource({
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot({ signal: caller.signal });
caller.abort(new Error('layer disabled'));
await assert.rejects(pending, /layer disabled/);
assert.equal(requestSignal.aborted, true);
const already = new AbortController();
already.abort(new Error('before start'));
let calls = 0;
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => {
calls += 1;
return json({ features: [] });
},
}).getSnapshot({ signal: already.signal }),
/before start/,
);
assert.equal(calls, 0);
});
test('a request that outlives its deadline is aborted', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const source = createEcccAirQualitySource({
timeoutMs: 1_000,
fetchImpl: (_url, { signal }) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
});
const pending = source.getSnapshot();
t.mock.timers.tick(1_000);
await assert.rejects(pending, /ECCC request timed out/);
});
+1
View File
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/layerStateCoordinator.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
+82
View File
@@ -0,0 +1,82 @@
/** One cancellable consumer of the shared terrain service, coalesced by floor cell. */
export function createAlprFloorResolver(ground) {
const pending = new Map();
const queued = new Map();
let owner = null;
let running = false;
async function pump(controller) {
try {
while (queued.size && !controller.signal.aborted) {
const batch = [...queued.values()].slice(0, 64);
for (const cell of batch) queued.delete(cell.key);
try {
await ground.resolveGroundFloorCells(
batch.map(({ lat, lon }) => ({ lat, lon })),
{ signal: controller.signal },
);
} catch {
// Missing floors remain clamped; cancellation must not publish later.
}
for (const cell of batch) {
if (pending.get(cell.key) === cell) pending.delete(cell.key);
cell.finish();
}
}
} finally {
if (owner === controller) running = false;
}
}
function resolve(points) {
if (typeof ground.resolveGroundFloorCells !== 'function')
return Promise.resolve();
owner ??= new AbortController();
const waits = [];
for (const point of points) {
const lat = Number(point.lat.toFixed(3));
const lon = Number(point.lon.toFixed(3));
const key = `${lat},${lon}`;
let cell = pending.get(key);
if (!cell) {
let finish;
const promise = new Promise((done) => {
finish = done;
});
cell = { key, lat, lon, promise, finish };
pending.set(key, cell);
queued.set(key, cell);
}
waits.push(cell.promise);
}
if (!running && queued.size) {
running = true;
void pump(owner);
}
return Promise.all(waits);
}
async function prepare(points) {
let timer;
try {
await Promise.race([
resolve(points),
new Promise((done) => {
timer = setTimeout(done, ground.FLOOR_RESOLVE_DEADLINE_MS ?? 1200);
}),
]);
} finally {
clearTimeout(timer);
}
}
function cancel() {
owner?.abort();
owner = null;
running = false;
for (const cell of pending.values()) cell.finish();
pending.clear();
queued.clear();
}
return { resolve, prepare, cancel };
}
+161 -42
View File
@@ -1,3 +1,4 @@
import { isUnavailableCapability } from '../../sources/capability.js';
import * as Cesium from 'cesium';
import {
LAYER_ID,
@@ -17,11 +18,14 @@ import {
alprCreditMarkup,
} from './model.js';
import { createAlprPresentation } from './presentation.js';
import { t, tn, msg } from '../../i18n/index.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
* A source fetches a bounded box with an AbortSignal and resolves
* { records, stale, saturated }. Records use stable string ids and latitude /
* { records, stale, saturated }, optionally with `coverage` (the bounds its
* records fully cover, reused for later views inside it) and `zoom` (their
* tile precision; `source.detailZoom(box)` names the zoom a view needs). Records use stable string ids and latitude /
* longitude in degrees; optional directionDeg is a compass bearing [0, 360).
* Manufacturer, operator and cameraType are optional display fields.
* Source label and attribution { name, description, text, href } identify the
@@ -67,6 +71,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
debounceTimer: null,
/** Snapped box of the last successful query; a view still inside it reuses its records. */
lastQueryBox: null,
/** Extract zoom of the accepted records (null for sources without tiles). */
lastQueryZoom: null,
/** Increments whenever the displayed camera set changes (QA stability probe). */
renderRevision: 0,
/** Cameras in view beyond the render cap. */
renderSaturated: false,
};
const {
initOverlay,
@@ -81,6 +91,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
} = createAlprPresentation({ state, services, source });
function setAlprStatus(status, error = null) {
@@ -109,10 +120,34 @@ export function createAlprCamerasLayer({ source, services } = {}) {
if (resetBackoff) state.retryDelayMs = 0;
}
/** Whether accepted records already answer `box` without a request. */
function canReuse(box) {
const detailZoom = source.detailZoom?.(box) ?? null;
return Boolean(
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
(state.lastQueryZoom == null ||
detailZoom == null ||
state.lastQueryZoom >= detailZoom - 1) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable',
);
}
function scheduleLoad() {
if (!state.enabled) return;
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now.
// Only a view that needs a request waits out the debounce.
const box = viewportBox(state.viewer);
if (box && canReuse(box)) {
state.debounceTimer = null;
loadCameras();
return;
}
state.debounceTimer = setTimeout(() => {
state.debounceTimer = null;
loadCameras();
@@ -142,14 +177,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
// request still in flight was for a viewport the user has since left, so it
// is aborted rather than allowed to replace the records that already cover
// this view. Failed queries never replace the last successful box.
if (
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable'
) {
// Reuse also needs the accepted records to be precise enough for this view:
// one zoom coarser than the view would pick is a few metres, never more.
if (canReuse(box)) {
if (state.abort) {
state.abort.abort();
state.abort = null;
@@ -165,7 +195,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
}
// A move inside an in-flight query must not abort and restart that query.
if (state.abort && boxContains(state.pendingQueryBox, box)) {
renderRecords();
// The accepted records may cover only part of the new view. Re-filtering
// them during floor preparation creates an intermediate marker set.
return;
}
const queryBox = snapAlprBox(box);
@@ -176,7 +207,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.loading = true;
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
setAlprStatus('loading');
renderRecords();
// Keep the current markers until the new view's records land, so a still
// camera sees one update instead of old records re-filtered, then new ones.
try {
const snapshot = await source.fetch(queryBox, requestAbort.signal);
if (
@@ -185,16 +217,43 @@ export function createAlprCamerasLayer({ source, services } = {}) {
!state.enabled
)
return;
if (snapshot.zoomIn) {
state.records = [];
state.recordById.clear();
state.noCoverage = false;
state.lastQueryBox = null;
state.lastQueryZoom = null;
clearUnavailableRetry();
renderRecords();
setAlprStatus('zoom-in');
return;
}
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
// Resolve marker floors (bounded wait, warm cells answer at once) so new
// markers are placed on the floor instead of clamped then moved.
await prepareFloors(records);
if (
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
!state.enabled
)
return;
state.noCoverage = snapshot.noCoverage === true;
state.records = records;
state.recordById = new Map(records.map((r) => [r.id, r]));
state.lastUpdate = Date.now();
state.stale = stale;
// Saturated when the QUERY truncated OR the render cap would hide cameras:
// either way the view holds more than the screen shows, and the user must
// be told rather than left with a count that disagrees with the map.
state.saturated = saturated || records.length > MAX_RENDERED;
state.lastQueryBox = queryBox;
// Saturated when the QUERY truncated; the render cap is judged against
// the cameras in view (renderSaturated). Either way the row says so.
state.saturated = saturated;
// The fetched tiles cover more than the view: pans inside them are free.
state.lastQueryBox =
snapshot.coverage && boxContains(snapshot.coverage, queryBox)
? snapshot.coverage
: queryBox;
state.lastQueryZoom = Number.isInteger(snapshot.zoom)
? snapshot.zoom
: null;
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
@@ -217,9 +276,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.stale = Boolean(state.lastUpdate);
setAlprStatus(
'unavailable',
error?.message || 'Camera source temporarily unavailable',
error?.message || msg('Camera source temporarily unavailable'),
);
scheduleUnavailableRetry();
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
if (state.abort === requestAbort) {
state.abort = null;
@@ -235,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
id: LAYER_ID,
name: 'ALPR Cameras',
icon: '📷',
source: source.label || 'Mapped camera locations',
// English in layer state; the layer row translates it.
source: source.label || msg('Mapped camera locations'),
updateInterval: 0,
statsRefreshInterval: 1000,
init(viewer) {
@@ -282,6 +342,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
return loadCameras();
},
destroy(viewer = state.viewer) {
source.destroy?.();
this.disable();
destroyOverlay();
state.moveEndRemove?.();
@@ -299,6 +360,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.records = [];
state.recordById = new Map();
state.lastQueryBox = null;
state.lastQueryZoom = null;
state.lastUpdate = null;
state.error = null;
state.status = 'idle';
@@ -312,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
chips: [
{
id: 'find-camera',
label: 'SHOW NEAREST',
label: t('SHOW NEAREST'),
title: state.viewer?.trackedEntity
? 'Stop following the current object before navigating to a camera'
: 'Move to the nearest loaded camera and show its details',
? t(
'Stop following the current object before navigating to a camera',
)
: t('Move to the nearest loaded camera and show its details'),
disabled:
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
onClick: focusNearest,
@@ -323,24 +387,72 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
legend: [
{
label: 'Camera badges',
label: t('Camera badges'),
color: ALPR_COLOR,
count,
blurb:
blurb: t(
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
),
},
],
};
},
/**
* Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!state.enabled) return [];
const result = [];
for (const record of state.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.id,
lat: record.latitude,
lon: record.longitude,
operator: record.operator ?? null,
manufacturer: record.manufacturer ?? null,
cameraType: record.cameraType ?? null,
directionDeg: Number.isFinite(record.directionDeg)
? record.directionDeg
: null,
});
}
return result;
},
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
countLabel: state.enabled
? `${state.dataSource?.entities.values.length || 0} nearby`
: '',
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? Number.isInteger(state.onScreen)
? tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby · {onScreen} on screen',
'{count} nearby · {onScreen} on screen',
{ onScreen: state.onScreen },
)
: tn(
state.dataSource?.entities.values.length || 0,
'{count} nearby',
'{count} nearby',
)
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
stale: state.stale,
saturated: state.saturated,
saturated: state.saturated || state.renderSaturated,
renderRevision: state.renderRevision,
onScreen: state.onScreen ?? null,
shown: state.dataSource?.entities.values.length || 0,
error: state.error,
status: state.status,
loading: state.loading,
@@ -351,19 +463,26 @@ export function createAlprCamerasLayer({ source, services } = {}) {
: 0,
loadingLabel: state.loading
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated ? 'Coverage limited — zoom in' : '',
state.status === 'empty'
? 'No mapped cameras returned — coverage is incomplete'
: '',
]
.filter(Boolean)
.join(' · '),
? t('retrying mapped ALPR cameras')
: t('loading mapped ALPR cameras')
: state.noCoverage
? t('No ALPR data for this area — US and Canada only')
: state.status === 'zoom-in'
? t('Zoom in to load mapped cameras')
: [
state.stale ? t('Showing cached locations') : '',
state.saturated || state.renderSaturated
? t('Coverage limited — zoom in')
: '',
state.status === 'empty'
? t('No ALPR data for this area')
: '',
state.status === 'ready' && state.onScreen === 0
? t('None on screen — nearby cameras are outside the view')
: '',
]
.filter(Boolean)
.join(' · '),
};
},
};
@@ -384,4 +503,4 @@ export {
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
} from './policy.js';
export { createOverpassAlprSource } from './source.js';
export { createOverpassAlprSource, createAlprTileSource } from './source.js';
+178 -32
View File
@@ -3,18 +3,28 @@ import {
MAX_CANVAS_FRUSTUMS,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
BRACKETS_IMAGE,
directionWedgePositions,
markerScale,
paintDirectionWedge,
validAlprGroundHeight,
} from './visuals.js';
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({ state, services }) {
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
}) {
let lane,
removeMapListener,
retryTimer,
@@ -22,14 +32,21 @@ export function createAlprOverlay({ state, services }) {
let records = [],
hits = [],
images = [],
destroyed = false;
let surfaceRegime;
destroyed = false,
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
function nativeVisible(entity, visible) {
if (!entity) return;
canvasOwned(entity, !visible);
setShown(entity, visible);
}
/** Swap a marker between its native look and a faint canvas pick target. */
function canvasOwned(entity, owned) {
const visible = !owned;
const billboard = entity.billboard;
// Keep a faint native pick target under the canvas badge so sibling layer
// handlers recognize ALPR ownership instead of treating the click as empty.
@@ -55,7 +72,15 @@ export function createAlprOverlay({ state, services }) {
entity.gevAlprNativeAppearance = null;
entity.gevAlprPickPosition = null;
}
billboard.show = visible;
}
// Assign only on change: each Entity property write mints a new property
// and raises definitionChanged, which paint used to do for every overlay
// camera on every frame.
function setShown(entity, visible) {
if (entity.gevAlprShown === visible) return;
entity.gevAlprShown = visible;
entity.billboard.show = visible;
if (entity.polyline) entity.polyline.show = visible;
if (entity.polygon) entity.polygon.show = visible;
}
@@ -72,15 +97,25 @@ export function createAlprOverlay({ state, services }) {
requestPaint();
}
function anchorFor(record, entity) {
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
let height;
if (scene.sampleHeightSupported) {
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
@@ -89,19 +124,107 @@ export function createAlprOverlay({ state, services }) {
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height))
height = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
}
function floorHeightAt(latitude, longitude) {
const floor = services.groundFloor.cachedGroundFloor(latitude, longitude);
return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null;
}
function setAnchor(record, entity, height) {
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
entity.gevAlprWedge = directionWedgePositions(record, height);
return entity.gevAlprCanvasPosition;
entity.gevAlprWedge = directionWedgePositions(
record,
height,
floorHeightAt,
);
}
/** Whether the visible photoreal tiles (or globe) have finished streaming. */
function surfaceSettled(scene) {
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (primitive?.show && primitive.tilesLoaded === false) return false;
}
return true;
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
lastCamera &&
camera.positionWC &&
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastCamera.position,
0,
0.05,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastCamera.direction,
0,
1e-6,
);
if (camera.positionWC && camera.directionWC)
lastCamera = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
const accepted =
validAlprGroundHeight(sample) &&
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
return pending;
}
function paint({ ctx, width, height, keyhole, occluder }) {
@@ -109,6 +232,7 @@ export function createAlprOverlay({ state, services }) {
if (!state.enabled || destroyed) return;
let unresolved = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -122,23 +246,32 @@ export function createAlprOverlay({ state, services }) {
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
continue;
}
nativeVisible(entity, false);
if (occluder && !occluder.isPointVisible(anchor)) continue;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
continue;
}
const scene = state.viewer.scene;
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
anchor,
);
if (!origin) continue;
if (!origin) {
setShown(entity, false);
continue;
}
const alpha =
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
if (!(alpha > 0)) continue;
if (!(alpha > 0)) {
setShown(entity, false);
continue;
}
const wedge = entity.gevAlprWedge;
if (wedge) {
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
@@ -161,15 +294,27 @@ export function createAlprOverlay({ state, services }) {
origin.x > width + 60 ||
origin.y < -60 ||
origin.y > height + 60
)
) {
setShown(entity, false);
continue;
entity.billboard.show = true;
painted.push({ record, origin, selected, image, alpha });
}
setShown(entity, true);
painted.push({
record,
origin,
selected,
image,
alpha,
scale: markerScale(
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
),
});
if (selected) entity.gevAlprDisplayPosition = anchor;
}
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
for (const { record, origin, selected, image, alpha } of painted) {
const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE;
for (const { record, origin, selected, image, alpha, scale } of painted) {
const size =
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.drawImage(
@@ -190,7 +335,10 @@ export function createAlprOverlay({ state, services }) {
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
if (unresolved && !retryTimer && Date.now() < retryUntil) {
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
@@ -201,6 +349,12 @@ export function createAlprOverlay({ state, services }) {
return {
init() {
destroyed = false;
removeMapListener = services.overlays?.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready' || !state.enabled) return;
// Same-regime provider changes also invalidate absolute placements.
onSurfaceChange();
resetAnchors();
});
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
@@ -214,14 +368,6 @@ export function createAlprOverlay({ state, services }) {
image.src = src;
return image;
});
surfaceRegime = state.viewer.scene.globe.show;
removeMapListener = services.overlays.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready') return;
const next = state.viewer.scene.globe.show;
if (next === surfaceRegime) return;
surfaceRegime = next;
resetAnchors();
});
},
sync(visible) {
if (!lane) return;
+67 -3
View File
@@ -2,9 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { directionWedgePositions, alprLabelDetails } from './visuals.js';
import {
directionWedgePositions,
alprLabelDetails,
markerScale,
} from './visuals.js';
function harness(t, count = 2) {
function harness(t, count = 2, { floor = null } = {}) {
const previousImage = globalThis.Image;
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.Image = class {
@@ -43,7 +47,7 @@ function harness(t, count = 2) {
const overlay = createAlprOverlay({
state,
services: {
groundFloor: { cachedGroundFloor: () => null },
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
registerPaintLane(_lane, painter) {
paint = painter;
@@ -190,6 +194,20 @@ test('an unresolved surface preserves the native marker; the horizon removes ove
assert.equal(h.draw.length, 0);
});
test('repeated paints of a still view never rewrite marker properties', (t) => {
// Each Entity write raises definitionChanged; paint used to hide then show
// every overlay camera's native marker on every frame.
const h = harness(t, 3);
h.overlay.sync(h.records);
h.paint();
let changes = 0;
for (const entity of h.entities.values)
entity.definitionChanged.addEventListener(() => changes++);
for (let i = 0; i < 5; i++) h.paint();
assert.equal(changes, 0);
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
});
test('adapter labels never claim a custom source is public OSM data', () => {
assert.deepEqual(
alprLabelDetails(
@@ -199,3 +217,49 @@ test('adapter labels never claim a custom source is public OSM data', () => {
['Source: Custom directory', 'VENDOR'],
);
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
assert.equal(markerScale(90_000), 0.45);
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
const h = harness(t, 2, { floor: 150 });
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
};
h.overlay.sync(h.records);
h.paint();
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.equal(
samples,
0,
'no depth samples while the camera may still be moving',
);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
assert.ok(
Math.abs(height('camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(height('camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
const taken = samples;
h.paint();
assert.equal(samples, taken, 'each anchor is checked once');
});
+42 -21
View File
@@ -1,33 +1,37 @@
/**
* @file Community-mapped automatic license plate reader (ALPR) camera layer.
*
* Data source: OpenStreetMap contributors, including the DeFlock community
* (`man_made=surveillance` + `surveillance:type=ALPR`) — see
* https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and
* https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code.
* Fetched viewport-bounded through the existing generic
* `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js`
* use) — no new server route needed for a single narrow tag pair.
*
* This is mapped surveillance infrastructure, not a live camera feed: no
* plate records, no vendor accounts, nothing beyond what a contributor chose
* to publish to OSM. Coverage and tag accuracy are not guaranteed.
*
* @module data/alprCameras
* Community-mapped OpenStreetMap ALPR locations, via hourly US/Canada detail
* tiles. OSM data remains ODbL, separate from the application code. These are
* mapped locations, not footage, plate records or evidence of current activity.
*/
export const LAYER_ID = 'alpr-cameras';
export const OVERPASS_URL = '/api/overpass';
export const REQUEST_DEBOUNCE_MS = 500;
export const REQUEST_DEBOUNCE_MS = 300;
/** Keep public Overpass queries city-scale, never globe-wide. */
/** Keep camera acquisition city-scale, never globe-wide. */
export const MAX_VIEWPORT_DEGREES = 3;
/** Overpass `out body N;` cap — also detects possibly truncated coverage. */
/** Camera record cap; reaching it reports limited coverage. */
export const QUERY_LIMIT = 1500;
/**
* The extract carries full camera attributes from z9 (z0-8 are geometry-only
* heat tiles). Each view uses the finest zoom from z12 down to z9 that fits
* DETAIL_MAX_TILES, so a whole city loads from a few z9/z10 tiles and a
* neighbourhood from z12 (about 2 m position precision).
*/
export const DETAIL_MIN_ZOOM = 9;
export const DETAIL_MAX_ZOOM = 12;
export const DETAIL_MAX_TILES = 16;
/** Decoded records kept per accepted tile coverage (memory bound, not a render cap). */
export const SOURCE_RECORD_LIMIT = 6000;
/** Best-precision coordinates remembered per camera across zoom changes. */
export const PRECISION_CACHE_LIMIT = 20000;
/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below
* it, cameras between the two would be silently dropped while `saturated`
* stayed false and `count` still reported them — a lie about coverage. */
@@ -40,20 +44,37 @@ export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
export const EARTH_MEAN_RADIUS_M = 6371008.8;
/** Query boxes are snapped outward to this grid so nearby camera moves reuse
* one Overpass request body (the shared proxy keys its cache and in-flight
* dedupe by the exact body) instead of each becoming a distinct upstream fetch. */
* one accepted viewport snapshot rather than rebuilding records on every pan. */
export const QUERY_SNAP_DEGREES = 0.05;
/** A view still fully inside the last snapped query box reuses those records
* for this long before asking Overpass again. */
* for this long before selecting tiles again. */
export const QUERY_REUSE_MS = 10 * 60 * 1000;
/** Vendor-neutral camera badge palette, with coral selection. */
export const ALPR_COLOR = '#52d4ff';
export const ALPR_SELECTED_COLOR = '#ff6474';
export const MARKER_ICON_SIZE = 38;
/** Badge scale by camera distance (near m, scale, far m, scale), shared by
* native badges and overlay glyphs so both shrink alike in city-wide views. */
export const MARKER_SCALE_BY_DISTANCE = Object.freeze([
500, 1.15, 40_000, 0.45,
]);
export const SELECTED_MARKER_ICON_SIZE = 60;
export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
export const CREDIT_DISPLAY_MS = 5000;
+246 -52
View File
@@ -9,15 +9,18 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
CREDIT_DISPLAY_MS,
MARKER_FLOOR_LIFT_M,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { t } from '../../i18n/index.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
directionWedgePositions,
validAlprGroundHeight,
} from './visuals.js';
@@ -32,9 +35,198 @@ export function createAlprPresentation({ state, services, source }) {
} = services.context;
const { cachedGroundFloor } = services.groundFloor;
const overlay = createAlprOverlay({ state, services });
const overlay = createAlprOverlay({
state,
services,
onSurfaceChange: refreshMarkerPlacements,
});
let visibleRecords = [];
let selectionStartedAt = 0;
/** Markers still clamped while their floor cell resolves (id -> entity). */
const clampedMarkers = new Map();
let floorResolve = null;
const floors = createAlprFloorResolver(services.groundFloor);
/**
* Marker placement from the cached ground floor (validated mesh or DEM, the
* ellipsoidal datum every layer shares). Ground clamping on photoreal tiles
* re-samples every clamped marker whenever a tile streams in, which made a
* whole-city view stutter while zooming, so it is only the placeholder for
* a cell that has not resolved yet.
* @param {{latitude:number, longitude:number}} record Camera.
* @returns {{position: Cesium.Cartesian3, clamped: boolean}} Placement.
*/
function markerPlacement(record) {
const floor = cachedGroundFloor(record.latitude, record.longitude);
const clamped = !validAlprGroundHeight(floor);
return {
clamped,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
clamped ? 0 : floor + MARKER_FLOOR_LIFT_M,
),
};
}
/** Records the current view would display (viewport box, nearest first when capped). */
function viewRecords(records) {
const box = viewportBox(state.viewer);
if (!box) return { box, visible: [], capped: false };
let visible = records.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
);
const capped = visible.length > MAX_RENDERED;
if (capped) {
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
const distance = (record) =>
(record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2;
visible = visible
.map((record) => ({ record, d: distance(record) }))
.sort((a, b) => a.d - b.d)
.slice(0, MAX_RENDERED)
.map((item) => item.record);
}
return { box, visible, capped };
}
/**
* Resolve the floor cells of the records this view will display, waiting at
* most the ground service's bounded deadline. Never throws.
* @param {Array<object>} records Candidate records.
* @returns {Promise<void>}
*/
async function prepareFloors(records) {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (typeof resolve !== 'function') return;
const cold = viewRecords(records)
.visible.filter(
(record) =>
!validAlprGroundHeight(
cachedGroundFloor(record.latitude, record.longitude),
),
)
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
if (!cold.length) return;
try {
await floors.prepare(cold);
} catch {
/* markers fall back to clamping until their floors resolve */
}
}
/**
* Loaded cameras that project inside the canvas and in front of the globe.
* "Nearby" spans twice the camera-to-ground range, so a view can hold
* cameras while none is on screen; the row then says so rather than
* leaving the user to wait for markers. Runs once per render pass.
* @returns {number|null} Count, or null without a scene to project into.
*/
function countOnScreen() {
const scene = state.viewer?.scene;
const canvas = scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
const camera = state.viewer?.camera;
if (!scene || !width || !height || !camera?.positionWC) return null;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.positionWC,
);
let count = 0;
try {
for (const entity of state.dataSource.entities.values) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position || !occluder.isPointVisible(position)) continue;
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
);
if (
point &&
point.x >= 0 &&
point.x <= width &&
point.y >= 0 &&
point.y <= height
)
count += 1;
}
} catch {
// A scene without a usable frame (startup, teardown) cannot answer.
return null;
}
return count;
}
/** Write a marker position, respecting the overlay's pick-target swap. */
function setMarkerPosition(entity, position, clamped) {
const heightReference = clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const saved = entity.gevAlprNativeAppearance;
if (saved) {
saved.position = position;
saved.heightReference = heightReference;
} else {
entity.position = position;
entity.billboard.heightReference = heightReference;
}
entity.gevAlprClamped = clamped;
}
/** Refresh all native placements, including the appearances held by the canvas. */
function refreshMarkerPlacements() {
if (!state.enabled || !state.dataSource) return;
clampedMarkers.clear();
for (const entity of state.dataSource.entities.values) {
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
setMarkerPosition(entity, position, clamped);
if (clamped) clampedMarkers.set(entity.id, entity);
}
resolveClampedFloors();
governorRequestRender('alpr-surface');
}
/** Resolve floors for clamped markers once, then place them in place. */
function resolveClampedFloors() {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (floorResolve || !clampedMarkers.size || typeof resolve !== 'function')
return;
const batch = [...clampedMarkers.values()];
const request = Promise.resolve(
floors.resolve(
batch.map((entity) => ({
lat: entity.gevAlprRecord.latitude,
lon: entity.gevAlprRecord.longitude,
})),
),
)
.catch(() => {})
.then(() => {
if (floorResolve !== request) return;
floorResolve = null;
if (!state.enabled || !state.dataSource) return;
let moved = false;
for (const entity of batch) {
if (clampedMarkers.get(entity.id) !== entity) continue;
clampedMarkers.delete(entity.id);
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
if (clamped) continue;
setMarkerPosition(entity, position, false);
moved = true;
}
if (moved) governorRequestRender('alpr-floor');
// Cells that never resolved stay clamped; later renders retry them.
});
floorResolve = request;
}
function updateAppearance(entity, selected) {
const color = Cesium.Color.fromCssColorString(
@@ -44,12 +236,6 @@ export function createAlprPresentation({ state, services, source }) {
entity.billboard.width = entity.billboard.height = selected
? SELECTED_MARKER_ICON_SIZE
: MARKER_ICON_SIZE;
if (entity.polyline) {
entity.polyline.width = selected ? 3 : 1.75;
entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82);
}
if (entity.polygon)
entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2);
if (entity.gevLabelModel) {
entity.gevLabelModel.accent = color.toCssColorString();
entity.gevLabelModel.leaderAnimationStartedAt = selected
@@ -118,13 +304,18 @@ export function createAlprPresentation({ state, services, source }) {
}
function clearRendered() {
hideOnMapCredit();
floors.cancel();
floorResolve = null;
overlay.clear();
visibleRecords = [];
clampedMarkers.clear();
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
function hideOnMapCredit() {
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
clearTimeout(state.creditTimer);
state.creditTimer = null;
if (state.credit)
@@ -133,6 +324,14 @@ export function createAlprPresentation({ state, services, source }) {
}
function presentOnMapCredit() {
if (
state.enabled &&
source.attribution?.name === 'OpenStreetMap' &&
services.credits?.showOsmCredit
) {
services.credits.showOsmCredit(state.viewer, LAYER_ID);
return;
}
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
state.viewer.creditDisplay?.addStaticCredit(state.credit);
@@ -142,19 +341,10 @@ export function createAlprPresentation({ state, services, source }) {
function renderRecords() {
const selectedContext = getSelectedEntityContext();
const box = viewportBox(state.viewer);
const visible = box
? state.records
.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
)
.slice(0, MAX_RENDERED)
: [];
// Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset.
const { visible, capped } = viewRecords(state.records);
state.renderSaturated = capped;
visibleRecords = visible;
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
@@ -168,8 +358,14 @@ export function createAlprPresentation({ state, services, source }) {
}
governorRequestRender('alpr-render');
const visibleIds = new Set(visible.map((record) => record.id));
let changed = false;
state.dataSource.entities.suspendEvents();
for (const entity of [...state.dataSource.entities.values]) {
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
if (!visibleIds.has(entity.id)) {
state.dataSource.entities.remove(entity);
clampedMarkers.delete(entity.id);
changed = true;
}
}
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
for (const record of visible) {
@@ -180,10 +376,11 @@ export function createAlprPresentation({ state, services, source }) {
}
const color = markerColor();
const selected = record.id === state.selectedId;
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
);
const { position, clamped } = markerPlacement(record);
// Direction wedges are painted by the overlay for the nearest cameras.
// Native ground-clamped wedges (a ground polyline plus a classification
// polygon per camera) cost ~40 ms per frame for 200 downtown cameras on
// photoreal tiles, so markers stay billboards only.
const entityDef = {
id: record.id,
position,
@@ -191,38 +388,30 @@ export function createAlprPresentation({ state, services, source }) {
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
heightReference: clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
// City-wide views hold hundreds of badges; let them shrink.
scaleByDistance: new Cesium.NearFarScalar(
...MARKER_SCALE_BY_DISTANCE,
),
},
};
const wedge = directionWedgePositions(record);
if (wedge) {
entityDef.polyline = {
positions: [wedge[1], position, wedge[2]],
width: selected ? 3 : 1.75,
material: color.withAlpha(0.82),
clampToGround: true,
};
entityDef.polygon = {
hierarchy: wedge,
material: color.withAlpha(0.2),
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
classificationType: Cesium.ClassificationType.BOTH,
};
}
let entity = existing;
// Keep the Cesium entity and its ground-clamping subscription while its
// geometry is unchanged. Updating metadata must not rebuild the marker.
const previous = entity?.gevAlprRecord;
if (!entity) entity = state.dataSource.entities.add(entityDef);
else {
if (!entity) {
entity = state.dataSource.entities.add(entityDef);
entity.gevAlprClamped = clamped;
changed = true;
} else {
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude
) {
entity.position = position;
setMarkerPosition(entity, position, clamped);
entity.gevAlprCanvasPosition = null;
entity.gevAlprDisplayPosition = null;
}
@@ -231,8 +420,6 @@ export function createAlprPresentation({ state, services, source }) {
previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg
) {
entity.polyline = entityDef.polyline;
entity.polygon = entityDef.polygon;
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
}
@@ -265,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
layerName: 'ALPR Cameras',
source:
source.attribution?.description || source.label || 'Camera source',
label: 'ALPR camera',
label: t('ALPR camera'),
latitude: record.latitude,
longitude: record.longitude,
properties: {
@@ -281,6 +468,12 @@ export function createAlprPresentation({ state, services, source }) {
},
});
}
state.dataSource.entities.resumeEvents();
if (changed) state.renderRevision += 1;
state.onScreen = countOnScreen();
for (const entity of state.dataSource.entities.values)
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
resolveClampedFloors();
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
: null;
@@ -288,9 +481,9 @@ export function createAlprPresentation({ state, services, source }) {
updateSelectedAnchor();
overlay.sync(visible);
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
// Start only when mapped data is actually displayed, not while an upstream
// request or a zoom-in prompt could consume the five-second introduction.
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
if (visible.length) presentOnMapCredit();
else hideOnMapCredit();
}
function focusNearest() {
@@ -458,5 +651,6 @@ export function createAlprPresentation({ state, services, source }) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
};
}
+196 -4
View File
@@ -1,10 +1,202 @@
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import {
OVERPASS_URL,
QUERY_LIMIT,
MAX_VIEWPORT_DEGREES,
QUERY_SNAP_DEGREES,
QUERY_LIMIT,
DETAIL_MIN_ZOOM,
DETAIL_MAX_ZOOM,
DETAIL_MAX_TILES,
SOURCE_RECORD_LIMIT,
PRECISION_CACHE_LIMIT,
} from './policy.js';
import {
createVectorTileSource,
validTileBounds,
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
// Marks text for i18n extraction; the UI translates it with t(). Kept local
// because AI tool services import this module outside the browser.
const msg = (text) => text;
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
* or null when even z9 would not (the layer asks the user to zoom in).
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @returns {number|null} Tile zoom.
*/
export function alprDetailZoom(box) {
for (let zoom = DETAIL_MAX_ZOOM; zoom >= DETAIL_MIN_ZOOM; zoom--)
if (
tilesForBounds(box, zoom, { maxTiles: DETAIL_MAX_TILES + 1 }).length <=
DETAIL_MAX_TILES
)
return zoom;
return null;
}
/**
* Tile coordinates are quantized per zoom (about 19 m at z9, 2 m at z12).
* Remember each camera's finest position so zooming out never nudges a marker
* that a closer view already placed precisely. Bounded, oldest first.
* @param {number} [limit] Cameras remembered.
* @returns {{apply: (record: object, zoom: number) => object, clear: () => void,
* size: () => number}} Cache.
*/
export function createAlprPrecisionCache(limit = PRECISION_CACHE_LIMIT) {
const known = new Map();
return {
apply(record, zoom) {
const finer = known.get(record.id);
if (finer && finer.zoom > zoom)
return finer.latitude === record.latitude &&
finer.longitude === record.longitude
? record
: { ...record, latitude: finer.latitude, longitude: finer.longitude };
known.delete(record.id);
known.set(record.id, {
zoom,
latitude: record.latitude,
longitude: record.longitude,
});
while (known.size > limit) known.delete(known.keys().next().value);
return record;
},
clear: () => known.clear(),
size: () => known.size,
};
}
/** Construct the viewport-bounded OpenStreetMap hourly extract adapter. */
export function createAlprTileSource({
tileFetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const sources = ['us', 'ca'].map((country) =>
createVectorTileSource({
tileJsonUrl: `https://tiles.dontgetflocked.com/cameras-${country}-hourly.json`,
allowedOrigin: 'https://tiles.dontgetflocked.com',
decode: decodeAlprTile,
fetchImpl: tileFetchImpl,
maxTiles: DETAIL_MAX_TILES,
ttlMs: 60 * 60 * 1000,
}),
);
const precision = createAlprPrecisionCache();
const precise = precision.apply;
return {
/**
* Read the camera records for every extract tile covering `box`.
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @param {AbortSignal} [signal] Cancellation.
* @returns {Promise<{records: Array<object>, stale: boolean, saturated: boolean,
* noCoverage?: boolean, zoomIn?: boolean, coverage?: object, zoom?: number}>}
* Records inside the fetched tiles; `coverage` is those tiles' bounds.
*/
async fetch(box, signal) {
if (
!validTileBounds(box) ||
box.north - box.south >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 ||
box.east - box.west >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9
)
throw new TypeError('ALPR requires a bounded city viewport');
signal?.throwIfAborted();
// Fast geographic rejection; the exact extract extents below refine coverage.
if (box.east < -180 || box.west > -50 || box.north < 17 || box.south > 84)
return {
records: [],
stale: false,
saturated: false,
noCoverage: true,
};
const zoom = alprDetailZoom(box);
if (zoom === null)
return { records: [], stale: false, saturated: false, zoomIn: true };
const tiles = tilesForBounds(box, zoom, {
maxTiles: DETAIL_MAX_TILES + 1,
});
const tileBounds = tiles.map((tile) => tileToBBox(zoom, tile.x, tile.y));
const coverage = {
south: Math.min(...tileBounds.map((b) => b.south)),
west: Math.min(...tileBounds.map((b) => b.west)),
north: Math.max(...tileBounds.map((b) => b.north)),
east: Math.max(...tileBounds.map((b) => b.east)),
};
const records = new Map();
let covered = false,
partial = false;
for (const [index, source] of sources.entries()) {
if (
index === 1 &&
(box.north < 41 ||
box.south > 84 ||
box.east < -142 ||
box.west > -52)
)
continue;
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error(msg('Camera coverage unavailable'));
if (
box.east < west ||
box.west > east ||
box.north < south ||
box.south > north
)
continue;
covered = true;
const result = await source.fetchBounds(box, { zoom, signal });
partial ||= result.partial;
for (const record of result.tiles.flat())
records.set(record.id, precise(record, zoom));
}
signal?.throwIfAborted();
let list = [...records.values()];
const truncated = list.length > SOURCE_RECORD_LIMIT;
if (truncated) {
// Keep the cameras nearest the view centre when a dense metro overflows.
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
list = list
.map((record) => ({
record,
d:
(record.latitude - lat) ** 2 +
((record.longitude - lon) * scale) ** 2,
}))
.sort((a, b) => a.d - b.d)
.slice(0, SOURCE_RECORD_LIMIT)
.map((item) => item.record);
}
return {
records: list,
stale: false,
saturated: partial || truncated,
noCoverage: !covered,
// A partial or truncated read does not cover its tiles; reuse only the box.
coverage: partial || truncated ? { ...box } : coverage,
zoom,
};
},
detailZoom: alprDetailZoom,
destroy() {
for (const source of sources) source.clear();
precision.clear();
},
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
text: '© OpenStreetMap contributors, ODbL',
href: 'https://www.openstreetmap.org/copyright',
},
};
}
/** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -53,7 +245,7 @@ export function createOverpassAlprSource({
// The shared proxy already rejects query errors. Validate here too so a
// malformed or partial response never becomes an authoritative empty map.
if (!Array.isArray(payload?.elements) || payload.remark) {
throw new Error('Overpass returned an incomplete camera response');
throw new Error(msg('Overpass returned an incomplete camera response'));
}
return {
records: [
@@ -71,11 +263,11 @@ export function createOverpassAlprSource({
}
return {
fetch: fetchAlprNodes,
label: 'OpenStreetMap · community mapped',
label: msg('OpenStreetMap · community mapped'),
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
text: '© OpenStreetMap',
text: '© OpenStreetMap contributors',
href: 'https://www.openstreetmap.org/copyright',
},
};
+211 -38
View File
@@ -1,12 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOverpassAlprSource } from './source.js';
import { readFileSync } from 'node:fs';
import {
alprDetailZoom,
createAlprPrecisionCache,
createAlprTileSource,
createOverpassAlprSource,
buildOverpassQuery,
} from './source.js';
import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
test('the source rejects invalid and unbounded queries before fetching', async () => {
let calls = 0;
const source = createOverpassAlprSource({
fetchImpl() {
const source = createAlprTileSource({
tileFetchImpl() {
calls++;
},
});
@@ -21,55 +28,77 @@ test('the source rejects invalid and unbounded queries before fetching', async (
}
assert.equal(calls, 0);
});
test('cancellation during body parsing rejects even when the transport ignores it', async () => {
const fixture = readFileSync(
new URL(
'../../data/fixtures/osm-alpr-austin-11-467-843.pbf',
import.meta.url,
),
);
const metadata = (country) => ({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
});
const austin = { south: 30.2, north: 30.35, west: -97.85, east: -97.65 };
test('cancellation while reading TileJSON is preserved even if fetch ignores it', async () => {
const abort = new AbortController();
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: true,
headers: new Headers(),
json: async () => {
async text() {
abort.abort();
return { elements: [] };
return JSON.stringify(metadata('us'));
},
}),
});
await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' });
await assert.rejects(source.fetch(austin, abort.signal), {
name: 'AbortError',
});
});
test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'x-overpass-cache': 'STALE' }),
json: async () => ({
elements: [
{
type: 'node',
id: 12,
lat: 30,
lon: -98,
tags: {
'surveillance:type': 'camera;ALPR',
'camera:direction': '90',
},
},
],
}),
}),
test('extract detail tiles map OSM records and repeated pans reuse decoded tiles', async () => {
const calls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
calls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const snapshot = await source.fetch(box);
assert.equal(snapshot.records[0].id, 'alpr:12');
assert.equal(snapshot.records[0].latitude, 30);
assert.equal(snapshot.records[0].directionDeg, 90);
assert.equal(snapshot.stale, true);
assert.equal(snapshot.saturated, false);
const snapshot = await source.fetch(austin);
assert.ok(snapshot.records.length > 0);
const record = snapshot.records[0];
assert.match(record.id, /^alpr:/);
assert.equal(record.manufacturer, 'Flock Safety');
assert.equal(record.lastVerified, null);
assert.ok(record.osmTimestamp);
assert.equal(snapshot.stale, false);
assert.equal('elements' in snapshot, false);
const fetched = calls.length;
await source.fetch(austin);
assert.equal(calls.length, fetched);
assert.ok(calls.every((url) => !url.includes('overpass')));
assert.doesNotMatch(JSON.stringify(source.attribution), /flock/i);
});
test('outside extract coverage is an explicit no-data state without Overpass', async () => {
const source = createAlprTileSource({
tileFetchImpl: () => assert.fail('no network expected in Europe'),
});
assert.deepEqual(
await source.fetch({ south: 51, north: 51.1, west: 0, east: 0.1 }),
{ records: [], stale: false, saturated: false, noCoverage: true },
);
});
test('an unsuccessful source response releases its body before reporting an error', async () => {
let cancelled = 0;
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: false,
status: 429,
body: {
@@ -79,7 +108,7 @@ test('an unsuccessful source response releases its body before reporting an erro
},
}),
});
await assert.rejects(source.fetch(box), /rate-limited/);
await assert.rejects(source.fetch(box), /unavailable/);
assert.equal(cancelled, 1);
});
@@ -115,3 +144,147 @@ test('provider attribution escapes markup and rejects executable links', () => {
assert.ok(html.includes('?a=1&amp;b=2'));
assert.ok(!html.includes('<img'));
});
test('wide detail view returns zoom guidance before any fetch and can retry a smaller view', async () => {
let calls = 0;
const source = createAlprTileSource({
tileFetchImpl: async () => {
calls++;
return Response.json({
tiles: ['https://tiles.dontgetflocked.com/{z}/{x}/{y}.pbf'],
bounds: [-180, 17, -50, 84],
});
},
});
const wide = await source.fetch({
south: 29,
north: 32,
west: -99,
east: -96,
});
assert.equal(wide.zoomIn, true);
assert.equal(calls, 0);
await source
.fetch({ south: 30.267, north: 30.268, west: -97.744, east: -97.743 })
.catch(() => {});
assert.ok(calls > 0);
});
test('each view reads the finest attributed extract zoom that fits 16 tiles', () => {
// Field report: a z11-only read refused whole-city views. Attributed detail
// exists from z9, so a city reads a few z9/z10 tiles and a street z12.
assert.equal(
alprDetailZoom({ south: 30.29, north: 30.31, west: -97.78, east: -97.75 }),
12,
);
assert.equal(
alprDetailZoom({ south: 30.17, north: 30.36, west: -97.85, east: -97.63 }),
12,
);
assert.equal(
alprDetailZoom({ south: 29.7, north: 30.7, west: -98.25, east: -97.25 }),
10,
);
assert.equal(
alprDetailZoom({ south: 29.2, north: 31.2, west: -98.75, east: -96.75 }),
9,
);
assert.equal(
alprDetailZoom({ south: 29, north: 32, west: -99, east: -96 }),
null,
);
});
test('a whole-city view loads cameras and reports the tiles it covers', async () => {
const urls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const city = { south: 29.95, north: 30.55, west: -98.05, east: -97.45 };
const snapshot = await source.fetch(city);
assert.equal(snapshot.zoomIn, undefined);
assert.equal(snapshot.zoom, 10);
assert.ok(snapshot.records.length > 0);
assert.ok(urls.some((url) => url.includes('/10/')));
for (const key of ['south', 'west'])
assert.ok(snapshot.coverage[key] <= city[key]);
for (const key of ['north', 'east'])
assert.ok(snapshot.coverage[key] >= city[key]);
});
test('coarser tiles never move a camera a finer tile already placed', () => {
const cache = createAlprPrecisionCache(2);
const fine = {
id: 'alpr:1',
latitude: 30.30001,
longitude: -97.76001,
operator: 'A',
};
assert.equal(cache.apply(fine, 12), fine);
const coarse = {
...fine,
latitude: 30.3001,
longitude: -97.7602,
operator: 'B',
};
const kept = cache.apply(coarse, 9);
assert.equal(kept.latitude, fine.latitude);
assert.equal(kept.longitude, fine.longitude);
assert.equal(kept.operator, 'B', 'attributes still refresh');
const finer = { ...fine, latitude: 30.300012 };
assert.equal(cache.apply(finer, 13), finer);
cache.apply({ id: 'alpr:2', latitude: 1, longitude: 1 }, 12);
cache.apply({ id: 'alpr:3', latitude: 1, longitude: 1 }, 12);
assert.equal(cache.size(), 2, 'bounded');
});
test('the explicit Overpass factory posts the unchanged query to its API transport', async () => {
const calls = [];
const source = createOverpassAlprSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
elements: [
{
type: 'node',
id: 42,
lat: 30.05,
lon: -97.95,
tags: { 'surveillance:type': 'ALPR', operator: 'Test' },
},
],
});
},
});
const result = await source.fetch(box);
assert.equal(result.records.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/overpass');
assert.equal(calls[0].options.method, 'POST');
assert.equal(
new URLSearchParams(calls[0].options.body).get('data'),
buildOverpassQuery(box.south, box.west, box.north, box.east),
);
});
test('the tile adapter never passes public URLs through an injected API transport', async (t) => {
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata('us'))
: new Response(fixture);
});
const source = createAlprTileSource({
fetchImpl: () => assert.fail('API transport must not receive tile URLs'),
});
const result = await source.fetch(austin);
assert.ok(result.records.length);
assert.ok(urls.length > 1);
assert.ok(urls.every((url) => url.startsWith('https://tiles.')));
});
+43
View File
@@ -0,0 +1,43 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
const record = normalizeAlprNode({
type: 'node',
id: Number(p.osmId),
lat,
lon,
tags: {
'surveillance:type': 'ALPR',
operator: p.operator,
manufacturer: p.brand,
'camera:type': p.cameraType,
'camera:direction': p.direction,
'surveillance:zone': p.surveillanceZone,
ref: p.ref,
check_date: p.check_date,
},
});
return record ? { ...record, osmTimestamp: p.osmTimestamp || null } : null;
}
/** Decode detail-level camera points; geometry-only heatmap tiles are not records. */
export function decodeAlprTile(bytes, z, x, y) {
const layer = new VectorTile(new PbfReader(bytes)).layers.cameras;
if (!layer) return [];
if (layer.length > 40_000)
throw new Error('Camera tile feature limit exceeded');
const records = [];
for (let i = 0; i < layer.length; i++) {
const record = normalizeAlprTileFeature(
layer.feature(i).toGeoJSON(x, y, z),
);
if (record) records.push(record);
}
return records;
}
+40 -5
View File
@@ -1,6 +1,25 @@
import * as Cesium from 'cesium';
import { destinationPointDeg } from './model.js';
import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js';
import {
DIRECTION_CONE_M,
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
* native badges (linear between near and far, clamped outside).
* @param {number} distanceM Camera-to-marker distance.
* @returns {number} Scale factor.
*/
export function markerScale(distanceM) {
const [near, nearScale, far, farScale] = MARKER_SCALE_BY_DISTANCE;
if (!Number.isFinite(distanceM) || distanceM <= near) return nearScale;
if (distanceM >= far) return farScale;
return (
nearScale + ((distanceM - near) / (far - near)) * (farScale - nearScale)
);
}
// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466).
// Module-relative assets also resolve when the layer is consumed by another app.
@@ -48,8 +67,16 @@ export function alprLabelDetails(record, source) {
return details;
}
/** Illustrative bearing wedge, not measured field of view or operating range. */
export function directionWedgePositions(record, heightM = 0) {
/**
* Illustrative bearing wedge, not measured field of view or operating range.
* @param {object} record Camera record.
* @param {number} [heightM] Apex height.
* @param {function(number, number): (number|null)} [heightAt] Optional height
* for the two far corners (degrees lat, lon), so the wedge follows a slope
* instead of floating level from the apex; falls back to the apex height.
* @returns {Array<Cesium.Cartesian3>|null} Apex, left and right corners.
*/
export function directionWedgePositions(record, heightM = 0, heightAt = null) {
if (!Number.isFinite(record.directionDeg)) return null;
const left = destinationPointDeg(
record.latitude,
@@ -63,10 +90,18 @@ export function directionWedgePositions(record, heightM = 0) {
record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG,
DIRECTION_CONE_M,
);
const corner = (point) => {
const height = heightAt?.(point.latitude, point.longitude);
return Number.isFinite(height) ? height : heightM;
};
return [
Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM),
Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, corner(left)),
Cesium.Cartesian3.fromDegrees(
right.longitude,
right.latitude,
corner(right),
),
];
}
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createControls } from './controls.js';
import { createSubject } from './subject.js';
test('passive Contacts releases its installation anchor and subject refresh cannot reclaim it', (t) => {
const prior = globalThis.document;
globalThis.document = { body: { classList: { contains: () => false } } };
t.after(() => {
globalThis.document = prior;
});
const anchors = [];
const position = Cesium.Cartesian3.fromDegrees(-117.14, 32.87);
const state = {
enabled: true,
passive: false,
subject: {
id: 'site',
layerId: 'military-installations',
label: 'Site',
position,
},
sourceRevision: 'current',
lastEvaluatedPosition: position,
results: {},
};
const services = {
installations: { setContextAnchor: (value) => anchors.push(value) },
};
let activations = 0;
const parts = {
dependencies: { activateOperationalContext: () => activations++ },
queries: {
collectSourceStates: () => ({}),
sourceRevision: () => 'current',
},
navigation: { isFlightLayer: () => false },
model: { awarenessRefreshRequired: () => false },
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
};
const controls = createControls({ state, services, parts }).methods;
const subject = createSubject({ state, services, parts });
subject.refreshSelectedSubject();
assert.equal(anchors.length, 1);
assert.ok(anchors[0] instanceof Cesium.Cartesian3);
controls.setParams({ passive: true });
assert.equal(anchors.at(-1), null);
const released = anchors.length;
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(
anchors.length,
released,
'passive refresh must not reclaim the viewport',
);
controls.setParams({ passive: false });
assert.equal(activations, 1);
subject.refreshSelectedSubject();
assert.ok(anchors.at(-1) instanceof Cesium.Cartesian3);
});
+51
View File
@@ -29,6 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) {
if (typeof params.passive !== 'boolean') return;
const wasPassive = layerState.passive;
layerState.passive = params.passive;
if (layerState.passive) services.installations?.setContextAnchor?.(null);
if (layerState.enabled && wasPassive && !layerState.passive)
parts.dependencies.activateOperationalContext();
},
@@ -74,6 +75,56 @@ export function createControls({ state: layerState, services, parts, source }) {
);
},
/**
* Return the exact retained aircraft cohorts behind the current Contacts
* panel snapshot. Voice reads this immutable copy instead of re-scanning
* live layers, so panel and spoken counts share one evaluation timestamp.
*/
getAircraftQuerySnapshot() {
if (!layerState.enabled || !layerState.subject || !layerState.results)
return null;
// Build every public count from this exact results object before
// returning. A refresh may replace `layerState.results` as soon as the
// caller awaits, so consumers must not make a second read later and
// accidentally combine two evaluation timestamps.
const panelSnapshot = parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
const cohorts = {};
for (const cohort of layerState.results.cohorts || []) {
if (!['flights', 'military'].includes(cohort?.id)) continue;
cohorts[cohort.id] = {
count: cohort.summary?.count ?? null,
complete:
Number.isFinite(cohort.summary?.count) &&
cohort.summary?.complete !== false,
truncated:
!Number.isFinite(cohort.summary?.count) ||
cohort.summary?.truncated === true,
// These records carry structured values such as Cartesian positions.
// A shallow spread would let a voice consumer mutate the retained
// Contacts cohort through the advertised read-only snapshot.
items: structuredClone(cohort.summary?.navigationNearest || []),
source: cohort.source || null,
provenance: cohort.provenance
? structuredClone(cohort.provenance)
: null,
reason: cohort.summary?.reason || null,
};
}
return {
subject: structuredClone(layerState.results.subject),
evaluatedAt: layerState.results.evaluatedAt,
radiusM: layerState.results.radiusM,
cohorts,
contactsWindow: parts.model.contactsWindowFromSnapshot(panelSnapshot),
};
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
+1
View File
@@ -206,6 +206,7 @@ export function createLifecycle({
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
layerState.subject = null;
services.installations?.setContextAnchor?.(null);
layerState.results = null;
layerState.lastSubjectRefreshMs = 0;
layerState.lastEvaluatedPosition = null;
+11
View File
@@ -144,6 +144,9 @@ export function createModel({ state: layerState, services, parts, source }) {
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
['flights', 'military'].includes(cohort?.id),
);
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
flights,
military,
vessels: countFor('ais-live-vessels'),
complete:
aircraftCohorts.length === 2 &&
aircraftCohorts.every(
(cohort) =>
Number.isFinite(cohort.count) && cohort.complete !== false,
),
};
}
@@ -190,6 +199,8 @@ export function createModel({ state: layerState, services, parts, source }) {
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
complete: cohort.summary.complete !== false,
truncated: cohort.summary.truncated === true,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
+3
View File
@@ -25,6 +25,7 @@ export function createNavigation({
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
complete = true,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
@@ -34,6 +35,8 @@ export function createNavigation({
if (summary.count === null) return summary;
return {
...summary,
complete: Boolean(complete),
truncated: !complete,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
+58 -14
View File
@@ -1,10 +1,47 @@
import {
formatAwarenessLabel,
formatAwarenessDistance,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = summary.count.toLocaleString('en-US');
return summary.complete === false || summary.truncated === true
? `At least ${count}`
: count;
}
/** The count as the panel shows it, in the UI language. */
function presentAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = formatNumber(summary.count);
return summary.complete === false || summary.truncated === true
? t('At least {count}', { count })
: count;
}
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** The cohort's reason, kept English in data, shown in the UI language. */
function presentAwarenessReason(summary = {}) {
return summary.reasonTemplate
? t(summary.reasonTemplate, summary.reasonValues)
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -47,8 +84,8 @@ export function createPanel({ state: layerState, services, parts, source }) {
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? 'CONTEXT READY' : 'GLOBAL CONTEXT OFF'}</strong>
<span>${layerState.enabled ? 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION' : 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'}</span>
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
@@ -61,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
@@ -69,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
const pageCount = Math.max(
@@ -86,17 +123,17 @@ export function createPanel({ state: layerState, services, parts, source }) {
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="Global Context navigation">
<button type="button" data-awareness-action="previous" title="Previous — prior visited contact in the 250 km window"${canPrevious ? '' : ' disabled'}>PREVIOUS</button>
<button type="button" data-awareness-action="focus">FOCUS</button>
<button type="button" data-awareness-action="next" title="Next — nearest unvisited contact in the 250 km window"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>NEXT</button>
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
}
@@ -164,14 +201,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
);
}
function namedAreasHtml(names) {
return names?.length
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
+2 -2
View File
@@ -38,8 +38,8 @@ export const AWARENESS_MAX_EXAMPLES = 10;
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
// refresh. Callers sample beyond this cap and carry explicit completeness, so
// a saturated cohort is a lower bound rather than a silently exact count.
export const AWARENESS_QUERY_LIMIT = 20000;
+69 -16
View File
@@ -1,4 +1,16 @@
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
// The reason stays English for the voice analyst; the Contacts panel shows it
// in the UI language from `reasonTemplate`.
const msg = (text) => text;
const fillTemplate = (template, values = {}) =>
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
const withReason = (summary, template, values) => ({
...summary,
reason: fillTemplate(template, values),
reasonTemplate: template,
reasonValues: values,
});
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
export function createQueries({ state: layerState, services, parts, source }) {
@@ -35,7 +47,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// update() both resolve as soon as the first /api/vessels poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
@@ -53,7 +65,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
return {
available: !unavailable,
enabled,
stale: Boolean(stats.stale),
stats,
};
}
function collectSourceStates() {
@@ -94,20 +111,40 @@ export function createQueries({ state: layerState, services, parts, source }) {
*/
function summarizeInstallationViewport(items, source) {
const coverage = source.stats?.coverage;
// getNearby measures these distances from the current subject, independent
// of the retained square used to fetch installation tiles.
const withinRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? items.filter(
(item) => (item.distanceM ?? item.distance) <= coverage.radiusM,
)
: items;
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
items,
withinRadius,
source,
);
if (summary.count === null)
return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
return {
...summary,
reason: summary.count
? 'mapped matches from the loaded viewport'
: 'viewport feed is not a complete 250 km survey',
};
// A subject window is its own bounded area: name it instead of the viewport.
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
const km = Math.round(coverage.radiusM / 1000);
return withReason(
summary,
summary.count
? msg('mapped matches within {km} km of the subject')
: msg('none mapped within {km} km; not a complete 250 km survey'),
{ km },
);
}
return withReason(
summary,
summary.count
? msg('mapped matches from the loaded viewport')
: msg('viewport feed is not a complete 250 km survey'),
);
}
/**
@@ -128,8 +165,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
* @returns {{flights: Array, military: Array, aircraft: number,
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
* their lower-bound completeness, and the combined aircraft count, or
* null without a position.
*/
function collectAircraftProximityWindow(
@@ -137,21 +176,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
// Ask for one usable row beyond the retained limit so a capped cohort is
// reported as a lower bound instead of an exact count. The selected
// subject can consume one slot before exclusion, hence the extra two.
const sampleLimit = AWARENESS_QUERY_LIMIT + 2;
const flightSample = flightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
const militarySample = militaryFlightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT);
const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT);
return {
flights,
military,
aircraft: flights.length + military.length,
completeByLayer: {
flights: flightSample.length <= AWARENESS_QUERY_LIMIT,
military: militarySample.length <= AWARENESS_QUERY_LIMIT,
},
};
}
return {
sourceState,
+83
View File
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createControls } from '../installations/controls.js';
import { createModel } from '../installations/model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_QUERY_LIMIT } from './policy.js';
test('aircraft window marks a capped layer as a lower bound', () => {
const rows = Array.from({ length: AWARENESS_QUERY_LIMIT + 1 }, (_, index) => ({
icao24: `f${index}`,
distance: index,
}));
let requested = null;
const services = {
flights: {
getNearby: (_position, _radius, limit) => {
requested = limit;
return rows.slice(0, limit);
},
},
military: { getNearby: () => [] },
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
},
};
const queries = createQueries({ state: {}, services, parts });
const window = queries.collectAircraftProximityWindow({ center: true });
assert.equal(requested, AWARENESS_QUERY_LIMIT + 2);
assert.equal(window.flights.length, AWARENESS_QUERY_LIMIT);
assert.equal(window.aircraft, AWARENESS_QUERY_LIMIT);
assert.deepEqual(window.completeByLayer, {
flights: false,
military: true,
});
});
test('Contacts excludes square corners and measures from the current moving subject', () => {
const records = [
{ id: 'corner', kind: 'installation', latitude: 90 / 111.195, longitude: 90 / 111.195 },
{ id: 'east', kind: 'installation', latitude: 0, longitude: 90 / 111.195 },
{ id: 'west', kind: 'installation', latitude: 0, longitude: -90 / 111.195 },
];
const installationState = { records, contextAnchor: { latitude: 0, longitude: 0 },
lastUpdate: 1, status: 'ready', coverage: { kind: 'subject', radiusM: 100000 },
distanceEndpointScratch: new Cesium.Cartographic(),
distanceGeodesicScratch: new Cesium.EllipsoidGeodesic(),
};
const installationParts = { rendering: { installationSurfaceHeightM: () => 0 } };
installationParts.model = createModel({ state: installationState, parts: installationParts });
const installations = createControls({ state: installationState, parts: installationParts }).methods;
const services = { installations, flights: { getNearby: () => [] },
military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = Object.fromEntries(['flights', 'military', 'ais-live-vessels', 'military-installations']
.map((id) => [id, { available: true, stats: {} }]));
states['military-installations'].stats.coverage = { kind: 'subject', radiusM: 100000 };
const evaluate = (lon) => subjectOwner.evaluateSubject({
id: 'moving', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(lon, 0, 10000),
}, states).cohorts[3].summary;
installations.setContextAnchor({ latitude: 0, longitude: 0 });
const first = evaluate(0);
assert.equal(first.count, 2);
assert.ok(!first.nearest.some((item) => item.id === 'corner'), '90 km N + 90 km E is outside the disk');
assert.equal(installations.getStats().count, 2);
assert.equal(installations.getStats().statusMessage, '2 mapped sites within 100 km of the contact');
installations.setContextAnchor({ latitude: 0, longitude: 19 / 111.195 });
const moved = evaluate(19 / 111.195);
assert.equal(installations.getStats().count, 1);
assert.equal(installations.getStats().statusMessage, '1 mapped site within 100 km of the contact');
assert.equal(moved.count, 1, 'west site leaves the radius before the fetch anchor refreshes');
assert.equal(moved.nearest[0].id, 'east');
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
+27 -4
View File
@@ -1,4 +1,9 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
formatAwarenessLabel,
formatAwarenessDistance,
@@ -105,6 +110,9 @@ export function createRendering({
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(width, height)
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
@@ -130,7 +138,9 @@ export function createRendering({
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = `HDG ${String(headingDeg).padStart(3, '0')}°`;
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
@@ -182,6 +192,15 @@ export function createRendering({
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
const label = formatAwarenessLabel(item);
@@ -205,10 +224,14 @@ export function createRendering({
)
: null;
const bearingText = Number.isFinite(bearing)
? `BRG ${String(Math.round(bearing)).padStart(3, '0')}°`
: 'BRG —';
? t('BRG {value}°', {
value: String(Math.round(bearing)).padStart(3, '0'),
})
: t('BRG —');
const courseText = Number.isFinite(item.track)
? ` · CRS ${String(Math.round(item.track)).padStart(3, '0')}°`
? ` · ${t('CRS {value}°', {
value: String(Math.round(item.track)).padStart(3, '0'),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.hidden = false;
+80 -8
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
SOURCE_LABEL,
@@ -10,6 +11,19 @@ import {
AWARENESS_PAGE_SIZE,
} from './policy.js';
// coverageLabel is machine-composed English from installations/controls.js
// (pinned by src/data/militaryInstallations.test.mjs); translate the known
// forms here, pass anything else through.
const WITHIN_KM_RE = /^WITHIN (\d+) KM$/;
function localizeCoverageLabel(label) {
if (typeof label !== 'string' || !label) return label;
if (label === 'CURRENT VIEWPORT ONLY') return t('CURRENT VIEWPORT ONLY');
const within = WITHIN_KM_RE.exec(label);
if (within) return t('WITHIN {km} KM', { km: within[1] });
return label;
}
export function createSubject({ state: layerState, services, parts, source }) {
const aisLiveVesselsLayer = services.vessels;
const militaryInstallationsLayer = services.installations;
@@ -37,50 +51,93 @@ export function createSubject({ state: layerState, services, parts, source }) {
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
id,
name: label,
enabled: sourceState.enabled,
source: retainedSource,
stats: { ...sourceState.stats, source: retainedSource },
};
};
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const { flights, military, completeByLayer } = aircraftWindow;
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
);
const coverage = installationsState.stats?.coverage;
const installationRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? coverage.radiusM
: AWARENESS_RADIUS_M;
const installations = militaryInstallationsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.getNearby(position, installationRadius, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
);
return {
subject,
evaluatedAt: Date.now(),
evaluatedAt,
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
'Flights',
flightsState,
SOURCE_LABEL.flights,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.flights,
},
),
},
{
id: 'military',
label: 'Military flights',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
'Military flights',
militaryState,
SOURCE_LABEL.military,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.military,
},
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
vesselsState,
SOURCE_LABEL['ais-live-vessels'],
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
@@ -88,11 +145,19 @@ export function createSubject({ state: layerState, services, parts, source }) {
},
{
id: 'military-installations',
label: 'Mapped installations',
label: t('Mapped installations'),
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage: 'CURRENT VIEWPORT ONLY',
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
installationsState,
SOURCE_LABEL['military-installations'],
),
coverage:
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
t('CURRENT VIEWPORT ONLY'),
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
@@ -326,6 +391,11 @@ export function createSubject({ state: layerState, services, parts, source }) {
});
if (!resolved) return;
const { position, presence } = resolved;
// Mapped installations load around the subject, not the (follow/Cockpit)
// camera view. The installation layer moves its window only after the
// subject travels far enough, so this per-refresh call is cheap.
if (!layerState.passive)
militaryInstallationsLayer.setContextAnchor?.(position);
// UNCHECKED leaves the verdict alone: this tick simply did not look.
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
@@ -392,6 +462,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
}
@@ -411,6 +482,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}
function clearAwarenessSubject() {
militaryInstallationsLayer.setContextAnchor?.(null);
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectMissing = false;
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Barcelona system's host, and the stations farthest from its registered
// centre in the live feed (checked 2026-10-16). AMBici spans the metropolitan
// area, so it lists its south-western and north-eastern ends.
const SYSTEMS = {
'barcelona-bicing': {
host: 'barcelona.publicbikesystem.net',
provider: 'Bicing',
stations: [
{ name: 'AV. RASOS DE PEGUERA, 10', lat: 41.462095, lon: 2.178959 },
{ name: 'C/ 60, NÚMERO 25', lat: 41.3467746, lon: 2.1436235 },
],
},
'barcelona-ambici': {
host: 'gbfs.nextbike.net',
provider: 'AMBici',
stations: [
{ name: 'Poal', lat: 41.268865, lon: 1.943849 },
{ name: 'Platja del Manresà', lat: 41.460101, lon: 2.267271 },
{ name: 'Can Ruti', lat: 41.482205, lon: 2.237607 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bicing and AMBici are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.provider, expected.provider);
assert.deepEqual(system.hosts, [expected.host]);
for (const feed of [
system.stationInformationUrl,
system.stationStatusUrl,
]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(
isAllowedGbfsPath(url.pathname, url.hostname),
`${id} path ${url.pathname}`,
);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(SYSTEMS)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km, so the test above passes
// with any loadRadiusKm; get_bike_share uses the radius as registered.
for (const [id, expected] of Object.entries(SYSTEMS)) {
const system = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
system.centerLat,
system.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= system.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('a camera over central Barcelona activates both systems', () => {
// Plaça de Catalunya.
const active = computeInRangeCities({ lat: 41.387, lon: 2.17 });
assert.ok(active.has('barcelona-bicing'));
assert.ok(active.has('barcelona-ambici'));
});
test('the nextbike allowance is that one host, not its domain', () => {
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net'), true);
assert.equal(isAllowedGbfsHost('api.nextbike.net'), false);
assert.equal(isAllowedGbfsHost('gbfs.nextbike.net.example'), false);
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each Bike Itaú city's PBSC host, and the station farthest from its
// registered centre in the live feed (checked 2026-10-15).
const CITIES = {
'sao-paulo-bike-itau': {
host: 'saopaulo.publicbikesystem.net',
stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }],
},
'rio-de-janeiro-bike-itau': {
host: 'riodejaneiro.publicbikesystem.net',
stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }],
},
'salvador-bike-itau': {
host: 'salvador.publicbikesystem.net',
stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }],
},
'recife-bike-itau': {
host: 'recife.publicbikesystem.net',
stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }],
},
'porto-alegre-bike-itau': {
host: 'portoalegre.publicbikesystem.net',
stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }],
},
'brasilia-bike-itau': {
host: 'brasilia.publicbikesystem.net',
stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }],
},
'curitiba-bike-itau': {
host: 'curitiba.publicbikesystem.net',
stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('the Bike Itaú systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, 'Bike Itaú');
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
+54 -4
View File
@@ -1,3 +1,4 @@
import { msg, t } from '../../i18n/index.js';
import { STATUS_POLL_MS } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
@@ -21,9 +22,42 @@ export function createControls({ state: layerState, services, parts, source }) {
return parts.queries.collectDetectableStations(options);
},
/**
* Snapshot loaded bikeshare stations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, city, lat, lon, bikesAvailable, docksAvailable, capacity, renting}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const record of layerState._stationRenderMap.values()) {
if (result.length >= maxCount) break;
const info = layerState._stationInfoCache
.get(record.cityId)
?.get(record.stationId);
if (!Number.isFinite(info?.lat) || !Number.isFinite(info?.lon))
continue;
result.push({
id: record.stationName || `Dock ${record.stationId}`,
name: record.stationName || null,
city: record.cityId || null,
lat: info.lat,
lon: info.lon,
bikesAvailable: num(record.bikesAvailable),
docksAvailable: num(record.docksAvailable),
capacity: num(record.capacity),
renting: record.isRenting !== false,
});
}
return result;
},
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
*/
getStats() {
const stats = {
@@ -34,10 +68,26 @@ export function createControls({ state: layerState, services, parts, source }) {
if (layerState._loading) {
stats.loadingLabel =
layerState._activeCityIds.size > 0
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
? t('syncing {count} city feeds...', {
count: layerState._activeCityIds.size,
})
: t('scanning nearby systems...');
}
// A failed city is only the layer's failure when it left nothing on the
// map. While other cities still render, the partial failure reads
// DEGRADED; when every in-range city failed and only previously drawn
// points remain, they read STALE.
if (layerState._failedCityIds.size > 0) {
if (layerState._stationRenderMap.size === 0) {
stats.error = msg('GBFS fetch error');
} else {
stats.degraded = true;
const allFailed = Array.from(layerState._activeCityIds).every(
(cityId) => layerState._failedCityIds.has(cityId),
);
if (allFailed) stats.stale = true;
}
}
if (layerState._error) stats.error = layerState._error;
return stats;
},
};
@@ -0,0 +1,376 @@
// BIKESHARE FAILURE REPORTING — one dead city is not a dead layer.
//
// City feeds are fetched independently, but the layer reported failure through
// a single flag. A city whose operator had retired its feed set that flag on
// every proximity check, and the only code that cleared it ran on a successful
// *activation* — which an already-loaded city never repeats. The result was a
// permanent LOAD FAILED over a map drawing a thousand live stations.
//
// These cases pin the rule that replaced it: the layer reports failure only
// when it has nothing to show, and a city that comes back clears its own
// failure rather than waiting for a neighbour to succeed.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createControls } from './controls.js';
function makeLayer() {
const services = {
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
};
const state = createState({ services });
const parts = { queries: { collectDetectableStations: () => [] } };
const { methods } = createControls({ state, services, parts, source: {} });
return { state, methods };
}
/** Stand in for a rendered station without reaching into the renderer. */
function renderStations(state, count) {
for (let i = 0; i < count; i += 1) state._stationRenderMap.set(`s${i}`, {});
state._count = state._stationRenderMap.size;
}
test('a failed city does not fail a layer that is drawing stations', () => {
const { state, methods } = makeLayer();
renderStations(state, 1069);
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 1069);
assert.equal(
stats.error,
undefined,
'a layer drawing 1069 stations reported LOAD FAILED',
);
});
test('the layer reports failure when a failed city left it with nothing', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.error, 'GBFS fetch error');
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
});
test('a recovered city clears its own failure', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
assert.equal(methods.getStats().error, 'GBFS fetch error');
state._failedCityIds.delete('buffalo-reddy');
assert.equal(
methods.getStats().error,
undefined,
'the failure outlived the city that caused it',
);
});
// The cases above pin how getStats() reads failure state. The cases below
// drive the real proximity check, status poll and lifecycle against a fake
// GBFS source, so the failure state they read is the state the layer builds.
import * as Cesium from 'cesium';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
const TORONTO = 'toronto-bike-share';
const BUFFALO = 'buffalo-reddy';
// Within range of both Toronto and Buffalo.
const BETWEEN = { lat: 43.25, lon: -79.1 };
// Within range of Toronto only.
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
// Within range of Buffalo only.
const BUFFALO_ONLY = { lat: 42.5, lon: -78.0 };
const LOW_M = 10_000;
// disable() detaches a keydown listener; node has no document.
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
/** A GBFS source whose per-city answers each test can change. */
function makeSource() {
const cityOf = (url) => {
for (const [id, city] of CITY_BY_ID) {
if (url === city.stationInformationUrl) return { id, kind: 'info' };
if (url === city.stationStatusUrl) return { id, kind: 'status' };
}
throw new Error(`unexpected GBFS url ${url}`);
};
const source = {
dead: new Set(),
deadStatus: new Set(),
/** Optional per-call override: (cityId, kind) => Promise | undefined. */
hook: null,
async getStations(url, { signal } = {}) {
const { id, kind } = cityOf(url);
const hooked = source.hook?.(id, kind);
if (hooked) return hooked;
await new Promise((resolve) => setTimeout(resolve, 1));
signal?.throwIfAborted();
if (source.dead.has(id)) throw new Error('GBFS HTTP 404');
if (kind === 'status' && source.deadStatus.has(id))
throw new Error('GBFS HTTP 503');
const city = CITY_BY_ID.get(id);
if (kind === 'info') {
return {
data: {
stations: [
{
station_id: `${id}-1`,
name: `${id} dock`,
lat: city.centerLat,
lon: city.centerLon,
capacity: 10,
},
],
},
};
}
return {
data: {
stations: [
{
station_id: `${id}-1`,
num_bikes_available: 3,
num_docks_available: 7,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
};
},
};
return source;
}
/** Assemble the layer from its real parts around a camera the test moves. */
function makeLiveLayer() {
const source = makeSource();
const services = {
render: { governorRequestRender() {} },
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.ingestion = createIngestion(context);
parts.model = createModel(context);
parts.viewport = createViewport(context);
parts.rendering = createRendering(context);
// Selection owns a DOM click handler; nothing here selects a station.
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.queries = { collectDetectableStations: () => [] };
parts.lifecycle = createLifecycle(context);
const { methods: controls } = createControls(context);
const camera = { ...BETWEEN, height: LOW_M };
const viewer = {
camera: {
get positionCartographic() {
return Cesium.Cartographic.fromDegrees(
camera.lon,
camera.lat,
camera.height,
);
},
computeViewRectangle: () => undefined,
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: { globe: {}, primitives: { add() {}, remove() {} } },
};
parts.lifecycle.methods.init(viewer);
return {
state,
source,
stats: () => controls.getStats(),
moveTo(place, height = LOW_M) {
Object.assign(camera, place, { height });
return parts.viewport.runProximityCheck();
},
async enable() {
parts.lifecycle.methods.enable(viewer);
await settle();
},
disable: () => parts.lifecycle.methods.disable(viewer),
poll: () => parts.ingestion.methods.update(),
};
}
/** Let fire-and-forget activation started by enable() finish. */
async function settle() {
for (let i = 0; i < 5; i += 1)
await new Promise((resolve) => setTimeout(resolve, 2));
}
function deferred() {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
test('a city that fails beside one that renders reads DEGRADED, not healthy', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
const stats = layer.stats();
assert.equal(stats.count, 1, 'Toronto should still draw');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, true, 'the Buffalo failure was hidden');
assert.equal(stats.stale, undefined);
});
test('a failed city that comes back clears its own failure', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
assert.equal(layer.stats().degraded, true);
layer.source.dead.clear();
await layer.moveTo(BETWEEN);
const stats = layer.stats();
assert.equal(stats.count, 2);
assert.equal(stats.degraded, undefined);
assert.equal(stats.error, undefined);
});
test('a status poll that recovers clears the failure the last poll set', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
layer.source.deadStatus.add(TORONTO);
await layer.poll();
assert.equal(layer.stats().degraded, true);
layer.source.deadStatus.clear();
await layer.poll();
assert.equal(layer.stats().degraded, undefined);
assert.equal(layer.stats().stale, undefined);
});
test('a failure does not outlive its city leaving range', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
await layer.moveTo(BUFFALO_ONLY);
assert.equal(layer.stats().error, 'GBFS fetch error');
// Low over the open Atlantic: nothing in range is empty, not broken.
await layer.moveTo({ lat: 30, lon: -40 });
const stats = layer.stats();
assert.equal(stats.count, 0);
assert.equal(stats.error, undefined, 'LOAD FAILED over empty ocean');
assert.equal(stats.degraded, undefined);
});
test('zooming out above the altitude gate drops a city failure', async () => {
const layer = makeLiveLayer();
layer.source.dead.add(BUFFALO);
await layer.enable();
await layer.moveTo(BUFFALO_ONLY);
assert.equal(layer.stats().error, 'GBFS fetch error');
await layer.moveTo(BUFFALO_ONLY, 200_000);
const stats = layer.stats();
assert.equal(stats.count, 0);
assert.equal(stats.error, undefined, 'LOAD FAILED above the gate');
});
test('a late rejection from before a disable/re-enable is ignored', async () => {
const layer = makeLiveLayer();
await layer.moveTo(BUFFALO_ONLY);
const first = deferred();
// The first info request hangs and ignores its abort signal.
layer.source.hook = (id, kind) => {
if (id !== BUFFALO || kind !== 'info') return undefined;
layer.source.hook = null;
return first.promise;
};
await layer.enable();
layer.disable();
await layer.enable();
assert.equal(layer.stats().count, 1, 're-enable should draw Buffalo');
first.reject(new Error('GBFS HTTP 500'));
await settle();
const stats = layer.stats();
assert.equal(stats.count, 1, 'a stale rejection tore down live points');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, undefined);
});
test('a late status poll rejection from before a disable/re-enable is ignored', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
const first = deferred();
layer.source.hook = (id, kind) => {
if (id !== TORONTO || kind !== 'status') return undefined;
layer.source.hook = null;
return first.promise;
};
const poll = layer.poll();
layer.disable();
await layer.enable();
first.reject(new Error('GBFS HTTP 500'));
await poll;
const stats = layer.stats();
assert.equal(stats.count, 1);
assert.equal(stats.degraded, undefined);
assert.equal(stats.error, undefined);
});
test('every status poll failing over cached points reads STALE, not fresh', async () => {
const layer = makeLiveLayer();
await layer.moveTo(TORONTO_ONLY);
await layer.enable();
const loadedAt = layer.stats().lastUpdate;
assert.ok(loadedAt, 'activation should record freshness');
layer.source.deadStatus.add(TORONTO);
await new Promise((resolve) => setTimeout(resolve, 5));
await layer.poll();
const stats = layer.stats();
assert.equal(stats.count, 1, 'the cached points stay drawn');
assert.equal(stats.error, undefined);
assert.equal(stats.degraded, true);
assert.equal(stats.stale, true);
assert.equal(stats.lastUpdate, loadedAt, 'a failed poll advanced freshness');
});
+17 -10
View File
@@ -97,7 +97,10 @@ export function createIngestion({
const payload = await fetchGbfsJson(city.stationInformationUrl, {
signal: controller.signal,
});
const stationMap = parts.model.parseStationInformation(payload);
const stationMap = parts.model.stationsWithinCity(
city,
parts.model.parseStationInformation(payload),
);
if (stationMap.size === 0) {
throw new Error(`No station information for ${city.city}`);
}
@@ -171,30 +174,34 @@ export function createIngestion({
const generation = layerState._proximityGeneration;
const cityIds = Array.from(layerState._activeCityIds);
const isCurrent = (cityId) =>
layerState._enabled &&
layerState._activeCityIds.has(cityId) &&
generation === layerState._proximityGeneration;
let refreshed = false;
await Promise.all(
cityIds.map(async (cityId) => {
try {
const statusMap = await loadCityStationStatus(cityId, generation);
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
if (!isCurrent(cityId)) return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._failedCityIds.delete(cityId);
refreshed = true;
} catch (error) {
if (error?.name === 'AbortError') return;
if (error?.name === 'AbortError' || !isCurrent(cityId)) return;
console.warn(
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
layerState._failedCityIds.add(cityId);
}
}),
);
layerState._count = layerState._stationRenderMap.size;
if (layerState._count > 0) layerState._lastUpdate = Date.now();
// Freshness advances only on a poll that actually refreshed a city.
if (refreshed && layerState._count > 0)
layerState._lastUpdate = Date.now();
},
};
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createIngestion } from './ingestion.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each system's PBSC host, the station farthest from its registered centre,
// and the depots its live feed lists on other continents (checked 2026-10-16).
const CITIES = {
'bogota-tembici': {
host: 'bogota.publicbikesystem.net',
provider: 'Tembici Bogotá',
stations: [
{ name: '067 - KR 7B Bis con CL 152A', lat: 4.727729, lon: -74.026271 },
],
depots: [
{
station_id: '316',
name: 'Calle 13 - 316',
lat: -23.4008649,
lon: -46.3972181,
},
],
},
'santiago-bike-itau': {
host: 'santiago.publicbikesystem.net',
provider: 'Bike Itaú Santiago',
stations: [
{ name: 'LC077 - Mall Sport', lat: -33.3707813, lon: -70.5073122 },
],
depots: [
{
station_id: '459',
name: 'Galpon - 459',
lat: 40.4167747,
lon: -3.7037899,
},
{
station_id: '440',
name: 'Galpon - 440',
lat: -23.5690975,
lon: -46.697366,
},
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = {
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
};
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('Bogotá and Santiago are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, expected.provider);
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('each load radius reaches its outermost stations', () => {
// The range check floors every radius at 100 km; the station filter and
// get_bike_share use the radius as registered.
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
for (const station of expected.stations) {
const distance = parts.model.haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
assert.ok(
distance <= city.loadRadiusKm,
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
);
}
}
});
test('stations a feed lists on another continent are not loaded', async () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
const outermost = expected.stations[0];
const payload = {
data: {
stations: [
{
station_id: 'local',
name: outermost.name,
lat: outermost.lat,
lon: outermost.lon,
},
...expected.depots,
],
},
};
const state = {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
};
const ingestion = createIngestion({
state,
services: {},
parts,
source: {
async getStations(url) {
assert.equal(url, city.stationInformationUrl);
return payload;
},
},
});
const stations = await ingestion.loadCityStationInfo(id, 1);
assert.deepEqual(
[...stations.keys()],
['local'],
`${id} keeps only local stations`,
);
assert.equal(state._stationInfoCache.get(id), stations);
}
});
test('a feed with only far-away stations reports no station information', async () => {
const city = CITY_BY_ID.get('santiago-bike-itau');
const ingestion = createIngestion({
state: {
_stationInfoCache: new Map(),
_inFlightInfo: new Map(),
_inFlightStatus: new Map(),
_loadingOps: 0,
_loading: false,
},
services: {},
parts,
source: {
async getStations() {
return { data: { stations: CITIES['santiago-bike-itau'].depots } };
},
},
});
await assert.rejects(
ingestion.loadCityStationInfo('santiago-bike-itau', 1),
new RegExp(`No station information for ${city.city}`),
);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createLifecycle({
layerState._lastUpdate = null;
layerState._loading = false;
layerState._loadingOps = 0;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._limitWarned = false;
layerState._overlayHost.setVisible(
@@ -75,7 +75,7 @@ export function createLifecycle({
*/
enable(viewer) {
layerState._enabled = true;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._pointCollection.show = true;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
+26 -113
View File
@@ -1,38 +1,13 @@
import {
extractStationsArray,
normalizeGbfsBool,
parseStationInformation,
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
import { greatCircleKm } from '../../geoDistance.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
@@ -58,97 +33,35 @@ export function createModel({ state: layerState, services, parts, source }) {
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
* Drop stations outside a city's load radius. Some operators list depots on
* other continents as in-service stations in a city's feed (Bogotá's and
* Santiago's PBSC feeds carry warehouses in São Paulo and Madrid), which
* would otherwise draw as live docks thousands of kilometres away.
* @param {{ centerLat: number, centerLon: number, loadRadiusKm: number }} city
* @param {Map<string, Object>} stationMap - Parsed station info map.
* @returns {Map<string, Object>} Stations within the city's load radius.
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
function stationsWithinCity(city, stationMap) {
const kept = new Map();
for (const [stationId, station] of stationMap) {
const distance = haversineKm(
city.centerLat,
city.centerLon,
station.lat,
station.lon,
);
if (distance <= city.loadRadiusKm) kept.set(stationId, station);
}
return [];
return kept;
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
stationKey,
haversineKm,
stationsWithinCity,
extractStationsArray,
parseStationInformation,
parseStationStatus,
+148
View File
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
import { CITY_BY_ID } from './registry.js';
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
const STATION_INFORMATION = {
data: {
@@ -204,6 +205,101 @@ test('the proxy relays its cache policy and upstream host through the mounted ro
);
});
test('the Norwegian city-bike systems are served through the mounted route', async () => {
for (const [id, systemId] of [
['oslo-bysykkel', 'oslobysykkel.no'],
['bergen-bysykkel', 'bergenbysykkel.no'],
['trondheim-bysykkel', 'trondheimbysykkel.no'],
]) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']);
assert.equal(
system.stationStatusUrl,
`https://gbfs.urbansharing.com/${systemId}/station_status.json`,
);
}
await withUpstream(
{
[OSLO.stationInformationUrl]: STATION_INFORMATION,
[OSLO.stationStatusUrl]: STATION_STATUS,
},
async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
const info = await source.getStations(OSLO.stationInformationUrl);
const status = await source.getStations(OSLO.stationStatusUrl);
assert.deepEqual(requested, [
OSLO.stationInformationUrl,
OSLO.stationStatusUrl,
]);
assert.equal(info.data.stations.length, 1);
assert.equal(status.data.stations.length, 1);
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl),
);
assert.equal(response.status, 200);
assert.equal(
response.headers.get('x-gbfs-upstream'),
'gbfs.urbansharing.com',
);
},
);
});
test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => {
const expected = {
'stavanger-kolumbus':
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
'skien-farte':
'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status',
'lillestrom-bysykkel':
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
};
const upstream = {};
for (const [id, statusUrl] of Object.entries(expected)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.stationStatusUrl, statusUrl);
upstream[system.stationInformationUrl] = STATION_INFORMATION;
upstream[system.stationStatusUrl] = STATION_STATUS;
}
await withUpstream(upstream, async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
for (const id of Object.keys(expected)) {
const system = CITY_BY_ID.get(id);
const info = await source.getStations(system.stationInformationUrl);
const status = await source.getStations(system.stationStatusUrl);
assert.equal(info.data.stations.length, 1, `${id} information`);
assert.equal(status.data.stations.length, 1, `${id} status`);
}
assert.equal(requested.length, 6);
// Entur's extensionless station_information keeps its 5-minute cache.
const kolumbus = CITY_BY_ID.get('stavanger-kolumbus');
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl),
);
assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
});
});
test('the mounted route keeps multi-API hosts to their GBFS tree', async () => {
await withUpstream({}, async (requested) => {
const mounted = mountGbfsProxy();
for (const target of [
'https://api.entur.io/journey-planner/v3/station_status',
'https://api.cyclocity.fr/other/station_status.json',
]) {
const response = await mounted('/api/gbfs/' + encodeURIComponent(target));
assert.equal(response.status, 400, target);
}
assert.deepEqual(requested, []);
});
});
test('the query-string shape the client used to send is refused by the mounted route', async () => {
await withUpstream(
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
@@ -298,3 +394,55 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
);
});
}
test('unknown GBFS counts stay unknown through the source, model and station color', async () => {
const { createRendering } = await import('./rendering.js');
const { COLOR_NEUTRAL, COLOR_RED } = await import('./policy.js');
for (const missing of [null, '']) {
const source = createBikeshareSource({
fetchImpl: async () =>
Response.json({
data: {
stations: [
{
station_id: '2537',
num_bikes_available: missing,
num_docks_available: 4,
is_installed: true,
is_renting: true,
is_returning: true,
},
],
},
}),
});
const model = createModel({});
const status = model
.parseStationStatus(await source.getStations(AUSTIN.stationStatusUrl))
.get('2537');
const rendering = createRendering({
state: {},
services: { render: {} },
parts: { model },
});
assert.equal(status.bikesAvailable, null);
assert.equal(
model.toNonNegativeInteger(status.bikesAvailable),
null,
'render-map normalization preserves unknown',
);
assert.equal(rendering.statusToColor(status, 12), COLOR_NEUTRAL);
assert.equal(rendering.resolveCapacity(missing, status), 4);
assert.equal(
rendering.statusToColor({ ...status, bikesAvailable: 0 }, 12),
COLOR_RED,
'reported zero is still empty',
);
assert.equal(
model.toNonNegativeInteger(missing, 7),
7,
'caller fallback is preserved',
);
assert.equal(model.toNonNegativeInteger('3'), 3);
}
});
+320 -32
View File
@@ -23,6 +23,47 @@ function bcycleEntry({
...buildBcycleUrls(systemId),
};
}
function urbanSharingEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`,
stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`,
};
}
function enturEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`;
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `${base}/station_information`,
stationStatusUrl: `${base}/station_status`,
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
@@ -48,6 +89,56 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json',
provider: 'Divvy',
},
{
id: 'detroit-mogo',
city: 'Detroit, MI',
centerLat: 42.3314,
centerLon: -83.0458,
loadRadiusKm: 90,
stationInformationUrl:
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'MoGo',
},
{
id: 'pittsburgh-pogoh',
city: 'Pittsburgh, PA',
centerLat: 40.4406,
centerLon: -79.9959,
loadRadiusKm: 90,
stationInformationUrl:
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'POGOH',
},
{
id: 'aspen-we-cycle',
city: 'Aspen, CO',
centerLat: 39.1911,
centerLon: -106.8175,
loadRadiusKm: 90,
stationInformationUrl:
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'WE-cycle',
},
// HIBike serves both Kailua-Kona and Hilo, about 95 km apart; the centre sits
// between them so a camera over either town loads the whole system.
{
id: 'hawaii-island-hibike',
city: 'Kona & Hilo, HI',
centerLat: 19.68,
centerLon: -155.54,
loadRadiusKm: 90,
stationInformationUrl:
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'HIBike',
},
{
id: 'dc-capital-bikeshare',
city: 'Washington, DC',
@@ -143,18 +234,6 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Biki',
},
{
id: 'columbus-cogo',
city: 'Columbus, OH',
centerLat: 39.9612,
centerLon: -82.9988,
loadRadiusKm: 90,
stationInformationUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
provider: 'CoGo',
},
{
id: 'chattanooga-bikechatt',
city: 'Chattanooga, TN',
@@ -167,6 +246,126 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
{
id: 'sao-paulo-bike-itau',
city: 'São Paulo, SP',
centerLat: -23.5505,
centerLon: -46.6333,
loadRadiusKm: 100,
stationInformationUrl:
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'rio-de-janeiro-bike-itau',
city: 'Rio de Janeiro, RJ',
centerLat: -22.9068,
centerLon: -43.1729,
loadRadiusKm: 100,
stationInformationUrl:
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'salvador-bike-itau',
city: 'Salvador, BA',
centerLat: -12.9777,
centerLon: -38.5016,
loadRadiusKm: 90,
stationInformationUrl:
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'recife-bike-itau',
city: 'Recife, PE',
centerLat: -8.0476,
centerLon: -34.877,
loadRadiusKm: 90,
stationInformationUrl:
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'porto-alegre-bike-itau',
city: 'Porto Alegre, RS',
centerLat: -30.0346,
centerLon: -51.2177,
loadRadiusKm: 90,
stationInformationUrl:
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'brasilia-bike-itau',
city: 'Brasília, DF',
centerLat: -15.7939,
centerLon: -47.8828,
loadRadiusKm: 90,
stationInformationUrl:
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'curitiba-bike-itau',
city: 'Curitiba, PR',
centerLat: -25.4284,
centerLon: -49.2733,
loadRadiusKm: 90,
stationInformationUrl:
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú',
},
{
id: 'bogota-tembici',
city: 'Bogotá, Colombia',
centerLat: 4.6533,
centerLon: -74.0836,
loadRadiusKm: 90,
stationInformationUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Tembici Bogotá',
},
{
id: 'santiago-bike-itau',
city: 'Santiago, Chile',
centerLat: -33.4372,
centerLon: -70.6506,
loadRadiusKm: 90,
stationInformationUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Itaú Santiago',
},
{
id: 'toronto-bike-share',
city: 'Toronto, ON',
centerLat: 43.6532,
centerLon: -79.3832,
loadRadiusKm: 90,
stationInformationUrl:
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Share Toronto',
},
bcycleEntry({
id: 'boulder-bcycle',
city: 'Boulder, CO',
@@ -234,14 +433,6 @@ const RAW_GBFS_CITY_REGISTRY = [
systemId: 'bcycle_pacersbikeshare',
provider: 'Pacers Bikeshare',
}),
bcycleEntry({
id: 'fort-lauderdale-broward',
city: 'Fort Lauderdale, FL',
centerLat: 26.1224,
centerLon: -80.1373,
systemId: 'bcycle_broward',
provider: 'Broward B-cycle',
}),
bcycleEntry({
id: 'memphis-bcycle',
city: 'Memphis, TN',
@@ -256,22 +447,32 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -93.625,
systemId: 'bcycle_desmoines',
}),
bcycleEntry({
{
// Tugo moved off BCycle to PBSC; the bcycle_tugo feed is gone (404).
id: 'tucson-tugo',
city: 'Tucson, AZ',
centerLat: 32.2226,
centerLon: -110.9747,
systemId: 'bcycle_tugo',
loadRadiusKm: 100,
stationInformationUrl:
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Tugo',
}),
bcycleEntry({
},
{
// Trinity Metro moved off BCycle to PBSC; the bcycle_fortworth feed is gone.
id: 'fort-worth-trinity',
city: 'Fort Worth, TX',
centerLat: 32.7555,
centerLon: -97.3308,
systemId: 'bcycle_fortworth',
loadRadiusKm: 100,
stationInformationUrl:
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Trinity Metro',
}),
},
bcycleEntry({
id: 'omaha-heartland',
city: 'Omaha, NE',
@@ -295,14 +496,20 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -82.394,
systemId: 'bcycle_greenville',
}),
bcycleEntry({
{
// Reddy left BCycle for Social Bicycles; the bcycle_reddy feed is gone
// (404). This is the endpoint the MobilityData GBFS registry lists.
id: 'buffalo-reddy',
city: 'Buffalo, NY',
centerLat: 42.8864,
centerLon: -78.8784,
systemId: 'bcycle_reddy',
loadRadiusKm: 100,
stationInformationUrl:
'https://reddybikeshare.socialbicycles.com/opendata/station_information.json',
stationStatusUrl:
'https://reddybikeshare.socialbicycles.com/opendata/station_status.json',
provider: 'Reddy Bikeshare',
}),
},
bcycleEntry({
id: 'las-vegas-rtc-bike-share',
city: 'Las Vegas, NV',
@@ -318,6 +525,85 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -119.6982,
systemId: 'bcycle_santabarbara',
}),
urbanSharingEntry({
id: 'oslo-bysykkel',
city: 'Oslo, Norway',
centerLat: 59.9139,
centerLon: 10.7522,
systemId: 'oslobysykkel.no',
provider: 'Oslo Bysykkel',
}),
urbanSharingEntry({
id: 'bergen-bysykkel',
city: 'Bergen, Norway',
centerLat: 60.3913,
centerLon: 5.3221,
systemId: 'bergenbysykkel.no',
provider: 'Bergen Bysykkel',
}),
urbanSharingEntry({
id: 'trondheim-bysykkel',
city: 'Trondheim, Norway',
centerLat: 63.4305,
centerLon: 10.3951,
systemId: 'trondheimbysykkel.no',
provider: 'Trondheim Bysykkel',
}),
enturEntry({
id: 'stavanger-kolumbus',
city: 'Stavanger, Norway',
centerLat: 58.9,
centerLon: 5.72,
systemId: 'kolumbusbysykkel',
provider: 'Kolumbus Bysykkel',
}),
enturEntry({
id: 'skien-farte',
city: 'Skien/Porsgrunn, Norway',
centerLat: 59.16,
centerLon: 9.64,
systemId: 'fartebysykkel',
provider: 'Farte Bysykkel',
loadRadiusKm: 40,
}),
{
id: 'lillestrom-bysykkel',
city: 'Lillestrøm, Norway',
centerLat: 59.9556,
centerLon: 11.0492,
loadRadiusKm: 40,
stationInformationUrl:
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json',
stationStatusUrl:
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
provider: 'Bysykkel Lillestrøm',
},
{
id: 'barcelona-bicing',
city: 'Barcelona, Spain',
centerLat: 41.3926,
centerLon: 2.164,
loadRadiusKm: 40,
stationInformationUrl:
'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bicing',
},
// AMBici covers the metropolitan municipalities around Barcelona, from
// Castelldefels to Badalona, and overlaps Bicing only at the city edge.
{
id: 'barcelona-ambici',
city: 'Barcelona metropolitan area, Spain',
centerLat: 41.37,
centerLon: 2.07,
loadRadiusKm: 40,
stationInformationUrl:
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_information.json',
stationStatusUrl:
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
provider: 'AMBici',
},
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
@@ -346,11 +632,13 @@ function normalizeRegistryEntry(entry) {
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
// Validate that URLs end with expected GBFS endpoint filenames
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
if (
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}
+58
View File
@@ -0,0 +1,58 @@
// BIKESHARE REGISTRY — a city entry is only useful if the proxy will serve it.
//
// A registry entry and the GBFS proxy allowlist are edited in different files,
// so a city can look correctly registered while every one of its requests is
// rejected at the proxy. These cases hold the two together: each entry is
// checked against the same host and path predicates the proxy enforces, so a
// new city that the proxy would refuse fails here rather than silently
// rendering an empty layer.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GBFS_CITY_REGISTRY, CITY_BY_ID } from './registry.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
test('every registered city is reachable through the GBFS proxy allowlist', () => {
for (const city of GBFS_CITY_REGISTRY) {
for (const raw of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(raw);
assert.equal(
isAllowedGbfsHost(url.hostname),
true,
`${city.id}: host ${url.hostname} is not allowlisted`,
);
assert.equal(
isAllowedGbfsPath(url.pathname, url.hostname),
true,
`${city.id}: path ${url.pathname} is not allowlisted`,
);
}
}
});
test('Bike Share Toronto is registered against the PBSC v2 feeds', () => {
const toronto = CITY_BY_ID.get('toronto-bike-share');
assert.ok(toronto, 'toronto-bike-share is missing from the registry');
assert.equal(toronto.city, 'Toronto, ON');
assert.equal(toronto.provider, 'Bike Share Toronto');
assert.equal(
toronto.stationInformationUrl,
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
);
assert.equal(
toronto.stationStatusUrl,
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
);
});
test('the Toronto center sits inside the system service area', () => {
const toronto = CITY_BY_ID.get('toronto-bike-share');
// Bounding box of the 1,069 live stations, sampled from the published
// station_information feed: 43.588..43.813 N, -79.603..-79.123 W.
assert.ok(toronto.centerLat > 43.588 && toronto.centerLat < 43.813);
assert.ok(toronto.centerLon > -79.603 && toronto.centerLon < -79.123);
// The system spans roughly 37 km east-west, so the load radius has to clear
// half that from the center or downtown pans lose the outer stations.
assert.ok(toronto.loadRadiusKm >= 25);
});
+12 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
@@ -22,7 +23,9 @@ export function createSelection({
const stationName = String(record?.stationName || '').trim();
const stationLabel =
stationName ||
(record?.stationId ? `Station ${record.stationId}` : 'Station');
(record?.stationId
? t('Station {id}', { id: record.stationId })
: t('Station'));
const bikes = Number.isFinite(record?.bikesAvailable)
? record.bikesAvailable
: '?';
@@ -33,14 +36,18 @@ export function createSelection({
const lines = [
stationLabel,
`🚲 ${bikes} avail · ${docks} docks · ${capacity} cap`,
t('🚲 {bikes} avail · {docks} docks · {capacity} cap', {
bikes,
docks,
capacity,
}),
];
// Append warnings for stations that are offline or partially non-operational
const abnormal = [];
if (record?.isInstalled === false) abnormal.push('⚠️ Not installed');
if (record?.isRenting === false) abnormal.push('⚠️ Not renting');
if (record?.isReturning === false) abnormal.push('⚠️ Not returning');
if (record?.isInstalled === false) abnormal.push(t('⚠️ Not installed'));
if (record?.isRenting === false) abnormal.push(t('⚠️ Not renting'));
if (record?.isReturning === false) abnormal.push(t('⚠️ Not returning'));
if (abnormal.length > 0) {
lines.push(abnormal.join(' · '));
}
+10 -2
View File
@@ -99,9 +99,17 @@ export function createState({ services }) {
layerState._loadingOps = 0;
/** Most recent error message string, or null. */
/**
* @type {Set<string>} City ids whose most recent fetch failed.
*
* Per city rather than one layer-wide flag: feeds are fetched independently,
* so a retired operator must not mark a layer that other cities are still
* populating as failed. Entries are dropped when their city leaves range.
* The layer reports failure only when these cost it everything it had to
* draw, and degraded otherwise — see `getStats()` in controls.js.
*/
layerState._error = null;
layerState._failedCityIds = new Set();
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each city's PBSC host, and the stations farthest from its registered centre
// in the live feed (checked 2026-10-13). HIBike's centre sits between its two
// towns, so it lists the outermost station in each.
const CITIES = {
'detroit-mogo': {
host: 'detroit.publicbikesystem.net',
provider: 'MoGo',
stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }],
},
'pittsburgh-pogoh': {
host: 'pittsburgh.publicbikesystem.net',
provider: 'POGOH',
stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }],
},
'aspen-we-cycle': {
host: 'aspen.publicbikesystem.net',
provider: 'WE-cycle',
stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }],
},
'hawaii-island-hibike': {
host: 'kona.publicbikesystem.net',
provider: 'HIBike',
stations: [
{ name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 },
{ name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('the added US PBSC systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, expected.provider);
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('HIBike stays inactive away from Hawaii Island', () => {
// Honolulu is about 270 km from the HIBike centre.
assert.equal(
computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'),
false,
);
});
+17 -2
View File
@@ -122,6 +122,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
const cityIds = Array.from(layerState._activeCityIds);
for (const cityId of cityIds) deactivateCity(cityId);
layerState._activeCityIds.clear();
// A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear();
layerState._count = 0;
}
/**
@@ -168,11 +171,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._lastUpdate = Date.now();
layerState._error = null;
layerState._failedCityIds.delete(cityId);
} catch (error) {
if (error?.name === 'AbortError') return;
// A rejection from superseded work must not mark (or tear down) a city
// that a newer proximity check or re-enable now owns.
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
layerState._failedCityIds.add(cityId);
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
@@ -208,6 +219,10 @@ export function createViewport({ state: layerState, services, parts, source }) {
deactivateCity(cityId);
}
}
// A failure never outlives its city's range.
for (const cityId of layerState._failedCityIds) {
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
}
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
+57
View File
@@ -1,4 +1,41 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
import {
lookupModelSpec,
horizontalFovRangeDeg,
fixedHorizontalFovDeg,
} from './modelSpecs.js';
/**
* Applies datasheet enrichment to a camera in place: when it names a known
* hardware `model` (and, when published, a matching `manufacturer`), the spec
* record is attached for the HUD and the datasheet's horizontal FOV range
* rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware
* fills the FOV, and only when none was given: an explicit source/seed
* `fovDeg` or a curated pose is never overwritten. A PTZ/varifocal range says
* what the lens CAN do, not where it is zoomed now.
* Unknown models — the common case — change nothing.
* @param {Object} camera - Camera being built (mutated).
* @param {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware
* - Hardware model/manufacturer from a source or seed, and whether that
* source or seed stated its own `fovDeg`.
* @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper.
*/
function applyModelEnrichment(camera, hardware, clamp) {
const { model, manufacturer, explicitFov } = hardware;
const spec = model ? lookupModelSpec(model, manufacturer) : null;
if (!spec) return;
const range = horizontalFovRangeDeg(spec);
if (range) {
camera.specFovMinDeg = range.minDeg;
camera.specFovMaxDeg = range.maxDeg;
}
const fixedFov = fixedHorizontalFovDeg(spec);
if (fixedFov && !explicitFov && camera.poseSource !== 'curated') {
camera.fovDeg = clamp(fixedFov, 20, 125);
camera.fovSource = 'datasheet';
}
camera.spec = spec;
}
export function createCatalog({ state: layerState, services, parts, source }) {
const { CITY_POIS } = services.locations;
@@ -45,6 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) {
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
};
applyModelEnrichment(
camera,
{ model: seed.model, explicitFov: seed.fovDeg != null },
parts.model.clamp,
);
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
@@ -204,7 +246,22 @@ export function createCatalog({ state: layerState, services, parts, source }) {
? source.groundHeights
: null,
poseSource,
model: String(source.model || ''),
manufacturer: String(source.manufacturer || ''),
};
applyModelEnrichment(
camera,
{
model: source.model || seed?.model,
manufacturer: source.model ? source.manufacturer : '',
explicitFov:
(source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) ||
CAMERA_SEEDS.some(
(entry) => entry.id === id && entry.fovDeg != null,
),
},
parts.model.clamp,
);
parts.model.ensureCameraPose(camera);
catalog.push(camera);
}
+51
View File
@@ -0,0 +1,51 @@
import { t } from '../../i18n/index.js';
/**
* @module headingConfidence
*
* Consumers for the catalog's per-camera `headingConfidence` flag (#639).
*
* Packs whose feed publishes no facing get a synthetic bearing from
* `fallbackHeadingFromId` — a hash of the id string, with no geographic
* input — and are marked `headingConfidence: 'low'`. Until now nothing read
* the flag, so a guessed bearing rendered exactly like a surveyed one. These
* helpers make the guess visible: the HUD heading token gains an
* `(ESTIMATED)` tag and the coverage wireframe draws dashed instead of solid.
*
* A human-vouched pose always wins over the pack flag: a manually saved
* calibration (`calSource:'manual'`) or a hand-authored catalog entry
* (`poseSource:'curated'`) is never presented as estimated, mirroring the
* CAL badge states in calibration.js.
*/
/**
* Whether a camera's bearing is a synthetic guess that should present as
* provisional.
* @param {{headingConfidence?: string|null, calSource?: string|null,
* poseSource?: string|null}} camera - Catalog camera (or public state).
* @returns {boolean}
*/
export function isHeadingEstimated(camera) {
if (!camera) return false;
if (camera.calSource === 'manual') return false;
if (camera.poseSource === 'curated') return false;
return (
String(camera.headingConfidence || '')
.trim()
.toLowerCase() === 'low'
);
}
/**
* HUD heading token: `HDG 194°`, tagged `(ESTIMATED)` when the bearing is
* synthetic — so a hash can never read as a surveyed facing.
* @param {{headingDeg?: number, headingConfidence?: string|null,
* calSource?: string|null, poseSource?: string|null}} camera
* @returns {string}
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
}
@@ -0,0 +1,60 @@
// src/layers/cctv/headingConfidence.test.mjs
// Bearing-provenance consumers (#639): a pack's headingConfidence:'low'
// (a hashed synthetic bearing) must present as estimated — unless a human
// vouched for the pose via manual calibration or a curated catalog entry.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isHeadingEstimated, headingHudToken } from './headingConfidence.js';
test('low confidence marks the bearing estimated', () => {
assert.equal(isHeadingEstimated({ headingConfidence: 'low' }), true);
});
test('confidence matching is trimmed and case-insensitive', () => {
assert.equal(isHeadingEstimated({ headingConfidence: ' LOW ' }), true);
assert.equal(isHeadingEstimated({ headingConfidence: 'Low' }), true);
});
test('medium, high, missing, and absent cameras are not estimated', () => {
assert.equal(isHeadingEstimated({ headingConfidence: 'medium' }), false);
assert.equal(isHeadingEstimated({ headingConfidence: 'high' }), false);
assert.equal(isHeadingEstimated({}), false);
assert.equal(isHeadingEstimated(null), false);
assert.equal(isHeadingEstimated(undefined), false);
});
test('a manually saved calibration overrides a low pack flag', () => {
assert.equal(
isHeadingEstimated({ headingConfidence: 'low', calSource: 'manual' }),
false,
);
});
test('a curated catalog entry overrides a low pack flag', () => {
assert.equal(
isHeadingEstimated({ headingConfidence: 'low', poseSource: 'curated' }),
false,
);
});
test('HUD token tags a synthetic bearing and rounds the degrees', () => {
assert.equal(
headingHudToken({ headingDeg: 194.4, headingConfidence: 'low' }),
'HDG 194° (ESTIMATED)',
);
});
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
assert.equal(
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
'HDG 68°',
);
assert.equal(
headingHudToken({
headingDeg: 225,
headingConfidence: 'low',
calSource: 'manual',
}),
'HDG 225°',
);
});
+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+8
View File
@@ -339,6 +339,12 @@ export function createLifecycle({
*/
enable() {
layerState._enabled = true;
if (
layerState._records.some(
(record) => record.camera.cityId === 'warendorf',
)
)
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
@@ -385,6 +391,7 @@ export function createLifecycle({
/** Disables the layer: hides entities, stops the projection loop and load queue. */
disable() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._enabled = false;
unregisterPickOwner('cctv');
// ADJUST mode does not survive a layer toggle — predictable re-entry.
@@ -408,6 +415,7 @@ export function createLifecycle({
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
*/
destroy(viewer) {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._sourceAbort?.abort();
if (typeof document !== 'undefined')
document.removeEventListener(
+11
View File
@@ -98,12 +98,23 @@ export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerces a value to a finite number or returns the fallback.
*
* Absent, blank and boolean values return the fallback as well. The source
* list serializes an unknown number (NaN) as JSON null, and `Number(null)`
* is 0, so without this an unknown pose field arrived as zero and was
* clamped to the field's floor instead of taking its default.
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
function safeNumber(value, fallback = NaN) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
+550
View File
@@ -0,0 +1,550 @@
/**
* @module cctvModelSpecs
*
* Hardware-model spec enrichment for the CCTV layer.
*
* When a catalog camera names its hardware model (source packs may set
* `model`; hand-authored seeds may too), the model resolves to a datasheet
* record and the HUD meta line gains the model's real specs. A fixed-lens
* model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or
* multi-sensor range is carried as capability metadata beside the estimate,
* because the lens's current zoom is unknown.
* Cameras without an identified model are untouched — unknown never implies
* anything.
*
* Spec records are vendored verbatim from the CCTV Camera Database
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
* publish other hardware models can grow it, or vendor the full extract.
*
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
* night vision) — never endpoints, stream paths, or access/config data.
*/
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
const RECORDS = [
{
id: 'axis-q6135-le',
brand: 'Axis',
model: 'Q6135-LE',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/axis-q6135-le/',
aliases: ['01959-004'],
fov_deg: '58.3-2.4 horizontal',
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
ptz: true,
autotracking: true,
varifocal: true,
night_vision: { type: 'ir', range_m: 250 },
environment: ['outdoor'],
ndaa_compliant: true,
},
{
id: 'hanwha-xnp-6400rw',
brand: 'Hanwha',
model: 'XNP-6400RW',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/',
aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'],
fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical',
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
ptz: true,
autotracking: true,
varifocal: true,
night_vision: { type: 'ir', range_m: 200 },
environment: ['outdoor'],
ndaa_compliant: true,
},
{
id: 'hikvision-ds-2cd2087g3-li2uy',
brand: 'Hikvision',
model: 'DS-2CD2087G3-LI2UY',
type: 'bullet',
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
// i18n-ignore: datasheet label
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
night_vision: { type: 'color', range_m: 40 },
environment: ['outdoor'],
ndaa_compliant: false,
},
{
id: 'axis-f4105-lre',
brand: 'Axis',
model: 'F4105-LRE',
type: 'dome',
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
fov_deg: '110 horizontal, 60 vertical',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
night_vision: { type: 'ir', range_m: 10 },
environment: ['outdoor'],
ndaa_compliant: true,
},
{
id: 'axis-p3707-pe',
brand: 'Axis',
model: 'P3707-PE',
type: 'panoramic',
url: 'https://www.cctv-database.com/camera/axis-p3707-pe/',
aliases: ['AXIS P3707-PE', 'P3707-PE'],
fov_deg: '108-54 horizontal per sensor (4x 1080p)',
resolution: {
mp: 8,
w: 1920,
h: 1080,
// i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)',
},
lens_count: 4,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'axis-q6000-e-mk-ii',
brand: 'Axis',
model: 'Q6000-E Mk II',
type: 'panoramic',
url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/',
aliases: [
'AXIS Q6000-E Mk II',
'Q6000-E Mk II 50 Hz',
'Q6000-E Mk II 60 Hz',
],
fov_deg: 'up to 360 combined; 113-152 horizontal per sensor',
resolution: {
mp: 8,
w: 1920,
h: 1080,
// i18n-ignore: datasheet label
label: '4x 2MP (8MP total; 4x 1080p)',
},
lens_count: 4,
environment: ['outdoor'],
},
{
id: 'axis-q6054-e',
brand: 'Axis',
model: 'Q6054-E',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/axis-q6054-e/',
aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'],
fov_deg: '62.9-2.2 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'axis-q6155-e',
brand: 'Axis',
model: 'Q6155-E',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/axis-q6155-e/',
aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'],
fov_deg: '66.7-2.36 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-autodome-500i',
brand: 'Bosch',
model: 'AutoDome 500i Series (VG4)',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/',
aliases: [
'VG4 AUTODOME',
'VG4 AUTODOME H.264',
'AutoDome 500i',
'AutoDome 500i Series',
'VG4-500i',
'VG4-500',
],
fov_deg: '57.8-1.7 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-autodome-ip-dynamic-7000-hd',
brand: 'Bosch',
model: 'AUTODOME IP dynamic 7000 HD',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/',
aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'],
fov_deg: '65-2.3 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-autodome-ip-starlight-7000-hd',
brand: 'Bosch',
model: 'AUTODOME IP starlight 7000 HD',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/',
aliases: [
'AUTODOME IP starlight 7000 HD',
'Starlight 7000HD',
'VG5-7130-CPT4',
'VG5-7130',
],
fov_deg: '59-2.1 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-ndp-5502-30',
brand: 'Bosch',
model: 'AUTODOME IP 5000i (NDP-5502-Z30)',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/',
aliases: [
'AUTODOME IP 5000i',
'NDP-5502-Z30',
'NDP-5502-Z30C',
'NDP-5502-30',
],
fov_deg: '60.9-2.4 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-ndp-7512-z30',
brand: 'Bosch',
model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/',
aliases: [
'AUTODOME IP starlight 7000i',
'NDP-7512-Z30',
'NDP-7512-Z30C',
'NDP-7512-Z30K',
],
fov_deg: '64.7-2.3 horizontal',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-vg5-836-ecev',
brand: 'Bosch',
model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/',
aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'],
resolution: { mp: 2, label: '1080p' },
ptz: true,
autotracking: true,
environment: ['outdoor'],
},
{
id: 'cohu-3920-idome',
brand: 'Cohu',
model: '3920 iDome',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3920-idome/',
aliases: [
'3920',
'3920 Series',
'3923',
'3925',
'3925 Dome',
'iDome 3925',
'Cohu iDome 3920 Series',
],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'cohu-3950-iview',
brand: 'Cohu',
model: '3950 iVIEW',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3950-iview/',
aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
ptz: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'cohu-3960',
brand: 'Cohu',
model: '3960',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/cohu-3960/',
aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'],
// i18n-ignore: datasheet label
resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' },
ptz: true,
varifocal: true,
environment: ['outdoor'],
},
];
/** Lowercase + collapse whitespace, the shared normal form for matching. */
function normalize(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
/**
* Whitespace words of a name, lowercased, with surrounding punctuation trimmed
* so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay:
* "f4105-lre-unknown" is one word, never "f4105-lre" plus noise.
* @param {string} value
* @returns {string[]}
*/
function words(value) {
return normalize(value)
.split(' ')
.map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
.filter(Boolean);
}
/**
* Descriptor words that never identify hardware: product-type nouns, codec
* and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p").
*/
const GENERIC_WORDS = new Set([
'network',
'camera',
'cam',
'ip',
'ptz',
'dome',
'bullet',
'box',
'hd',
'ir',
'outdoor',
'indoor',
'series',
]);
function isGeneric(word) {
return (
GENERIC_WORDS.has(word) ||
/^h\.?\d+$/.test(word) ||
/^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word)
);
}
/** A model code: letters AND digits, and not a generic descriptor. */
function isModelCode(word) {
return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word);
}
/** Hyphenated variant suffixes that do not change the hardware. */
const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']);
/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */
function stripVariantSuffix(word) {
let out = word;
for (;;) {
const cut = out.lastIndexOf('-');
if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out;
out = out.slice(0, cut);
}
}
/** Manufacturer names (lowercased words) accepted for each record brand. */
const BRAND_ALIASES = {
Hanwha: ['wisenet', 'samsung'],
Hikvision: ['hiwatch'],
};
function brandWords(record) {
return new Set([
...words(record.brand),
...(BRAND_ALIASES[record.brand] || []),
]);
}
/**
* One entry per record name (model + aliases). `required` is every
* non-generic word of the name — all must appear in a query, so a generation
* or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks
* names carrying a letter+digit model code; names without one ("3950",
* "01959-004", "AUTODOME 800 Series") match only when the brand does.
*/
const NAME_INDEX = RECORDS.flatMap((record) => {
const brand = brandWords(record);
return [record.model, ...(record.aliases || [])].map((name) => {
const nameWords = words(name);
return {
record,
brand,
allowed: new Set(nameWords),
required: nameWords.filter((word) => !isGeneric(word)),
hasCode: nameWords.some(isModelCode),
};
});
});
/**
* Looks up a spec record for a hardware-model string.
*
* A record name matches when every non-generic word of that name appears in
* the query (case-insensitive, punctuation-trimmed) and every other query word
* is generic or the record's brand. That lets a VERBOSE registry string
* resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while
* an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part
* number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and
* product-type words never identify hardware on their own. A name with no
* letter+digit model code ("3950") needs the brand, from `manufacturer` or the
* query itself. A `manufacturer` that is not the record's brand always
* rejects. Matches on two different records are ambiguous → null (never
* guesses).
* @param {string} model - Hardware model as named by a source pack or seed.
* @param {string} [manufacturer] - The source's manufacturer, when published.
* @returns {Object|null} The verbatim spec record, or null when unknown.
*/
export function lookupModelSpec(model, manufacturer) {
const query = words(model).map(stripVariantSuffix);
if (!query.length) return null;
const maker = words(manufacturer);
const found = new Set();
for (const entry of NAME_INDEX) {
const makerMatches = maker.some((word) => entry.brand.has(word));
if (maker.length && !makerMatches) continue;
if (
!entry.hasCode &&
!makerMatches &&
!query.some((word) => entry.brand.has(word))
)
continue;
if (!entry.required.length) continue;
if (!entry.required.every((word) => query.includes(word))) continue;
if (
!query.every(
(word) =>
entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word),
)
)
continue;
found.add(entry.record);
}
return found.size === 1 ? [...found][0] : null;
}
/**
* Datasheet horizontal FOV as the range the hardware can cover. Only
* segments stating a horizontal figure count — vertical and "combined"
* panoramic figures never do — and parenthesized lens/sensor notes are
* ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list
* ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range.
* @param {Object} spec - A record from this module.
* @returns {{minDeg:number, maxDeg:number}|null} Null when unstated.
*/
export function horizontalFovRangeDeg(spec) {
const values = [];
for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) {
if (!/horizontal/i.test(segment)) continue;
const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0];
for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) {
const value = Number(match[0]);
if (Number.isFinite(value) && value > 0) values.push(value);
}
}
if (!values.length) return null;
return { minDeg: Math.min(...values), maxDeg: Math.max(...values) };
}
/**
* The datasheet horizontal FOV as the camera's CURRENT optical state, which
* holds only for fixed single-lens hardware stating one figure. PTZ,
* varifocal and multi-sensor models publish a capability range whose in-use
* value is unknown — known hardware capability is not known pose state — so
* they return null and keep their estimated FOV.
* @param {Object} spec - A record from this module.
* @returns {number|null} Fixed horizontal FOV in degrees, or null.
*/
export function fixedHorizontalFovDeg(spec) {
if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1)
return null;
const range = horizontalFovRangeDeg(spec);
return range && range.minDeg === range.maxDeg ? range.minDeg : null;
}
/**
* True when the camera's CURRENT FOV is the datasheet figure: it was filled
* from a fixed-lens datasheet and no manual calibration offset has moved it.
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
* @returns {boolean}
*/
export function isDatasheetFov(camera) {
return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg;
}
/**
* HUD token for a datasheet FOV that describes capability rather than the
* camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'.
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
* @param {(span: string) => string} [format] Builds the token from the
* degree span; the HUD passes a translated one.
* @returns {string|null} Null when the datasheet FOV already IS the camera's
* FOV (fixed lens) or no datasheet range is known.
*/
export function specFovCapabilityToken(
camera,
format = (span) => `SPEC FOV ${span}° (CAPABILITY)`,
) {
if (!camera || camera.fovSource === 'datasheet') return null;
const { specFovMinDeg: min, specFovMaxDeg: max } = camera;
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg)));
const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`;
return format(span);
}
/**
* One-line spec summary for HUD meta text.
* @param {Object} spec - A record from this module.
* @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null).
*/
export function specSummary(spec) {
if (!spec) return '';
const parts = [`${spec.brand} ${spec.model}`];
if (spec.resolution?.label) parts.push(spec.resolution.label);
if (spec.night_vision) {
parts.push(
`${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`,
);
}
if (spec.ptz) parts.push('PTZ');
return parts.join(' · ');
}
+301
View File
@@ -0,0 +1,301 @@
// src/layers/cctv/modelSpecs.test.mjs
// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias
// matching, unknown → null — never a guess), datasheet-FOV parsing across the
// real fov_deg string shapes, and the HUD spec-summary line.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
lookupModelSpec,
horizontalFovRangeDeg,
fixedHorizontalFovDeg,
specFovCapabilityToken,
isDatasheetFov,
specSummary,
} from './modelSpecs.js';
import { createCatalog } from './catalog.js';
test('lookupModelSpec matches case-insensitively and trims', () => {
const spec = lookupModelSpec(' q6135-le ');
assert.ok(spec);
assert.equal(spec.brand, 'Axis');
assert.equal(lookupModelSpec('Q6135-LE'), spec);
});
test('lookupModelSpec matches aliases', () => {
// A part number without letters needs the brand.
const byAlias = lookupModelSpec('01959-004', 'Axis');
assert.ok(byAlias);
assert.equal(byAlias.model, 'Q6135-LE');
});
test('vendored table covers the integrated catalogs published hardware', () => {
// Models the live packs publish: King County (Cohu, AUTODOME IP 5000i),
// Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis).
// King County publishes "3950" with Manufacturer "Cohu".
const cohu = lookupModelSpec('3950', 'Cohu');
assert.equal(cohu?.brand, 'Cohu');
const autodome = lookupModelSpec('AUTODOME IP 5000i');
assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0);
const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string
assert.ok(vg4);
const axis = lookupModelSpec('Q6155-E'); // Sioux Falls
assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 });
});
test('verbose registry strings resolve via distinctive-token fallback', () => {
// Sioux Falls publishes the full product title, not the bare model.
assert.equal(
lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model,
'Q6155-E',
);
assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II');
assert.equal(
lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id,
'bosch-ndp-7512-z30',
);
// Punctuation around the model token doesn't defeat the match.
assert.equal(
lookupModelSpec('camera (P3707-PE), outdoor')?.model,
'P3707-PE',
);
});
test('token fallback never matches on generic words or wrong models', () => {
// Only generic descriptors — no distinctive hardware token — stays null.
assert.equal(lookupModelSpec('PTZ Dome Camera'), null);
assert.equal(lookupModelSpec('HD Network Camera'), null);
assert.equal(lookupModelSpec('Outdoor Dome Camera'), null);
// A real-looking but unknown model resolves to nothing, not a near neighbour.
assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null);
// A generation token alone must not collide with a similar one: "7000"
// belongs to two records (dynamic / starlight 7000 HD), so a bare
// "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick.
assert.equal(lookupModelSpec('AUTODOME 7000'), null);
});
test('exact match still wins over the token fallback', () => {
// "3965" is an alias of the 3960, not the 3950.
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30');
});
test('lookupModelSpec returns null for unknown or empty input — never guesses', () => {
assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null);
assert.equal(lookupModelSpec(''), null);
assert.equal(lookupModelSpec(null), null);
assert.equal(lookupModelSpec(undefined), null);
});
test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => {
const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model));
// PTZ zoom: both ends, not just the wide one.
assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 });
// Vertical figures never count.
assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 });
// Lens options: parenthesized focal lengths are not FOV figures.
assert.deepEqual(range('DS-2CD2087G3-LI2UY'), {
minDeg: 93.3,
maxDeg: 108.8,
});
assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 });
// The "360 combined" panoramic figure is not a horizontal lens FOV.
assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 });
assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 });
});
test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => {
assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null);
assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null);
assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null);
assert.equal(horizontalFovRangeDeg({}), null);
assert.equal(horizontalFovRangeDeg(null), null);
});
test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => {
// Fixed dome, one horizontal figure: the datasheet IS the optical state.
assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
// Known hardware capability != known pose state: PTZ, multi-sensor, and a
// model sold in several lens options all stay estimated.
for (const model of [
'Q6135-LE',
'XNP-6400RW',
'AUTODOME IP 5000i',
'P3707-PE',
'Q6000-E Mk II',
'DS-2CD2087G3-LI2UY',
]) {
assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model);
}
assert.equal(fixedHorizontalFovDeg(null), null);
});
test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => {
assert.equal(
specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }),
'SPEC FOV 2.4–58° (CAPABILITY)',
);
assert.equal(
specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }),
'SPEC FOV 113° (CAPABILITY)',
);
// Already the camera's FOV (fixed lens) or nothing known: no token.
assert.equal(
specFovCapabilityToken({
fovSource: 'datasheet',
specFovMinDeg: 110,
specFovMaxDeg: 110,
}),
null,
);
assert.equal(specFovCapabilityToken({}), null);
assert.equal(specFovCapabilityToken(null), null);
});
/** Browser catalog with the pose math stubbed: only enrichment is under test. */
function browserCatalog() {
const model = {
safeNumber: (value, fallback = NaN) =>
Number.isFinite(Number(value)) ? Number(value) : fallback,
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
headingFromId: () => 0,
normalizeFeedType: (type) => type,
ensureCameraPose: () => {},
};
return createCatalog({
state: {},
services: { locations: { CITY_POIS: {} } },
parts: { model },
source: {},
});
}
test('catalog keeps the estimated FOV for PTZ hardware and fills only fixed-lens datasheets', () => {
const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([
{ id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' },
{ id: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' },
{ id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' },
]);
// PTZ: the frustum keeps its estimate; the datasheet range is metadata.
assert.equal(ptz.fovDeg, 44);
assert.equal(ptz.fovSource, undefined);
assert.equal(ptz.specFovMinDeg, 2.4);
assert.equal(ptz.specFovMaxDeg, 58.3);
assert.equal(ptz.spec.model, 'Q6135-LE');
// Fixed lens: the datasheet figure is the optical state.
assert.equal(fixed.fovDeg, 110);
assert.equal(fixed.fovSource, 'datasheet');
// Unknown hardware changes nothing.
assert.equal(unknown.fovDeg, 44);
assert.equal(unknown.spec, undefined);
assert.equal(unknown.specFovMaxDeg, undefined);
});
test('codec, format and product words never identify hardware', () => {
// "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code.
assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null);
assert.equal(lookupModelSpec('H.264 Network Camera'), null);
assert.equal(lookupModelSpec('H.265 IP Camera'), null);
});
test('generation and suffix distinctions are preserved', () => {
// A Mk II is not the vendored (Mk I) Q6135-LE.
assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null);
// A longer part number is a different product...
assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null);
// ...unless the rest is a mains-frequency variant suffix.
assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e');
});
test('manufacturer must agree with the record brand', () => {
// Purely numeric models need a brand match.
assert.equal(lookupModelSpec('3950'), null);
assert.equal(lookupModelSpec('Pelco 3950'), null);
assert.equal(lookupModelSpec('3950', 'Pelco'), null);
// A model code under the wrong manufacturer is not that record either.
assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null);
// Case-insensitive, with company suffixes and aliases.
assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview');
assert.equal(
lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id,
'axis-q6135-le',
);
assert.equal(
lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id,
'hanwha-xnp-6400rw',
);
assert.equal(
lookupModelSpec('XNP-6400RW', 'Wisenet')?.id,
'hanwha-xnp-6400rw',
);
});
test('King County snapshot model strings still resolve', () => {
// Model/Manufacturer pairs as published in the King County feed.
assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview');
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome');
assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le');
assert.equal(
lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id,
'bosch-ndp-5502-30',
);
});
test('catalog never overwrites an explicit FOV or a curated pose', () => {
const [explicit, curated, cohu, pelco] =
browserCatalog().buildCatalogFromSources([
{ id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' },
{
id: 'curated',
lat: 47.7,
lon: -122,
model: 'F4105-LRE',
poseSource: 'curated',
},
{ id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' },
{
id: 'pelco',
lat: 47.7,
lon: -122,
model: '3950',
manufacturer: 'Pelco',
},
]);
assert.equal(explicit.fovDeg, 44);
assert.equal(explicit.fovSource, undefined);
assert.equal(explicit.spec.model, 'F4105-LRE');
assert.equal(curated.fovDeg, 74);
assert.equal(curated.fovSource, undefined);
// The source's manufacturer reaches the lookup.
assert.equal(cohu.spec?.id, 'cohu-3950-iview');
assert.equal(pelco.spec, undefined);
});
test('the DATASHEET label drops once calibration moves the FOV', () => {
const camera = { fovSource: 'datasheet', fovDeg: 110 };
assert.equal(isDatasheetFov(camera), true);
assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true);
assert.equal(
isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }),
false,
);
assert.equal(isDatasheetFov({ fovDeg: 74 }), false);
assert.equal(isDatasheetFov(null), false);
});
test('specSummary renders the HUD line and degrades by omission', () => {
assert.equal(
specSummary(lookupModelSpec('Q6135-LE')),
'Axis Q6135-LE · 1080p · IR 250m · PTZ',
);
// No night vision / no PTZ → those segments are simply absent.
assert.equal(
specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }),
'Axis X · 4K',
);
assert.equal(specSummary(null), '');
});
+18 -18
View File
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-midtown-w',
cityId: 'nyc',
poiIndex: 1,
label: 'Midtown West @ 34th',
label: 'Midtown West @ 34th', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: -70,
headingDeg: 206,
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-wtc-n',
cityId: 'nyc',
poiIndex: 2,
label: 'WTC North Plaza',
label: 'WTC North Plaza', // i18n-ignore: camera name
offsetNorthM: 95,
offsetEastM: 34,
headingDeg: 164,
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
id: 'nyc-times-square-ne',
cityId: 'nyc',
poiIndex: 1,
label: 'Times Sq Northeast',
label: 'Times Sq Northeast', // i18n-ignore: camera name
offsetNorthM: 230,
offsetEastM: 120,
headingDeg: 218,
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
id: 'sf-market-5th',
cityId: 'sf',
poiIndex: 2,
label: 'Market & 5th',
label: 'Market & 5th', // i18n-ignore: camera name
offsetNorthM: -160,
offsetEastM: 80,
headingDeg: 320,
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
id: 'sf-financial-district',
cityId: 'sf',
poiIndex: 1,
label: 'SF Financial Core',
label: 'SF Financial Core', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: 52,
headingDeg: 205,
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-shibuya-scramble',
cityId: 'tokyo',
poiIndex: 4,
label: 'Shibuya Crossing',
label: 'Shibuya Crossing', // i18n-ignore: camera name
offsetNorthM: 180,
offsetEastM: 46,
headingDeg: 18,
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-ginza-core',
cityId: 'tokyo',
poiIndex: 0,
label: 'Ginza Core',
label: 'Ginza Core', // i18n-ignore: camera name
offsetNorthM: -180,
offsetEastM: 150,
headingDeg: 245,
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
id: 'tokyo-asakusa-n',
cityId: 'tokyo',
poiIndex: 3,
label: 'Asakusa North Gate',
label: 'Asakusa North Gate', // i18n-ignore: camera name
offsetNorthM: 110,
offsetEastM: -65,
headingDeg: 192,
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
id: 'london-city-a1',
cityId: 'london',
poiIndex: 4,
label: 'City Cluster A1',
label: 'City Cluster A1', // i18n-ignore: camera name
offsetNorthM: 80,
offsetEastM: 65,
headingDeg: 220,
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
id: 'london-soho-core',
cityId: 'london',
poiIndex: 2,
label: 'Soho Core',
label: 'Soho Core', // i18n-ignore: camera name
offsetNorthM: 210,
offsetEastM: 120,
headingDeg: 206,
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
id: 'paris-rivoli',
cityId: 'paris',
poiIndex: 4,
label: 'Rue de Rivoli',
label: 'Rue de Rivoli', // i18n-ignore: camera name
offsetNorthM: 55,
offsetEastM: 85,
headingDeg: 248,
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
id: 'paris-champs-n',
cityId: 'paris',
poiIndex: 1,
label: 'Champs-Élysées North',
label: 'Champs-Élysées North', // i18n-ignore: camera name
offsetNorthM: 130,
offsetEastM: -38,
headingDeg: 175,
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
id: 'dc-mall-center',
cityId: 'dc',
poiIndex: 1,
label: 'National Mall Center',
label: 'National Mall Center', // i18n-ignore: camera name
offsetNorthM: 120,
offsetEastM: 20,
headingDeg: 258,
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
id: 'dc-pentagon-s',
cityId: 'dc',
poiIndex: 3,
label: 'Pentagon South',
label: 'Pentagon South', // i18n-ignore: camera name
offsetNorthM: -100,
offsetEastM: 92,
headingDeg: 14,
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-difc-loop',
cityId: 'dubai',
poiIndex: 4,
label: 'DIFC Loop',
label: 'DIFC Loop', // i18n-ignore: camera name
offsetNorthM: 92,
offsetEastM: -45,
headingDeg: 196,
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
id: 'dubai-downtown-east',
cityId: 'dubai',
poiIndex: 0,
label: 'Downtown East',
label: 'Downtown East', // i18n-ignore: camera name
offsetNorthM: -130,
offsetEastM: 190,
headingDeg: 322,
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
id: 'austin-congress-s',
cityId: 'austin',
poiIndex: 0,
label: 'Congress Southbound',
label: 'Congress Southbound', // i18n-ignore: camera name
offsetNorthM: -165,
offsetEastM: 40,
headingDeg: 12,
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
id: 'austin-downtown-west',
cityId: 'austin',
poiIndex: 1,
label: 'Downtown West',
label: 'Downtown West', // i18n-ignore: camera name
offsetNorthM: -120,
offsetEastM: -160,
headingDeg: 120,
+73 -13
View File
@@ -1,5 +1,12 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
specSummary,
specFovCapabilityToken,
isDatasheetFov,
} from './modelSpecs.js';
export function createPresentation({
state: layerState,
@@ -18,8 +25,13 @@ export function createPresentation({
const active = parts.selection.getActiveRecord();
if (!active) {
return layerState._records.length
? `${layerState._records.length} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE`
: 'No cameras available in catalog.';
? t(
'{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE',
{
count: layerState._records.length,
},
)
: t('No cameras available in catalog.');
}
const area = parts.model.sectorAreaKm2(
@@ -33,19 +45,50 @@ export function createPresentation({
const calBadge = parts.calibration.deriveCalBadge(active.camera);
return [
`${active.camera.city.toUpperCase()} CCTV`,
t('{city} CCTV', {
city: active.camera.city.toUpperCase(),
}),
`${active.camera.name.toUpperCase()}`,
`HDG ${Math.round(active.camera.headingDeg)}°`,
`FOV ${Math.round(active.camera.fovDeg)}°`,
`COVERAGE ${area.toFixed(2)}km²`,
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
layerState._coverageMode === 'viewshed' ? 'VIEWSHED' : null,
`CAL ${calBadge.replace('-', ' ').toUpperCase()}`,
// A synthetic bearing (headingConfidence 'low', no human calibration)
// is tagged so a hashed guess never reads as a surveyed facing (#639).
headingHudToken(active.camera),
// Identified hardware labels its FOV as datasheet-sourced (catalog.js)
// only while no manual calibration offset has moved it.
isDatasheetFov(active.camera)
? t('FOV {value}° (DATASHEET)', {
value: Math.round(active.camera.fovDeg),
})
: t('FOV {value}°', {
value: Math.round(active.camera.fovDeg),
}),
// A PTZ/varifocal datasheet range is what the lens can do, not its
// current zoom, so it is shown beside the estimate, never as it.
specFovCapabilityToken(active.camera, (span) =>
t('SPEC FOV {span}° (CAPABILITY)', { span }),
),
t('COVERAGE {value}km²', { value: area.toFixed(2) }),
overlapCount > 0
? t('OVERLAP {count} cams', { count: overlapCount })
: t('ISOLATED VIEW'),
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
t('CAL {value}', {
value: calBadge.replace('-', ' ').toUpperCase(),
}),
health?.sourceKind
? `SRC ${String(health.sourceKind).toUpperCase()}`
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
`${viewBand.toUpperCase()} CONTEXT`,
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
// Identified hardware: append the model's datasheet specs + attribution.
active.camera.spec
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
spec: specSummary(active.camera.spec).toUpperCase(),
})
: null,
]
.filter(Boolean)
.join(' · ');
@@ -76,13 +119,30 @@ export function createPresentation({
lat: camera.lat,
lon: camera.lon,
headingDeg: camera.headingDeg,
// Bearing provenance (#639): the pack's confidence flag plus the derived
// "is this a synthetic guess" bit (calibration-aware), so UI consumers
// never have to re-derive it.
headingConfidence: camera.headingConfidence || null,
headingEstimated: isHeadingEstimated(camera),
pitchDeg: camera.pitchDeg,
fovDeg: camera.fovDeg,
// Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js).
fovSource: isDatasheetFov(camera)
? 'datasheet'
: camera.fovSource === 'datasheet'
? 'calibrated'
: camera.fovSource || 'estimated',
specFovMinDeg: camera.specFovMinDeg ?? null,
specFovMaxDeg: camera.specFovMaxDeg ?? null,
spec: camera.spec || null,
rangeM: camera.rangeM,
elevationM: camera.absoluteHeightM,
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+34 -15
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
@@ -215,7 +216,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
@@ -228,6 +234,15 @@ export function createRendering({
);
const inVisibleSet = coverageVisible.has(record.camera.id);
// A synthetic bearing (headingConfidence 'low', no human calibration)
// draws its wireframe dashed so a guessed facing is visibly provisional
// rather than rendering identically to a surveyed one (#639). Colors,
// widths, and the active/idle emphasis are unchanged.
const bearingEstimated = isHeadingEstimated(record.camera);
const lineMaterial = (color) =>
bearingEstimated
? new Cesium.PolylineDashMaterialProperty({ color })
: color;
for (const entity of record.coverageEntities || []) {
// The frustum wireframe is part of the projection representation —
// force it on for the active camera and let it read through geometry
@@ -242,13 +257,15 @@ export function createRendering({
// active camera keeps its width/alpha emphasis in both schemes.
const hue = viewshedOn ? record.viewshedColors : null;
if (entity._coverageRole === 'cap') {
entity.polyline.material = hue
? isActive
? hue.lineActive
: hue.line
: isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED;
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line
: isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED,
);
entity.polyline.width = isActive ? 2.2 : 1.0;
entity.polyline.depthFailMaterial = planeShowing
? hue
@@ -256,13 +273,15 @@ export function createRendering({
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
: undefined;
} else {
entity.polyline.material = hue
? isActive
? hue.lineActive
: hue.line.withAlpha(0.6)
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED;
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line.withAlpha(0.6)
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED,
);
entity.polyline.width = isActive ? 1.8 : 0.9;
entity.polyline.depthFailMaterial = planeShowing
? hue
+24
View File
@@ -3,6 +3,12 @@ import {
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
} from './sourcePolicy.js';
const FRAME_SOURCE_KINDS = new Set([
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
]);
function safeNumber(value, fallback = NaN) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
@@ -51,5 +57,23 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read a registered frame and its provenance, including fallback frames. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
cache: 'no-store',
signal,
});
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
const sourceKind = response.headers.get('x-cctv-source');
return {
contentType: type.split(';')[0].trim().toLowerCase(),
bytes,
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
};
},
};
}
+48
View File
@@ -148,3 +148,51 @@ test('camera construction is inert and destruction cancels a pending catalog and
assert.equal(a.getStats().count, 0);
assert.equal(b.getStats().count, 0);
});
test('frames are read through the registered frame endpoint', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createCctvSource({
fetchImpl: async (url, init) => {
requested.push({ url, cache: init.cache });
return new Response(png, {
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
});
},
});
const frame = await source.getFrame(camera);
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.equal(
requested[0].url.split('?')[0],
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
);
assert.equal(requested[0].cache, 'no-store');
const failing = createCctvSource({
fetchImpl: async () => new Response(null, { status: 502 }),
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
for (const kind of [
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
'other',
null,
]) {
const source = createCctvSource({
fetchImpl: async () =>
new Response(new Uint8Array([1]), {
headers: {
'Content-Type': 'image/png',
...(kind ? { 'X-CCTV-Source': kind } : {}),
},
}),
});
const frame = await source.getFrame(camera);
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
}
});
+137
View File
@@ -0,0 +1,137 @@
/**
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
* contexts, so a page opened over plain HTTP from another machine (the
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
* and yields the same shape the server's lease check accepts.
*/
export function createLeaseId(cryptoImpl = globalThis.crypto) {
if (typeof cryptoImpl.randomUUID === 'function') {
return cryptoImpl.randomUUID();
}
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4),
hex.slice(4, 6),
hex.slice(6, 8),
hex.slice(8, 10),
hex.slice(10),
]
.map((group) => group.join(''))
.join('-');
}
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
cryptoImpl = globalThis.crypto,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto';
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
const LEASE_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
/** What an insecure context exposes: getRandomValues but no randomUUID. */
const insecureCrypto = {
getRandomValues: (array) => webcrypto.getRandomValues(array),
};
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
assert.match(createLeaseId(), LEASE_V4);
const ids = new Set();
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
assert.equal(ids.size, 64);
for (const id of ids) assert.match(id, LEASE_V4);
});
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
const source = video();
source.canPlayType = () => 'probably';
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async () => {},
cryptoImpl: insecureCrypto,
});
try {
await playback.ready;
const lease = new URL(
source.src,
'http://192.168.1.10:4173/',
).searchParams.get('lease');
assert.match(lease, LEASE_V4);
} finally {
playback.dispose();
}
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import { setLocale, t } from '../../i18n/index.js';
// The exact string the server sends (COVERAGE in server/providers/cyclones.js).
const SERVER_COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const time = '2026-09-16T03:00:00.000Z';
const storm = {
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
};
const snapshot = (storms, extra = {}) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: SERVER_COVERAGE,
reason: null,
...extra,
});
function harness(feed) {
const rendering = {
async setSnapshot() {
return true;
},
setSelection() {},
getFocusSphere: () => ({ radius: 500000 }),
clear() {},
destroy() {},
getDiagnostics: () => ({ entities: 0, timerActive: false }),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: false }),
openLink: () => {},
});
layer.init({ camera: { flyToBoundingSphere() {} } });
layer.attachShellServices({ runNavigation: (fn) => fn() });
return layer;
}
const translated = () => t(SERVER_COVERAGE);
test('es: server snapshot without storms -> coverage line in Spanish', async () => {
setLocale('es');
try {
assert.notEqual(translated(), SERVER_COVERAGE, 'es.json has a translation');
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('es: server snapshot with a storm -> coverage line in Spanish', async () => {
setLocale('es');
try {
const layer = harness({ getSnapshot: async () => snapshot([storm]) });
layer.enable();
await layer.update();
const info = layer.getRowControls().info;
assert.ok(
info.endsWith(translated()),
`expected Spanish coverage, got:\n${info}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('es: unavailable without reason -> fallback status not the English literal', async () => {
setLocale('es');
try {
const layer = harness({
getSnapshot: async () =>
snapshot([], { unavailable: true, reason: null }),
});
layer.enable();
await layer.update();
const status = layer.getRowControls().summary.status;
assert.notEqual(
status,
'Cyclone advisories unavailable',
`fallback shown in English: ${status}`,
);
layer.destroy();
} finally {
setLocale('en');
}
});
test('en: coverage and fallback status read as before', async () => {
const layer = harness({ getSnapshot: async () => snapshot([]) });
layer.enable();
await layer.update();
assert.ok(layer.getRowControls().info.endsWith(SERVER_COVERAGE));
layer.destroy();
const down = harness({
getSnapshot: async () => snapshot([], { unavailable: true, reason: null }),
});
down.enable();
await down.update();
assert.equal(
down.getRowControls().summary.status,
'Cyclone advisories unavailable',
);
down.destroy();
});
+535
View File
@@ -0,0 +1,535 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
formatUtcDayMonth,
t,
tn,
msg,
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
const utc = (value) =>
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
);
// The server sends the same English coverage (server/providers/cyclones.js);
// data stays English and is translated here, where it is shown.
const coverageText = (coverage) =>
!coverage || coverage === COVERAGE ? t(COVERAGE) : translateKnown(coverage);
const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable');
// Reason the server gives with an unavailable snapshot, translated when shown.
const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]);
const errorText = (error) =>
error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error));
const CLASSIFICATION_NAMES = Object.freeze({
PTC: msg('Potential tropical cyclone'),
HU: msg('Hurricane'),
TS: msg('Tropical storm'),
TD: msg('Tropical depression'),
SS: msg('Subtropical storm'),
SD: msg('Subtropical depression'),
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code)
? t(CLASSIFICATION_NAMES[code])
: code;
const number = (value, unit) =>
value === null ? t('Unavailable') : formatQuantity(value, unit);
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let viewer = null,
rendering = null,
snapshot = null,
request = null,
listener = null,
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
loading = false,
error = null,
destroyed = false,
runNavigation = null;
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
rendering?.setSelection(id);
}
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
};
const capture = (event) => {
capturedHit = null;
const point = event.changedTouches?.[0] || event;
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
return;
const rect = canvas.getBoundingClientRect();
const x = point.clientX - rect.left,
y = point.clientY - rect.top;
capturedHit = { x, y, ...overlayHit(x, y) };
};
const resetCapture = () => {
capturedHit = null;
};
const resetEvents = [
'pointerdown',
'mousedown',
'touchstart',
'pointercancel',
'touchcancel',
];
for (const type of resetEvents)
canvas.addEventListener?.(type, resetCapture, { capture: true });
const events = ['pointerup', 'mouseup', 'touchend'];
for (const type of events)
canvas.addEventListener?.(type, capture, { capture: true });
removeClickCapture = () => {
for (const type of events)
canvas.removeEventListener?.(type, capture, { capture: true });
for (const type of resetEvents)
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
// the pointer, camera, or tracking state.
if (
!enabled ||
destroyed ||
clickHandler !== owner ||
!isPointerFree() ||
!click?.position
)
return;
// AIS cards paint above the globe on a pointer-events:none canvas. The
// vessel handler resolves this same topmost hit before cyclone geometry.
const captureMatches =
nativeHit &&
Math.abs(nativeHit.x - click.position.x) < 1 &&
Math.abs(nativeHit.y - click.position.y) < 1;
const hit = captureMatches
? nativeHit
: overlayHit(click.position.x, click.position.y);
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
// Storm cards and lead-hour labels paint on the same canvas; a click on
// one selects its storm. An id from a superseded advisory changes nothing.
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
const id = cycloneStormIdFromEntryId(hit.entryId);
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
}
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
},
attachShellServices(services) {
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
if (!destroyed && !enabled) {
enabled = true;
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
);
installSelection();
}
},
disable() {
enabled = false;
unregisterPickOwner('weather-cyclones');
removeSelection();
request?.abort();
request = null;
loading = false;
error = null;
snapshot = null;
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
if (!enabled || destroyed) return false;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const next = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || ADVISORIES_UNAVAILABLE;
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
if (
!applied ||
!enabled ||
controller.signal.aborted ||
request !== controller
)
return false;
snapshot = next;
error = null;
if (!snapshot.storms.some((storm) => storm.id === selectedId))
select(
selectionIntent === 'cleared'
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || ADVISORIES_UNAVAILABLE;
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
selectedId = null;
++navigationGeneration;
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
if (!enabled || destroyed) return;
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
notify();
} else if (
typeof params.stormId === 'string' &&
snapshot?.storms.some((storm) => storm.id === params.stormId)
) {
selectionIntent = 'user';
select(params.stormId);
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
if (
!enabled ||
destroyed ||
generation !== navigationGeneration ||
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
: 1.4,
});
});
}
}
if (params.advisory === true && storm?.advisoryUrl)
openLink(storm.advisoryUrl);
},
getRowControls() {
const storm = selected();
const empty =
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? t('Track and cone match this advisory')
: storm.geometryStatus === 'pending'
? t('Track/cone awaiting advisory {number}', {
number: storm.advisoryNumber,
})
: t('Track/cone unavailable'));
const status =
errorText(error) ||
(snapshot?.stale
? t('Cached advisory · stale source')
: loading
? t('Loading advisories…')
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const stormCount = snapshot?.storms.length || 0;
const activeStorms = tn(
stormCount,
'{count} active storm',
'{count} active storms',
);
const position = storm
? t('Position as of {time}', { time: utc(storm.positionAt) })
: '';
const intensity = storm
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
wind: number(storm.windKt, 'kt'),
pressure: number(storm.pressureHpa, 'hPa'),
})
: '';
const detail = storm
? t('{name} · {classification} · Advisory {number} · issued {time}', {
name: storm.name,
classification: classificationName(storm.classification),
number: storm.advisoryNumber,
time: utc(storm.issuedAt),
})
: empty
? t('No active NHC/CPHC systems')
: stormCount
? activeStorms
: loading
? t('Loading advisories…')
: t('Advisories unavailable');
const controls = {
readout: true,
summary: {
label: t('Cyclones · NHC / CPHC'),
coverage: t('Atlantic · E/C Pacific'),
compact: stormCount
? storm
? t('{storms} · {name} selected', {
storms: activeStorms,
name: storm.name,
})
: activeStorms
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: t('Official advisory ↗'),
href: storm.advisoryUrl,
},
]
: [],
settings: [],
lines: storm
? [
{
id: 'position',
text: position,
muted: true,
},
{
id: 'intensity',
text: intensity,
},
{ id: 'geometry', text: geometry, muted: true },
]
: [],
detail,
status,
units: 'kt',
},
list: {
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
},
chips: [],
legendCategorical: true,
legend: storm
? [
{
label: t('Advisory center / forecast track'),
color: '#7fe6ed',
},
{
label: t('Center-track uncertainty cone'),
color: '#7fe6ed44',
},
]
: [],
info: storm
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${coverageText(snapshot.coverage)}`
: `${detail}${status ? '\n' + status : ''}\n${coverageText(snapshot?.coverage)}`,
infoTitle: t(
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
),
};
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
/**
* Snapshot loaded active storms as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!enabled || destroyed) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const storm of snapshot?.storms || []) {
if (result.length >= maxCount) break;
const lat = storm.position?.latitude;
const lon = storm.position?.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
result.push({
id: storm.name || storm.id,
name: storm.name || null,
classification: storm.classification || null,
basin: storm.basin || null,
lat,
lon,
windKt: num(storm.windKt),
pressureHpa: num(storm.pressureHpa),
});
}
return result;
},
getStats() {
const storm = selected();
return {
count: snapshot?.storms.length || 0,
lastUpdate: storm
? Date.parse(storm.issuedAt)
: snapshot?.fetchedAt || null,
loading,
error,
stale: Boolean(snapshot?.stale),
source: 'NOAA NHC / CPHC',
advisoryAt: storm?.issuedAt || null,
empty: Boolean(
snapshot && !snapshot.unavailable && !snapshot.storms.length,
),
};
},
getDiagnostics() {
return {
...rendering?.getDiagnostics(),
enabled,
loading,
requestPending: !!request,
selectionActive: clickHandler !== null,
selectedId,
selectionIntent,
timerActive: false,
};
},
destroy() {
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
listener = null;
runNavigation = null;
},
};
return layer;
}
+675
View File
@@ -0,0 +1,675 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import {
claimPointer,
releasePointer,
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
id,
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
});
const snapshot = (storms = [storm()]) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: 'Atlantic and eastern/central North Pacific',
reason: null,
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
true,
'flight tracking recognizes the cyclone pick and leaves the camera alone',
);
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
'a different click cannot reuse the captured card',
);
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
'3D Tiles surface content is empty map',
);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(
isPointerFree(),
true,
'ambient selection never claims the pointer',
);
layer.disable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
);
assert.equal(handlers.length, 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: () => null,
ownsPickId: () => false,
getDiagnostics: () => ({}),
};
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
) {
const applied = [],
navigation = [],
opened = [];
let cleared = 0,
destroyed = 0,
selection = null;
const rendering = {
async setSnapshot(value) {
applied.push(value);
return true;
},
setSelection(id) {
selection = id;
},
getFocusSphere: () => ({ radius: 500000 }),
clear() {
cleared++;
},
destroy() {
destroyed++;
},
getDiagnostics: () => ({
entities: applied.at(-1)?.storms.length || 0,
timerActive: false,
}),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
return fn();
},
});
return {
layer,
applied,
navigation,
opened,
get cleared() {
return cleared;
},
get destroyed() {
return destroyed;
},
get selection() {
return selection;
},
};
}
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm('ep162026')]);
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm(), storm('ep162026')]);
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep162026' });
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
assert.equal(h.navigation.length, 0);
next = snapshot([storm()]);
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
for (const clear of [{ stormId: null }, { clear: true }]) {
h.layer.setParams({ stormId: 'ep152026' });
h.layer.setParams(clear);
assert.equal(h.selection, null);
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
await h.layer.update();
assert.equal(h.selection, null);
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
}
h.layer.disable();
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
});
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
const staged = deferred();
const staging = deferred();
let calls = 0;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot() },
createRendering: () => ({
setSnapshot() {
if (++calls === 1) return true;
staging.resolve();
return staged.promise;
},
setSelection() {},
getDiagnostics: () => ({}),
clear() {},
destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const pending = layer.update();
await staging.promise;
layer.setParams({ clear: true });
staged.resolve(true);
await pending;
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
layer.destroy();
});
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
if (change === 'select') {
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep152026' });
} else if (change === 'clear') h.layer.setParams({ stormId: null });
else if (change === 'fallback') {
next = snapshot([storm('ep162026')]);
await h.layer.update();
} else h.layer[change]();
callbacks.shift()();
assert.equal(h.navigation.length, 0, change);
h.layer.destroy();
}
});
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
const h = harness();
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
h.layer.setParams({ stormId: 'ep152026', focus: true });
await h.layer.update();
callbacks[0]();
assert.equal(h.navigation.length, 0);
callbacks[1]();
assert.equal(h.navigation.length, 1);
assert.equal(h.navigation[0].options.duration, 1.4);
h.layer.destroy();
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
const record = { ...storm(), classification: code };
const h = harness({ getSnapshot: async () => snapshot([record]) });
try {
h.layer.enable();
await h.layer.update();
const controls = h.layer.getRowControls();
assert.ok(controls.summary.detail.includes(` · ${label} · `));
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
assert.equal(record.classification, code);
assert.equal(h.applied[0].storms[0].classification, code);
} finally {
h.layer.destroy();
}
}
});
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
const h = harness(
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
{ reducedMotion: true },
);
h.layer.enable();
assert.equal(await h.layer.update(), true);
const controls = h.layer.getRowControls();
assert.equal(controls.readout, true);
assert.deepEqual(controls.chips, []);
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
assert.equal(controls.summary.lines.length, 3);
assert.deepEqual(controls.summary.settings, []);
assert.deepEqual(controls.summary.actions, [
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
]);
assert.match(controls.summary.compact, /2 active storms · .* selected/);
assert.equal(controls.summary.sections, undefined);
assert.match(controls.list.ariaLabel, /NHC/);
assert.equal(controls.list.items.length, 2);
assert.equal(controls.list.items[0].active, true);
assert.match(controls.info, /awaiting advisory 10/);
assert.equal(
controls.info.split('Track/cone awaiting advisory 10').length - 1,
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
// No line break inside the date: a word joiner holds the hyphen.
assert.match(controls.info, /issued 09-\u206016\u00a003:00\u00a0UTC/);
assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
assert.equal(
controls.chips.some((chip) => chip.id === 'focus'),
false,
);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.selection, 'ep162026');
assert.equal(h.navigation[0], 'claimed');
assert.equal(h.navigation[1].options.duration, 0);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.navigation.length, 4, 'selected row focuses again');
h.layer.setParams({ advisory: true });
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
h.layer.disable();
h.layer.setParams({ focus: true, advisory: true });
assert.equal(h.navigation.length, 4);
assert.equal(h.opened.length, 1);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.layer.getDiagnostics().timerActive, false);
h.layer.destroy();
assert.equal(h.destroyed, 1);
});
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
let next = snapshot([]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().empty, true);
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
next = { ...snapshot(), stale: true };
await h.layer.update();
assert.match(h.layer.getRowControls().summary.status, /stale/);
assert.match(
h.layer.getRowControls().info,
/Track\/cone awaiting advisory 10/,
);
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.equal(h.layer.getStats().empty, false);
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
const first = deferred(),
second = deferred();
const signals = [];
let calls = 0;
const h = harness({
getSnapshot: ({ signal }) => {
signals.push(signal);
return (++calls === 1 ? first : second).promise;
},
});
h.layer.enable();
const old = h.layer.update(),
latest = h.layer.update();
assert.equal(signals[0].aborted, true);
second.resolve(snapshot());
assert.equal(await latest, true);
first.resolve(snapshot([storm('ep162026')]));
assert.equal(await old, false);
assert.equal(h.applied.length, 1);
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
const delayed = deferred();
const disabled = harness({ getSnapshot: () => delayed.promise });
disabled.layer.enable();
const work = disabled.layer.update();
disabled.layer.disable();
delayed.resolve(snapshot());
assert.equal(await work, false);
assert.equal(disabled.applied.length, 0);
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
disabled.layer.destroy();
});
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return snapshot();
},
});
h.layer.enable();
assert.equal(
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
false,
);
assert.equal(h.applied.length, 0);
await h.layer.update();
fail = true;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
assert.match(h.layer.getStats().error, /Network/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
+180
View File
@@ -0,0 +1,180 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
/**
* @module cyclones/labels
* @description Storm cards and lead-hour labels on the shared world-overlay
* host. The renderer supplies the anchors its points are drawn and
* horizon-culled at, plus the selection; the host owns projection, the same
* ellipsoid horizon test, collisions, fades and paint.
*/
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
/** Lead-hour labels show within 4,000 km of the camera. */
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
/** Advisory centre and forecast track; single source for points and cards. */
export const CYCLONE_ACCENT = '#7fe6ed';
/** Selected advisory centre. */
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
/** Point sizes the cards keep clear of. */
export const CYCLONE_MARKER_PX = Object.freeze({
storm: 9,
selected: 12,
forecast: 5,
});
const STORM_PRIORITY = 1000;
const LEAD_PRIORITY = 500;
const CYCLONE_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
function markerClearance(markerPx) {
const gapPx = Math.max(12, markerPx + 8);
return {
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
};
}
const SHARED_CARD = Object.freeze({
cardStyle: 'tactical',
collisionGroup: 'ambient-card',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// A click on a card selects its storm.
interactive: true,
});
/**
* One card per storm: name, then the source classification code.
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
),
id: `storm:${storm.id}`,
position: storm.position,
variant: selected ? 'selected' : 'card',
title: storm.name,
details: storm.classification ? [storm.classification] : [],
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
selected,
protected: selected,
priority: selected
? Number.MAX_SAFE_INTEGER
: STORM_PRIORITY + (Number(storm.windKt) || 0),
maxDistance: Number.POSITIVE_INFINITY,
};
}
/**
* Lead-hour label at one forecast point of the selected storm. Earlier lead
* times win collisions.
* @param {string} stormId
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
id: `lead:${stormId}:${point.tauHours}`,
position: point.position,
variant: 'card',
title: `${point.tauHours} h`,
details: [],
accent: CYCLONE_ACCENT,
selected: false,
protected: false,
priority: LEAD_PRIORITY - point.tauHours,
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
// Hard cutoff at the limit, without a fade.
distanceFadeStartRatio: 1,
};
}
/**
* Storm cards for every storm and lead-hour labels for the selected one.
* @param {Array<object>} storms Renderer anchors with `forecasts`.
* @param {?string} selectedId
* @returns {Array<object>}
*/
export function cycloneOverlayEntries(storms, selectedId) {
const entries = [];
for (const storm of storms) {
const selected = storm.id === selectedId;
entries.push(cycloneStormEntry(storm, selected));
if (!selected) continue;
for (const point of storm.forecasts || [])
entries.push(cycloneLeadEntry(storm.id, point));
}
return entries;
}
/** Storm id behind a published card or lead-hour label id, else null. */
export function cycloneStormIdFromEntryId(entryId) {
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
return match ? match[1] : null;
}
/**
* Publish the renderer's committed anchors and selection to the overlay host.
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
*/
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
let storms = [];
let selectedId = null;
let published = false;
function publish() {
const entries = cycloneOverlayEntries(storms, selectedId);
let ambient = 0;
for (const entry of entries) if (!entry.protected) ambient++;
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
moving: false,
});
published = true;
}
return {
/** Replace every anchor after a snapshot commits. */
setSnapshot(nextStorms, nextSelectedId = selectedId) {
storms = nextStorms;
selectedId = nextSelectedId;
publish();
},
/** Republish only when the selection changes a published snapshot. */
setSelection(id) {
if (id === selectedId) return;
selectedId = id;
if (published) publish();
},
clear() {
storms = [];
selectedId = null;
if (!published) return;
published = false;
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
},
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
CYCLONE_OVERLAY_COHORT_LIMIT,
CYCLONE_OVERLAY_SOURCE_ID,
createCycloneLabels,
cycloneOverlayEntries,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
WORLD_OVERLAY_PAINT_LANES,
} from '../../overlays/worldOverlay.js';
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
const anchor = (longitude, latitude) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
const storms = () => [
{
id: 'al062026',
name: 'Fay',
classification: 'TS',
windKt: 50,
position: anchor(-60, 20),
forecasts: [
{ tauHours: 12, position: anchor(-61, 21) },
{ tauHours: 24, position: anchor(-62, 22) },
],
},
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
windKt: null,
position: anchor(-125, 15),
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
},
];
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
const input = storms();
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
assert.deepEqual(
[...entries.keys()],
[
'storm:al062026',
'lead:al062026:12',
'lead:al062026:24',
'storm:ep152026',
],
'lead-hour labels belong to the selected storm only',
);
const selected = entries.get('storm:al062026');
const ambient = entries.get('storm:ep152026');
for (const card of [selected, ambient]) {
assert.equal(card.cardStyle, 'tactical');
assert.equal(card.collisionGroup, 'ambient-card');
assert.equal(card.verticalOnly, true);
assert.equal(card.horizonCull, true);
assert.equal(card.edgeFade, 'keyhole');
assert.equal(card.interactive, true);
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
}
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
assert.equal(selected.title, 'Fay');
assert.deepEqual(selected.details, ['TS']);
assert.equal(selected.variant, 'selected');
assert.equal(selected.selected, true);
assert.equal(selected.protected, true);
assert.equal(selected.accent, '#ffe19a');
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
assert.equal(selected.gapPx, 20);
assert.equal(selected.leaderOffsetPx, 14);
assert.equal(ambient.title, 'Fifteen-E');
assert.deepEqual(ambient.details, ['PTC']);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.accent, '#7fe6ed');
assert.equal(ambient.gapPx, 17);
assert.equal(ambient.leaderOffsetPx, 11);
const lead = entries.get('lead:al062026:24');
assert.equal(lead.position, input[0].forecasts[1].position);
assert.equal(lead.title, '24 h');
assert.deepEqual(lead.details, []);
assert.equal(lead.cardStyle, 'tactical');
assert.equal(lead.collisionGroup, 'ambient-card');
assert.equal(lead.protected, false);
assert.equal(lead.interactive, true);
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
assert.equal(lead.gapPx, 13);
assert.equal(lead.leaderOffsetPx, 7);
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
const none = cycloneOverlayEntries(input, null);
assert.deepEqual(
none.map((entry) => [entry.id, entry.variant]),
[
['storm:al062026', 'card'],
['storm:ep152026', 'card'],
],
);
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
});
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
);
const lane = (entry) =>
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
const [selected, lead] = entries;
assert.equal(lane(selected), 'selected');
assert.equal(lane(lead), 'ambient-card');
assert.equal(lane(entries.at(-1)), 'ambient-card');
for (const entry of entries) {
assert.equal(entry.collisionGroup, 'ambient-card');
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.horizonCull, true);
}
const range = {
minDistance: lead.minDistance,
maxDistance: lead.maxDistance,
fadeStartRatio: lead.distanceFadeStartRatio,
};
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
assert.equal(
distanceFade(40_000_000, {
minDistance: selected.minDistance,
maxDistance: selected.maxDistance,
fadeStartRatio: selected.distanceFadeStartRatio,
}),
1,
'storm cards have no distance limit',
);
});
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
const calls = [];
const host = {
setEntries: (...args) => calls.push(['entries', ...args]),
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const labels = createCycloneLabels({ host });
labels.setSelection('al062026');
labels.clear();
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
labels.setSnapshot(storms(), 'al062026');
assert.deepEqual(
calls.map(([kind, source]) => [kind, source]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID],
['entries', CYCLONE_OVERLAY_SOURCE_ID],
],
);
assert.equal(calls[0][2], true);
const [, , entries, options] = calls[1];
assert.equal(entries.length, 4);
assert.deepEqual(options, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: 3,
moving: false,
});
calls.length = 0;
labels.setSelection('al062026');
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
labels.setSelection('ep152026');
const published = calls.find(([kind]) => kind === 'entries')[2];
assert.deepEqual(
published.map((entry) => entry.id),
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
);
assert.equal(published[1].variant, 'selected');
calls.length = 0;
labels.clear();
assert.deepEqual(calls, [
['clear', CYCLONE_OVERLAY_SOURCE_ID],
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
]);
labels.clear();
labels.setSelection('al062026');
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
});
test('storm identity resolves from card and lead-hour ids only', () => {
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
});
+314
View File
@@ -0,0 +1,314 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
return (
storm.geometryStatus === 'current' &&
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
+669
View File
@@ -0,0 +1,669 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCycloneRendering } from './rendering.js';
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
/** Records what the renderer publishes to the shared overlay host. */
function overlayRecorder() {
const calls = [];
return {
calls,
host: {
setEntries: (source, entries, options) =>
calls.push({ kind: 'entries', source, entries, options }),
setVisible: (source, visible) =>
calls.push({ kind: 'visible', source, visible }),
clearSource: (source) => calls.push({ kind: 'clear', source }),
},
get publishes() {
return calls.filter(({ kind }) => kind === 'entries').length;
},
published() {
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
},
entry(id) {
return this.published().find((entry) => entry.id === id) || null;
},
};
}
function harness({ deferred = false } = {}) {
const sources = [],
completions = [],
pointOccluders = [],
sphereOccluders = [];
const listeners = new Set();
const visibility = {
points: new Map(),
spheres: new Map(),
pointCalls: [],
sphereCalls: [],
writes: 0,
};
const color = (value) => ({
value,
withAlpha: (alpha) => ({ value, alpha }),
});
const cesium = {
Color: {
fromCssColorString: color,
WHITE: color('white'),
BLACK: color('black'),
},
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Cartesian3: {
ZERO: { x: 0, y: 0, z: 0 },
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
},
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
EllipsoidalOccluder: class {
constructor(ellipsoid, cameraPosition) {
this.ellipsoid = ellipsoid;
this.cameraPosition = cameraPosition;
pointOccluders.push(this);
}
isPointVisible(position) {
visibility.pointCalls.push(position);
return visibility.points.get(position) ?? true;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
this.sphere = sphere;
this.cameraPosition = cameraPosition;
sphereOccluders.push(this);
}
isBoundingSphereVisible(sphere) {
visibility.sphereCalls.push(sphere);
return visibility.spheres.get(sphere) ?? true;
}
},
PolygonHierarchy: class {
constructor(positions, holes = []) {
this.positions = positions;
this.holes = holes;
}
},
BoundingSphere: class {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
static fromPoints(points) {
return { points, radius: 10 };
}
},
LabelStyle: { FILL_AND_OUTLINE: 1 },
HorizontalOrigin: { LEFT: 1 },
ArcType: { GEODESIC: 1 },
HeightReference: Cesium.HeightReference,
ClassificationType: Cesium.ClassificationType,
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
CustomDataSource: class {
constructor() {
const values = [];
this.entities = {
values,
add: (value) => {
let show = true;
Object.defineProperty(value, 'show', {
get: () => show,
set: (next) => {
show = next;
visibility.writes++;
},
});
values.push(value);
return value;
},
removeAll: () => {
values.length = 0;
},
};
}
},
};
let renders = 0;
const overlay = overlayRecorder();
const viewer = {
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
scene: {
requestRender: () => renders++,
preRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
dataSources: {
add(value) {
if (deferred)
return new Promise((resolve) =>
completions.push(() => {
sources.push(value);
resolve(value);
}),
);
sources.push(value);
return Promise.resolve(value);
},
remove(value) {
const i = sources.indexOf(value);
if (i >= 0) sources.splice(i, 1);
},
},
};
return {
rendering: createCycloneRendering({
viewer,
cesium,
overlayHost: overlay.host,
}),
overlay,
sources,
completions,
viewer,
cesium,
visibility,
pointOccluders,
sphereOccluders,
listeners,
frame() {
for (const listener of listeners) listener();
},
get renders() {
return renders;
},
};
}
const storm = () => ({
id: 'ep152026',
name: 'Fifteen-E',
advisoryNumber: '10',
geometryAdvisoryNumber: '10',
geometryStatus: 'current',
position: { longitude: 179, latitude: 15 },
forecastPoints: [
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
cone: {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
},
});
test('horizon culling updates only changed entities and keeps selection independent', async () => {
const h = harness();
await h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near' }],
});
const entities = h.sources[0].entities.values;
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
const sphere = h.rendering.getFocusSphere('ep152026');
for (const entity of far)
if (entity.position) h.visibility.points.set(entity.position, false);
h.visibility.spheres.set(sphere, false);
h.rendering.setSelection('ep152026');
const publishes = h.overlay.publishes;
const before = h.renders;
h.frame();
assert.ok(far.every((e) => e.show === false));
assert.ok(near.every((e) => e.show === true));
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
forecast.position,
'the host culls labels at the same anchors the points use',
);
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
assert.equal(h.visibility.pointCalls.length, 4);
assert.deepEqual(h.visibility.sphereCalls, [
sphere,
h.rendering.getFocusSphere('near'),
]);
h.frame();
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
// A visible portion of the extent keeps all tracks/cones shown even when
// the centre and an individual forecast point remain beyond the horizon.
h.visibility.spheres.set(sphere, true);
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
h.frame();
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
assert.equal(h.renders, before + 2);
assert.equal(h.pointOccluders.length, 1);
assert.equal(h.sphereOccluders.length, 1);
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
h.rendering.setSelection('near');
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(
h.overlay.entry('lead:near:12').position,
near.find((e) => e.id.endsWith('forecast:0')).position,
);
h.visibility.points.set(forecast.position, true);
const beforeReveal = h.renders;
const revealPublishes = h.overlay.publishes;
h.frame();
assert.equal(forecast.show, true);
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(h.overlay.publishes, revealPublishes);
assert.equal(far[0].show, false);
assert.equal(h.renders, beforeReveal + 1);
h.rendering.destroy();
});
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
const h = harness();
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
const old = [...h.sources[0].entities.values];
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.frame();
assert.ok(
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
assert.equal(h.listeners.size, 0);
const before = h.renders;
h.frame();
assert.equal(h.renders, before);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.rendering.destroy();
h.rendering.destroy();
assert.equal(h.listeners.size, 0);
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
assert.equal(h.listeners.size, 0);
});
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
for (const method of ['clear', 'destroy']) {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 0);
h.rendering[method]();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.listeners.size, 0);
}
});
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
for (const globeShow of [true, false]) {
const sources = [];
const viewer = {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
scene: {
globe: { show: globeShow },
preRender: new Cesium.Event(),
requestRender() {},
},
dataSources: {
async add(source) {
sources.push(source);
},
remove() {},
},
};
const overlay = overlayRecorder();
const rendering = createCycloneRendering({
viewer,
cesium: Cesium,
overlayHost: overlay.host,
});
const at = (id, longitude) => ({
...storm(),
id,
position: { longitude, latitude: 0 },
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
track: {
type: 'LineString',
coordinates: [
[longitude, 0],
[longitude, 1],
],
},
cone: {
type: 'Polygon',
coordinates: [
[
[longitude, 0],
[longitude + 1, 0],
[longitude, 1],
[longitude, 0],
],
],
},
});
await rendering.setSnapshot({
storms: [at('near', 0), at('far', 180), at('limb', 5)],
});
rendering.setSelection('near');
viewer.scene.preRender.raiseEvent();
const entities = sources[0].entities.values;
for (const entity of entities.filter((e) => e.position)) {
assert.equal(
entity.point.heightReference.getValue(),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
assert.equal(entity.label, undefined, 'no Cesium label graphics');
const published = overlay.entry(
entity.id.includes(':forecast:')
? `lead:${entity.id.split(':')[1]}:24`
: `storm:${entity.id.split(':')[1]}`,
);
if (entity.id.startsWith('cyclone:near:')) {
assert.ok(
Cesium.Cartesian3.equals(
published.position,
entity.position.getValue(),
),
);
assert.equal(published.horizonCull, true);
}
if (entity.id === 'cyclone:near:forecast:0')
assert.equal(published.maxDistance, 4_000_000);
}
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:near:'))
.every((e) => e.show),
);
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:far:'))
.every((e) => !e.show),
);
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
rendering.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
}
});
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const oldEntities = [...h.sources[0].entities.values];
assert.equal(oldEntities.length, 8);
for (const entity of oldEntities) {
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
assert.equal(h.rendering.ownsPickId(entity.id), true);
assert.equal(
h.rendering.pickStorm({ id: { ...entity } }),
null,
'copied ID is not ownership',
);
assert.equal(
h.rendering.pickStorm({ id: entity.id }),
null,
'string prefix is not ownership',
);
}
assert.equal(h.rendering.pickStorm(undefined), null);
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
await h.rendering.setSnapshot({ storms: [storm()] });
for (const entity of oldEntities)
assert.equal(h.rendering.pickStorm({ id: entity }), null);
const current = h.sources[0].entities.values[0];
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
h.rendering.clear();
assert.equal(h.rendering.pickStorm({ id: current }), null);
assert.equal(h.rendering.ownsPickId(current.id), false);
h.rendering.destroy();
});
test('registry IDs switch only when the next coherent data source commits', async () => {
const h = harness({ deferred: true });
const first = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
h.completions.shift()();
await first;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
const next = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'ep162026' }],
});
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
h.completions.shift()();
await next;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
h.rendering.destroy();
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
});
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const entities = h.sources[0].entities.values;
const cones = entities.filter((e) => e.polygon);
assert.equal(cones.length, 2);
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
for (const cone of cones) {
assert.equal(
cone.polygon.classificationType,
h.cesium.ClassificationType.BOTH,
);
assert.equal(cone.polygon.height, undefined);
assert.equal(cone.polygon.extrudedHeight, undefined);
assert.equal(cone.polygon.outline, undefined);
}
const outlines = entities.filter((e) => e.id.includes(':outline:'));
assert.equal(outlines.length, 3);
assert.deepEqual(
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
storm().cone.coordinates.flat(),
);
for (const outline of outlines) {
assert.deepEqual(outline.polyline.material, {
value: '#7fe6ed',
alpha: 0.55,
});
}
for (const { polyline } of entities.filter((e) => e.polyline)) {
assert.equal(polyline.clampToGround, true);
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
assert.ok(polyline.positions.every((p) => p.height === 0));
}
assert.equal(
entities.find((e) => e.polyline).polyline.positions[1].lon,
-179,
);
assert.deepEqual(h.rendering.getDiagnostics(), {
storms: 1,
tracks: 1,
cones: 2,
forecastPoints: 1,
dataSources: 1,
entities: 8,
selectedId: null,
timerActive: false,
});
h.rendering.setSelection('ep152026');
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
entities.find((e) => e.id.endsWith('forecast:0')).position,
);
assert.ok(entities.every((e) => e.label === undefined));
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
h.rendering.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().entities, 0);
});
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
for (const changed of [
{ geometryStatus: 'pending' },
{ geometryAdvisoryNumber: '9' },
]) {
const h = harness();
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.rendering.getDiagnostics().tracks, 0);
h.rendering.destroy();
}
});
test('a data-source add settling after disable is removed, without a new owner', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
});
test('superseded asynchronous additions cannot replace newer geometry', async () => {
const h = harness({ deferred: true });
const old = h.rendering.setSnapshot({ storms: [storm()] });
const latest = h.rendering.setSnapshot({ storms: [] });
h.completions[1]();
assert.equal(await latest, true);
h.completions[0]();
assert.equal(await old, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 0);
h.rendering.destroy();
});
test('aborting a pending add retains the prior complete source', async () => {
const h = harness({ deferred: true });
const initial = h.rendering.setSnapshot({ storms: [storm()] });
h.completions.shift()();
await initial;
const controller = new AbortController();
const update = h.rendering.setSnapshot(
{ storms: [] },
{ signal: controller.signal },
);
controller.abort();
h.completions.shift()();
assert.equal(await update, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 1);
h.rendering.destroy();
});
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
});
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
h.completions.shift()();
assert.equal(await pending, true);
const entities = h.sources[0].entities.values;
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
assert.deepEqual(
h.overlay.calls
.slice(0, 2)
.map(({ kind, source, visible }) => [kind, source, visible]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
],
);
assert.deepEqual(
h.overlay.published().map((entry) => entry.id),
['storm:ep152026', 'storm:near'],
'no selection, no lead-hour labels',
);
assert.equal(h.overlay.entry('storm:near').title, 'Near');
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
h.rendering.setSelection('near');
assert.equal(center('near').point.pixelSize, 12);
assert.equal(center('ep152026').point.pixelSize, 9);
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
assert.ok(h.overlay.entry('lead:near:12'));
const publishes = h.overlay.publishes;
h.rendering.setSelection('near');
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
// A refresh republishes the new anchors with the retained selection.
const refresh = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'near', name: 'Near' }],
});
h.completions.shift()();
await refresh;
assert.deepEqual(
h.overlay.published().map((entry) => [entry.id, entry.variant]),
[
['storm:near', 'selected'],
['lead:near:12', 'card'],
],
);
assert.equal(
h.overlay.entry('storm:near').position,
h.sources[0].entities.values[0].position,
);
// A superseded addition never publishes, and clear hides the source.
const stale = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
const afterClear = h.overlay.calls.slice(-2);
assert.deepEqual(afterClear, [
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
]);
h.completions.shift()();
assert.equal(await stale, false);
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
h.rendering.destroy();
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
});
+245
View File
@@ -0,0 +1,245 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw malformed();
return value;
};
const number = (value, low, high) => {
if (value === null) return null;
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
return value;
};
const time = (value) => {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw malformed();
return value;
};
const advisory = (value) => {
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
throw malformed();
return value;
};
const position = (value) => {
if (!value || value.longitude === null || value.latitude === null)
throw malformed();
return {
longitude: number(value.longitude, -180, 180),
latitude: number(value.latitude, -90, 90),
};
};
function officialLink(value, outlook = false) {
if (value === null) return null;
const url = new URL(text(value, 256));
if (
url.origin !== 'https://www.nhc.noaa.gov' ||
url.username ||
url.password ||
url.hash
)
throw malformed();
if (
outlook
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
url.pathname + url.search,
)
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
)
throw malformed();
return url.href;
}
function geometry(value, kind, budget) {
if (value === null) return null;
const allowed =
kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!allowed.includes(value?.type)) throw malformed();
const point = (pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
pair.some((v) => typeof v !== 'number')
)
throw malformed();
position({ longitude: pair[0], latitude: pair[1] });
if (++budget.coordinates > 25_000) throw malformed();
return [...pair];
};
const list = (items, read, min = 1, max = 128) => {
if (!Array.isArray(items) || items.length < min || items.length > max)
throw malformed();
return items.map(read);
};
const line = (pairs) => list(pairs, point, 2, 10_000);
const ring = (pairs) => {
const result = list(pairs, point, 4, 10_000);
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
throw malformed();
return result;
};
const polygon = (rings) => list(rings, ring);
const coordinates =
value.type === 'LineString'
? line(value.coordinates)
: value.type === 'MultiLineString'
? list(value.coordinates, line)
: value.type === 'Polygon'
? polygon(value.coordinates)
: list(value.coordinates, polygon);
return { type: value.type, coordinates };
}
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
export function validateCycloneSnapshot(value) {
if (
!value ||
value.schemaVersion !== 1 ||
typeof value.stale !== 'boolean' ||
typeof value.unavailable !== 'boolean' ||
!Array.isArray(value.storms) ||
value.storms.length > 32
)
throw malformed();
const seen = new Set(),
budget = { coordinates: 0, points: 0 };
const storms = value.storms.map((raw) => {
if (
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
seen.has(raw.id) ||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
)
throw malformed();
seen.add(raw.id);
const advisoryNumber = advisory(raw.advisoryNumber);
const geometryAdvisoryNumber =
raw.geometryAdvisoryNumber === null
? null
: advisory(raw.geometryAdvisoryNumber);
if (
raw.geometryStatus === 'current' &&
geometryAdvisoryNumber !== advisoryNumber
)
throw malformed();
if (
!Array.isArray(raw.forecastPoints) ||
(budget.points += raw.forecastPoints.length) > 500
)
throw malformed();
const taus = new Set();
const forecastPoints = raw.forecastPoints.map((point) => {
const tauHours = number(point.tauHours, 0, 168);
if (tauHours === null || taus.has(tauHours)) throw malformed();
taus.add(tauHours);
if (++budget.coordinates > 25_000) throw malformed();
return {
position: position(point.position),
tauHours,
windKt: number(point.windKt, 0, 300),
gustKt: number(point.gustKt, 0, 350),
};
});
const track = geometry(raw.track, 'track', budget),
cone = geometry(raw.cone, 'cone', budget);
if (
raw.geometryStatus !== 'current' &&
(track || cone || forecastPoints.length)
)
throw malformed();
if (
raw.geometryStatus === 'current' &&
(!track || !cone || !forecastPoints.length)
)
throw malformed();
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
return {
id: raw.id,
name: text(raw.name, 80),
classification: text(raw.classification, 16),
basin: raw.basin,
position: position(raw.position),
positionAt: time(raw.positionAt),
advisoryNumber,
issuedAt: time(raw.issuedAt),
windKt: number(raw.windKt, 0, 300),
pressureHpa: number(raw.pressureHpa, 800, 1100),
movement: {
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
speedKt: number(raw.movement?.speedKt, 0, 200),
},
advisoryUrl: officialLink(raw.advisoryUrl),
outlookUrl: officialLink(raw.outlookUrl, true),
geometryStatus: raw.geometryStatus,
geometryAdvisoryNumber,
forecastPoints,
track,
cone,
};
});
if (value.unavailable && storms.length) throw malformed();
return {
schemaVersion: 1,
source: text(value.source),
attribution: text(value.attribution, 240),
coverage: text(value.coverage, 240),
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
stale: value.stale,
unavailable: value.unavailable,
reason: value.reason === null ? null : text(value.reason, 240),
storms,
};
}
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
export function createCycloneSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ signal } = {}) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl('/api/cyclones', {
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Cyclone HTTP ${response.status}`);
}
const result = await readResponseJsonCapped(
response,
CYCLONE_RESPONSE_LIMIT,
controller.signal,
);
controller.signal.throwIfAborted();
return validateCycloneSnapshot(result);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCycloneSource,
validateCycloneSnapshot,
CYCLONE_RESPONSE_LIMIT,
} from './source.js';
const issuedAt = '2026-09-16T03:00:00.000Z';
function fixture() {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution: 'NOAA/NWS',
coverage: 'Atlantic and eastern/central North Pacific',
fetchedAt: Date.parse(issuedAt),
stale: false,
unavailable: false,
reason: null,
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: issuedAt,
issuedAt,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
movement: { directionDegrees: null, speedKt: null },
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
},
],
};
}
function current() {
const value = fixture(),
storm = value.storms[0];
storm.geometryStatus = 'current';
storm.geometryAdvisoryNumber = '10';
storm.forecastPoints = [
{
position: { longitude: 179, latitude: 15 },
tauHours: 12,
windKt: 30,
gustKt: 40,
},
];
storm.track = {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
[
[170, 14],
[179, 15],
],
],
};
storm.cone = {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
};
return value;
}
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
const value = current();
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
value.storms[0].cone.properties = {
html: '<script>',
href: 'https://example.invalid',
};
const result = validateCycloneSnapshot(value);
assert.deepEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
assert.equal(result.storms[0].cone.properties, undefined);
assert.notEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
});
test('successful empty coverage is distinct from unavailable', () => {
const value = fixture();
value.storms = [];
assert.equal(validateCycloneSnapshot(value).unavailable, false);
assert.equal(
validateCycloneSnapshot({
...value,
unavailable: true,
stale: true,
fetchedAt: null,
reason: 'Unavailable',
}).unavailable,
true,
);
assert.throws(
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
/Malformed/,
);
});
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
const changes = [
(storm) => {
storm.geometryAdvisoryNumber = '9';
},
(storm) => {
storm.geometryStatus = 'pending';
},
(storm) => {
storm.position.longitude = 181;
},
(storm) => {
storm.track.coordinates[0][0][0] = NaN;
},
(storm) => {
storm.cone.coordinates[0][0].pop();
},
(storm) => {
storm.forecastPoints.push(storm.forecastPoints[0]);
},
(storm) => {
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
},
(storm) => {
storm.issuedAt = '2026-02-31T00:00:00.000Z';
},
(storm) => {
storm.windKt = 9999;
},
];
for (const change of changes) {
const value = current();
change(value.storms[0]);
assert.throws(() => validateCycloneSnapshot(value));
}
const oversized = current();
oversized.storms[0].track = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () =>
Array.from({ length: 9000 }, () => [1, 2]),
),
};
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
assert.throws(
() =>
validateCycloneSnapshot({
...fixture(),
storms: Array(33).fill(fixture().storms[0]),
}),
/Malformed/,
);
});
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
const calls = [];
const source = createCycloneSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json(fixture());
},
});
assert.equal(calls.length, 0);
await source.getSnapshot();
assert.equal(calls[0].url, '/api/cyclones');
assert.equal(calls[0].options.redirect, 'error');
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls.length, 1);
const oversized = createCycloneSource({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
}),
});
await assert.rejects(oversized.getSnapshot(), /too large/);
});
test('source deadline cancels request and removes timer on completion', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const source = createCycloneSource({
timeoutMs: 10,
fetchImpl: async (_url, options) => {
signal = options.signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot();
const rejected = assert.rejects(pending, /timed out/);
t.mock.timers.tick(11);
await rejected;
assert.equal(signal.aborted, true);
});
+399 -97
View File
@@ -17,10 +17,10 @@
*/
import * as Cesium from 'cesium';
import {
formatRouteDistance,
formatRouteDuration,
} from '../../data/routeSteps.js';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -31,12 +31,42 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
/** Travel modes, keyed by the `/api/route` profile name. */
export const DIRECTIONS_MODES = Object.freeze({
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
});
export const DEFAULT_DIRECTIONS_MODE = 'car';
/**
* The route proxy's refusal texts (server/providers/places/routes.js). They
* stay English on the wire and in the AI tools; the row shows them translated.
*/
const ROUTING_REFUSALS = new Set([
msg('rate limited'),
msg('routing service is rate limited'),
msg('route leg too long'),
msg('route too long'),
]);
/** A refusal text from the route proxy in the language; unknown ones as given. */
export function presentRoutingRefusal(text) {
return ROUTING_REFUSALS.has(text) ? t(text) : text;
}
/** The row text for a failed route request (createDirectionsSource errors). */
export function presentRoutingFailure(error) {
if (error?.status === 429) {
return error.refusal
? t('{error} — try again in a moment', {
error: presentRoutingRefusal(error.refusal),
})
: t('Routing is rate limited — try again in a moment');
}
if (Number.isFinite(error?.status) && error.status > 0)
return t('Routing unavailable (HTTP {status})', { status: error.status });
return error?.message || t('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f');
@@ -68,6 +98,269 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
/** How often the row repaints the highlighted step while FLY is running (ms). */
export const FLIGHT_PROGRESS_MS = 250;
// ---------------------------------------------------------------------------
// Display text in the page's language. The proxy's English `instruction` stays
// the machine value; steps are phrased from their OSRM type/modifier/road.
// ---------------------------------------------------------------------------
/**
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
* in src/data/routeSteps.js); number and unit never break apart.
* @param {number} meters
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
}
/**
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
* @param {number} seconds
* @returns {string}
*/
export function routeDurationText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
const minutes = Math.round(seconds / 60);
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
function directionWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'direction' });
case 'right':
return t('right', { context: 'direction' });
case 'slight left':
return t('slightly left', { context: 'direction' });
case 'slight right':
return t('slightly right', { context: 'direction' });
case 'sharp left':
return t('sharply left', { context: 'direction' });
case 'sharp right':
return t('sharply right', { context: 'direction' });
case 'straight':
return t('straight', { context: 'direction' });
case 'uturn':
return t('around', { context: 'direction' });
default:
return modifier;
}
}
/**
* The side to keep at a fork. English reuses the turn words; a language may
* need another form here ("держитесь левее", not "держитесь налево").
*/
function forkWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'fork direction' });
case 'right':
return t('right', { context: 'fork direction' });
case 'slight left':
return t('slightly left', { context: 'fork direction' });
case 'slight right':
return t('slightly right', { context: 'fork direction' });
case 'sharp left':
return t('sharply left', { context: 'fork direction' });
case 'sharp right':
return t('sharply right', { context: 'fork direction' });
case 'straight':
return t('straight', { context: 'fork direction' });
default:
return directionWord(modifier);
}
}
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
function laneWord(modifier) {
switch (modifier) {
case 'left':
return t('left', { context: 'lane direction' });
case 'right':
return t('right', { context: 'lane direction' });
case 'slight left':
return t('slightly left', { context: 'lane direction' });
case 'slight right':
return t('slightly right', { context: 'lane direction' });
case 'sharp left':
return t('sharply left', { context: 'lane direction' });
case 'sharp right':
return t('sharply right', { context: 'lane direction' });
case 'straight':
return t('straight', { context: 'lane direction' });
default:
return directionWord(modifier);
}
}
/** "1st", "2nd", … for a roundabout exit. */
function exitOrdinal(exit) {
const number = formatNumber(exit);
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
case 'one':
return t('{number}st', { context: 'ordinal', number });
case 'two':
return t('{number}nd', { context: 'ordinal', number });
case 'few':
return t('{number}rd', { context: 'ordinal', number });
default:
return t('{number}th', { context: 'ordinal', number });
}
}
/**
* One maneuver in the page's language, from the step's OSRM type, modifier,
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
* word for word. A step without a type (an older payload) keeps the proxy's
* English instruction.
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
* @returns {string}
*/
export function directionsInstruction(step) {
const type =
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
if (!type)
return typeof step?.instruction === 'string' ? step.instruction : '';
const modifier =
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
const road = roadLabel(step);
const direction = directionWord(modifier);
const values = { road, direction };
switch (type) {
case 'depart':
return road ? t('Head out on {road}', values) : t('Head out');
case 'arrive':
if (modifier === 'left')
return t('Arrive at your destination, on the left');
if (modifier === 'right')
return t('Arrive at your destination, on the right');
return t('Arrive at your destination');
case 'turn':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (modifier === 'straight')
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
if (direction)
return road
? t('Turn {direction} onto {road}', values)
: t('Turn {direction}', values);
return road
? t('Turn onto {road}', values)
: t('Turn', { context: 'maneuver' });
case 'continue':
if (modifier === 'uturn')
return road
? t('Make a U-turn onto {road}', values)
: t('Make a U-turn');
if (direction && modifier !== 'straight')
return road
? t('Continue {direction} onto {road}', values)
: t('Continue {direction}', values);
return road
? t('Continue straight onto {road}', values)
: t('Continue straight');
case 'end of road':
if (direction)
return road
? t('At the end of the road, turn {direction} onto {road}', values)
: t('At the end of the road, turn {direction}', values);
return road
? t('At the end of the road, continue onto {road}', values)
: t('At the end of the road, continue');
case 'fork':
if (direction) {
const fork = { road, direction: forkWord(modifier) };
return road
? t('Keep {direction} at the fork onto {road}', fork)
: t('Keep {direction} at the fork', fork);
}
return road
? t('Continue at the fork onto {road}', values)
: t('Continue at the fork');
case 'merge':
if (direction)
return road
? t('Merge {direction} onto {road}', values)
: t('Merge {direction}', values);
return road
? t('Merge onto {road}', values)
: t('Merge', { context: 'maneuver' });
case 'on ramp':
if (direction)
return road
? t('Take the ramp on the {direction} onto {road}', values)
: t('Take the ramp on the {direction}', values);
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
case 'off ramp':
if (direction)
return road
? t('Take the exit on the {direction} onto {road}', values)
: t('Take the exit on the {direction}', values);
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
case 'roundabout':
case 'rotary':
if (Number.isInteger(step.exit) && step.exit >= 1) {
const exit = exitOrdinal(step.exit);
return road
? t('At the roundabout, take the {exit} exit onto {road}', {
exit,
road,
})
: t('At the roundabout, take the {exit} exit', { exit });
}
return road
? t('Enter the roundabout onto {road}', values)
: t('Enter the roundabout');
case 'roundabout turn':
if (direction)
return road
? t('At the roundabout, turn {direction} onto {road}', values)
: t('At the roundabout, turn {direction}', values);
return road
? t('At the roundabout, continue onto {road}', values)
: t('At the roundabout, continue');
case 'exit roundabout':
case 'exit rotary':
return road
? t('Exit the roundabout onto {road}', values)
: t('Exit the roundabout');
case 'use lane':
if (direction) {
const lane = { road, direction: laneWord(modifier) };
return road
? t('Use the {direction} lane onto {road}', lane)
: t('Use the {direction} lane', lane);
}
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
default:
return road
? t('Continue onto {road}', values)
: t('Continue', { context: 'maneuver' });
}
}
// ---------------------------------------------------------------------------
// Pure helpers (exported for tests)
// ---------------------------------------------------------------------------
@@ -107,32 +400,35 @@ export function directionsRowControls(state) {
const routing = status === 'routing';
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
id: `mode-${id}`,
label: spec.chip,
label: t(spec.chip),
active: mode === id,
state: mode === id ? 'active' : 'idle',
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
title: t('{mode} — reroute for {modeLower}', {
mode: t(spec.word),
modeLower: t(spec.word).toLowerCase(),
}),
params: { mode: id },
}));
chips.push({
id: 'set-a',
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
active: armed === 'a',
state: armed === 'a' ? 'active' : 'idle',
title:
armed === 'a'
? 'Click a spot on the globe to place A (click again to cancel)'
: 'Then click the globe to place the start',
? t('Click a spot on the globe to place A (click again to cancel)')
: t('Then click the globe to place the start'),
params: { arm: armed === 'a' ? null : 'a' },
});
chips.push({
id: 'set-b',
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
active: armed === 'b',
state: armed === 'b' ? 'active' : 'idle',
title:
armed === 'b'
? 'Click a spot on the globe to place B (click again to cancel)'
: 'Then click the globe to place the destination',
? t('Click a spot on the globe to place B (click again to cancel)')
: t('Then click the globe to place the destination'),
params: { arm: armed === 'b' ? null : 'b' },
});
chips.push({
@@ -140,26 +436,28 @@ export function directionsRowControls(state) {
label: '⇄',
disabled: !(a && b) || routing,
state: 'idle',
title: 'Swap A and B',
title: t('Swap A and B'),
params: { swap: true },
});
const flying = Number.isInteger(flightStep);
chips.push({
id: 'fly',
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
disabled: !route || routing,
busy: routing || flying,
active: flying,
state: routing || flying ? 'loading' : 'idle',
title: route ? 'Fly the camera along the route' : 'Place A and B first',
title: route
? t('Fly the camera along the route')
: t('Place A and B first'),
params: { fly: true },
});
chips.push({
id: 'clear',
label: 'CLEAR',
label: t('CLEAR', { context: 'action' }),
disabled: !a && !b && !route,
state: 'idle',
title: 'Remove the route and both markers',
title: t('Remove the route and both markers'),
params: { clear: true },
});
return {
@@ -179,8 +477,8 @@ export function directionsRowControls(state) {
*/
export const POINTER_TOOL_EXITS = Object.freeze({
draw: Object.freeze({
name: 'Draw',
leave: 'press Escape twice to leave Draw',
name: msg('Draw'),
leave: msg('press Escape twice to leave Draw'),
}),
});
@@ -201,9 +499,16 @@ export function pointerBlockedMessage(owner, which) {
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
const endpoint = which === 'b' ? 'B' : 'A';
const known = POINTER_TOOL_EXITS[id];
const name = known?.name || id;
const leave = known?.leave || `turn ${name} off`;
return `${name} is active — ${leave}, then set ${endpoint}`;
if (!known)
return t('{name} is active — turn {name} off, then set {endpoint}', {
name: translateKnown(pointerOwnerName(id)),
endpoint,
});
return t('{name} is active — {leave}, then set {endpoint}', {
name: t(known.name),
leave: t(known.leave),
endpoint,
});
}
/**
@@ -272,8 +577,8 @@ export function directionsStepList({
const items = steps.map((step, index) => ({
id: `step-${index}`,
ordinal: index + 1,
lead: formatRouteDistance(step.distanceM) || '—',
text: step.instruction,
lead: routeDistanceText(step.distanceM) || '—',
text: directionsInstruction(step),
active: index === active,
current: Number.isInteger(flightStep) && index === flightStep,
params: { step: index },
@@ -285,13 +590,15 @@ export function directionsStepList({
id: 'step-truncated',
ordinal: items.length + 1,
lead: '',
text: `Only the first ${steps.length} turns of this route are shown`,
text: t('Only the first {count} turns of this route are shown', {
count: steps.length,
}),
active: false,
current: false,
disabled: true,
});
}
return { ariaLabel: 'Turn-by-turn directions', items };
return { ariaLabel: t('Turn-by-turn directions'), items };
}
/**
@@ -331,12 +638,12 @@ export function directionsStats(state) {
armed,
pointerBlocked,
} = state;
const source = 'OSM routing';
const source = 'OSM routing'; // English in layer state; the layer row translates it
if (pointerBlocked) {
return {
count: route?.steps.length || 0,
lastUpdate,
error: 'Another map tool is using clicks — close it, then SET A again',
error: t('Another map tool is using clicks — close it, then SET A again'),
status: 'empty',
source,
};
@@ -347,7 +654,7 @@ export function directionsStats(state) {
lastUpdate,
error: null,
loading: true,
loadingLabel: 'Routing…',
loadingLabel: t('Routing…'),
source,
};
}
@@ -355,7 +662,7 @@ export function directionsStats(state) {
return {
count: 0,
lastUpdate,
error: error || 'No route found',
error: error || t('No route found'),
status: 'empty',
source,
};
@@ -364,10 +671,17 @@ export function directionsStats(state) {
// set (not only while loading), so the route summary and the placement
// guidance ride on it; `coverage` carries the same text for stats readers.
if (route) {
const word = DIRECTIONS_MODES[mode]?.word || mode;
const summary =
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
const spec = DIRECTIONS_MODES[mode];
const word = spec ? t(spec.word) : mode;
const values = {
distance: routeDistanceText(route.distanceM),
duration: routeDurationText(route.durationS),
mode: word,
count: route.steps.length,
};
const summary = route.stepsTruncated
? t('{distance} · {duration} · {mode} · first {count} turns', values)
: t('{distance} · {duration} · {mode}', values);
return {
count: route.steps.length,
lastUpdate,
@@ -378,10 +692,13 @@ export function directionsStats(state) {
};
}
let coverage;
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
else if (a && !b) coverage = 'SET B, then click the globe';
else if (!a && b) coverage = 'SET A, then click the globe';
else coverage = 'SET A, then click the globe';
if (armed)
coverage = t('Click the globe to place {endpoint}', {
endpoint: armed.toUpperCase(),
});
else if (a && !b) coverage = t('SET B, then click the globe');
else if (!a && b) coverage = t('SET A, then click the globe');
else coverage = t('SET A, then click the globe');
return {
count: 0,
lastUpdate,
@@ -403,14 +720,23 @@ export function directionsStepCopy(steps, index) {
const step = steps[index];
const details = [];
const leg = [];
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
const position = { step: index + 1, total: steps.length };
details.push(
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
leg.length
? t('Step {step} of {total} · then {leg}', {
...position,
leg: leg.join(' · '),
})
: t('Step {step} of {total}', position),
);
const next = steps[index + 1];
if (next) details.push(`Then: ${next.instruction}`);
return { title: step.instruction, details };
if (next)
details.push(
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
);
return { title: directionsInstruction(step), details };
}
/**
@@ -452,10 +778,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
* @param {{lat:number, lon:number}} b
* @returns {string}
*/
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
export { directionsRequestUrl } from './source.js';
/**
* Validate a proxy payload into the route record the layer keeps, or null.
@@ -509,10 +832,12 @@ export function normalizeRoutePayload(payload, mode) {
* route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter.
* @param {{services: object}} options
* @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers.
*/
export function createDirectionsLayer({ services }) {
export function createDirectionsLayer({ services, source }) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
/**
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
* tests swap it. Resolved on use, not at import, because the services arrive
@@ -618,32 +943,11 @@ export function createDirectionsLayer({ services }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
@@ -695,6 +999,7 @@ export function createDirectionsLayer({ services }) {
}
function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer, 'directions');
_clearStepSelection();
stopStepAnchoring();
removeEntity(_routeEntity);
@@ -800,6 +1105,7 @@ export function createDirectionsLayer({ services }) {
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
services.credits?.showOsmCredit?.(_viewer, 'directions');
services.ground.warmGroundFloor(cells);
_anchorAttempts = 0;
_anchorStartedAt = Date.now();
@@ -825,18 +1131,12 @@ export function createDirectionsLayer({ services }) {
_error = null;
notifyRow();
try {
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
const payload = await source.getRoute(
{ mode, a: _a, b: _b },
{
signal: controller.signal,
},
);
if (seq !== _routeSeq || !_enabled) return;
const route = normalizeRoutePayload(payload, mode);
if (!route) {
@@ -845,10 +1145,12 @@ export function createDirectionsLayer({ services }) {
_status = 'error';
_error =
payload?.error === 'no route found'
? 'No route found between A and B'
? t('No route found between A and B')
: payload?.error
? `Routing failed: ${payload.error}`
: 'No route found between A and B';
? t('Routing failed: {error}', {
error: presentRoutingRefusal(payload.error),
})
: t('No route found between A and B');
return;
}
_route = route;
@@ -865,10 +1167,10 @@ export function createDirectionsLayer({ services }) {
// happened instead.
_error =
error?.name === 'AbortError'
? 'Routing timed out'
? t('Routing timed out')
: error?.name === 'TypeError'
? 'Routing unavailable — no response from the routing service'
: error?.message || 'Routing unavailable';
? t('Routing unavailable — no response from the routing service')
: presentRoutingFailure(error);
} finally {
clearTimeout(timer);
if (_routeAbort === controller) _routeAbort = null;
@@ -1063,7 +1365,7 @@ export function createDirectionsLayer({ services }) {
[
{
type: 'route',
label: 'Directions',
label: t('Directions'),
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
},
],
+50
View File
@@ -0,0 +1,50 @@
// A source module (no browser globals, see check:boundaries), so no i18n
// import: failures stay English here and the layer translates them on show.
const msg = (text) => text;
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** Acquire route records using caller-owned transport and an optional endpoint. */
export function createDirectionsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/route',
} = {}) {
return {
async getRoute({ mode, a, b }, { signal } = {}) {
signal?.throwIfAborted();
const query = directionsRequestUrl(mode, a, b).split('?')[1];
const response = await fetchImpl(`${endpoint}?${query}`, {
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
signal?.throwIfAborted();
// Failures keep their English message and carry the HTTP status and the
// server's refusal text, so the layer can show them in the language.
if (response.status === 429) {
const refusal =
typeof payload?.error === 'string' && payload.error
? payload.error
: null;
throw Object.assign(
new Error(
refusal
? `${refusal} — try again in a moment`
: msg('Routing is rate limited — try again in a moment'),
),
{ status: 429, refusal },
);
}
if (!response.ok && !payload?.error)
throw Object.assign(
new Error(`Routing unavailable (HTTP ${response.status})`),
{ status: response.status },
);
return payload;
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDirectionsSource } from './source.js';
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
test('route acquisition uses only the supplied transport and endpoint', async () => {
const controller = new AbortController();
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
const calls = [];
const source = createDirectionsSource({
endpoint: 'https://routes.example/route',
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
});
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
assert.equal(calls.length, 1);
const [url, init] = calls[0];
assert.equal(new URL(url).origin, 'https://routes.example');
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
assert.equal(init.signal, controller.signal);
assert.equal(init.headers.Accept, 'application/json');
});
test('aborting during route parsing discards the result', async () => {
const controller = new AbortController();
const source = createDirectionsSource({ fetchImpl: async () => ({
ok: true, json: async () => { controller.abort(); return { ok: true }; },
}) });
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
});
test('route failures are surfaced without fallback requests', async () => {
for (const status of [429, 503]) {
let calls = 0;
const source = createDirectionsSource({ fetchImpl: async () => {
calls++;
return Response.json({}, { status });
} });
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
assert.equal(calls, 1);
}
});
+5 -1
View File
@@ -11,6 +11,10 @@ import {
export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js';
// A portable package (no i18n): the error stays English data and the layer
// panel shows it translated.
const msg = (text) => text;
/** Own one earthquake display and its refresh lifecycle. */
export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
@@ -154,7 +158,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:Earthquakes] Fetch error:', e);
_lastError = e?.message || 'Earthquake source unavailable';
_lastError = e?.message || msg('Earthquake source unavailable');
return false;
} finally {
if (_request === request) _request = null;
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeEarthquakeSnapshot } from './records.js';
test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => {
const geojson = {
type: 'FeatureCollection',
features: [
{
id: 'us7000m123',
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749, 10.5],
},
properties: {
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
},
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(Array.isArray(rows), true);
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
stableId: 'us7000m123',
usgsId: 'us7000m123',
lon: -122.4194,
lat: 37.7749,
depthKm: 10.5,
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
});
});
test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => {
const geojson = {
features: [
{
id: null,
geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] },
properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 },
},
{
id: '',
geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] },
properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'event-1');
assert.equal(rows[0].usgsId, null);
assert.equal(rows[1].stableId, 'event-2');
assert.equal(rows[1].usgsId, '');
});
test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => {
const geojson = {
features: [
{
id: 'ev-sub',
geometry: { type: 'Point', coordinates: [10, 20, 5] },
properties: { mag: 2.49, place: 'Minor tremor' },
},
{
id: 'ev-null-mag',
geometry: { type: 'Point', coordinates: [11, 21, 5] },
properties: { mag: null, place: 'Uncalculated' },
},
{
id: 'ev-threshold',
geometry: { type: 'Point', coordinates: [12, 22, 5] },
properties: { mag: 2.5, place: 'Threshold event' },
},
{
id: 'ev-strong',
geometry: { type: 'Point', coordinates: [13, 23, 5] },
properties: { mag: 6.8, place: 'Major event' },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'ev-threshold');
assert.equal(rows[0].mag, 2.5);
assert.equal(rows[1].stableId, 'ev-strong');
assert.equal(rows[1].mag, 6.8);
});
test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => {
const geojson = {
features: [
{
id: 'ev-sparse',
geometry: { coordinates: [-100, 40] }, // depth omitted
properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 1);
assert.equal(rows[0].depthKm, null);
assert.equal(rows[0].place, null);
assert.equal(rows[0].time, null);
});
test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => {
assert.equal(normalizeEarthquakeSnapshot(null), null);
assert.equal(normalizeEarthquakeSnapshot(undefined), null);
assert.equal(normalizeEarthquakeSnapshot({}), null);
assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null);
assert.equal(normalizeEarthquakeSnapshot({ features: null }), null);
});
test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => {
const baseFeature = {
id: 'test',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
};
// Non-Point geometry
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: {
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
},
},
],
}),
null,
);
// Missing or short coordinates
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{ ...baseFeature, geometry: { type: 'Point', coordinates: [0] } },
],
}),
null,
);
// Longitude out of range [-180, 180]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [180.1, 0, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [-180.1, 0, 0] },
},
],
}),
null,
);
// Latitude out of range [-90, 90]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 90.1, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, -90.1, 0] },
},
],
}),
null,
);
// Non-finite depth
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 0, NaN] },
},
],
}),
null,
);
// Magnitude > 10
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: { mag: 10.1 } }],
}),
null,
);
// Non-object or array properties
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: 'invalid' }],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: [] }],
}),
null,
);
});
test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => {
const geojson = {
features: [
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
},
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [1, 1, 1] },
properties: { mag: 4.0 },
},
],
};
assert.equal(normalizeEarthquakeSnapshot(geojson), null);
});
+84
View File
@@ -0,0 +1,84 @@
// The AIS degradation chip and the traffic "SIMULATED — <reason>" text read in
// the UI language; English keeps its wording.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, translateKnown } from '../i18n/index.js';
import { deriveAisFeedError } from '../data/aisLiveVessels.js';
import { createModel } from './traffic/model.js';
test('AIS degraded-feed reasons are localized in es', () => {
setLocale('es');
try {
const cases = [
[
{ status: 'down', reconnectAttempt: 3 },
'feed down — retrying slowly (attempt 3)',
],
[{ status: 'missing-key' }, 'AISSTREAM_API_KEY not set'],
[
{ status: 'stale', silentForMs: 90000 },
'feed silent 90s — no AIS data',
],
[{ status: 'auth-failed' }, 'API key rejected — check AISSTREAM_API_KEY'],
[{ status: 'connecting' }, 'connecting to feed…'],
[{ status: 'closed' }, 'feed disconnected'],
];
for (const [payload, english] of cases) {
// The layer panel runs stats.error through translateKnown(); emulate it.
const shown = translateKnown(deriveAisFeedError(payload, 0));
assert.notEqual(shown, english, `status ${payload.status} still English`);
}
} finally {
setLocale('en');
}
});
test('traffic "SIMULATED — <reason>" is localized in es', () => {
setLocale('es');
try {
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
const degraded = trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
});
const shown = translateKnown(degraded.error);
assert.notEqual(shown, 'SIMULATED — TomTom daily budget reached');
assert.ok(!shown.startsWith('SIMULATED'), `still English: ${shown}`);
} finally {
setLocale('en');
}
});
test('English degraded-feed wording is unchanged', () => {
setLocale('en');
assert.equal(
deriveAisFeedError({ status: 'down', reconnectAttempt: 3 }, 0),
'feed down — retrying slowly (attempt 3)',
);
assert.equal(
deriveAisFeedError({ status: 'reconnecting' }, 0),
'reconnecting to feed…',
);
assert.equal(
deriveAisFeedError({ status: 'missing-key' }, 0),
'AISSTREAM_API_KEY not set',
);
const { trafficFeedPresentation } = createModel({
state: {},
services: {},
parts: {},
source: {},
});
assert.equal(
trafficFeedPresentation({
liveMode: true,
flowError: 'TomTom daily budget reached',
}).error,
'SIMULATED — TomTom daily budget reached',
);
});
+5 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
FIRMS_OVERLAY_SOURCE_ID,
FIRMS_AMBIENT_COHORT_LIMIT,
@@ -115,7 +116,10 @@ export function createCards({
if (!card.interactive) return card;
return {
...card,
accessibilityLabel: `Focus fire detection ${card.title}, ${card.details.join(', ')}`,
accessibilityLabel: t('Focus fire detection {title}, {details}', {
title: card.title,
details: card.details.join(', '),
}),
activate: () => {
const fire = layerState._fireByCardId.get(card.id);
if (!fire) return false;
+3 -1
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
layerState,
@@ -72,7 +73,8 @@ export function createIngestion({
)
return;
console.warn(`[Data:${id}] FIRMS live load failed:`, error);
layerState._error = 'live feed unavailable';
// English data (stats reach the voice tools); the panel translates it.
layerState._error = msg('live feed unavailable');
} finally {
if (layerState.request === request) {
layerState.request = null;
+83 -18
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -29,7 +30,8 @@ export function createModel({
* context-store ids, so the two stay consistent).
* @param {Object|null|undefined} fire - Internal fire record.
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
* confidence: number|null, satellite: string|null, acqTime: number|null}}
* confidence: number|null, satellite: string|null, sensor: string|null,
* acqTime: number|null}}
*/
function mapAnalystRecord(fire) {
@@ -45,6 +47,7 @@ export function createModel({
frp: num(fire?.frp),
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
satellite: text(fire?.satellite) || text(fire?.sensor),
sensor: text(fire?.sensor), // VIIRS or MODIS
acqTime:
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
};
@@ -327,14 +330,18 @@ export function createModel({
*/
function buildSelectedFireCard(fire, nowMs) {
const meta = [`${confidenceBucket(fire.confidence)} conf`];
const meta = [
t('{value} conf', {
value: confidenceLabel(confidenceBucket(fire.confidence)),
}),
];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
if (age) meta.push(`${age} ago`);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(t('{value} ago', { value: age }));
}
const sat = satelliteShortName(fire.satellite);
meta.push(
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || 'sensor n/a',
sat ? `${fire.sensor || 'VIIRS'} ${sat}` : fire.sensor || t('sensor n/a'),
);
return {
id: `selected-fire:${fireDetectionKey(fire)}`,
@@ -344,10 +351,11 @@ export function createModel({
cullPosition: fireCullPosition(fire),
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `FIRE · ${formatFrp(fire.frp)} MW`,
title: t('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [
meta.join(' · '),
formatLatLon(fire.lat, fire.lon) + (fire.night ? ' · NIGHT' : ''),
formatLatLonLabel(fire.lat, fire.lon) +
(fire.night ? ` · ${t('NIGHT')}` : ''),
],
selected: true,
priority: Number.MAX_SAFE_INTEGER,
@@ -366,9 +374,9 @@ export function createModel({
function buildFireCard(candidate, nowMs) {
const fire = candidate.fire;
const meta = [confidenceBucket(fire.confidence)];
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
if (fire.acqMs > 0) {
const age = formatAge(nowMs - fire.acqMs);
const age = formatAgeLabel(nowMs - fire.acqMs);
if (age) meta.push(age);
}
const sat = satelliteShortName(fire.satellite) || fire.sensor;
@@ -380,7 +388,7 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: frpPixelSize(fire.frp),
accent: accentForSeverity(detectionColorStop(fire).name),
title: `▲ ${formatFrp(fire.frp)} MW`,
title: t('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
details: [meta.join(' · ')],
selected: false,
priority: Number(fire.frp) || 0,
@@ -398,11 +406,11 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? 'FIRE' : 'FIRES';
const parts = [`max ${formatFrp(cell.maxFrp)} MW`];
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAge(nowMs - cell.newestAcqMs);
if (age) parts.push(`new ${age}`);
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
if (age) parts.push(t('new {value}', { value: age }));
}
return {
id: `cell:${cell.latCell ?? 'x'}:${cell.lonCell ?? 'x'}`,
@@ -475,6 +483,37 @@ export function createModel({
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
}
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
function formatFrpLabel(frp) {
const digits = frp >= 10 ? 0 : 1;
return formatNumber(frp, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
}
/** formatLatLon for a card: "8,389° ю. ш. 44,162° з. д." in Russian. */
function formatLatLonLabel(lat, lon) {
const amount = (value) =>
formatNumber(Math.abs(value), {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
const latPart =
lat >= 0
? t('{value}°N', { value: amount(lat) })
: t('{value}°S', { value: amount(lat) });
const lonPart =
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) {
@@ -488,11 +527,37 @@ export function createModel({
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
function formatAgoMinutes(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 'just now';
if (!Number.isFinite(deltaMs) || deltaMs < 0) return t('just now');
const minutes = Math.floor(deltaMs / 60000);
if (minutes < 1) return '<1m ago';
if (minutes < 90) return `${minutes}m ago`;
return `${Math.round(minutes / 60)}h ago`;
if (minutes < 1) return t('<1m ago');
if (minutes < 90) return t('{count}m ago', { count: minutes });
return t('{count}h ago', { count: Math.round(minutes / 60) });
}
/** A confidence bucket as a card shows it, in the UI language. */
function confidenceLabel(bucket) {
switch (bucket) {
case 'high':
return t('high', { context: 'fire confidence' });
case 'nominal':
return t('nominal', { context: 'fire confidence' });
case 'low':
return t('low', { context: 'fire confidence' });
default:
return bucket;
}
}
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
function formatAgeLabel(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return t('<1h', { context: 'age' });
if (hours < 48)
return t('{count}h', { context: 'age', count: Math.round(hours) });
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+19 -9
View File
@@ -1,4 +1,5 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
export function createQueries({
@@ -17,7 +18,9 @@ export function createQueries({
if (!fire) return null;
return {
id: fireDetectionKey(fire),
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
frp: fire.frp,
@@ -59,21 +62,26 @@ export function createQueries({
getStats() {
const now = Date.now();
const staleText = layerState._lastUpdate
? `STALE · cached ${components.model.formatAge(now - layerState._lastUpdate) || '<1h'}`
: 'STALE';
? t('STALE · cached {age}', {
age:
components.model.formatAge(now - layerState._lastUpdate) || '<1h',
})
: t('STALE');
let loadingLabel = '';
if (layerState._loading) {
loadingLabel = layerState._fires.length
? 'refreshing...'
: 'loading...';
? t('refreshing...')
: t('loading...');
} else if (layerState._keyRequired) {
loadingLabel = 'KEY REQUIRED';
loadingLabel = t('KEY REQUIRED');
} else if (layerState._stale) {
loadingLabel = staleText;
} else if (layerState._error) {
loadingLabel = layerState._error;
} else if (layerState._lastUpdate) {
loadingLabel = `LIVE · updated ${components.model.formatAgoMinutes(now - layerState._lastUpdate)}`;
loadingLabel = t('LIVE · updated {age}', {
age: components.model.formatAgoMinutes(now - layerState._lastUpdate),
});
}
return {
count: layerState._count,
@@ -87,7 +95,7 @@ export function createQueries({
// the operator can take.
keyRequired: layerState._keyRequired,
error: layerState._keyRequired
? 'KEY REQUIRED'
? t('KEY REQUIRED')
: layerState._stale
? staleText
: layerState._error,
@@ -109,7 +117,9 @@ export function createQueries({
latitude: strongest.lat,
longitude: strongest.lon,
frp: strongest.frp,
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(strongest.frp),
}),
};
},
+5 -2
View File
@@ -5,6 +5,7 @@ import {
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { CONTEXT_TOP_N } from './policy.js';
import { t } from '../../i18n/index.js';
export function createSelection({
layerState,
@@ -80,7 +81,7 @@ export function createSelection({
requestWorldFocus({
kind: 'fire',
id: fireDetectionKey(fire),
label: 'FIRE',
label: t('FIRE'),
position: components.model.firePosition(fire),
});
}
@@ -192,7 +193,9 @@ export function createSelection({
layerName: name,
source: 'NASA FIRMS',
dataSource: layerState._dataSource,
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
label: t('Fire · FRP {frp} MW', {
frp: components.model.formatFrp(fire.frp),
}),
latitude: fire.lat,
longitude: fire.lon,
properties: {
+9 -2
View File
@@ -1,5 +1,9 @@
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
// Marks English the Layers panel translates when it shows the error. Kept
// local: this module stays free of browser globals (package boundaries).
const msg = (text) => text;
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
export function createIngestion({
feed,
@@ -41,9 +45,10 @@ export function createIngestion({
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
feed._backoff = sourceStale;
feed._retryAt = 0;
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
feed._lastError = sourceStale
? sourceAgeMs == null
? 'Source snapshot time unavailable'
? msg('Source snapshot time unavailable')
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
feed._lastSource = snapshot.source;
@@ -77,7 +82,9 @@ export function createIngestion({
setSourceLabel(feed._lastSource);
}
feed._lastError =
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
e?.name === 'LiveSourceError'
? e.message
: msg('Live data unavailable');
} finally {
feed._activeUpdateControllers.delete(resourceController);
}
+52
View File
@@ -0,0 +1,52 @@
import {
CLASS_MODEL_REAL,
CLASS_MODEL_URL,
CLASS_SCALE_3D,
} from '../../data/aircraftClass.js';
import {
trailAnchorForModel,
visualCenterForModel,
} from '../../data/modelVisualAnchor.js';
import {
MODEL_BELLY_OFFSET_NATIVE,
MODEL_COLOR_BLEND_AMOUNT,
MODEL_NATIVE_RADIUS_M,
MODEL_SCALE,
PLANE_MODEL_URL,
} from './policy.js';
/**
* The glTF asset, scale and measured geometry a civil aircraft class renders
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
* belly and radius); classes without one share airplane.glb at the class's
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
* the same model for the same class.
* @param {string} klass Aircraft class from `classifyAircraft`.
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
*/
export function civilAircraftModelSpec(klass) {
const real = CLASS_MODEL_REAL[klass];
if (real) {
return {
url: real.url,
scale: 1,
nativeRadiusM: real.radiusM,
bellyM: real.bellyM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
}
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
return {
url,
scale,
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
}
+6
View File
@@ -7,6 +7,7 @@ export {
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
export const EMERGENCY_TINT =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
// taxiing and landing") -----------------------------------------------------------
// Present-but-grounded planes are RENDERED instead of being skipped: same class
+34 -3
View File
@@ -15,6 +15,7 @@ import {
FOCUS_EVIDENCE_DEV,
FLEET_DR_INTERVAL_MS,
} from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -113,7 +114,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO 24-bit transponder address.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -163,10 +164,23 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Climb/descent rate, the one kinematic field `mapAnalystRecord` already
// published (as `verticalRateMps`) but this descriptor did not, so a
// consumer reading the tracked seam had to reach back into `_flightData`
// for it. Reported, never derived: `stickyNumber` in records.js keeps the
// last value across a blank poll, and an absent rate stays null rather
// than becoming a level-flight 0 the aircraft never transmitted.
verticalRateMps: Number.isFinite(info?.verticalRate)
? info.verticalRate
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
),
// This poll's transponder broadcast (not sticky; see records.js).
squawk: info?.squawk ?? null,
emergency: info?.emergency ? { ...info.emergency } : null,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
airline: info?.airline ?? null,
// CLASS label follows the TR-3B conversion so every downstream card
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
@@ -219,7 +233,10 @@ export function createQueries({
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: string|null,
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null,
* routeDestination: string|null}}
*/
function mapAnalystRecord(
@@ -253,6 +270,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: text(info?.originCountry),
operator: text(info?.airline),
squawk: text(info?.squawk),
// True while this poll broadcast an emergency (7500/7600/7700 or the
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
// without the flag.
emergency: info?.emergency?.severity === 'emergency',
emergencyKind: text(info?.emergency?.kind),
// Provenance, so a summary can say "broadcasting squawk 7700" rather
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
// when the Mode A code names the status, 'ads-b' for the readsb field.
emergencySource: text(info?.emergency?.source),
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
// Latest known general aircraft-message contact, retained if a poll
// has no time. This is not the receipt time of the current status.
lastContactAtMs: num(info?.lastContactEpochMs),
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
};
@@ -834,7 +865,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+1 -6
View File
@@ -13,9 +13,4 @@ export const MISSING_POLL_LIMIT = 3;
export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
export { approxDistanceKm } from '../../data/approxDistance.js';
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
} from './recordPolicy.js';
test('approximate distance takes the short way across the antimeridian', () => {
// 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km.
for (const [lon1, lon2] of [
[179.9, -179.9],
[-179.9, 179.9],
]) {
const km = approxDistanceKm(-16.8, lon1, -16.8, lon2);
assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`);
assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM);
}
});
+22 -3
View File
@@ -4,6 +4,7 @@ import {
pickRenderAltitudeM,
} from '../../data/renderAltitude.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
@@ -226,6 +227,22 @@ export class FlightRecords {
// like callsign so a transient blank row doesn't blank the field.
originCountry:
stickyText(origin_country, prevMeta?.originCountry) || null,
// Per poll, NOT sticky: an emergency code clears when the crew resets
// it, so a blank squawk must not hold a stale 7700 on screen.
squawk: observation.squawk ?? null,
// One source-switch omission may retain an ADS-B status, while an
// explicit clear or changed squawk always takes effect immediately.
emergency:
observation.emergency === undefined &&
observation.squawk === prevMeta?.squawk &&
prevMeta?.emergency?.source === 'ads-b' &&
(prevMeta?.missingEmergencyPolls ?? 0) < 1
? prevMeta.emergency
: aircraftEmergency(observation),
missingEmergencyPolls:
observation.emergency === undefined
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
: 0,
// OpenSky distinguishes the last position epoch from the last
// transponder message. The fleet coast horizon uses this actual
// contact time so a temporarily old position does not hard-freeze
@@ -258,10 +275,12 @@ export class FlightRecords {
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, likelyLanded }) {
// Partial admissions do not prove absence, but retention remains bounded.
absence(id, { complete, retainIds, likelyLanded }) {
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
!complete &&
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
+34
View File
@@ -106,3 +106,37 @@ test('ground transitions preserve last render height while a surface cell is col
assert.equal(next.meta.renderAltitudeM, 1040);
assert.equal(next.meta.wasAirborne, true);
});
test('complete snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
const store = records();
for (const id of ['abc123', 'def456', 'ghi789'])
store.receive(observation({ id, reference: id }), view());
// abc123 and ghi789 are still listed without a position; def456 vanished.
const absent = (id, likelyLanded = false) =>
store.absence(id, {
complete: true,
retainIds: new Set(['abc123', 'ghi789']),
likelyLanded,
});
assert.deepEqual(
[absent('def456'), absent('def456'), absent('def456')],
['stale', 'stale', 'remove'],
);
assert.equal(absent('abc123'), 'retain');
assert.equal(store.missingPolls.has('abc123'), false);
// A landed contact that lost its position takes the landed fast-cull.
assert.equal(absent('ghi789', true), 'remove');
// Position-less retention stays bounded like partial admission.
store.data.get('abc123').observedReceiptMs = Date.now() - 300001;
assert.equal(absent('abc123'), 'stale');
});
test('civil emergency status follows the latest poll instead of sticking', () => {
const store = records();
const first = store.receive(observation({ squawk: '7700' }), view());
assert.equal(first.meta.squawk, '7700');
assert.equal(first.meta.emergency.kind, 'general');
const next = store.receive(observation({ squawk: '2000' }), view());
assert.equal(next.meta.squawk, '2000');
assert.equal(next.meta.emergency, null);
});
+43 -78
View File
@@ -1,14 +1,9 @@
import * as Cesium from 'cesium';
import {
CLASS_SCALE_2D,
CLASS_MODEL_REAL,
CLASS_SCALE_3D,
CLASS_MODEL_URL,
} from '../../data/aircraftClass.js';
import {
visualCenterForModel,
trailAnchorForModel,
} from '../../data/modelVisualAnchor.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -19,12 +14,8 @@ import {
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_TINT,
EMERGENCY_TINT,
GROUND_SCALE,
MODEL_COLOR_BLEND_AMOUNT,
MODEL_SCALE,
PLANE_MODEL_URL,
MODEL_NATIVE_RADIUS_M,
MODEL_BELLY_OFFSET_NATIVE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR,
MODEL_ALT_CEIL_M,
@@ -65,13 +56,34 @@ export function createRendering({
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
services.recession;
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
/** True while this contact's latest poll broadcast an emergency code. */
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
return isEmergency(info?.emergency);
}
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
* amber for known-military, white otherwise.
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
function _fleetBillboardColor(icao24) {
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
}
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
function _cockpitContactColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
}
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
function _fleetBillboardScale(icao24, klass) {
@@ -100,31 +112,7 @@ export function createRendering({
function _modelSpec(klass) {
const cached = flightState._specCache.get(klass);
if (cached) return cached;
const real = CLASS_MODEL_REAL[klass];
let spec;
if (real) {
spec = {
url: real.url,
scale: 1,
nativeRadiusM: real.radiusM,
bellyM: real.bellyM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
} else {
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
spec = {
url,
scale,
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
}
const spec = civilAircraftModelSpec(klass);
flightState._specCache.set(klass, spec);
return spec;
}
@@ -158,15 +146,13 @@ export function createRendering({
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = (
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
).withAlpha(freshnessAlpha);
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -180,7 +166,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1);
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
}
/**
@@ -197,7 +183,7 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
return _fleetBillboardColor(icao24);
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -953,31 +939,11 @@ export function createRendering({
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
// invisible and it re-adds the moment it's back in view).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => flightState._models.has(icao),
});
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
@@ -1062,12 +1028,11 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor =
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? isMilitaryIcao(icao24)
? MIL_TINT
: COCKPIT_CIVILIAN_COLOR
: _fleetBillboardColor(icao24);
? _cockpitContactColor(icao24, info)
: _fleetBillboardColor(icao24, info);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
+20 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
@@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(bb.color?.alpha ?? 1);
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
@@ -178,8 +179,24 @@ export function createFlightSnapshotRenderer({
// as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time, and a grounded contact is then
// lifted onto the floor under that delayed position. Creating it at
// the raw fix drew it ahead until the next fleet tick snapped it back;
// skipping the floor started a taxiing contact low and then raised it.
const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3());
const displayed =
(delayed &&
motion._floorGroundedDisplayPosition(
icao24,
flightState.records.data.get(icao24) || meta,
delayed,
rendering._modelOwnsVisual(icao24),
)) ||
position;
const bb = flightState._billboardCollection.add({
position,
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
@@ -226,6 +243,7 @@ export function createFlightSnapshotRenderer({
if (currentIcaos.has(icao24)) continue;
const absence = records.absence(icao24, {
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
});
if (absence === 'retain') continue;
+2
View File
@@ -250,6 +250,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+13 -3
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
import {
@@ -130,6 +132,7 @@ export function createTracking({
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
// narrated position/velocity are last-known rather than live.
// i18n-ignore: voice-context data, English for the model
status: described.stale ? 'stale (missed polls)' : 'live',
},
};
@@ -751,11 +754,18 @@ export function createTracking({
// the tail number rather than raw hex.
const cs = parts.queries._contactLabel(icao24, info);
const altFt = Math.round((info.altitude || 0) * 3.28084);
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
// Flight levels are an aviation code; feet and knots read in the active
// language ("0 фт · 1 уз").
const quantity = (value, unit) =>
`${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
const fl =
altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? 'STALE'
? t('STALE')
: '';
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
+435
View File
@@ -0,0 +1,435 @@
import * as Cesium from 'cesium';
import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
gnssProvenance,
pruneGnssObservations,
} from './records.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({
low: msg('Low-accuracy score < 2%'),
medium: msg('Low-accuracy score 2–10%'),
high: msg('Low-accuracy score > 10%'),
});
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/**
* Own one GNSS navigation-integrity display: rolling integrity observations
* around the views the user visits, binned into cells coloured by the
* gpsjam low-accuracy score. Share links retain token 3.
*/
export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
let _viewer = null;
let _primitives = null;
let _request = null;
let _enabled = false;
let _cells = [];
let _signature = null;
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
let _manager = null;
let _cameraTimer = null;
let _backgroundRefresh = null;
let _lastQueryKey = null;
let _lastRequestAt = -Infinity;
let _removeCameraEnd = null;
let _removeCameraChanged = null;
let _coverage = null;
let _retryTimer = null;
let _retryAt = 0;
let _failureCount = 0;
const _observations = new Map();
const provenance = gnssProvenance({ windowMs });
const queryKey = (anchor) =>
anchor
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
: null;
function scheduleCameraRefresh() {
if (!_enabled || !_primitives) return;
if (now() < _retryAt) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
clearTimeout(_cameraTimer);
// Debounce drags and bound moving/follow cameras to at most 24 requests
// per minute. Nearby views share the proxy's whole-degree cache key.
_cameraTimer = setTimeout(
async () => {
_cameraTimer = null;
if (!_enabled || _backgroundRefresh || _request) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
await refreshInBackground();
},
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
);
}
async function refreshInBackground() {
if (!_enabled || _backgroundRefresh) return;
const controller = new AbortController();
_backgroundRefresh = controller;
try {
if (_manager)
await _manager.refreshLayer(layer.id, { signal: controller.signal });
else await layer.update(undefined, { signal: controller.signal });
} finally {
if (_backgroundRefresh === controller) {
_backgroundRefresh = null;
scheduleCameraRefresh();
scheduleRetry();
}
}
}
function scheduleRetry() {
clearTimeout(_retryTimer);
_retryTimer = null;
if (!_enabled || !_retryAt) return;
// Long provider cooldowns keep their full deadline. Browser timers cannot
// exceed ~24 days, so wake in bounded chunks without fetching early.
_retryTimer = setTimeout(
() => {
_retryTimer = null;
void refreshInBackground();
},
Math.min(2147483647, Math.max(0, _retryAt - now())),
);
_retryTimer?.unref?.();
}
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, now(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
function render() {
// Only the id and band change what is drawn; counts alone do not.
const signature = JSON.stringify(
_cells.map(({ id, level }) => [id, level]),
);
if (signature === _signature) return;
_signature = signature;
_primitives.removeAll();
if (_cells.length) {
_primitives.add(
new GnssGroundFill({
geometryInstances: _cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `${layer.id}:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(FILL_ALPHA[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
_viewer?.scene?.requestRender?.();
}
const layer = {
id: 'gnss-integrity',
name: 'GNSS Integrity',
icon: '📡',
source: 'adsb.lol',
/** Where each part of the method comes from; see records.js. */
provenance,
updateInterval: 60000,
attachDataManager(manager) {
_manager = manager;
},
init(viewer) {
if (_viewer)
throw new Error('GNSS integrity layer is already initialized');
_viewer = viewer;
_primitives = new Cesium.PrimitiveCollection();
_primitives.id = layer.id;
_primitives.show = false;
viewer.scene.groundPrimitives.add(_primitives);
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
scheduleCameraRefresh,
);
},
enable() {
_enabled = true;
// Cells left from before a long disable must not reappear past the window.
if (_primitives) {
expire();
_primitives.show = true;
}
},
disable() {
clearTimeout(_cameraTimer);
_cameraTimer = null;
clearTimeout(_retryTimer);
_retryTimer = null;
_backgroundRefresh?.abort();
_backgroundRefresh = null;
_request?.abort();
_request = null;
_enabled = false;
if (_primitives) _primitives.show = false;
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_primitives || signal?.aborted) return false;
if (now() < _retryAt) {
expire();
scheduleRetry();
return true;
}
_retryAt = 0;
clearTimeout(_retryTimer);
_retryTimer = null;
const anchor = viewAnchor(_viewer);
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
// retries; cells already drawn still age out of the window.
if (!anchor) {
expire();
return true;
}
_request?.abort();
const request = new AbortController();
_request = request;
const onAbort = () => request.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
_lastQueryKey = queryKey(anchor);
_lastRequestAt = monotonicNow();
_coverage = {
kind: 'regional',
latitude: Math.round(anchor.latitude),
longitude: Math.round(anchor.longitude),
radiusNm: 250,
};
let received = false;
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
received = true;
// Date the rows by when the proxy observed them: a cached or stale
// replay keeps its original age instead of restarting the window.
// The proxy reports that age, so client and server clocks never mix.
const at = now();
accumulateGnssObservations(_observations, snapshot.rows, at, {
windowMs,
observedAt: at - snapshot.ageMs,
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale === true;
_lastUpdate = at - snapshot.ageMs;
_lastError = null;
_retryAt = 0;
_failureCount = 0;
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// A renderer/programming failure remains a real lifecycle failure.
// Only acquisition failures are kept enabled for recovery.
if (received) throw e;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || msg('GNSS integrity source unavailable');
// Keep the last cells only while their evidence is inside the window.
expire();
if (e?.retryable === false) return false;
_failureCount = Math.min(3, _failureCount + 1);
const delay =
Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0
? e.retryAfterMs
: Math.min(120000, 30000 * 2 ** (_failureCount - 1));
_retryAt = Math.max(
_retryAt,
Math.min(Number.MAX_VALUE, now() + delay),
);
// Enabling the display succeeded. Stats still report an unavailable
// or degraded feed; the manager keeps its normal refresh failure lane.
return true;
} finally {
signal?.removeEventListener('abort', onAbort);
if (_request === request) _request = null;
scheduleCameraRefresh();
scheduleRetry();
}
},
destroy(viewer = _viewer) {
layer.disable();
_removeCameraEnd?.();
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
_observations.clear();
_cells = [];
_signature = null;
if (_primitives) {
viewer.scene.groundPrimitives.remove(_primitives);
_primitives = null;
}
_viewer = null;
_lastUpdate = null;
_lastError = null;
_stale = false;
_coverage = null;
_retryAt = 0;
_failureCount = 0;
_lastQueryKey = null;
_manager = null;
},
getRowControls() {
return {
chips: [],
info:
t(
'Regional sample · 250 nm radius · cells from visited views over 30 min',
) +
(_lastError && _retryAt
? ` · ${translateFeedError(_lastError)} · ${
_retryAt <= 8640000000000000
? t('Retrying automatically at {time} UTC', {
time: new Date(_retryAt).toISOString().slice(11, 19),
})
: t('Retrying automatically after provider cooldown')
}`
: ''),
infoTitle: t(
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
),
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: t(LEVEL_LABELS[level]),
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb: t(
'Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.',
{
aircraft: formatNumber(GNSS_MIN_AIRCRAFT),
minutes: formatNumber(provenance.window.minutes),
},
),
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
levels: Object.fromEntries(
['low', 'medium', 'high'].map((level) => [
level,
_cells.filter((cell) => cell.level === level).length,
]),
),
lastUpdate: _lastUpdate,
error: _lastError,
retryInSec: _retryAt
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
: null,
stale: _stale,
provenance,
coverage: _coverage,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
};
},
};
return layer;
}
+700
View File
@@ -0,0 +1,700 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{
anchor: initialAnchor = { latitude: 50, longitude: 30 },
initialize = true,
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
// The real source refuses an undated snapshot; stubs default to a fresh one.
source: {
async getSnapshot(...args) {
const snapshot = await getSnapshot(...args);
return { ageMs: 0, ...snapshot };
},
},
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
return {
layer,
sources,
anchors,
viewer,
advance(ms) {
clock += ms;
},
now: () => clock,
setAnchor(value) {
anchor = value;
},
};
}
const MIN = 60_000;
const threeHealthy = () => [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
];
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
});
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls, 2, 'one initial read and one read for the latest view');
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
assert.ok(changes.includes('refresh-transition'));
assert.ok(changes.includes('refresh'));
assert.equal(manager.isEnabled(h.layer.id), true);
assert.deepEqual(h.layer.getStats().coverage, {
kind: 'regional',
latitude: 55,
longitude: 20,
radiusNm: 250,
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolvePending;
const h = harness((anchor) => {
calls.push(anchor);
if (calls.length === 2)
return new Promise((resolve) => {
resolvePending = resolve;
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 2, 'only one camera request in flight');
resolvePending({ rows: threeHealthy() });
await flush();
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls.length, 3, 'disable drains the camera timer');
h.layer.destroy();
});
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /need 3 aircraft/);
});
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
const { layer } = harness(async () => ({ rows: [], stale: false }));
assert.equal(
layer.id,
'gnss-integrity',
'the unpublished id describes the measured state',
);
assert.equal(layer.name, 'GNSS Integrity');
assert.doesNotMatch(layer.name, /interference|jamming/i);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ label }) => label),
[
'Low-accuracy score > 10%',
'Low-accuracy score 2–10%',
'Low-accuracy score < 2%',
],
);
const { blurb } = legend[0];
assert.match(blurb, /one aircraft is discounted/);
assert.match(blurb, /not the raw share/);
assert.match(blurb, /not a detection/);
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
assert.equal(layer.provenance.classifier.validated, false);
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
assert.deepEqual(layer.provenance.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(layer.provenance.interpretation.validated, false);
assert.equal(layer.getStats().provenance, layer.provenance);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(
await layer.update(),
true,
'a settling camera is not a failure',
);
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance, now } = harness(async () => ({
rows: threeHealthy(),
ageMs: now() - 1_000,
stale: true,
}));
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(29 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(2 * MIN);
assert.equal(
await layer.update(),
true,
'the replay is not an error, only no evidence',
);
assert.equal(
layer.getStats().count,
0,
'the replay did not restart the window',
);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
fail = true;
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1, 'still inside the window');
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
setAnchor(null);
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, null);
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
}));
layer.enable();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const first = sources[0].get(0);
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
if (calls === 1) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((c) => changes.push(c.type));
assert.equal(await manager.setEnabled(h.layer.id, true), true);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
assert.equal(h.layer.getStats().retryInSec, 30);
assert.match(
h.layer.getRowControls().info,
/HTTP 502.*Retrying automatically at/,
);
assert.equal(changes.includes('visibility-failed'), false);
assert.equal(
await manager.refreshLayer(h.layer.id),
false,
'feed remains honestly unhealthy during cooldown',
);
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(calls, 2);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
assert.equal(h.layer.getStats().count, 1);
assert.equal(h.layer.getStats().retryInSec, null);
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
await manager.destroyAll();
});
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
if (calls === 1)
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
retryAfterMs: 3600000,
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 10, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
await h.layer.update();
assert.equal(calls, 1);
assert.equal(h.layer.getStats().retryInSec, 3570);
h.layer.disable();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
h.layer.enable();
await h.layer.update();
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
h.advance(3540000);
t.mock.timers.tick(3540000);
await flush();
assert.equal(calls, 2);
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
h.layer.destroy();
const stopped = harness(async () => {
throw new Error('network unavailable');
});
stopped.layer.enable();
await stopped.layer.update();
stopped.layer.disable();
stopped.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
stopped.layer.destroy();
});
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
throw new Error('network unavailable');
});
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().retryInSec, 30);
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 60);
h.advance(60000);
t.mock.timers.tick(60000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 120);
assert.equal(calls, 3);
h.layer.destroy();
const fatal = harness(async () => {
throw Object.assign(new Error('invalid request (HTTP 400)'), {
retryable: false,
});
});
fatal.layer.enable();
assert.equal(await fatal.layer.update(), false);
assert.equal(fatal.layer.getStats().retryInSec, null);
fatal.layer.destroy();
});
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
const h = harness(async () => ({
rows: threeHealthy(),
ageMs: 120000,
stale: true,
}));
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
h.layer.destroy();
});
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
return { rows: [] };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
assert.equal(calls, 1);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
assert.equal(calls, 2);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 3);
h.layer.destroy();
});
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolve;
const h = harness((anchor) => {
calls.push(anchor);
return calls.length === 1
? new Promise((r) => (resolve = r))
: Promise.resolve({ rows: threeHealthy() });
});
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 1);
resolve({ rows: threeHealthy() });
assert.equal(await initial, true);
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 2);
h.layer.destroy();
});
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness(async () => ({ rows: [] }));
h.layer.destroy();
let wall = 1800000000000,
mono = 0,
anchor = { latitude: 50, longitude: 30 },
calls = 0;
const layer = createGnssIntegrityLayer({
source: {
getSnapshot: async () => {
calls++;
return { rows: [], ageMs: 0 };
},
},
viewAnchor: () => anchor,
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
assert.equal(calls, 2);
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
*
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). A cluster of aircraft reporting low values in one area is a
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
* such clusters are a well-known public proxy for GNSS interference, but
* avionics faults and old transponders also report low values: suspected
* interference is a second, unvalidated interpretation, never the primary
* state.
*
* Three separately sourced parts make up the method (see `gnssProvenance`):
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
* flag. gpsjam.org does not publish its per-aircraft threshold.
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
*/
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
export const GNSS_MIN_AIRCRAFT = 3;
/** Rolling window over which aircraft seen in a cell are counted. */
export const GNSS_WINDOW_MS = 30 * 60_000;
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
export const GNSS_MEDIUM_PERCENT = 2;
export const GNSS_HIGH_PERCENT = 10;
/** Primary cell state: what the data shows (low navigation accuracy). */
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
export const GNSS_NOMINAL_STATE = 'nominal';
/** Secondary, unvalidated reading of an anomaly cell. */
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
/** The per-aircraft rule; GEV-defined and not validated against known events. */
export const GNSS_CLASSIFIER = Object.freeze({
id: 'gev-nic-nacp-v2',
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
/**
* Machine-readable provenance of the cell output: which part of the method
* comes from where, and which parts are unvalidated.
*
* @param {{windowMs?: number}} [options] The window the layer actually uses.
*/
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
return Object.freeze({
classifier: GNSS_CLASSIFIER,
aggregation: Object.freeze({
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
}),
window: Object.freeze({
minutes: windowMs / 60_000,
scope: 'visited-view',
cellDeg: GNSS_CELL_DEG,
minAircraft: GNSS_MIN_AIRCRAFT,
definedBy: 'GEV',
}),
state: Object.freeze({
id: GNSS_ANOMALY_STATE,
meaning: 'low navigation accuracy reported by ADS-B aircraft',
}),
interpretation: Object.freeze({
id: GNSS_INTERPRETATION,
validated: false,
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
}),
});
}
/** Provenance at the default window. */
export const GNSS_PROVENANCE = gnssProvenance();
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
let { lat, lon } = aircraft;
const derived = (field) =>
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
const nacp =
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb exposes the flag while GPS is lost (15 s–15 min after the
// last good position), and removes it on recovery. Locate that evidence
// at the last good GNSS position, rather than a later MLAT estimate.
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
if (nic === null && nacp === null && !gpsLost) continue;
if (
gpsLost &&
finite(aircraft.gpsOkLat) &&
Math.abs(aircraft.gpsOkLat) <= 90 &&
finite(aircraft.gpsOkLon) &&
Math.abs(aircraft.gpsOkLon) <= 180
) {
lat = aircraft.gpsOkLat;
lon = aircraft.gpsOkLon;
}
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost ||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
(nic !== null &&
nic < GNSS_NIC_THRESHOLD &&
!(nic === 0 && nacp !== null && nacp >= 10)),
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window. Presence and degraded evidence age separately: a
* degraded report keeps the aircraft degraded in that cell for one window
* after the report itself, however many healthy reports follow, so an
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
* is not held degraded forever.
*
* Every row is dated by when the snapshot was observed, not when it arrived:
* replaying a cached or stale snapshot never makes its evidence younger, and
* a snapshot older than the window adds nothing.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{
windowMs = GNSS_WINDOW_MS,
cellDeg = GNSS_CELL_DEG,
observedAt = nowMs,
} = {},
) {
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
if (Number.isFinite(at) && nowMs - at <= windowMs) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
const previousDegradedAt = previous?.degradedAt ?? null;
store.set(key, {
cell,
seenAt: Math.max(previous?.seenAt ?? at, at),
degradedAt: row.degraded
? Math.max(previousDegradedAt ?? at, at)
: previousDegradedAt,
});
}
}
return pruneGnssObservations(store, nowMs, { windowMs });
}
/**
* Drop observations older than the window and degraded evidence older than
* the window, without adding anything. Used when no fresh snapshot arrives.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {number} nowMs
* @param {{windowMs?: number}} [options]
* @returns {Map} The same store.
*/
export function pruneGnssObservations(
store,
nowMs,
{ windowMs = GNSS_WINDOW_MS } = {},
) {
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
store.set(key, { ...entry, degradedAt: null });
}
return store;
}
/**
* Navigation-integrity level of a cell, using gpsjam.org's published cell
* formula and bands: the first degraded aircraft is discounted to limit false
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
* high cells are navigation-integrity anomalies.
*/
export function gnssIntegrityLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
const level =
percent > GNSS_HIGH_PERCENT
? 'high'
: percent >= GNSS_MEDIUM_PERCENT
? 'medium'
: 'low';
const anomaly = level !== 'low';
return {
percent,
level,
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
interpretation: anomaly ? GNSS_INTERPRETATION : null,
};
}
/**
* Aggregate the observation store into displayable cells. Expects a store
* already pruned to the window by `accumulateGnssObservations` or
* `pruneGnssObservations`.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degradedAt } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degradedAt != null) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level, state, interpretation } = gnssIntegrityLevel(
aircraft,
degraded,
);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
state,
interpretation,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+491
View File
@@ -0,0 +1,491 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
GNSS_CLASSIFIER,
GNSS_PROVENANCE,
GNSS_WINDOW_MS,
gnssCellKey,
gnssIntegrityLevel,
gnssProvenance,
normalizeGnssAircraft,
pruneGnssObservations,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({
hex: '000001',
type: 'mlat',
nic: undefined,
nac_p: undefined,
}),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
aircraft({ hex: '000009', version: 3 }),
aircraft({ hex: '00000a', version: '2' }),
aircraft({ hex: '00000b', version: 1.5 }),
// A position is current only when its age is known, finite and 0..60 s.
aircraft({ hex: '00000c', seen_pos: undefined }),
aircraft({ hex: '00000d', seen_pos: null }),
aircraft({ hex: '00000e', seen_pos: -0.5 }),
aircraft({ hex: '00000f', seen_pos: '1.2' }),
aircraft({ hex: '000010', seen_pos: Number.NaN }),
aircraft({ hex: '000011', seen_pos: 60.1 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
// Version 1, and both ends of the position-age range, qualify.
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
aircraft({ hex: 'b00002', seen_pos: 60 }),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
{
hex: '~a1b2c3',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
...['b00001', 'b00002'].map((hex) => ({
hex,
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
})),
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
});
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
const nominal = {
percent: 0,
level: 'low',
state: 'nominal',
interpretation: null,
};
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
// One degraded aircraft is always discounted.
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
});
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
for (const [total, bad] of [
[50, 2],
[10, 3],
]) {
const { state, interpretation } = gnssIntegrityLevel(total, bad);
assert.equal(state, 'navigation-integrity-anomaly');
assert.equal(interpretation, 'suspected-gnss-interference');
}
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
],
0,
);
const [cell] = binGnssCells(store);
assert.equal(cell.level, 'high');
assert.equal(cell.state, 'navigation-integrity-anomaly');
assert.equal(cell.interpretation, 'suspected-gnss-interference');
for (const key of Object.keys(cell))
assert.doesNotMatch(key, /interference|jamming|spoof/i);
});
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
assert.deepEqual(GNSS_PROVENANCE.classifier, {
id: 'gev-nic-nacp-v2',
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: [2, 10],
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
});
assert.deepEqual(GNSS_PROVENANCE.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
assert.equal(
GNSS_PROVENANCE.interpretation.id,
'suspected-gnss-interference',
);
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
// The bands in provenance are the ones the level function applies.
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
const MIN = 60_000;
const report = (hex, degraded, lat = 50.1) => ({
hex,
lat,
lon: 20.1,
degraded,
});
const entry = (store, hex) =>
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 keeps reporting healthy every minute in the same cell.
for (let minute = 1; minute <= 30; minute += 1)
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
assert.equal(
entry(store, 'a1').degradedAt,
0,
'healthy reports do not refresh the degraded evidence',
);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// A new degraded report starts its own window.
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
});
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
const store = new Map();
const healthy = [report('a2', false), report('a3', false)];
accumulateGnssObservations(
store,
[report('a1', true), report('b1', true), ...healthy],
0,
);
assert.equal(binGnssCells(store)[0].level, 'high');
for (let minute = 10; minute <= 30; minute += 10)
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
minute * MIN,
);
assert.equal(binGnssCells(store)[0].degraded, 2);
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
31 * MIN,
);
const [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 4);
assert.equal(cell.degraded, 0);
assert.equal(cell.level, 'low');
});
test('an aircraft returning after the window starts fresh', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 leaves; nothing is heard from it for longer than the window.
accumulateGnssObservations(store, [], 31 * MIN);
assert.equal(store.size, 0);
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 32 * MIN,
degradedAt: null,
});
// Returning degraded dates the evidence from the new report.
const again = new Map();
accumulateGnssObservations(again, [report('a9', true)], 0);
accumulateGnssObservations(again, [], 31 * MIN);
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
});
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
// No prune runs in between: the old entry is still in the store when the
// healthy report arrives, so the new report alone must not carry it over.
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// Exactly one window after the degraded report it still counts.
const edge = new Map();
accumulateGnssObservations(edge, [report('a1', true)], 0);
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
assert.equal(entry(edge, 'a1').degradedAt, 0);
});
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
const store = new Map();
const snapshot = [report('a1', true), report('a2', false)];
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
// The same snapshot replayed (cached or stale) 29 minutes later.
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
assert.equal(entry(store, 'a1').seenAt, 0);
assert.equal(entry(store, 'a1').degradedAt, 0);
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
// An older replay never moves an entry's age backwards either.
accumulateGnssObservations(store, snapshot, 40 * MIN);
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
observedAt: 35 * MIN,
});
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
// A future time is clamped to now; an undated snapshot adds nothing.
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
observedAt: 99 * MIN,
});
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
for (const observedAt of [null, Number.NaN])
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
observedAt,
});
assert.equal(entry(store, 'u1'), undefined);
});
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
accumulateGnssObservations(store, [report('a2', false)], 0);
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
assert.deepEqual(
[...store.keys()].map((key) => key.split('|')[0]),
['a1'],
);
assert.equal(entry(store, 'a1').degradedAt, null);
pruneGnssObservations(store, 51 * MIN);
assert.equal(store.size, 0);
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
null,
],
0,
);
assert.equal(store.size, 0);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
const [row] = normalizeGnssAircraft({
ac: [
aircraft({
type: 'mlat',
nac_p: 0,
nic: 0,
mlat: ['lat', 'lon', 'nic'],
gpsOkBefore: 1800000000,
gpsOkLat: 54.2,
gpsOkLon: 19.3,
}),
],
});
assert.ok(row);
assert.equal(row.lat, 54.2);
assert.equal(row.lon, 19.3);
assert.equal(row.nic, null);
assert.equal(row.degraded, true);
const [healthy] = normalizeGnssAircraft({
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
});
assert.equal(healthy.degraded, false);
});
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
],
});
assert.deepEqual(
rows.map((r) => r.degraded),
[false, false, false, true, true],
);
});
// Quality-field subsets captured from public adsb.lol US point responses on
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
const tisbQuality = {
nic: 0,
nac_p: 0,
tisb: [
'altitude',
'alt_baro',
'gs',
'track',
'geom_rate',
'lat',
'lon',
'nic',
'rc',
'nac_p',
'nac_v',
'sil',
'sil_type',
],
mlat: [],
};
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
const unknownContainment = {
nic: 0,
nac_p: 10,
tisb: ['callsign'],
mlat: [],
};
assert.equal(
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
false,
);
});
+81
View File
@@ -0,0 +1,81 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// A portable export (package.json): no i18n here. Failures stay English data;
// the Layers panel shows them translated.
const msg = (text) => text;
function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
const ms = seconds * 1000;
if (Number.isFinite(ms) && seconds > 0)
return Math.min(3600000, Math.max(5000, ms));
const at = Date.parse(raw);
return Number.isFinite(at) && at > nowMs
? Math.min(3600000, Math.max(5000, at - nowMs))
: null;
}
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
export function createAdsbGnssSource({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
return {
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error(msg('GNSS snapshot needs a view anchor'));
const params = new URLSearchParams({
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
});
if (!response.ok) {
const limited = response.status === 429;
const retryAfterMs = retryDelay(
response.headers.get('retry-after'),
now(),
);
throw Object.assign(
new Error(
// HTTP errors are matched by FEED_ERROR_PATTERNS in ui/statusText.js.
limited
? msg('GNSS feed rate limited (HTTP 429)')
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
status: response.status,
retryable: limited || response.status >= 500,
retryAfterMs: retryAfterMs ?? (limited ? 60000 : null),
},
);
}
const payload = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error(msg('Malformed GNSS integrity snapshot'));
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error(msg('GNSS integrity snapshot has no observation time'));
return {
rows: payload.rows,
ageMs: payload.ageMs,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
};
},
};
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v2',
rows: [{ hex: 'abc123' }],
});
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v2',
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
assert.equal(stale.fetchedAt, 7);
assert.equal(stale.classifier, null);
// Without an observation age the rows could only be dated as new.
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
const at = Date.UTC(2026, 9, 13, 12);
for (const [status, header, delay, retryable] of [
[429, '3600', 3600000, true],
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
[429, null, 60000, true],
[429, 'Infinity', 60000, true],
[502, '5', 5000, true],
[502, null, null, true],
[400, null, null, false],
]) {
const source = createAdsbGnssSource({
now: () => at,
fetchImpl: async () =>
Response.json(
{},
{ status, headers: header ? { 'Retry-After': header } : {} },
),
});
await assert.rejects(
source.getSnapshot({ latitude: 50, longitude: 20 }),
(e) => {
assert.equal(e.status, status);
assert.equal(e.retryAfterMs, delay);
assert.equal(e.retryable, retryable);
assert.match(
e.message,
status === 429 ? /rate limited/ : /feed unavailable/,
);
return true;
},
);
}
});
@@ -0,0 +1,54 @@
// The GNSS source's own failures are English data (source.js); the
// card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { setLocale } from '../../i18n/index.js';
import { createGnssIntegrityLayer } from './index.js';
async function failedLayer(message) {
const layer = createGnssIntegrityLayer({
source: {
async getSnapshot() {
throw Object.assign(new Error(message), {
retryable: true,
retryAfterMs: 60_000,
});
},
},
viewAnchor: () => ({ latitude: 50, longitude: 30 }),
now: () => 1_000,
monotonicNow: () => 1_000,
});
layer.init({
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: { add() {}, remove() {} },
},
});
layer.enable();
await layer.update();
return layer;
}
for (const locale of ['es', 'ru']) {
test(`${locale}: the GNSS source failure is translated on the card`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
for (const message of [
'GNSS feed unavailable (HTTP 503)',
'GNSS feed rate limited (HTTP 429)',
]) {
const layer = await failedLayer(message);
setLocale(locale);
try {
const { info } = layer.getRowControls();
assert.ok(info.includes('HTTP'), info);
assert.doesNotMatch(info, /feed (unavailable|rate limited)/, info);
} finally {
setLocale('en');
layer.destroy();
}
}
});
}
+48
View File
@@ -0,0 +1,48 @@
import { t } from '../../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from '../../data/installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
/**
* An installation's card title: a mapped name as it is; the English label
* the data gives an unnamed feature ("Firing range", "Military area") in the
* UI language. The record keeps the English name.
* @param {string} name
* @returns {string} Empty when the record has no name.
*/
export function installationTitle(name) {
const text = String(name ?? '');
return UNNAMED_NAMES.has(text.trim()) ? t(text.trim()) : text;
}
/**
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
* the UI language. The class itself stays an English identifier in data.
* @param {string} klass
* @returns {string}
*/
export function installationClassLabel(klass) {
const key = String(klass || 'installation');
switch (key) {
case 'airfield':
return t('AIRFIELD', { context: 'installation class' });
case 'naval_base':
return t('NAVAL BASE', { context: 'installation class' });
case 'range':
return t('RANGE', { context: 'installation class' });
case 'military_land':
return t('MILITARY LAND', { context: 'installation class' });
case 'base':
return t('BASE', { context: 'installation class' });
case 'barracks':
return t('BARRACKS', { context: 'installation class' });
case 'training_area':
return t('TRAINING AREA', { context: 'installation class' });
case 'places_candidate':
return t('PLACES CANDIDATE', { context: 'installation class' });
case 'installation':
return t('INSTALLATION', { context: 'installation class' });
default:
return key.replaceAll('_', ' ').toUpperCase();
}
}
Loaded 100 of 1373 files, more files were not shown because too many files have changed in this diff. Show more