Merge remote-tracking branch 'upstream/main' into fix/tracked-info-vertical-rate

This commit is contained in:
daikaginza committed 2026-10-16 08:39:10 +07:00
commit e9fb6c15c6
868 files changed
+136598 -6063

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
import * as Cesium from 'cesium';
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 || '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,
);
return {
readout: true,
summary: {
label: provider.label,
coverage: provider.coverage,
validTime: newest ? new Date(newest).toISOString() : undefined,
detail: `${provider.network} · ${provider.scale}`,
status: _lastError
? _lastError
: _count === 0
? _lastUpdate === null
? 'No readings available'
: 'No current readings'
: `${_count} stations reporting`,
units: provider.scale,
},
legend: AIR_QUALITY_BANDS.map((band) => ({
color: band.color,
label: band.label,
})),
legendCategorical: true,
list: {
ariaLabel: '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} · ${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;
}
+377
View File
@@ -0,0 +1,377 @@
/**
* @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.
*/
/** Band identifiers shared across providers, in ascending severity. */
export const AIR_QUALITY_BANDS = Object.freeze([
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
Object.freeze({ id: 'very-high', label: 'Very High', 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: 'Air quality',
network: 'Environment and Climate Change Canada',
coverage: '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) {
const band = airQualityBandById(bandId);
return band ? `${band.label} health risk` : null;
}
/**
* Validate a station FeatureCollection into an id-keyed catalog.
*
* Atomic: a structurally broken response returns null so the caller keeps its
* last good catalog. A station missing coordinates is skipped, since one
* unusable station is not evidence the catalog is broken.
*
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
* @returns {Map<string, object>|null} Stations by location_id, or null.
*/
export function normalizeAqhiStations(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const stations = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const coordinates = feature?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
const [lon, lat] = coordinates.map(Number);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
const id = String(properties.location_id ?? feature.id ?? '').trim();
if (!id) continue;
stations.set(id, {
id,
name: String(properties.location_name_en || id).trim(),
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
zone: String(properties['eccc_administrative-zone'] || '').trim(),
lat,
lon,
});
}
return stations;
}
/**
* Join latest ECCC observations onto known stations into provider-shaped rows.
*
* The catalog is the only thing that supplies a reading its coordinates, so an
* observation without a matching station cannot be placed and is dropped.
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
* air-quality number presented as current is worse than no number.
*
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
* @param {Map<string, object>} stations - Known stations by location_id.
* @param {number} [now=Date.now()] - Clock seam for tests.
* @returns {Array<object>|null} Normalized readings, or null when malformed.
*/
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
if (!Array.isArray(geojson?.features)) return null;
if (!(stations instanceof Map)) return null;
const provider = AIR_QUALITY_PROVIDERS.eccc;
const newest = new Map();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
return null;
const stationId = String(properties.location_id ?? '').trim();
const station = stations.get(stationId);
if (!station) continue;
const value = aqhiDisplayValue(properties.aqhi);
if (value === null) continue;
const observedMs = Date.parse(properties.observation_datetime);
if (!Number.isFinite(observedMs)) continue;
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
const previous = newest.get(stationId);
if (previous && previous.observedMs >= observedMs) continue;
const band = bandForValue(provider.id, value);
newest.set(stationId, {
id: `${provider.id}:${stationId}`,
provider: provider.id,
scale: provider.scale,
value,
band,
stationId,
name: station.name,
zone: station.zone,
lat: station.lat,
lon: station.lon,
observedMs,
color: airQualityColor(band),
label: valueLabel(provider.id, value),
risk: bandRiskText(band),
note: String(properties.special_notes_en || '').trim() || null,
});
}
return [...newest.values()].sort(
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
);
}
/**
* Build the source-owned presentation for one station label.
* @param {object} input
* @returns {object}
*/
export function createAirQualityOverlayEntry({
id,
position,
title,
accent,
value,
}) {
return {
id: String(id),
position,
variant: 'label',
title,
accent,
// Worse air wins the label budget — the reading people need to see first.
priority: Math.round(Number(value) || 0) * 1000,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the worst readings, with stable identity as the tie-break. */
export function selectAirQualityOverlayCohort(
entries,
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(
0,
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(entries) || cap === 0) return [];
return entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
)
.slice(0, cap);
}
/**
* The worst readings inside a lon/lat rectangle, for the readout card.
*
* A null rectangle means the camera could not resolve one (a fully oblique or
* space-level view); everything is then in scope rather than nothing, so the
* card degrades to the worst readings overall instead of going blank.
*
* @param {Array<object>} readings
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
* @param {number} [limit=5]
* @returns {Array<object>}
*/
export function readingsInView(readings, rect, limit = 5) {
if (!Array.isArray(readings)) return [];
const cap = Math.max(0, Math.floor(Number(limit) || 0));
if (cap === 0) return [];
const inside = !rect
? readings
: readings.filter((reading) => {
if (reading.lat < rect.south || reading.lat > rect.north) return false;
// A rectangle crossing the antimeridian has west > east.
return rect.west <= rect.east
? reading.lon >= rect.west && reading.lon <= rect.east
: reading.lon >= rect.west || reading.lon <= rect.east;
});
return inside
.slice()
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
.slice(0, cap);
}
/**
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
* @param {object|null|undefined} raw
* @param {number} [index=0]
* @returns {object}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
provider: text(raw?.provider),
scale: text(raw?.scale),
value: num(raw?.value),
band: text(raw?.band),
name: text(raw?.name),
zone: text(raw?.zone),
risk: text(raw?.risk),
lat: num(raw?.lat),
lon: num(raw?.lon),
observedMs: num(raw?.observedMs),
};
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AIR_QUALITY_BANDS,
AIR_QUALITY_PROVIDERS,
AIR_QUALITY_UNKNOWN_COLOR,
OBSERVATION_MAX_AGE_MS,
STALE_AFTER_MS,
airQualityColor,
aqhiDisplayValue,
bandForValue,
bandRiskText,
mapAnalystRecord,
normalizeAqhiObservations,
normalizeAqhiStations,
readingsAreStale,
readingsInView,
selectAirQualityOverlayCohort,
valueLabel,
} from './model.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const RECENT = '2026-09-24T18:00:00Z';
const collection = (features) => ({ type: 'FeatureCollection', features });
const station = (id, name, zone, lon, lat) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: {
location_id: id,
location_name_en: name,
'eccc_administrative-zone': zone,
},
});
const observation = (id, aqhi, when = RECENT) => ({
type: 'Feature',
properties: { location_id: id, aqhi, observation_datetime: when },
});
const STATIONS = collection([
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
]);
// ── provider-shaped records ────────────────────────────────────────────────
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
// US AQI is 0-500 on concentration. The shape names which index a value is on
// so a second network can be added without inventing a shared meaning.
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2.32)]),
stations,
NOW,
);
assert.equal(row.provider, 'eccc');
assert.equal(row.scale, 'AQHI');
assert.equal(row.value, 2);
assert.equal(row.band, 'low');
});
test('bands map from a value on a named provider scale', () => {
assert.equal(bandForValue('eccc', 1), 'low');
assert.equal(bandForValue('eccc', 3), 'low');
assert.equal(bandForValue('eccc', 4), 'moderate');
assert.equal(bandForValue('eccc', 7), 'high');
assert.equal(bandForValue('eccc', 11), 'very-high');
// An unregistered provider cannot be banded rather than being guessed at.
assert.equal(bandForValue('unknown-network', 5), null);
assert.equal(bandForValue('eccc', null), null);
});
test('the provider states its coverage plainly', () => {
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
});
test("values above ten collapse to the scale's open-ended label", () => {
assert.equal(valueLabel('eccc', 10), '10');
assert.equal(valueLabel('eccc', 11), '10+');
assert.equal(valueLabel('eccc', null), '--');
});
test('every band has a distinct colour, and an unknown band is neutral', () => {
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
assert.equal(new Set(colors).size, colors.length);
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
assert.equal(bandRiskText('high'), 'High health risk');
assert.equal(bandRiskText(null), null);
});
// ── published form ─────────────────────────────────────────────────────────
test('AQHI rounds to the published integer form and floors at 1', () => {
assert.equal(aqhiDisplayValue(1.08), 1);
assert.equal(aqhiDisplayValue(3.5), 4);
assert.equal(aqhiDisplayValue(0), 1);
assert.equal(aqhiDisplayValue('3'), 3);
});
test('an absent reading never becomes a confident AQHI 1', () => {
// Number(null) and Number('') are both 0, which would floor to 1.
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
}
});
// ── snapshot validation ────────────────────────────────────────────────────
test('stations normalize into an id-keyed catalog carrying their zone', () => {
const stations = normalizeAqhiStations(STATIONS);
assert.equal(stations.size, 3);
assert.equal(stations.get('OAAAA').zone, 'ont');
});
test('malformed payloads are rejected atomically', () => {
assert.equal(normalizeAqhiStations(null), null);
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
const stations = normalizeAqhiStations(STATIONS);
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
assert.equal(
normalizeAqhiObservations(collection([]), 'not a map', NOW),
null,
);
});
test('an observation with no matching station is dropped', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
stations,
NOW,
);
assert.equal(rows.length, 1);
});
test('a stale reading is dropped rather than shown as current', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.stationId),
['OAAAA'],
);
});
test('a snapshot whose every observation was too old is stale, not nominal', () => {
const stations = normalizeAqhiStations(STATIONS);
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
const rows = normalizeAqhiObservations(
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
stations,
NOW,
);
assert.deepEqual(rows, []);
assert.equal(readingsAreStale(rows, NOW), true);
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
assert.equal(
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
true,
);
});
test('the newest reading per station wins and worst air sorts first', () => {
const stations = normalizeAqhiStations(STATIONS);
const rows = normalizeAqhiObservations(
collection([
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
observation('OAAAA', 5),
]),
stations,
NOW,
);
assert.deepEqual(
rows.map((r) => r.value),
[7, 5],
);
});
// ── readout selection ──────────────────────────────────────────────────────
const reading = (id, value, lat, lon) => ({
id,
stationId: id,
value,
lat,
lon,
name: id,
});
test('the readout lists the worst readings inside the view rectangle', () => {
const rows = [
reading('west', 9, 51.0, -114.0),
reading('east', 4, 43.6, -79.3),
reading('mild', 2, 51.1, -114.1),
];
const inView = readingsInView(
rows,
{ west: -115, south: 50, east: -113, north: 52 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['west', 'mild'],
);
});
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
// A space-level or fully oblique camera cannot produce a rectangle; the card
// should still say something useful.
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
assert.deepEqual(
readingsInView(rows, null, 5).map((r) => r.id),
['b', 'a'],
);
assert.deepEqual(readingsInView(rows, null, 0), []);
assert.deepEqual(readingsInView(null, null, 5), []);
});
test('a view rectangle crossing the antimeridian still selects correctly', () => {
// west > east is how a dateline-crossing rectangle is expressed.
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
const inView = readingsInView(
rows,
{ west: 170, south: -10, east: -170, north: 10 },
5,
);
assert.deepEqual(
inView.map((r) => r.id),
['near'],
);
});
test('the overlay cohort is capped and deterministic', () => {
const entries = Array.from({ length: 120 }, (_, i) => ({
id: `s${i}`,
priority: i % 11,
}));
const cohort = selectAirQualityOverlayCohort(entries);
assert.ok(cohort.length <= 48);
assert.deepEqual(
cohort.map((e) => e.id),
selectAirQualityOverlayCohort(entries).map((e) => e.id),
);
});
test('analyst records are JSON-safe and name the provider and scale', () => {
const stations = normalizeAqhiStations(STATIONS);
const [row] = normalizeAqhiObservations(
collection([observation('IAKID', 2)]),
stations,
NOW,
);
const record = mapAnalystRecord(row, 0);
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
assert.equal(record.provider, 'eccc');
assert.equal(record.scale, 'AQHI');
const empty = mapAnalystRecord(undefined, 7);
assert.equal(empty.id, 'AIR-0007');
for (const [key, value] of Object.entries(empty)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
}
});
+275
View File
@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAirQualityLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createAirQualityLayer({
source,
overlayHost: {
setEntries(...args) {
events.push(args);
},
setVisible() {},
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
}
const reading = (id, value, overrides = {}) => ({
id: `eccc:${id}`,
provider: 'eccc',
scale: 'AQHI',
value,
band: 'low',
stationId: id,
name: id,
zone: 'ont',
lat: 43.65,
lon: -79.38,
observedMs: 1_700_000_000_000,
color: '#4cc9f0',
label: String(value),
risk: 'Low health risk',
note: null,
...overrides,
});
const staticSource = (rows) => ({ getSnapshot: async () => rows });
test('a layer requires both a snapshot source and an overlay host', () => {
assert.throws(() => createAirQualityLayer(), /snapshot source/);
assert.throws(
() => createAirQualityLayer({ source: staticSource([]) }),
/overlay host/,
);
});
test('initialization is single-use and owns exactly one data source', () => {
const { layer, viewer, sources } = harness(staticSource([]));
assert.equal(sources.length, 1);
assert.throws(() => layer.init(viewer), /already initialized/);
layer.destroy(viewer);
assert.equal(sources.length, 0);
});
test('a refresh renders readings and publishes an overlay cohort', async () => {
const { layer, events } = harness(
staticSource([reading('A', 2), reading('B', 7)]),
);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().error, null);
assert.equal(events.at(-1)[0], 'air-quality');
});
// ── the abort + enabled guard ──────────────────────────────────────────────
test('a response landing after disable cannot redraw the scene', async () => {
// The failure this prevents: disable() clears the overlay, then an in-flight
// response resolves and repopulates it behind the user's back.
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const { layer, events } = harness({
getSnapshot: async ({ signal } = {}) => {
await gate;
signal?.throwIfAborted();
return [reading('A', 5)];
},
});
const pending = layer.update();
layer.disable();
const publishedBefore = events.length;
release();
assert.equal(await pending, false, 'a late response must not be applied');
assert.equal(
events.length,
publishedBefore,
'no overlay write after disable',
);
assert.equal(layer.getStats().count, 0);
});
test('disable aborts the request in flight', async () => {
let seenSignal = null;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
seenSignal = signal;
await new Promise((resolve) => setTimeout(resolve, 40));
signal?.throwIfAborted();
return [];
},
});
const pending = layer.update();
layer.disable();
await pending;
assert.ok(seenSignal, 'a signal was passed through to the source');
assert.equal(seenSignal.aborted, true);
});
test('a superseded refresh does not clobber the newer one', async () => {
let call = 0;
const { layer } = harness({
getSnapshot: async ({ signal } = {}) => {
call += 1;
const mine = call;
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
signal?.throwIfAborted();
return [reading(`call-${mine}`, mine)];
},
});
const first = layer.update();
const second = layer.update();
const [firstResult, secondResult] = await Promise.all([first, second]);
assert.equal(firstResult, false, 'the superseded call reports no update');
assert.equal(secondResult, true);
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
});
test('update is a no-op while the layer is disabled', async () => {
const { layer } = harness(staticSource([reading('A', 2)]));
layer.disable();
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().count, 0);
});
// ── readout descriptor ─────────────────────────────────────────────────────
test('the readout descriptor carries the band legend and coverage', async () => {
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
await layer.update();
const controls = layer.getRowControls();
assert.equal(controls.readout, true);
assert.match(controls.summary.coverage, /Canada only/);
// The Weather panel's legend shape: one {color, label} per band.
assert.equal(controls.legendCategorical, true);
assert.deepEqual(
controls.legend.map((entry) => entry.label),
['Low', 'Moderate', 'High', 'Very High'],
);
assert.ok(
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
);
// Worst first, and every item keyed.
assert.deepEqual(
controls.list.items.map((i) => i.text),
['B · Low health risk', 'A · Low health risk'],
);
assert.ok(controls.list.items.every((i) => i.id));
assert.match(controls.summary.status, /2 stations reporting/);
});
test('the readout reports an error rather than a silent empty card', async () => {
const { layer } = harness({
getSnapshot: async () => {
throw new Error('ECCC HTTP 503');
},
});
await layer.update();
assert.match(layer.getRowControls().summary.status, /503/);
});
test('row-control listeners are notified on refresh and teardown-safe', async () => {
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
let calls = 0;
layer.setRowControlsListener(() => {
calls += 1;
});
await layer.update();
assert.ok(calls >= 1, 'a refresh notifies the card');
layer.destroy(viewer);
const after = calls;
layer.setRowControlsListener(() => {
calls += 1;
});
assert.equal(calls, after);
});
// ── feed-state honesty ─────────────────────────────────────────────────────
test('freshness is judged on observation time, not on when we last fetched', async () => {
// lastUpdate stays current every time the network answers, even when it
// answers with nothing new. Judging freshness on it would let the HUD and
// voice describe hours-old air quality as live.
const aged = Date.now() - STALE_AFTER_MS - 60_000;
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: aged })]),
);
await layer.update();
const stats = layer.getStats();
assert.ok(
stats.lastUpdate >= Date.now() - 5_000,
'we did just receive a response',
);
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
assert.equal(stats.stale, true);
assert.equal(
layerFeedState(stats),
'stale',
'provenance must not read this as nominal',
);
});
test('a current reading reports nominal', async () => {
const { layer } = harness(
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
);
await layer.update();
const stats = layer.getStats();
assert.equal(stats.stale, false);
assert.equal(layerFeedState(stats), 'nominal');
});
test('a refresh that kept no current observation reports stale, not nominal', async () => {
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
// that only has hours-old rows yields an empty snapshot. That is the
// stalest a feed can be; it must not read as a healthy empty layer.
const { layer } = harness(staticSource([]));
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
await layer.update();
const stats = layer.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.observedAt, null);
assert.equal(stats.stale, true);
assert.equal(layerFeedState(stats), 'stale');
assert.equal(layer.getRowControls().summary.status, 'No current readings');
});
test('a failed refresh over good readings reports degraded, not nominal', async () => {
let fail = false;
const { layer } = harness({
getSnapshot: async () => {
if (fail) throw new Error('ECCC HTTP 503');
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
},
});
await layer.update();
assert.equal(layerFeedState(layer.getStats()), 'nominal');
fail = true;
await layer.update();
const stats = layer.getStats();
assert.ok(stats.count > 0, 'the previous readings are still displayed');
assert.equal(
layerFeedState(stats),
'degraded',
'showing old data after a failure is degraded',
);
});
+104
View File
@@ -0,0 +1,104 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
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('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('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('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('Malformed AQHI observation response');
return readings;
},
};
}
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEcccAirQualitySource } from './source.js';
const NOW = Date.parse('2026-09-24T18:30:00Z');
const HOUR = 60 * 60 * 1000;
const station = (id) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
properties: { location_id: id, location_name_en: id },
});
const observation = (id, at = NOW, aqhi = 3) => ({
type: 'Feature',
properties: {
location_id: id,
aqhi,
observation_datetime: new Date(at - 10 * 60_000).toISOString(),
},
});
const json = (body, init) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
...init,
});
/** A fetch that serves the station catalog and observations from mutable state. */
function feed() {
const state = {
clock: NOW,
stations: [station('A')],
stationIds: null,
urls: [],
};
state.source = createEcccAirQualitySource({
now: () => state.clock,
fetchImpl: async (url) => {
state.urls.push(url);
return json({
type: 'FeatureCollection',
// Every known station reports ten minutes before the current clock.
features: url.includes('aqhi-stations')
? state.stations
: (state.stationIds || ['A']).map((id) =>
observation(id, state.clock),
),
});
},
});
state.stationRequests = () =>
state.urls.filter((url) => url.includes('aqhi-stations')).length;
return state;
}
test('the station catalog is cached for twelve hours, then refetched', async () => {
const state = feed();
await state.source.getSnapshot();
state.clock = NOW + 12 * HOUR;
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 1, 'within the TTL the cache serves');
state.clock = NOW + 12 * HOUR + 1;
await state.source.getSnapshot();
assert.equal(
state.stationRequests(),
2,
'past the TTL the catalog refreshes',
);
});
test('an empty catalog refresh keeps the previous catalog', async () => {
const state = feed();
await state.source.getSnapshot();
state.stations = [];
state.clock = NOW + 13 * HOUR;
const readings = await state.source.getSnapshot();
assert.deepEqual(
readings.map((reading) => reading.stationId),
['A'],
);
// The empty refresh did not count as fresh, so the next call retries it.
await state.source.getSnapshot();
assert.equal(state.stationRequests(), 3);
});
test('an empty first catalog is an error, not an empty layer', async () => {
const state = feed();
state.stations = [];
await assert.rejects(state.source.getSnapshot(), /No AQHI stations/);
});
test('requests ask for JSON, the latest observations only, and a bounded page', async () => {
const state = feed();
await state.source.getSnapshot();
const [stations, observations] = state.urls.map((url) => new URL(url));
assert.equal(stations.pathname, '/collections/aqhi-stations/items');
assert.equal(stations.searchParams.get('f'), 'json');
assert.equal(stations.searchParams.get('limit'), '500');
assert.equal(
observations.pathname,
'/collections/aqhi-observations-realtime/items',
);
// Without latest=true the collection returns the full history (~2,000 rows).
assert.equal(observations.searchParams.get('latest'), 'true');
assert.equal(observations.searchParams.get('limit'), '500');
});
test('rows past the requested limit are ignored', async () => {
const state = feed();
state.stations = Array.from({ length: 600 }, (_, index) =>
station(`S${String(index).padStart(3, '0')}`),
);
state.stationIds = state.stations.map((row) => row.properties.location_id);
const readings = await state.source.getSnapshot();
assert.equal(readings.length, 500);
});
test('a malformed catalog and HTTP errors reject', async () => {
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({ features: 'no' }),
}).getSnapshot(),
/Malformed AQHI station/,
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => json({}, { status: 503 }),
}).getSnapshot(),
/ECCC HTTP 503/,
);
});
test('an oversized response is refused before it is parsed', async () => {
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
new Response(new Blob([big]).stream(), {
headers: { 'content-type': 'application/json' },
}),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () =>
json({}, { headers: { 'content-length': String(5 * 1024 * 1024) } }),
}).getSnapshot(),
(error) => error.code === 'RESPONSE_TOO_LARGE',
);
});
test('a caller abort cancels the request and detaches its listener', async () => {
const caller = new AbortController();
let requestSignal = null;
const source = createEcccAirQualitySource({
fetchImpl: (_url, { signal }) => {
requestSignal = signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot({ signal: caller.signal });
caller.abort(new Error('layer disabled'));
await assert.rejects(pending, /layer disabled/);
assert.equal(requestSignal.aborted, true);
const already = new AbortController();
already.abort(new Error('before start'));
let calls = 0;
await assert.rejects(
createEcccAirQualitySource({
fetchImpl: async () => {
calls += 1;
return json({ features: [] });
},
}).getSnapshot({ signal: already.signal }),
/before start/,
);
assert.equal(calls, 0);
});
test('a request that outlives its deadline is aborted', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const source = createEcccAirQualitySource({
timeoutMs: 1_000,
fetchImpl: (_url, { signal }) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
});
const pending = source.getSnapshot();
t.mock.timers.tick(1_000);
await assert.rejects(pending, /ECCC request timed out/);
});
+1
View File
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/layerStateCoordinator.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
+82
View File
@@ -0,0 +1,82 @@
/** One cancellable consumer of the shared terrain service, coalesced by floor cell. */
export function createAlprFloorResolver(ground) {
const pending = new Map();
const queued = new Map();
let owner = null;
let running = false;
async function pump(controller) {
try {
while (queued.size && !controller.signal.aborted) {
const batch = [...queued.values()].slice(0, 64);
for (const cell of batch) queued.delete(cell.key);
try {
await ground.resolveGroundFloorCells(
batch.map(({ lat, lon }) => ({ lat, lon })),
{ signal: controller.signal },
);
} catch {
// Missing floors remain clamped; cancellation must not publish later.
}
for (const cell of batch) {
if (pending.get(cell.key) === cell) pending.delete(cell.key);
cell.finish();
}
}
} finally {
if (owner === controller) running = false;
}
}
function resolve(points) {
if (typeof ground.resolveGroundFloorCells !== 'function')
return Promise.resolve();
owner ??= new AbortController();
const waits = [];
for (const point of points) {
const lat = Number(point.lat.toFixed(3));
const lon = Number(point.lon.toFixed(3));
const key = `${lat},${lon}`;
let cell = pending.get(key);
if (!cell) {
let finish;
const promise = new Promise((done) => {
finish = done;
});
cell = { key, lat, lon, promise, finish };
pending.set(key, cell);
queued.set(key, cell);
}
waits.push(cell.promise);
}
if (!running && queued.size) {
running = true;
void pump(owner);
}
return Promise.all(waits);
}
async function prepare(points) {
let timer;
try {
await Promise.race([
resolve(points),
new Promise((done) => {
timer = setTimeout(done, ground.FLOOR_RESOLVE_DEADLINE_MS ?? 1200);
}),
]);
} finally {
clearTimeout(timer);
}
}
function cancel() {
owner?.abort();
owner = null;
running = false;
for (const cell of pending.values()) cell.finish();
pending.clear();
queued.clear();
}
return { resolve, prepare, cancel };
}
+138 -33
View File
@@ -1,3 +1,4 @@
import { isUnavailableCapability } from '../../sources/capability.js';
import * as Cesium from 'cesium';
import {
LAYER_ID,
@@ -21,7 +22,9 @@ import { createAlprPresentation } from './presentation.js';
/**
* Own one layer's requests, records, display and viewer subscriptions.
* A source fetches a bounded box with an AbortSignal and resolves
* { records, stale, saturated }. Records use stable string ids and latitude /
* { records, stale, saturated }, optionally with `coverage` (the bounds its
* records fully cover, reused for later views inside it) and `zoom` (their
* tile precision; `source.detailZoom(box)` names the zoom a view needs). Records use stable string ids and latitude /
* longitude in degrees; optional directionDeg is a compass bearing [0, 360).
* Manufacturer, operator and cameraType are optional display fields.
* Source label and attribution { name, description, text, href } identify the
@@ -67,6 +70,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
debounceTimer: null,
/** Snapped box of the last successful query; a view still inside it reuses its records. */
lastQueryBox: null,
/** Extract zoom of the accepted records (null for sources without tiles). */
lastQueryZoom: null,
/** Increments whenever the displayed camera set changes (QA stability probe). */
renderRevision: 0,
/** Cameras in view beyond the render cap. */
renderSaturated: false,
};
const {
initOverlay,
@@ -81,6 +90,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
} = createAlprPresentation({ state, services, source });
function setAlprStatus(status, error = null) {
@@ -109,10 +119,34 @@ export function createAlprCamerasLayer({ source, services } = {}) {
if (resetBackoff) state.retryDelayMs = 0;
}
/** Whether accepted records already answer `box` without a request. */
function canReuse(box) {
const detailZoom = source.detailZoom?.(box) ?? null;
return Boolean(
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
(state.lastQueryZoom == null ||
detailZoom == null ||
state.lastQueryZoom >= detailZoom - 1) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable',
);
}
function scheduleLoad() {
if (!state.enabled) return;
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(state.debounceTimer);
// A view the accepted tiles already cover is a local filter: show it now.
// Only a view that needs a request waits out the debounce.
const box = viewportBox(state.viewer);
if (box && canReuse(box)) {
state.debounceTimer = null;
loadCameras();
return;
}
state.debounceTimer = setTimeout(() => {
state.debounceTimer = null;
loadCameras();
@@ -142,14 +176,9 @@ export function createAlprCamerasLayer({ source, services } = {}) {
// request still in flight was for a viewport the user has since left, so it
// is aborted rather than allowed to replace the records that already cover
// this view. Failed queries never replace the last successful box.
if (
state.lastQueryBox &&
boxContains(state.lastQueryBox, box) &&
state.lastUpdate &&
Date.now() - state.lastUpdate < QUERY_REUSE_MS &&
!state.stale &&
state.status !== 'unavailable'
) {
// Reuse also needs the accepted records to be precise enough for this view:
// one zoom coarser than the view would pick is a few metres, never more.
if (canReuse(box)) {
if (state.abort) {
state.abort.abort();
state.abort = null;
@@ -165,7 +194,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
}
// A move inside an in-flight query must not abort and restart that query.
if (state.abort && boxContains(state.pendingQueryBox, box)) {
renderRecords();
// The accepted records may cover only part of the new view. Re-filtering
// them during floor preparation creates an intermediate marker set.
return;
}
const queryBox = snapAlprBox(box);
@@ -176,7 +206,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.loading = true;
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
setAlprStatus('loading');
renderRecords();
// Keep the current markers until the new view's records land, so a still
// camera sees one update instead of old records re-filtered, then new ones.
try {
const snapshot = await source.fetch(queryBox, requestAbort.signal);
if (
@@ -185,16 +216,43 @@ export function createAlprCamerasLayer({ source, services } = {}) {
!state.enabled
)
return;
if (snapshot.zoomIn) {
state.records = [];
state.recordById.clear();
state.noCoverage = false;
state.lastQueryBox = null;
state.lastQueryZoom = null;
clearUnavailableRetry();
renderRecords();
setAlprStatus('zoom-in');
return;
}
const { records, stale, saturated } = validateAlprSnapshot(snapshot);
// Resolve marker floors (bounded wait, warm cells answer at once) so new
// markers are placed on the floor instead of clamped then moved.
await prepareFloors(records);
if (
requestAbort.signal.aborted ||
state.abort !== requestAbort ||
!state.enabled
)
return;
state.noCoverage = snapshot.noCoverage === true;
state.records = records;
state.recordById = new Map(records.map((r) => [r.id, r]));
state.lastUpdate = Date.now();
state.stale = stale;
// Saturated when the QUERY truncated OR the render cap would hide cameras:
// either way the view holds more than the screen shows, and the user must
// be told rather than left with a count that disagrees with the map.
state.saturated = saturated || records.length > MAX_RENDERED;
state.lastQueryBox = queryBox;
// Saturated when the QUERY truncated; the render cap is judged against
// the cameras in view (renderSaturated). Either way the row says so.
state.saturated = saturated;
// The fetched tiles cover more than the view: pans inside them are free.
state.lastQueryBox =
snapshot.coverage && boxContains(snapshot.coverage, queryBox)
? snapshot.coverage
: queryBox;
state.lastQueryZoom = Number.isInteger(snapshot.zoom)
? snapshot.zoom
: null;
clearUnavailableRetry();
// Stale and saturated are independent facts; a cached response that also
// hit the cap must still warn about coverage, not just about freshness.
@@ -219,7 +277,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
'unavailable',
error?.message || 'Camera source temporarily unavailable',
);
scheduleUnavailableRetry();
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
} finally {
if (state.abort === requestAbort) {
state.abort = null;
@@ -282,6 +340,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
return loadCameras();
},
destroy(viewer = state.viewer) {
source.destroy?.();
this.disable();
destroyOverlay();
state.moveEndRemove?.();
@@ -299,6 +358,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
state.records = [];
state.recordById = new Map();
state.lastQueryBox = null;
state.lastQueryZoom = null;
state.lastUpdate = null;
state.error = null;
state.status = 'idle';
@@ -332,15 +392,55 @@ export function createAlprCamerasLayer({ source, services } = {}) {
],
};
},
/**
* Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!state.enabled) return [];
const result = [];
for (const record of state.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.id,
lat: record.latitude,
lon: record.longitude,
operator: record.operator ?? null,
manufacturer: record.manufacturer ?? null,
cameraType: record.cameraType ?? null,
directionDeg: Number.isFinite(record.directionDeg)
? record.directionDeg
: null,
});
}
return result;
},
getStats() {
return {
count: state.dataSource?.entities.values.length || 0,
countLabel: state.enabled
? `${state.dataSource?.entities.values.length || 0} nearby`
: '',
countLabel:
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
? `${state.dataSource?.entities.values.length || 0} nearby${
Number.isInteger(state.onScreen)
? ` · ${state.onScreen} on screen`
: ''
}`
: '',
noCoverage: Boolean(state.noCoverage),
lastUpdate: state.lastUpdate,
stale: state.stale,
saturated: state.saturated,
saturated: state.saturated || state.renderSaturated,
renderRevision: state.renderRevision,
onScreen: state.onScreen ?? null,
shown: state.dataSource?.entities.values.length || 0,
error: state.error,
status: state.status,
loading: state.loading,
@@ -353,17 +453,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
? state.retrying
? 'retrying mapped ALPR cameras'
: 'loading mapped ALPR cameras'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated ? 'Coverage limited — zoom in' : '',
state.status === 'empty'
? 'No mapped cameras returned — coverage is incomplete'
: '',
]
.filter(Boolean)
.join(' · '),
: state.noCoverage
? 'No ALPR data for this area — US and Canada only'
: state.status === 'zoom-in'
? 'Zoom in to load mapped cameras'
: [
state.stale ? 'Showing cached locations' : '',
state.saturated || state.renderSaturated
? 'Coverage limited — zoom in'
: '',
state.status === 'empty' ? 'No ALPR data for this area' : '',
state.status === 'ready' && state.onScreen === 0
? 'None on screen — nearby cameras are outside the view'
: '',
]
.filter(Boolean)
.join(' · '),
};
},
};
@@ -384,4 +489,4 @@ export {
QUERY_SNAP_DEGREES,
QUERY_REUSE_MS,
} from './policy.js';
export { createOverpassAlprSource } from './source.js';
export { createOverpassAlprSource, createAlprTileSource } from './source.js';
+178 -32
View File
@@ -3,18 +3,28 @@ import {
MAX_CANVAS_FRUSTUMS,
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
MARKER_FLOOR_LIFT_M,
ANCHOR_SAMPLES_PER_PAINT,
SURFACE_BELOW_FLOOR_M,
SURFACE_ABOVE_FLOOR_M,
SURFACE_SAMPLES_PER_PAINT,
} from './policy.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
BRACKETS_IMAGE,
directionWedgePositions,
markerScale,
paintDirectionWedge,
validAlprGroundHeight,
} from './visuals.js';
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
export function createAlprOverlay({ state, services }) {
export function createAlprOverlay({
state,
services,
onSurfaceChange = () => {},
}) {
let lane,
removeMapListener,
retryTimer,
@@ -22,14 +32,21 @@ export function createAlprOverlay({ state, services }) {
let records = [],
hits = [],
images = [],
destroyed = false;
let surfaceRegime;
destroyed = false,
lastCamera = null;
const requestPaint = () => {
if (!destroyed && state.enabled) lane?.requestPaint();
};
function nativeVisible(entity, visible) {
if (!entity) return;
canvasOwned(entity, !visible);
setShown(entity, visible);
}
/** Swap a marker between its native look and a faint canvas pick target. */
function canvasOwned(entity, owned) {
const visible = !owned;
const billboard = entity.billboard;
// Keep a faint native pick target under the canvas badge so sibling layer
// handlers recognize ALPR ownership instead of treating the click as empty.
@@ -55,7 +72,15 @@ export function createAlprOverlay({ state, services }) {
entity.gevAlprNativeAppearance = null;
entity.gevAlprPickPosition = null;
}
billboard.show = visible;
}
// Assign only on change: each Entity property write mints a new property
// and raises definitionChanged, which paint used to do for every overlay
// camera on every frame.
function setShown(entity, visible) {
if (entity.gevAlprShown === visible) return;
entity.gevAlprShown = visible;
entity.billboard.show = visible;
if (entity.polyline) entity.polyline.show = visible;
if (entity.polygon) entity.polygon.show = visible;
}
@@ -72,15 +97,25 @@ export function createAlprOverlay({ state, services }) {
requestPaint();
}
function anchorFor(record, entity) {
function anchorFor(record, entity, budget) {
if (entity.gevAlprCanvasPosition) return entity.gevAlprCanvasPosition;
const scene = state.viewer.scene;
const location = Cesium.Cartographic.fromDegrees(
record.longitude,
record.latitude,
);
// A floor-placed marker anchors where its native badge sits; only a
// marker still waiting for its floor samples the rendered surface, and
// each paint samples a few at most (each sample is a depth render).
let height;
if (scene.sampleHeightSupported) {
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (validAlprGroundHeight(floor)) height = floor + MARKER_FLOOR_LIFT_M;
else if (scene.sampleHeightSupported) {
if (budget.samples <= 0) return null;
budget.samples -= 1;
try {
height = scene.sampleHeight(location, [entity]);
} catch {
@@ -89,19 +124,107 @@ export function createAlprOverlay({ state, services }) {
}
if (!validAlprGroundHeight(height) && scene.globe.show)
height = scene.globe.getHeight?.(location);
if (!validAlprGroundHeight(height))
height = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
if (!validAlprGroundHeight(height)) return null;
setAnchor(record, entity, height);
return entity.gevAlprCanvasPosition;
}
function floorHeightAt(latitude, longitude) {
const floor = services.groundFloor.cachedGroundFloor(latitude, longitude);
return validAlprGroundHeight(floor) ? floor + MARKER_FLOOR_LIFT_M : null;
}
function setAnchor(record, entity, height) {
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
height,
);
entity.gevAlprWedge = directionWedgePositions(record, height);
return entity.gevAlprCanvasPosition;
entity.gevAlprWedge = directionWedgePositions(
record,
height,
floorHeightAt,
);
}
/** Whether the visible photoreal tiles (or globe) have finished streaming. */
function surfaceSettled(scene) {
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (primitive?.show && primitive.tilesLoaded === false) return false;
}
return true;
}
/**
* Replace floor anchors with the rendered surface under each overlay camera.
* Floors come from ~111 m cells, so on a slope a badge could float or sink
* by tens of metres. Runs only while the camera is still and tiles have
* settled, a few depth samples per paint, and each anchor at most once;
* samples far outside the floor prior (a roof or a streaming tile) are
* rejected. Returns whether anchors remain to check.
*/
function refineAnchors(budget) {
const camera = state.viewer.camera;
const scene = state.viewer.scene;
const still =
lastCamera &&
camera.positionWC &&
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastCamera.position,
0,
0.05,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastCamera.direction,
0,
1e-6,
);
if (camera.positionWC && camera.directionWC)
lastCamera = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction: Cesium.Cartesian3.clone(camera.directionWC),
};
if (!scene.sampleHeightSupported) return false;
let pending = false;
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
const anchor = entity?.gevAlprCanvasPosition;
if (!anchor || entity.gevAlprSurfaceCheckedFor === anchor) continue;
if (!still || !surfaceSettled(scene) || budget.samples <= 0) {
pending = true;
continue;
}
budget.samples -= 1;
let sample;
try {
sample = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
[entity],
);
} catch {
/* streaming tiles */
}
const current = Cesium.Cartographic.fromCartesian(anchor).height;
const floor = services.groundFloor.cachedGroundFloor(
record.latitude,
record.longitude,
);
const accepted =
validAlprGroundHeight(sample) &&
(!validAlprGroundHeight(floor) ||
(sample >= floor - SURFACE_BELOW_FLOOR_M &&
sample <= floor + SURFACE_ABOVE_FLOOR_M));
if (accepted && Math.abs(sample + MARKER_FLOOR_LIFT_M - current) > 1)
setAnchor(record, entity, sample + MARKER_FLOOR_LIFT_M);
entity.gevAlprSurfaceCheckedFor = entity.gevAlprCanvasPosition;
if (entity.id === state.selectedId)
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
}
return pending;
}
function paint({ ctx, width, height, keyhole, occluder }) {
@@ -109,6 +232,7 @@ export function createAlprOverlay({ state, services }) {
if (!state.enabled || destroyed) return;
let unresolved = false;
const painted = [];
const budget = { samples: ANCHOR_SAMPLES_PER_PAINT };
for (const record of records) {
const entity = state.dataSource.entities.getById(record.id);
if (!entity) continue;
@@ -122,23 +246,32 @@ export function createAlprOverlay({ state, services }) {
nativeVisible(entity, true);
continue;
}
const anchor = anchorFor(record, entity);
const anchor = anchorFor(record, entity, budget);
if (!anchor) {
nativeVisible(entity, true);
unresolved = true;
continue;
}
nativeVisible(entity, false);
if (occluder && !occluder.isPointVisible(anchor)) continue;
canvasOwned(entity, true);
if (occluder && !occluder.isPointVisible(anchor)) {
setShown(entity, false);
continue;
}
const scene = state.viewer.scene;
const origin = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
anchor,
);
if (!origin) continue;
if (!origin) {
setShown(entity, false);
continue;
}
const alpha =
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
if (!(alpha > 0)) continue;
if (!(alpha > 0)) {
setShown(entity, false);
continue;
}
const wedge = entity.gevAlprWedge;
if (wedge) {
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
@@ -161,15 +294,27 @@ export function createAlprOverlay({ state, services }) {
origin.x > width + 60 ||
origin.y < -60 ||
origin.y > height + 60
)
) {
setShown(entity, false);
continue;
entity.billboard.show = true;
painted.push({ record, origin, selected, image, alpha });
}
setShown(entity, true);
painted.push({
record,
origin,
selected,
image,
alpha,
scale: markerScale(
Cesium.Cartesian3.distance(state.viewer.camera.positionWC, anchor),
),
});
if (selected) entity.gevAlprDisplayPosition = anchor;
}
// Paint all glyphs after the wedges so one camera's cone cannot wash out another.
for (const { record, origin, selected, image, alpha } of painted) {
const size = selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE;
for (const { record, origin, selected, image, alpha, scale } of painted) {
const size =
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
ctx.save();
ctx.globalAlpha *= alpha;
ctx.drawImage(
@@ -190,7 +335,10 @@ export function createAlprOverlay({ state, services }) {
ctx.restore();
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
}
if (unresolved && !retryTimer && Date.now() < retryUntil) {
const refining = refineAnchors({
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
});
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
retryTimer = setTimeout(() => {
retryTimer = null;
requestPaint();
@@ -201,6 +349,12 @@ export function createAlprOverlay({ state, services }) {
return {
init() {
destroyed = false;
removeMapListener = services.overlays?.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready' || !state.enabled) return;
// Same-regime provider changes also invalidate absolute placements.
onSurfaceChange();
resetAnchors();
});
if (!services.overlays?.registerPaintLane || typeof Image === 'undefined')
return;
lane = services.overlays.registerPaintLane('selected', paint, {
@@ -214,14 +368,6 @@ export function createAlprOverlay({ state, services }) {
image.src = src;
return image;
});
surfaceRegime = state.viewer.scene.globe.show;
removeMapListener = services.overlays.subscribeMapStack?.((event) => {
if (event.detail?.status !== 'ready') return;
const next = state.viewer.scene.globe.show;
if (next === surfaceRegime) return;
surfaceRegime = next;
resetAnchors();
});
},
sync(visible) {
if (!lane) return;
+67 -3
View File
@@ -2,9 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { directionWedgePositions, alprLabelDetails } from './visuals.js';
import {
directionWedgePositions,
alprLabelDetails,
markerScale,
} from './visuals.js';
function harness(t, count = 2) {
function harness(t, count = 2, { floor = null } = {}) {
const previousImage = globalThis.Image;
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
globalThis.Image = class {
@@ -43,7 +47,7 @@ function harness(t, count = 2) {
const overlay = createAlprOverlay({
state,
services: {
groundFloor: { cachedGroundFloor: () => null },
groundFloor: { cachedGroundFloor: () => floor },
overlays: {
registerPaintLane(_lane, painter) {
paint = painter;
@@ -190,6 +194,20 @@ test('an unresolved surface preserves the native marker; the horizon removes ove
assert.equal(h.draw.length, 0);
});
test('repeated paints of a still view never rewrite marker properties', (t) => {
// Each Entity write raises definitionChanged; paint used to hide then show
// every overlay camera's native marker on every frame.
const h = harness(t, 3);
h.overlay.sync(h.records);
h.paint();
let changes = 0;
for (const entity of h.entities.values)
entity.definitionChanged.addEventListener(() => changes++);
for (let i = 0; i < 5; i++) h.paint();
assert.equal(changes, 0);
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
});
test('adapter labels never claim a custom source is public OSM data', () => {
assert.deepEqual(
alprLabelDetails(
@@ -199,3 +217,49 @@ test('adapter labels never claim a custom source is public OSM data', () => {
['Source: Custom directory', 'VENDOR'],
);
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
assert.equal(markerScale(90_000), 0.45);
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
});
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
const h = harness(t, 2, { floor: 150 });
const scene = h.state.viewer.scene;
const camera = h.state.viewer.camera;
camera.directionWC = Cesium.Cartesian3.UNIT_X;
scene.sampleHeightSupported = true;
scene.primitives = { length: 0, get: () => null };
let samples = 0;
scene.sampleHeight = (carto) => {
samples++;
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
};
h.overlay.sync(h.records);
h.paint();
const height = (id) =>
Cesium.Cartographic.fromCartesian(
h.entities.getById(id).gevAlprCanvasPosition,
).height;
assert.equal(
samples,
0,
'no depth samples while the camera may still be moving',
);
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
h.paint();
assert.ok(
Math.abs(height('camera:0') - 173.5) < 0.01,
'rendered surface once still',
);
assert.ok(
Math.abs(height('camera:1') - 151.5) < 0.01,
'a rooftop-height sample is rejected',
);
const taken = samples;
h.paint();
assert.equal(samples, taken, 'each anchor is checked once');
});
+42 -21
View File
@@ -1,33 +1,37 @@
/**
* @file Community-mapped automatic license plate reader (ALPR) camera layer.
*
* Data source: OpenStreetMap contributors, including the DeFlock community
* (`man_made=surveillance` + `surveillance:type=ALPR`) — see
* https://wiki.openstreetmap.org/wiki/Tag:surveillance:type=ALPR and
* https://deflock.org. OSM data remains ODbL 1.0, separate from the MIT code.
* Fetched viewport-bounded through the existing generic
* `/api/overpass` proxy (same one `traffic.js` and `militaryInstallations.js`
* use) — no new server route needed for a single narrow tag pair.
*
* This is mapped surveillance infrastructure, not a live camera feed: no
* plate records, no vendor accounts, nothing beyond what a contributor chose
* to publish to OSM. Coverage and tag accuracy are not guaranteed.
*
* @module data/alprCameras
* Community-mapped OpenStreetMap ALPR locations, via hourly US/Canada detail
* tiles. OSM data remains ODbL, separate from the application code. These are
* mapped locations, not footage, plate records or evidence of current activity.
*/
export const LAYER_ID = 'alpr-cameras';
export const OVERPASS_URL = '/api/overpass';
export const REQUEST_DEBOUNCE_MS = 500;
export const REQUEST_DEBOUNCE_MS = 300;
/** Keep public Overpass queries city-scale, never globe-wide. */
/** Keep camera acquisition city-scale, never globe-wide. */
export const MAX_VIEWPORT_DEGREES = 3;
/** Overpass `out body N;` cap — also detects possibly truncated coverage. */
/** Camera record cap; reaching it reports limited coverage. */
export const QUERY_LIMIT = 1500;
/**
* The extract carries full camera attributes from z9 (z0-8 are geometry-only
* heat tiles). Each view uses the finest zoom from z12 down to z9 that fits
* DETAIL_MAX_TILES, so a whole city loads from a few z9/z10 tiles and a
* neighbourhood from z12 (about 2 m position precision).
*/
export const DETAIL_MIN_ZOOM = 9;
export const DETAIL_MAX_ZOOM = 12;
export const DETAIL_MAX_TILES = 16;
/** Decoded records kept per accepted tile coverage (memory bound, not a render cap). */
export const SOURCE_RECORD_LIMIT = 6000;
/** Best-precision coordinates remembered per camera across zoom changes. */
export const PRECISION_CACHE_LIMIT = 20000;
/** Render cap. Kept at or above QUERY_LIMIT on purpose: if it ever sat below
* it, cameras between the two would be silently dropped while `saturated`
* stayed false and `count` still reported them — a lie about coverage. */
@@ -40,20 +44,37 @@ export const DIRECTION_CONE_HALF_ANGLE_DEG = 20;
export const EARTH_MEAN_RADIUS_M = 6371008.8;
/** Query boxes are snapped outward to this grid so nearby camera moves reuse
* one Overpass request body (the shared proxy keys its cache and in-flight
* dedupe by the exact body) instead of each becoming a distinct upstream fetch. */
* one accepted viewport snapshot rather than rebuilding records on every pan. */
export const QUERY_SNAP_DEGREES = 0.05;
/** A view still fully inside the last snapped query box reuses those records
* for this long before asking Overpass again. */
* for this long before selecting tiles again. */
export const QUERY_REUSE_MS = 10 * 60 * 1000;
/** Vendor-neutral camera badge palette, with coral selection. */
export const ALPR_COLOR = '#52d4ff';
export const ALPR_SELECTED_COLOR = '#ff6474';
export const MARKER_ICON_SIZE = 38;
/** Badge scale by camera distance (near m, scale, far m, scale), shared by
* native badges and overlay glyphs so both shrink alike in city-wide views. */
export const MARKER_SCALE_BY_DISTANCE = Object.freeze([
500, 1.15, 40_000, 0.45,
]);
export const SELECTED_MARKER_ICON_SIZE = 60;
export const MAX_CANVAS_FRUSTUMS = 64;
/** Metres a floor-placed marker sits above its resolved ground cell. */
export const MARKER_FLOOR_LIFT_M = 1.5;
/** Rendered-surface samples one overlay paint may take (each is a depth render). */
export const ANCHOR_SAMPLES_PER_PAINT = 4;
/** Accepted rendered-surface window around a floor cell (a cut or an embankment
* sits within it; a rooftop or an unstreamed tile usually does not). */
export const SURFACE_BELOW_FLOOR_M = 30;
/** Rendered-surface refinements per paint while the camera is still. */
export const SURFACE_SAMPLES_PER_PAINT = 2;
export const SURFACE_ABOVE_FLOOR_M = 40;
/** OSM attribution may collapse after five seconds; full credit stays in Data attribution. */
export const CREDIT_DISPLAY_MS = 5000;
+244 -51
View File
@@ -9,15 +9,17 @@ import {
MARKER_ICON_SIZE,
SELECTED_MARKER_ICON_SIZE,
CREDIT_DISPLAY_MS,
MARKER_FLOOR_LIFT_M,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { boxContains } from './model.js';
import { createAlprFloorResolver } from './floors.js';
import { createAlprOverlay } from './overlay.js';
import {
MARKER_IMAGE,
SELECTED_IMAGE,
alprDisplayId,
alprLabelDetails,
directionWedgePositions,
validAlprGroundHeight,
} from './visuals.js';
@@ -32,9 +34,198 @@ export function createAlprPresentation({ state, services, source }) {
} = services.context;
const { cachedGroundFloor } = services.groundFloor;
const overlay = createAlprOverlay({ state, services });
const overlay = createAlprOverlay({
state,
services,
onSurfaceChange: refreshMarkerPlacements,
});
let visibleRecords = [];
let selectionStartedAt = 0;
/** Markers still clamped while their floor cell resolves (id -> entity). */
const clampedMarkers = new Map();
let floorResolve = null;
const floors = createAlprFloorResolver(services.groundFloor);
/**
* Marker placement from the cached ground floor (validated mesh or DEM, the
* ellipsoidal datum every layer shares). Ground clamping on photoreal tiles
* re-samples every clamped marker whenever a tile streams in, which made a
* whole-city view stutter while zooming, so it is only the placeholder for
* a cell that has not resolved yet.
* @param {{latitude:number, longitude:number}} record Camera.
* @returns {{position: Cesium.Cartesian3, clamped: boolean}} Placement.
*/
function markerPlacement(record) {
const floor = cachedGroundFloor(record.latitude, record.longitude);
const clamped = !validAlprGroundHeight(floor);
return {
clamped,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
clamped ? 0 : floor + MARKER_FLOOR_LIFT_M,
),
};
}
/** Records the current view would display (viewport box, nearest first when capped). */
function viewRecords(records) {
const box = viewportBox(state.viewer);
if (!box) return { box, visible: [], capped: false };
let visible = records.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
);
const capped = visible.length > MAX_RENDERED;
if (capped) {
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
const distance = (record) =>
(record.latitude - lat) ** 2 + ((record.longitude - lon) * scale) ** 2;
visible = visible
.map((record) => ({ record, d: distance(record) }))
.sort((a, b) => a.d - b.d)
.slice(0, MAX_RENDERED)
.map((item) => item.record);
}
return { box, visible, capped };
}
/**
* Resolve the floor cells of the records this view will display, waiting at
* most the ground service's bounded deadline. Never throws.
* @param {Array<object>} records Candidate records.
* @returns {Promise<void>}
*/
async function prepareFloors(records) {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (typeof resolve !== 'function') return;
const cold = viewRecords(records)
.visible.filter(
(record) =>
!validAlprGroundHeight(
cachedGroundFloor(record.latitude, record.longitude),
),
)
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
if (!cold.length) return;
try {
await floors.prepare(cold);
} catch {
/* markers fall back to clamping until their floors resolve */
}
}
/**
* Loaded cameras that project inside the canvas and in front of the globe.
* "Nearby" spans twice the camera-to-ground range, so a view can hold
* cameras while none is on screen; the row then says so rather than
* leaving the user to wait for markers. Runs once per render pass.
* @returns {number|null} Count, or null without a scene to project into.
*/
function countOnScreen() {
const scene = state.viewer?.scene;
const canvas = scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
const camera = state.viewer?.camera;
if (!scene || !width || !height || !camera?.positionWC) return null;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.positionWC,
);
let count = 0;
try {
for (const entity of state.dataSource.entities.values) {
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
if (!position || !occluder.isPointVisible(position)) continue;
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
);
if (
point &&
point.x >= 0 &&
point.x <= width &&
point.y >= 0 &&
point.y <= height
)
count += 1;
}
} catch {
// A scene without a usable frame (startup, teardown) cannot answer.
return null;
}
return count;
}
/** Write a marker position, respecting the overlay's pick-target swap. */
function setMarkerPosition(entity, position, clamped) {
const heightReference = clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const saved = entity.gevAlprNativeAppearance;
if (saved) {
saved.position = position;
saved.heightReference = heightReference;
} else {
entity.position = position;
entity.billboard.heightReference = heightReference;
}
entity.gevAlprClamped = clamped;
}
/** Refresh all native placements, including the appearances held by the canvas. */
function refreshMarkerPlacements() {
if (!state.enabled || !state.dataSource) return;
clampedMarkers.clear();
for (const entity of state.dataSource.entities.values) {
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
setMarkerPosition(entity, position, clamped);
if (clamped) clampedMarkers.set(entity.id, entity);
}
resolveClampedFloors();
governorRequestRender('alpr-surface');
}
/** Resolve floors for clamped markers once, then place them in place. */
function resolveClampedFloors() {
const resolve = services.groundFloor.resolveGroundFloorCells;
if (floorResolve || !clampedMarkers.size || typeof resolve !== 'function')
return;
const batch = [...clampedMarkers.values()];
const request = Promise.resolve(
floors.resolve(
batch.map((entity) => ({
lat: entity.gevAlprRecord.latitude,
lon: entity.gevAlprRecord.longitude,
})),
),
)
.catch(() => {})
.then(() => {
if (floorResolve !== request) return;
floorResolve = null;
if (!state.enabled || !state.dataSource) return;
let moved = false;
for (const entity of batch) {
if (clampedMarkers.get(entity.id) !== entity) continue;
clampedMarkers.delete(entity.id);
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
if (clamped) continue;
setMarkerPosition(entity, position, false);
moved = true;
}
if (moved) governorRequestRender('alpr-floor');
// Cells that never resolved stay clamped; later renders retry them.
});
floorResolve = request;
}
function updateAppearance(entity, selected) {
const color = Cesium.Color.fromCssColorString(
@@ -44,12 +235,6 @@ export function createAlprPresentation({ state, services, source }) {
entity.billboard.width = entity.billboard.height = selected
? SELECTED_MARKER_ICON_SIZE
: MARKER_ICON_SIZE;
if (entity.polyline) {
entity.polyline.width = selected ? 3 : 1.75;
entity.polyline.material = color.withAlpha(selected ? 0.98 : 0.82);
}
if (entity.polygon)
entity.polygon.material = color.withAlpha(selected ? 0.8 : 0.2);
if (entity.gevLabelModel) {
entity.gevLabelModel.accent = color.toCssColorString();
entity.gevLabelModel.leaderAnimationStartedAt = selected
@@ -118,13 +303,18 @@ export function createAlprPresentation({ state, services, source }) {
}
function clearRendered() {
hideOnMapCredit();
floors.cancel();
floorResolve = null;
overlay.clear();
visibleRecords = [];
clampedMarkers.clear();
if (state.dataSource?.entities) state.dataSource.entities.removeAll();
removeEntityContextsForLayer(LAYER_ID);
}
function hideOnMapCredit() {
services.credits?.hideOsmCredit?.(state.viewer, LAYER_ID);
clearTimeout(state.creditTimer);
state.creditTimer = null;
if (state.credit)
@@ -133,6 +323,14 @@ export function createAlprPresentation({ state, services, source }) {
}
function presentOnMapCredit() {
if (
state.enabled &&
source.attribution?.name === 'OpenStreetMap' &&
services.credits?.showOsmCredit
) {
services.credits.showOsmCredit(state.viewer, LAYER_ID);
return;
}
if (!state.enabled || state.creditPresented || !state.credit) return;
state.creditPresented = true;
state.viewer.creditDisplay?.addStaticCredit(state.credit);
@@ -142,19 +340,10 @@ export function createAlprPresentation({ state, services, source }) {
function renderRecords() {
const selectedContext = getSelectedEntityContext();
const box = viewportBox(state.viewer);
const visible = box
? state.records
.filter((record) =>
boxContains(box, {
south: record.latitude,
north: record.latitude,
west: record.longitude,
east: record.longitude,
}),
)
.slice(0, MAX_RENDERED)
: [];
// Over the render cap, keep the cameras nearest the view centre and say
// coverage is limited rather than dropping an arbitrary subset.
const { visible, capped } = viewRecords(state.records);
state.renderSaturated = capped;
visibleRecords = visible;
// A refresh may retain its own selection, never reclaim one cleared or
// replaced by an aircraft, another layer, or a voice action.
@@ -168,8 +357,14 @@ export function createAlprPresentation({ state, services, source }) {
}
governorRequestRender('alpr-render');
const visibleIds = new Set(visible.map((record) => record.id));
let changed = false;
state.dataSource.entities.suspendEvents();
for (const entity of [...state.dataSource.entities.values]) {
if (!visibleIds.has(entity.id)) state.dataSource.entities.remove(entity);
if (!visibleIds.has(entity.id)) {
state.dataSource.entities.remove(entity);
clampedMarkers.delete(entity.id);
changed = true;
}
}
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
for (const record of visible) {
@@ -180,10 +375,11 @@ export function createAlprPresentation({ state, services, source }) {
}
const color = markerColor();
const selected = record.id === state.selectedId;
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
);
const { position, clamped } = markerPlacement(record);
// Direction wedges are painted by the overlay for the nearest cameras.
// Native ground-clamped wedges (a ground polyline plus a classification
// polygon per camera) cost ~40 ms per frame for 200 downtown cameras on
// photoreal tiles, so markers stay billboards only.
const entityDef = {
id: record.id,
position,
@@ -191,38 +387,30 @@ export function createAlprPresentation({ state, services, source }) {
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
heightReference: clamped
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(350, 1.15, 4_000_000, 0.48),
// City-wide views hold hundreds of badges; let them shrink.
scaleByDistance: new Cesium.NearFarScalar(
...MARKER_SCALE_BY_DISTANCE,
),
},
};
const wedge = directionWedgePositions(record);
if (wedge) {
entityDef.polyline = {
positions: [wedge[1], position, wedge[2]],
width: selected ? 3 : 1.75,
material: color.withAlpha(0.82),
clampToGround: true,
};
entityDef.polygon = {
hierarchy: wedge,
material: color.withAlpha(0.2),
height: 0,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
classificationType: Cesium.ClassificationType.BOTH,
};
}
let entity = existing;
// Keep the Cesium entity and its ground-clamping subscription while its
// geometry is unchanged. Updating metadata must not rebuild the marker.
const previous = entity?.gevAlprRecord;
if (!entity) entity = state.dataSource.entities.add(entityDef);
else {
if (!entity) {
entity = state.dataSource.entities.add(entityDef);
entity.gevAlprClamped = clamped;
changed = true;
} else {
if (
previous.latitude !== record.latitude ||
previous.longitude !== record.longitude
) {
entity.position = position;
setMarkerPosition(entity, position, clamped);
entity.gevAlprCanvasPosition = null;
entity.gevAlprDisplayPosition = null;
}
@@ -231,8 +419,6 @@ export function createAlprPresentation({ state, services, source }) {
previous.longitude !== record.longitude ||
previous.directionDeg !== record.directionDeg
) {
entity.polyline = entityDef.polyline;
entity.polygon = entityDef.polygon;
entity.gevAlprCanvasPosition = null;
entity.gevAlprWedge = null;
}
@@ -281,6 +467,12 @@ export function createAlprPresentation({ state, services, source }) {
},
});
}
state.dataSource.entities.resumeEvents();
if (changed) state.renderRevision += 1;
state.onScreen = countOnScreen();
for (const entity of state.dataSource.entities.values)
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
resolveClampedFloors();
const selectedEntity = state.selectedId
? state.dataSource.entities.getById(state.selectedId)
: null;
@@ -288,9 +480,9 @@ export function createAlprPresentation({ state, services, source }) {
updateSelectedAnchor();
overlay.sync(visible);
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
// Start only when mapped data is actually displayed, not while an upstream
// request or a zoom-in prompt could consume the five-second introduction.
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
if (visible.length) presentOnMapCredit();
else hideOnMapCredit();
}
function focusNearest() {
@@ -458,5 +650,6 @@ export function createAlprPresentation({ state, services, source }) {
clearSelection,
updateSelectedAnchor,
installInteraction,
prepareFloors,
};
}
+191 -2
View File
@@ -1,10 +1,199 @@
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
import {
OVERPASS_URL,
QUERY_LIMIT,
MAX_VIEWPORT_DEGREES,
QUERY_SNAP_DEGREES,
QUERY_LIMIT,
DETAIL_MIN_ZOOM,
DETAIL_MAX_ZOOM,
DETAIL_MAX_TILES,
SOURCE_RECORD_LIMIT,
PRECISION_CACHE_LIMIT,
} from './policy.js';
import {
createVectorTileSource,
validTileBounds,
} from '../../sources/vectorTiles.js';
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
import { decodeAlprTile } from './tileRecords.js';
/**
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
* or null when even z9 would not (the layer asks the user to zoom in).
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @returns {number|null} Tile zoom.
*/
export function alprDetailZoom(box) {
for (let zoom = DETAIL_MAX_ZOOM; zoom >= DETAIL_MIN_ZOOM; zoom--)
if (
tilesForBounds(box, zoom, { maxTiles: DETAIL_MAX_TILES + 1 }).length <=
DETAIL_MAX_TILES
)
return zoom;
return null;
}
/**
* Tile coordinates are quantized per zoom (about 19 m at z9, 2 m at z12).
* Remember each camera's finest position so zooming out never nudges a marker
* that a closer view already placed precisely. Bounded, oldest first.
* @param {number} [limit] Cameras remembered.
* @returns {{apply: (record: object, zoom: number) => object, clear: () => void,
* size: () => number}} Cache.
*/
export function createAlprPrecisionCache(limit = PRECISION_CACHE_LIMIT) {
const known = new Map();
return {
apply(record, zoom) {
const finer = known.get(record.id);
if (finer && finer.zoom > zoom)
return finer.latitude === record.latitude &&
finer.longitude === record.longitude
? record
: { ...record, latitude: finer.latitude, longitude: finer.longitude };
known.delete(record.id);
known.set(record.id, {
zoom,
latitude: record.latitude,
longitude: record.longitude,
});
while (known.size > limit) known.delete(known.keys().next().value);
return record;
},
clear: () => known.clear(),
size: () => known.size,
};
}
/** Construct the viewport-bounded OpenStreetMap hourly extract adapter. */
export function createAlprTileSource({
tileFetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const sources = ['us', 'ca'].map((country) =>
createVectorTileSource({
tileJsonUrl: `https://tiles.dontgetflocked.com/cameras-${country}-hourly.json`,
allowedOrigin: 'https://tiles.dontgetflocked.com',
decode: decodeAlprTile,
fetchImpl: tileFetchImpl,
maxTiles: DETAIL_MAX_TILES,
ttlMs: 60 * 60 * 1000,
}),
);
const precision = createAlprPrecisionCache();
const precise = precision.apply;
return {
/**
* Read the camera records for every extract tile covering `box`.
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @param {AbortSignal} [signal] Cancellation.
* @returns {Promise<{records: Array<object>, stale: boolean, saturated: boolean,
* noCoverage?: boolean, zoomIn?: boolean, coverage?: object, zoom?: number}>}
* Records inside the fetched tiles; `coverage` is those tiles' bounds.
*/
async fetch(box, signal) {
if (
!validTileBounds(box) ||
box.north - box.south >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9 ||
box.east - box.west >
MAX_VIEWPORT_DEGREES + 2 * QUERY_SNAP_DEGREES + 1e-9
)
throw new TypeError('ALPR requires a bounded city viewport');
signal?.throwIfAborted();
// Fast geographic rejection; the exact extract extents below refine coverage.
if (box.east < -180 || box.west > -50 || box.north < 17 || box.south > 84)
return {
records: [],
stale: false,
saturated: false,
noCoverage: true,
};
const zoom = alprDetailZoom(box);
if (zoom === null)
return { records: [], stale: false, saturated: false, zoomIn: true };
const tiles = tilesForBounds(box, zoom, {
maxTiles: DETAIL_MAX_TILES + 1,
});
const tileBounds = tiles.map((tile) => tileToBBox(zoom, tile.x, tile.y));
const coverage = {
south: Math.min(...tileBounds.map((b) => b.south)),
west: Math.min(...tileBounds.map((b) => b.west)),
north: Math.max(...tileBounds.map((b) => b.north)),
east: Math.max(...tileBounds.map((b) => b.east)),
};
const records = new Map();
let covered = false,
partial = false;
for (const [index, source] of sources.entries()) {
if (
index === 1 &&
(box.north < 41 ||
box.south > 84 ||
box.east < -142 ||
box.west > -52)
)
continue;
const metadata = await source.getMetadata(signal);
const [west, south, east, north] = metadata.bounds || [];
if (![west, south, east, north].every(Number.isFinite))
throw new Error('Camera coverage unavailable');
if (
box.east < west ||
box.west > east ||
box.north < south ||
box.south > north
)
continue;
covered = true;
const result = await source.fetchBounds(box, { zoom, signal });
partial ||= result.partial;
for (const record of result.tiles.flat())
records.set(record.id, precise(record, zoom));
}
signal?.throwIfAborted();
let list = [...records.values()];
const truncated = list.length > SOURCE_RECORD_LIMIT;
if (truncated) {
// Keep the cameras nearest the view centre when a dense metro overflows.
const lat = (box.south + box.north) / 2;
const lon = (box.west + box.east) / 2;
const scale = Math.cos((lat * Math.PI) / 180);
list = list
.map((record) => ({
record,
d:
(record.latitude - lat) ** 2 +
((record.longitude - lon) * scale) ** 2,
}))
.sort((a, b) => a.d - b.d)
.slice(0, SOURCE_RECORD_LIMIT)
.map((item) => item.record);
}
return {
records: list,
stale: false,
saturated: partial || truncated,
noCoverage: !covered,
// A partial or truncated read does not cover its tiles; reuse only the box.
coverage: partial || truncated ? { ...box } : coverage,
zoom,
};
},
detailZoom: alprDetailZoom,
destroy() {
for (const source of sources) source.clear();
precision.clear();
},
label: 'OpenStreetMap · community mapped',
attribution: {
name: 'OpenStreetMap',
description: '© OpenStreetMap contributors, ODbL',
text: '© OpenStreetMap contributors, ODbL',
href: 'https://www.openstreetmap.org/copyright',
},
};
}
/** Construct the bounded OSM request adapter without starting a request. */
export function createOverpassAlprSource({
fetchImpl = (...args) => globalThis.fetch(...args),
@@ -75,7 +264,7 @@ export function createOverpassAlprSource({
attribution: {
name: 'OpenStreetMap',
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
text: '© OpenStreetMap',
text: '© OpenStreetMap contributors',
href: 'https://www.openstreetmap.org/copyright',
},
};
+211 -38
View File
@@ -1,12 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOverpassAlprSource } from './source.js';
import { readFileSync } from 'node:fs';
import {
alprDetailZoom,
createAlprPrecisionCache,
createAlprTileSource,
createOverpassAlprSource,
buildOverpassQuery,
} from './source.js';
import { validateAlprSnapshot, alprCreditMarkup } from './model.js';
const box = { south: 30, west: -98, north: 30.1, east: -97.9 };
test('the source rejects invalid and unbounded queries before fetching', async () => {
let calls = 0;
const source = createOverpassAlprSource({
fetchImpl() {
const source = createAlprTileSource({
tileFetchImpl() {
calls++;
},
});
@@ -21,55 +28,77 @@ test('the source rejects invalid and unbounded queries before fetching', async (
}
assert.equal(calls, 0);
});
test('cancellation during body parsing rejects even when the transport ignores it', async () => {
const fixture = readFileSync(
new URL(
'../../data/fixtures/osm-alpr-austin-11-467-843.pbf',
import.meta.url,
),
);
const metadata = (country) => ({
tiles: [
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
],
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
});
const austin = { south: 30.2, north: 30.35, west: -97.85, east: -97.65 };
test('cancellation while reading TileJSON is preserved even if fetch ignores it', async () => {
const abort = new AbortController();
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: true,
headers: new Headers(),
json: async () => {
async text() {
abort.abort();
return { elements: [] };
return JSON.stringify(metadata('us'));
},
}),
});
await assert.rejects(source.fetch(box, abort.signal), { name: 'AbortError' });
await assert.rejects(source.fetch(austin, abort.signal), {
name: 'AbortError',
});
});
test('Overpass normalization returns camera records and preserves limited/stale coverage', async () => {
const source = createOverpassAlprSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'x-overpass-cache': 'STALE' }),
json: async () => ({
elements: [
{
type: 'node',
id: 12,
lat: 30,
lon: -98,
tags: {
'surveillance:type': 'camera;ALPR',
'camera:direction': '90',
},
},
],
}),
}),
test('extract detail tiles map OSM records and repeated pans reuse decoded tiles', async () => {
const calls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
calls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const snapshot = await source.fetch(box);
assert.equal(snapshot.records[0].id, 'alpr:12');
assert.equal(snapshot.records[0].latitude, 30);
assert.equal(snapshot.records[0].directionDeg, 90);
assert.equal(snapshot.stale, true);
assert.equal(snapshot.saturated, false);
const snapshot = await source.fetch(austin);
assert.ok(snapshot.records.length > 0);
const record = snapshot.records[0];
assert.match(record.id, /^alpr:/);
assert.equal(record.manufacturer, 'Flock Safety');
assert.equal(record.lastVerified, null);
assert.ok(record.osmTimestamp);
assert.equal(snapshot.stale, false);
assert.equal('elements' in snapshot, false);
const fetched = calls.length;
await source.fetch(austin);
assert.equal(calls.length, fetched);
assert.ok(calls.every((url) => !url.includes('overpass')));
assert.doesNotMatch(JSON.stringify(source.attribution), /flock/i);
});
test('outside extract coverage is an explicit no-data state without Overpass', async () => {
const source = createAlprTileSource({
tileFetchImpl: () => assert.fail('no network expected in Europe'),
});
assert.deepEqual(
await source.fetch({ south: 51, north: 51.1, west: 0, east: 0.1 }),
{ records: [], stale: false, saturated: false, noCoverage: true },
);
});
test('an unsuccessful source response releases its body before reporting an error', async () => {
let cancelled = 0;
const source = createOverpassAlprSource({
fetchImpl: async () => ({
const source = createAlprTileSource({
tileFetchImpl: async () => ({
ok: false,
status: 429,
body: {
@@ -79,7 +108,7 @@ test('an unsuccessful source response releases its body before reporting an erro
},
}),
});
await assert.rejects(source.fetch(box), /rate-limited/);
await assert.rejects(source.fetch(box), /unavailable/);
assert.equal(cancelled, 1);
});
@@ -115,3 +144,147 @@ test('provider attribution escapes markup and rejects executable links', () => {
assert.ok(html.includes('?a=1&amp;b=2'));
assert.ok(!html.includes('<img'));
});
test('wide detail view returns zoom guidance before any fetch and can retry a smaller view', async () => {
let calls = 0;
const source = createAlprTileSource({
tileFetchImpl: async () => {
calls++;
return Response.json({
tiles: ['https://tiles.dontgetflocked.com/{z}/{x}/{y}.pbf'],
bounds: [-180, 17, -50, 84],
});
},
});
const wide = await source.fetch({
south: 29,
north: 32,
west: -99,
east: -96,
});
assert.equal(wide.zoomIn, true);
assert.equal(calls, 0);
await source
.fetch({ south: 30.267, north: 30.268, west: -97.744, east: -97.743 })
.catch(() => {});
assert.ok(calls > 0);
});
test('each view reads the finest attributed extract zoom that fits 16 tiles', () => {
// Field report: a z11-only read refused whole-city views. Attributed detail
// exists from z9, so a city reads a few z9/z10 tiles and a street z12.
assert.equal(
alprDetailZoom({ south: 30.29, north: 30.31, west: -97.78, east: -97.75 }),
12,
);
assert.equal(
alprDetailZoom({ south: 30.17, north: 30.36, west: -97.85, east: -97.63 }),
12,
);
assert.equal(
alprDetailZoom({ south: 29.7, north: 30.7, west: -98.25, east: -97.25 }),
10,
);
assert.equal(
alprDetailZoom({ south: 29.2, north: 31.2, west: -98.75, east: -96.75 }),
9,
);
assert.equal(
alprDetailZoom({ south: 29, north: 32, west: -99, east: -96 }),
null,
);
});
test('a whole-city view loads cameras and reports the tiles it covers', async () => {
const urls = [];
const source = createAlprTileSource({
tileFetchImpl: async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata(url.includes('-ca-') ? 'ca' : 'us'))
: new Response(fixture);
},
});
const city = { south: 29.95, north: 30.55, west: -98.05, east: -97.45 };
const snapshot = await source.fetch(city);
assert.equal(snapshot.zoomIn, undefined);
assert.equal(snapshot.zoom, 10);
assert.ok(snapshot.records.length > 0);
assert.ok(urls.some((url) => url.includes('/10/')));
for (const key of ['south', 'west'])
assert.ok(snapshot.coverage[key] <= city[key]);
for (const key of ['north', 'east'])
assert.ok(snapshot.coverage[key] >= city[key]);
});
test('coarser tiles never move a camera a finer tile already placed', () => {
const cache = createAlprPrecisionCache(2);
const fine = {
id: 'alpr:1',
latitude: 30.30001,
longitude: -97.76001,
operator: 'A',
};
assert.equal(cache.apply(fine, 12), fine);
const coarse = {
...fine,
latitude: 30.3001,
longitude: -97.7602,
operator: 'B',
};
const kept = cache.apply(coarse, 9);
assert.equal(kept.latitude, fine.latitude);
assert.equal(kept.longitude, fine.longitude);
assert.equal(kept.operator, 'B', 'attributes still refresh');
const finer = { ...fine, latitude: 30.300012 };
assert.equal(cache.apply(finer, 13), finer);
cache.apply({ id: 'alpr:2', latitude: 1, longitude: 1 }, 12);
cache.apply({ id: 'alpr:3', latitude: 1, longitude: 1 }, 12);
assert.equal(cache.size(), 2, 'bounded');
});
test('the explicit Overpass factory posts the unchanged query to its API transport', async () => {
const calls = [];
const source = createOverpassAlprSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
elements: [
{
type: 'node',
id: 42,
lat: 30.05,
lon: -97.95,
tags: { 'surveillance:type': 'ALPR', operator: 'Test' },
},
],
});
},
});
const result = await source.fetch(box);
assert.equal(result.records.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/overpass');
assert.equal(calls[0].options.method, 'POST');
assert.equal(
new URLSearchParams(calls[0].options.body).get('data'),
buildOverpassQuery(box.south, box.west, box.north, box.east),
);
});
test('the tile adapter never passes public URLs through an injected API transport', async (t) => {
const urls = [];
t.mock.method(globalThis, 'fetch', async (url) => {
urls.push(url);
return url.endsWith('.json')
? Response.json(metadata('us'))
: new Response(fixture);
});
const source = createAlprTileSource({
fetchImpl: () => assert.fail('API transport must not receive tile URLs'),
});
const result = await source.fetch(austin);
assert.ok(result.records.length);
assert.ok(urls.length > 1);
assert.ok(urls.every((url) => url.startsWith('https://tiles.')));
});
+43
View File
@@ -0,0 +1,43 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { normalizeAlprNode } from './records.js';
/** Map extract attributes without treating an OSM edit date as a field verification. */
export function normalizeAlprTileFeature(feature) {
const p = feature?.properties || {};
if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null;
const [lon, lat] = feature.geometry.coordinates;
const record = normalizeAlprNode({
type: 'node',
id: Number(p.osmId),
lat,
lon,
tags: {
'surveillance:type': 'ALPR',
operator: p.operator,
manufacturer: p.brand,
'camera:type': p.cameraType,
'camera:direction': p.direction,
'surveillance:zone': p.surveillanceZone,
ref: p.ref,
check_date: p.check_date,
},
});
return record ? { ...record, osmTimestamp: p.osmTimestamp || null } : null;
}
/** Decode detail-level camera points; geometry-only heatmap tiles are not records. */
export function decodeAlprTile(bytes, z, x, y) {
const layer = new VectorTile(new PbfReader(bytes)).layers.cameras;
if (!layer) return [];
if (layer.length > 40_000)
throw new Error('Camera tile feature limit exceeded');
const records = [];
for (let i = 0; i < layer.length; i++) {
const record = normalizeAlprTileFeature(
layer.feature(i).toGeoJSON(x, y, z),
);
if (record) records.push(record);
}
return records;
}
+40 -5
View File
@@ -1,6 +1,25 @@
import * as Cesium from 'cesium';
import { destinationPointDeg } from './model.js';
import { DIRECTION_CONE_M, DIRECTION_CONE_HALF_ANGLE_DEG } from './policy.js';
import {
DIRECTION_CONE_M,
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
* native badges (linear between near and far, clamped outside).
* @param {number} distanceM Camera-to-marker distance.
* @returns {number} Scale factor.
*/
export function markerScale(distanceM) {
const [near, nearScale, far, farScale] = MARKER_SCALE_BY_DISTANCE;
if (!Number.isFinite(distanceM) || distanceM <= near) return nearScale;
if (distanceM >= far) return farScale;
return (
nearScale + ((distanceM - near) / (far - near)) * (farScale - nearScale)
);
}
// Camera artwork and visual treatment contributed by Manjunath (@manjunath22466).
// Module-relative assets also resolve when the layer is consumed by another app.
@@ -48,8 +67,16 @@ export function alprLabelDetails(record, source) {
return details;
}
/** Illustrative bearing wedge, not measured field of view or operating range. */
export function directionWedgePositions(record, heightM = 0) {
/**
* Illustrative bearing wedge, not measured field of view or operating range.
* @param {object} record Camera record.
* @param {number} [heightM] Apex height.
* @param {function(number, number): (number|null)} [heightAt] Optional height
* for the two far corners (degrees lat, lon), so the wedge follows a slope
* instead of floating level from the apex; falls back to the apex height.
* @returns {Array<Cesium.Cartesian3>|null} Apex, left and right corners.
*/
export function directionWedgePositions(record, heightM = 0, heightAt = null) {
if (!Number.isFinite(record.directionDeg)) return null;
const left = destinationPointDeg(
record.latitude,
@@ -63,10 +90,18 @@ export function directionWedgePositions(record, heightM = 0) {
record.directionDeg + DIRECTION_CONE_HALF_ANGLE_DEG,
DIRECTION_CONE_M,
);
const corner = (point) => {
const height = heightAt?.(point.latitude, point.longitude);
return Number.isFinite(height) ? height : heightM;
};
return [
Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, heightM),
Cesium.Cartesian3.fromDegrees(right.longitude, right.latitude, heightM),
Cesium.Cartesian3.fromDegrees(left.longitude, left.latitude, corner(left)),
Cesium.Cartesian3.fromDegrees(
right.longitude,
right.latitude,
corner(right),
),
];
}
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createControls } from './controls.js';
import { createSubject } from './subject.js';
test('passive Contacts releases its installation anchor and subject refresh cannot reclaim it', (t) => {
const prior = globalThis.document;
globalThis.document = { body: { classList: { contains: () => false } } };
t.after(() => {
globalThis.document = prior;
});
const anchors = [];
const position = Cesium.Cartesian3.fromDegrees(-117.14, 32.87);
const state = {
enabled: true,
passive: false,
subject: {
id: 'site',
layerId: 'military-installations',
label: 'Site',
position,
},
sourceRevision: 'current',
lastEvaluatedPosition: position,
results: {},
};
const services = {
installations: { setContextAnchor: (value) => anchors.push(value) },
};
let activations = 0;
const parts = {
dependencies: { activateOperationalContext: () => activations++ },
queries: {
collectSourceStates: () => ({}),
sourceRevision: () => 'current',
},
navigation: { isFlightLayer: () => false },
model: { awarenessRefreshRequired: () => false },
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
};
const controls = createControls({ state, services, parts }).methods;
const subject = createSubject({ state, services, parts });
subject.refreshSelectedSubject();
assert.equal(anchors.length, 1);
assert.ok(anchors[0] instanceof Cesium.Cartesian3);
controls.setParams({ passive: true });
assert.equal(anchors.at(-1), null);
const released = anchors.length;
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(
anchors.length,
released,
'passive refresh must not reclaim the viewport',
);
controls.setParams({ passive: false });
assert.equal(activations, 1);
subject.refreshSelectedSubject();
assert.ok(anchors.at(-1) instanceof Cesium.Cartesian3);
});
+51
View File
@@ -29,6 +29,7 @@ export function createControls({ state: layerState, services, parts, source }) {
if (typeof params.passive !== 'boolean') return;
const wasPassive = layerState.passive;
layerState.passive = params.passive;
if (layerState.passive) services.installations?.setContextAnchor?.(null);
if (layerState.enabled && wasPassive && !layerState.passive)
parts.dependencies.activateOperationalContext();
},
@@ -74,6 +75,56 @@ export function createControls({ state: layerState, services, parts, source }) {
);
},
/**
* Return the exact retained aircraft cohorts behind the current Contacts
* panel snapshot. Voice reads this immutable copy instead of re-scanning
* live layers, so panel and spoken counts share one evaluation timestamp.
*/
getAircraftQuerySnapshot() {
if (!layerState.enabled || !layerState.subject || !layerState.results)
return null;
// Build every public count from this exact results object before
// returning. A refresh may replace `layerState.results` as soon as the
// caller awaits, so consumers must not make a second read later and
// accidentally combine two evaluation timestamps.
const panelSnapshot = parts.model.buildAwarenessContextSnapshot(
layerState.results,
parts.model.navigationState(),
{
subjectPresent: !layerState.subjectMissing,
},
);
const cohorts = {};
for (const cohort of layerState.results.cohorts || []) {
if (!['flights', 'military'].includes(cohort?.id)) continue;
cohorts[cohort.id] = {
count: cohort.summary?.count ?? null,
complete:
Number.isFinite(cohort.summary?.count) &&
cohort.summary?.complete !== false,
truncated:
!Number.isFinite(cohort.summary?.count) ||
cohort.summary?.truncated === true,
// These records carry structured values such as Cartesian positions.
// A shallow spread would let a voice consumer mutate the retained
// Contacts cohort through the advertised read-only snapshot.
items: structuredClone(cohort.summary?.navigationNearest || []),
source: cohort.source || null,
provenance: cohort.provenance
? structuredClone(cohort.provenance)
: null,
reason: cohort.summary?.reason || null,
};
}
return {
subject: structuredClone(layerState.results.subject),
evaluatedAt: layerState.results.evaluatedAt,
radiusM: layerState.results.radiusM,
cohorts,
contactsWindow: parts.model.contactsWindowFromSnapshot(panelSnapshot),
};
},
/**
* Release Contact-owned camera tracking without discarding the selected
* subject. Reset-to-globe uses this route so the normal Context FOCUS action
+1
View File
@@ -206,6 +206,7 @@ export function createLifecycle({
layerState.autoFocusAttempted = false;
layerState.autoFocusRetryPending = false;
layerState.subject = null;
services.installations?.setContextAnchor?.(null);
layerState.results = null;
layerState.lastSubjectRefreshMs = 0;
layerState.lastEvaluatedPosition = null;
+11
View File
@@ -144,6 +144,9 @@ export function createModel({ state: layerState, services, parts, source }) {
function contactsWindowFromSnapshot(snapshot) {
if (!snapshot?.subject) return null;
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
['flights', 'military'].includes(cohort?.id),
);
const countFor = (cohortId) => {
const cohort = Array.isArray(snapshot.cohorts)
? snapshot.cohorts.find((item) => item?.id === cohortId)
@@ -164,6 +167,12 @@ export function createModel({ state: layerState, services, parts, source }) {
flights,
military,
vessels: countFor('ais-live-vessels'),
complete:
aircraftCohorts.length === 2 &&
aircraftCohorts.every(
(cohort) =>
Number.isFinite(cohort.count) && cohort.complete !== false,
),
};
}
@@ -190,6 +199,8 @@ export function createModel({ state: layerState, services, parts, source }) {
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
complete: cohort.summary.complete !== false,
truncated: cohort.summary.truncated === true,
reason: cohort.summary.reason,
nearest: cohort.summary.nearest.slice(),
})),
+3
View File
@@ -25,6 +25,7 @@ export function createNavigation({
{
displayLimit = AWARENESS_MAX_EXAMPLES,
navigationLimit = AWARENESS_MAX_NAVIGATION_EXAMPLES,
complete = true,
} = {},
) {
const summary = summarizeAwarenessCohort(items, {
@@ -34,6 +35,8 @@ export function createNavigation({
if (summary.count === null) return summary;
return {
...summary,
complete: Boolean(complete),
truncated: !complete,
nearest: summary.nearest.slice(0, displayLimit),
navigationNearest: summary.nearest,
};
+18 -2
View File
@@ -5,6 +5,15 @@ import {
} from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
if (!Number.isFinite(summary.count)) return '?';
const count = summary.count.toLocaleString('en-US');
return summary.complete === false || summary.truncated === true
? `At least ${count}`
: count;
}
export function createPanel({ state: layerState, services, parts, source }) {
function ensurePanel() {
if (layerState.panel) return layerState.panel;
@@ -61,7 +70,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
function rowHtml(cohort) {
const summary = cohort.summary;
const count = summary.count === null ? '?' : String(summary.count);
const count = formatAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
@@ -72,7 +81,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
return `<li><span class="military-awareness-target unavailable" aria-label="Unavailable">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const accessibleLabel = label === '—' ? 'Unavailable' : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="Focus ${escapeHtml(accessibleLabel)}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button></li>`;
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>`;
})
.join('');
const pageCount = Math.max(
@@ -164,11 +173,18 @@ 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>`
: '';
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${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>`;
+2 -2
View File
@@ -38,8 +38,8 @@ export const AWARENESS_MAX_EXAMPLES = 10;
// so these caps sit far above any realistic in-range population rather than at
// the display limit. They are still FINITE: an unbounded materialization would
// let a pathological feed sort an eleven-thousand-contact array on every
// refresh. Truthfulness does not depend on them — `summarizeAwarenessCohort`
// derives `count` from the full in-range set before any slice.
// refresh. Callers sample beyond this cap and carry explicit completeness, so
// a saturated cohort is a lower bound rather than a silently exact count.
export const AWARENESS_QUERY_LIMIT = 20000;
+50 -10
View File
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// update() both resolve as soon as the first /api/vessels poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
@@ -53,7 +53,12 @@ export function createQueries({ state: layerState, services, parts, source }) {
neverAnswered ||
['unavailable', 'zoom-in'].includes(stats.status) ||
Boolean(stats.error && stats.count === 0);
return { available: !unavailable, stale: Boolean(stats.stale), stats };
return {
available: !unavailable,
enabled,
stale: Boolean(stats.stale),
stats,
};
}
function collectSourceStates() {
@@ -94,14 +99,33 @@ export function createQueries({ state: layerState, services, parts, source }) {
*/
function summarizeInstallationViewport(items, source) {
const coverage = source.stats?.coverage;
// getNearby measures these distances from the current subject, independent
// of the retained square used to fetch installation tiles.
const withinRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? items.filter(
(item) => (item.distanceM ?? item.distance) <= coverage.radiusM,
)
: items;
const summary = parts.navigation.summarizeAwarenessCohortForNavigation(
items,
withinRadius,
source,
);
if (summary.count === null)
return source.stats?.statusMessage
? { ...summary, reason: source.stats.statusMessage }
: summary;
// 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 {
...summary,
reason: summary.count
@@ -128,8 +152,10 @@ export function createQueries({ state: layerState, services, parts, source }) {
* @param {object} [options]
* @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius.
* @param {object|null} [options.subject=null] Contact at the centre, excluded.
* @returns {{flights: Array, military: Array, aircraft: number}|null} Cohorts
* plus the combined aircraft count, or null without a position.
* @returns {{flights: Array, military: Array, aircraft: number,
* completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts,
* their lower-bound completeness, and the combined aircraft count, or
* null without a position.
*/
function collectAircraftProximityWindow(
@@ -137,21 +163,35 @@ export function createQueries({ state: layerState, services, parts, source }) {
{ radiusM = AWARENESS_RADIUS_M, subject = null } = {},
) {
if (!position) return null;
const flights = flightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
// Ask for one usable row beyond the retained limit so a capped cohort is
// reported as a lower bound instead of an exact count. The selected
// subject can consume one slot before exclusion, hence the extra two.
const sampleLimit = AWARENESS_QUERY_LIMIT + 2;
const flightSample = flightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'flights', 'icao24'),
);
const military = militaryFlightsLayer
.getNearby(position, radiusM, AWARENESS_QUERY_LIMIT, {
const militarySample = militaryFlightsLayer
.getNearby(position, radiusM, sampleLimit, {
includeHidden: true,
})
.filter(
(item) => !subject || !isSame(subject, item, 'military', 'icao24'),
);
return { flights, military, aircraft: flights.length + military.length };
const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT);
const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT);
return {
flights,
military,
aircraft: flights.length + military.length,
completeByLayer: {
flights: flightSample.length <= AWARENESS_QUERY_LIMIT,
military: militarySample.length <= AWARENESS_QUERY_LIMIT,
},
};
}
return {
sourceState,
+83
View File
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createControls } from '../installations/controls.js';
import { createModel } from '../installations/model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { AWARENESS_QUERY_LIMIT } from './policy.js';
test('aircraft window marks a capped layer as a lower bound', () => {
const rows = Array.from({ length: AWARENESS_QUERY_LIMIT + 1 }, (_, index) => ({
icao24: `f${index}`,
distance: index,
}));
let requested = null;
const services = {
flights: {
getNearby: (_position, _radius, limit) => {
requested = limit;
return rows.slice(0, limit);
},
},
military: { getNearby: () => [] },
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
},
};
const queries = createQueries({ state: {}, services, parts });
const window = queries.collectAircraftProximityWindow({ center: true });
assert.equal(requested, AWARENESS_QUERY_LIMIT + 2);
assert.equal(window.flights.length, AWARENESS_QUERY_LIMIT);
assert.equal(window.aircraft, AWARENESS_QUERY_LIMIT);
assert.deepEqual(window.completeByLayer, {
flights: false,
military: true,
});
});
test('Contacts excludes square corners and measures from the current moving subject', () => {
const records = [
{ id: 'corner', kind: 'installation', latitude: 90 / 111.195, longitude: 90 / 111.195 },
{ id: 'east', kind: 'installation', latitude: 0, longitude: 90 / 111.195 },
{ id: 'west', kind: 'installation', latitude: 0, longitude: -90 / 111.195 },
];
const installationState = { records, contextAnchor: { latitude: 0, longitude: 0 },
lastUpdate: 1, status: 'ready', coverage: { kind: 'subject', radiusM: 100000 },
distanceEndpointScratch: new Cesium.Cartographic(),
distanceGeodesicScratch: new Cesium.EllipsoidGeodesic(),
};
const installationParts = { rendering: { installationSurfaceHeightM: () => 0 } };
installationParts.model = createModel({ state: installationState, parts: installationParts });
const installations = createControls({ state: installationState, parts: installationParts }).methods;
const services = { installations, flights: { getNearby: () => [] },
military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = Object.fromEntries(['flights', 'military', 'ais-live-vessels', 'military-installations']
.map((id) => [id, { available: true, stats: {} }]));
states['military-installations'].stats.coverage = { kind: 'subject', radiusM: 100000 };
const evaluate = (lon) => subjectOwner.evaluateSubject({
id: 'moving', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(lon, 0, 10000),
}, states).cohorts[3].summary;
installations.setContextAnchor({ latitude: 0, longitude: 0 });
const first = evaluate(0);
assert.equal(first.count, 2);
assert.ok(!first.nearest.some((item) => item.id === 'corner'), '90 km N + 90 km E is outside the disk');
assert.equal(installations.getStats().count, 2);
assert.equal(installations.getStats().statusMessage, '2 mapped sites within 100 km of the contact');
installations.setContextAnchor({ latitude: 0, longitude: 19 / 111.195 });
const moved = evaluate(19 / 111.195);
assert.equal(installations.getStats().count, 1);
assert.equal(installations.getStats().statusMessage, '1 mapped site within 100 km of the contact');
assert.equal(moved.count, 1, 'west site leaves the radius before the fetch anchor refreshes');
assert.equal(moved.nearest[0].id, 'east');
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
+61 -4
View File
@@ -37,50 +37,93 @@ export function createSubject({ state: layerState, services, parts, source }) {
const militaryState = sourceStates.military;
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
id,
name: label,
enabled: sourceState.enabled,
source: retainedSource,
stats: { ...sourceState.stats, source: retainedSource },
};
};
// Same engine the voice analyst calls — see collectAircraftProximityWindow.
const { flights, military } = parts.queries.collectAircraftProximityWindow(
const aircraftWindow = parts.queries.collectAircraftProximityWindow(
position,
{ subject },
);
const { flights, military, completeByLayer } = aircraftWindow;
const vessels = aisLiveVesselsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
);
const coverage = installationsState.stats?.coverage;
const installationRadius =
coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)
? coverage.radiusM
: AWARENESS_RADIUS_M;
const installations = militaryInstallationsLayer
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.getNearby(position, installationRadius, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'military-installations', 'id'),
);
return {
subject,
evaluatedAt: Date.now(),
evaluatedAt,
radiusM: AWARENESS_RADIUS_M,
cohorts: [
{
id: 'flights',
label: 'Flights',
source: flightsState.stats.source || SOURCE_LABEL.flights,
provenance: retainedProvenance(
'flights',
'Flights',
flightsState,
SOURCE_LABEL.flights,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
flights,
flightsState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.flights,
},
),
},
{
id: 'military',
label: 'Military flights',
source: militaryState.stats.source || SOURCE_LABEL.military,
provenance: retainedProvenance(
'military',
'Military flights',
militaryState,
SOURCE_LABEL.military,
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
military,
militaryState,
{
navigationLimit: AWARENESS_QUERY_LIMIT,
complete: completeByLayer.military,
},
),
},
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
vesselsState,
SOURCE_LABEL['ais-live-vessels'],
),
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
vessels,
vesselsState,
@@ -92,7 +135,14 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
coverage: 'CURRENT VIEWPORT ONLY',
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
installationsState,
SOURCE_LABEL['military-installations'],
),
coverage:
installationsState.stats.coverageLabel || 'CURRENT VIEWPORT ONLY',
summary: parts.queries.summarizeInstallationViewport(
installations,
installationsState,
@@ -326,6 +376,11 @@ export function createSubject({ state: layerState, services, parts, source }) {
});
if (!resolved) return;
const { position, presence } = resolved;
// Mapped installations load around the subject, not the (follow/Cockpit)
// camera view. The installation layer moves its window only after the
// subject travels far enough, so this per-refresh call is cheap.
if (!layerState.passive)
militaryInstallationsLayer.setContextAnchor?.(position);
// UNCHECKED leaves the verdict alone: this tick simply did not look.
if (presence === SUBJECT_PRESENCE.LIVE) layerState.subjectMissing = false;
else if (presence === SUBJECT_PRESENCE.MISSING)
@@ -392,6 +447,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
}
@@ -411,6 +467,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
}
function clearAwarenessSubject() {
militaryInstallationsLayer.setContextAnchor?.(null);
layerState.autoFocusRetryPending = false;
layerState.subject = null;
layerState.subjectMissing = false;
+49 -2
View File
@@ -21,9 +21,42 @@ export function createControls({ state: layerState, services, parts, source }) {
return parts.queries.collectDetectableStations(options);
},
/**
* Snapshot loaded bikeshare stations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, city, lat, lon, bikesAvailable, docksAvailable, capacity, renting}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const record of layerState._stationRenderMap.values()) {
if (result.length >= maxCount) break;
const info = layerState._stationInfoCache
.get(record.cityId)
?.get(record.stationId);
if (!Number.isFinite(info?.lat) || !Number.isFinite(info?.lon))
continue;
result.push({
id: record.stationName || `Dock ${record.stationId}`,
name: record.stationName || null,
city: record.cityId || null,
lat: info.lat,
lon: info.lon,
bikesAvailable: num(record.bikesAvailable),
docksAvailable: num(record.docksAvailable),
capacity: num(record.capacity),
renting: record.isRenting !== false,
});
}
return result;
},
/**
* Return current layer statistics for the UI status display.
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
*/
getStats() {
const stats = {
@@ -37,7 +70,21 @@ export function createControls({ state: layerState, services, parts, source }) {
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
}
if (layerState._error) stats.error = layerState._error;
// 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 = 'GBFS fetch error';
} else {
stats.degraded = true;
const allFailed = Array.from(layerState._activeCityIds).every(
(cityId) => layerState._failedCityIds.has(cityId),
);
if (allFailed) stats.stale = true;
}
}
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');
});
+13 -9
View File
@@ -171,30 +171,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();
},
};
+2 -2
View File
@@ -51,7 +51,7 @@ export function createLifecycle({
layerState._lastUpdate = null;
layerState._loading = false;
layerState._loadingOps = 0;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._limitWarned = false;
layerState._overlayHost.setVisible(
@@ -75,7 +75,7 @@ export function createLifecycle({
*/
enable(viewer) {
layerState._enabled = true;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._pointCollection.show = true;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
+8 -119
View File
@@ -1,36 +1,12 @@
import {
extractStationsArray,
normalizeGbfsBool,
parseStationInformation,
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
@@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
+96
View File
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
import { CITY_BY_ID } from './registry.js';
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
const STATION_INFORMATION = {
data: {
@@ -204,6 +205,101 @@ test('the proxy relays its cache policy and upstream host through the mounted ro
);
});
test('the Norwegian city-bike systems are served through the mounted route', async () => {
for (const [id, systemId] of [
['oslo-bysykkel', 'oslobysykkel.no'],
['bergen-bysykkel', 'bergenbysykkel.no'],
['trondheim-bysykkel', 'trondheimbysykkel.no'],
]) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']);
assert.equal(
system.stationStatusUrl,
`https://gbfs.urbansharing.com/${systemId}/station_status.json`,
);
}
await withUpstream(
{
[OSLO.stationInformationUrl]: STATION_INFORMATION,
[OSLO.stationStatusUrl]: STATION_STATUS,
},
async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
const info = await source.getStations(OSLO.stationInformationUrl);
const status = await source.getStations(OSLO.stationStatusUrl);
assert.deepEqual(requested, [
OSLO.stationInformationUrl,
OSLO.stationStatusUrl,
]);
assert.equal(info.data.stations.length, 1);
assert.equal(status.data.stations.length, 1);
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl),
);
assert.equal(response.status, 200);
assert.equal(
response.headers.get('x-gbfs-upstream'),
'gbfs.urbansharing.com',
);
},
);
});
test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => {
const expected = {
'stavanger-kolumbus':
'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
'skien-farte':
'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status',
'lillestrom-bysykkel':
'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
};
const upstream = {};
for (const [id, statusUrl] of Object.entries(expected)) {
const system = CITY_BY_ID.get(id);
assert.ok(system, `${id} is registered`);
assert.equal(system.stationStatusUrl, statusUrl);
upstream[system.stationInformationUrl] = STATION_INFORMATION;
upstream[system.stationStatusUrl] = STATION_STATUS;
}
await withUpstream(upstream, async (requested) => {
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
for (const id of Object.keys(expected)) {
const system = CITY_BY_ID.get(id);
const info = await source.getStations(system.stationInformationUrl);
const status = await source.getStations(system.stationStatusUrl);
assert.equal(info.data.stations.length, 1, `${id} information`);
assert.equal(status.data.stations.length, 1, `${id} status`);
}
assert.equal(requested.length, 6);
// Entur's extensionless station_information keeps its 5-minute cache.
const kolumbus = CITY_BY_ID.get('stavanger-kolumbus');
const response = await mountGbfsProxy()(
'/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl),
);
assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
});
});
test('the mounted route keeps multi-API hosts to their GBFS tree', async () => {
await withUpstream({}, async (requested) => {
const mounted = mountGbfsProxy();
for (const target of [
'https://api.entur.io/journey-planner/v3/station_status',
'https://api.cyclocity.fr/other/station_status.json',
]) {
const response = await mounted('/api/gbfs/' + encodeURIComponent(target));
assert.equal(response.status, 400, target);
}
assert.deepEqual(requested, []);
});
});
test('the query-string shape the client used to send is refused by the mounted route', async () => {
await withUpstream(
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
+186 -32
View File
@@ -23,6 +23,47 @@ function bcycleEntry({
...buildBcycleUrls(systemId),
};
}
function urbanSharingEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`,
stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`,
};
}
function enturEntry({
id,
city,
centerLat,
centerLon,
systemId,
provider,
loadRadiusKm = 100,
}) {
const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`;
return {
id,
city,
centerLat,
centerLon,
loadRadiusKm,
provider,
stationInformationUrl: `${base}/station_information`,
stationStatusUrl: `${base}/station_status`,
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
@@ -48,6 +89,56 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_status.json',
provider: 'Divvy',
},
{
id: 'detroit-mogo',
city: 'Detroit, MI',
centerLat: 42.3314,
centerLon: -83.0458,
loadRadiusKm: 90,
stationInformationUrl:
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://detroit.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'MoGo',
},
{
id: 'pittsburgh-pogoh',
city: 'Pittsburgh, PA',
centerLat: 40.4406,
centerLon: -79.9959,
loadRadiusKm: 90,
stationInformationUrl:
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://pittsburgh.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'POGOH',
},
{
id: 'aspen-we-cycle',
city: 'Aspen, CO',
centerLat: 39.1911,
centerLon: -106.8175,
loadRadiusKm: 90,
stationInformationUrl:
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://aspen.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'WE-cycle',
},
// HIBike serves both Kailua-Kona and Hilo, about 95 km apart; the centre sits
// between them so a camera over either town loads the whole system.
{
id: 'hawaii-island-hibike',
city: 'Kona & Hilo, HI',
centerLat: 19.68,
centerLon: -155.54,
loadRadiusKm: 90,
stationInformationUrl:
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://kona.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'HIBike',
},
{
id: 'dc-capital-bikeshare',
city: 'Washington, DC',
@@ -143,18 +234,6 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Biki',
},
{
id: 'columbus-cogo',
city: 'Columbus, OH',
centerLat: 39.9612,
centerLon: -82.9988,
loadRadiusKm: 90,
stationInformationUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
provider: 'CoGo',
},
{
id: 'chattanooga-bikechatt',
city: 'Chattanooga, TN',
@@ -167,6 +246,18 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Bike Chattanooga',
},
{
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 +325,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 +339,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 +388,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 +417,59 @@ 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',
},
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
@@ -346,11 +498,13 @@ function normalizeRegistryEntry(entry) {
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
// Validate that URLs end with expected GBFS endpoint filenames
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
if (
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}
+58
View File
@@ -0,0 +1,58 @@
// BIKESHARE REGISTRY — a city entry is only useful if the proxy will serve it.
//
// A registry entry and the GBFS proxy allowlist are edited in different files,
// so a city can look correctly registered while every one of its requests is
// rejected at the proxy. These cases hold the two together: each entry is
// checked against the same host and path predicates the proxy enforces, so a
// new city that the proxy would refuse fails here rather than silently
// rendering an empty layer.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GBFS_CITY_REGISTRY, CITY_BY_ID } from './registry.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
test('every registered city is reachable through the GBFS proxy allowlist', () => {
for (const city of GBFS_CITY_REGISTRY) {
for (const raw of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(raw);
assert.equal(
isAllowedGbfsHost(url.hostname),
true,
`${city.id}: host ${url.hostname} is not allowlisted`,
);
assert.equal(
isAllowedGbfsPath(url.pathname, url.hostname),
true,
`${city.id}: path ${url.pathname} is not allowlisted`,
);
}
}
});
test('Bike Share Toronto is registered against the PBSC v2 feeds', () => {
const toronto = CITY_BY_ID.get('toronto-bike-share');
assert.ok(toronto, 'toronto-bike-share is missing from the registry');
assert.equal(toronto.city, 'Toronto, ON');
assert.equal(toronto.provider, 'Bike Share Toronto');
assert.equal(
toronto.stationInformationUrl,
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
);
assert.equal(
toronto.stationStatusUrl,
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
);
});
test('the Toronto center sits inside the system service area', () => {
const toronto = CITY_BY_ID.get('toronto-bike-share');
// Bounding box of the 1,069 live stations, sampled from the published
// station_information feed: 43.588..43.813 N, -79.603..-79.123 W.
assert.ok(toronto.centerLat > 43.588 && toronto.centerLat < 43.813);
assert.ok(toronto.centerLon > -79.603 && toronto.centerLon < -79.123);
// The system spans roughly 37 km east-west, so the load radius has to clear
// half that from the center or downtown pans lose the outer stations.
assert.ok(toronto.loadRadiusKm >= 25);
});
+10 -2
View File
@@ -99,9 +99,17 @@ export function createState({ services }) {
layerState._loadingOps = 0;
/** Most recent error message string, or null. */
/**
* @type {Set<string>} City ids whose most recent fetch failed.
*
* Per city rather than one layer-wide flag: feeds are fetched independently,
* so a retired operator must not mark a layer that other cities are still
* populating as failed. Entries are dropped when their city leaves range.
* The layer reports failure only when these cost it everything it had to
* draw, and degraded otherwise — see `getStats()` in controls.js.
*/
layerState._error = null;
layerState._failedCityIds = new Set();
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CITY_BY_ID } from './registry.js';
import { createModel } from './model.js';
import { createViewport } from './viewport.js';
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
// Each city's PBSC host, and the stations farthest from its registered centre
// in the live feed (checked 2026-10-13). HIBike's centre sits between its two
// towns, so it lists the outermost station in each.
const CITIES = {
'detroit-mogo': {
host: 'detroit.publicbikesystem.net',
provider: 'MoGo',
stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }],
},
'pittsburgh-pogoh': {
host: 'pittsburgh.publicbikesystem.net',
provider: 'POGOH',
stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }],
},
'aspen-we-cycle': {
host: 'aspen.publicbikesystem.net',
provider: 'WE-cycle',
stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }],
},
'hawaii-island-hibike': {
host: 'kona.publicbikesystem.net',
provider: 'HIBike',
stations: [
{ name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 },
{ name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 },
],
},
};
// The production range check, with no viewer or fetches behind it.
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
const { computeInRangeCities } = createViewport({
state: {},
services: { render: { governorRequestRender() {} } },
parts,
source: null,
});
test('the added US PBSC systems are registered and readable through the proxy', () => {
for (const [id, expected] of Object.entries(CITIES)) {
const city = CITY_BY_ID.get(id);
assert.ok(city, `${id} is registered`);
assert.equal(city.provider, expected.provider);
assert.deepEqual(city.hosts, [expected.host]);
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
const url = new URL(feed);
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
}
}
});
test('a camera over each outermost station activates its system', () => {
for (const [id, expected] of Object.entries(CITIES)) {
for (const station of expected.stations) {
assert.ok(
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
`${station.name} activates ${id}`,
);
}
}
});
test('HIBike stays inactive away from Hawaii Island', () => {
// Honolulu is about 270 km from the HIBike centre.
assert.equal(
computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'),
false,
);
});
+17 -2
View File
@@ -122,6 +122,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
const cityIds = Array.from(layerState._activeCityIds);
for (const cityId of cityIds) deactivateCity(cityId);
layerState._activeCityIds.clear();
// A failure belongs to a city in range; none are once everything is off.
layerState._failedCityIds.clear();
layerState._count = 0;
}
/**
@@ -168,11 +171,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._lastUpdate = Date.now();
layerState._error = null;
layerState._failedCityIds.delete(cityId);
} catch (error) {
if (error?.name === 'AbortError') return;
// A rejection from superseded work must not mark (or tear down) a city
// that a newer proximity check or re-enable now owns.
if (
!layerState._enabled ||
!layerState._activeCityIds.has(cityId) ||
generation !== layerState._proximityGeneration
)
return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
layerState._failedCityIds.add(cityId);
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
@@ -208,6 +219,10 @@ export function createViewport({ state: layerState, services, parts, source }) {
deactivateCity(cityId);
}
}
// A failure never outlives its city's range.
for (const cityId of layerState._failedCityIds) {
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
}
layerState._activeCityIds = nextActive;
if (layerState._activeCityIds.size === 0) {
+2
View File
@@ -204,6 +204,8 @@ export function createCatalog({ state: layerState, services, parts, source }) {
? source.groundHeights
: null,
poseSource,
model: String(source.model || ''),
manufacturer: String(source.manufacturer || ''),
};
parts.model.ensureCameraPose(camera);
catalog.push(camera);
+8
View File
@@ -339,6 +339,12 @@ export function createLifecycle({
*/
enable() {
layerState._enabled = true;
if (
layerState._records.some(
(record) => record.camera.cityId === 'warendorf',
)
)
services.credits?.showOsmCredit?.(layerState._viewer, 'cctv');
layerState._lastUpdate = Date.now();
// Pick-ownership (H2): camera billboards use the camera id directly;
// coverage polyline entities use `cctv-<cameraId>-<role>` entity ids.
@@ -385,6 +391,7 @@ export function createLifecycle({
/** Disables the layer: hides entities, stops the projection loop and load queue. */
disable() {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._enabled = false;
unregisterPickOwner('cctv');
// ADJUST mode does not survive a layer toggle — predictable re-entry.
@@ -408,6 +415,7 @@ export function createLifecycle({
* @param {Cesium.Viewer} [viewer] - Viewer instance (falls back to stored ref).
*/
destroy(viewer) {
services.credits?.hideOsmCredit?.(layerState._viewer, 'cctv');
layerState._sourceAbort?.abort();
if (typeof document !== 'undefined')
document.removeEventListener(
+13
View File
@@ -51,5 +51,18 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
cache: 'no-store',
signal,
});
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
},
};
}
+25
View File
@@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and
assert.equal(a.getStats().count, 0);
assert.equal(b.getStats().count, 0);
});
test('frames are read through the registered frame endpoint', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createCctvSource({
fetchImpl: async (url, init) => {
requested.push({ url, cache: init.cache });
return new Response(png, {
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
});
},
});
const frame = await source.getFrame(camera);
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.equal(
requested[0].url.split('?')[0],
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
);
assert.equal(requested[0].cache, 'no-store');
const failing = createCctvSource({
fetchImpl: async () => new Response(null, { status: 502 }),
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
+27 -1
View File
@@ -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}`
+38 -1
View File
@@ -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();
+30
View File
@@ -386,6 +386,7 @@ export function createCyclonesLayer({
})),
},
chips: [],
legendCategorical: true,
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
@@ -403,6 +404,35 @@ export function createCyclonesLayer({
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
/**
* Snapshot loaded active storms as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!enabled || destroyed) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const storm of snapshot?.storms || []) {
if (result.length >= maxCount) break;
const lat = storm.position?.latitude;
const lon = storm.position?.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
result.push({
id: storm.name || storm.id,
name: storm.name || null,
classification: storm.classification || null,
basin: storm.basin || null,
lat,
lon,
windKt: num(storm.windKt),
pressureHpa: num(storm.pressureHpa),
});
}
return result;
},
getStats() {
const storm = selected();
return {
+18 -44
View File
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
* @param {{lat:number, lon:number}} b
* @returns {string}
*/
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
export { directionsRequestUrl } from './source.js';
/**
* Validate a proxy payload into the route record the layer keeps, or null.
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
* route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter.
* @param {{services: object}} options
* @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers.
*/
export function createDirectionsLayer({ services }) {
export function createDirectionsLayer({ services, source }) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
/**
* Where the maneuver card is drawn. Defaults to the shared world-overlay host;
* tests swap it. Resolved on use, not at import, because the services arrive
@@ -618,32 +617,11 @@ export function createDirectionsLayer({ services }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
@@ -695,6 +673,7 @@ export function createDirectionsLayer({ services }) {
}
function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer, 'directions');
_clearStepSelection();
stopStepAnchoring();
removeEntity(_routeEntity);
@@ -800,6 +779,7 @@ export function createDirectionsLayer({ services }) {
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
services.credits?.showOsmCredit?.(_viewer, 'directions');
services.ground.warmGroundFloor(cells);
_anchorAttempts = 0;
_anchorStartedAt = Date.now();
@@ -825,18 +805,12 @@ export function createDirectionsLayer({ services }) {
_error = null;
notifyRow();
try {
const response = await fetch(directionsRequestUrl(mode, _a, _b), {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
const payload = await source.getRoute(
{ mode, a: _a, b: _b },
{
signal: controller.signal,
},
);
if (seq !== _routeSeq || !_enabled) return;
const route = normalizeRoutePayload(payload, mode);
if (!route) {
+34
View File
@@ -0,0 +1,34 @@
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** Acquire route records using caller-owned transport and an optional endpoint. */
export function createDirectionsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/route',
} = {}) {
return {
async getRoute({ mode, a, b }, { signal } = {}) {
signal?.throwIfAborted();
const query = directionsRequestUrl(mode, a, b).split('?')[1];
const response = await fetchImpl(`${endpoint}?${query}`, {
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
signal?.throwIfAborted();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
return payload;
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDirectionsSource } from './source.js';
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
test('route acquisition uses only the supplied transport and endpoint', async () => {
const controller = new AbortController();
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
const calls = [];
const source = createDirectionsSource({
endpoint: 'https://routes.example/route',
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
});
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
assert.equal(calls.length, 1);
const [url, init] = calls[0];
assert.equal(new URL(url).origin, 'https://routes.example');
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
assert.equal(init.signal, controller.signal);
assert.equal(init.headers.Accept, 'application/json');
});
test('aborting during route parsing discards the result', async () => {
const controller = new AbortController();
const source = createDirectionsSource({ fetchImpl: async () => ({
ok: true, json: async () => { controller.abort(); return { ok: true }; },
}) });
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
});
test('route failures are surfaced without fallback requests', async () => {
for (const status of [429, 503]) {
let calls = 0;
const source = createDirectionsSource({ fetchImpl: async () => {
calls++;
return Response.json({}, { status });
} });
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
assert.equal(calls, 1);
}
});
+3 -1
View File
@@ -29,7 +29,8 @@ export function createModel({
* context-store ids, so the two stay consistent).
* @param {Object|null|undefined} fire - Internal fire record.
* @returns {{id: string, lat: number|null, lon: number|null, frp: number|null,
* confidence: number|null, satellite: string|null, acqTime: number|null}}
* confidence: number|null, satellite: string|null, sensor: string|null,
* acqTime: number|null}}
*/
function mapAnalystRecord(fire) {
@@ -45,6 +46,7 @@ export function createModel({
frp: num(fire?.frp),
confidence: num(fire?.confidence), // normalized 0..1 (firmsAdapt.normalizeConfidence)
satellite: text(fire?.satellite) || text(fire?.sensor),
sensor: text(fire?.sensor), // VIIRS or MODIS
acqTime:
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? fire.acqMs : null, // epoch ms; 0 = unparseable → null
};
+6
View File
@@ -7,6 +7,7 @@ export {
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
export const EMERGENCY_TINT =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
// taxiing and landing") -----------------------------------------------------------
// Present-but-grounded planes are RENDERED instead of being skipped: same class
+23 -1
View File
@@ -15,6 +15,7 @@ import {
FOCUS_EVIDENCE_DEV,
FLEET_DR_INTERVAL_MS,
} from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -176,6 +177,10 @@ export function createQueries({
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.
@@ -228,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(
@@ -262,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,
};
+22 -3
View File
@@ -4,6 +4,7 @@ import {
pickRenderAltitudeM,
} from '../../data/renderAltitude.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
@@ -226,6 +227,22 @@ export class FlightRecords {
// like callsign so a transient blank row doesn't blank the field.
originCountry:
stickyText(origin_country, prevMeta?.originCountry) || null,
// Per poll, NOT sticky: an emergency code clears when the crew resets
// it, so a blank squawk must not hold a stale 7700 on screen.
squawk: observation.squawk ?? null,
// One source-switch omission may retain an ADS-B status, while an
// explicit clear or changed squawk always takes effect immediately.
emergency:
observation.emergency === undefined &&
observation.squawk === prevMeta?.squawk &&
prevMeta?.emergency?.source === 'ads-b' &&
(prevMeta?.missingEmergencyPolls ?? 0) < 1
? prevMeta.emergency
: aircraftEmergency(observation),
missingEmergencyPolls:
observation.emergency === undefined
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
: 0,
// OpenSky distinguishes the last position epoch from the last
// transponder message. The fleet coast horizon uses this actual
// contact time so a temporarily old position does not hard-freeze
@@ -258,10 +275,12 @@ export class FlightRecords {
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, likelyLanded }) {
// Partial admissions do not prove absence, but retention remains bounded.
absence(id, { complete, retainIds, likelyLanded }) {
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
if (
!complete &&
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
+34
View File
@@ -106,3 +106,37 @@ test('ground transitions preserve last render height while a surface cell is col
assert.equal(next.meta.renderAltitudeM, 1040);
assert.equal(next.meta.wasAirborne, true);
});
test('complete snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
const store = records();
for (const id of ['abc123', 'def456', 'ghi789'])
store.receive(observation({ id, reference: id }), view());
// abc123 and ghi789 are still listed without a position; def456 vanished.
const absent = (id, likelyLanded = false) =>
store.absence(id, {
complete: true,
retainIds: new Set(['abc123', 'ghi789']),
likelyLanded,
});
assert.deepEqual(
[absent('def456'), absent('def456'), absent('def456')],
['stale', 'stale', 'remove'],
);
assert.equal(absent('abc123'), 'retain');
assert.equal(store.missingPolls.has('abc123'), false);
// A landed contact that lost its position takes the landed fast-cull.
assert.equal(absent('ghi789', true), 'remove');
// Position-less retention stays bounded like partial admission.
store.data.get('abc123').observedReceiptMs = Date.now() - 300001;
assert.equal(absent('abc123'), 'stale');
});
test('civil emergency status follows the latest poll instead of sticking', () => {
const store = records();
const first = store.receive(observation({ squawk: '7700' }), view());
assert.equal(first.meta.squawk, '7700');
assert.equal(first.meta.emergency.kind, 'general');
const next = store.receive(observation({ squawk: '2000' }), view());
assert.equal(next.meta.squawk, '2000');
assert.equal(next.meta.emergency, null);
});
+29 -10
View File
@@ -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,
+18 -1
View File
@@ -179,8 +179,24 @@ export function createFlightSnapshotRenderer({
// as the enrichment path — the class's GLB/scale may have changed.
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time, and a grounded contact is then
// lifted onto the floor under that delayed position. Creating it at
// the raw fix drew it ahead until the next fleet tick snapped it back;
// skipping the floor started a taxiing contact low and then raised it.
const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3());
const displayed =
(delayed &&
motion._floorGroundedDisplayPosition(
icao24,
flightState.records.data.get(icao24) || meta,
delayed,
rendering._modelOwnsVisual(icao24),
)) ||
position;
const bb = flightState._billboardCollection.add({
position,
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
@@ -227,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;
+422
View File
@@ -0,0 +1,422 @@
import * as Cesium from 'cesium';
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: 'Low-accuracy score < 2%',
medium: 'Low-accuracy score 2–10%',
high: '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 || '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:
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
(_lastError && _retryAt
? ` · ${_lastError} · Retrying automatically${
_retryAt <= 8640000000000000
? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC`
: ' after provider cooldown'
}`
: ''),
infoTitle:
'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: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`,
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
levels: Object.fromEntries(
['low', 'medium', 'high'].map((level) => [
level,
_cells.filter((cell) => cell.level === level).length,
]),
),
lastUpdate: _lastUpdate,
error: _lastError,
retryInSec: _retryAt
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
: null,
stale: _stale,
provenance,
coverage: _coverage,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
};
},
};
return layer;
}
+700
View File
@@ -0,0 +1,700 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{
anchor: initialAnchor = { latitude: 50, longitude: 30 },
initialize = true,
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
// The real source refuses an undated snapshot; stubs default to a fresh one.
source: {
async getSnapshot(...args) {
const snapshot = await getSnapshot(...args);
return { ageMs: 0, ...snapshot };
},
},
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
return {
layer,
sources,
anchors,
viewer,
advance(ms) {
clock += ms;
},
now: () => clock,
setAnchor(value) {
anchor = value;
},
};
}
const MIN = 60_000;
const threeHealthy = () => [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
];
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
});
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls, 2, 'one initial read and one read for the latest view');
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
assert.ok(changes.includes('refresh-transition'));
assert.ok(changes.includes('refresh'));
assert.equal(manager.isEnabled(h.layer.id), true);
assert.deepEqual(h.layer.getStats().coverage, {
kind: 'regional',
latitude: 55,
longitude: 20,
radiusNm: 250,
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolvePending;
const h = harness((anchor) => {
calls.push(anchor);
if (calls.length === 2)
return new Promise((resolve) => {
resolvePending = resolve;
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 2, 'only one camera request in flight');
resolvePending({ rows: threeHealthy() });
await flush();
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls.length, 3, 'disable drains the camera timer');
h.layer.destroy();
});
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /need 3 aircraft/);
});
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
const { layer } = harness(async () => ({ rows: [], stale: false }));
assert.equal(
layer.id,
'gnss-integrity',
'the unpublished id describes the measured state',
);
assert.equal(layer.name, 'GNSS Integrity');
assert.doesNotMatch(layer.name, /interference|jamming/i);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ label }) => label),
[
'Low-accuracy score > 10%',
'Low-accuracy score 2–10%',
'Low-accuracy score < 2%',
],
);
const { blurb } = legend[0];
assert.match(blurb, /one aircraft is discounted/);
assert.match(blurb, /not the raw share/);
assert.match(blurb, /not a detection/);
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
assert.equal(layer.provenance.classifier.validated, false);
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
assert.deepEqual(layer.provenance.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(layer.provenance.interpretation.validated, false);
assert.equal(layer.getStats().provenance, layer.provenance);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(
await layer.update(),
true,
'a settling camera is not a failure',
);
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance, now } = harness(async () => ({
rows: threeHealthy(),
ageMs: now() - 1_000,
stale: true,
}));
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(29 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(2 * MIN);
assert.equal(
await layer.update(),
true,
'the replay is not an error, only no evidence',
);
assert.equal(
layer.getStats().count,
0,
'the replay did not restart the window',
);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
fail = true;
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1, 'still inside the window');
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
setAnchor(null);
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, null);
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
}));
layer.enable();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const first = sources[0].get(0);
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
if (calls === 1) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((c) => changes.push(c.type));
assert.equal(await manager.setEnabled(h.layer.id, true), true);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
assert.equal(h.layer.getStats().retryInSec, 30);
assert.match(
h.layer.getRowControls().info,
/HTTP 502.*Retrying automatically at/,
);
assert.equal(changes.includes('visibility-failed'), false);
assert.equal(
await manager.refreshLayer(h.layer.id),
false,
'feed remains honestly unhealthy during cooldown',
);
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(calls, 2);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
assert.equal(h.layer.getStats().count, 1);
assert.equal(h.layer.getStats().retryInSec, null);
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
await manager.destroyAll();
});
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
if (calls === 1)
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
retryAfterMs: 3600000,
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 10, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
await h.layer.update();
assert.equal(calls, 1);
assert.equal(h.layer.getStats().retryInSec, 3570);
h.layer.disable();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
h.layer.enable();
await h.layer.update();
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
h.advance(3540000);
t.mock.timers.tick(3540000);
await flush();
assert.equal(calls, 2);
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
h.layer.destroy();
const stopped = harness(async () => {
throw new Error('network unavailable');
});
stopped.layer.enable();
await stopped.layer.update();
stopped.layer.disable();
stopped.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
stopped.layer.destroy();
});
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
throw new Error('network unavailable');
});
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().retryInSec, 30);
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 60);
h.advance(60000);
t.mock.timers.tick(60000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 120);
assert.equal(calls, 3);
h.layer.destroy();
const fatal = harness(async () => {
throw Object.assign(new Error('invalid request (HTTP 400)'), {
retryable: false,
});
});
fatal.layer.enable();
assert.equal(await fatal.layer.update(), false);
assert.equal(fatal.layer.getStats().retryInSec, null);
fatal.layer.destroy();
});
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
const h = harness(async () => ({
rows: threeHealthy(),
ageMs: 120000,
stale: true,
}));
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
h.layer.destroy();
});
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
return { rows: [] };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
assert.equal(calls, 1);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
assert.equal(calls, 2);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 3);
h.layer.destroy();
});
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolve;
const h = harness((anchor) => {
calls.push(anchor);
return calls.length === 1
? new Promise((r) => (resolve = r))
: Promise.resolve({ rows: threeHealthy() });
});
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 1);
resolve({ rows: threeHealthy() });
assert.equal(await initial, true);
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 2);
h.layer.destroy();
});
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness(async () => ({ rows: [] }));
h.layer.destroy();
let wall = 1800000000000,
mono = 0,
anchor = { latitude: 50, longitude: 30 },
calls = 0;
const layer = createGnssIntegrityLayer({
source: {
getSnapshot: async () => {
calls++;
return { rows: [], ageMs: 0 };
},
},
viewAnchor: () => anchor,
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
assert.equal(calls, 2);
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
*
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). A cluster of aircraft reporting low values in one area is a
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
* such clusters are a well-known public proxy for GNSS interference, but
* avionics faults and old transponders also report low values: suspected
* interference is a second, unvalidated interpretation, never the primary
* state.
*
* Three separately sourced parts make up the method (see `gnssProvenance`):
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
* flag. gpsjam.org does not publish its per-aircraft threshold.
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
*/
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
export const GNSS_MIN_AIRCRAFT = 3;
/** Rolling window over which aircraft seen in a cell are counted. */
export const GNSS_WINDOW_MS = 30 * 60_000;
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
export const GNSS_MEDIUM_PERCENT = 2;
export const GNSS_HIGH_PERCENT = 10;
/** Primary cell state: what the data shows (low navigation accuracy). */
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
export const GNSS_NOMINAL_STATE = 'nominal';
/** Secondary, unvalidated reading of an anomaly cell. */
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
/** The per-aircraft rule; GEV-defined and not validated against known events. */
export const GNSS_CLASSIFIER = Object.freeze({
id: 'gev-nic-nacp-v2',
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
/**
* Machine-readable provenance of the cell output: which part of the method
* comes from where, and which parts are unvalidated.
*
* @param {{windowMs?: number}} [options] The window the layer actually uses.
*/
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
return Object.freeze({
classifier: GNSS_CLASSIFIER,
aggregation: Object.freeze({
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
}),
window: Object.freeze({
minutes: windowMs / 60_000,
scope: 'visited-view',
cellDeg: GNSS_CELL_DEG,
minAircraft: GNSS_MIN_AIRCRAFT,
definedBy: 'GEV',
}),
state: Object.freeze({
id: GNSS_ANOMALY_STATE,
meaning: 'low navigation accuracy reported by ADS-B aircraft',
}),
interpretation: Object.freeze({
id: GNSS_INTERPRETATION,
validated: false,
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
}),
});
}
/** Provenance at the default window. */
export const GNSS_PROVENANCE = gnssProvenance();
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
let { lat, lon } = aircraft;
const derived = (field) =>
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
const nacp =
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb exposes the flag while GPS is lost (15 s–15 min after the
// last good position), and removes it on recovery. Locate that evidence
// at the last good GNSS position, rather than a later MLAT estimate.
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
if (nic === null && nacp === null && !gpsLost) continue;
if (
gpsLost &&
finite(aircraft.gpsOkLat) &&
Math.abs(aircraft.gpsOkLat) <= 90 &&
finite(aircraft.gpsOkLon) &&
Math.abs(aircraft.gpsOkLon) <= 180
) {
lat = aircraft.gpsOkLat;
lon = aircraft.gpsOkLon;
}
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost ||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
(nic !== null &&
nic < GNSS_NIC_THRESHOLD &&
!(nic === 0 && nacp !== null && nacp >= 10)),
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window. Presence and degraded evidence age separately: a
* degraded report keeps the aircraft degraded in that cell for one window
* after the report itself, however many healthy reports follow, so an
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
* is not held degraded forever.
*
* Every row is dated by when the snapshot was observed, not when it arrived:
* replaying a cached or stale snapshot never makes its evidence younger, and
* a snapshot older than the window adds nothing.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{
windowMs = GNSS_WINDOW_MS,
cellDeg = GNSS_CELL_DEG,
observedAt = nowMs,
} = {},
) {
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
if (Number.isFinite(at) && nowMs - at <= windowMs) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
const previousDegradedAt = previous?.degradedAt ?? null;
store.set(key, {
cell,
seenAt: Math.max(previous?.seenAt ?? at, at),
degradedAt: row.degraded
? Math.max(previousDegradedAt ?? at, at)
: previousDegradedAt,
});
}
}
return pruneGnssObservations(store, nowMs, { windowMs });
}
/**
* Drop observations older than the window and degraded evidence older than
* the window, without adding anything. Used when no fresh snapshot arrives.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {number} nowMs
* @param {{windowMs?: number}} [options]
* @returns {Map} The same store.
*/
export function pruneGnssObservations(
store,
nowMs,
{ windowMs = GNSS_WINDOW_MS } = {},
) {
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
store.set(key, { ...entry, degradedAt: null });
}
return store;
}
/**
* Navigation-integrity level of a cell, using gpsjam.org's published cell
* formula and bands: the first degraded aircraft is discounted to limit false
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
* high cells are navigation-integrity anomalies.
*/
export function gnssIntegrityLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
const level =
percent > GNSS_HIGH_PERCENT
? 'high'
: percent >= GNSS_MEDIUM_PERCENT
? 'medium'
: 'low';
const anomaly = level !== 'low';
return {
percent,
level,
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
interpretation: anomaly ? GNSS_INTERPRETATION : null,
};
}
/**
* Aggregate the observation store into displayable cells. Expects a store
* already pruned to the window by `accumulateGnssObservations` or
* `pruneGnssObservations`.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degradedAt } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degradedAt != null) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level, state, interpretation } = gnssIntegrityLevel(
aircraft,
degraded,
);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
state,
interpretation,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+491
View File
@@ -0,0 +1,491 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
GNSS_CLASSIFIER,
GNSS_PROVENANCE,
GNSS_WINDOW_MS,
gnssCellKey,
gnssIntegrityLevel,
gnssProvenance,
normalizeGnssAircraft,
pruneGnssObservations,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({
hex: '000001',
type: 'mlat',
nic: undefined,
nac_p: undefined,
}),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
aircraft({ hex: '000009', version: 3 }),
aircraft({ hex: '00000a', version: '2' }),
aircraft({ hex: '00000b', version: 1.5 }),
// A position is current only when its age is known, finite and 0..60 s.
aircraft({ hex: '00000c', seen_pos: undefined }),
aircraft({ hex: '00000d', seen_pos: null }),
aircraft({ hex: '00000e', seen_pos: -0.5 }),
aircraft({ hex: '00000f', seen_pos: '1.2' }),
aircraft({ hex: '000010', seen_pos: Number.NaN }),
aircraft({ hex: '000011', seen_pos: 60.1 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
// Version 1, and both ends of the position-age range, qualify.
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
aircraft({ hex: 'b00002', seen_pos: 60 }),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
{
hex: '~a1b2c3',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
...['b00001', 'b00002'].map((hex) => ({
hex,
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
})),
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
});
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
const nominal = {
percent: 0,
level: 'low',
state: 'nominal',
interpretation: null,
};
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
// One degraded aircraft is always discounted.
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
});
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
for (const [total, bad] of [
[50, 2],
[10, 3],
]) {
const { state, interpretation } = gnssIntegrityLevel(total, bad);
assert.equal(state, 'navigation-integrity-anomaly');
assert.equal(interpretation, 'suspected-gnss-interference');
}
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
],
0,
);
const [cell] = binGnssCells(store);
assert.equal(cell.level, 'high');
assert.equal(cell.state, 'navigation-integrity-anomaly');
assert.equal(cell.interpretation, 'suspected-gnss-interference');
for (const key of Object.keys(cell))
assert.doesNotMatch(key, /interference|jamming|spoof/i);
});
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
assert.deepEqual(GNSS_PROVENANCE.classifier, {
id: 'gev-nic-nacp-v2',
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: [2, 10],
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
});
assert.deepEqual(GNSS_PROVENANCE.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
assert.equal(
GNSS_PROVENANCE.interpretation.id,
'suspected-gnss-interference',
);
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
// The bands in provenance are the ones the level function applies.
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
const MIN = 60_000;
const report = (hex, degraded, lat = 50.1) => ({
hex,
lat,
lon: 20.1,
degraded,
});
const entry = (store, hex) =>
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 keeps reporting healthy every minute in the same cell.
for (let minute = 1; minute <= 30; minute += 1)
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
assert.equal(
entry(store, 'a1').degradedAt,
0,
'healthy reports do not refresh the degraded evidence',
);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// A new degraded report starts its own window.
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
});
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
const store = new Map();
const healthy = [report('a2', false), report('a3', false)];
accumulateGnssObservations(
store,
[report('a1', true), report('b1', true), ...healthy],
0,
);
assert.equal(binGnssCells(store)[0].level, 'high');
for (let minute = 10; minute <= 30; minute += 10)
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
minute * MIN,
);
assert.equal(binGnssCells(store)[0].degraded, 2);
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
31 * MIN,
);
const [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 4);
assert.equal(cell.degraded, 0);
assert.equal(cell.level, 'low');
});
test('an aircraft returning after the window starts fresh', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 leaves; nothing is heard from it for longer than the window.
accumulateGnssObservations(store, [], 31 * MIN);
assert.equal(store.size, 0);
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 32 * MIN,
degradedAt: null,
});
// Returning degraded dates the evidence from the new report.
const again = new Map();
accumulateGnssObservations(again, [report('a9', true)], 0);
accumulateGnssObservations(again, [], 31 * MIN);
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
});
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
// No prune runs in between: the old entry is still in the store when the
// healthy report arrives, so the new report alone must not carry it over.
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// Exactly one window after the degraded report it still counts.
const edge = new Map();
accumulateGnssObservations(edge, [report('a1', true)], 0);
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
assert.equal(entry(edge, 'a1').degradedAt, 0);
});
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
const store = new Map();
const snapshot = [report('a1', true), report('a2', false)];
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
// The same snapshot replayed (cached or stale) 29 minutes later.
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
assert.equal(entry(store, 'a1').seenAt, 0);
assert.equal(entry(store, 'a1').degradedAt, 0);
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
// An older replay never moves an entry's age backwards either.
accumulateGnssObservations(store, snapshot, 40 * MIN);
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
observedAt: 35 * MIN,
});
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
// A future time is clamped to now; an undated snapshot adds nothing.
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
observedAt: 99 * MIN,
});
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
for (const observedAt of [null, Number.NaN])
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
observedAt,
});
assert.equal(entry(store, 'u1'), undefined);
});
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
accumulateGnssObservations(store, [report('a2', false)], 0);
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
assert.deepEqual(
[...store.keys()].map((key) => key.split('|')[0]),
['a1'],
);
assert.equal(entry(store, 'a1').degradedAt, null);
pruneGnssObservations(store, 51 * MIN);
assert.equal(store.size, 0);
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
null,
],
0,
);
assert.equal(store.size, 0);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
const [row] = normalizeGnssAircraft({
ac: [
aircraft({
type: 'mlat',
nac_p: 0,
nic: 0,
mlat: ['lat', 'lon', 'nic'],
gpsOkBefore: 1800000000,
gpsOkLat: 54.2,
gpsOkLon: 19.3,
}),
],
});
assert.ok(row);
assert.equal(row.lat, 54.2);
assert.equal(row.lon, 19.3);
assert.equal(row.nic, null);
assert.equal(row.degraded, true);
const [healthy] = normalizeGnssAircraft({
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
});
assert.equal(healthy.degraded, false);
});
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
],
});
assert.deepEqual(
rows.map((r) => r.degraded),
[false, false, false, true, true],
);
});
// Quality-field subsets captured from public adsb.lol US point responses on
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
const tisbQuality = {
nic: 0,
nac_p: 0,
tisb: [
'altitude',
'alt_baro',
'gs',
'track',
'geom_rate',
'lat',
'lon',
'nic',
'rc',
'nac_p',
'nac_v',
'sil',
'sil_type',
],
mlat: [],
};
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
const unknownContainment = {
nic: 0,
nac_p: 10,
tisb: ['callsign'],
mlat: [],
};
assert.equal(
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
false,
);
});
+76
View File
@@ -0,0 +1,76 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
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('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(
limited
? '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('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('GNSS integrity snapshot has no observation time');
return {
rows: payload.rows,
ageMs: payload.ageMs,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
};
},
};
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v2',
rows: [{ hex: 'abc123' }],
});
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v2',
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
assert.equal(stale.fetchedAt, 7);
assert.equal(stale.classifier, null);
// Without an observation age the rows could only be dated as new.
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
const at = Date.UTC(2026, 9, 13, 12);
for (const [status, header, delay, retryable] of [
[429, '3600', 3600000, true],
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
[429, null, 60000, true],
[429, 'Infinity', 60000, true],
[502, '5', 5000, true],
[502, null, null, true],
[400, null, null, false],
]) {
const source = createAdsbGnssSource({
now: () => at,
fetchImpl: async () =>
Response.json(
{},
{ status, headers: header ? { 'Retry-After': header } : {} },
),
});
await assert.rejects(
source.getSnapshot({ latitude: 50, longitude: 20 }),
(e) => {
assert.equal(e.status, status);
assert.equal(e.retryAfterMs, delay);
assert.equal(e.retryable, retryable);
assert.match(
e.message,
status === 429 ? /rate limited/ : /feed unavailable/,
);
return true;
},
);
}
});
+122 -2
View File
@@ -1,6 +1,10 @@
import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import { LAYER_ID, DISTANCE_PREFILTER_MARGIN_M } from './policy.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
@@ -16,12 +20,71 @@ export function createControls({ state: layerState, services, parts, source }) {
statsRefreshInterval: 1000,
visitNamedMarkers(visitor) {
parts.namedMarkers.visit(visitor);
},
/** Request a one-shot Google Maps Places search around the current map view. */
searchNearby() {
layerState.googleSearchRequested = true;
return parts.ingestion.loadInstallations();
},
/**
* Load sites around a Contacts subject instead of the camera viewport.
*
* A follow or Cockpit camera moves every frame, so `moveEnd` never fires and
* its view rectangle often reaches the horizon; a subject window is the
* only area that answers "what is near this contact". The window moves
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
* that cheap. Passing null returns the layer to viewport loading.
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position
* Subject position.
* @returns {boolean} Whether a new load was scheduled.
*/
setContextAnchor(position) {
let next = null;
if (position) {
if (
Number.isFinite(position.latitude) &&
Number.isFinite(position.longitude)
)
next = { latitude: position.latitude, longitude: position.longitude };
else {
const carto = Cesium.Cartographic.fromCartesian(position);
if (carto)
next = {
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
};
}
}
// Keep the live centre even when the coarse fetch anchor has not moved.
layerState.contextPosition = next
? Cesium.Cartesian3.fromDegrees(next.longitude, next.latitude)
: null;
const previous = layerState.contextAnchor;
if (!next) {
if (!previous) return false;
layerState.contextAnchor = null;
parts.viewport.scheduleLoad();
return true;
}
if (
previous &&
parts.model.approximateSurfaceDistanceM(
Cesium.Math.toRadians(previous.latitude),
Cesium.Math.toRadians(previous.longitude),
next.latitude,
next.longitude,
) < ANCHOR_REFRESH_M
)
return false;
layerState.contextAnchor = next;
parts.viewport.scheduleLoad();
return true;
},
getNearby(center, rangeM, maxCount = 50) {
if (!center) return [];
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
@@ -99,9 +162,60 @@ export function createControls({ state: layerState, services, parts, source }) {
return true;
},
/**
* Snapshot loaded mapped installations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, class, kind, lat, lon}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState.enabled) return [];
const result = [];
for (const record of layerState.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.name || record.id,
name: record.name || null,
class: record.class || null,
kind: record.kind || null,
lat: record.latitude,
lon: record.longitude,
});
}
return result;
},
getStats() {
const namedMarkers = parts.namedMarkers?.stats();
const subjectWindow =
layerState.coverage?.kind === 'subject' &&
layerState.contextPosition &&
Number.isFinite(layerState.coverage.radiusM);
const mappedCount = subjectWindow
? methods.getNearby(
layerState.contextPosition,
layerState.coverage.radiusM,
layerState.records.length || 1,
).length
: layerState.wide && namedMarkers
? namedMarkers.pointsOnScreen
: layerState.records.filter(
(record) => record.kind === 'installation',
).length;
return {
count: layerState.records.length,
count:
subjectWindow || layerState.wide
? mappedCount
: layerState.records.length,
namedInView: layerState.namedInView || 0,
wide: Boolean(layerState.wide),
...namedMarkers,
lastUpdate: layerState.lastUpdate,
stale: layerState.stale,
saturated: layerState.saturated,
@@ -113,11 +227,17 @@ export function createControls({ state: layerState, services, parts, source }) {
failureReason: layerState.failureReason,
statusMessage: installationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
loadingLabel: layerState.loading
? 'loading mapped installation context'
: '',
coverage: layerState.coverage,
coverageLabel:
layerState.coverage?.kind === 'subject'
? `WITHIN ${Math.round(layerState.coverage.radiusM / 1000)} KM`
: 'CURRENT VIEWPORT ONLY',
};
},
};
+4 -2
View File
@@ -1,3 +1,4 @@
import { createNamedMarkers } from './namedMarkers.js';
import { createModel } from './model.js';
import { createRendering } from './rendering.js';
import { createIngestion } from './ingestion.js';
@@ -8,7 +9,7 @@ import { createLifecycle } from './lifecycle.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createInstallationsLayer({ services, source }) {
export function createInstallationsLayer({ services, source, overlayHost }) {
if (
typeof source?.getMappedSites !== 'function' ||
typeof source?.searchNearby !== 'function'
@@ -16,9 +17,10 @@ export function createInstallationsLayer({ services, source }) {
throw new TypeError('An installation source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const context = { state, services, parts, source, overlayHost };
parts.model = createModel(context);
parts.rendering = createRendering(context);
parts.namedMarkers = createNamedMarkers(context);
parts.ingestion = createIngestion(context);
parts.viewport = createViewport(context);
parts.selection = createSelection(context);
+147 -37
View File
@@ -1,3 +1,50 @@
import { militaryOsmKey } from '../../data/militaryNames.js';
import { isUnavailableCapability } from '../../sources/capability.js';
/** Keep named point coverage until its matching polygon takes over. */
export function retainNamedHandoff(previous, records, box) {
const named = new Map(
previous.filter((r) => r.namedArea).map((r) => [r.id, r]),
);
const represented = new Set();
const next = records.map((record) => {
represented.add(record.id);
for (const id of record.aliasIds || []) represented.add(id);
for (const member of record.namedMembers || []) represented.add(member.id);
for (const source of record.sources || []) {
const key = militaryOsmKey(source.id);
if (key) represented.add(`osm:military:${key}`);
}
const prior =
named.get(record.id) ||
(record.aliasIds || [])
.map((id) => named.get(id))
.filter(Boolean)
.sort((a, b) => b.areaM2 - a.areaM2)[0];
return !record.namedArea && prior
? {
...record,
name: prior.name,
namedArea: true,
areaM2: prior.areaM2,
class: prior.class,
}
: record;
});
for (const record of previous) {
if (
record.pointOnly &&
record.namedArea &&
!represented.has(record.id) &&
record.longitude >= box.west &&
record.longitude <= box.east &&
record.latitude >= box.south &&
record.latitude <= box.north
)
next.push(record);
}
return next;
}
export function createIngestion({
state: layerState,
services,
@@ -26,7 +73,7 @@ export function createIngestion({
async function loadInstallations() {
if (!layerState.enabled || !layerState.viewer) return;
const box = parts.viewport.viewportBox(layerState.viewer);
const { box, coverage } = parts.viewport.loadArea(layerState.viewer);
// Guidance, not a fault: the layer chose not to query because the view is
// unbounded (a global view, or Cockpit looking at the horizon). Keep it out
// of `error` — the manager derives refresh failures and the global status
@@ -43,6 +90,7 @@ export function createIngestion({
}
layerState.abort?.abort();
const requestAbort = new AbortController();
const keyOwner = (layerState.cameraLoadOwner ??= {});
layerState.abort = requestAbort;
layerState.loading = true;
parts.viewport.clearUnavailableRetry({ resetBackoff: false });
@@ -53,19 +101,34 @@ export function createIngestion({
source.getMappedSites(box, { exact, signal: requestAbort.signal });
let payload = await fetchInstallations(false);
// Map tiles carry military areas only at z9 and finer: a view too wide
// for that is guidance, exactly like an unbounded view, never an empty
// all-clear. Previously loaded sites stay on the map.
if (payload.zoomIn) {
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
parts.viewport.clearUnavailableRetry();
setInstallationStatus('zoom-in');
return;
}
// A SATURATED snapped tile was truncated upstream, so features from the
// snap's extra ring may have crowded out sites actually on screen. Re-ask
// for the exact viewport (separately keyed and cached) before rendering.
let saturated = payload.saturated === true;
if (saturated) {
if (saturated && !payload.tileSource) {
payload = await fetchInstallations(true);
saturated = payload.saturated === true;
}
// The proxy answers a bbox at least as large as the viewport; keep only what
// was actually asked for so nothing off-screen reaches the map or the
// "current viewport only" context claim.
const records = payload.records.filter((record) =>
parts.model.installationWithinViewport(record, box),
const records = payload.records.filter(
(record) =>
payload.wide || parts.model.installationWithinViewport(record, box),
);
let placesError = null;
if (layerState.googleSearchRequested) {
@@ -139,43 +202,84 @@ export function createIngestion({
'Google Places search unavailable; showing mapped sites';
}
}
await resolveGroundFloorCellsBounded(
records.map((record) => ({
lat: record.latitude,
lon: record.longitude,
})),
);
let floorTimer;
await Promise.race([
resolveGroundFloorCellsBounded(
records
.filter((record) => !record.pointOnly)
.map((record) => ({
lat: record.latitude,
lon: record.longitude,
})),
),
new Promise((resolve) => {
floorTimer = setTimeout(resolve, 120);
}),
]).finally(() => clearTimeout(floorTimer));
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
layerState.records = records;
layerState.recordById = new Map(
layerState.records.map((record) => [record.id, record]),
);
layerState.lastUpdate = Date.now();
layerState.stale = payload.status === 'stale';
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
layerState.saturated = saturated;
layerState.failureReason = null;
parts.viewport.clearUnavailableRetry();
setInstallationStatus(
layerState.records.length
? layerState.stale
? 'stale'
: 'ready'
: 'empty',
payload.status === 'stale'
? 'Serving cached mapped context'
: saturated
? 'Too many mapped sites in view to list them all'
: placesError,
);
parts.rendering.renderRecords();
parts.rendering.warmInstallationFloors(layerState.records);
const publish = (payload, records) => {
if (
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
if (!payload.wide)
records = retainNamedHandoff(layerState.records, records, box);
layerState.namedInView =
payload.namedInView ?? records.filter((r) => r.namedArea).length;
layerState.wide = Boolean(payload.wide);
layerState.records = records;
layerState.recordById = new Map(
layerState.records.map((record) => [record.id, record]),
);
layerState.lastUpdate = Date.now();
layerState.coverage = coverage;
layerState.stale = payload.status === 'stale';
// Even the exact-viewport retry can saturate in a dense area. Say so rather
// than implying the view is completely surveyed.
layerState.saturated = saturated;
layerState.failureReason = null;
parts.viewport.clearUnavailableRetry();
setInstallationStatus(
layerState.records.length
? layerState.stale
? 'stale'
: 'ready'
: 'empty',
payload.status === 'stale'
? `Serving cached mapped context · ${payload.records[0]?.retrievedAt || 'date unknown'}`
: saturated
? 'Too many mapped sites in view to list them all'
: placesError,
);
parts.rendering.renderRecords();
parts.rendering.warmInstallationFloors(
layerState.records.filter((r) => !r.pointOnly),
);
};
publish(payload, records);
payload.enrichment?.then((enriched) => {
if (
!enriched ||
requestAbort.signal.aborted ||
layerState.abort !== requestAbort ||
!layerState.enabled
)
return;
const named = enriched.records.filter((record) =>
parts.model.installationWithinViewport(record, box),
);
publish(enriched, [
...named,
...records.filter((record) => record.id.startsWith('google:')),
]);
});
} catch (error) {
if (
requestAbort.signal.aborted ||
@@ -189,11 +293,17 @@ export function createIngestion({
'unavailable',
error?.message || 'Installation context unavailable',
);
parts.viewport.scheduleUnavailableRetry();
if (!isUnavailableCapability(error))
parts.viewport.scheduleUnavailableRetry(error?.retryAfterMs);
} finally {
// An older aborted request must not clear a newer request's busy state.
if (layerState.abort === requestAbort) {
layerState.abort = null;
if (
requestAbort.signal.aborted &&
layerState.cameraLoadOwner === keyOwner
)
layerState.cameraLoadKey = null;
// Keep ownership until superseded so late names cannot enrich an old view.
layerState.loading = false;
}
}
+48 -3
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium';
import { LAYER_ID } from './policy.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js';
export function createLifecycle({
state: layerState,
@@ -17,14 +21,37 @@ export function createLifecycle({
'military-installations',
);
viewer.dataSources.add(layerState.dataSource);
layerState.moveEndRemove = viewer.camera.moveEnd.addEventListener(
parts.viewport.scheduleLoad,
);
const cameraChanged = () => {
if (!layerState.contextAnchor) parts.viewport.scheduleLoad();
};
layerState.moveEndRemove =
viewer.camera.moveEnd.addEventListener(cameraChanged);
layerState.changedRemove =
viewer.camera.changed?.addEventListener(cameraChanged);
parts.selection.installInteraction(viewer);
},
enable() {
layerState.enabled = true;
layerState.surfaceChangedRemove ||= services.maps?.subscribeMapStack(
parts.rendering.syncSelectionFill,
);
// Cached markers and geometry become visible before the next fetch.
if (
layerState.records?.some((r) =>
r.sources?.some((s) => s.name === 'OpenStreetMap'),
)
)
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
openMapTiles: layerState.records.some((r) =>
r.sources?.some(
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
),
),
});
parts.namedMarkers?.enable();
parts.namedMarkers?.sync(layerState.records || []);
claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01);
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
layerState.dataSource.show = true;
// DataLayerManager invokes update() immediately after enable(), which owns
@@ -33,6 +60,14 @@ export function createLifecycle({
disable() {
layerState.enabled = false;
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
parts.namedMarkers?.hide();
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
layerState.contextAnchor = null;
layerState.contextPosition = null;
layerState.coverage = { kind: 'viewport' };
layerState.cameraLoadKey = null;
layerState.cameraLoadOwner = null;
unregisterPickOwner(LAYER_ID);
parts.viewport.clearUnavailableRetry();
clearTimeout(layerState.timer);
@@ -42,15 +77,25 @@ export function createLifecycle({
if (layerState.dataSource) layerState.dataSource.show = false;
clearSelectedEntityContextForLayer(LAYER_ID);
layerState.selectedId = null;
parts.rendering.clearSelectionFill?.();
layerState.surfaceChangedRemove?.();
layerState.surfaceChangedRemove = null;
layerState.failureReason = null;
parts.ingestion.setInstallationStatus('idle');
},
destroy(viewer) {
source.destroy?.();
this.disable();
layerState.contextAnchor = null;
layerState.contextPosition = null;
layerState.coverage = { kind: 'viewport' };
layerState.moveEndRemove?.();
layerState.changedRemove?.();
parts.selection.destroy?.();
layerState.clickHandler?.destroy();
layerState.clickHandler = null;
parts.namedMarkers?.destroy();
parts.rendering.clearRendered();
if (layerState.dataSource && viewer)
viewer.dataSources.remove(layerState.dataSource, true);
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as credits from '../../data/dataCredits.js';
import { createLifecycle } from './lifecycle.js';
import { LAYER_ID } from './policy.js';
for (const source of [
{ name: 'OpenStreetMap', id: 'tile:base' },
{ name: 'OpenStreetMap', id: 'way:123' },
{ name: 'Google Places', id: 'place:base' },
null,
]) {
test(`enable restores cached installation credits before a refresh: ${source?.id || 'empty'}`, () => {
const visibleCredits = new Set();
const noop = () => {};
const viewer = {
camera: {},
creditDisplay: {
addStaticCredit: (credit) => visibleCredits.add(credit),
removeStaticCredit: (credit) => visibleCredits.delete(credit),
},
};
const state = {
viewer,
records: source ? [{ id: 'base', sources: [source] }] : [],
recordById: new Map(),
dataSource: { show: false },
};
let markersVisible = false;
const parts = {
namedMarkers: {
enable: () => {
markersVisible = true;
},
sync: noop,
hide: () => {
markersVisible = false;
},
},
rendering: {},
viewport: { clearUnavailableRetry: noop },
ingestion: { setInstallationStatus: noop },
};
const lifecycle = createLifecycle({
state,
services: {
credits,
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
parts,
source: {},
}).methods;
const expected =
source?.name === 'OpenStreetMap'
? source.id.startsWith('tile:')
? 2
: 1
: 0;
try {
for (let cycle = 0; cycle < 2; cycle++) {
lifecycle.enable();
assert.equal(state.dataSource.show, true);
assert.equal(markersVisible, true);
// No renderRecords or successful fetch occurs between toggles.
assert.equal(visibleCredits.size, expected);
lifecycle.disable();
assert.equal(state.dataSource.show, false);
assert.equal(markersVisible, false);
assert.equal(visibleCredits.size, 0);
}
credits.showOsmCredit(viewer, 'another-layer');
lifecycle.enable();
lifecycle.disable();
assert.equal(visibleCredits.size, 1, 'another owner retains its credit');
assert.equal(credits.hideOsmCredit(viewer, LAYER_ID), false);
} finally {
lifecycle.disable();
credits.hideOsmCredit(viewer, 'another-layer');
}
});
}
+22 -18
View File
@@ -116,24 +116,28 @@ export function createModel({ state: layerState, services, parts, source }) {
longitude >= box.west &&
longitude <= box.east;
if (centreInside) return true;
if (Array.isArray(footprint) && footprint.length) {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const [lon, lat] of footprint) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return (
maxLat >= box.south &&
minLat <= box.north &&
maxLon >= box.west &&
minLon <= box.east
);
}
const footprints =
record.footprints?.map((rings) => rings[0]) ||
(footprint?.length ? [footprint] : []);
if (footprints.length)
return footprints.some((footprint) => {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const [lon, lat] of footprint) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
return (
maxLat >= box.south &&
minLat <= box.north &&
maxLon >= box.west &&
minLon <= box.east
);
});
// Unknown extent: inclusive. Only a point feature may be excluded on centre.
return record.osmType !== 'node';
}
+224
View File
@@ -0,0 +1,224 @@
import * as Cesium from 'cesium';
import {
MILITARY_LABEL_CAP,
MILITARY_POINT_CAP,
} from '../../data/militaryNames.js';
import { LAYER_ID, COLOR_BY_CLASS } from './policy.js';
import { createInfrastructureOverlayEntry } from '../../data/infrastructureOverlayEntry.js';
/** Bounded point markers; the shared overlay owns text projection and decluttering. */
export function createNamedMarkers({ state, services, parts, overlayHost }) {
let points = null;
let entries = [];
const markers = new Map();
const screen = new Cesium.Cartesian2();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
let shownPoints = 0;
let ordered = [];
const viewMatrix = new Cesium.Matrix4();
let width = 0,
height = 0;
let removeChanged = null,
removeEnd = null,
removeFrame = null;
function refresh() {
const viewer = state.viewer;
if (!state.enabled || !points) return;
width = viewer.scene.canvas.clientWidth;
height = viewer.scene.canvas.clientHeight;
if (viewer.camera.viewMatrix)
Cesium.Matrix4.clone(viewer.camera.viewMatrix, viewMatrix);
occluder.cameraPosition = viewer.camera.positionWC;
shownPoints = 0;
for (let index = 0; index < ordered.length; index++) {
const marker = ordered[index];
const point = marker.point;
point.show = marker.named && occluder.isPointVisible(point.position);
if (!point.show) continue;
const projected = Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
point.position,
screen,
);
if (
projected &&
screen.x >= 0 &&
screen.y >= 0 &&
screen.x < width &&
screen.y < height
)
shownPoints++;
}
}
function publishLabels() {
overlayHost.setEntries(LAYER_ID, entries, {
// Keep the bounded point cohort available while Contacts owns the window.
// The arbiter spends at most 24 slots on currently visible, separated titles.
cohortLimit: MILITARY_POINT_CAP,
collisionCapacity: MILITARY_LABEL_CAP,
maxVisible: MILITARY_LABEL_CAP - (state.selectedId ? 1 : 0),
moving: false,
visible: state.enabled,
});
}
function cameraChanged() {
refresh();
services.render.governorRequestRender('installation-names');
}
function frame() {
const viewer = state.viewer;
if (
viewer?.camera.viewMatrix &&
(!Cesium.Matrix4.equals(viewMatrix, viewer.camera.viewMatrix) ||
width !== viewer.scene.canvas.clientWidth ||
height !== viewer.scene.canvas.clientHeight)
)
refresh();
}
function enable() {
const viewer = state.viewer;
if (!viewer || removeChanged || removeEnd || removeFrame) return;
removeChanged = viewer.camera.changed?.addEventListener(cameraChanged);
removeEnd = viewer.camera.moveEnd?.addEventListener(cameraChanged);
removeFrame = viewer.scene.preRender?.addEventListener(frame);
if (points) points.show = true;
publishLabels();
overlayHost.setVisible(LAYER_ID, true);
cameraChanged();
}
function hide() {
removeChanged?.();
removeEnd?.();
removeFrame?.();
removeChanged = removeEnd = removeFrame = null;
if (points) points.show = false;
overlayHost.clearSource(LAYER_ID);
overlayHost.setVisible(LAYER_ID, false);
shownPoints = 0;
}
function sync(records) {
const viewer = state.viewer;
if (!viewer?.scene?.primitives) return;
if (!points) {
points = viewer.scene.primitives.add(
new Cesium.PointPrimitiveCollection(),
);
}
points.show = state.enabled;
const named = records
.filter((r) => r.namedArea || r.id === state.selectedId)
.sort(
(a, b) =>
Number(b.id === state.selectedId) -
Number(a.id === state.selectedId) || b.areaM2 - a.areaM2,
)
.slice(0, MILITARY_POINT_CAP);
const keep = new Set(named.map((r) => r.id));
for (const record of named) {
if (markers.has(record.id)) continue;
const alias = record.aliasIds?.find(
(id) => !keep.has(id) && markers.has(id),
);
if (!alias) continue;
const marker = markers.get(alias);
markers.delete(alias);
marker.point.id = { installationId: record.id };
marker.entry.id = record.id;
markers.set(record.id, marker);
}
for (const [id, marker] of markers)
if (!keep.has(id)) {
points.remove(marker.point);
markers.delete(id);
}
ordered = [];
entries = [];
for (const record of named) {
const position = Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
record.pointOnly
? 0
: parts.rendering.installationSurfaceHeightM(record),
);
let marker = markers.get(record.id);
if (!marker) {
const id = { installationId: record.id };
marker = {
point: points.add({
id,
position,
pixelSize: 7,
color: parts.model.colorFor(record),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
disableDepthTestDistance: Infinity,
}),
entry: createInfrastructureOverlayEntry({
id: record.id,
source: LAYER_ID,
position,
title: record.name,
priority: record.areaM2 || 0,
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
}),
};
markers.set(record.id, marker);
} else {
marker.point.position = position;
}
marker.named = record.namedArea;
marker.entry.variant =
record.id === state.selectedId ? 'selected' : 'card';
marker.entry.selected = record.id === state.selectedId;
marker.entry.details = marker.entry.selected
? [
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
...(record.memberNames || []).slice(0, 3),
]
: [];
marker.entry.position = position;
marker.entry.title = record.name;
marker.entry.priority = record.areaM2 || 0;
entries.push(marker.entry);
ordered.push(marker);
}
if (state.enabled) publishLabels();
refresh();
services.render.governorRequestRender('installation-names');
}
return {
sync,
visit(visitor) {
for (let i = 0; i < ordered.length; i++) {
const marker = ordered[i];
visitor(marker.point.id.installationId, marker.point);
}
},
refresh,
enable,
hide,
stats: () => ({
namedMarkers: markers.size,
pointsOnScreen: shownPoints,
labelsOnScreen: state.enabled
? overlayHost.getDiagnostics?.().paintedBySource[LAYER_ID] || 0
: 0,
labelCap: MILITARY_LABEL_CAP,
}),
destroy() {
hide();
ordered = [];
if (points) state.viewer?.scene.primitives.remove(points);
points = null;
entries = [];
markers.clear();
},
};
}
@@ -0,0 +1,185 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createNamedMarkers } from './namedMarkers.js';
import { createLifecycle } from './lifecycle.js';
import { createLocalInfrastructureOverlayEntry } from '../../data/localGeojsonCore.js';
function setup(t) {
let labels = [], options, publications = 0, visible = false;
const overlayHost = {
setEntries(id, entries, policy) {
assert.equal(id, 'military-installations');
labels = entries; options = policy; publications++;
},
clearSource() { labels = []; },
setVisible(_id, value) { visible = value; },
};
let x = -100;
const projections = new Set();
t.mock.method(C.SceneTransforms, 'worldToWindowCoordinates', (_scene, _point, scratch) => { projections.add(scratch); scratch.x = x; scratch.y = 300; return scratch; });
const camera = { changed: new C.Event(), moveEnd: new C.Event(), positionWC: C.Cartesian3.fromDegrees(0, 0, 1e6), viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) };
const state = { viewer: { camera, dataSources: { add() {}, remove() {} }, scene: { canvas: { clientWidth: 1280, clientHeight: 800 }, preRender: new C.Event(), primitives: { add: (p) => p, remove() {} } } }, enabled: true, contextAnchor: { latitude: 0, longitude: 0 } };
const services = { render: { governorRequestRender() {} }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, context: { clearSelectedEntityContextForLayer() {} } };
let loads = 0;
const parts = { model: { colorFor: () => C.Color.ORANGE }, rendering: { installationSurfaceHeightM: () => 0, clearRendered() {} }, viewport: { scheduleLoad: () => loads++, clearUnavailableRetry() {} }, selection: { installInteraction() {} }, ingestion: { setInstallationStatus() {} } };
const ctx = { state, services, parts, source: {}, overlayHost };
parts.namedMarkers = createNamedMarkers(ctx);
const lifecycle = createLifecycle(ctx).methods;
lifecycle.init(state.viewer); lifecycle.enable();
const records = [1, 2].map((n) => ({ id: `osm:military:w${n}`, name: `Site ${n}`, namedArea: true, areaM2: 100 / n, latitude: 0, longitude: 0, pointOnly: true }));
return { state, camera, get labels() { return labels; }, get options() { return options; }, get visible() { return visible; }, projections, records, markers: parts.namedMarkers, lifecycle, setX: (value) => { x = value; }, publications: () => publications, loads: () => loads };
}
test('Contacts camera motion reprojects and declutters existing labels without source loads or primitive replacement', (t) => {
const h = setup(t);
h.markers.sync(h.records);
assert.equal(h.markers.stats().pointsOnScreen, 0);
h.setX(600); h.camera.changed.raiseEvent(); h.camera.moveEnd.raiseEvent();
assert.equal(h.markers.stats().pointsOnScreen, 2);
assert.equal(h.labels.length, 2);
assert.equal(h.options.collisionCapacity, 24);
assert.equal(h.options.cohortLimit, 512);
assert.equal(h.visible, true);
assert.equal(h.loads(), 0);
assert.equal(h.publications(), 3);
assert.equal(h.projections.size, 1);
h.setX(1400); h.camera.viewMatrix[12] = 1; h.state.viewer.scene.preRender.raiseEvent();
assert.equal(h.markers.stats().pointsOnScreen, 0, 'small pose changes refresh before render');
h.lifecycle.disable();
assert.equal(h.camera.changed.numberOfListeners, 1, 'only the viewport listener remains');
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
assert.equal(h.labels.length, 0);
assert.equal(h.visible, false);
h.state.records = h.records;
h.setX(600); h.lifecycle.enable();
assert.equal(h.labels.length, 2);
assert.equal(h.publications(), 5);
h.lifecycle.destroy(h.state.viewer);
assert.equal(h.camera.changed.numberOfListeners, 0);
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.state.viewer.scene.preRender.numberOfListeners, 0);
});
test('wide named markers retain primitives until close polygons publish with the same identity', async (t) => {
const { retainNamedHandoff } = await import('./ingestion.js');
const h = setup(t);
h.setX(600); h.markers.sync(h.records);
const first = h.labels[0];
const box = { west: -1, east: 1, south: -1, north: 1 };
h.markers.sync(retainNamedHandoff(h.records, [], box));
assert.equal(h.labels[0], first);
assert.equal(first.title, 'Site 1');
const polygon = { ...h.records[0], namedArea: false, name: 'Military area', pointOnly: false, footprint: [[0, 0], [0.1, 0], [0, 0.1], [0, 0]] };
const handed = retainNamedHandoff(h.records, [polygon], box);
assert.equal(handed.length, 2);
assert.equal(handed[0].name, 'Site 1');
assert.equal(handed[0].pointOnly, false);
h.markers.sync(handed);
assert.equal(h.labels[0], first);
assert.equal(first.title, 'Site 1');
assert.equal(h.labels.length, 2, 'no duplicate title at handoff');
assert.equal(retainNamedHandoff(h.records, [], { west: 1, east: 2, south: 1, north: 2 }).length, 0);
h.lifecycle.destroy(h.state.viewer);
});
test('canonical aliases transfer named marker ownership without replacing primitives', (t) => {
const h = setup(t);
h.setX(600);
const parent = h.records[1];
h.markers.sync([parent]);
let originalPoint;
h.markers.visit((_id, point) => { originalPoint = point; });
const label = h.labels[0];
for (const record of [
{ ...parent, id: h.records[0].id, aliasIds: [parent.id], pointOnly: false },
{ ...parent, aliasIds: [h.records[0].id], pointOnly: false },
parent,
]) {
h.markers.sync([record]);
h.markers.visit((id, point) => {
const currentLabel = h.labels[0];
assert.equal(id, record.id);
assert.equal(point, originalPoint);
assert.equal(currentLabel, label);
assert.equal(point.id.installationId, record.id);
assert.equal(currentLabel.id, record.id);
});
}
assert.equal(h.labels.length, 1);
h.lifecycle.destroy(h.state.viewer);
});
test('the bounded host cohort ranks wide points and polygon titles by area', (t) => {
const h = setup(t);
h.markers.sync(Array.from({ length: 600 }, (_, n) => ({
...h.records[0], id: `site:${n}`, name: `Site ${n}`, areaM2: n,
pointOnly: n % 2 === 0,
})));
assert.equal(h.labels.length, 512);
assert.equal(h.labels[0].id, 'site:599');
assert.equal(h.labels.at(-1).id, 'site:88');
assert.ok(h.labels.every((entry) => !entry.stateless && entry.horizonCull && entry.collisionGroup === 'ambient-card'));
const first = h.labels[0];
const publications = h.publications();
for (let i = 0; i < 100; i++) {
h.camera.viewMatrix[12] += 0.01;
h.state.viewer.scene.preRender.raiseEvent();
}
assert.equal(h.labels[0], first);
assert.equal(h.publications(), publications, 'camera frames never rebuild host entries');
assert.equal(h.projections.size, 1, 'point projection reuses scratch storage');
h.lifecycle.destroy(h.state.viewer);
assert.equal(h.labels.length, 0);
});
test('wide and polygon titles use the Data Centers entry contract and persistent shared domain', (t) => {
const h = setup(t);
h.markers.sync([h.records[0], { ...h.records[1], pointOnly: false }]);
assert.equal(h.options.moving, false);
assert.equal(h.options.solveIntervalMs, undefined);
assert.equal(h.options.maxVisible, 24);
for (const entry of h.labels) {
const reference = createLocalInfrastructureOverlayEntry({ id: entry.id, layerId: 'military-installations', position: entry.position, properties: { name: entry.title }, priority: entry.priority, accent: entry.accent });
assert.deepEqual(entry, { ...reference, selected: false });
}
h.lifecycle.destroy(h.state.viewer);
});
test('selection promotes exactly one entry and deselection restores that same label', (t) => {
const h = setup(t);
h.markers.sync(h.records);
const original = h.labels[0];
h.state.selectedId = h.records[0].id;
h.markers.sync(h.records);
assert.equal(h.labels.length, 2);
assert.equal(h.labels[0], original);
assert.equal(original.variant, 'selected');
assert.equal(h.labels.filter(e => e.selected).length, 1);
assert.equal(h.options.maxVisible, 23);
h.state.selectedId = null;
h.markers.sync(h.records);
assert.equal(h.labels[0], original);
assert.equal(original.variant, 'card');
assert.equal(original.selected, false);
assert.deepEqual(original.details, []);
h.lifecycle.destroy(h.state.viewer);
});
test('Contacts can select a point outside the normal title cap or an unnamed polygon', (t) => {
const h = setup(t);
const records = Array.from({ length: 520 }, (_, i) => ({ ...h.records[0], id: `site:${i}`, areaM2: 1000 - i }));
records.push({ ...h.records[0], id: 'unnamed', name: 'Military area', namedArea: false, pointOnly: false });
for (const id of ['site:519', 'unnamed']) {
h.state.selectedId = id;
h.markers.sync(records);
assert.equal(h.labels[0].id, id);
assert.equal(h.labels[0].variant, 'selected');
assert.equal(h.labels.filter(e => e.selected).length, 1);
assert.equal(h.labels.length, 512);
}
h.lifecycle.destroy(h.state.viewer);
});
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createInstallationSource } from './source.js';
import { createIngestion } from './ingestion.js';
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
import {
createMilitaryNamesLoader,
loadMilitaryNames,
} from '../../data/militaryNames.js';
import { loadBundledJson } from '../../data/bundledJson.js';
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
const wide = { south: 20, west: -130, north: 60, east: -60 };
const tile = decodeOpenFreeMapMilitaryTile(
readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
),
12,
935,
1685,
);
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function deferred() {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function sourceWith(job) {
return createInstallationSource({
loadNames: () => job.promise,
fetchImpl: async () => Response.json({ unavailable: true }),
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
});
}
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
const job = deferred(),
source = sourceWith(job),
abort = new AbortController();
const waiting = source.getMappedSites(wide, { signal: abort.signal });
const rejection = assert.rejects(waiting, { name: 'AbortError' });
const payload = await source.getMappedSites(box);
assert.ok(payload.records.length);
assert.ok(payload.records.every((r) => r.name === 'Military area'));
abort.abort();
await rejection;
job.resolve(await loadMilitaryNames());
const enriched = await payload.enrichment;
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
});
for (const stale of [false, true])
test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
const job = deferred(),
source = sourceWith(job),
publications = [];
let currentBox = box;
const state = {
enabled: true,
viewer: {},
records: [],
googleSearchRequested: false,
};
const ingestion = createIngestion({
state,
source,
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
parts: {
viewport: {
loadArea: () => ({ box: currentBox, coverage: {} }),
clearUnavailableRetry() {},
scheduleUnavailableRetry() {},
},
model: { installationWithinViewport: () => true },
rendering: {
renderRecords: () => publications.push(state.records),
warmInstallationFloors() {},
},
},
});
await ingestion.loadInstallations();
assert.equal(state.loading, false);
assert.ok(publications[0].length);
if (stale) {
currentBox = wide;
state.enabled = false;
state.abort.abort();
}
job.resolve(await loadMilitaryNames());
await flush();
assert.equal(publications.length, stale ? 1 : 2);
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
state.abort.abort();
});
for (const stage of ['response', 'body'])
test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
let calls = 0,
signal;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
calls++;
signal = options.signal;
if (calls > 1) return Response.json({ records: [], classes: [] });
if (stage === 'response') return new Promise(() => {});
return { ok: true, json: () => new Promise(() => {}) };
});
const load = createMilitaryNamesLoader({
timeoutMs: 50,
cooldownMs: 100,
loadPack: (signal) =>
loadBundledJson(new URL('https://example.test/names.json'), { signal }),
});
const first = load();
assert.equal(load(), first, 'shared acquisition');
const rejected = assert.rejects(first, { name: 'TimeoutError' });
await flush();
t.mock.timers.tick(51);
await rejected;
assert.equal(signal.aborted, true);
await assert.rejects(load(), { name: 'TimeoutError' });
assert.equal(calls, 1);
t.mock.timers.tick(101);
assert.deepEqual((await load()).records, []);
assert.equal(calls, 2);
});
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createViewport } from './viewport.js';
import { createLifecycle } from './lifecycle.js';
test('camera changes debounce, deduplicate moveEnd, cancel superseded loads and recover from wide views', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
const camera = {
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
percentageChanged: 0.5,
computeViewRectangle: () => rectangle,
};
const viewer = {
camera,
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
dataSources: { add() {}, remove() {} },
};
const state = { enabled: false, renderedKeys: new Map() };
const loads = [];
const parts = {
selection: { installInteraction() {} },
rendering: { clearRendered() {} },
ingestion: {
loadInstallations() {
state.abort = new AbortController();
loads.push({
...parts.viewport.loadArea(viewer),
signal: state.abort.signal,
});
},
setInstallationStatus() {},
},
};
const services = {
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
context: { clearSelectedEntityContextForLayer() {} },
};
const ctx = { state, parts, services, source: {} };
parts.viewport = createViewport(ctx);
const life = createLifecycle(ctx).methods;
life.init(viewer);
life.enable();
for (let i = 0; i < 8; i++) {
camera.changed.raiseEvent();
t.mock.timers.tick(15);
}
t.mock.timers.tick(200);
assert.equal(
loads.length,
1,
'changed alone must load, even without moveEnd',
);
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.length, 1);
assert.equal(
loads[0].signal.aborted,
false,
'duplicate settle must not abort its own load',
);
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
camera.changed.raiseEvent();
assert.equal(loads[0].signal.aborted, true);
t.mock.timers.tick(200);
assert.equal(loads.length, 2);
rectangle = Cesium.Rectangle.fromDegrees(-150, 10, -80, 60);
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(
loads.at(-1).box.west,
-150,
'wide views reach the named-point source',
);
rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
camera.changed.raiseEvent();
t.mock.timers.tick(200);
assert.ok(loads.at(-1).box);
state.contextAnchor = { latitude: 32.8, longitude: -117.1 };
const count = loads.length;
camera.moveEnd.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.length, count, 'Contacts owns the anchor while active');
life.disable();
assert.equal(state.contextAnchor, null);
life.enable();
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
camera.changed.raiseEvent();
t.mock.timers.tick(200);
assert.equal(loads.at(-1).coverage.kind, 'viewport');
life.destroy(viewer);
assert.equal(camera.changed.numberOfListeners, 0);
assert.equal(camera.moveEnd.numberOfListeners, 0);
assert.equal(camera.percentageChanged, 0.5);
});
test('an aborting real ingestion cannot clear the next scheduled view key', async (t) => {
const { createIngestion } = await import('./ingestion.js');
t.mock.timers.enable({ apis: ['setTimeout'] });
let rectangle = Cesium.Rectangle.fromDegrees(-117.2, 32.7, -117.1, 32.8);
const viewer = {
camera: { computeViewRectangle: () => rectangle },
scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } },
};
const state = { enabled: true, viewer };
const pending = [];
const parts = {
rendering: { renderRecords() {}, warmInstallationFloors() {} },
model: { installationWithinViewport: () => true },
};
const ctx = {
state,
parts,
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
source: {
getMappedSites(box, { signal }) {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
pending.push({ box, signal, resolve });
});
},
},
};
parts.viewport = createViewport(ctx);
parts.ingestion = createIngestion(ctx);
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
parts.viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(pending.length, 1);
rectangle = Cesium.Rectangle.fromDegrees(-117.5, 33.2, -117.3, 33.4);
parts.viewport.scheduleLoad();
const nextKey = state.cameraLoadKey;
assert.equal(pending[0].signal.aborted, true);
await flush();
assert.equal(
state.cameraLoadKey,
nextKey,
'A finally must not clear B schedule ownership',
);
t.mock.timers.tick(200);
assert.equal(pending.length, 2);
parts.viewport.scheduleLoad();
t.mock.timers.tick(600);
assert.equal(pending.length, 2);
assert.equal(pending[1].signal.aborted, false);
pending[1].resolve({ records: [], status: 'ready' });
await flush();
assert.equal(state.loading, false);
assert.equal(state.cameraLoadKey, nextKey);
});
test('wide views refresh screen decluttering when the globe rectangle stays unchanged', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const camera = {
positionWC: new Cesium.Cartesian3(10000000, 0, 0),
directionWC: new Cesium.Cartesian3(-1, 0, 0),
computeViewRectangle: () => Cesium.Rectangle.MAX_VALUE,
};
const state = {
enabled: true,
viewer: { camera, scene: { globe: { ellipsoid: Cesium.Ellipsoid.WGS84 } } },
};
let loads = 0;
const parts = {
ingestion: {
loadInstallations() {
loads++;
},
},
};
const viewport = createViewport({ state, parts, services: {}, source: {} });
viewport.scheduleLoad();
t.mock.timers.tick(200);
viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(loads, 1, 'the same settled camera remains deduplicated');
camera.positionWC = new Cesium.Cartesian3(0, 10000000, 0);
camera.directionWC = new Cesium.Cartesian3(0, -1, 0);
viewport.scheduleLoad();
t.mock.timers.tick(200);
assert.equal(loads, 2, 'a different continent needs fresh projected labels');
});
+4 -1
View File
@@ -1,6 +1,6 @@
export const LAYER_ID = 'military-installations';
export const REQUEST_DEBOUNCE_MS = 500;
export const REQUEST_DEBOUNCE_MS = 180;
export const MAX_VIEWPORT_DEGREES = 10;
@@ -19,3 +19,6 @@ export const COLOR_BY_CLASS = {
export const EARTH_MEAN_RADIUS_M = 6371008.8;
export const DISTANCE_PREFILTER_MARGIN_M = 5000;
/** A Contacts subject must move this far before its installation window moves. */
export const ANCHOR_REFRESH_M = 20_000;
+188 -20
View File
@@ -32,12 +32,104 @@ export function createRendering({
);
}
let selectedFillKey = null;
const selectedFillIds = [];
function clearSelectionFill() {
for (const id of selectedFillIds)
layerState.dataSource?.entities.removeById(id);
selectedFillIds.length = 0;
selectedFillKey = null;
}
/** Ground geometry classifies the active surface; no height/extrusion plane. */
function syncSelectionFill() {
const record =
layerState.enabled && layerState.recordById.get(layerState.selectedId);
if (!record) {
clearSelectionFill();
return;
}
const classificationType =
layerState.viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.BOTH;
const key = `${record.id}|${renderKey(record)}|${classificationType}`;
if (key === selectedFillKey) return;
clearSelectionFill();
selectedFillKey = key;
const footprints =
record.footprints || (record.footprint ? [[record.footprint]] : []);
const material = new Cesium.ColorMaterialProperty(
parts.model.colorFor(record).withAlpha(0.22),
);
for (let i = 0; i < footprints.length; i++) {
const rings = footprints[i];
const positions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const fill = layerState.dataSource.entities.add({
id: `${record.id}:selected-fill:${i}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
positions(rings[0]),
rings
.slice(1)
.map((ring) => new Cesium.PolygonHierarchy(positions(ring))),
),
material,
classificationType,
},
});
fill.installationId = record.id;
selectedFillIds.push(fill.id);
}
governorRequestRender('installation-selection-fill');
}
function clearRendered() {
clearSelectionFill();
if (layerState.dataSource?.entities)
layerState.dataSource.entities.removeAll();
layerState.renderedKeys.clear();
removeEntityContextsForLayer(LAYER_ID);
}
/**
* Geometry identity of one rendered record. Records whose key is unchanged
* keep their Cesium entities across refreshes: ground-clamped outlines and
* classification fills rebuild asynchronously, so recreating them on every
* reload made unchanged sites blink.
* @param {object} record Installation record.
* @returns {string} Stable key for the record's drawn geometry.
*/
function renderKey(record) {
const footprints =
record.footprints || (record.footprint ? [[record.footprint]] : []);
const outlines =
record.outlineLines || (record.footprint ? [record.footprint] : []);
let points = 0;
for (const rings of footprints)
for (const ring of rings) points += ring.length;
for (const line of outlines) points += line.length;
return [
record.latitude.toFixed(6),
record.longitude.toFixed(6),
record.class,
record.kind,
record.name,
footprints.length,
outlines.length,
points,
].join('|');
}
function removeRecordEntities(id) {
const entities = layerState.dataSource.entities;
for (const entityId of layerState.renderedKeys.get(id)?.entityIds || [])
entities.removeById(entityId);
layerState.renderedKeys.delete(id);
}
/**
* The records that get entities this paint: the nearest `MAX_RENDERED`, plus
* the selected one when it falls outside that window.
@@ -52,7 +144,9 @@ export function createRendering({
*/
function renderableRecords() {
const rendered = layerState.records.slice(0, MAX_RENDERED);
const rendered = layerState.records
.filter((r) => !r.pointOnly)
.slice(0, MAX_RENDERED);
if (!layerState.selectedId) return rendered;
if (rendered.some((record) => record.id === layerState.selectedId))
return rendered;
@@ -67,16 +161,48 @@ export function createRendering({
if (
!claimSelection &&
layerState.selectedId &&
selectedContext &&
selectedContext.id !== layerState.selectedId
(!selectedContext || selectedContext.id !== layerState.selectedId)
) {
layerState.selectedId = null;
}
let migratedSelection = false;
if (
layerState.selectedId &&
!layerState.recordById.has(layerState.selectedId)
) {
const replacement = layerState.records.find((record) =>
record.aliasIds?.includes(layerState.selectedId),
);
if (replacement) {
layerState.selectedId = replacement.id;
migratedSelection = true;
}
}
// Post-moveEnd debounced fetches commit after the camera settles; the
// rebuilt entities need one frame in idle mode. (perf wave 2 fix)
governorRequestRender('installations-render');
clearRendered();
for (const record of renderableRecords()) {
if (
layerState.records.some((r) =>
r.sources?.some((s) => s.name === 'OpenStreetMap'),
)
)
services.credits?.showOsmCredit?.(layerState.viewer, LAYER_ID, {
openMapTiles: layerState.records.some((r) =>
r.sources?.some(
(s) => s.name === 'OpenStreetMap' && s.id?.startsWith('tile:'),
),
),
});
else services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
const records = renderableRecords();
const keys = new Map(
records.map((record) => [record.id, renderKey(record)]),
);
const entities = layerState.dataSource.entities;
entities.suspendEvents();
for (const [id, rendered] of [...layerState.renderedKeys])
if (keys.get(id) !== rendered.key) removeRecordEntities(id);
for (const record of records) {
const color = parts.model.colorFor(record);
const surfaceHeightM = installationSurfaceHeightM(record);
const displayPosition = Cesium.Cartesian3.fromDegrees(
@@ -84,31 +210,63 @@ export function createRendering({
record.latitude,
surfaceHeightM,
);
const selectedRecord = record.id === layerState.selectedId;
const retained = layerState.renderedKeys.has(record.id)
? entities.getById(record.id)
: null;
if (retained) {
// Same geometry: update the marker in place (selection, floor height).
if (retained.gevInstallationHeightM !== surfaceHeightM) {
retained.gevInstallationHeightM = surfaceHeightM;
retained.position = displayPosition;
retained.gevDisplayPosition = () => displayPosition;
}
if (retained.gevInstallationSelected !== selectedRecord) {
retained.gevInstallationSelected = selectedRecord;
retained.point.pixelSize = selectedRecord ? 13 : 9;
retained.point.color = selectedRecord ? Cesium.Color.WHITE : color;
}
continue;
}
const entityIds = [record.id];
layerState.renderedKeys.set(record.id, {
key: keys.get(record.id),
entityIds,
});
const entity = layerState.dataSource.entities.add({
id: record.id,
position: displayPosition,
point: {
show: !record.namedArea,
pixelSize: record.id === layerState.selectedId ? 13 : 9,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
color:
record.id === layerState.selectedId ? Cesium.Color.WHITE : color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
polygon: record.footprint
? {
hierarchy: new Cesium.PolygonHierarchy(
record.footprint.map(([longitude, latitude]) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude),
),
),
material: color.withAlpha(0.12),
outline: true,
outlineColor: color.withAlpha(0.65),
height: surfaceHeightM,
}
: undefined,
});
const outlines =
record.outlineLines || (record.footprint ? [record.footprint] : []);
for (let i = 0; i < outlines.length; i++) {
const outline = layerState.dataSource.entities.add({
id: `${record.id}:outline:${i}`,
polyline: {
positions: outlines[i].map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
width: 2,
material: color.withAlpha(0.85),
clampToGround: true,
classificationType: Cesium.ClassificationType.BOTH,
},
});
outline.installationId = record.id;
entityIds.push(outline.id);
}
entity.gevInstallationHeightM = surfaceHeightM;
entity.gevInstallationSelected = selectedRecord;
entity.gevTrackedId = `installations:${record.id}`;
entity.gevDisplayPosition = () => displayPosition;
entity.gevLabelModel = {
@@ -117,6 +275,7 @@ export function createRendering({
String(record.class || 'installation')
.replaceAll('_', ' ')
.toUpperCase(),
...(record.memberNames || []).slice(0, 3),
],
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
};
@@ -133,6 +292,7 @@ export function createRendering({
longitude: record.longitude,
properties: {
class: record.class,
memberNames: record.memberNames || [],
primaryType: record.primaryType || null,
placeTypes: Array.isArray(record.placeTypes) ? record.placeTypes : [],
validation: record.validation,
@@ -140,11 +300,17 @@ export function createRendering({
},
});
}
entities.resumeEvents();
const selectedEntity = layerState.selectedId
? layerState.dataSource.entities.getById(layerState.selectedId)
: null;
if (selectedEntity) selectEntityContext(selectedEntity);
else layerState.selectedId = null;
if (selectedEntity && (claimSelection || migratedSelection))
selectEntityContext(selectedEntity);
else if (!selectedEntity) layerState.selectedId = null;
// Re-register rebuilt geometry and migrate aliases before pruning old context.
removeEntityContextsForLayer(LAYER_ID, { retainIds: new Set(keys.keys()) });
parts.namedMarkers?.sync(layerState.records);
syncSelectionFill();
}
/**
@@ -187,6 +353,8 @@ export function createRendering({
}
return {
installationSurfaceHeightM,
clearSelectionFill,
syncSelectionFill,
clearRendered,
renderableRecords,
renderRecords,
+40 -2
View File
@@ -10,6 +10,24 @@ export function createSelection({
}) {
const { clearSelectedEntityContextForLayer } = services.context;
let removeContextListeners = null;
function syncContextSelection(event) {
if (!layerState.enabled) return;
// Tracking layers publish this event before replacing the shared context.
const selected =
event?.type === 'gev:awareness-subject-selected'
? event.detail
: services.context.getSelectedEntityContext();
const next =
selected?.layerId === LAYER_ID && layerState.recordById.has(selected.id)
? selected.id
: null;
if (next === layerState.selectedId) return;
layerState.selectedId = next;
parts.rendering.renderRecords();
}
function selectRecord(id) {
const record = layerState.recordById.get(id);
if (!record || !layerState.dataSource) return false;
@@ -21,6 +39,17 @@ export function createSelection({
function installInteraction(viewer) {
if (layerState.clickHandler) return;
const events = [
'gev:entity-selected',
'gev:entity-selection-cleared',
'gev:awareness-subject-selected',
];
for (const event of events)
globalThis.window?.addEventListener?.(event, syncContextSelection);
removeContextListeners = () => {
for (const event of events)
globalThis.window?.removeEventListener?.(event, syncContextSelection);
};
layerState.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
@@ -29,7 +58,9 @@ export function createSelection({
if (!isPointerFree()) return;
if (!layerState.enabled) return;
const picked = viewer.scene.pick(click.position);
const id = typeof picked?.id?.id === 'string' ? picked.id.id : null;
const id =
picked?.id?.installationId ||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
if (id && layerState.recordById.has(id) && id !== layerState.selectedId) {
selectRecord(id);
} else if (layerState.selectedId) {
@@ -42,5 +73,12 @@ export function createSelection({
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
return { selectRecord, installInteraction };
return {
selectRecord,
installInteraction,
destroy() {
removeContextListeners?.();
removeContextListeners = null;
},
};
}
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as context from '../../data/contextStore.js';
import { createSelection } from './selection.js';
import { createRendering } from './rendering.js';
import { createNamedMarkers } from './namedMarkers.js';
function harness(t) {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = new EventTarget();
globalThis.document = new EventTarget();
const record = {
id: 'base',
name: 'Base',
class: 'base',
namedArea: true,
latitude: 0,
longitude: 0,
footprints: [
[
[
[0, 0],
[0.01, 0],
[0.01, 0.01],
[0, 0],
],
],
],
};
const state = {
enabled: true,
selectedId: null,
records: [record],
recordById: new Map([[record.id, record]]),
renderedKeys: new Map(),
dataSource: new Cesium.CustomDataSource(),
viewer: {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e6) },
scene: {
globe: { show: false },
canvas: Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
}),
primitives: { add: (p) => p, remove() {} },
},
},
};
t.mock.method(
Cesium.SceneTransforms,
'worldToWindowCoordinates',
(_scene, _point, scratch) => {
scratch.x = 640;
scratch.y = 400;
return scratch;
},
);
let labels = [];
const services = {
context,
anchors: {},
ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 },
render: { governorRequestRender() {} },
};
const parts = {
model: {
colorFor: () => Cesium.Color.ORANGE,
installationSourceLabel: () => 'OpenStreetMap',
},
};
const ctx = {
state,
services,
parts,
overlayHost: {
setEntries(_id, entries) {
labels = entries;
},
clearSource() {},
setVisible() {},
},
};
parts.rendering = createRendering(ctx);
parts.namedMarkers = createNamedMarkers(ctx);
const selection = createSelection(ctx);
selection.installInteraction(state.viewer);
t.after(() => {
selection.destroy();
state.clickHandler.destroy();
parts.namedMarkers.destroy();
parts.rendering.clearRendered();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
});
const assertSelected = (selected) => {
assert.equal(state.selectedId, selected ? record.id : null);
assert.equal(labels[0].selected, selected);
assert.equal(labels[0].variant, selected ? 'selected' : 'card');
assert.equal(
state.dataSource.entities.values.filter((e) => e.polygon).length,
selected ? 1 : 0,
);
};
assert.equal(selection.selectRecord(record.id), true);
assertSelected(true);
return { state, selection, assertSelected };
}
for (const layerId of ['flights', 'military', 'satellites']) {
for (const publishBeforeEvent of [false, true]) {
test(`${layerId} programmatic selection clears the installation card and fill (context first: ${publishBeforeEvent})`, (t) => {
const h = harness(t);
const subject = { id: 'other', layerId, origin: 'programmatic' };
if (publishBeforeEvent) context.selectTrackedSubjectContext(subject);
window.dispatchEvent(
new CustomEvent('gev:awareness-subject-selected', { detail: subject }),
);
h.assertSelected(false);
if (!publishBeforeEvent) context.selectTrackedSubjectContext(subject);
assert.equal(context.getSelectedEntityContext().id, 'other');
assert.equal(context.getSelectedEntityContext().layerId, layerId);
});
}
}
test('entity selection and clear still release the installation card and fill', (t) => {
const h = harness(t);
const other = {};
context.registerEntityContext(other, { id: 'other', layerId: 'vessels' });
context.selectEntityContext(other);
h.assertSelected(false);
h.selection.selectRecord('base');
context.clearSelectedEntityContextForLayer('military-installations');
h.assertSelected(false);
});
test('destroy removes all installation context listeners', (t) => {
const h = harness(t);
h.selection.destroy();
context.selectTrackedSubjectContext({ id: 'other', layerId: 'flights' });
for (const type of [
'gev:entity-selected',
'gev:entity-selection-cleared',
'gev:awareness-subject-selected',
]) {
window.dispatchEvent(
new CustomEvent(type, { detail: { id: 'other', layerId: 'flights' } }),
);
h.assertSelected(true);
}
});
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as C from 'cesium';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
function harness() {
const ring = [[-97, 30], [-96.99, 30], [-96.99, 30.01], [-97, 30]];
const record = { id: 'base', latitude: 30, longitude: -97, name: 'Base', class: 'base', footprints: [[ring, ring.map(([x,y]) => [x+0.001,y+0.001])], [ring]] };
const state = { enabled: true, selectedId: 'base', records: [record], recordById: new Map([['base', record]]), renderedKeys: new Map(), dataSource: new C.CustomDataSource(), viewer: { scene: { globe: { show: false } }, camera: {} } };
let listener, removed = 0;
const noop = () => {};
const services = { ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {}, render: { governorRequestRender: noop }, context: { removeEntityContextsForLayer: noop, clearSelectedEntityContextForLayer: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop }, maps: { subscribeMapStack(cb) { listener = cb; return () => { listener = null; removed++; }; } } };
const parts = { model: { colorFor: () => C.Color.ORANGE, installationSourceLabel: () => 'OpenStreetMap' }, viewport: { clearUnavailableRetry: noop }, ingestion: { setInstallationStatus: noop } };
parts.rendering = createRendering({ state, services, parts });
const lifecycle = createLifecycle({ state, services, parts, source: {} }).methods;
return { state, record, parts, lifecycle, changeSurface: () => listener(), removed: () => removed };
}
test('selected multipart footprints classify Google tiles with one constant translucent material and holes', () => {
const h = harness(), render = h.parts.rendering;
render.syncSelectionFill();
const fills = h.state.dataSource.entities.values.slice();
assert.equal(fills.length, 2);
for (const fill of fills) {
assert.equal(fill.installationId, 'base');
assert.equal(fill.polygon.classificationType.getValue(), C.ClassificationType.CESIUM_3D_TILE);
assert.equal(fill.polygon.height, undefined);
assert.equal(fill.polygon.extrudedHeight, undefined);
assert.equal(fill.polygon.material.isConstant, true);
assert.equal(fill.polygon.material.color.getValue().alpha, 0.22);
}
assert.equal(fills[0].polygon.hierarchy.getValue().holes.length, 1);
assert.equal(fills[0].polygon.material, fills[1].polygon.material);
for (let i = 0; i < 100; i++) render.syncSelectionFill();
assert.deepEqual(h.state.dataSource.entities.values, fills, 'repaints retain ground geometry');
h.state.selectedId = null; render.syncSelectionFill();
assert.equal(h.state.dataSource.entities.values.length, 0);
});
test('surface changes reclassify selected geometry and disable tears down fills and subscription', () => {
const h = harness();
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
h.state.viewer.scene.globe.show = true; h.changeSurface();
assert.ok(h.state.dataSource.entities.values.every(e => e.polygon.classificationType.getValue() === C.ClassificationType.BOTH));
h.lifecycle.disable();
assert.equal(h.state.dataSource.entities.values.length, 0);
assert.equal(h.removed(), 1);
h.lifecycle.enable(); h.parts.rendering.syncSelectionFill();
assert.equal(h.state.dataSource.entities.values.length, 0, 'reenabling cannot revive a stale selection');
h.lifecycle.disable();
});
test('geometry rebuild and canonical alias migration preserve shared selection before pruning', async () => {
const context = await import('../../data/contextStore.js');
const previousWindow = globalThis.window;
globalThis.window = new EventTarget();
try {
const h = harness();
const services = { context, render: { governorRequestRender() {} }, ground: { floorAltitudeM: () => 0, cachedGroundFloor: () => 0 }, anchors: {} };
const rendering = createRendering({ state: h.state, services, parts: h.parts });
const cleared = [];
window.addEventListener('gev:entity-selection-cleared', e => { cleared.push(e.detail); h.state.selectedId = null; });
rendering.renderRecords({ claimSelection: true });
const oldEntity = context.getSelectedEntityContext().entity;
h.record.latitude += 0.001;
rendering.renderRecords();
assert.equal(h.state.selectedId, 'base');
assert.equal(context.getSelectedEntityContext().id, 'base');
assert.notEqual(context.getSelectedEntityContext().entity, oldEntity);
const replacement = { ...h.record, id: 'canonical', aliasIds: ['base'] };
h.state.records = [replacement]; h.state.recordById = new Map([['canonical', replacement]]);
rendering.renderRecords();
assert.equal(h.state.selectedId, 'canonical');
assert.equal(context.getSelectedEntityContext().id, 'canonical');
assert.deepEqual(cleared, [], 'no transient eviction can tear down the selected card or fill');
rendering.clearRendered();
} finally { if (previousWindow === undefined) delete globalThis.window; else globalThis.window = previousWindow; }
});
+190 -5
View File
@@ -1,3 +1,22 @@
import { waitForSignal } from '../../sources/requestDeadline.js';
import {
loadMilitaryNames,
militaryNamesInView,
nameMilitaryFragment,
} from '../../data/militaryNames.js';
import {
createOpenFreeMapSource,
decodeOpenFreeMapMilitaryTile,
} from '../../sources/openFreeMap.js';
import {
mergeMilitaryFragments,
militaryNameInView,
} from '../../sources/militaryTileGeometry.js';
import { tilesForBounds } from '../../data/tomtomTiles.js';
import {
isUnavailableCapability,
sourceResponseError,
} from '../../sources/capability.js';
import { normalizeMilitaryInstallations } from '../../data/militaryInstallationData.js';
/** Preserve legacy cache admission even when the explicit saturation flag is absent. */
@@ -8,26 +27,184 @@ export function installationResponseSaturated(payload) {
return Array.isArray(payload?.elements) && payload.elements.length >= cap;
}
/**
* OpenMapTiles carries `landuse=military` only from z9; coarser tiles omit it,
* so a view that needs them would read as an empty (falsely all-clear) area.
*/
export const MILITARY_TILE_MIN_ZOOM = 9;
/** Per-view tile cap for installation tiles. */
export const MILITARY_TILE_MAX = 16;
/**
* Pick the finest installation tile zoom that fits the view, or null when even
* z9 would exceed the tile cap (the caller uses bundled named points).
* @param {{south:number, west:number, north:number, east:number}} box View box.
* @returns {number|null} Tile zoom.
*/
export function installationTileZoom(box) {
let zoom =
Math.max(box.north - box.south, box.east - box.west) > 0.2 ? 10 : 12;
const fits = (z) =>
tilesForBounds(box, z, { maxTiles: MILITARY_TILE_MAX + 1 }).length <=
MILITARY_TILE_MAX;
while (zoom > MILITARY_TILE_MIN_ZOOM && !fits(zoom)) zoom -= 1;
return fits(zoom) ? zoom : null;
}
/**
* Square window around a context subject that fits the installation tile cap
* at the military minimum zoom: 100 km half-width at mid latitudes, shrinking
* toward the poles where z9 tiles are shorter.
* @param {{latitude:number, longitude:number}} anchor Subject position.
* @returns {{south:number, west:number, north:number, east:number,
* radiusM:number}|null} Bounded box, or null for an invalid anchor.
*/
export function installationAnchorBox(anchor) {
const { latitude, longitude } = anchor || {};
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 85 ||
Math.abs(longitude) > 180
)
return null;
for (let radiusM = 100_000; radiusM >= 10_000; radiusM *= 0.8) {
const latSpan = radiusM / 111_320;
const lonSpan =
latSpan / Math.max(0.05, Math.cos((latitude * Math.PI) / 180));
const box = {
south: Math.max(-85, latitude - latSpan),
north: Math.min(85, latitude + latSpan),
west: Math.max(-180, longitude - lonSpan),
east: Math.min(180, longitude + lonSpan),
};
if (
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, {
maxTiles: MILITARY_TILE_MAX + 1,
}).length <= MILITARY_TILE_MAX
)
return { ...box, radiusM: Math.round(radiusM) };
}
return null;
}
/** Read mapped installations and explicit nearby-place searches through fixed endpoints. */
export function createInstallationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
loadNames = loadMilitaryNames,
mapTiles = createOpenFreeMapSource({
fetchImpl: tileFetchImpl,
decode: decodeOpenFreeMapMilitaryTile,
}),
} = {}) {
let overpassUnavailable = false;
const installationIds = new Map();
async function getNamedSites(box, signal, thinned = true) {
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
signal?.throwIfAborted();
throw Object.assign(
new Error('Mapped names temporarily unavailable', { cause }),
{
failureReason: 'names_unavailable',
},
);
});
signal?.throwIfAborted();
// The map keeps one site per display cell; queries can ask for all.
const { records, count } = militaryNamesInView(
names,
box,
thinned ? undefined : Infinity,
thinned,
);
return {
records,
namedInView: count,
wide: true,
status: 'ready',
tileSource: true,
saturated: false,
droppedCount: 0,
source: 'OpenStreetMap',
};
}
async function getTileSites(box, signal, thinned) {
const zoom = installationTileZoom(box);
if (zoom === null) return getNamedSites(box, signal, thinned);
let names = null;
const namesJob = waitForSignal(loadNames(), signal)
.then((loaded) => {
names = loaded;
return loaded;
})
.catch(() => null);
let result;
try {
result = await mapTiles.fetchBounds(box, { zoom, signal });
} catch (error) {
if (error?.name === 'AbortError' || signal?.aborted) throw error;
// Name the tile source: an Overpass reason here would be untrue.
throw Object.assign(
error instanceof Error ? error : new Error(String(error)),
{
failureReason: 'tiles_unavailable',
},
);
}
signal?.throwIfAborted();
const snapshot = (names) => {
const fragments = result.tiles
.flatMap((tile) => tile.military)
.map((record) => nameMilitaryFragment(record, names));
const retrievedAt = new Date().toISOString();
return {
records: mergeMilitaryFragments(
fragments.slice(0, 2048),
installationIds,
).map((record) => ({
...militaryNameInView(record, box),
retrievedAt,
sources: record.sources.map((source) => ({ ...source, retrievedAt })),
})),
status: 'ready',
droppedCount: 0,
saturated: result.partial || fragments.length > 2048,
source: 'OpenStreetMap tiles',
tileSource: true,
};
};
const payload = snapshot(names);
if (!names)
payload.enrichment = namesJob.then((loaded) =>
loaded && !signal?.aborted ? snapshot(loaded) : null,
);
return payload;
}
return {
async getMappedSites(box, { exact = false, signal } = {}) {
destroy() {
mapTiles.clear();
installationIds.clear();
},
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
const { south, west, north, east } = box || {};
if (
![south, west, north, east].every(Number.isFinite) ||
south < -90 ||
north > 90 ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
north <= south ||
east <= west ||
north - south > 10 ||
east - west > 10
east === west
)
throw new TypeError('A bounded installation viewport is required');
signal?.throwIfAborted();
if (east < west || north - south > 10 || east - west > 10)
return getNamedSites(box, signal, thinned);
if (overpassUnavailable) return getTileSites(box, signal, thinned);
const query = new URLSearchParams(
Object.entries({ south, west, north, east }).map(([key, value]) => [
key,
@@ -40,9 +217,17 @@ export function createInstallationSource({
});
const body = await response.json();
signal?.throwIfAborted();
if (isUnavailableCapability(body)) {
overpassUnavailable = true;
return getTileSites(box, signal, thinned);
}
if (!response.ok)
throw Object.assign(
new Error(body?.error || `Installation feed HTTP ${response.status}`),
sourceResponseError(
body,
response,
'Installation context unavailable',
),
{
failureReason: ['rate_limited', 'timeout', 'query_failed'].includes(
body?.reason,
+180 -2
View File
@@ -1,8 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInstallationSource } from './source.js';
import { readFileSync } from 'node:fs';
import { tilesForBounds } from '../../data/tomtomTiles.js';
import { installationFeedback } from '../../data/installationFeedback.js';
import {
createInstallationSource,
installationAnchorBox,
installationTileZoom,
MILITARY_TILE_MAX,
MILITARY_TILE_MIN_ZOOM,
} from './source.js';
import {
decodeOpenFreeMapMilitaryTile,
decodeOpenFreeMapTile,
} from '../../sources/openFreeMap.js';
const box = { south: 30.1, west: -97.9, north: 30.3, east: -97.6 };
test('a wide-view pack failure names the unavailable data without an API fallback', async () => {
const source = createInstallationSource({
fetchImpl: async () => assert.fail('no API request for bundled names'),
loadNames: async () => {
throw new Error('asset unavailable');
},
});
await assert.rejects(
source.getMappedSites({ south: 20, west: -130, north: 60, east: -60 }),
(error) => {
assert.equal(error.failureReason, 'names_unavailable');
assert.equal(
installationFeedback({
status: 'unavailable',
failureReason: error.failureReason,
}),
'Mapped names temporarily unavailable',
);
return true;
},
);
});
test('mapped-site sources validate viewport bounds and preserve the exact retry key', async () => {
const calls = [];
const source = createInstallationSource({
@@ -14,7 +49,7 @@ test('mapped-site sources validate viewport bounds and preserve the exact retry
for (const invalid of [
null,
{ ...box, east: Infinity },
{ ...box, north: 90 },
{ ...box, north: 91 },
{ ...box, south: 31 },
])
await assert.rejects(
@@ -84,3 +119,146 @@ test('mapped-site sources normalize records and derive saturation from legacy ca
assert.equal(payload.status, 'stale');
assert.equal('elements' in payload, false);
});
test('installation tiles never drop below z9, where military areas exist', () => {
// OpenMapTiles omits landuse=military below z9; a z6-z8 view used to read as
// an empty, "Mapped sites loaded" area in Cockpit and wide follow views.
assert.equal(
installationTileZoom({
south: 30.3,
west: -97.8,
north: 30.33,
east: -97.74,
}),
12,
);
assert.equal(
installationTileZoom({ south: 30, west: -98, north: 31, east: -97 }),
10,
);
assert.equal(
installationTileZoom({ south: 30, west: -98.2, north: 31.4, east: -96.8 }),
9,
);
assert.equal(
installationTileZoom({ south: 28, west: -100, north: 32, east: -96 }),
null,
);
});
test('a Contacts window fits the tile cap at z9 from the tropics to the Arctic', () => {
for (const latitude of [0, 30.3125, 51.5, 60, 70]) {
const box = installationAnchorBox({ latitude, longitude: -97.765 });
assert.ok(box, `window at ${latitude}`);
assert.ok(
tilesForBounds(box, MILITARY_TILE_MIN_ZOOM, { maxTiles: 64 }).length <=
MILITARY_TILE_MAX,
);
assert.ok(box.south < latitude && box.north > latitude);
}
assert.equal(
installationAnchorBox({ latitude: 30.3125, longitude: -97.765 }).radiusM,
100_000,
);
assert.ok(
installationAnchorBox({ latitude: 60, longitude: 10 }).radiusM < 100_000,
);
assert.equal(
installationAnchorBox({ latitude: Number.NaN, longitude: 0 }),
null,
);
});
test('too-wide tile views use bundled named points without requesting tiles', async () => {
const fetched = [];
const source = createInstallationSource({
fetchImpl: async () =>
Response.json({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }),
mapTiles: {
clear() {},
async fetchBounds(box, { zoom }) {
fetched.push(zoom);
return { tiles: [], partial: false };
},
},
});
const wide = await source.getMappedSites({
south: 28,
west: -100,
north: 32,
east: -96,
});
assert.equal(wide.wide, true);
assert.equal(wide.status, 'ready');
assert.ok(wide.records.some((r) => r.name === 'Fort Hood'));
assert.ok(wide.records.every((r) => r.namedArea));
assert.deepEqual(
fetched,
[],
'no tile request for a view the tiles cannot answer',
);
await source.getMappedSites({
south: 30,
west: -98.2,
north: 31.4,
east: -96.8,
});
assert.deepEqual(fetched, [9]);
});
test('the military-only decoder keeps Camp Mabry and skips road geometry', () => {
const bytes = readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
);
const full = decodeOpenFreeMapTile(bytes, 12, 935, 1685);
const military = decodeOpenFreeMapMilitaryTile(bytes, 12, 935, 1685);
assert.ok(full.roads.length > 0);
assert.equal(military.roads.length, 0);
assert.deepEqual(
military.military.map((record) => record.id),
full.military.map((record) => record.id),
);
});
test('installation API transports never receive vector-tile URLs', async (t) => {
const calls = [];
const tileFetch = async (url) => {
calls.push(url);
return url.endsWith('/planet')
? Response.json({
tiles: ['https://tiles.openfreemap.org/test/{z}/{x}/{y}.pbf'],
})
: new Response(
readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
),
);
};
t.mock.method(globalThis, 'fetch', tileFetch);
for (const tileFetchImpl of [undefined, tileFetch]) {
const source = createInstallationSource({
tileFetchImpl,
fetchImpl: async (url) => {
assert.ok(url.startsWith('/api/'));
return Response.json({
code: 'OVERPASS_NOT_CONFIGURED',
retryable: false,
});
},
});
const data = await source.getMappedSites({
south: 30.3,
west: -97.77,
north: 30.32,
east: -97.75,
});
assert.ok(data.records.length);
}
assert.ok(calls.length >= 4);
});
+8
View File
@@ -32,6 +32,14 @@ export function createState({ services }) {
clickHandler: null,
timer: null,
googleSearchRequested: false,
/** Contacts subject position; when set, it replaces the camera viewport. */
contextAnchor: null,
/** Latest subject centre, independent of the retained tile-fetch anchor. */
contextPosition: null,
/** Coverage of the last successful load: viewport, or radius around a subject. */
coverage: { kind: 'viewport' },
/** Per-record geometry keys of the rendered entities, for in-place updates. */
renderedKeys: new Map(),
});
return state;
}
+50 -7
View File
@@ -1,7 +1,27 @@
import * as Cesium from 'cesium';
import { MAX_VIEWPORT_DEGREES, REQUEST_DEBOUNCE_MS } from './policy.js';
import { REQUEST_DEBOUNCE_MS } from './policy.js';
import { installationAnchorBox } from './source.js';
export function createViewport({ state: layerState, services, parts, source }) {
/**
* The area this load describes: the window around a Contacts subject while
* one is set (a follow or Cockpit camera never settles and often looks at the
* horizon), otherwise the settled camera viewport.
* @param {object} viewer Cesium viewer.
* @returns {{box: object|null, coverage: object}} Query box and its coverage.
*/
function loadArea(viewer) {
const anchor = layerState.contextAnchor;
if (anchor) {
const box = installationAnchorBox(anchor);
if (box) {
const { radiusM, ...bounds } = box;
return { box: bounds, coverage: { kind: 'subject', radiusM } };
}
}
return { box: viewportBox(viewer), coverage: { kind: 'viewport' } };
}
function viewportBox(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle(
viewer.scene.globe.ellipsoid,
@@ -11,12 +31,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
const north = Cesium.Math.toDegrees(rectangle.north);
const west = Cesium.Math.toDegrees(rectangle.west);
const east = Cesium.Math.toDegrees(rectangle.east);
// Cross-dateline/global views require a zoom before a bounded request.
// Wide and dateline views use the bundled point index.
if (
!Number.isFinite(south + north + west + east) ||
east <= west ||
north - south > MAX_VIEWPORT_DEGREES ||
east - west > MAX_VIEWPORT_DEGREES
east === west ||
north <= south
)
return null;
return { south, west, north, east };
@@ -44,10 +63,13 @@ export function createViewport({ state: layerState, services, parts, source }) {
* zoom-out, or disable cancels it.
*/
function scheduleUnavailableRetry() {
function scheduleUnavailableRetry(retryAfterMs = 0) {
if (!layerState.enabled) return;
clearTimeout(layerState.retryTimer);
layerState.retryDelayMs = installationRetryDelayMs(layerState.retryDelayMs);
layerState.retryDelayMs = Math.max(
retryAfterMs,
installationRetryDelayMs(layerState.retryDelayMs),
);
layerState.retryAt = Date.now() + layerState.retryDelayMs;
layerState.retryTimer = setTimeout(() => {
layerState.retryTimer = null;
@@ -66,15 +88,36 @@ export function createViewport({ state: layerState, services, parts, source }) {
function scheduleLoad() {
if (!layerState.enabled) return;
const { box, coverage } = loadArea(layerState.viewer);
const camera = layerState.viewer.camera;
const widePose =
coverage.kind === 'viewport' &&
box &&
(box.east < box.west ||
box.east - box.west > 10 ||
box.north - box.south > 10)
? [camera.positionWC, camera.directionWC]
.map((v) =>
v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '',
)
.join(':')
: '';
const key = `${coverage.kind}:${box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(6)).join(',') : 'wide'}:${widePose}`;
if (layerState.cameraLoadKey === key && !layerState.error) return;
layerState.cameraLoadKey = key;
layerState.cameraLoadOwner = {};
layerState.abort?.abort();
// A user-driven load supersedes any pending retry; the load reschedules on
// failure, so the backoff step is kept rather than reset.
clearUnavailableRetry({ resetBackoff: false });
clearTimeout(layerState.timer);
layerState.timer = setTimeout(() => {
layerState.timer = null;
parts.ingestion.loadInstallations();
}, REQUEST_DEBOUNCE_MS);
}
return {
loadArea,
viewportBox,
installationRetryDelayMs,
scheduleUnavailableRetry,
+34
View File
@@ -17,6 +17,40 @@ export function createControls({ state: layerState, services, parts, source }) {
parts.selection.stopMissionZoomAnchor();
},
/**
* Snapshot loaded launches as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, provider, status, launchSite, missionName, lat, lon, launchTimeMs, hoursUntil}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return [];
const now = Date.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = [];
for (const launch of layerState._launches || []) {
if (result.length >= maxCount) break;
const at = Date.parse(launch.launchTime);
result.push({
id: launch.id,
name: text(launch.name),
provider: text(launch.provider),
status: text(launch.status),
launchSite: text(launch.launchSite),
missionName: text(launch.missionName),
lat: launch.lat,
lon: launch.lon,
launchTimeMs: Number.isFinite(at) ? at : null,
// Negative for launches already flown.
hoursUntil: Number.isFinite(at)
? Math.round(((at - now) / 3_600_000) * 10) / 10
: null,
});
}
return result;
},
getStats() {
return {
count: layerState._count,
+17 -9
View File
@@ -2,16 +2,24 @@
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/** The launch payload, and whether the proxy served it from a stale cache. */
async function getLaunchSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
};
}
return {
async getLaunches({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return payload;
getLaunchSnapshot,
async getLaunches(options) {
return (await getLaunchSnapshot(options)).payload;
},
async getActiveTle({ signal } = {}) {
signal?.throwIfAborted();
+34
View File
@@ -828,6 +828,40 @@ export function createLocalAdsbLayer({
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
},
/**
* Snapshot loaded locally received aircraft as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, icao24, callsign, lat, lon, altitudeM, speedKts, heading, onGround}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!enabled) return [];
const num = (v) => (Number.isFinite(v) ? v : null);
const result = [];
for (const marker of markers.values()) {
if (result.length >= maxCount) break;
const record = marker.record || {};
const lat = marker.lat ?? record.lat;
const lon = marker.lon ?? record.lon;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
result.push({
id: record.callsign || record.icao,
icao24: record.icao || null,
callsign: record.callsign || null,
lat,
lon,
altitudeM: Number.isFinite(record.altitudeFt)
? Math.round(record.altitudeFt * 0.3048)
: null,
speedKts: num(record.groundSpeedKt),
heading: num(record.trackDeg),
onGround: record.onGround === true,
});
}
return result;
},
getAllPositions(maxCount = 500) {
return positionRows(maxCount);
},
+1
View File
@@ -33,6 +33,7 @@ export function createMilitaryFlightLayer({
militaryRegistry: services.militaryRegistry,
rendering: parts.rendering,
tracking: parts.tracking,
motion: parts.motion,
queries: parts.queries,
});
parts.ingestion = createIngestion({
+98
View File
@@ -113,3 +113,101 @@ test('a normalized source can retain its stale reason without changing standalon
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().stale, true);
});
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
// contact's first displayed position is its fix projected back to that
// delayed time. Creating the billboard at the raw fix drew it ahead until
// the next fleet tick jumped it back.
const Cesium = await import('cesium');
const { RENDER_DELAY_SEC } = await import('./policy.js');
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
const supplied = {
picking: await import('../../data/pickRegistry.js'),
sprites: await import('../../data/spriteOrder.js'),
trails: await import('../../data/trailRenderer.js'),
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
geoid: await import('../../data/geoid.js'),
focus: await import('../../data/focusDeemphasis.js'),
readout: await import('../../data/trackedReadout.js'),
context: await import('../../data/contextStore.js'),
render: await import('../../renderGovernor.js'),
recession: await import('../../data/aircraftRecession.js'),
groundFloor: await import('../../data/groundFloor.js'),
groundSnap: await import('../../data/groundSnap.js'),
meshFloor: { sampleMeshFloorCells: () => {} },
militaryRegistry: { registerMilitaryIcaos: () => {} },
};
const fixLon = -97.6;
const fixLat = 30.3;
const speedMps = 200;
const fixAgeMs = 2_000;
const nowMs = Date.now();
const layer = createMilitaryFlightLayer({
services: supplied,
source: {
label: 'Fixture aircraft',
async getSnapshot() {
return {
source: 'Fixture aircraft',
complete: true,
observedAtMs: nowMs,
records: [
{
id: 'ae0001',
latitude: fixLat,
longitude: fixLon,
onGround: false,
baroAltitudeM: 9_000,
courseDeg: 90,
speedMps,
positionTimeMs: nowMs - fixAgeMs,
contactTimeMs: nowMs - fixAgeMs,
},
],
};
},
},
});
const added = [];
layer.testing._setTrackedMilitaryRefreshStateForTest({
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
billboard: {
show: false,
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
billboardCollection: {
show: false,
add(options) {
const billboard = { ...options };
added.push(billboard);
return billboard;
},
remove() {},
},
viewer: { camera: { positionCartographic: null }, scene: {} },
tracked: false,
});
await layer.update({ camera: { positionCartographic: null }, scene: {} });
const billboard = added.find((entry) => entry.id === 'ae0001');
assert.ok(billboard, 'the new contact gets a billboard');
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
const behindM = Cesium.Cartesian3.distance(
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
);
const expectedM = speedMps * (RENDER_DELAY_SEC - fixAgeMs / 1000);
assert.ok(
Cesium.Math.toDegrees(shown.longitude) < fixLon,
'drawn behind the fix',
);
assert.ok(
Math.abs(behindM - expectedM) < speedMps * 3,
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
+6
View File
@@ -6,6 +6,7 @@ export {
ERROR_BACKOFF_INTERVAL,
} from './recordPolicy.js';
import * as Cesium from 'cesium';
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
/**
* @module militaryFlights
@@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */
export const EMERGENCY_ICON_COLOR =
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
+22 -1
View File
@@ -9,6 +9,7 @@ import {
CONTACT_MATCH_TIER,
} from '../../data/contactMatch.js';
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -140,6 +141,10 @@ export function createQueries({
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),
};
}
@@ -163,7 +168,9 @@ 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: null,
* operator: string|null, routeOrigin: null, routeDestination: null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}}
*/
function mapAnalystRecord(icao24, info) {
@@ -194,6 +201,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: null,
operator: text(info?.operator),
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: null,
routeDestination: null,
};
+4
View File
@@ -1,6 +1,7 @@
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
import { classifyAircraft } from '../../data/aircraftClass.js';
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
@@ -161,6 +162,9 @@ export class MilitaryFlightRecords {
null,
),
turnRateDps: prevMeta?.turnRateDps || 0,
// Per poll, NOT sticky (mirror of flights): a cleared code clears here.
squawk: aircraft.squawk ?? null,
emergency: aircraftEmergency(aircraft),
onGround,
// Round 7: sticky airborne history (see _likelyLanded).
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
+16
View File
@@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only
'remove',
);
});
test('military emergency status follows the latest poll instead of sticking', () => {
const records = store();
const first = records.receive(
observation({ squawk: '1234', emergency: 'minfuel' }),
context(),
);
assert.equal(first.meta.squawk, '1234');
assert.equal(first.meta.emergency.kind, 'minfuel');
assert.equal(first.meta.emergency.severity, 'priority');
const next = records.receive(
observation({ squawk: '1234', emergency: 'none' }),
context(),
);
assert.equal(next.meta.emergency, null);
});
+19 -4
View File
@@ -18,6 +18,7 @@ import {
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import {
PLANE_MODEL_SCALE,
PLANE_MODEL_URL,
@@ -33,6 +34,7 @@ import {
COCKPIT_CONTACT_SIZE_PX,
MIL_ICON_COLOR,
TRACKED_ICON_COLOR,
EMERGENCY_ICON_COLOR,
MODEL_ALT_CEIL_M,
MODEL_MAX_ALL,
MODEL_MAX,
@@ -159,7 +161,7 @@ export function createRendering({
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -173,7 +175,9 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
cyberSonarBaseAlpha(bb),
);
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -189,7 +193,17 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
return MIL_ICON_COLOR;
return _fleetBillboardColor(icao24);
}
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
* amber otherwise. Cockpit far-contact dots use it too. */
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -965,7 +979,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const baseColor = _fleetBillboardColor(icao24, info);
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -1082,6 +1096,7 @@ export function createRendering({
_applyFleetBillboardPresentation,
_iconKind,
_modelColor,
_fleetBillboardColor,
_modelRegimeActive,
_modelCap,
_modelAddDistM,
+18 -8
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
@@ -7,12 +8,7 @@ import {
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import {
BILLBOARD_SCALE,
GROUND_SCALE,
TRACKED_ICON_COLOR,
MIL_ICON_COLOR,
} from './policy.js';
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
/** Apply military record changes to Cesium resources and existing follow operations. */
export function createMilitarySnapshotRenderer({
@@ -23,6 +19,7 @@ export function createMilitarySnapshotRenderer({
militaryRegistry,
rendering,
tracking,
motion,
queries,
}) {
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
@@ -124,6 +121,11 @@ export function createMilitarySnapshotRenderer({
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
if (!isTracked) {
bb.color = rendering
._fleetBillboardColor(icao24, meta)
.withAlpha(cyberSonarBaseAlpha(bb));
}
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
// Ground flips (landing/takeoff) restyle this SAME billboard in
@@ -142,8 +144,14 @@ export function createMilitarySnapshotRenderer({
// Round 5: depth policy is uniform (always depth-test-free, see
// _groundDepthDistance) — nothing to flip on landing/takeoff.
} else {
// Start where the fleet pass will draw it: the fleet renders behind
// real time, so a fresh contact's displayed position is its fix
// projected back to that delayed time. Creating it at the raw fix drew
// it ahead until the next fleet tick snapped it back.
const displayed =
motion._deadReckon(icao24, new Cesium.Cartesian3()) || position;
const bb = flightState._billboardCollection.add({
position,
position: displayed,
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
width: isTracked ? 24 : 20,
height: isTracked ? 24 : 20,
@@ -154,7 +162,9 @@ export function createMilitarySnapshotRenderer({
// Screen-projected rotation lands on the next fleet tick.
rotation: 0,
alignedAxis: Cesium.Cartesian3.ZERO,
color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR,
color: isTracked
? TRACKED_ICON_COLOR
: rendering._fleetBillboardColor(icao24, meta),
sizeInMeters: false,
scaleByDistance: rendering._normalBillboardScaleByDistance(),
// Grounded/near-surface planes sit at/below the photoreal tile
+6 -3
View File
@@ -21,7 +21,6 @@ import {
TRAIL_COLOR,
BILLBOARD_SCALE,
GROUND_SCALE,
MIL_ICON_COLOR,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
@@ -780,12 +779,16 @@ export function createTracking({
bb.width = 20;
bb.height = 20;
// Ground-aware restore (a plane untracked while taxiing comes back at
// ground scale; tint is full amber on the ground and in the air).
// ground scale; the fleet tint is amber, or red while broadcasting an
// emergency, on the ground and in the air).
bb.scale =
BILLBOARD_SCALE *
(CLASS_SCALE_2D[meta?.klass] || 1) *
(meta?.onGround ? GROUND_SCALE : 1);
bb.color = MIL_ICON_COLOR;
bb.color = parts.rendering._fleetBillboardColor(
flightState._trackedIcao,
meta,
);
bb.rotation = flightState._lastTrackedRotation;
}
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
+18 -7
View File
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
* read, or the cap dropped valid granules from the last page read.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
}) {
const granules = [];
let hits = 0;
let readCount = 0;
let capped = 0;
let cursor = null;
for (;;) {
const init = { signal };
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
const json = await response.json();
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
const page = parseCmrUmm(json, product);
readCount += rawCount;
const admitted = page.granules.slice(
0,
Math.max(0, maxRecords - granules.length),
);
granules.push(...admitted);
capped += page.granules.length - admitted.length;
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
rawCount < CMR_PAGE_SIZE ||
readCount >= maxRecords ||
readCount >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
return { granules, truncated: hits > readCount || capped > 0 };
}
function finiteOrNull(value) {
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCmrPages } from './catalog.js';
const item = (id) => ({
meta: { 'concept-id': id },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
},
},
});
test('discarded rows do not turn a full CMR page into a final page', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
fetchImpl: async () => {
calls++;
const items =
calls === 1
? Array.from({ length: 200 }, (_, i) =>
i === 0 ? {} : item(String(i)),
)
: [item('last')];
return Response.json(
{ hits: 201, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 200);
assert.equal(result.granules.at(-1).id, 'last');
assert.equal(result.truncated, false);
});
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 1000,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
return Response.json(
{
hits: 400,
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
},
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, true);
});
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
let calls = 0;
const result = await fetchCmrPages({
product: 'S30',
url: 'fixture',
maxRecords: 250,
fetchImpl: async () => {
calls++;
const items = Array.from({ length: 200 }, (_, i) =>
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
);
return Response.json(
{ hits: 400, items },
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
);
},
});
assert.equal(calls, 2);
assert.equal(result.granules.length, 250);
assert.equal(result.truncated, false);
});
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCentre,
boxFromRectangle,
dequantizeBox,
validateBox,
} from './model.js';
import { searchHls } from './catalog.js';
test('missing imagery edges do not turn into invented zero coordinates', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
const box = { west: value, south: 0, east: 1, north: 1 };
assert.equal(validateBox(box).ok, false);
assert.equal(validateBox(box).reason, 'invalid');
assert.equal(boxCentre(box), null);
}
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
assert.equal(
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
true,
);
});
test('every edge and every box entry point refuses a missing edge', async () => {
const blanks = [null, undefined, '', ' ', false, [], {}];
for (const edge of ['west', 'south', 'east', 'north']) {
for (const value of blanks) {
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
// Share-link integers and Cesium radians are converted per edge.
assert.equal(dequantizeBox(box), null, `share ${edge}`);
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
}
}
let fetched = false;
await assert.rejects(
searchHls({
box: { west: ' ', south: 1, east: 2, north: 2 },
fetchImpl: async () => {
fetched = true;
},
}),
TypeError,
);
assert.equal(fetched, false);
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
});
+19 -6
View File
@@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
/** A box edge as a number, or null when it is missing, blank or not numeric. */
function edgeNumber(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
@@ -82,8 +90,8 @@ function normalizeBox(box) {
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
].map(edgeNumber);
if ([west, south, east, north].some((edge) => edge == null)) return null;
return {
west,
south: Math.min(south, north),
@@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
const edge = (value) => {
const number = edgeNumber(value);
return number == null ? null : fn(number);
};
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
west: edge(box.west),
south: edge(box.south),
east: edge(box.east),
north: edge(box.north),
});
}
+4 -2
View File
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
*/
export function createRecentImageryRenderer({
cesium = Cesium,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
url: tileTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
box.east,
box.north,
),
credit: 'NASA GIBS',
credit,
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
+20 -1
View File
@@ -53,10 +53,11 @@ const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
...options,
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
const { renderer, globe } = fixture({
tileTemplate: (product, day) =>
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.equal(renderer.showSlot('a', S30, BOX), true);
const layer = globe.layers[0];
assert.equal(
layer.provider.options.url,
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
);
assert.equal(layer.provider.options.credit, 'Example imagery');
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(layer.destroyed, true);
});
+36
View File
@@ -0,0 +1,36 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/**
* NASA-compatible imagery acquisition; transport and URL builders are replaceable.
* Thumbnail responses must expose Data-Present: true for image admission, or
* false for confirmed empty coverage, including injected getThumbnail sources.
*/
export function createRecentImagerySource({
fetchImpl = (...args) => globalThis.fetch(...args),
search = searchHls,
thumbnailUrl = wvsSnapshotUrl,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
} = {}) {
return {
credit,
searchHls: (query) => search({ ...query, fetchImpl }),
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
thumbnailUrl({
product: candidate.product,
day: candidate.day,
box,
width: size,
height: size,
}),
{ signal },
);
signal?.throwIfAborted();
return response;
},
getTileTemplate: (product, day) => tileTemplate(product, day),
};
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRecentImagerySource } from './source.js';
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
const controller = new AbortController();
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
const candidate = { product: 'S30', day: '2026-10-01' };
const calls = [];
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
const source = createRecentImagerySource({
fetchImpl,
search: async (request) => {
assert.equal(request.fetchImpl, fetchImpl);
assert.equal(request.signal, controller.signal);
assert.equal(request.box, query.box);
return [candidate];
},
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.deepEqual(await source.searchHls(query), [candidate]);
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
assert.equal(calls[0][1].signal, controller.signal);
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
assert.equal(source.credit, 'Example imagery');
});
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
const controller = new AbortController();
let calls = 0;
const source = createRecentImagerySource({
thumbnailUrl: () => 'https://imagery.example/thumbnail',
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
});
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 1);
});
Loaded 100 of 868 files, more files were not shown because too many files have changed in this diff. Show more