mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge upstream main and migrate cable changelog to fragment
This commit is contained in:
commit
4c0a8e3748
1020 files changed
+126461
-21563
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')),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
+34
-20
@@ -18,6 +18,7 @@ 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.
|
||||
@@ -275,7 +276,7 @@ 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'),
|
||||
);
|
||||
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
||||
} finally {
|
||||
@@ -293,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) {
|
||||
@@ -372,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,
|
||||
@@ -383,11 +387,12 @@ 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.',
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel:
|
||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
||||
Number.isInteger(state.onScreen)
|
||||
? ` · ${state.onScreen} on screen`
|
||||
: ''
|
||||
}`
|
||||
? 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,
|
||||
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
: 0,
|
||||
loadingLabel: state.loading
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
? t('retrying mapped ALPR cameras')
|
||||
: t('loading mapped ALPR cameras')
|
||||
: state.noCoverage
|
||||
? 'No ALPR data for this area — US and Canada only'
|
||||
? t('No ALPR data for this area — US and Canada only')
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
? t('Zoom in to load mapped cameras')
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.stale ? t('Showing cached locations') : '',
|
||||
state.saturated || state.renderSaturated
|
||||
? 'Coverage limited — zoom in'
|
||||
? t('Coverage limited — zoom in')
|
||||
: '',
|
||||
state.status === 'empty'
|
||||
? t('No ALPR data for this area')
|
||||
: '',
|
||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
||||
state.status === 'ready' && state.onScreen === 0
|
||||
? 'None on screen — nearby cameras are outside the view'
|
||||
? t('None on screen — nearby cameras are outside the view')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
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 {
|
||||
@@ -451,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: {
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} 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,
|
||||
@@ -136,7 +139,7 @@ export function createAlprTileSource({
|
||||
const metadata = await source.getMetadata(signal);
|
||||
const [west, south, east, north] = metadata.bounds || [];
|
||||
if (![west, south, east, north].every(Number.isFinite))
|
||||
throw new Error('Camera coverage unavailable');
|
||||
throw new Error(msg('Camera coverage unavailable'));
|
||||
if (
|
||||
box.east < west ||
|
||||
box.west > east ||
|
||||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
|
||||
for (const source of sources) source.clear();
|
||||
precision.clear();
|
||||
},
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: '© OpenStreetMap contributors, ODbL',
|
||||
@@ -242,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: [
|
||||
@@ -260,7 +263,7 @@ 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)',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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. */
|
||||
@@ -14,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
|
||||
: 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;
|
||||
@@ -56,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) {
|
||||
@@ -70,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function rowHtml(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = formatAwarenessCount(summary);
|
||||
const count = presentAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
@@ -78,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>${namedAreasHtml(item.memberNames)}</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(
|
||||
@@ -95,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>`;
|
||||
}
|
||||
|
||||
@@ -175,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
? `<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>`
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -183,11 +211,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
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);
|
||||
|
||||
@@ -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 }) {
|
||||
@@ -119,19 +131,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// 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 {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? `mapped matches within ${km} km of the subject`
|
||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
||||
};
|
||||
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 {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? 'mapped matches from the loaded viewport'
|
||||
: 'viewport feed is not a complete 250 km survey',
|
||||
};
|
||||
return withReason(
|
||||
summary,
|
||||
summary.count
|
||||
? msg('mapped matches from the loaded viewport')
|
||||
: msg('viewport feed is not a complete 250 km survey'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
@@ -137,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) {
|
||||
@@ -221,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -78,7 +92,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
label: t('Flights'),
|
||||
source: flightsState.stats.source || SOURCE_LABEL.flights,
|
||||
provenance: retainedProvenance(
|
||||
'flights',
|
||||
@@ -97,7 +111,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: 'Military flights',
|
||||
label: t('Military flights'),
|
||||
source: militaryState.stats.source || SOURCE_LABEL.military,
|
||||
provenance: retainedProvenance(
|
||||
'military',
|
||||
@@ -116,7 +130,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -131,7 +145,7 @@ 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'],
|
||||
@@ -142,7 +156,8 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
SOURCE_LABEL['military-installations'],
|
||||
),
|
||||
coverage:
|
||||
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
|
||||
localizeCoverageLabel(installationsState.stats.coverageLabel) ||
|
||||
t('CURRENT VIEWPORT ONLY'),
|
||||
summary: parts.queries.summarizeInstallationViewport(
|
||||
installations,
|
||||
installationsState,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+21
-4
@@ -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 }) {
|
||||
@@ -56,7 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
@@ -67,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');
|
||||
});
|
||||
@@ -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}`),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseStationStatus,
|
||||
toNonNegativeInteger,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
@@ -28,15 +29,31 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (value) => (value * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const p1 = toRad(aLat);
|
||||
const p2 = toRad(bLat);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 kept;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -44,6 +61,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
toNonNegativeInteger,
|
||||
stationKey,
|
||||
haversineKm,
|
||||
stationsWithinCity,
|
||||
extractStationsArray,
|
||||
parseStationInformation,
|
||||
parseStationStatus,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,193 @@ 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',
|
||||
},
|
||||
{
|
||||
id: 'madrid-bicimad',
|
||||
city: 'Madrid, Spain',
|
||||
centerLat: 40.4241,
|
||||
centerLon: -3.6825,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciMAD',
|
||||
},
|
||||
{
|
||||
id: 'valencia-valenbisi',
|
||||
city: 'Valencia, Spain',
|
||||
centerLat: 39.4706,
|
||||
centerLon: -0.3749,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
|
||||
provider: 'Valenbisi',
|
||||
},
|
||||
{
|
||||
id: 'zaragoza-bizi',
|
||||
city: 'Zaragoza, Spain',
|
||||
centerLat: 41.6481,
|
||||
centerLon: -0.8927,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bizi Zaragoza',
|
||||
},
|
||||
{
|
||||
id: 'sevilla-sevici',
|
||||
city: 'Seville, Spain',
|
||||
centerLat: 37.3726,
|
||||
centerLon: -5.9607,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
|
||||
provider: 'Sevici',
|
||||
},
|
||||
{
|
||||
id: 'valladolid-biki',
|
||||
city: 'Valladolid, Spain',
|
||||
centerLat: 41.6233,
|
||||
centerLon: -4.7382,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BIKI',
|
||||
},
|
||||
{
|
||||
id: 'a-coruna-bicicoruna',
|
||||
city: 'A Coruña, Spain',
|
||||
centerLat: 43.3534,
|
||||
centerLon: -8.4156,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciCoruña',
|
||||
},
|
||||
{
|
||||
id: 'donostia-dbizi',
|
||||
city: 'Donostia-San Sebastián, Spain',
|
||||
centerLat: 43.3075,
|
||||
centerLon: -1.9696,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Dbizi',
|
||||
},
|
||||
{
|
||||
id: 'bilbao-bilbaobizi',
|
||||
city: 'Bilbao, Spain',
|
||||
centerLat: 43.2624,
|
||||
centerLon: -2.9367,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bilbao Bizi',
|
||||
},
|
||||
{
|
||||
id: 'rivas-bicinrivas',
|
||||
city: 'Rivas-Vaciamadrid, Spain',
|
||||
centerLat: 40.3535,
|
||||
centerLon: -3.5319,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BicinRivas',
|
||||
},
|
||||
];
|
||||
function normalizeRegistryEntry(entry) {
|
||||
const id = String(entry?.id || '')
|
||||
@@ -346,11 +740,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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(' · '));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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 host, its stations farthest from the registered centre in
|
||||
// opposite directions, and the stations its live feed lists away from the
|
||||
// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
|
||||
// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
|
||||
const SYSTEMS = {
|
||||
'madrid-bicimad': {
|
||||
host: 'madrid.publicbikesystem.net',
|
||||
provider: 'BiciMAD',
|
||||
stations: [
|
||||
{
|
||||
name: '627 - Alcalde Andrés Madrid Dávila, 92',
|
||||
lat: 40.398303,
|
||||
lon: -3.548534,
|
||||
},
|
||||
{
|
||||
name: '672 - Estación de tren de El Barrial-Pozuelo',
|
||||
lat: 40.464908,
|
||||
lon: -3.807764,
|
||||
},
|
||||
],
|
||||
},
|
||||
'valencia-valenbisi': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Valenbisi',
|
||||
stations: [
|
||||
{ name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
|
||||
{ name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
|
||||
],
|
||||
},
|
||||
'zaragoza-bizi': {
|
||||
host: 'zaragoza.publicbikesystem.net',
|
||||
provider: 'Bizi Zaragoza',
|
||||
stations: [
|
||||
{ name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
|
||||
{ name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
|
||||
],
|
||||
},
|
||||
'sevilla-sevici': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Sevici',
|
||||
stations: [
|
||||
{ name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
|
||||
{ name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
|
||||
],
|
||||
strays: [
|
||||
{
|
||||
station_id: '298',
|
||||
name: '_LAB MADRID',
|
||||
lat: 40.448779,
|
||||
lon: -3.584983,
|
||||
},
|
||||
{ station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'valladolid-biki': {
|
||||
host: 'valladolid.publicbikesystem.net',
|
||||
provider: 'BIKI',
|
||||
stations: [
|
||||
{ name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
|
||||
{ name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
|
||||
],
|
||||
},
|
||||
'a-coruna-bicicoruna': {
|
||||
host: 'acoruna.publicbikesystem.net',
|
||||
provider: 'BiciCoruña',
|
||||
stations: [
|
||||
{ name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
|
||||
{ name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
|
||||
],
|
||||
strays: [
|
||||
{ station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
|
||||
{ station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
|
||||
{ station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'donostia-dbizi': {
|
||||
host: 'sansebastian.publicbikesystem.net',
|
||||
provider: 'Dbizi',
|
||||
stations: [
|
||||
{ name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
|
||||
{ name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
|
||||
],
|
||||
},
|
||||
'bilbao-bilbaobizi': {
|
||||
host: 'bilbao.publicbikesystem.net',
|
||||
provider: 'Bilbao Bizi',
|
||||
stations: [
|
||||
{ name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
|
||||
{ name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
|
||||
],
|
||||
},
|
||||
'rivas-bicinrivas': {
|
||||
host: 'rivas.publicbikesystem.net',
|
||||
provider: 'BicinRivas',
|
||||
stations: [
|
||||
{
|
||||
name: 'Polideportivo Parque Sureste',
|
||||
lat: 40.3236695,
|
||||
lon: -3.5127191,
|
||||
},
|
||||
{ name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
/** Station information for `id` through the real ingestion path. */
|
||||
async function loadThroughIngestion(id, stations) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
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 { data: { stations } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return ingestion.loadCityStationInfo(id, 1);
|
||||
}
|
||||
|
||||
test('the Spanish city systems 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, but station information is
|
||||
// trimmed to the radius as registered, so it has to reach every station.
|
||||
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 Madrid activates BiciMAD and BicinRivas', () => {
|
||||
// Puerta del Sol.
|
||||
const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
|
||||
assert.ok(active.has('madrid-bicimad'));
|
||||
assert.ok(active.has('rivas-bicinrivas'));
|
||||
});
|
||||
|
||||
test('stations a feed lists away from its city are not loaded', async () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
if (!expected.strays) continue;
|
||||
const local = expected.stations.map((station, index) => ({
|
||||
station_id: `local-${index}`,
|
||||
...station,
|
||||
}));
|
||||
const stations = await loadThroughIngestion(id, [
|
||||
...local,
|
||||
...expected.strays,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...stations.keys()],
|
||||
local.map((station) => station.station_id),
|
||||
`${id} keeps only its local stations`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('JCDecaux station names reach the layer without underscores', async () => {
|
||||
for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
|
||||
const stations = await loadThroughIngestion(
|
||||
id,
|
||||
SYSTEMS[id].stations.map((station, index) => ({
|
||||
station_id: String(index),
|
||||
...station,
|
||||
})),
|
||||
);
|
||||
for (const station of stations.values())
|
||||
assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
|
||||
}
|
||||
const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
|
||||
{ station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
|
||||
]);
|
||||
assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
|
||||
});
|
||||
|
||||
test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
|
||||
// Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
|
||||
const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
|
||||
const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
|
||||
{ station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
|
||||
]);
|
||||
assert.equal(bilbaoStations.get('1').capacity, 39);
|
||||
const [madrid] = SYSTEMS['madrid-bicimad'].stations;
|
||||
const madridStations = await loadThroughIngestion('madrid-bicimad', [
|
||||
{ station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
|
||||
]);
|
||||
assert.equal(madridStations.get('627').capacity, 27);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+44
-10
@@ -3,6 +3,7 @@ import {
|
||||
FRAME_SIGNATURE_H,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
PROJECTION_TEXTURE_SWAP_MS,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
@@ -12,6 +13,37 @@ import {
|
||||
PLACEHOLDER_REPAINT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Contain-fits a source frame into the projection canvas: scaled to the
|
||||
* largest size that fits, centered, with the margins left for the matte fill.
|
||||
* Falls back to the full canvas when either dimension is missing or zero.
|
||||
*
|
||||
* @param {number} sourceW - Source width in pixels.
|
||||
* @param {number} sourceH - Source height in pixels.
|
||||
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
|
||||
*/
|
||||
export function fitToProjection(sourceW, sourceH) {
|
||||
if (!sourceW || !sourceH)
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: PROJECTION_CANVAS_WIDTH,
|
||||
h: PROJECTION_CANVAS_HEIGHT,
|
||||
};
|
||||
const scale = Math.min(
|
||||
PROJECTION_CANVAS_WIDTH / sourceW,
|
||||
PROJECTION_CANVAS_HEIGHT / sourceH,
|
||||
);
|
||||
const w = Math.round(sourceW * scale);
|
||||
const h = Math.round(sourceH * scale);
|
||||
return {
|
||||
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
|
||||
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrames({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
||||
@@ -182,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||
g.addColorStop(0, '#05111a');
|
||||
g.addColorStop(1, '#01070c');
|
||||
g.addColorStop(1, PROJECTION_MATTE_COLOR);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
@@ -320,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||
// owner field tests (2026-07-04 and 2026-07-30).
|
||||
const signature = projectionFrameSignature(runtime);
|
||||
const fit = fitToProjection(
|
||||
runtime.image.naturalWidth,
|
||||
runtime.image.naturalHeight,
|
||||
);
|
||||
// Pixels are hashed at a fixed size, so the fit carries the aspect.
|
||||
const pixels = projectionFrameSignature(runtime);
|
||||
const signature =
|
||||
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
|
||||
runtime.drawnImageStamp = runtime.imageStamp;
|
||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||
// Identical pixels — leave the canvas, and therefore the bound
|
||||
@@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
}
|
||||
runtime.lastFrameSignature = signature;
|
||||
runtime.ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(
|
||||
runtime.image,
|
||||
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
|
||||
runtime.ctx.fillRect(
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
);
|
||||
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
|
||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||
runtime.lastPlaceholderPaintAt = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// src/layers/cctv/frames.test.mjs
|
||||
// Still-frame fit (#1201): stills draw at their source aspect, centered on the
|
||||
// 1920x1080 projection canvas, instead of being stretched to fill it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFrames, fitToProjection } from './frames.js';
|
||||
import {
|
||||
FRAME_SIGNATURE_W,
|
||||
FRAME_SIGNATURE_H,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
PROJECTION_MATTE_COLOR,
|
||||
} from './policy.js';
|
||||
|
||||
// The expected rectangles below are written for a 1920x1080 canvas.
|
||||
assert.equal(PROJECTION_CANVAS_WIDTH, 1920);
|
||||
assert.equal(PROJECTION_CANVAS_HEIGHT, 1080);
|
||||
|
||||
const FULL_CANVAS = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: PROJECTION_CANVAS_WIDTH,
|
||||
h: PROJECTION_CANVAS_HEIGHT,
|
||||
};
|
||||
|
||||
test('a 4:3 still is pillarboxed at full height', () => {
|
||||
assert.deepEqual(fitToProjection(640, 480), {
|
||||
x: 240,
|
||||
y: 0,
|
||||
w: 1440,
|
||||
h: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
test('a source wider than 16:9 is letterboxed at full width', () => {
|
||||
assert.deepEqual(fitToProjection(3840, 1080), {
|
||||
x: 0,
|
||||
y: 270,
|
||||
w: 1920,
|
||||
h: 540,
|
||||
});
|
||||
});
|
||||
|
||||
test('a 16:9 still fills the canvas', () => {
|
||||
assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS);
|
||||
});
|
||||
|
||||
test('a missing or zero dimension falls back to the full canvas', () => {
|
||||
assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS);
|
||||
assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS);
|
||||
});
|
||||
|
||||
// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the
|
||||
// fitted size as well as the pixels (the 64x36 hash alone drops aspect).
|
||||
function stillHarness() {
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
fillStyle: null,
|
||||
fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]),
|
||||
clearRect: () => {},
|
||||
drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]),
|
||||
};
|
||||
// Every frame downsamples to the same pixels, so only the size differs.
|
||||
const pixels = new Uint8ClampedArray(
|
||||
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||
).fill(7);
|
||||
const runtime = {
|
||||
mode: 'image',
|
||||
ctx,
|
||||
image: { naturalWidth: 0, naturalHeight: 0 },
|
||||
imageReady: true,
|
||||
// In flight, so refreshProjectionImage returns before touching image.src.
|
||||
imageLoading: true,
|
||||
imageStamp: 0,
|
||||
drawnImageStamp: -1,
|
||||
lastFrameSignature: null,
|
||||
canvasStamp: 0,
|
||||
signatureCtx: {
|
||||
clearRect: () => {},
|
||||
drawImage: () => {},
|
||||
getImageData: () => ({ data: pixels }),
|
||||
},
|
||||
};
|
||||
const record = { camera: { id: 'still' }, projection: runtime };
|
||||
const { drawProjectionFrame } = createFrames({
|
||||
state: { _healthById: new Map(), _activeCameraId: null },
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const load = (width, height) => {
|
||||
runtime.image = { naturalWidth: width, naturalHeight: height };
|
||||
runtime.imageStamp += 1;
|
||||
calls.length = 0;
|
||||
drawProjectionFrame(record);
|
||||
return calls.slice();
|
||||
};
|
||||
return { runtime, load };
|
||||
}
|
||||
|
||||
test('a still is drawn into its fitted rectangle over a full matte', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
assert.deepEqual(load(640, 480), [
|
||||
[
|
||||
'fillRect',
|
||||
PROJECTION_MATTE_COLOR,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
],
|
||||
['drawImage', 240, 0, 1440, 1080],
|
||||
]);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
});
|
||||
|
||||
test('same pixels at the same size skip the redraw', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(640, 480), []);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
});
|
||||
|
||||
test('same pixels at a new aspect redraw at the new fit', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(1920, 1080), [
|
||||
[
|
||||
'fillRect',
|
||||
PROJECTION_MATTE_COLOR,
|
||||
0,
|
||||
0,
|
||||
PROJECTION_CANVAS_WIDTH,
|
||||
PROJECTION_CANVAS_HEIGHT,
|
||||
],
|
||||
['drawImage', 0, 0, 1920, 1080],
|
||||
]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* @module headingConfidence
|
||||
*
|
||||
@@ -43,5 +45,7 @@ export function isHeadingEstimated(camera) {
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
|
||||
return isHeadingEstimated(camera)
|
||||
? t('HDG {value}° (ESTIMATED)', { value: hdg })
|
||||
: t('HDG {value}°', { value: hdg });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
|
||||
@@ -97,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;
|
||||
}
|
||||
@@ -298,12 +310,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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), '');
|
||||
});
|
||||
+24
-18
@@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920;
|
||||
|
||||
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
||||
|
||||
/** Matte behind fitted stills; the placeholder gradient's dark end. */
|
||||
export const PROJECTION_MATTE_COLOR = '#01070c';
|
||||
|
||||
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
||||
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
||||
// full-resolution compare would touch.
|
||||
@@ -179,7 +182,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 +194,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 +206,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 +219,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 +231,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 +244,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 +256,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 +268,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 +281,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 +293,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 +306,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 +318,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 +331,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 +343,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 +356,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 +368,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 +381,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 +393,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,
|
||||
@@ -410,6 +413,9 @@ export const IDLE_CAMERA_COLOR =
|
||||
export const ACTIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95);
|
||||
|
||||
export const LIVE_CAMERA_COLOR =
|
||||
Cesium.Color.fromCssColorString('#b48cff').withAlpha(0.88);
|
||||
|
||||
export const IDLE_COVERAGE_COLOR =
|
||||
Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24);
|
||||
|
||||
|
||||
@@ -1,6 +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,
|
||||
@@ -19,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(
|
||||
@@ -34,21 +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()}`,
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
`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()}`,
|
||||
// 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(' · ');
|
||||
@@ -86,6 +126,15 @@ export function createPresentation({
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
ACTIVE_COVERAGE_CENTER,
|
||||
IDLE_COVERAGE_CENTER_MUTED,
|
||||
ACTIVE_COVERAGE_CENTER_DEPTHFAIL,
|
||||
@@ -18,6 +19,15 @@ export function createRendering({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Icon color: active wins, then live-capable video feeds, then idle stills. */
|
||||
function cameraColorFor(record) {
|
||||
if (record.camera.id === layerState._activeCameraId)
|
||||
return ACTIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedType(record.camera.feedType))
|
||||
return LIVE_CAMERA_COLOR;
|
||||
return IDLE_CAMERA_COLOR;
|
||||
}
|
||||
|
||||
const {
|
||||
focusNowMs,
|
||||
getFocusTarget,
|
||||
@@ -58,10 +68,7 @@ export function createRendering({
|
||||
),
|
||||
cameraDistanceFor: (position) =>
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
baseColorFor: (record) =>
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR,
|
||||
baseColorFor: cameraColorFor,
|
||||
});
|
||||
layerState._activeFocusStyleCount = result.activeCount;
|
||||
}
|
||||
@@ -208,9 +215,7 @@ export function createRendering({
|
||||
for (const record of layerState._records) {
|
||||
const isActive = record.camera.id === activeId;
|
||||
if (record.billboard) {
|
||||
record.billboard.color = isActive
|
||||
? ACTIVE_CAMERA_COLOR
|
||||
: IDLE_CAMERA_COLOR;
|
||||
record.billboard.color = cameraColorFor(record);
|
||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// src/layers/cctv/rendering.test.mjs
|
||||
// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon
|
||||
// from the same base color the coverage refresh assigns, so a video-feed camera
|
||||
// stays violet while it dims instead of falling back to the idle cyan.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
LIVE_CAMERA_COLOR,
|
||||
} from './policy.js';
|
||||
import { isVideoFeedType } from '../../sources/cctvTypes.js';
|
||||
|
||||
const FOCUS_FACTOR = 0.5;
|
||||
|
||||
function assertFadedFrom(actual, base, label) {
|
||||
assert.ok(actual, `${label}: color written`);
|
||||
assert.deepEqual(
|
||||
[actual.red, actual.green, actual.blue],
|
||||
[base.red, base.green, base.blue],
|
||||
`${label}: hue`,
|
||||
);
|
||||
assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`);
|
||||
}
|
||||
|
||||
test('focus de-emphasis fades each icon from its own color', () => {
|
||||
const record = (id, feedType) => ({
|
||||
camera: { id, feedType },
|
||||
// No position, so the pass skips screen projection; the focus stub below
|
||||
// decides the factor on its own.
|
||||
billboard: { show: true },
|
||||
});
|
||||
const idleVideo = record('idle-hls', 'hls');
|
||||
const idleStill = record('idle-image', 'image');
|
||||
const activeVideo = record('active-hls', 'hls');
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_viewer: { scene: {}, camera: {} },
|
||||
_records: [idleVideo, idleStill, activeVideo],
|
||||
_activeCameraId: activeVideo.camera.id,
|
||||
_activeFocusStyleCount: 0,
|
||||
_lastFocusStyleAt: -Infinity,
|
||||
};
|
||||
const focus = {
|
||||
focusNowMs: (nowMs) => nowMs,
|
||||
getFocusTarget: () => ({}),
|
||||
focusPassIsNeeded: () => true,
|
||||
advanceSpriteFocus: () => ({
|
||||
factor: FOCUS_FACTOR,
|
||||
active: true,
|
||||
transitioning: false,
|
||||
}),
|
||||
focusAlphaNeedsWrite: () => true,
|
||||
};
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
source: {},
|
||||
});
|
||||
|
||||
refreshCctvFocusStyles(1000);
|
||||
|
||||
assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video');
|
||||
assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still');
|
||||
assertFadedFrom(
|
||||
activeVideo.billboard.color,
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
'active video',
|
||||
);
|
||||
assert.equal(state._activeFocusStyleCount, 3);
|
||||
});
|
||||
@@ -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,7 +57,7 @@ export function createCctvSource({
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
/** Read one current frame through the registered frame endpoint. */
|
||||
/** Read a registered frame and its provenance, including fallback frames. */
|
||||
async getFrame(camera, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(frameUrlFor(camera), {
|
||||
@@ -62,7 +68,12 @@ export function createCctvSource({
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
const sourceKind = response.headers.get('x-cctv-source');
|
||||
return {
|
||||
contentType: type.split(';')[0].trim().toLowerCase(),
|
||||
bytes,
|
||||
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* 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,
|
||||
@@ -7,13 +32,14 @@ export function attachCctvVideo(
|
||||
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' ? globalThis.crypto.randomUUID() : null;
|
||||
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
|
||||
const mediaUrl =
|
||||
feedType === 'hls'
|
||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
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, {
|
||||
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -14,23 +14,47 @@ 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 ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
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: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
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) ? CLASSIFICATION_NAMES[code] : code;
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
||||
? t(CLASSIFICATION_NAMES[code])
|
||||
: code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
value === null ? t('Unavailable') : formatQuantity(value, unit);
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
@@ -229,7 +253,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
error = next.reason || 'Cyclone advisories unavailable';
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
const applied = await rendering.setSnapshot(next, {
|
||||
@@ -254,7 +278,7 @@ export function createCyclonesLayer({
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Cyclone advisories unavailable';
|
||||
error = cause?.message || ADVISORIES_UNAVAILABLE;
|
||||
// Failed acquisition has no bounded last-good age guarantee at this layer.
|
||||
rendering?.clear();
|
||||
snapshot = null;
|
||||
@@ -316,41 +340,68 @@ export function createCyclonesLayer({
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
? t('Track and cone match this advisory')
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
? t('Track/cone awaiting advisory {number}', {
|
||||
number: storm.advisoryNumber,
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
errorText(error) ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
? 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
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
? t('{name} · {classification} · Advisory {number} · issued {time}', {
|
||||
name: storm.name,
|
||||
classification: classificationName(storm.classification),
|
||||
number: storm.advisoryNumber,
|
||||
time: utc(storm.issuedAt),
|
||||
})
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
? t('No active NHC/CPHC systems')
|
||||
: stormCount
|
||||
? activeStorms
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
? t('Loading advisories…')
|
||||
: t('Advisories unavailable');
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
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: 'Official advisory ↗',
|
||||
label: t('Official advisory ↗'),
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
@@ -360,12 +411,12 @@ export function createCyclonesLayer({
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
text: position,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
text: intensity,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
@@ -375,28 +426,36 @@ export function createCyclonesLayer({
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
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 ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
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: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
{
|
||||
label: t('Advisory center / forecast track'),
|
||||
color: '#7fe6ed',
|
||||
},
|
||||
{
|
||||
label: t('Center-track uncertainty cone'),
|
||||
color: '#7fe6ed44',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
? `${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;
|
||||
},
|
||||
|
||||
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
|
||||
1,
|
||||
);
|
||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
||||
assert.match(controls.info, /09-16 03:00 UTC/);
|
||||
// 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);
|
||||
|
||||
+386
-79
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -617,32 +943,11 @@ export function createDirectionsLayer({ services, source }) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -840,10 +1145,12 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_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;
|
||||
@@ -860,10 +1167,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// 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;
|
||||
@@ -1058,7 +1365,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
label: 'Directions',
|
||||
label: t('Directions'),
|
||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// 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)}`;
|
||||
@@ -19,15 +23,27 @@ export function createDirectionsSource({
|
||||
});
|
||||
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) {
|
||||
throw new Error(
|
||||
const refusal =
|
||||
typeof payload?.error === 'string' && payload.error
|
||||
? `${payload.error} — try again in a moment`
|
||||
: 'Routing is rate limited — try again in a moment',
|
||||
? 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 new Error(`Routing unavailable (HTTP ${response.status})`);
|
||||
throw Object.assign(
|
||||
new Error(`Routing unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status },
|
||||
);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+80
-17
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -329,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)}`,
|
||||
@@ -346,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,
|
||||
@@ -368,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;
|
||||
@@ -382,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,
|
||||
@@ -400,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'}`,
|
||||
@@ -477,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) {
|
||||
@@ -490,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. */
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -213,8 +219,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25;
|
||||
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
|
||||
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
|
||||
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
|
||||
// caches per-key on disk forever, negative results included, so repeat
|
||||
// sessions never re-hit adsbdb). Each key is requested at most once per
|
||||
// caches successful and 404 responses per key for 24 hours, so later sessions
|
||||
// may refresh them). Each key is requested at most once per
|
||||
// session. Priority jobs (tracked plane, model-eligible planes) jump the
|
||||
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -237,7 +243,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
|
||||
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
|
||||
// request rate (repeat sessions resolve instantly from the proxy's
|
||||
// permanent disk cache).
|
||||
// 24-hour disk cache).
|
||||
// Fail-silent by contract: the sweep never throws into the poll loop, never
|
||||
// blocks rendering, and never touches tracking state. When a type answer
|
||||
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
|
||||
@@ -251,7 +257,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
|
||||
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
|
||||
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
|
||||
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
|
||||
// the 4-concurrent limit + the proxy's 24-hour disk cache) is the REAL
|
||||
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
|
||||
// burst so a fresh region still classifies quickly.
|
||||
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
EMERGENCY_TINT,
|
||||
GROUND_SCALE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
@@ -54,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) {
|
||||
@@ -129,9 +152,7 @@ export function createRendering({
|
||||
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;
|
||||
}
|
||||
@@ -162,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
|
||||
@@ -1009,10 +1030,8 @@ export function createRendering({
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
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,
|
||||
|
||||
@@ -243,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Every class the bundled OSM names pack can put on a record has a
|
||||
// catalog entry, so the selected card shows it in the UI language.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { setLocale, getCatalog } from '../../i18n/index.js';
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
|
||||
const pack = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/local_data/osm_military_names/names.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
setLocale('en');
|
||||
const englishLabel = Object.fromEntries(
|
||||
pack.classes.map((c) => [c, installationClassLabel(c)]),
|
||||
);
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`every names-pack class is in the ${locale} catalog`, () => {
|
||||
const catalog = getCatalog(locale);
|
||||
const missing = pack.classes.filter(
|
||||
(c) => catalog[`installation class|${englishLabel[c]}`] === undefined,
|
||||
);
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`no ${locale} entry for: ${missing.join(', ')}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Vendored
+36
-7
@@ -1,10 +1,37 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { installationFeedback } from '../../data/installationFeedback.js';
|
||||
import { describeInstallationFeedback } from '../../data/installationFeedback.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
DISTANCE_PREFILTER_MARGIN_M,
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
|
||||
// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
|
||||
// hands back msg()-marked English plus values; getStats translates them here.
|
||||
function localizeInstallationFeedback(feedback) {
|
||||
if (feedback.text !== undefined) {
|
||||
const values = {};
|
||||
if (feedback.reason !== undefined) values.reason = t(feedback.reason);
|
||||
if (feedback.seconds !== undefined) values.seconds = feedback.seconds;
|
||||
return t(feedback.text, values);
|
||||
}
|
||||
const { count, km } = feedback;
|
||||
if (km) {
|
||||
const kmText = formatNumber(km);
|
||||
return count === 0
|
||||
? t('No mapped sites within {km} km of the contact', { km: kmText })
|
||||
: tn(
|
||||
count,
|
||||
'{count} mapped site within {km} km of the contact',
|
||||
'{count} mapped sites within {km} km of the contact',
|
||||
{ km: kmText },
|
||||
);
|
||||
}
|
||||
return count === 0
|
||||
? t('No mapped sites in view')
|
||||
: tn(count, '{count} mapped site in view', '{count} mapped sites in view');
|
||||
}
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
@@ -225,13 +252,15 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
retryAt: layerState.retryAt,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
failureReason: layerState.failureReason,
|
||||
statusMessage: installationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
statusMessage: localizeInstallationFeedback(
|
||||
describeInstallationFeedback({
|
||||
...layerState,
|
||||
count: layerState.lastUpdate ? mappedCount : undefined,
|
||||
retrying: layerState.loading && Boolean(layerState.failureReason),
|
||||
}),
|
||||
),
|
||||
loadingLabel: layerState.loading
|
||||
? 'loading mapped installation context'
|
||||
? t('loading mapped installation context')
|
||||
: '',
|
||||
coverage: layerState.coverage,
|
||||
coverageLabel:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { militaryOsmKey } from '../../data/militaryNames.js';
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
/** Keep named point coverage until its matching polygon takes over. */
|
||||
export function retainNamedHandoff(previous, records, box) {
|
||||
const named = new Map(
|
||||
@@ -133,15 +134,16 @@ export function createIngestion({
|
||||
let placesError = null;
|
||||
if (layerState.googleSearchRequested) {
|
||||
layerState.googleSearchRequested = false;
|
||||
// A view across 180° has west > east; its width runs through 180°.
|
||||
const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0);
|
||||
const latitude = (box.south + box.north) / 2;
|
||||
const longitude = (box.west + box.east) / 2;
|
||||
let longitude = box.west + lonSpan / 2;
|
||||
if (longitude > 180) longitude -= 360;
|
||||
const radiusM = Math.min(
|
||||
50000,
|
||||
Math.max(
|
||||
1000,
|
||||
Math.round(
|
||||
Math.max(box.north - box.south, box.east - box.west) * 55_000,
|
||||
),
|
||||
Math.round(Math.max(box.north - box.south, lonSpan) * 55_000),
|
||||
),
|
||||
);
|
||||
try {
|
||||
@@ -255,7 +257,7 @@ export function createIngestion({
|
||||
payload.status === 'stale'
|
||||
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
|
||||
: saturated
|
||||
? 'Too many mapped sites in view to list them all'
|
||||
? msg('Too many mapped sites in view to list them all')
|
||||
: placesError,
|
||||
);
|
||||
parts.rendering.renderRecords();
|
||||
@@ -291,7 +293,7 @@ export function createIngestion({
|
||||
layerState.failureReason = error?.failureReason || 'unavailable';
|
||||
setInstallationStatus(
|
||||
'unavailable',
|
||||
error?.message || 'Installation context unavailable',
|
||||
error?.message || msg('Installation context unavailable'),
|
||||
);
|
||||
if (!isUnavailableCapability(error))
|
||||
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
|
||||
// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view
|
||||
// is 0.4° wide and 0.2° tall; the last one does not cross 180°.
|
||||
for (const [west, east, centre] of [
|
||||
[179.7, -179.9, 179.9],
|
||||
[179.9, -179.7, -179.9],
|
||||
[179.5, 179.9, 179.7],
|
||||
])
|
||||
test(`a nearby search is centred on the view at ${centre}`, async () => {
|
||||
const box = { south: -17, west, north: -16.8, east };
|
||||
const searches = [];
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {},
|
||||
records: [],
|
||||
googleSearchRequested: true,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source: {
|
||||
getMappedSites: async () => ({ records: [], wide: true }),
|
||||
searchNearby: async (query) => {
|
||||
searches.push(query);
|
||||
return { places: [] };
|
||||
},
|
||||
},
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
parts: {
|
||||
viewport: {
|
||||
loadArea: () => ({ box, coverage: {} }),
|
||||
clearUnavailableRetry() {},
|
||||
scheduleUnavailableRetry() {},
|
||||
},
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords() {}, warmInstallationFloors() {} },
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(searches.length, 1);
|
||||
const { latitude, longitude, radiusM } = searches[0];
|
||||
assert.ok(Math.abs(latitude + 16.9) < 1e-9);
|
||||
assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`);
|
||||
// The width, not the height, sets the radius.
|
||||
assert.equal(radiusM, 22000);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../../i18n/index.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function stats(state) {
|
||||
const layerState = { records: [], enabled: true, ...state };
|
||||
return createControls({
|
||||
state: layerState,
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
}).methods.getStats();
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: installation feedback is in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
const empty = stats({ status: 'empty', lastUpdate: 1 }).statusMessage;
|
||||
assert.notEqual(empty, 'No mapped sites in view');
|
||||
const found = stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
records: [{ kind: 'installation' }, { kind: 'installation' }],
|
||||
}).statusMessage;
|
||||
assert.notEqual(found, '2 mapped sites in view');
|
||||
const tiles = stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'tiles_unavailable',
|
||||
}).statusMessage;
|
||||
assert.notEqual(tiles, 'Map tiles temporarily unavailable');
|
||||
const retry = stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'names_unavailable',
|
||||
retryAt: Date.now() + 5000,
|
||||
}).statusMessage;
|
||||
assert.doesNotMatch(retry, /^Mapped names temporarily unavailable/);
|
||||
for (const text of [
|
||||
'Too many mapped sites in view to list them all',
|
||||
'Installation context unavailable',
|
||||
]) {
|
||||
assert.notEqual(translateKnown(text), text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: installation feedback keeps its English wording', () => {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
stats({ status: 'empty', lastUpdate: 1 }).statusMessage,
|
||||
'No mapped sites in view',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
records: [{ kind: 'installation' }],
|
||||
}).statusMessage,
|
||||
'1 mapped site in view',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'ready',
|
||||
lastUpdate: 1,
|
||||
wide: true,
|
||||
coverage: { kind: 'subject', radiusM: 25000 },
|
||||
records: [{ kind: 'installation' }, { kind: 'installation' }],
|
||||
}).statusMessage,
|
||||
'2 mapped sites within 25 km of the contact',
|
||||
);
|
||||
assert.equal(
|
||||
stats({
|
||||
status: 'unavailable',
|
||||
failureReason: 'names_unavailable',
|
||||
retryAt: Date.now() + 5000,
|
||||
}).statusMessage,
|
||||
'Mapped names temporarily unavailable — retrying in 5s',
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installationClassLabel, installationTitle } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
@@ -162,7 +163,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
id: record.id,
|
||||
source: LAYER_ID,
|
||||
position,
|
||||
title: record.name,
|
||||
title: installationTitle(record.name),
|
||||
priority: record.areaM2 || 0,
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
}),
|
||||
@@ -177,14 +178,12 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
marker.entry.position = position;
|
||||
marker.entry.title = record.name;
|
||||
marker.entry.title = installationTitle(record.name);
|
||||
marker.entry.priority = record.areaM2 || 0;
|
||||
entries.push(marker.entry);
|
||||
ordered.push(marker);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { installationClassLabel, installationTitle } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
@@ -270,11 +272,9 @@ export function createRendering({
|
||||
entity.gevTrackedId = `installations:${record.id}`;
|
||||
entity.gevDisplayPosition = () => displayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || 'MAPPED INSTALLATION',
|
||||
title: installationTitle(record.name) || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
|
||||
import { UNNAMED_MILITARY_AREA } from '../../sources/openFreeMap.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { installationTitle } from './classLabel.js';
|
||||
import { formatAwarenessLabel } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
// Unnamed installations get an English label in data ("Firing range",
|
||||
// "Military land", "Military airfield"; OpenFreeMap polygons: "Military area").
|
||||
// The data stays English; the map card title reads in the UI language.
|
||||
const { records } = normalizeMilitaryInstallations({
|
||||
elements: [
|
||||
{ type: 'node', id: 1, lat: 1, lon: 1, tags: { military: 'range' } },
|
||||
{
|
||||
type: 'way',
|
||||
id: 2,
|
||||
center: { lat: 2, lon: 2 },
|
||||
tags: { landuse: 'military' },
|
||||
},
|
||||
{ type: 'node', id: 3, lat: 3, lon: 3, tags: { military: 'airfield' } },
|
||||
{ type: 'node', id: 4, lat: 4, lon: 4, tags: { military: 'naval_base' } },
|
||||
],
|
||||
});
|
||||
const names = [...records.map((r) => r.name), UNNAMED_MILITARY_AREA];
|
||||
|
||||
test('unnamed installations keep English names in data', () => {
|
||||
assert.deepEqual(names, [
|
||||
'Firing range',
|
||||
'Military land',
|
||||
'Military airfield',
|
||||
'Naval base',
|
||||
'Military area',
|
||||
]);
|
||||
});
|
||||
|
||||
test('unnamed installation card title reads in the UI language', () => {
|
||||
try {
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const name of names) {
|
||||
const shown = installationTitle(name);
|
||||
assert.ok(shown, `${locale}: "${name}" has a title`);
|
||||
assert.notEqual(
|
||||
shown,
|
||||
name,
|
||||
`${locale}: "${name}" is shown in English`,
|
||||
);
|
||||
}
|
||||
// A mapped name is shown as it is.
|
||||
assert.equal(installationTitle('Fort Bragg'), 'Fort Bragg');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('English card titles are unchanged', () => {
|
||||
setLocale('en');
|
||||
for (const name of names) assert.equal(installationTitle(name), name);
|
||||
assert.equal(installationTitle(''), '');
|
||||
});
|
||||
|
||||
test('the Contacts line names an unnamed installation in the UI language', () => {
|
||||
try {
|
||||
setLocale('en');
|
||||
for (const name of names)
|
||||
assert.equal(formatAwarenessLabel({ id: 'osm-1', name }), name);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const name of names)
|
||||
assert.equal(
|
||||
formatAwarenessLabel({ id: 'osm-1', name }),
|
||||
installationTitle(name),
|
||||
);
|
||||
assert.equal(
|
||||
formatAwarenessLabel({ id: 'osm-2', name: 'Fort Bragg' }),
|
||||
'Fort Bragg',
|
||||
);
|
||||
assert.equal(formatAwarenessLabel({ callsign: 'RCH123 ' }), 'RCH123');
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
Vendored
+1
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -168,9 +168,16 @@ export function createIngestion({
|
||||
async function performMissionUpdate(token) {
|
||||
try {
|
||||
ensureActiveTleLookup(token);
|
||||
const payload = await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
});
|
||||
const { payload, stale } = source.getLaunchSnapshot
|
||||
? await source.getLaunchSnapshot({
|
||||
signal: layerState._sourceController.signal,
|
||||
})
|
||||
: {
|
||||
payload: await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
}),
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -214,6 +221,7 @@ export function createIngestion({
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function fixture({ legacySource = false } = {}) {
|
||||
const launch = {
|
||||
id: 'owned-launch',
|
||||
name: 'Owned mission fixture',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
const mode = { cache: 'HIT', results: [launch], hold: false };
|
||||
let arrival;
|
||||
let release;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
assert.equal(req.url, '/api/launches');
|
||||
const send = () => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GEV-Cache': mode.cache,
|
||||
});
|
||||
res.end(JSON.stringify({ results: mode.results }));
|
||||
};
|
||||
if (mode.hold) {
|
||||
release = send;
|
||||
arrival?.();
|
||||
} else send();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const launchSource = createLaunchSource({
|
||||
fetchImpl: (url, options) => {
|
||||
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
|
||||
return fetch(new URL(url, origin), options);
|
||||
},
|
||||
});
|
||||
const source = legacySource
|
||||
? {
|
||||
getLaunches: launchSource.getLaunches,
|
||||
getActiveTle: launchSource.getActiveTle,
|
||||
}
|
||||
: launchSource;
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
panel: {
|
||||
createMissionPanel: noop,
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
overlays: {
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.ok(value.stats.lastUpdate);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh recovery clears stale launch provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
const stale = f.current();
|
||||
f.mode.cache = 'MISS';
|
||||
await f.update();
|
||||
const recovered = f.current();
|
||||
assert.equal(recovered.feed, 'nominal');
|
||||
assert.equal(recovered.stats.count, 1);
|
||||
assert.equal(recovered.stats.error, null);
|
||||
assert.equal(stale.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty stale snapshot still identifies its fallback provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.results = [];
|
||||
await f.update();
|
||||
assert.equal(f.current().stats.count, 0);
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('getLaunches-only injected sources remain supported', async () => {
|
||||
const f = await fixture({ legacySource: true });
|
||||
try {
|
||||
await f.update();
|
||||
assert.equal(f.current().feed, 'nominal');
|
||||
assert.deepEqual(f.current().rendered, ['owned-launch']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending stale response without committing it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const healthy = f.current();
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.hold = true;
|
||||
const arrived = f.holdArrival();
|
||||
const pending = f.update();
|
||||
await arrived;
|
||||
await f.disable();
|
||||
f.release();
|
||||
await pending;
|
||||
const value = f.current();
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(f.state._sourceController.signal.aborted, true);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('hiding retained stale records does not relabel them as fresh', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.disable();
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
assert.equal(f.current().stats.count, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy clears the previous feed state and records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.enabled, false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
await f.init();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.equal(
|
||||
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
|
||||
'function',
|
||||
);
|
||||
await f.destroy();
|
||||
assert.equal(click.isDestroyed(), true);
|
||||
assert.equal(move.isDestroyed(), true);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export function createLifecycle({
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
@@ -160,6 +161,7 @@ export function createLifecycle({
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._activeTleText = null;
|
||||
layerState._activeTlePromise = null;
|
||||
layerState._activeTlePromiseToken = 0;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// A launch without a Launch Library status gets our 'Unknown'. The record keeps
|
||||
// it English; the mission panel translates it and shows provider statuses as sent.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
launchStatusAllowsOrbit,
|
||||
normalizeRocketLaunches,
|
||||
} from '../../data/rocketLaunches.js';
|
||||
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
|
||||
import { createPanel } from './panel.js';
|
||||
|
||||
const NOW = new Date('2026-07-27T00:00:00Z');
|
||||
|
||||
function shownStatus(status) {
|
||||
const outputs = new Map();
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector(selector) {
|
||||
if (!outputs.has(selector))
|
||||
outputs.set(selector, {
|
||||
textContent: '',
|
||||
closest: () => null,
|
||||
removeAttribute() {},
|
||||
});
|
||||
return outputs.get(selector);
|
||||
},
|
||||
closest: () => null,
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [
|
||||
{
|
||||
id: 'mission-1',
|
||||
name: 'Example',
|
||||
status,
|
||||
payloads: [],
|
||||
recoveryStages: [],
|
||||
},
|
||||
],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
},
|
||||
}).renderMissionPanel();
|
||||
return outputs.get('[data-mission-status]').textContent;
|
||||
}
|
||||
|
||||
test('a launch without a status keeps the English fallback in its record', () => {
|
||||
const [launch] = normalizeRocketLaunches(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
id: 'no-status',
|
||||
name: 'Test Flight',
|
||||
net: '2026-07-20T10:00:00Z',
|
||||
pad: {
|
||||
name: 'Pad A',
|
||||
location: { name: 'Test Range', coordinates: '12.5,45.5' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
assert.equal(UNKNOWN_LAUNCH_STATUS, 'Unknown');
|
||||
assert.equal(launch.status, 'Unknown');
|
||||
assert.equal(launchStatusAllowsOrbit(launch.status), true);
|
||||
});
|
||||
|
||||
test('the mission panel translates only the fallback status', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('en');
|
||||
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Unknown');
|
||||
setLocale('ru');
|
||||
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Неизвестно');
|
||||
assert.equal(shownStatus('Go for Launch'), 'Go for Launch');
|
||||
setLocale('es');
|
||||
assert.equal(shownStatus(UNKNOWN_LAUNCH_STATUS), 'Desconocido');
|
||||
assert.equal(shownStatus('Launch Successful'), 'Launch Successful');
|
||||
});
|
||||
@@ -1,8 +1,18 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { WINDOW_DAYS } from './policy.js';
|
||||
|
||||
/**
|
||||
* Fallback when Launch Library sends no status. English in the record
|
||||
* (launchStatusAllowsOrbit, analyst records); the mission panel translates it.
|
||||
*/
|
||||
export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
function finiteCoordinate(value) {
|
||||
// Number(null), Number('') and Number(false) are 0: absent is not zero.
|
||||
if (value == null || typeof value === 'boolean') return null;
|
||||
if (typeof value === 'string' && value.trim() === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
@@ -177,22 +187,18 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
const pad = launch.pad || {};
|
||||
const location = pad.location || {};
|
||||
const coordinates = location.coordinates || '';
|
||||
const [coordinateLon, coordinateLat] = String(coordinates)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
const lat = Number.isFinite(Number(pad.latitude))
|
||||
? Number(pad.latitude)
|
||||
: coordinateLat;
|
||||
const lon = Number.isFinite(Number(pad.longitude))
|
||||
? Number(pad.longitude)
|
||||
: coordinateLon;
|
||||
const [coordinateLon, coordinateLat] = String(coordinates).split(',');
|
||||
const lat =
|
||||
finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
|
||||
const lon =
|
||||
finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
|
||||
const payloads = normalizePayloadFlights(launch);
|
||||
return {
|
||||
id: String(
|
||||
launch.id || launch.slug || launch.name || `launch-${date}`,
|
||||
),
|
||||
name: launch.name || 'Unnamed launch',
|
||||
status: launch.status?.name || 'Unknown',
|
||||
status: launch.status?.name || UNKNOWN_LAUNCH_STATUS,
|
||||
launchTime: Number.isFinite(date)
|
||||
? new Date(date).toISOString()
|
||||
: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
@@ -119,8 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const details = selected
|
||||
? [
|
||||
siteName
|
||||
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
|
||||
: 'LAUNCH SITE',
|
||||
? t('LAUNCH SITE · {site}', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('LAUNCH SITE'),
|
||||
]
|
||||
: [];
|
||||
return {
|
||||
@@ -586,27 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
|
||||
const siteName = compactLaunchSiteName(launch.launchSite);
|
||||
const siteCallout = siteName
|
||||
? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}`
|
||||
: 'LAUNCH SITE';
|
||||
? t('LAUNCH SITE · {site}', {
|
||||
site: shortMissionLabel(siteName, 20).toUpperCase(),
|
||||
})
|
||||
: t('LAUNCH SITE');
|
||||
let title = mission;
|
||||
let detail = siteCallout;
|
||||
if (mode === 'countdown') {
|
||||
title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`;
|
||||
detail = `LAUNCH STANDBY\n${siteCallout}`;
|
||||
title = t('T−{time} · {mission}', {
|
||||
time: String(state.countdownSeconds).padStart(2, '0'),
|
||||
mission,
|
||||
});
|
||||
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
|
||||
} else if (mode === 'ascent') {
|
||||
title =
|
||||
state.elapsedSinceStart < 1
|
||||
? `LIFTOFF · ${mission}`
|
||||
: `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`;
|
||||
? t('LIFTOFF · {mission}', { mission })
|
||||
: t(
|
||||
launch.trajectory.length > 1
|
||||
? msg('ASCENT REPLAY · {mission}')
|
||||
: msg('ASCENT ESTIMATE · {mission}'),
|
||||
{ mission },
|
||||
);
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'recovery') {
|
||||
title = `STAGE RE-ENTRY / RECOVERY · ${mission}`;
|
||||
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
} else if (mode === 'orbit') {
|
||||
title = `ORBIT REPLAY · ${mission}`;
|
||||
title = t('ORBIT REPLAY · {mission}', { mission });
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
}
|
||||
if (layerState._replayPaused) title = `PAUSED · ${title}`;
|
||||
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
|
||||
const nextText = `${title}\n${detail}`;
|
||||
if (nextText !== layerState._replayVehicleOverlayText) {
|
||||
layerState._replayVehicleOverlayText = nextText;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
MISSION_CLOSE_VIEW_RANGE_M,
|
||||
MISSION_GLOBE_VIEW_RANGE_M,
|
||||
} from './policy.js';
|
||||
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
@@ -190,7 +192,14 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
|
||||
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
|
||||
setMissionPanelField('[data-mission-provider]', launch.provider);
|
||||
setMissionPanelField('[data-mission-status]', launch.status);
|
||||
// Launch Library status names are provider data, shown as sent; only the
|
||||
// fallback for a launch without one is ours to translate.
|
||||
setMissionPanelField(
|
||||
'[data-mission-status]',
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
);
|
||||
setMissionPanelField(
|
||||
'[data-mission-site]',
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
@@ -219,34 +228,42 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || 'UNSPECIFIED')}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}</td></tr>`;
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">+${launch.payloads.length - 5} additional payload records</td></tr>`,
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-payloads]',
|
||||
).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE');
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('PAYLOAD DATA UNAVAILABLE'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: 'UNAVAILABLE');
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE`
|
||||
: 'POSITION UNAVAILABLE';
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? `FLIGHT ${stage.flightNumber}`
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? 'REUSED' : null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -254,7 +271,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA');
|
||||
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
@@ -281,7 +298,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const count = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count) count.textContent = `${layerState._launches.length} / 30D`;
|
||||
if (count)
|
||||
count.textContent = t('{count} / 30D', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
@@ -289,8 +309,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML =
|
||||
'<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
@@ -303,12 +322,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
list.innerHTML = entries
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
|
||||
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
|
||||
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
|
||||
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
@@ -365,7 +384,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM`
|
||||
? t('{value} KM', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
@@ -374,7 +397,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S`
|
||||
? t('{value} KM/S', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
|
||||
@@ -450,16 +478,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
'Selected Space Mission',
|
||||
t('Selected Space Mission'),
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>SELECTED SPACE MISSION</span><button type="button" data-mission-close title="Show all missions" aria-label="Deselect mission">×</button></div><div class="space-mission-detail"><strong data-mission-title>MISSION</strong><span data-mission-field data-mission-provider></span><span data-mission-field>STATUS · <b data-mission-status></b></span><span data-mission-field>LAUNCH SITE · <b data-mission-site></b></span><span data-mission-field>LAUNCH TIME · <b data-mission-time></b></span><span data-mission-field>ORBIT · <b data-mission-orbit></b></span><span>ASCENT PATH · <b data-mission-ascent-source></b></span><span data-mission-field>CURRENT DISTANCE FROM EARTH · <b data-mission-distance></b></span><span data-mission-field>SATELLITE SPEED · <b data-mission-speed></b></span></div><section class="mission-data-section"><h4>PAYLOAD</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>NAME</th><th>TYPE</th><th>DESTINATION</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>STAGE / RE-ENTRY / RECOVERY</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>STAGE</th><th>STATUS</th><th>FINAL POSITION</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">REPLAY SPEED</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="Replay speed multiplier"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>FOCUS</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">REPLAY ASCENT</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="Previous mission"><span aria-hidden="true">‹</span> PREV</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="Next mission">NEXT <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>SHOW ALL / DESELECT</button>`;
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="Pause replay" aria-label="Pause replay">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="Cancel replay" aria-label="Cancel replay"><span aria-hidden="true">×</span></button>
|
||||
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
EARTH_ROTATION_RAD_PER_SEC,
|
||||
PROJECTED_ASCENT_ROTATION_SEC,
|
||||
@@ -521,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(lat),
|
||||
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
|
||||
accuracy: 'EST. DOWNRANGE',
|
||||
accuracy: t('EST. DOWNRANGE'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
SATELLITE_STANDALONE_DEFAULTS,
|
||||
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
|
||||
@@ -76,14 +77,14 @@ export function createPolicyHelpers({
|
||||
: 0;
|
||||
return {
|
||||
orbit: orbitName
|
||||
? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}`
|
||||
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
|
||||
: null,
|
||||
ascent:
|
||||
suppliedTrajectoryPoints > 1
|
||||
? 'SUPPLIED TRAJECTORY POINTS'
|
||||
? t('SUPPLIED TRAJECTORY POINTS')
|
||||
: replayAvailable
|
||||
? 'RECONSTRUCTED ESTIMATE'
|
||||
: 'UNAVAILABLE',
|
||||
? t('RECONSTRUCTED ESTIMATE')
|
||||
: t('UNAVAILABLE'),
|
||||
replayAvailable: Boolean(replayAvailable),
|
||||
};
|
||||
}
|
||||
@@ -196,7 +197,8 @@ export function createPolicyHelpers({
|
||||
|
||||
function formatMissionEventTime(launchTime) {
|
||||
const date = new Date(launchTime);
|
||||
if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE';
|
||||
if (!launchTime || !Number.isFinite(date.getTime()))
|
||||
return t('UNAVAILABLE');
|
||||
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
REPLAY_ASCENT_FALLBACK_SEC,
|
||||
REPLAY_ASCENT_MIN_SEC,
|
||||
@@ -200,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
if (speedControl) speedControl.hidden = !replayAvailable;
|
||||
button.hidden = active || !replayAvailable;
|
||||
button.disabled = !replayAvailable;
|
||||
button.textContent = 'REPLAY ASCENT';
|
||||
button.textContent = t('REPLAY ASCENT');
|
||||
button.classList.remove('active');
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.title = 'Replay the estimated ascent with a following camera';
|
||||
button.title = t('Replay the estimated ascent with a following camera');
|
||||
if (transport) {
|
||||
transport.hidden = !active;
|
||||
transport.classList.toggle(
|
||||
@@ -218,11 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
|
||||
toggleButton.setAttribute(
|
||||
'aria-label',
|
||||
layerState._replayPaused ? 'Resume replay' : 'Pause replay',
|
||||
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
|
||||
);
|
||||
toggleButton.title = layerState._replayPaused
|
||||
? 'Resume replay'
|
||||
: 'Pause replay';
|
||||
? t('Resume replay')
|
||||
: t('Pause replay');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,17 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
if (!transport || !layerState._replayCameraLaunchId) return;
|
||||
const phase = state.countdownActive
|
||||
? `T minus ${state.countdownSeconds}`
|
||||
? t('T minus {seconds}', {
|
||||
seconds: state.countdownSeconds,
|
||||
})
|
||||
: state.preCountdownActive
|
||||
? 'Preparing launch site'
|
||||
? t('Preparing launch site')
|
||||
: state.elapsedSinceStart < 1
|
||||
? 'Liftoff'
|
||||
? t('Liftoff')
|
||||
: state.ascending
|
||||
? 'Ascent replay'
|
||||
: 'Orbit replay';
|
||||
? t('Ascent replay')
|
||||
: t('Orbit replay');
|
||||
transport.setAttribute(
|
||||
'aria-label',
|
||||
`${phase}${layerState._replayPaused ? ', paused' : ''}`,
|
||||
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// A portable export (package.json): no i18n here. Failures stay English data;
|
||||
// the Layers panel shows them translated.
|
||||
const msg = (text) => text;
|
||||
|
||||
/** Read launch records and their optional active-orbit catalog with explicit cancellation. */
|
||||
export function createLaunchSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -10,7 +14,7 @@ export function createLaunchSource({
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||
throw new Error('Malformed launch snapshot');
|
||||
throw new Error(msg('Malformed launch snapshot'));
|
||||
return {
|
||||
payload,
|
||||
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// The Local ADS-B click card follows the UI language; English keeps its
|
||||
// wording (index.test.mjs pins the full English card).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { localAdsbCardModel, localAdsbSourceText } from './card.js';
|
||||
|
||||
const record = {
|
||||
icao: 'abc123',
|
||||
callsign: '',
|
||||
altitudeFt: 12500,
|
||||
groundSpeedKt: 240,
|
||||
trackDeg: 90,
|
||||
verticalRateFpm: -1200,
|
||||
lastPositionAt: 1000,
|
||||
messageCount: 48,
|
||||
bands: ['1090'],
|
||||
sources: ['webusb', 'feed'],
|
||||
};
|
||||
|
||||
test('en: the local ADS-B card keeps its English wording', () => {
|
||||
setLocale('en');
|
||||
assert.deepEqual(localAdsbCardModel(record, 5000).details, [
|
||||
'ICAO ABC123 · NO CALLSIGN',
|
||||
'ALT 12,500 FT · GS 240 KT · TRK 90°',
|
||||
'V/S -1,200 FPM',
|
||||
'POSITION 4 S AGO · 48 MSGS',
|
||||
'Heard by your receiver · 1090 MHz · browser SDR + decoder feed',
|
||||
]);
|
||||
assert.equal(
|
||||
localAdsbSourceText(record),
|
||||
'Your RTL-SDR receiver and decoder feed',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the local ADS-B card is in the UI language`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const text = [
|
||||
...localAdsbCardModel(record, 5000).details,
|
||||
localAdsbSourceText(record),
|
||||
].join(' | ');
|
||||
for (const english of [
|
||||
'NO CALLSIGN',
|
||||
'GS ',
|
||||
'POSITION',
|
||||
'AGO',
|
||||
'MSGS',
|
||||
'Heard by',
|
||||
'browser SDR',
|
||||
'decoder feed',
|
||||
'Your RTL-SDR',
|
||||
'12,500',
|
||||
])
|
||||
assert.ok(
|
||||
!text.includes(english),
|
||||
`${locale}: "${english}" in ${text}`,
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('the class line names the aircraft class in the UI language', async () => {
|
||||
const { localAdsbClassLine } = await import('./card.js');
|
||||
const { AIRCRAFT_CLASS_LABELS } = await import('../../data/aircraftClass.js');
|
||||
const heli = { category: 'A7' };
|
||||
try {
|
||||
setLocale('en');
|
||||
assert.equal(
|
||||
localAdsbClassLine(heli, { klass: 'helicopter', evidence: true }),
|
||||
'Helicopter · A7',
|
||||
);
|
||||
for (const locale of ['es', 'ru']) {
|
||||
setLocale(locale);
|
||||
for (const [klass, label] of Object.entries(AIRCRAFT_CLASS_LABELS)) {
|
||||
const line = localAdsbClassLine({}, { klass, evidence: true });
|
||||
assert.ok(line, `${locale}: ${klass} has a class line`);
|
||||
assert.notEqual(line, label, `${locale}: "${label}" is in English`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
// The receiver bands follow the UI language too ("1090 МГц").
|
||||
test('ru: the receiver line names the bands in Russian', async () => {
|
||||
const { localAdsbReceiverLine } = await import('./card.js');
|
||||
setLocale('ru');
|
||||
try {
|
||||
const line = localAdsbReceiverLine({ ...record, bands: ['1090', '978'] });
|
||||
assert.match(line, /· 1090 МГц \+ 978 МГц UAT ·/);
|
||||
assert.doesNotMatch(line, /MHz/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
BAND_LABELS,
|
||||
HEARD_BY_RECEIVER,
|
||||
LOCAL_ADSB_COLOR,
|
||||
SOURCE_LABELS,
|
||||
} from './policy.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
@@ -14,7 +10,7 @@ function rounded(value, digits = 0) {
|
||||
}
|
||||
|
||||
function grouped(value) {
|
||||
return value === null ? DASH : value.toLocaleString('en-US');
|
||||
return value === null ? DASH : formatNumber(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,17 +51,37 @@ export function localAdsbIsUat(record) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbReceiverLine(record) {
|
||||
const bands = recordBands(record)
|
||||
.map((band) => BAND_LABELS[band])
|
||||
.filter(Boolean);
|
||||
const sources = recordSources(record)
|
||||
.map((source) => SOURCE_LABELS[source])
|
||||
.filter(Boolean);
|
||||
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
|
||||
const bands = recordBands(record).map(bandLabel).filter(Boolean);
|
||||
const sources = recordSources(record).map(sourceLabel).filter(Boolean);
|
||||
return [t('Heard by your receiver'), bands.join(' + '), sources.join(' + ')]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/** A receiver band (policy.js BAND_LABELS) in the UI language: "1090 МГц". */
|
||||
function bandLabel(band) {
|
||||
switch (String(band)) {
|
||||
case '1090':
|
||||
return t('1090 MHz');
|
||||
case '978':
|
||||
return t('978 MHz UAT');
|
||||
default:
|
||||
return BAND_LABELS[band];
|
||||
}
|
||||
}
|
||||
|
||||
/** A receiver source (policy.js SOURCE_LABELS) in the UI language. */
|
||||
function sourceLabel(source) {
|
||||
switch (source) {
|
||||
case 'webusb':
|
||||
return t('browser SDR');
|
||||
case 'feed':
|
||||
return t('decoder feed');
|
||||
default:
|
||||
return SOURCE_LABELS[source];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heard aircraft came from, for the entity context's source field.
|
||||
* @param {object} record Local ADS-B record.
|
||||
@@ -74,9 +90,9 @@ export function localAdsbReceiverLine(record) {
|
||||
export function localAdsbSourceText(record) {
|
||||
const sources = recordSources(record);
|
||||
if (sources.includes('webusb') && sources.includes('feed'))
|
||||
return 'Your RTL-SDR receiver and decoder feed';
|
||||
if (sources.includes('feed')) return 'Your decoder feed';
|
||||
return 'Your RTL-SDR receiver';
|
||||
return t('Your RTL-SDR receiver and decoder feed');
|
||||
if (sources.includes('feed')) return t('Your decoder feed');
|
||||
return t('Your RTL-SDR receiver');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +105,7 @@ export function localAdsbSourceText(record) {
|
||||
export function localAdsbClassLine(record, aircraftClass = {}) {
|
||||
if (!aircraftClass.evidence) return '';
|
||||
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
|
||||
return [label, record?.category].filter(Boolean).join(' · ');
|
||||
return [label && t(label), record?.category].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,13 +145,26 @@ export function localAdsbCardModel(
|
||||
return {
|
||||
title: localAdsbTitle(record),
|
||||
details: [
|
||||
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
|
||||
`ICAO ${icao} · ${record?.callsign || t('NO CALLSIGN')}`,
|
||||
localAdsbClassLine(record, aircraftClass || {}),
|
||||
identity,
|
||||
routeLine,
|
||||
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
|
||||
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
|
||||
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
: t('V/S {rate} FPM', {
|
||||
rate: `${vertical > 0 ? '+' : ''}${grouped(vertical)}`,
|
||||
}),
|
||||
[
|
||||
ageS === null
|
||||
? t('POSITION {age}', { age: DASH })
|
||||
: t('POSITION {seconds} S AGO', { seconds: ageS }),
|
||||
tn(messages, '{count} MSG', '{count} MSGS'),
|
||||
].join(' · '),
|
||||
localAdsbReceiverLine(record),
|
||||
].filter(Boolean),
|
||||
accent: LOCAL_ADSB_COLOR,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { localReceiverFeedName } from './feedNames.js';
|
||||
import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
// Marks English that the Layers panel translates when it shows the status.
|
||||
// Texts with a number or a feed name stay English data too; the panel shows
|
||||
// them through STATUS_PIECE_PATTERNS (ui/statusText.js), piece by piece.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Layers-panel status for Local ADS-B, from its two inputs: the browser
|
||||
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
||||
*/
|
||||
|
||||
export const FEED_SOURCE = 'Decoder feeds';
|
||||
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
|
||||
export const FEED_SOURCE = msg('Decoder feeds');
|
||||
export const COMBINED_SOURCE = msg('WebUSB + decoder feeds');
|
||||
|
||||
function plural(count, one, many) {
|
||||
return count === 1 ? one : many;
|
||||
@@ -48,25 +53,29 @@ function webUsbStatus(receiver, heard, feedState) {
|
||||
if (receiver.status === 'error')
|
||||
return { status: 'error', error: receiver.message };
|
||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||
return { loading: true, loadingLabel: 'opening receiver' };
|
||||
return { loading: true, loadingLabel: msg('opening receiver') };
|
||||
if (
|
||||
feedState?.polling &&
|
||||
feedState.configured === null &&
|
||||
!receiver.connected
|
||||
)
|
||||
return { loading: true, loadingLabel: 'checking decoder feeds' };
|
||||
return { loading: true, loadingLabel: msg('checking decoder feeds') };
|
||||
if (!receiver.webUsbSupported)
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: 'WebUSB needs desktop Chrome or Edge',
|
||||
statusMessage: msg('WebUSB needs desktop Chrome or Edge'),
|
||||
};
|
||||
if (!receiver.connected)
|
||||
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: msg('connect a receiver in Radio'),
|
||||
};
|
||||
if (receiver.mode !== 'adsb')
|
||||
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
|
||||
return { status: 'idle', statusMessage: msg('receiver is in FM mode') };
|
||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||
? `${receiver.messagesPerSecond} msg/s`
|
||||
: 'listening';
|
||||
: msg('listening');
|
||||
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
|
||||
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
||||
}
|
||||
|
||||
@@ -97,7 +106,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
|
||||
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
|
||||
const live = feedState.feeds.filter((feed) => feed.status === 'live');
|
||||
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
|
||||
if (usbActive && receiver.status === 'error') problems.push('USB error');
|
||||
if (usbActive && receiver.status === 'error') problems.push(msg('USB error'));
|
||||
const rate =
|
||||
usbActive &&
|
||||
receiver.status !== 'error' &&
|
||||
@@ -109,6 +118,7 @@ export function localAdsbStatus({ receiver, feedState, heard }) {
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
// i18n-ignore: English data, shown through STATUS_PIECE_PATTERNS
|
||||
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
|
||||
};
|
||||
}
|
||||
|
||||
Loaded 100 of 1020 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user