mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into feat/brazil-gbfs-cities
This commit is contained in:
commit
477eb86d64
1521 files changed
+395579
-28629
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AIR_QUALITY_BANDS,
|
||||
AIR_QUALITY_PROVIDERS,
|
||||
AIR_QUALITY_UNKNOWN_COLOR,
|
||||
OBSERVATION_MAX_AGE_MS,
|
||||
STALE_AFTER_MS,
|
||||
airQualityColor,
|
||||
aqhiDisplayValue,
|
||||
bandForValue,
|
||||
bandRiskText,
|
||||
mapAnalystRecord,
|
||||
normalizeAqhiObservations,
|
||||
normalizeAqhiStations,
|
||||
readingsAreStale,
|
||||
readingsInView,
|
||||
selectAirQualityOverlayCohort,
|
||||
valueLabel,
|
||||
} from './model.js';
|
||||
|
||||
const NOW = Date.parse('2026-09-24T18:30:00Z');
|
||||
const RECENT = '2026-09-24T18:00:00Z';
|
||||
const collection = (features) => ({ type: 'FeatureCollection', features });
|
||||
const station = (id, name, zone, lon, lat) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: {
|
||||
location_id: id,
|
||||
location_name_en: name,
|
||||
'eccc_administrative-zone': zone,
|
||||
},
|
||||
});
|
||||
const observation = (id, aqhi, when = RECENT) => ({
|
||||
type: 'Feature',
|
||||
properties: { location_id: id, aqhi, observation_datetime: when },
|
||||
});
|
||||
|
||||
const STATIONS = collection([
|
||||
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
|
||||
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
|
||||
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
|
||||
]);
|
||||
|
||||
// ── provider-shaped records ────────────────────────────────────────────────
|
||||
|
||||
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
|
||||
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
|
||||
// US AQI is 0-500 on concentration. The shape names which index a value is on
|
||||
// so a second network can be added without inventing a shared meaning.
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const [row] = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2.32)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.equal(row.provider, 'eccc');
|
||||
assert.equal(row.scale, 'AQHI');
|
||||
assert.equal(row.value, 2);
|
||||
assert.equal(row.band, 'low');
|
||||
});
|
||||
|
||||
test('bands map from a value on a named provider scale', () => {
|
||||
assert.equal(bandForValue('eccc', 1), 'low');
|
||||
assert.equal(bandForValue('eccc', 3), 'low');
|
||||
assert.equal(bandForValue('eccc', 4), 'moderate');
|
||||
assert.equal(bandForValue('eccc', 7), 'high');
|
||||
assert.equal(bandForValue('eccc', 11), 'very-high');
|
||||
// An unregistered provider cannot be banded rather than being guessed at.
|
||||
assert.equal(bandForValue('unknown-network', 5), null);
|
||||
assert.equal(bandForValue('eccc', null), null);
|
||||
});
|
||||
|
||||
test('the provider states its coverage plainly', () => {
|
||||
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
|
||||
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
|
||||
});
|
||||
|
||||
test("values above ten collapse to the scale's open-ended label", () => {
|
||||
assert.equal(valueLabel('eccc', 10), '10');
|
||||
assert.equal(valueLabel('eccc', 11), '10+');
|
||||
assert.equal(valueLabel('eccc', null), '--');
|
||||
});
|
||||
|
||||
test('every band has a distinct colour, and an unknown band is neutral', () => {
|
||||
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
|
||||
assert.equal(new Set(colors).size, colors.length);
|
||||
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
|
||||
assert.equal(bandRiskText('high'), 'High health risk');
|
||||
assert.equal(bandRiskText(null), null);
|
||||
});
|
||||
|
||||
// ── published form ─────────────────────────────────────────────────────────
|
||||
|
||||
test('AQHI rounds to the published integer form and floors at 1', () => {
|
||||
assert.equal(aqhiDisplayValue(1.08), 1);
|
||||
assert.equal(aqhiDisplayValue(3.5), 4);
|
||||
assert.equal(aqhiDisplayValue(0), 1);
|
||||
assert.equal(aqhiDisplayValue('3'), 3);
|
||||
});
|
||||
|
||||
test('an absent reading never becomes a confident AQHI 1', () => {
|
||||
// Number(null) and Number('') are both 0, which would floor to 1.
|
||||
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
|
||||
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── snapshot validation ────────────────────────────────────────────────────
|
||||
|
||||
test('stations normalize into an id-keyed catalog carrying their zone', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
assert.equal(stations.size, 3);
|
||||
assert.equal(stations.get('OAAAA').zone, 'ont');
|
||||
});
|
||||
|
||||
test('malformed payloads are rejected atomically', () => {
|
||||
assert.equal(normalizeAqhiStations(null), null);
|
||||
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
|
||||
assert.equal(
|
||||
normalizeAqhiObservations(collection([]), 'not a map', NOW),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('an observation with no matching station is dropped', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('a stale reading is dropped rather than shown as current', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.stationId),
|
||||
['OAAAA'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a snapshot whose every observation was too old is stale, not nominal', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(rows, []);
|
||||
assert.equal(readingsAreStale(rows, NOW), true);
|
||||
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
|
||||
assert.equal(
|
||||
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the newest reading per station wins and worst air sorts first', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([
|
||||
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
|
||||
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
|
||||
observation('OAAAA', 5),
|
||||
]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.value),
|
||||
[7, 5],
|
||||
);
|
||||
});
|
||||
|
||||
// ── readout selection ──────────────────────────────────────────────────────
|
||||
|
||||
const reading = (id, value, lat, lon) => ({
|
||||
id,
|
||||
stationId: id,
|
||||
value,
|
||||
lat,
|
||||
lon,
|
||||
name: id,
|
||||
});
|
||||
|
||||
test('the readout lists the worst readings inside the view rectangle', () => {
|
||||
const rows = [
|
||||
reading('west', 9, 51.0, -114.0),
|
||||
reading('east', 4, 43.6, -79.3),
|
||||
reading('mild', 2, 51.1, -114.1),
|
||||
];
|
||||
const inView = readingsInView(
|
||||
rows,
|
||||
{ west: -115, south: 50, east: -113, north: 52 },
|
||||
5,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inView.map((r) => r.id),
|
||||
['west', 'mild'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
|
||||
// A space-level or fully oblique camera cannot produce a rectangle; the card
|
||||
// should still say something useful.
|
||||
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
|
||||
assert.deepEqual(
|
||||
readingsInView(rows, null, 5).map((r) => r.id),
|
||||
['b', 'a'],
|
||||
);
|
||||
assert.deepEqual(readingsInView(rows, null, 0), []);
|
||||
assert.deepEqual(readingsInView(null, null, 5), []);
|
||||
});
|
||||
|
||||
test('a view rectangle crossing the antimeridian still selects correctly', () => {
|
||||
// west > east is how a dateline-crossing rectangle is expressed.
|
||||
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
|
||||
const inView = readingsInView(
|
||||
rows,
|
||||
{ west: 170, south: -10, east: -170, north: 10 },
|
||||
5,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inView.map((r) => r.id),
|
||||
['near'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the overlay cohort is capped and deterministic', () => {
|
||||
const entries = Array.from({ length: 120 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
priority: i % 11,
|
||||
}));
|
||||
const cohort = selectAirQualityOverlayCohort(entries);
|
||||
assert.ok(cohort.length <= 48);
|
||||
assert.deepEqual(
|
||||
cohort.map((e) => e.id),
|
||||
selectAirQualityOverlayCohort(entries).map((e) => e.id),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst records are JSON-safe and name the provider and scale', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const [row] = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
const record = mapAnalystRecord(row, 0);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
|
||||
assert.equal(record.provider, 'eccc');
|
||||
assert.equal(record.scale, 'AQHI');
|
||||
const empty = mapAnalystRecord(undefined, 7);
|
||||
assert.equal(empty.id, 'AIR-0007');
|
||||
for (const [key, value] of Object.entries(empty)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAirQualityLayer } from './index.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
import { STALE_AFTER_MS } from './model.js';
|
||||
|
||||
function harness(source) {
|
||||
const sources = [];
|
||||
const events = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createAirQualityLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(...args) {
|
||||
events.push(args);
|
||||
},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
},
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, events };
|
||||
}
|
||||
|
||||
const reading = (id, value, overrides = {}) => ({
|
||||
id: `eccc:${id}`,
|
||||
provider: 'eccc',
|
||||
scale: 'AQHI',
|
||||
value,
|
||||
band: 'low',
|
||||
stationId: id,
|
||||
name: id,
|
||||
zone: 'ont',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
observedMs: 1_700_000_000_000,
|
||||
color: '#4cc9f0',
|
||||
label: String(value),
|
||||
risk: 'Low health risk',
|
||||
note: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const staticSource = (rows) => ({ getSnapshot: async () => rows });
|
||||
|
||||
test('a layer requires both a snapshot source and an overlay host', () => {
|
||||
assert.throws(() => createAirQualityLayer(), /snapshot source/);
|
||||
assert.throws(
|
||||
() => createAirQualityLayer({ source: staticSource([]) }),
|
||||
/overlay host/,
|
||||
);
|
||||
});
|
||||
|
||||
test('initialization is single-use and owns exactly one data source', () => {
|
||||
const { layer, viewer, sources } = harness(staticSource([]));
|
||||
assert.equal(sources.length, 1);
|
||||
assert.throws(() => layer.init(viewer), /already initialized/);
|
||||
layer.destroy(viewer);
|
||||
assert.equal(sources.length, 0);
|
||||
});
|
||||
|
||||
test('a refresh renders readings and publishes an overlay cohort', async () => {
|
||||
const { layer, events } = harness(
|
||||
staticSource([reading('A', 2), reading('B', 7)]),
|
||||
);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(events.at(-1)[0], 'air-quality');
|
||||
});
|
||||
|
||||
// ── the abort + enabled guard ──────────────────────────────────────────────
|
||||
|
||||
test('a response landing after disable cannot redraw the scene', async () => {
|
||||
// The failure this prevents: disable() clears the overlay, then an in-flight
|
||||
// response resolves and repopulates it behind the user's back.
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { layer, events } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
await gate;
|
||||
signal?.throwIfAborted();
|
||||
return [reading('A', 5)];
|
||||
},
|
||||
});
|
||||
const pending = layer.update();
|
||||
layer.disable();
|
||||
const publishedBefore = events.length;
|
||||
release();
|
||||
assert.equal(await pending, false, 'a late response must not be applied');
|
||||
assert.equal(
|
||||
events.length,
|
||||
publishedBefore,
|
||||
'no overlay write after disable',
|
||||
);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('disable aborts the request in flight', async () => {
|
||||
let seenSignal = null;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
seenSignal = signal;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
signal?.throwIfAborted();
|
||||
return [];
|
||||
},
|
||||
});
|
||||
const pending = layer.update();
|
||||
layer.disable();
|
||||
await pending;
|
||||
assert.ok(seenSignal, 'a signal was passed through to the source');
|
||||
assert.equal(seenSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('a superseded refresh does not clobber the newer one', async () => {
|
||||
let call = 0;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
call += 1;
|
||||
const mine = call;
|
||||
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
|
||||
signal?.throwIfAborted();
|
||||
return [reading(`call-${mine}`, mine)];
|
||||
},
|
||||
});
|
||||
const first = layer.update();
|
||||
const second = layer.update();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
assert.equal(firstResult, false, 'the superseded call reports no update');
|
||||
assert.equal(secondResult, true);
|
||||
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
|
||||
});
|
||||
|
||||
test('update is a no-op while the layer is disabled', async () => {
|
||||
const { layer } = harness(staticSource([reading('A', 2)]));
|
||||
layer.disable();
|
||||
assert.equal(await layer.update(), false);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
// ── readout descriptor ─────────────────────────────────────────────────────
|
||||
|
||||
test('the readout descriptor carries the band legend and coverage', async () => {
|
||||
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
|
||||
await layer.update();
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.readout, true);
|
||||
assert.match(controls.summary.coverage, /Canada only/);
|
||||
// The Weather panel's legend shape: one {color, label} per band.
|
||||
assert.equal(controls.legendCategorical, true);
|
||||
assert.deepEqual(
|
||||
controls.legend.map((entry) => entry.label),
|
||||
['Low', 'Moderate', 'High', 'Very High'],
|
||||
);
|
||||
assert.ok(
|
||||
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
|
||||
);
|
||||
// Worst first, and every item keyed.
|
||||
assert.deepEqual(
|
||||
controls.list.items.map((i) => i.text),
|
||||
['B · Low health risk', 'A · Low health risk'],
|
||||
);
|
||||
assert.ok(controls.list.items.every((i) => i.id));
|
||||
assert.match(controls.summary.status, /2 stations reporting/);
|
||||
});
|
||||
|
||||
test('the readout reports an error rather than a silent empty card', async () => {
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () => {
|
||||
throw new Error('ECCC HTTP 503');
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
assert.match(layer.getRowControls().summary.status, /503/);
|
||||
});
|
||||
|
||||
test('row-control listeners are notified on refresh and teardown-safe', async () => {
|
||||
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
|
||||
let calls = 0;
|
||||
layer.setRowControlsListener(() => {
|
||||
calls += 1;
|
||||
});
|
||||
await layer.update();
|
||||
assert.ok(calls >= 1, 'a refresh notifies the card');
|
||||
layer.destroy(viewer);
|
||||
const after = calls;
|
||||
layer.setRowControlsListener(() => {
|
||||
calls += 1;
|
||||
});
|
||||
assert.equal(calls, after);
|
||||
});
|
||||
|
||||
// ── feed-state honesty ─────────────────────────────────────────────────────
|
||||
|
||||
test('freshness is judged on observation time, not on when we last fetched', async () => {
|
||||
// lastUpdate stays current every time the network answers, even when it
|
||||
// answers with nothing new. Judging freshness on it would let the HUD and
|
||||
// voice describe hours-old air quality as live.
|
||||
const aged = Date.now() - STALE_AFTER_MS - 60_000;
|
||||
const { layer } = harness(
|
||||
staticSource([reading('A', 2, { observedMs: aged })]),
|
||||
);
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.ok(
|
||||
stats.lastUpdate >= Date.now() - 5_000,
|
||||
'we did just receive a response',
|
||||
);
|
||||
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
|
||||
assert.equal(stats.stale, true);
|
||||
assert.equal(
|
||||
layerFeedState(stats),
|
||||
'stale',
|
||||
'provenance must not read this as nominal',
|
||||
);
|
||||
});
|
||||
|
||||
test('a current reading reports nominal', async () => {
|
||||
const { layer } = harness(
|
||||
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
|
||||
);
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.stale, false);
|
||||
assert.equal(layerFeedState(stats), 'nominal');
|
||||
});
|
||||
|
||||
test('a refresh that kept no current observation reports stale, not nominal', async () => {
|
||||
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
|
||||
// that only has hours-old rows yields an empty snapshot. That is the
|
||||
// stalest a feed can be; it must not read as a healthy empty layer.
|
||||
const { layer } = harness(staticSource([]));
|
||||
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.observedAt, null);
|
||||
assert.equal(stats.stale, true);
|
||||
assert.equal(layerFeedState(stats), 'stale');
|
||||
assert.equal(layer.getRowControls().summary.status, 'No current readings');
|
||||
});
|
||||
|
||||
test('a failed refresh over good readings reports degraded, not nominal', async () => {
|
||||
let fail = false;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('ECCC HTTP 503');
|
||||
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layerFeedState(layer.getStats()), 'nominal');
|
||||
fail = true;
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.ok(stats.count > 0, 'the previous readings are still displayed');
|
||||
assert.equal(
|
||||
layerFeedState(stats),
|
||||
'degraded',
|
||||
'showing old data after a failure is degraded',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { isUnavailableCapability } from '../../sources/capability.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
LAYER_ID,
|
||||
REQUEST_DEBOUNCE_MS,
|
||||
QUERY_LIMIT,
|
||||
MAX_RENDERED,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
ALPR_COLOR,
|
||||
} from './policy.js';
|
||||
import {
|
||||
snapAlprBox,
|
||||
boxContains,
|
||||
destinationPointDeg,
|
||||
alprRetryDelayMs,
|
||||
validateAlprSnapshot,
|
||||
alprCreditMarkup,
|
||||
} from './model.js';
|
||||
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 }, 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
|
||||
* actual provider; href must be HTTPS. Provider payload parsing stays in source.
|
||||
* Context services remove obsolete records with
|
||||
* removeEntityContextsForLayer(layerId, { retainIds: Set<string> }), preserving
|
||||
* retained selection without dispatching a new selection event.
|
||||
*/
|
||||
export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
if (typeof source?.fetch !== 'function')
|
||||
throw new TypeError('ALPR requires a camera source');
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
const state = {
|
||||
viewer: null,
|
||||
dataSource: null,
|
||||
credit: null,
|
||||
creditTimer: null,
|
||||
creditPresented: false,
|
||||
enabled: false,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
selectedId: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
status: 'idle',
|
||||
stale: false,
|
||||
/** Whether the query hit QUERY_LIMIT — the view likely holds more cameras than shown. */
|
||||
saturated: false,
|
||||
loading: false,
|
||||
abort: null,
|
||||
pendingQueryBox: null,
|
||||
retryTimer: null,
|
||||
retryDelayMs: 0,
|
||||
retryAt: 0,
|
||||
retrying: false,
|
||||
moveEndRemove: null,
|
||||
postRenderRemove: null,
|
||||
lastAnchorSampleAt: 0,
|
||||
clickHandler: null,
|
||||
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,
|
||||
destroyOverlay,
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
presentOnMapCredit,
|
||||
renderRecords,
|
||||
selectRecord,
|
||||
focusNearest,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
prepareFloors,
|
||||
} = createAlprPresentation({ state, services, source });
|
||||
|
||||
function setAlprStatus(status, error = null) {
|
||||
if (state.status === status && state.error === error) return;
|
||||
state.status = status;
|
||||
state.error = error;
|
||||
governorRequestRender('alpr-status');
|
||||
}
|
||||
|
||||
function scheduleUnavailableRetry() {
|
||||
if (!state.enabled) return;
|
||||
clearTimeout(state.retryTimer);
|
||||
state.retryDelayMs = alprRetryDelayMs(state.retryDelayMs);
|
||||
state.retryAt = Date.now() + state.retryDelayMs;
|
||||
state.retryTimer = setTimeout(() => {
|
||||
state.retryTimer = null;
|
||||
state.retryAt = 0;
|
||||
if (state.enabled && !state.loading) loadCameras();
|
||||
}, state.retryDelayMs);
|
||||
}
|
||||
|
||||
function clearUnavailableRetry({ resetBackoff = true } = {}) {
|
||||
clearTimeout(state.retryTimer);
|
||||
state.retryTimer = null;
|
||||
state.retryAt = 0;
|
||||
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();
|
||||
}, REQUEST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function loadCameras() {
|
||||
if (!state.enabled || !state.viewer) return;
|
||||
clearTimeout(state.debounceTimer);
|
||||
state.debounceTimer = null;
|
||||
clearUnavailableRetry({ resetBackoff: false });
|
||||
const box = viewportBox(state.viewer);
|
||||
if (!box) {
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.pendingQueryBox = null;
|
||||
state.loading = false;
|
||||
state.retrying = false;
|
||||
clearUnavailableRetry();
|
||||
renderRecords();
|
||||
setAlprStatus('zoom-in');
|
||||
return;
|
||||
}
|
||||
// A settled view still inside the last snapped query box, with fresh
|
||||
// records, needs no new request: tiny pans and orbits
|
||||
// otherwise became distinct upstream fetches against the shared proxy. Any
|
||||
// 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.
|
||||
// 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;
|
||||
state.pendingQueryBox = null;
|
||||
state.loading = false;
|
||||
}
|
||||
renderRecords();
|
||||
state.retrying = false;
|
||||
setAlprStatus(
|
||||
state.dataSource.entities.values.length ? 'ready' : 'empty',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A move inside an in-flight query must not abort and restart that query.
|
||||
if (state.abort && boxContains(state.pendingQueryBox, box)) {
|
||||
// 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);
|
||||
state.abort?.abort();
|
||||
const requestAbort = new AbortController();
|
||||
state.abort = requestAbort;
|
||||
state.pendingQueryBox = queryBox;
|
||||
state.loading = true;
|
||||
state.retrying = state.status === 'unavailable' || state.retryDelayMs > 0;
|
||||
setAlprStatus('loading');
|
||||
// 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 (
|
||||
requestAbort.signal.aborted ||
|
||||
state.abort !== requestAbort ||
|
||||
!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; 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.
|
||||
renderRecords();
|
||||
setAlprStatus(
|
||||
state.dataSource.entities.values.length
|
||||
? stale
|
||||
? 'stale'
|
||||
: 'ready'
|
||||
: 'empty',
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.name === 'AbortError' ||
|
||||
requestAbort.signal.aborted ||
|
||||
state.abort !== requestAbort ||
|
||||
!state.enabled
|
||||
)
|
||||
return;
|
||||
state.stale = Boolean(state.lastUpdate);
|
||||
setAlprStatus(
|
||||
'unavailable',
|
||||
error?.message || 'Camera source temporarily unavailable',
|
||||
);
|
||||
if (!isUnavailableCapability(error)) scheduleUnavailableRetry();
|
||||
} finally {
|
||||
if (state.abort === requestAbort) {
|
||||
state.abort = null;
|
||||
state.pendingQueryBox = null;
|
||||
state.loading = false;
|
||||
state.retrying = false;
|
||||
governorRequestRender('alpr-status');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alprCamerasLayer = {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: source.label || 'Mapped camera locations',
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
if (state.viewer) throw new Error('ALPR layer is already initialized');
|
||||
state.viewer = viewer;
|
||||
state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
|
||||
const creditMarkup = alprCreditMarkup(source.attribution);
|
||||
state.credit = creditMarkup
|
||||
? new Cesium.Credit(creditMarkup, true)
|
||||
: null;
|
||||
viewer.dataSources.add(state.dataSource);
|
||||
state.moveEndRemove =
|
||||
viewer.camera.moveEnd.addEventListener(scheduleLoad);
|
||||
state.postRenderRemove =
|
||||
viewer.scene.postRender?.addEventListener(updateSelectedAnchor);
|
||||
initOverlay();
|
||||
installInteraction(viewer);
|
||||
},
|
||||
enable() {
|
||||
if (state.enabled) return;
|
||||
state.enabled = true;
|
||||
state.creditPresented = false;
|
||||
registerPickOwner(LAYER_ID, (id) => state.recordById.has(id));
|
||||
state.dataSource.show = true;
|
||||
// DataLayerManager calls update() right after enable(); it owns the first fetch.
|
||||
},
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
hideOnMapCredit();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
clearUnavailableRetry();
|
||||
clearTimeout(state.debounceTimer);
|
||||
state.abort?.abort();
|
||||
state.abort = null;
|
||||
state.pendingQueryBox = null;
|
||||
state.loading = false;
|
||||
state.retrying = false;
|
||||
if (state.dataSource) state.dataSource.show = false;
|
||||
clearSelection();
|
||||
clearRendered();
|
||||
setAlprStatus('idle');
|
||||
},
|
||||
update() {
|
||||
return loadCameras();
|
||||
},
|
||||
destroy(viewer = state.viewer) {
|
||||
source.destroy?.();
|
||||
this.disable();
|
||||
destroyOverlay();
|
||||
state.moveEndRemove?.();
|
||||
state.moveEndRemove = null;
|
||||
state.postRenderRemove?.();
|
||||
state.postRenderRemove = null;
|
||||
state.clickHandler?.destroy();
|
||||
state.clickHandler = null;
|
||||
clearRendered();
|
||||
if (state.dataSource && viewer)
|
||||
viewer.dataSources.remove(state.dataSource, true);
|
||||
state.dataSource = null;
|
||||
state.credit = null;
|
||||
state.creditPresented = false;
|
||||
state.records = [];
|
||||
state.recordById = new Map();
|
||||
state.lastQueryBox = null;
|
||||
state.lastQueryZoom = null;
|
||||
state.lastUpdate = null;
|
||||
state.error = null;
|
||||
state.status = 'idle';
|
||||
state.stale = false;
|
||||
state.saturated = false;
|
||||
state.viewer = null;
|
||||
},
|
||||
getRowControls() {
|
||||
const count = state.dataSource?.entities.values.length || 0;
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'find-camera',
|
||||
label: 'SHOW NEAREST',
|
||||
title: state.viewer?.trackedEntity
|
||||
? 'Stop following the current object before navigating to a camera'
|
||||
: 'Move to the nearest loaded camera and show its details',
|
||||
disabled:
|
||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||
onClick: focusNearest,
|
||||
},
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
label: 'Camera badges',
|
||||
color: ALPR_COLOR,
|
||||
count,
|
||||
blurb:
|
||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Snapshot loaded ALPR cameras (the nearby query box) as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, lat, lon, operator, manufacturer, cameraType, directionDeg}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!state.enabled) return [];
|
||||
const result = [];
|
||||
for (const record of state.records || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
if (
|
||||
!Number.isFinite(record.latitude) ||
|
||||
!Number.isFinite(record.longitude)
|
||||
)
|
||||
continue;
|
||||
result.push({
|
||||
id: record.id,
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
operator: record.operator ?? null,
|
||||
manufacturer: record.manufacturer ?? null,
|
||||
cameraType: record.cameraType ?? null,
|
||||
directionDeg: Number.isFinite(record.directionDeg)
|
||||
? record.directionDeg
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel:
|
||||
state.enabled && !state.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 || 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,
|
||||
retryAt: state.retryAt,
|
||||
retrying: state.retrying,
|
||||
retryInSec: state.retryAt
|
||||
? Math.max(0, Math.ceil((state.retryAt - Date.now()) / 1000))
|
||||
: 0,
|
||||
loadingLabel: state.loading
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
: 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(' · '),
|
||||
};
|
||||
},
|
||||
};
|
||||
return alprCamerasLayer;
|
||||
}
|
||||
export {
|
||||
snapAlprBox,
|
||||
boxContains,
|
||||
destinationPointDeg,
|
||||
alprRetryDelayMs,
|
||||
isAlprSurveillanceType,
|
||||
normalizeAlprNode,
|
||||
buildOverpassQuery,
|
||||
} from './model.js';
|
||||
export {
|
||||
QUERY_LIMIT,
|
||||
MAX_RENDERED,
|
||||
QUERY_SNAP_DEGREES,
|
||||
QUERY_REUSE_MS,
|
||||
} from './policy.js';
|
||||
export { createOverpassAlprSource, createAlprTileSource } from './source.js';
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { EARTH_MEAN_RADIUS_M } from './policy.js';
|
||||
export * from './records.js';
|
||||
|
||||
/**
|
||||
* Great-circle destination point — used only to draw the short
|
||||
* facing-direction line when a node carries `camera:direction`/`direction`.
|
||||
* @param {number} latDeg @param {number} lonDeg
|
||||
* @param {number} bearingDeg Compass bearing, degrees clockwise from north.
|
||||
* @param {number} distanceM
|
||||
* @returns {{latitude:number, longitude:number}}
|
||||
*/
|
||||
export function destinationPointDeg(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const angularDistance = distanceM / EARTH_MEAN_RADIUS_M;
|
||||
const bearing = Cesium.Math.toRadians(bearingDeg);
|
||||
const lat1 = Cesium.Math.toRadians(latDeg);
|
||||
const lon1 = Cesium.Math.toRadians(lonDeg);
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(angularDistance) +
|
||||
Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing),
|
||||
);
|
||||
const lon2 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1),
|
||||
Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2),
|
||||
);
|
||||
return {
|
||||
latitude: Cesium.Math.toDegrees(lat2),
|
||||
longitude: Cesium.Math.toDegrees(lon2),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a linked attribution from plain text and an HTTPS URL only. */
|
||||
export function alprCreditMarkup(attribution) {
|
||||
if (!attribution?.text || !attribution?.href) return null;
|
||||
const href = new URL(attribution.href);
|
||||
if (href.protocol !== 'https:' || href.username || href.password) {
|
||||
throw new TypeError('Camera attribution requires a public HTTPS link');
|
||||
}
|
||||
const escape = (value) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(char) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[char],
|
||||
);
|
||||
return `<span class="gev-alpr-credit">ALPR: <a href="${escape(href.href)}" target="_blank" rel="noopener">${escape(attribution.text)}</a></span>`;
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import * as Cesium from 'cesium';
|
||||
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,
|
||||
onSurfaceChange = () => {},
|
||||
}) {
|
||||
let lane,
|
||||
removeMapListener,
|
||||
retryTimer,
|
||||
retryUntil = 0;
|
||||
let records = [],
|
||||
hits = [],
|
||||
images = [],
|
||||
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.
|
||||
if (!visible && !entity.gevAlprNativeAppearance) {
|
||||
entity.gevAlprNativeAppearance = {
|
||||
position: entity.position,
|
||||
heightReference: billboard.heightReference,
|
||||
scaleByDistance: billboard.scaleByDistance,
|
||||
color: billboard.color,
|
||||
};
|
||||
entity.position = entity.gevAlprCanvasPosition;
|
||||
entity.gevAlprPickPosition = entity.position;
|
||||
billboard.heightReference = Cesium.HeightReference.NONE;
|
||||
billboard.scaleByDistance = undefined;
|
||||
billboard.color = Cesium.Color.WHITE.withAlpha(0.01);
|
||||
} else if (visible && entity.gevAlprNativeAppearance) {
|
||||
const saved = entity.gevAlprNativeAppearance;
|
||||
if (entity.position === entity.gevAlprPickPosition)
|
||||
entity.position = saved.position;
|
||||
billboard.heightReference = saved.heightReference;
|
||||
billboard.scaleByDistance = saved.scaleByDistance;
|
||||
billboard.color = saved.color;
|
||||
entity.gevAlprNativeAppearance = null;
|
||||
entity.gevAlprPickPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
function resetAnchors() {
|
||||
for (const entity of state.dataSource.entities.values) {
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprWedge = null;
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
nativeVisible(entity, true);
|
||||
}
|
||||
state.lastAnchorSampleAt = 0;
|
||||
retryUntil = Date.now() + 15000;
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
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;
|
||||
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 {
|
||||
/* streaming tiles */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
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,
|
||||
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 }) {
|
||||
hits = [];
|
||||
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;
|
||||
const selected = record.id === state.selectedId;
|
||||
const image = images[selected ? 1 : 0];
|
||||
if (
|
||||
!image?.complete ||
|
||||
!image.naturalWidth ||
|
||||
(selected && !images[2]?.naturalWidth)
|
||||
) {
|
||||
nativeVisible(entity, true);
|
||||
continue;
|
||||
}
|
||||
const anchor = anchorFor(record, entity, budget);
|
||||
if (!anchor) {
|
||||
nativeVisible(entity, true);
|
||||
unresolved = true;
|
||||
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) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
const alpha =
|
||||
services.overlays.keyholeAlpha?.(origin.x, origin.y, keyhole) ?? 1;
|
||||
if (!(alpha > 0)) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
const wedge = entity.gevAlprWedge;
|
||||
if (wedge) {
|
||||
const left = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
wedge[1],
|
||||
);
|
||||
const right = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
wedge[2],
|
||||
);
|
||||
if (left && right) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= alpha;
|
||||
paintDirectionWedge(ctx, origin, left, right, selected);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (
|
||||
origin.x < -60 ||
|
||||
origin.x > width + 60 ||
|
||||
origin.y < -60 ||
|
||||
origin.y > height + 60
|
||||
) {
|
||||
setShown(entity, false);
|
||||
continue;
|
||||
}
|
||||
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, scale } of painted) {
|
||||
const size =
|
||||
(selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE) * scale;
|
||||
ctx.save();
|
||||
ctx.globalAlpha *= alpha;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
origin.x - size / 2,
|
||||
origin.y - size / 2,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
if (selected)
|
||||
ctx.drawImage(
|
||||
images[2],
|
||||
origin.x - size / 2,
|
||||
origin.y - size / 2,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
ctx.restore();
|
||||
hits.push({ id: record.id, x: origin.x, y: origin.y, radius: size / 2 });
|
||||
}
|
||||
const refining = refineAnchors({
|
||||
samples: Math.min(budget.samples, SURFACE_SAMPLES_PER_PAINT),
|
||||
});
|
||||
if ((unresolved || refining) && !retryTimer && Date.now() < retryUntil) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
requestPaint();
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
id: `alpr-${Cesium.createGuid()}`,
|
||||
active: false,
|
||||
});
|
||||
images = [MARKER_IMAGE, SELECTED_IMAGE, BRACKETS_IMAGE].map((src) => {
|
||||
const image = new Image();
|
||||
image.onload = requestPaint;
|
||||
image.onerror = requestPaint;
|
||||
image.src = src;
|
||||
return image;
|
||||
});
|
||||
},
|
||||
sync(visible) {
|
||||
if (!lane) return;
|
||||
for (const record of records)
|
||||
nativeVisible(state.dataSource.entities.getById(record.id), true);
|
||||
const position = state.viewer.camera.positionWC;
|
||||
records = visible
|
||||
.filter(
|
||||
(record) =>
|
||||
Number.isFinite(record.directionDeg) &&
|
||||
record.id !== state.selectedId,
|
||||
)
|
||||
.map((record) => ({
|
||||
record,
|
||||
distance: position
|
||||
? Cesium.Cartesian3.distanceSquared(
|
||||
position,
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
),
|
||||
)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, MAX_CANVAS_FRUSTUMS)
|
||||
.map((item) => item.record);
|
||||
const selected = visible.find((record) => record.id === state.selectedId);
|
||||
if (selected) {
|
||||
if (records.length >= MAX_CANVAS_FRUSTUMS) records.pop();
|
||||
records.push(selected);
|
||||
}
|
||||
hits = [];
|
||||
retryUntil = Date.now() + 15000;
|
||||
lane.setActive(state.enabled && records.length > 0);
|
||||
requestPaint();
|
||||
},
|
||||
pick(position) {
|
||||
if (!state.enabled || !position) return null;
|
||||
let closest,
|
||||
distance = Infinity;
|
||||
for (const hit of hits) {
|
||||
const next = (hit.x - position.x) ** 2 + (hit.y - position.y) ** 2;
|
||||
if (next <= hit.radius ** 2 && next <= distance) {
|
||||
closest = hit.id;
|
||||
distance = next;
|
||||
}
|
||||
}
|
||||
return closest || null;
|
||||
},
|
||||
clear() {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
for (const record of records)
|
||||
nativeVisible(state.dataSource?.entities.getById(record.id), true);
|
||||
records = [];
|
||||
hits = [];
|
||||
lane?.setActive(false);
|
||||
},
|
||||
destroy() {
|
||||
this.clear();
|
||||
destroyed = true;
|
||||
removeMapListener?.();
|
||||
removeMapListener = null;
|
||||
lane?.unregister();
|
||||
lane = null;
|
||||
for (const image of images) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
}
|
||||
images = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
markerScale,
|
||||
} from './visuals.js';
|
||||
|
||||
function harness(t, count = 2, { floor = null } = {}) {
|
||||
const previousImage = globalThis.Image;
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.Image = class {
|
||||
complete = true;
|
||||
naturalWidth = 128;
|
||||
};
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 100, y: 100 });
|
||||
const records = Array.from({ length: count }, (_, i) => ({
|
||||
id: `camera:${i}`,
|
||||
latitude: 30 + i / 10000,
|
||||
longitude: -97,
|
||||
directionDeg: 90,
|
||||
}));
|
||||
const entities = new Cesium.EntityCollection();
|
||||
for (const record of records)
|
||||
entities.add({
|
||||
id: record.id,
|
||||
billboard: { image: 'fallback', show: true },
|
||||
polyline: { show: true },
|
||||
polygon: { show: true },
|
||||
});
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
dataSource: { entities },
|
||||
viewer: {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97, 30, 500) },
|
||||
scene: { globe: { show: true, getHeight: () => 150 } },
|
||||
},
|
||||
};
|
||||
let paint,
|
||||
mapListener,
|
||||
active,
|
||||
removed = 0,
|
||||
paints = 0;
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
registerPaintLane(_lane, painter) {
|
||||
paint = painter;
|
||||
return {
|
||||
setActive(value) {
|
||||
active = value;
|
||||
},
|
||||
requestPaint() {
|
||||
paints++;
|
||||
},
|
||||
unregister() {
|
||||
removed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
keyholeAlpha: () => 1,
|
||||
subscribeMapStack(fn) {
|
||||
mapListener = fn;
|
||||
return () => {
|
||||
mapListener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
overlay.init();
|
||||
t.after(() => {
|
||||
overlay.destroy();
|
||||
if (previousImage === undefined) delete globalThis.Image;
|
||||
else globalThis.Image = previousImage;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
});
|
||||
const draw = [];
|
||||
const ctx = {
|
||||
globalAlpha: 1,
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
closePath() {},
|
||||
stroke() {},
|
||||
fill() {
|
||||
draw.push('wedge');
|
||||
},
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
drawImage(image) {
|
||||
draw.push(image.src);
|
||||
},
|
||||
};
|
||||
return {
|
||||
state,
|
||||
records,
|
||||
overlay,
|
||||
entities,
|
||||
draw,
|
||||
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
|
||||
mapChanged() {
|
||||
mapListener({ detail: { status: 'ready' } });
|
||||
},
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
get paints() {
|
||||
return paints;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a selected camera without a bearing gets its badge and brackets, never a fabricated wedge', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.records[0].directionDeg = null;
|
||||
h.state.selectedId = h.records[0].id;
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.draw.length, 2);
|
||||
assert.ok(h.draw[0].endsWith('alpr-marker-selected.png'));
|
||||
assert.ok(h.draw[1].endsWith('alpr-marker-selected-brackets.png'));
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0');
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
assert.equal(
|
||||
h.entities.getById('camera:0').billboard.color.getValue().alpha,
|
||||
0.01,
|
||||
);
|
||||
assert.equal(directionWedgePositions(h.records[0]), null);
|
||||
});
|
||||
|
||||
test('the overlay bounds its cohort and promotes selection while leaving farther cameras native', (t) => {
|
||||
const h = harness(t, 70);
|
||||
h.state.selectedId = 'camera:69';
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const hidden = h.entities.values.filter((e) => e.gevAlprNativeAppearance);
|
||||
assert.equal(hidden.length, 64);
|
||||
assert.ok(hidden.some((e) => e.id === 'camera:69'));
|
||||
assert.equal(
|
||||
h.overlay.pick({ x: 100, y: 100 }),
|
||||
'camera:69',
|
||||
'selected glyph paints and picks on top',
|
||||
);
|
||||
const firstGlyph = h.draw.findIndex((value) => value !== 'wedge');
|
||||
assert.equal(firstGlyph, 64, 'all wedges paint before any glyph');
|
||||
});
|
||||
|
||||
test('surface changes invalidate cached anchors, and disable/disposal release painting and hits', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const entity = h.entities.getById('camera:0');
|
||||
const first = entity.gevAlprCanvasPosition;
|
||||
h.paint();
|
||||
assert.equal(
|
||||
entity.gevAlprCanvasPosition,
|
||||
first,
|
||||
'stable anchor across frames',
|
||||
);
|
||||
h.state.viewer.scene.globe.show = false;
|
||||
h.mapChanged();
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
h.state.enabled = false;
|
||||
h.overlay.clear();
|
||||
assert.equal(h.active, false);
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
assert.equal(entity.billboard.show.getValue(), true);
|
||||
assert.equal(entity.billboard.color, undefined);
|
||||
assert.equal(entity.position, undefined);
|
||||
h.overlay.destroy();
|
||||
assert.equal(h.removed, 1);
|
||||
});
|
||||
|
||||
test('an unresolved surface preserves the native marker; the horizon removes overlay hits', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.state.viewer.scene.globe.getHeight = () => -15000;
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
assert.equal(h.draw.length, 0);
|
||||
h.state.viewer.scene.globe.getHeight = () => 150;
|
||||
h.paint({ isPointVisible: () => false });
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
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(
|
||||
{ manufacturer: 'Vendor', operator: 'Vendor' },
|
||||
{ label: 'Custom directory' },
|
||||
),
|
||||
['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');
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 = 300;
|
||||
|
||||
/** Keep camera acquisition city-scale, never globe-wide. */
|
||||
export const MAX_VIEWPORT_DEGREES = 3;
|
||||
|
||||
/** 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. */
|
||||
export const MAX_RENDERED = 1500;
|
||||
|
||||
/** Meters — illustrative facing wedge depth, when a camera reports a bearing. */
|
||||
export const DIRECTION_CONE_M = 90;
|
||||
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 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 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;
|
||||
@@ -0,0 +1,655 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
MAX_RENDERED,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
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,
|
||||
validAlprGroundHeight,
|
||||
} from './visuals.js';
|
||||
|
||||
export function createAlprPresentation({ state, services, source }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
|
||||
const { cachedGroundFloor } = services.groundFloor;
|
||||
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(
|
||||
selected ? ALPR_SELECTED_COLOR : ALPR_COLOR,
|
||||
);
|
||||
entity.billboard.image = selected ? SELECTED_IMAGE : MARKER_IMAGE;
|
||||
entity.billboard.width = entity.billboard.height = selected
|
||||
? SELECTED_MARKER_ICON_SIZE
|
||||
: MARKER_ICON_SIZE;
|
||||
if (entity.gevLabelModel) {
|
||||
entity.gevLabelModel.accent = color.toCssColorString();
|
||||
entity.gevLabelModel.leaderAnimationStartedAt = selected
|
||||
? selectionStartedAt
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
function markerColor() {
|
||||
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
||||
}
|
||||
|
||||
// The horizon rectangle is unstable during low-angle orbits and can exclude
|
||||
// the ground point in the center of the screen. Bound nearby coverage around
|
||||
// that point instead; the camera-to-ground range keeps zoom-out queries capped.
|
||||
function viewportBox(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.scene.canvas;
|
||||
if (typeof camera?.pickEllipsoid === 'function' && canvas) {
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
if (!width || !height) return null;
|
||||
const focus = camera.pickEllipsoid(
|
||||
new Cesium.Cartesian2(width / 2, height / 2),
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
if (!focus) return null;
|
||||
const location = Cesium.Cartographic.fromCartesian(focus);
|
||||
const range = Cesium.Cartesian3.distance(camera.positionWC, focus);
|
||||
const radius = Math.max(1000, 2 * range);
|
||||
const latitude = Cesium.Math.toDegrees(location.latitude);
|
||||
const longitude = Cesium.Math.toDegrees(location.longitude);
|
||||
const latSpan = radius / 111000;
|
||||
const lonSpan = latSpan / Math.cos(location.latitude);
|
||||
if (
|
||||
!Number.isFinite(latSpan + lonSpan) ||
|
||||
2 * Math.max(latSpan, lonSpan) > MAX_VIEWPORT_DEGREES ||
|
||||
Math.abs(latitude) + latSpan > 90 ||
|
||||
Math.abs(longitude) + lonSpan > 180
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
south: latitude - latSpan,
|
||||
west: longitude - lonSpan,
|
||||
north: latitude + latSpan,
|
||||
east: longitude + lonSpan,
|
||||
};
|
||||
}
|
||||
// Rectangle-only viewers retain the same bounded-area contract.
|
||||
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return null;
|
||||
const south = Cesium.Math.toDegrees(rectangle.south);
|
||||
const north = Cesium.Math.toDegrees(rectangle.north);
|
||||
const west = Cesium.Math.toDegrees(rectangle.west);
|
||||
const east = Cesium.Math.toDegrees(rectangle.east);
|
||||
if (
|
||||
!Number.isFinite(south + north + west + east) ||
|
||||
east <= west ||
|
||||
north - south > MAX_VIEWPORT_DEGREES ||
|
||||
east - west > MAX_VIEWPORT_DEGREES
|
||||
)
|
||||
return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
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)
|
||||
state.viewer?.creditDisplay?.removeStaticCredit(state.credit);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
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);
|
||||
state.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
function renderRecords() {
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
// 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.
|
||||
if (
|
||||
selectedContext?.layerId !== LAYER_ID ||
|
||||
selectedContext.id !== state.selectedId ||
|
||||
!visible.some((record) => record.id === state.selectedId)
|
||||
) {
|
||||
state.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
}
|
||||
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);
|
||||
clampedMarkers.delete(entity.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
||||
for (const record of visible) {
|
||||
const existing = state.dataSource.entities.getById(record.id);
|
||||
if (existing?.gevAlprRecord === record) {
|
||||
updateAppearance(existing, record.id === state.selectedId);
|
||||
continue;
|
||||
}
|
||||
const color = markerColor();
|
||||
const selected = record.id === state.selectedId;
|
||||
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,
|
||||
billboard: {
|
||||
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: clamped
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// City-wide views hold hundreds of badges; let them shrink.
|
||||
scaleByDistance: new Cesium.NearFarScalar(
|
||||
...MARKER_SCALE_BY_DISTANCE,
|
||||
),
|
||||
},
|
||||
};
|
||||
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);
|
||||
entity.gevAlprClamped = clamped;
|
||||
changed = true;
|
||||
} else {
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
setMarkerPosition(entity, position, clamped);
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprWedge = null;
|
||||
}
|
||||
}
|
||||
entity.gevAlprRecord = record;
|
||||
entity.gevTrackedId = record.id;
|
||||
// The mapped camera datum has no elevation; it is not the clamped marker's
|
||||
// visual anchor. Only the selected marker samples the rendered surface,
|
||||
// at most once per second, through Cesium's public height APIs.
|
||||
entity.gevAlprDisplayPosition ??= null;
|
||||
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
|
||||
entity.gevLabelModel = {
|
||||
title: alprDisplayId(record),
|
||||
details: alprLabelDetails(record, source),
|
||||
accent: color.toCssColorString(),
|
||||
cardStyle: 'tactical',
|
||||
selected: true,
|
||||
leaderStyle: 'elbow',
|
||||
leaderAnimationMs: 440,
|
||||
leaderAnimationStartedAt: selected ? selectionStartedAt : 0,
|
||||
leaderDrawRatio: 0.68,
|
||||
anchorRadiusPx: SELECTED_MARKER_ICON_SIZE / 2,
|
||||
anchorRadiusScale: null,
|
||||
};
|
||||
updateAppearance(entity, selected);
|
||||
registerEntityContext(entity, {
|
||||
id: record.id,
|
||||
layerId: LAYER_ID,
|
||||
dataSource: state.dataSource,
|
||||
layerName: 'ALPR Cameras',
|
||||
source:
|
||||
source.attribution?.description || source.label || 'Camera source',
|
||||
label: 'ALPR camera',
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
operator: record.operator,
|
||||
manufacturer: record.manufacturer,
|
||||
cameraType: record.cameraType,
|
||||
zone: record.zone,
|
||||
directionDeg: record.directionDeg,
|
||||
ref: record.ref,
|
||||
lastVerified: record.lastVerified,
|
||||
osmId: record.osmId,
|
||||
sourceTag: record.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;
|
||||
if (!selectedEntity) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visible);
|
||||
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (visible.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
}
|
||||
|
||||
function focusNearest() {
|
||||
const camera = state.viewer?.camera;
|
||||
if (!state.enabled || !camera?.positionWC || state.viewer.trackedEntity)
|
||||
return false;
|
||||
let nearest = null,
|
||||
distance = Infinity;
|
||||
for (const entity of state.dataSource.entities.values) {
|
||||
const position = entity.position.getValue(Cesium.JulianDate.now());
|
||||
const candidate = Cesium.Cartesian3.distanceSquared(
|
||||
camera.positionWC,
|
||||
position,
|
||||
);
|
||||
if (candidate < distance) {
|
||||
nearest = entity;
|
||||
distance = candidate;
|
||||
}
|
||||
}
|
||||
if (!nearest) return false;
|
||||
const record = state.recordById.get(nearest.id);
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
let height;
|
||||
if (state.viewer.scene.sampleHeightSupported) {
|
||||
try {
|
||||
height = state.viewer.scene.sampleHeight(location, [nearest]);
|
||||
} catch {
|
||||
/* tiles may still be streaming */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && state.viewer.scene.globe.show)
|
||||
height = state.viewer.scene.globe.getHeight?.(location);
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
const center = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
|
||||
duration: 1.2,
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
camera.heading || 0,
|
||||
-Math.PI / 4,
|
||||
800,
|
||||
),
|
||||
});
|
||||
governorRequestRender('alpr-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const entity = state.dataSource?.entities.getById(id);
|
||||
if (!entity || !state.recordById.has(id)) return false;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
|
||||
updateAppearance(entity, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
selectEntityContext(entity);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
governorRequestRender('alpr-selection');
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const entity = state.selectedId
|
||||
? state.dataSource?.entities.getById(state.selectedId)
|
||||
: null;
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (entity && record) updateAppearance(entity, false);
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
overlay.sync(visibleRecords);
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
governorRequestRender('alpr-selection');
|
||||
}
|
||||
|
||||
function updateSelectedAnchor() {
|
||||
if (!state.enabled || !state.selectedId) return;
|
||||
const selected = getSelectedEntityContext();
|
||||
if (selected?.id !== state.selectedId) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const entity = state.dataSource?.entities.getById(state.selectedId);
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (entity?.gevAlprCanvasPosition) {
|
||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||
return;
|
||||
}
|
||||
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||
return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
const scene = state.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
let height;
|
||||
if (scene.sampleHeightSupported) {
|
||||
try {
|
||||
height = scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* tiles may still be streaming */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
if (
|
||||
!entity.gevAlprDisplayPosition ||
|
||||
Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1
|
||||
) {
|
||||
entity.gevAlprDisplayPosition = next;
|
||||
governorRequestRender('alpr-anchor');
|
||||
}
|
||||
}
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (state.clickHandler) return;
|
||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
state.clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id =
|
||||
overlay.pick(click.position) ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
if (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
|
||||
)
|
||||
selectRecord(id);
|
||||
else if (state.selectedId) clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return {
|
||||
initOverlay: () => overlay.init(),
|
||||
destroyOverlay: () => overlay.destroy(),
|
||||
markerColor,
|
||||
viewportBox,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
presentOnMapCredit,
|
||||
renderRecords,
|
||||
selectRecord,
|
||||
focusNearest,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
prepareFloors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js';
|
||||
|
||||
export function textTag(value) {
|
||||
const t = String(value ?? '').trim();
|
||||
return t || null;
|
||||
}
|
||||
|
||||
export function numTag(value) {
|
||||
// `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only
|
||||
// tag value must read as missing, never as a due-north bearing.
|
||||
if (value == null) return null;
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a viewport box OUTWARD to the query grid, clamped to valid ranges.
|
||||
* Pure so the reuse contract is pinnable.
|
||||
* @param {{south:number,west:number,north:number,east:number}} box
|
||||
* @param {number} [step=QUERY_SNAP_DEGREES]
|
||||
*/
|
||||
export function snapAlprBox(box, step = QUERY_SNAP_DEGREES) {
|
||||
const down = (v) => Math.floor(v / step) * step;
|
||||
const up = (v) => Math.ceil(v / step) * step;
|
||||
return {
|
||||
south: Math.max(-90, down(box.south)),
|
||||
west: Math.max(-180, down(box.west)),
|
||||
north: Math.min(90, up(box.north)),
|
||||
east: Math.min(180, up(box.east)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `inner` lies entirely within `outer` (no dateline handling; the viewport gate already rejects east <= west). */
|
||||
export function boxContains(outer, inner) {
|
||||
if (!outer || !inner) return false;
|
||||
return (
|
||||
inner.south >= outer.south &&
|
||||
inner.north <= outer.north &&
|
||||
inner.west >= outer.west &&
|
||||
inner.east <= outer.east
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff progression for the unavailable-state retry: 30 s, doubling to a
|
||||
* 240 s ceiling. Pure so the progression is pinnable without booting the layer.
|
||||
*/
|
||||
export function alprRetryDelayMs(prevDelayMs) {
|
||||
const RETRY_MIN_MS = 30000;
|
||||
const RETRY_CEIL_MS = 240000;
|
||||
if (!Number.isFinite(prevDelayMs) || prevDelayMs <= 0) return RETRY_MIN_MS;
|
||||
return Math.min(prevDelayMs * 2, RETRY_CEIL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `surveillance:type` value denotes an ALPR camera. OSM allows
|
||||
* semicolon multi-values (`camera;ALPR`) and mixed case, so an exact match
|
||||
* would silently miss tagged cameras.
|
||||
* @param {*} value - raw `surveillance:type` tag value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAlprSurveillanceType(value) {
|
||||
return String(value ?? '')
|
||||
.split(';')
|
||||
.some((part) => part.trim().toUpperCase() === 'ALPR');
|
||||
}
|
||||
|
||||
/** Map one raw Overpass node element to a plain camera record. Null for anything unusable. */
|
||||
export function normalizeAlprNode(el) {
|
||||
if (
|
||||
!el ||
|
||||
el.type !== 'node' ||
|
||||
!Number.isSafeInteger(el.id) ||
|
||||
el.id <= 0 ||
|
||||
!Number.isFinite(el.lat) ||
|
||||
Math.abs(el.lat) > 90 ||
|
||||
!Number.isFinite(el.lon) ||
|
||||
Math.abs(el.lon) > 180
|
||||
)
|
||||
return null;
|
||||
const tags = el.tags || {};
|
||||
if (!isAlprSurveillanceType(tags['surveillance:type'])) return null;
|
||||
return {
|
||||
id: `alpr:${el.id}`,
|
||||
osmId: el.id,
|
||||
latitude: el.lat,
|
||||
longitude: el.lon,
|
||||
operator: textTag(tags.operator),
|
||||
manufacturer: textTag(tags.manufacturer),
|
||||
cameraType: textTag(tags['camera:type']),
|
||||
zone: textTag(tags['surveillance:zone']),
|
||||
// Only numeric bearings are parsed; compass-word directions ("N"/"NE")
|
||||
// are rare on this tag and simply render with no facing line.
|
||||
directionDeg: normalizeDirection(
|
||||
tags['camera:direction'] ?? tags.direction,
|
||||
),
|
||||
ref: textTag(tags.ref),
|
||||
lastVerified: textTag(tags.check_date) || textTag(tags['survey:date']),
|
||||
source: textTag(tags.source),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDirection(value) {
|
||||
const degrees = numTag(value);
|
||||
return degrees != null && degrees >= 0 && degrees <= 360
|
||||
? degrees % 360
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overpass QL for the viewport. The tag match is a case-insensitive regex, not
|
||||
* an exact `="ALPR"`, because OSM semicolon multi-values such as
|
||||
* `surveillance:type=camera;ALPR` are common and an exact match drops them.
|
||||
* Exported so the query shape is pinnable.
|
||||
*/
|
||||
export function buildOverpassQuery(south, west, north, east) {
|
||||
return (
|
||||
`[out:json][timeout:20];node["man_made"="surveillance"]["surveillance:type"~"(^|;)\\s*ALPR\\s*(;|$)",i]` +
|
||||
`(${south},${west},${north},${east});out body ${QUERY_LIMIT};`
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate a source-independent snapshot before replacing the accepted display. */
|
||||
export function validateAlprSnapshot(snapshot) {
|
||||
if (
|
||||
!Array.isArray(snapshot?.records) ||
|
||||
typeof snapshot.stale !== 'boolean' ||
|
||||
typeof snapshot.saturated !== 'boolean'
|
||||
) {
|
||||
throw new TypeError('Camera source returned an invalid snapshot');
|
||||
}
|
||||
const ids = new Set();
|
||||
for (const record of snapshot.records) {
|
||||
if (
|
||||
!record ||
|
||||
typeof record.id !== 'string' ||
|
||||
!record.id.trim() ||
|
||||
ids.has(record.id) ||
|
||||
!Number.isFinite(record.latitude) ||
|
||||
Math.abs(record.latitude) > 90 ||
|
||||
!Number.isFinite(record.longitude) ||
|
||||
Math.abs(record.longitude) > 180 ||
|
||||
(record.directionDeg != null &&
|
||||
(!Number.isFinite(record.directionDeg) ||
|
||||
record.directionDeg < 0 ||
|
||||
record.directionDeg >= 360))
|
||||
) {
|
||||
throw new TypeError('Camera source returned an invalid record');
|
||||
}
|
||||
ids.add(record.id);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
||||
import {
|
||||
OVERPASS_URL,
|
||||
QUERY_LIMIT,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
QUERY_SNAP_DEGREES,
|
||||
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),
|
||||
} = {}) {
|
||||
async function fetchAlprNodes(box, signal) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
!box ||
|
||||
![box.south, box.west, box.north, box.east].every(Number.isFinite) ||
|
||||
box.south < -90 ||
|
||||
box.north > 90 ||
|
||||
box.west < -180 ||
|
||||
box.east > 180 ||
|
||||
box.north <= box.south ||
|
||||
box.east <= box.west ||
|
||||
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');
|
||||
}
|
||||
const query = buildOverpassQuery(box.south, box.west, box.north, box.east);
|
||||
const response = await fetchImpl(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
const message =
|
||||
response.status === 429
|
||||
? 'Overpass rate-limited'
|
||||
: response.status === 504
|
||||
? 'Overpass timed out'
|
||||
: 'Overpass temporarily unavailable';
|
||||
throw new Error(message);
|
||||
}
|
||||
const stale = response.headers.get('x-overpass-cache') === 'STALE';
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
// The shared proxy already rejects query errors. Validate here too so a
|
||||
// malformed or partial response never becomes an authoritative empty map.
|
||||
if (!Array.isArray(payload?.elements) || payload.remark) {
|
||||
throw new Error('Overpass returned an incomplete camera response');
|
||||
}
|
||||
return {
|
||||
records: [
|
||||
...new Map(
|
||||
payload.elements
|
||||
.slice(0, QUERY_LIMIT)
|
||||
.map(normalizeAlprNode)
|
||||
.filter(Boolean)
|
||||
.map((record) => [record.id, record]),
|
||||
).values(),
|
||||
],
|
||||
stale,
|
||||
saturated: payload.elements.length >= QUERY_LIMIT,
|
||||
};
|
||||
}
|
||||
return {
|
||||
fetch: fetchAlprNodes,
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
text: '© OpenStreetMap contributors',
|
||||
href: 'https://www.openstreetmap.org/copyright',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { buildOverpassQuery } from './records.js';
|
||||
@@ -0,0 +1,290 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 = createAlprTileSource({
|
||||
tileFetchImpl() {
|
||||
calls++;
|
||||
},
|
||||
});
|
||||
for (const bad of [
|
||||
null,
|
||||
{ ...box, west: '0);out;' },
|
||||
{ ...box, north: 90 },
|
||||
{ ...box, east: -99 },
|
||||
{ ...box, south: NaN },
|
||||
]) {
|
||||
await assert.rejects(source.fetch(bad), /bounded city viewport/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
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 = createAlprTileSource({
|
||||
tileFetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
async text() {
|
||||
abort.abort();
|
||||
return JSON.stringify(metadata('us'));
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(austin, abort.signal), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
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(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 = createAlprTileSource({
|
||||
tileFetchImpl: async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
body: {
|
||||
async cancel() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(box), /unavailable/);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
|
||||
test('source snapshots reject malformed coordinates and duplicate identities', () => {
|
||||
const record = { id: 'camera:1', latitude: 30, longitude: -98 };
|
||||
for (const records of [
|
||||
[{ ...record, latitude: NaN }],
|
||||
[record, record],
|
||||
[{ ...record, id: '' }],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => validateAlprSnapshot({ records, stale: false, saturated: false }),
|
||||
/invalid record/,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => validateAlprSnapshot({ elements: [] }),
|
||||
/invalid snapshot/,
|
||||
);
|
||||
});
|
||||
|
||||
test('provider attribution escapes markup and rejects executable links', () => {
|
||||
assert.equal(alprCreditMarkup(null), null);
|
||||
assert.throws(
|
||||
() => alprCreditMarkup({ text: 'test', href: 'javascript:alert(1)' }),
|
||||
/HTTPS/,
|
||||
);
|
||||
const html = alprCreditMarkup({
|
||||
text: '<img onerror=alert(1)>',
|
||||
href: 'https://example.org/?a=1&b=2',
|
||||
});
|
||||
assert.ok(html.includes('<img onerror=alert(1)>'));
|
||||
assert.ok(html.includes('?a=1&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.')));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { destinationPointDeg } from './model.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.
|
||||
export const MARKER_IMAGE = new URL(
|
||||
'./assets/alpr-marker-normal.png',
|
||||
import.meta.url,
|
||||
).href;
|
||||
export const SELECTED_IMAGE = new URL(
|
||||
'./assets/alpr-marker-selected.png',
|
||||
import.meta.url,
|
||||
).href;
|
||||
export const BRACKETS_IMAGE = new URL(
|
||||
'./assets/alpr-marker-selected-brackets.png',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/** Compact display label; selection identity always uses the full record id. */
|
||||
export function alprDisplayId(record) {
|
||||
if (!Number.isSafeInteger(record.osmId)) return 'ALPR CAMERA';
|
||||
return `ALPR-${String(record.osmId).slice(-4).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/** Show only supplied metadata, with the actual source named for custom adapters. */
|
||||
export function alprLabelDetails(record, source) {
|
||||
const sourceName =
|
||||
source.attribution?.name || source.label || 'Camera source';
|
||||
const details = [
|
||||
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
|
||||
];
|
||||
if (Number.isFinite(record.directionDeg))
|
||||
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
|
||||
const equipment = [
|
||||
...new Set(
|
||||
[record.manufacturer, record.operator, record.cameraType]
|
||||
.map((value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (equipment.length) details.push(equipment.join(' · '));
|
||||
if (sourceName === 'OpenStreetMap') details.push('PUBLIC MAP DATA');
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
record.longitude,
|
||||
record.directionDeg - DIRECTION_CONE_HALF_ANGLE_DEG,
|
||||
DIRECTION_CONE_M,
|
||||
);
|
||||
const right = destinationPointDeg(
|
||||
record.latitude,
|
||||
record.longitude,
|
||||
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, corner(left)),
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
right.longitude,
|
||||
right.latitude,
|
||||
corner(right),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Paint the original cyan/coral gradient with a crisp V-shaped boundary. */
|
||||
export function paintDirectionWedge(ctx, origin, left, right, selected) {
|
||||
const rgb = selected ? '255, 100, 116' : '82, 212, 255';
|
||||
const gradient = ctx.createLinearGradient(
|
||||
origin.x,
|
||||
origin.y,
|
||||
(left.x + right.x) / 2,
|
||||
(left.y + right.y) / 2,
|
||||
);
|
||||
const alpha = selected ? [0.8, 0.48, 0.22, 0.06] : [0.34, 0.2, 0.09, 0.025];
|
||||
[0, 0.36, 0.72, 1].forEach((stop, i) =>
|
||||
gradient.addColorStop(stop, `rgba(${rgb}, ${alpha[i]})`),
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(origin.x, origin.y);
|
||||
ctx.lineTo(left.x, left.y);
|
||||
ctx.lineTo(right.x, right.y);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left.x, left.y);
|
||||
ctx.lineTo(origin.x, origin.y);
|
||||
ctx.lineTo(right.x, right.y);
|
||||
ctx.strokeStyle = `rgba(${rgb}, ${selected ? 0.98 : 0.76})`;
|
||||
ctx.lineWidth = selected ? 2.5 : 1.5;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** Reject transient tile/terrain samples outside plausible mapped ground heights. */
|
||||
export function validAlprGroundHeight(height) {
|
||||
return Number.isFinite(height) && height >= -500 && height <= 10000;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+51
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
@@ -105,6 +109,9 @@ export function createRendering({
|
||||
const canvas = layerState.viewer.scene.canvas;
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(width, height)
|
||||
: null;
|
||||
const geometry = getKeyholeGeometry(width, height);
|
||||
if (!(geometry.radius > 0)) {
|
||||
root.hidden = true;
|
||||
@@ -182,6 +189,15 @@ export function createRendering({
|
||||
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||
marker.style.opacity = String(projection.opacity);
|
||||
const sonarFactor =
|
||||
sonar?.at(
|
||||
geometry.centerX + Math.cos(projection.angle) * markerRadius,
|
||||
geometry.centerY + Math.sin(projection.angle) * markerRadius,
|
||||
) ?? 1;
|
||||
marker._arrow.style.opacity = String(sonarFactor);
|
||||
marker._label.style.opacity = String(
|
||||
sonar ? sonar.label(sonarFactor) : 1,
|
||||
);
|
||||
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
|
||||
const label = formatAwarenessLabel(item);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+49
-2
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GBFS_CITY_REGISTRY } from './registry.js';
|
||||
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -47,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(
|
||||
@@ -71,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,
|
||||
@@ -85,10 +89,10 @@ export function createLifecycle({
|
||||
|
||||
if (!layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
viewer.camera.percentageChanged = Math.min(
|
||||
viewer.camera.percentageChanged || 1,
|
||||
0.05,
|
||||
);
|
||||
// Through the shared ledger: this is a single number every layer's
|
||||
// camera.changed listener shares, and a layer leaving must not hand
|
||||
// back a coarse default while this one is still driving off it.
|
||||
claimCameraSensitivity(viewer.camera, 'bikeshare', 0.05);
|
||||
layerState._cameraChangedAttached = true;
|
||||
}
|
||||
|
||||
@@ -124,6 +128,7 @@ export function createLifecycle({
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
|
||||
@@ -156,6 +161,7 @@ export function createLifecycle({
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// BIKESHARE PROXY ROUTE — the client's URL against the real mounted middleware.
|
||||
//
|
||||
// The station source and the GBFS proxy are unit-tested apart, each against a
|
||||
// stand-in for the other, so a disagreement about the request shape stayed
|
||||
// invisible while the layer was dark. These cases run the URL the client
|
||||
// actually produces through the middleware the dev server actually mounts, with
|
||||
// only the upstream network call replaced, and assert the two documents join
|
||||
// into a station record with availability counts.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gbfsProxy } from '../../../server/providers/gbfs.js';
|
||||
import { localProviderPlugins } from '../../../server/providers/local.js';
|
||||
import { createBikeshareSource } from './source.js';
|
||||
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: {
|
||||
stations: [
|
||||
{
|
||||
station_id: '2537',
|
||||
name: 'Republic Square',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const STATION_STATUS = {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: '2537',
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 9,
|
||||
is_renting: 1,
|
||||
is_returning: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount the GBFS proxy the way the dev server does and return a fetch-shaped
|
||||
* entry point for it.
|
||||
*
|
||||
* `server.middlewares.use(route, handler)` is connect: it matches a request
|
||||
* whose path starts with the mount route, then strips that prefix from
|
||||
* `req.url` before the handler sees it. The handler therefore never reads
|
||||
* `/api/gbfs/...`, only the remainder — which is exactly the detail the client
|
||||
* and the proxy disagreed about. Reproducing the strip here is what makes this
|
||||
* a round trip rather than two more isolated stubs.
|
||||
*
|
||||
* @returns {(input: string, init?: object) => Promise<Response>}
|
||||
*/
|
||||
function mountGbfsProxy(
|
||||
install = (server) => gbfsProxy().configureServer(server),
|
||||
) {
|
||||
const routes = new Map();
|
||||
install({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return async function mountedFetch(input, init = {}) {
|
||||
const requested = new URL(String(input), 'http://localhost');
|
||||
let matched = null;
|
||||
for (const [route, handler] of routes) {
|
||||
if (
|
||||
requested.pathname !== route &&
|
||||
!requested.pathname.startsWith(route + '/')
|
||||
)
|
||||
continue;
|
||||
matched = { route, handler };
|
||||
break;
|
||||
}
|
||||
if (!matched) return new Response('no route', { status: 404 });
|
||||
|
||||
// connect: the mount prefix is removed and a leading slash restored.
|
||||
let rest =
|
||||
requested.pathname.slice(matched.route.length) + requested.search;
|
||||
if (rest[0] !== '/') rest = '/' + rest;
|
||||
|
||||
const req = { method: init.method || 'GET', url: rest, headers: {} };
|
||||
return await new Promise((resolve, reject) => {
|
||||
let status = 200;
|
||||
let headers = {};
|
||||
const res = {
|
||||
writeHead(code, sent) {
|
||||
status = code;
|
||||
headers = sent || {};
|
||||
return res;
|
||||
},
|
||||
end(body = '') {
|
||||
resolve(new Response(String(body), { status, headers }));
|
||||
},
|
||||
};
|
||||
Promise.resolve(matched.handler(req, res)).catch(reject);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the global fetch used by the proxy's upstream step for one call. */
|
||||
async function withUpstream(byUrl, run) {
|
||||
const previous = globalThis.fetch;
|
||||
const requested = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
const body = byUrl[href];
|
||||
if (!body) return new Response('not found', { status: 404 });
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
try {
|
||||
return await run(requested);
|
||||
} finally {
|
||||
globalThis.fetch = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test('the station source reaches the mounted proxy and both documents join into a station record', async () => {
|
||||
const upstreamByUrl = {
|
||||
[AUSTIN.stationInformationUrl]: STATION_INFORMATION,
|
||||
[AUSTIN.stationStatusUrl]: STATION_STATUS,
|
||||
};
|
||||
|
||||
const { info, status, requested } = await withUpstream(
|
||||
upstreamByUrl,
|
||||
async (requested) => {
|
||||
const source = createBikeshareSource({
|
||||
fetchImpl: mountGbfsProxy(),
|
||||
});
|
||||
const info = await source.getStations(AUSTIN.stationInformationUrl);
|
||||
const status = await source.getStations(AUSTIN.stationStatusUrl);
|
||||
return { info, status, requested };
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
requested,
|
||||
[AUSTIN.stationInformationUrl, AUSTIN.stationStatusUrl],
|
||||
'the proxy reached the upstream the client asked for, unchanged',
|
||||
);
|
||||
|
||||
const model = createModel({});
|
||||
const infoMap = model.parseStationInformation(info);
|
||||
const statusMap = model.parseStationStatus(status);
|
||||
const station = infoMap.get('2537');
|
||||
const availability = statusMap.get('2537');
|
||||
assert.ok(station, 'station information survived the round trip');
|
||||
assert.ok(availability, 'station status survived the round trip');
|
||||
|
||||
assert.deepEqual(
|
||||
{
|
||||
stationId: station.stationId,
|
||||
name: station.name,
|
||||
lat: station.lat,
|
||||
lon: station.lon,
|
||||
capacity: station.capacity,
|
||||
bikesAvailable: availability.bikesAvailable,
|
||||
docksAvailable: availability.docksAvailable,
|
||||
isRenting: availability.isRenting,
|
||||
},
|
||||
{
|
||||
stationId: '2537',
|
||||
name: 'Republic Square',
|
||||
lat: 30.2681,
|
||||
lon: -97.7469,
|
||||
capacity: 13,
|
||||
bikesAvailable: 4,
|
||||
docksAvailable: 9,
|
||||
isRenting: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the proxy relays its cache policy and upstream host through the mounted route', async () => {
|
||||
await withUpstream(
|
||||
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
|
||||
async () => {
|
||||
const mounted = mountGbfsProxy();
|
||||
const response = await mounted(
|
||||
'/api/gbfs/' + encodeURIComponent(AUSTIN.stationStatusUrl),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(
|
||||
response.headers.get('x-gbfs-upstream'),
|
||||
'austin.publicbikesystem.net',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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 },
|
||||
async (requested) => {
|
||||
const mounted = mountGbfsProxy();
|
||||
const response = await mounted(
|
||||
'/api/gbfs?url=' + encodeURIComponent(AUSTIN.stationStatusUrl),
|
||||
);
|
||||
assert.equal(
|
||||
response.status,
|
||||
400,
|
||||
'the old client URL is the 400 every city reported',
|
||||
);
|
||||
assert.deepEqual(requested, [], 'no upstream request is made for it');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the mounted route still refuses a host outside the allowlist', async () => {
|
||||
await withUpstream({}, async (requested) => {
|
||||
const mounted = mountGbfsProxy();
|
||||
const response = await mounted(
|
||||
'/api/gbfs/' +
|
||||
encodeURIComponent('https://attacker.example/station_status.json'),
|
||||
);
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(requested, []);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GBFS plugin as the application actually composes it.
|
||||
*
|
||||
* Mounting `gbfsProxy()` directly proves the handler agrees with the client,
|
||||
* but not that the application still registers it. These take the plugin out
|
||||
* of the real provider list by name, so dropping GBFS from the composition —
|
||||
* or from the preview half of it — fails here rather than in a browser.
|
||||
*/
|
||||
function composedGbfsPlugin() {
|
||||
const plugin = localProviderPlugins().find(
|
||||
(entry) => entry.name === 'gbfs-proxy',
|
||||
);
|
||||
assert.ok(plugin, 'the application must compose a GBFS provider');
|
||||
return plugin;
|
||||
}
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`the composed application serves the client's GBFS URL through ${hook}`, async () => {
|
||||
const plugin = composedGbfsPlugin();
|
||||
assert.equal(
|
||||
typeof plugin[hook],
|
||||
'function',
|
||||
`the composed GBFS provider must register on ${hook}`,
|
||||
);
|
||||
|
||||
const { info, status, requested } = await withUpstream(
|
||||
{
|
||||
[AUSTIN.stationInformationUrl]: STATION_INFORMATION,
|
||||
[AUSTIN.stationStatusUrl]: STATION_STATUS,
|
||||
},
|
||||
async (requested) => {
|
||||
const source = createBikeshareSource({
|
||||
fetchImpl: mountGbfsProxy((server) => plugin[hook](server)),
|
||||
});
|
||||
const info = await source.getStations(AUSTIN.stationInformationUrl);
|
||||
const status = await source.getStations(AUSTIN.stationStatusUrl);
|
||||
return { info, status, requested };
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(requested, [
|
||||
AUSTIN.stationInformationUrl,
|
||||
AUSTIN.stationStatusUrl,
|
||||
]);
|
||||
|
||||
const model = createModel({});
|
||||
const station = model.parseStationInformation(info).get('2537');
|
||||
const availability = model.parseStationStatus(status).get('2537');
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: station?.name,
|
||||
capacity: station?.capacity,
|
||||
bikesAvailable: availability?.bikesAvailable,
|
||||
docksAvailable: availability?.docksAvailable,
|
||||
},
|
||||
{
|
||||
name: 'Republic Square',
|
||||
capacity: 13,
|
||||
bikesAvailable: 4,
|
||||
docksAvailable: 9,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
@@ -251,6 +330,18 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -318,14 +409,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',
|
||||
@@ -340,22 +423,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',
|
||||
@@ -379,14 +472,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',
|
||||
@@ -402,6 +501,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 || '')
|
||||
@@ -430,11 +582,13 @@ function normalizeRegistryEntry(entry) {
|
||||
) {
|
||||
throw new Error(`GBFS entry "${id}" must use https URLs`);
|
||||
}
|
||||
// Validate that URLs end with expected GBFS endpoint filenames
|
||||
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
|
||||
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
|
||||
if (
|
||||
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
|
||||
) {
|
||||
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
|
||||
}
|
||||
if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
|
||||
if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
|
||||
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// BIKESHARE REGISTRY — a city entry is only useful if the proxy will serve it.
|
||||
//
|
||||
// A registry entry and the GBFS proxy allowlist are edited in different files,
|
||||
// so a city can look correctly registered while every one of its requests is
|
||||
// rejected at the proxy. These cases hold the two together: each entry is
|
||||
// checked against the same host and path predicates the proxy enforces, so a
|
||||
// new city that the proxy would refuse fails here rather than silently
|
||||
// rendering an empty layer.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GBFS_CITY_REGISTRY, CITY_BY_ID } from './registry.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
test('every registered city is reachable through the GBFS proxy allowlist', () => {
|
||||
for (const city of GBFS_CITY_REGISTRY) {
|
||||
for (const raw of [city.stationInformationUrl, city.stationStatusUrl]) {
|
||||
const url = new URL(raw);
|
||||
assert.equal(
|
||||
isAllowedGbfsHost(url.hostname),
|
||||
true,
|
||||
`${city.id}: host ${url.hostname} is not allowlisted`,
|
||||
);
|
||||
assert.equal(
|
||||
isAllowedGbfsPath(url.pathname, url.hostname),
|
||||
true,
|
||||
`${city.id}: path ${url.pathname} is not allowlisted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bike Share Toronto is registered against the PBSC v2 feeds', () => {
|
||||
const toronto = CITY_BY_ID.get('toronto-bike-share');
|
||||
assert.ok(toronto, 'toronto-bike-share is missing from the registry');
|
||||
assert.equal(toronto.city, 'Toronto, ON');
|
||||
assert.equal(toronto.provider, 'Bike Share Toronto');
|
||||
assert.equal(
|
||||
toronto.stationInformationUrl,
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
);
|
||||
assert.equal(
|
||||
toronto.stationStatusUrl,
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Toronto center sits inside the system service area', () => {
|
||||
const toronto = CITY_BY_ID.get('toronto-bike-share');
|
||||
// Bounding box of the 1,069 live stations, sampled from the published
|
||||
// station_information feed: 43.588..43.813 N, -79.603..-79.123 W.
|
||||
assert.ok(toronto.centerLat > 43.588 && toronto.centerLat < 43.813);
|
||||
assert.ok(toronto.centerLon > -79.603 && toronto.centerLon < -79.123);
|
||||
// The system spans roughly 37 km east-west, so the load radius has to clear
|
||||
// half that from the center or downtown pans lose the outer stations.
|
||||
assert.ok(toronto.loadRadiusKm >= 25);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
@@ -153,6 +154,8 @@ export function createSelection({
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export function createBikeshareSource({
|
||||
throw new TypeError('A public HTTPS GBFS URL is required');
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
'/api/gbfs?url=' + encodeURIComponent(url.href),
|
||||
'/api/gbfs/' + encodeURIComponent(url.href),
|
||||
{ method: 'GET', headers: { Accept: 'application/json' }, signal },
|
||||
);
|
||||
if (!response.ok) throw new Error('GBFS HTTP ' + response.status);
|
||||
|
||||
@@ -18,9 +18,9 @@ test('station source keeps upstream URLs behind the fixed GBFS endpoint', async
|
||||
assert.equal(calls.length, 0);
|
||||
await source.getStations('https://example.test/stations.json');
|
||||
const url = new URL(calls[0][0], 'https://app.example');
|
||||
assert.equal(url.pathname, '/api/gbfs');
|
||||
assert.equal(url.search, '');
|
||||
assert.equal(
|
||||
url.searchParams.get('url'),
|
||||
decodeURIComponent(url.pathname.replace(/^\/api\/gbfs\//, '')),
|
||||
'https://example.test/stations.json',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,9 +99,17 @@ export function createState({ services }) {
|
||||
|
||||
layerState._loadingOps = 0;
|
||||
|
||||
/** Most recent error message string, or null. */
|
||||
/**
|
||||
* @type {Set<string>} City ids whose most recent fetch failed.
|
||||
*
|
||||
* Per city rather than one layer-wide flag: feeds are fetched independently,
|
||||
* so a retired operator must not mark a layer that other cities are still
|
||||
* populating as failed. Entries are dropped when their city leaves range.
|
||||
* The layer reports failure only when these cost it everything it had to
|
||||
* draw, and degraded otherwise — see `getStats()` in controls.js.
|
||||
*/
|
||||
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds = new Set();
|
||||
|
||||
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each city's PBSC host, and the stations farthest from its registered centre
|
||||
// in the live feed (checked 2026-10-13). HIBike's centre sits between its two
|
||||
// towns, so it lists the outermost station in each.
|
||||
const CITIES = {
|
||||
'detroit-mogo': {
|
||||
host: 'detroit.publicbikesystem.net',
|
||||
provider: 'MoGo',
|
||||
stations: [{ name: 'Robina Ave & W Twelve Mile Rd', lat: 42.50288, lon: -83.18921 }],
|
||||
},
|
||||
'pittsburgh-pogoh': {
|
||||
host: 'pittsburgh.publicbikesystem.net',
|
||||
provider: 'POGOH',
|
||||
stations: [{ name: 'Wilkinsburg Park & Ride', lat: 40.446951, lon: -79.888593 }],
|
||||
},
|
||||
'aspen-we-cycle': {
|
||||
host: 'aspen.publicbikesystem.net',
|
||||
provider: 'WE-cycle',
|
||||
stations: [{ name: 'Cowen Dr | Hwy 133', lat: 39.413115, lon: -107.220634 }],
|
||||
},
|
||||
'hawaii-island-hibike': {
|
||||
host: 'kona.publicbikesystem.net',
|
||||
provider: 'HIBike',
|
||||
stations: [
|
||||
{ name: 'Kona Community Center', lat: 19.642932, lon: -156.004962 },
|
||||
{ name: 'Kealoha Beach Park, Hilo', lat: 19.733317, lon: -155.029202 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
test('the added US PBSC systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
assert.ok(city, `${id} is registered`);
|
||||
assert.equal(city.provider, expected.provider);
|
||||
assert.deepEqual(city.hosts, [expected.host]);
|
||||
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('HIBike stays inactive away from Hawaii Island', () => {
|
||||
// Honolulu is about 270 km from the HIBike centre.
|
||||
assert.equal(
|
||||
computeInRangeCities({ lat: 21.3069, lon: -157.8583 }).has('hawaii-island-hibike'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -122,6 +122,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
const cityIds = Array.from(layerState._activeCityIds);
|
||||
for (const cityId of cityIds) deactivateCity(cityId);
|
||||
layerState._activeCityIds.clear();
|
||||
// A failure belongs to a city in range; none are once everything is off.
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,11 +171,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
parts.rendering.applyStatusToPoints(cityId, statusMap);
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds.delete(cityId);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
// A rejection from superseded work must not mark (or tear down) a city
|
||||
// that a newer proximity check or re-enable now owns.
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._activeCityIds.has(cityId) ||
|
||||
generation !== layerState._proximityGeneration
|
||||
)
|
||||
return;
|
||||
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
|
||||
layerState._error = 'GBFS fetch error';
|
||||
layerState._failedCityIds.add(cityId);
|
||||
deactivateCity(cityId);
|
||||
layerState._activeCityIds.delete(cityId);
|
||||
} finally {
|
||||
@@ -208,6 +219,10 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
deactivateCity(cityId);
|
||||
}
|
||||
}
|
||||
// A failure never outlives its city's range.
|
||||
for (const cityId of layerState._failedCityIds) {
|
||||
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
|
||||
}
|
||||
|
||||
layerState._activeCityIds = nextActive;
|
||||
if (layerState._activeCityIds.size === 0) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createCalibrationGizmo } from '../../data/cctvGizmo.js';
|
||||
import {
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CCTV_CALIBRATION_STORAGE_KEY_V2,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCalibration({
|
||||
@@ -75,6 +77,35 @@ export function createCalibration({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-expresses a saved `rangeScale` after the catalog's range floor moved.
|
||||
* Entries were authored as a multiplier on the OLD client base range
|
||||
* (`max(220, packRange)`); the base is now `max(120, packRange)`, so a camera
|
||||
* whose pack range sits below 220 m would silently shrink. Scaling by the
|
||||
* ratio of the two bases keeps the user's effective range; the 3× ceiling
|
||||
* still applies (an extreme entry loses a little rather than exploding).
|
||||
* @param {Object} values - Normalized 7-field calibration.
|
||||
* @param {number} packRangeM - The camera's served (pack) range.
|
||||
* @returns {Object} A new values object.
|
||||
*/
|
||||
function migrateRangeScaleForFloor(values, packRangeM) {
|
||||
const oldBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
LEGACY_CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
const newBase = parts.model.clamp(
|
||||
parts.model.safeNumber(packRangeM, 700),
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
2200,
|
||||
);
|
||||
if (oldBase === newBase) return values;
|
||||
return normalizeCalibration({
|
||||
...values,
|
||||
rangeScale: values.rangeScale * (oldBase / newBase),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given calibration is effectively the default (all offsets near zero).
|
||||
* @param {Object} calibration
|
||||
@@ -120,6 +151,9 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, 0),
|
||||
// Range floor the entry's rangeScale was authored against; absent
|
||||
// on entries saved before the floor dropped from 220 m to 120 m.
|
||||
rangeFloorM: parts.model.safeNumber(entry.rangeFloorM, NaN),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
@@ -147,6 +181,12 @@ export function createCalibration({
|
||||
values: normalizeCalibration(entry.values),
|
||||
source: 'manual',
|
||||
savedAt: parts.model.safeNumber(entry.savedAt, Date.now()),
|
||||
// Only entries that were migrated or saved against the current
|
||||
// floor carry the marker; an entry for a camera absent from this
|
||||
// catalog keeps its eligibility to migrate when it returns.
|
||||
...(Number.isFinite(entry.rangeFloorM)
|
||||
? { rangeFloorM: entry.rangeFloorM }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
storage.setItem(CCTV_CALIBRATION_STORAGE_KEY_V2, JSON.stringify(payload));
|
||||
@@ -243,6 +283,8 @@ export function createCalibration({
|
||||
parts.ground.groundAltFor(record),
|
||||
);
|
||||
}
|
||||
// The edited pose has a new footprint; resolve the ground under it once.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
return true;
|
||||
}
|
||||
@@ -279,6 +321,9 @@ export function createCalibration({
|
||||
endPatch: (draggedRecord) => {
|
||||
const record = liveRecord(draggedRecord);
|
||||
if (!record) return;
|
||||
// The drag changed the plane's footprint; resolve the ground under
|
||||
// the released pose once (revision-guarded, cached proxy).
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
if (record.calibrationAnchorDirty) {
|
||||
record.calibrationAnchorDirty = false;
|
||||
parts.ground.resolveCommittedGroundAnchor(record);
|
||||
@@ -298,6 +343,7 @@ export function createCalibration({
|
||||
calibrationPatchMovesAnchor,
|
||||
normalizeCalibration,
|
||||
isDefaultCalibration,
|
||||
migrateRangeScaleForFloor,
|
||||
readCalibrationStoreV2,
|
||||
writeCalibrationStoreV2,
|
||||
loadCalibrationStore,
|
||||
|
||||
@@ -136,9 +136,12 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
20,
|
||||
125,
|
||||
);
|
||||
// Same floor as the pose model (model.js): the packs' 145/210 m ranges
|
||||
// are intentional, and inflating them to 220 m pushed the monitor
|
||||
// plane's far edge into higher ground (owner field test 2026-09-13).
|
||||
const rangeM = parts.model.clamp(
|
||||
parts.model.safeNumber(source.rangeM, seed?.rangeM ?? 700),
|
||||
220,
|
||||
120,
|
||||
2200,
|
||||
);
|
||||
const mountHeightM = parts.model.clamp(
|
||||
@@ -193,7 +196,16 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
credit: String(source.credit || ''),
|
||||
code: String(source.code || ''),
|
||||
// Shipped precompute (see server/providers/cctv/groundHeights.js).
|
||||
groundHeights:
|
||||
source.groundHeights && typeof source.groundHeights === 'object'
|
||||
? source.groundHeights
|
||||
: null,
|
||||
poseSource,
|
||||
model: String(source.model || ''),
|
||||
manufacturer: String(source.manufacturer || ''),
|
||||
};
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
|
||||
Vendored
+8
-2
@@ -3,6 +3,7 @@ import {
|
||||
MIN_AUTO_HOP_SEC,
|
||||
MAX_AUTO_HOP_SEC,
|
||||
DEFAULT_CAMERA_CALIBRATION,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
@@ -100,8 +101,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
parts.model.ensureCameraPose(targetRecord.camera);
|
||||
layerState._calibrationById.delete(targetCameraId);
|
||||
parts.calibration.saveCalibrationStore();
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once.
|
||||
// Reset returns to the base lat/lon, so resolve that anchor once,
|
||||
// and the footprint under the restored pose.
|
||||
parts.ground.resolveCommittedGroundAnchor(targetRecord);
|
||||
void parts.ground.resolveFootprintGround(targetRecord);
|
||||
parts.frames.refreshProjectionImage(targetRecord, true);
|
||||
}
|
||||
if (
|
||||
@@ -134,6 +137,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
values: { ...targetRecord.camera.calibration },
|
||||
source: 'manual',
|
||||
savedAt: Date.now(),
|
||||
// Authored against the current range floor: never migrated.
|
||||
rangeFloorM: CALIBRATION_RANGE_FLOOR_M,
|
||||
});
|
||||
}
|
||||
targetRecord.calDirty = false;
|
||||
@@ -217,7 +222,8 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
sourceId: camera.id,
|
||||
id: `CAM-${camera.id}`,
|
||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||
id: `CAM-${camera.code || camera.id}`,
|
||||
type: 'CAM',
|
||||
});
|
||||
if (objects.length >= maxCount) break;
|
||||
|
||||
+163
-40
@@ -8,12 +8,89 @@ import {
|
||||
COVERAGE_NEIGHBOR_RADIUS_KM,
|
||||
COVERAGE_NEIGHBOR_LIMIT,
|
||||
IDLE_COVERAGE_COLOR,
|
||||
PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
} from './policy.js';
|
||||
|
||||
import {
|
||||
planeDimensions,
|
||||
poseHash,
|
||||
requiredPlaneLift,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
const { warmGroundFloor, cachedGroundFloor } = services.ground;
|
||||
const { sampleMeshFloorCells } = services.mesh;
|
||||
|
||||
/**
|
||||
* Ground measured under the plane's support points for the record's
|
||||
* CURRENT pose, or null. Shipped precompute samples (`camera.groundHeights`)
|
||||
* were taken for the nominal pose; they apply only while the pose hash still
|
||||
* matches, so a calibrated (edited) camera falls back to the mount ground
|
||||
* until its own footprint is resolved. Photoreal regime only: the values
|
||||
* are aligned to the Google 3D Tiles surface and would be wrong against a
|
||||
* globe DEM.
|
||||
* @param {Object} record
|
||||
* @returns {Record<string, number>|null}
|
||||
*/
|
||||
function footprintGroundFor(record) {
|
||||
if (hasShippedFootprint(record)) {
|
||||
return record.camera.groundHeights.supports || null;
|
||||
}
|
||||
// DEM footprint resolved on demand (activation / calibration commit) —
|
||||
// valid in either regime, the DEM is what the globe stacks render anyway.
|
||||
const resolved = record?.footprintGround;
|
||||
if (resolved && resolved.poseHash === poseHash(footprintPose(record))) {
|
||||
return resolved.supports || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pose the monitor plane is actually rendered with: the calibrated
|
||||
* camera fields plus the activation probe's range clamp. Footprint samples
|
||||
* (shipped or DEM) are only valid for exactly this pose.
|
||||
* @param {Object} record
|
||||
* @returns {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
|
||||
* fovDeg:number, rangeM:number, mountHeightM:number}}
|
||||
*/
|
||||
function footprintPose(record) {
|
||||
const camera = record.camera;
|
||||
const override = parts.model.safeNumber(record.probeClampRangeM, NaN);
|
||||
const rangeM =
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(camera.rangeM, override)
|
||||
: camera.rangeM;
|
||||
return {
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
headingDeg: camera.headingDeg,
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
rangeM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the record carries shipped precompute samples for its CURRENT
|
||||
* pose and the scene is rendering the Google 3D Tiles surface they are
|
||||
* aligned to.
|
||||
* @param {Object} record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasShippedFootprint(record) {
|
||||
const shipped = record?.camera?.groundHeights;
|
||||
if (!shipped || typeof shipped !== 'object') return false;
|
||||
if (parts.ground.currentSurfaceRegime() !== 'google-3d') return false;
|
||||
// Samples describe the pose as served. Any calibration, client clamp or
|
||||
// activation range clamp changes the rendered pose and its hash, and the
|
||||
// camera then falls back to its own DEM footprint.
|
||||
return (
|
||||
Number.isFinite(shipped.mountGroundM) &&
|
||||
shipped.poseHash === poseHash(footprintPose(record))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 core geometry (design §2a): computes the pitched frustum pyramid — mount
|
||||
* point, far-cap (monitor plane) center, and the 4 far-plane corners — purely
|
||||
@@ -28,16 +105,20 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* vFov = 2·atan(tan(hFov/2) / (16/9)) → halfH = R·tan(vFov/2)
|
||||
* upOffset = cos(pitch)·halfH vertical + (−sin(pitch))·halfH along heading
|
||||
* corners = (capCenter ∓ halfW toward heading∓90°) ± upOffset
|
||||
* The cap CENTER altitude clamps to ≥ groundAltM + 2 m (§6 risk: fabricated
|
||||
* pitch must never bury the plane's anchor); corners derive rigidly from the
|
||||
* clamped center so the wireframe rays always terminate on the plane's
|
||||
* corners — the bottom pair may dip below ground (tiles occlude it).
|
||||
* The whole rectangle is then lifted rigidly by the smallest amount that
|
||||
* puts every support point (a 3×3 grid over the plane) at least 2 m above
|
||||
* the ground measured under it — the ground at the mount where nothing
|
||||
* finer is known — so the plane never clips into the terrain and the
|
||||
* wireframe rays still terminate exactly on its corners.
|
||||
*
|
||||
* @param {Object} camera - Pose: lat, lon, headingDeg, pitchDeg, fovDeg,
|
||||
* rangeM, mountHeightM.
|
||||
* @param {number} groundAltM - Ground altitude at the mount (metres).
|
||||
* @param {number|null} [rangeOverrideM=null] - Obstruction-probe clamp: caps the
|
||||
* effective range (never lengthens it).
|
||||
* @param {Record<string, number>|null} [groundUnderPlane=null] - Ground
|
||||
* altitude measured under each plane support point (`bl`…`tr`, see
|
||||
* footprint.js), from the shipped precompute or a DEM lookup.
|
||||
* @returns {{ rangeM: number, vFovDeg: number, halfW: number, halfH: number,
|
||||
* mount: {lat:number,lon:number,alt:number},
|
||||
* capCenter: {lat:number,lon:number,alt:number},
|
||||
@@ -45,7 +126,12 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
* topCenter: {lat:number,lon:number,alt:number}, groundAltM: number }}
|
||||
*/
|
||||
|
||||
function computeFrustumGeometry(camera, groundAltM, rangeOverrideM = null) {
|
||||
function computeFrustumGeometry(
|
||||
camera,
|
||||
groundAltM,
|
||||
rangeOverrideM = null,
|
||||
groundUnderPlane = null,
|
||||
) {
|
||||
const ground = parts.model.safeNumber(groundAltM, 0);
|
||||
const poseRange = Math.max(1, parts.model.safeNumber(camera.rangeM, 700));
|
||||
const override = parts.model.safeNumber(rangeOverrideM, NaN);
|
||||
@@ -53,73 +139,92 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
Number.isFinite(override) && override > 0
|
||||
? Math.min(poseRange, override)
|
||||
: poseRange;
|
||||
const pitch = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.pitchDeg, -17), -89, 89),
|
||||
const pitchDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.pitchDeg, -17),
|
||||
-89,
|
||||
89,
|
||||
);
|
||||
const hFov = parts.model.toRad(
|
||||
parts.model.clamp(parts.model.safeNumber(camera.fovDeg, 74), 8, 160),
|
||||
const fovDeg = parts.model.clamp(
|
||||
parts.model.safeNumber(camera.fovDeg, 74),
|
||||
8,
|
||||
160,
|
||||
);
|
||||
const heading = parts.model.safeNumber(camera.headingDeg, 0);
|
||||
const mountAlt = ground + parts.model.safeNumber(camera.mountHeightM, 24);
|
||||
const dims = planeDimensions({ rangeM: R, pitchDeg, fovDeg });
|
||||
|
||||
const horiz = R * Math.cos(pitch);
|
||||
const vert = R * Math.sin(pitch);
|
||||
const capLL = parts.model.projectPoint(
|
||||
camera.lat,
|
||||
camera.lon,
|
||||
heading,
|
||||
horiz,
|
||||
dims.horiz,
|
||||
);
|
||||
const capAlt = mountAlt + vert;
|
||||
|
||||
const halfW = R * Math.tan(hFov / 2);
|
||||
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PROJECTION_VERT_ASPECT);
|
||||
const halfH = R * Math.tan(vFovRad / 2);
|
||||
|
||||
// In-plane "up" of the pitched cap, decomposed into a vertical part and a
|
||||
// horizontal part along the heading (pitch < 0 tilts the cap's top forward).
|
||||
const upVert = Math.cos(pitch) * halfH;
|
||||
const upHoriz = -Math.sin(pitch) * halfH;
|
||||
|
||||
const capAlt = mountAlt + dims.vert;
|
||||
const capL = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading - 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
const capR = parts.model.projectPoint(
|
||||
capLL.lat,
|
||||
capLL.lon,
|
||||
heading + 90,
|
||||
halfW,
|
||||
dims.halfW,
|
||||
);
|
||||
// Ground clamp (§6 risk): lift the CAP CENTER once so a fabricated pitch
|
||||
// never buries the plane's anchor — then derive the corners RIGIDLY from the
|
||||
// lifted center. Clamping each corner independently flattened the wireframe
|
||||
// into a ground-hugging fan while the rigid plane kept its height (owner
|
||||
// field test 2026-07-04): the corner rays must always terminate exactly on
|
||||
// the monitor plane's corners. The bottom pair may dip below ground; the 3D
|
||||
// tiles occlude that portion, exactly as they do for the plane itself.
|
||||
const minAlt = ground + FRUSTUM_GROUND_CLEARANCE_M;
|
||||
const capAltClamped = Math.max(minAlt, capAlt);
|
||||
// Ground clearance: ONE rigid lift for the whole rectangle, sized by the
|
||||
// support point with the largest deficit against the ground measured
|
||||
// under it (a 3×3 grid over the plane; see footprint.js). Points without
|
||||
// a measurement fall back to the ground at the mount, so with no
|
||||
// footprint data the bottom edge still clears the mount's ground — the
|
||||
// old center-only clamp left the bottom edge tens of metres underground
|
||||
// (owner field test 2026-09-13). The rectangle stays rigid and the
|
||||
// wireframe rays still terminate exactly on its corners.
|
||||
// Two lifts: what the mount's own ground demands (always honoured), plus
|
||||
// whatever the measured footprint adds, capped so a building under the
|
||||
// far edge cannot send the plane into the sky.
|
||||
const base = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
null,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const measured = requiredPlaneLift(
|
||||
capAlt,
|
||||
dims,
|
||||
groundUnderPlane,
|
||||
ground,
|
||||
FRUSTUM_GROUND_CLEARANCE_M,
|
||||
);
|
||||
const footprintExtraM = Math.max(0, measured.liftM - base.liftM);
|
||||
const liftM =
|
||||
base.liftM + Math.min(PLANE_FOOTPRINT_LIFT_CAP_M, footprintExtraM);
|
||||
const limitingKey =
|
||||
footprintExtraM > 0 ? measured.limitingKey : base.limitingKey;
|
||||
const capAltLifted = capAlt + liftM;
|
||||
const corner = (base, sign) => {
|
||||
const ll = parts.model.projectPoint(
|
||||
base.lat,
|
||||
base.lon,
|
||||
heading,
|
||||
sign * upHoriz,
|
||||
sign * dims.upHoriz,
|
||||
);
|
||||
return { lat: ll.lat, lon: ll.lon, alt: capAltClamped + sign * upVert };
|
||||
return {
|
||||
lat: ll.lat,
|
||||
lon: ll.lon,
|
||||
alt: capAltLifted + sign * dims.upVert,
|
||||
};
|
||||
};
|
||||
|
||||
const topCenter = corner(capLL, 1);
|
||||
return {
|
||||
rangeM: R,
|
||||
vFovDeg: Cesium.Math.toDegrees(vFovRad),
|
||||
halfW,
|
||||
halfH,
|
||||
vFovDeg: dims.vFovDeg,
|
||||
halfW: dims.halfW,
|
||||
halfH: dims.halfH,
|
||||
mount: { lat: camera.lat, lon: camera.lon, alt: mountAlt },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltClamped },
|
||||
capCenter: { lat: capLL.lat, lon: capLL.lon, alt: capAltLifted },
|
||||
corners: {
|
||||
tl: corner(capL, 1),
|
||||
tr: corner(capR, 1),
|
||||
@@ -128,6 +233,10 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
topCenter,
|
||||
groundAltM: ground,
|
||||
liftM,
|
||||
liftLimitedBy: limitingKey,
|
||||
liftCapped: footprintExtraM > PLANE_FOOTPRINT_LIFT_CAP_M,
|
||||
footprintMeasured: !!groundUnderPlane,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,6 +339,7 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
record.camera,
|
||||
groundAltM,
|
||||
record.probeClampRangeM,
|
||||
footprintGroundFor(record),
|
||||
);
|
||||
const positions = frustumCartesians(geometry);
|
||||
record.frustumGeometry = geometry;
|
||||
@@ -327,6 +437,17 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime with shipped precompute samples for exactly this pose:
|
||||
// the mount ground and the footprint come from the sidecar, no scene
|
||||
// query and no shared-cell write (aircraft floors stay untouched).
|
||||
if (hasShippedFootprint(record)) {
|
||||
const shippedGround = record.camera.groundHeights.mountGroundM;
|
||||
record.groundSamples['google-3d'] = shippedGround;
|
||||
record.groundResolved['google-3d'] = true;
|
||||
applyFrustumGeometry(record, shippedGround);
|
||||
return;
|
||||
}
|
||||
|
||||
// Photoreal regime. Sampling is delegated to the shared coarse-cell
|
||||
// sampler. It remains event-driven, one-shot per cell, and keeps its
|
||||
// existing acceptance window; CCTV adds no rooftop rejection policy.
|
||||
@@ -696,6 +817,8 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
return {
|
||||
computeFrustumGeometry,
|
||||
hasShippedFootprint,
|
||||
footprintPose,
|
||||
activationProbeClampRange,
|
||||
frustumCartesians,
|
||||
frustumFrameEcef,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
} from '../../data/cctvFootprint.js';
|
||||
|
||||
/** Cooldown before a provisional (geoid-fallback / partial) footprint is retried. */
|
||||
const FOOTPRINT_RETRY_MS = 60_000;
|
||||
|
||||
export function createGround({ state: layerState, services, parts, source }) {
|
||||
const { resolveGroundFloorCells } = services.ground;
|
||||
const { resolveEllipsoidalGround } = services.terrain;
|
||||
@@ -248,8 +257,13 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
const ground = groundAltFor(record, regime);
|
||||
// Skip the entity rewrite when the applied ground already matches (e.g.
|
||||
// entering google-3d before any sample: prior → prior is a no-op).
|
||||
// entering google-3d before any sample: prior → prior is a no-op) —
|
||||
// unless the record has a footprint source, whose eligibility depends
|
||||
// on the regime (shipped mesh samples apply in google-3d only).
|
||||
const hasFootprintSource =
|
||||
!!record.camera?.groundHeights || !!record.footprintGround;
|
||||
if (
|
||||
!hasFootprintSource &&
|
||||
record.frustumGeometry &&
|
||||
Math.abs(record.frustumGeometry.groundAltM - ground) < 0.001
|
||||
) {
|
||||
@@ -266,11 +280,77 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
// under the ≤1-per-(camera, session) ceiling.
|
||||
layerState._tilesReadyReenqueued = false;
|
||||
}
|
||||
// The active camera may have just lost its shipped footprint (globe
|
||||
// regime) — resolve its DEM footprint for the new surface.
|
||||
const active = layerState._recordById.get(layerState._activeCameraId);
|
||||
if (active) void resolveFootprintGround(active);
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
/**
|
||||
* Resolves the ground under the monitor plane's nine support points for the
|
||||
* record's CURRENT pose from the Re:Earth DEM (network-cached proxy, never a
|
||||
* scene query), then re-applies the geometry so the rigid lift accounts for
|
||||
* terrain rising under the plane's far edge. This is the fallback for
|
||||
* cameras without a shipped precompute for this pose (a new pack, or a
|
||||
* camera the user has edited), and it runs only on demand: activation and
|
||||
* calibration commit. A late result for a stale pose or a torn-down record
|
||||
* is dropped. Never rejects.
|
||||
* @param {Object} record - Camera record.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resolveFootprintGround(record) {
|
||||
if (!record?.camera) return;
|
||||
const pose = parts.geometry.footprintPose(record);
|
||||
const hash = poseHash(pose);
|
||||
const existing = record.footprintGround;
|
||||
if (existing?.poseHash === hash) {
|
||||
// Complete real-DEM results are final; a geoid-fallback (proxy outage)
|
||||
// or partial result is retried after a cooldown.
|
||||
if (!existing.provisional || Date.now() < existing.retryAt) return;
|
||||
}
|
||||
if (parts.geometry.hasShippedFootprint(record)) return;
|
||||
const revision = (record.footprintRevision || 0) + 1;
|
||||
record.footprintRevision = revision;
|
||||
const { supports } = planeSupportPoints(pose);
|
||||
const coords = SUPPORT_KEYS.map((key) => ({
|
||||
lat: supports[key].lat,
|
||||
lon: supports[key].lon,
|
||||
}));
|
||||
let results = null;
|
||||
try {
|
||||
results = await resolveEllipsoidalGround(coords);
|
||||
} catch {
|
||||
results = null;
|
||||
}
|
||||
if (!Array.isArray(results)) return;
|
||||
if (record.footprintRevision !== revision) return;
|
||||
if (layerState._recordById.get(record.camera.id) !== record) return;
|
||||
if (poseHash(parts.geometry.footprintPose(record)) !== hash) return;
|
||||
const under = {};
|
||||
let real = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (Number.isFinite(result?.ellipsoid)) {
|
||||
under[SUPPORT_KEYS[index]] = result.ellipsoid;
|
||||
if (result.source === 'reearth') real += 1;
|
||||
}
|
||||
});
|
||||
const provisional = real < SUPPORT_KEYS.length;
|
||||
record.footprintGround = {
|
||||
poseHash: hash,
|
||||
supports: under,
|
||||
source: provisional ? 'dem-provisional' : 'dem',
|
||||
provisional,
|
||||
retryAt: provisional ? Date.now() + FOOTPRINT_RETRY_MS : 0,
|
||||
};
|
||||
parts.geometry.applyFrustumGeometry(record, groundAltFor(record));
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceRegimeKey,
|
||||
currentSurfaceRegime,
|
||||
resolveFootprintGround,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
groundAltFor,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @module headingConfidence
|
||||
*
|
||||
* Consumers for the catalog's per-camera `headingConfidence` flag (#639).
|
||||
*
|
||||
* Packs whose feed publishes no facing get a synthetic bearing from
|
||||
* `fallbackHeadingFromId` — a hash of the id string, with no geographic
|
||||
* input — and are marked `headingConfidence: 'low'`. Until now nothing read
|
||||
* the flag, so a guessed bearing rendered exactly like a surveyed one. These
|
||||
* helpers make the guess visible: the HUD heading token gains an
|
||||
* `(ESTIMATED)` tag and the coverage wireframe draws dashed instead of solid.
|
||||
*
|
||||
* A human-vouched pose always wins over the pack flag: a manually saved
|
||||
* calibration (`calSource:'manual'`) or a hand-authored catalog entry
|
||||
* (`poseSource:'curated'`) is never presented as estimated, mirroring the
|
||||
* CAL badge states in calibration.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a camera's bearing is a synthetic guess that should present as
|
||||
* provisional.
|
||||
* @param {{headingConfidence?: string|null, calSource?: string|null,
|
||||
* poseSource?: string|null}} camera - Catalog camera (or public state).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isHeadingEstimated(camera) {
|
||||
if (!camera) return false;
|
||||
if (camera.calSource === 'manual') return false;
|
||||
if (camera.poseSource === 'curated') return false;
|
||||
return (
|
||||
String(camera.headingConfidence || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'low'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD heading token: `HDG 194°`, tagged `(ESTIMATED)` when the bearing is
|
||||
* synthetic — so a hash can never read as a surveyed facing.
|
||||
* @param {{headingDeg?: number, headingConfidence?: string|null,
|
||||
* calSource?: string|null, poseSource?: string|null}} camera
|
||||
* @returns {string}
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/layers/cctv/headingConfidence.test.mjs
|
||||
// Bearing-provenance consumers (#639): a pack's headingConfidence:'low'
|
||||
// (a hashed synthetic bearing) must present as estimated — unless a human
|
||||
// vouched for the pose via manual calibration or a curated catalog entry.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isHeadingEstimated, headingHudToken } from './headingConfidence.js';
|
||||
|
||||
test('low confidence marks the bearing estimated', () => {
|
||||
assert.equal(isHeadingEstimated({ headingConfidence: 'low' }), true);
|
||||
});
|
||||
|
||||
test('confidence matching is trimmed and case-insensitive', () => {
|
||||
assert.equal(isHeadingEstimated({ headingConfidence: ' LOW ' }), true);
|
||||
assert.equal(isHeadingEstimated({ headingConfidence: 'Low' }), true);
|
||||
});
|
||||
|
||||
test('medium, high, missing, and absent cameras are not estimated', () => {
|
||||
assert.equal(isHeadingEstimated({ headingConfidence: 'medium' }), false);
|
||||
assert.equal(isHeadingEstimated({ headingConfidence: 'high' }), false);
|
||||
assert.equal(isHeadingEstimated({}), false);
|
||||
assert.equal(isHeadingEstimated(null), false);
|
||||
assert.equal(isHeadingEstimated(undefined), false);
|
||||
});
|
||||
|
||||
test('a manually saved calibration overrides a low pack flag', () => {
|
||||
assert.equal(
|
||||
isHeadingEstimated({ headingConfidence: 'low', calSource: 'manual' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a curated catalog entry overrides a low pack flag', () => {
|
||||
assert.equal(
|
||||
isHeadingEstimated({ headingConfidence: 'low', poseSource: 'curated' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token tags a synthetic bearing and rounds the degrees', () => {
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 194.4, headingConfidence: 'low' }),
|
||||
'HDG 194° (ESTIMATED)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
|
||||
'HDG 68°',
|
||||
);
|
||||
assert.equal(
|
||||
headingHudToken({
|
||||
headingDeg: 225,
|
||||
headingConfidence: 'low',
|
||||
calSource: 'manual',
|
||||
}),
|
||||
'HDG 225°',
|
||||
);
|
||||
});
|
||||
@@ -57,11 +57,13 @@ export function createCctvLayer({ services, source }) {
|
||||
{
|
||||
calibrationPatchMovesAnchor:
|
||||
parts.calibration.calibrationPatchMovesAnchor,
|
||||
migrateRangeScaleForFloor: parts.calibration.migrateRangeScaleForFloor,
|
||||
_pushAmbientCardEntriesForTest:
|
||||
parts.testing._pushAmbientCardEntriesForTest,
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
createCctvProjectionOverlayEntry:
|
||||
parts.projection.createCctvProjectionOverlayEntry,
|
||||
getActiveVideoElement: parts.projection.getActiveVideoElement,
|
||||
setCctvCardPresentationOptions:
|
||||
parts.cards.setCctvCardPresentationOptions,
|
||||
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GROUND_PRIOR_INIT_WAIT_MS,
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -92,9 +93,18 @@ export function createLifecycle({
|
||||
for (const camera of catalog) {
|
||||
const savedEntry = layerState._calibrationById.get(camera.id);
|
||||
if (savedEntry) {
|
||||
camera.calibration = parts.calibration.normalizeCalibration(
|
||||
savedEntry.values,
|
||||
);
|
||||
// Entries saved before the range floor dropped (no rangeFloorM)
|
||||
// keep their effective range by re-basing rangeScale once.
|
||||
const values =
|
||||
savedEntry.rangeFloorM === CALIBRATION_RANGE_FLOOR_M
|
||||
? savedEntry.values
|
||||
: parts.calibration.migrateRangeScaleForFloor(
|
||||
savedEntry.values,
|
||||
camera.rangeM,
|
||||
);
|
||||
savedEntry.values = values;
|
||||
savedEntry.rangeFloorM = CALIBRATION_RANGE_FLOOR_M;
|
||||
camera.calibration = parts.calibration.normalizeCalibration(values);
|
||||
camera.calSource = savedEntry.source;
|
||||
}
|
||||
parts.model.ensureCameraPose(camera);
|
||||
@@ -329,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.
|
||||
@@ -375,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.
|
||||
@@ -398,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(
|
||||
|
||||
+18
-10
@@ -1,3 +1,10 @@
|
||||
export {
|
||||
FRAME_ENDPOINT,
|
||||
SOURCE_ENDPOINT,
|
||||
HEALTH_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
} from './sourcePolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
@@ -6,14 +13,6 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
// API endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing and geometry constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,8 +25,6 @@ export const MAX_AUTO_HOP_SEC = 90;
|
||||
|
||||
export const HEALTH_SYNC_INTERVAL_MS = 7000;
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
|
||||
export const IDLE_FRAME_REFRESH_MS = 60000;
|
||||
|
||||
export const PROJECTION_ACTIVE_REFRESH_MS = 10000;
|
||||
@@ -98,6 +95,17 @@ export const PROJECTION_VERT_ASPECT =
|
||||
// the monitor plane in the 3D tiles. Exported for the unit suite.
|
||||
|
||||
export const FRUSTUM_GROUND_CLEARANCE_M = 2;
|
||||
/**
|
||||
* Most the footprint clearance may lift a monitor plane beyond what the
|
||||
* ground at its own mount requires (metres). A plane whose far edge crosses
|
||||
* a tall building would otherwise float hundreds of metres up to "clear" it;
|
||||
* past this the plane accepts the intersection instead of leaving the scene.
|
||||
*/
|
||||
export const PLANE_FOOTPRINT_LIFT_CAP_M = 60;
|
||||
/** Client range floor the pose model and catalog agree on (metres). */
|
||||
export const CALIBRATION_RANGE_FLOOR_M = 120;
|
||||
/** The floor saved v2 calibrations were authored against before 2026-09-13. */
|
||||
export const LEGACY_CALIBRATION_RANGE_FLOOR_M = 220;
|
||||
|
||||
/** Public result codes for explicit CCTV camera flights. */
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
@@ -35,7 +36,9 @@ export function createPresentation({
|
||||
return [
|
||||
`${active.camera.city.toUpperCase()} CCTV`,
|
||||
`${active.camera.name.toUpperCase()}`,
|
||||
`HDG ${Math.round(active.camera.headingDeg)}°`,
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°`,
|
||||
`COVERAGE ${area.toFixed(2)}km²`,
|
||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||
@@ -76,6 +79,11 @@ export function createPresentation({
|
||||
lat: camera.lat,
|
||||
lon: camera.lon,
|
||||
headingDeg: camera.headingDeg,
|
||||
// Bearing provenance (#639): the pack's confidence flag plus the derived
|
||||
// "is this a synthetic guess" bit (calibration-aware), so UI consumers
|
||||
// never have to re-derive it.
|
||||
headingConfidence: camera.headingConfidence || null,
|
||||
headingEstimated: isHeadingEstimated(camera),
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
rangeM: camera.rangeM,
|
||||
@@ -83,6 +91,9 @@ export function createPresentation({
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
isVideo:
|
||||
parts.model.isVideoFeedType(camera.feedType) &&
|
||||
record.projection?.mode !== 'image',
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
@@ -90,6 +101,7 @@ export function createPresentation({
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
credit: camera.credit || '',
|
||||
calibration: {
|
||||
...parts.calibration.normalizeCalibration(camera.calibration),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
@@ -207,6 +208,7 @@ export function createProjection({
|
||||
// only re-uploads the plane texture when there is genuinely new content.
|
||||
canvasStamp: 1,
|
||||
lastSwappedCanvasStamp: 0,
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
||||
@@ -214,16 +216,49 @@ export function createProjection({
|
||||
if (mode === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.src = parts.frames.mediaUrlFor(record.camera);
|
||||
video.addEventListener('canplay', () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
// Cesium sizes the video texture from the element's width/height
|
||||
// attributes at first upload. Set them from the real stream dimensions
|
||||
// and rebind on any resolution change (camera switch, adaptive source).
|
||||
const bindVideoTexture = () => {
|
||||
if (runtime.video !== video || !runtime.planeMaterial) return;
|
||||
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
|
||||
video.width = video.videoWidth;
|
||||
video.height = video.videoHeight;
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
runtime.planeMaterial.image = video;
|
||||
};
|
||||
video.addEventListener('loadedmetadata', bindVideoTexture);
|
||||
video.addEventListener('resize', bindVideoTexture);
|
||||
runtime.video = video;
|
||||
runtime.playback = attachCctvVideo(
|
||||
video,
|
||||
parts.frames.mediaUrlFor(record.camera),
|
||||
feedType,
|
||||
{
|
||||
onFailure: () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.video = null;
|
||||
runtime.mode = 'image';
|
||||
runtime.image = new Image();
|
||||
runtime.image.decoding = 'async';
|
||||
runtime.image.onload = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = true;
|
||||
runtime.imageStamp = Date.now();
|
||||
};
|
||||
runtime.image.onerror = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = false;
|
||||
};
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
@@ -254,7 +289,7 @@ export function createProjection({
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
|
||||
image: mode === 'video' && runtime.video ? runtime.video : canvas,
|
||||
image: canvas,
|
||||
transparent: true,
|
||||
color: Cesium.Color.WHITE.withAlpha(0.95),
|
||||
});
|
||||
@@ -271,7 +306,8 @@ export function createProjection({
|
||||
|
||||
function ensureProjectionRuntime(record) {
|
||||
if (!record) return null;
|
||||
if (record.projection) return record.projection;
|
||||
if (record.projection && !record.projection.disposed)
|
||||
return record.projection;
|
||||
const runtime = createProjectionRuntime(record);
|
||||
record.projection = runtime;
|
||||
if (runtime) {
|
||||
@@ -287,7 +323,15 @@ export function createProjection({
|
||||
*/
|
||||
|
||||
function destroyProjectionRuntime(runtime) {
|
||||
if (!runtime) return;
|
||||
if (!runtime || runtime.disposed) return;
|
||||
runtime.disposed = true;
|
||||
runtime.playback?.dispose();
|
||||
runtime.playback = null;
|
||||
if (runtime.image) {
|
||||
runtime.image.onload = null;
|
||||
runtime.image.onerror = null;
|
||||
runtime.image.src = '';
|
||||
}
|
||||
if (runtime.video) {
|
||||
runtime.video.pause();
|
||||
runtime.video.removeAttribute('src');
|
||||
@@ -324,10 +368,7 @@ export function createProjection({
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (layerState._enabled && layerState._showProjection && active) {
|
||||
ensureProjectionRuntime(active);
|
||||
if (active.projection?.video) {
|
||||
active.projection.video.play().catch(() => {});
|
||||
}
|
||||
if (active.projection) {
|
||||
if (active.projection && !active.projection.video) {
|
||||
parts.frames.drawProjectionFrame(active);
|
||||
parts.frames.refreshProjectionTextures(active);
|
||||
}
|
||||
@@ -355,22 +396,34 @@ export function createProjection({
|
||||
* @param {string|null} activeId - ID of the currently active camera.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The active camera's decoded <video>, for a second surface (the panel card)
|
||||
* to paint from. Null when the active feed is a still or not yet attached.
|
||||
* @returns {HTMLVideoElement|null}
|
||||
*/
|
||||
function getActiveVideoElement() {
|
||||
if (!layerState._enabled) return null;
|
||||
return parts.selection.getActiveRecord()?.projection?.video || null;
|
||||
}
|
||||
|
||||
function pauseInactiveProjectionFeeds(activeId) {
|
||||
for (const record of layerState._records) {
|
||||
if (!record.projection?.video) continue;
|
||||
if (
|
||||
record.camera.id === activeId &&
|
||||
layerState._enabled &&
|
||||
layerState._showProjection
|
||||
) {
|
||||
if (record.camera.id === activeId && layerState._enabled) {
|
||||
record.projection.video.play().catch(() => {});
|
||||
} else {
|
||||
record.projection.video.pause();
|
||||
const runtime = record.projection;
|
||||
destroyProjectionRuntime(runtime);
|
||||
record.projection = null;
|
||||
layerState._projectionEntities = layerState._projectionEntities.filter(
|
||||
(entry) => entry !== runtime,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
createCctvProjectionOverlayEntry,
|
||||
getActiveVideoElement,
|
||||
updatePlanePlacement,
|
||||
clearProjectionOverlay,
|
||||
setPlaneVisible,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
IDLE_CAMERA_COLOR,
|
||||
@@ -215,7 +216,12 @@ export function createRendering({
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
}
|
||||
|
||||
if (layerState._enabled && layerState._showProjection && isActive) {
|
||||
if (
|
||||
layerState._enabled &&
|
||||
isActive &&
|
||||
(layerState._showProjection ||
|
||||
parts.model.isVideoFeedType(record.camera.feedType))
|
||||
) {
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
}
|
||||
// One live plane in the world at a time (§2c): only the active camera's
|
||||
@@ -228,6 +234,15 @@ export function createRendering({
|
||||
);
|
||||
|
||||
const inVisibleSet = coverageVisible.has(record.camera.id);
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// draws its wireframe dashed so a guessed facing is visibly provisional
|
||||
// rather than rendering identically to a surveyed one (#639). Colors,
|
||||
// widths, and the active/idle emphasis are unchanged.
|
||||
const bearingEstimated = isHeadingEstimated(record.camera);
|
||||
const lineMaterial = (color) =>
|
||||
bearingEstimated
|
||||
? new Cesium.PolylineDashMaterialProperty({ color })
|
||||
: color;
|
||||
for (const entity of record.coverageEntities || []) {
|
||||
// The frustum wireframe is part of the projection representation —
|
||||
// force it on for the active camera and let it read through geometry
|
||||
@@ -242,13 +257,15 @@ export function createRendering({
|
||||
// active camera keeps its width/alpha emphasis in both schemes.
|
||||
const hue = viewshedOn ? record.viewshedColors : null;
|
||||
if (entity._coverageRole === 'cap') {
|
||||
entity.polyline.material = hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_CENTER
|
||||
: IDLE_COVERAGE_CENTER_MUTED;
|
||||
entity.polyline.material = lineMaterial(
|
||||
hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_CENTER
|
||||
: IDLE_COVERAGE_CENTER_MUTED,
|
||||
);
|
||||
entity.polyline.width = isActive ? 2.2 : 1.0;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
@@ -256,13 +273,15 @@ export function createRendering({
|
||||
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
|
||||
: undefined;
|
||||
} else {
|
||||
entity.polyline.material = hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line.withAlpha(0.6)
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_EDGE
|
||||
: IDLE_COVERAGE_EDGE_MUTED;
|
||||
entity.polyline.material = lineMaterial(
|
||||
hue
|
||||
? isActive
|
||||
? hue.lineActive
|
||||
: hue.line.withAlpha(0.6)
|
||||
: isActive
|
||||
? ACTIVE_COVERAGE_EDGE
|
||||
: IDLE_COVERAGE_EDGE_MUTED,
|
||||
);
|
||||
entity.polyline.width = isActive ? 1.8 : 0.9;
|
||||
entity.polyline.depthFailMaterial = planeShowing
|
||||
? hue
|
||||
|
||||
@@ -135,6 +135,10 @@ export function createSelection({
|
||||
// until update()'s one-shot tiles-ready completion pass re-grounds it.
|
||||
parts.ground.rearmGroundResolution(record);
|
||||
parts.geometry.updateRecordGeometry(record);
|
||||
// Ground under the plane's footprint (DEM, cached proxy) for cameras
|
||||
// without a shipped precompute for this pose — only the camera the user
|
||||
// is looking at ever asks.
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ACTIVE_FRAME_REFRESH_MS,
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
} from './policy.js';
|
||||
} from './sourcePolicy.js';
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const FRAME_ENDPOINT = '/api/cctv/frame';
|
||||
|
||||
export const SOURCE_ENDPOINT = '/api/cctv/sources';
|
||||
|
||||
export const HEALTH_ENDPOINT = '/api/cctv/health';
|
||||
|
||||
export const MEDIA_ENDPOINT = '/api/cctv/media';
|
||||
|
||||
export const ACTIVE_FRAME_REFRESH_MS = 10000;
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
|
||||
* contexts, so a page opened over plain HTTP from another machine (the
|
||||
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
|
||||
* and yields the same shape the server's lease check accepts.
|
||||
*/
|
||||
export function createLeaseId(cryptoImpl = globalThis.crypto) {
|
||||
if (typeof cryptoImpl.randomUUID === 'function') {
|
||||
return cryptoImpl.randomUUID();
|
||||
}
|
||||
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
|
||||
return [
|
||||
hex.slice(0, 4),
|
||||
hex.slice(4, 6),
|
||||
hex.slice(6, 8),
|
||||
hex.slice(8, 10),
|
||||
hex.slice(10),
|
||||
]
|
||||
.map((group) => group.join(''))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/** One decoder per active camera; both surfaces consume this video element. */
|
||||
export function attachCctvVideo(
|
||||
video,
|
||||
url,
|
||||
feedType,
|
||||
{
|
||||
loadHls = () => import('hls.js'),
|
||||
onFailure = () => {},
|
||||
fetchImpl = globalThis.fetch,
|
||||
cryptoImpl = globalThis.crypto,
|
||||
} = {},
|
||||
) {
|
||||
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
||||
video.loop = feedType !== 'hls';
|
||||
let disposed = false;
|
||||
let hls = null;
|
||||
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
|
||||
const mediaUrl =
|
||||
feedType === 'hls'
|
||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||
: url;
|
||||
let timer = null;
|
||||
let startup = null;
|
||||
let governor = null;
|
||||
let retries = 0;
|
||||
const release = () => {
|
||||
if (feedType === 'hls') {
|
||||
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
};
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(startup);
|
||||
clearInterval(governor);
|
||||
hls?.destroy();
|
||||
hls = null;
|
||||
video.removeEventListener('canplay', play);
|
||||
video.removeEventListener('error', fail);
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
release();
|
||||
};
|
||||
const fail = () => {
|
||||
if (disposed) return;
|
||||
dispose();
|
||||
onFailure();
|
||||
};
|
||||
const play = () => {
|
||||
if (disposed) return;
|
||||
clearTimeout(startup);
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
video.addEventListener('canplay', play);
|
||||
video.addEventListener('error', fail);
|
||||
startup = setTimeout(fail, 30000);
|
||||
const ready = (async () => {
|
||||
if (feedType !== 'hls') {
|
||||
video.src = mediaUrl;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { default: Hls } = await loadHls();
|
||||
if (disposed) return;
|
||||
if (!Hls.isSupported()) {
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl'))
|
||||
video.src = mediaUrl;
|
||||
else fail();
|
||||
return;
|
||||
}
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
maxBufferLength: 24,
|
||||
maxMaxBufferLength: 30,
|
||||
backBufferLength: 0,
|
||||
maxBufferSize: 16 * 1024 * 1024,
|
||||
liveSyncDurationCount: 3,
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (disposed || !data?.fatal) return;
|
||||
if (++retries > 2) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (disposed) return;
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
|
||||
else hls.loadSource(mediaUrl);
|
||||
}, 2000);
|
||||
});
|
||||
governor = setInterval(() => {
|
||||
if (disposed || !video.buffered?.length) return;
|
||||
const ahead =
|
||||
video.buffered.end(video.buffered.length - 1) - video.currentTime;
|
||||
// Some agency encoders publish less media time than wall time. Bound
|
||||
// correction rather than repeatedly draining the live buffer at 1x.
|
||||
video.playbackRate =
|
||||
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
|
||||
}, 1000);
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(mediaUrl);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
})();
|
||||
return { dispose, ready };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
|
||||
|
||||
const LEASE_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
/** What an insecure context exposes: getRandomValues but no randomUUID. */
|
||||
const insecureCrypto = {
|
||||
getRandomValues: (array) => webcrypto.getRandomValues(array),
|
||||
};
|
||||
function video() {
|
||||
const v = new EventTarget();
|
||||
Object.assign(v, {
|
||||
src: '',
|
||||
pause() {},
|
||||
load() {},
|
||||
removeAttribute() {},
|
||||
play: () => Promise.resolve(),
|
||||
});
|
||||
return v;
|
||||
}
|
||||
test('switch during lazy import never starts the stale decoder', async () => {
|
||||
let resolve;
|
||||
let constructed = 0;
|
||||
class Hls {
|
||||
constructor() {
|
||||
constructed++;
|
||||
}
|
||||
static isSupported() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const source = video();
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: () =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
});
|
||||
playback.dispose();
|
||||
resolve({ default: Hls });
|
||||
await playback.ready;
|
||||
assert.equal(constructed, 0);
|
||||
assert.equal(source.src, '');
|
||||
});
|
||||
test('missing decoder uses honest failure fallback once', async () => {
|
||||
let failures = 0;
|
||||
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => {
|
||||
throw new Error('unavailable');
|
||||
},
|
||||
onFailure: () => failures++,
|
||||
});
|
||||
await playback.ready;
|
||||
playback.dispose();
|
||||
assert.equal(failures, 1);
|
||||
});
|
||||
|
||||
test('native HLS releases its client lease without response-header access', async () => {
|
||||
const source = video();
|
||||
source.canPlayType = () => 'probably';
|
||||
const releases = [];
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||
fetchImpl: async (url, init) => {
|
||||
releases.push({ url, init });
|
||||
},
|
||||
});
|
||||
await playback.ready;
|
||||
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
|
||||
const requested = source.src;
|
||||
playback.dispose();
|
||||
playback.dispose();
|
||||
assert.equal(releases.length, 1);
|
||||
assert.equal(releases[0].url, requested);
|
||||
assert.equal(releases[0].init.method, 'DELETE');
|
||||
});
|
||||
|
||||
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
|
||||
assert.match(createLeaseId(), LEASE_V4);
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
|
||||
assert.equal(ids.size, 64);
|
||||
for (const id of ids) assert.match(id, LEASE_V4);
|
||||
});
|
||||
|
||||
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
|
||||
const source = video();
|
||||
source.canPlayType = () => 'probably';
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||
fetchImpl: async () => {},
|
||||
cryptoImpl: insecureCrypto,
|
||||
});
|
||||
try {
|
||||
await playback.ready;
|
||||
const lease = new URL(
|
||||
source.src,
|
||||
'http://192.168.1.10:4173/',
|
||||
).searchParams.get('lease');
|
||||
assert.match(lease, LEASE_V4);
|
||||
} finally {
|
||||
playback.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
||||
for (const feedType of ['mp4', 'webm', 'hls']) {
|
||||
const source = video();
|
||||
source.loop = feedType === 'hls';
|
||||
source.canPlayType = () => 'probably';
|
||||
let imports = 0;
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
|
||||
loadHls: async () => {
|
||||
imports++;
|
||||
return { default: { isSupported: () => false } };
|
||||
},
|
||||
fetchImpl: async () => {},
|
||||
});
|
||||
try {
|
||||
await playback.ready;
|
||||
assert.equal(source.loop, feedType !== 'hls');
|
||||
assert.equal(imports, feedType === 'hls' ? 1 : 0);
|
||||
} finally {
|
||||
playback.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import {
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
} from '../../data/pickRegistry.js';
|
||||
import {
|
||||
createCycloneRendering,
|
||||
coherentCycloneGeometry,
|
||||
} from './rendering.js';
|
||||
import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
});
|
||||
const classificationName = (code) =>
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Cyclones require a snapshot source');
|
||||
let viewer = null,
|
||||
rendering = null,
|
||||
snapshot = null,
|
||||
request = null,
|
||||
listener = null,
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
loading = false,
|
||||
error = null,
|
||||
destroyed = false,
|
||||
runNavigation = null;
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
rendering?.setSelection(id);
|
||||
}
|
||||
// Photorealistic 3D Tiles pick as tileset content without an entity id;
|
||||
// that is empty map, the same as no pick at all on the globe.
|
||||
const isSurfacePick = (picked) =>
|
||||
!picked ||
|
||||
(picked.id === undefined &&
|
||||
(picked.content !== undefined ||
|
||||
(typeof cesium.Cesium3DTileset === 'function' &&
|
||||
picked.primitive instanceof cesium.Cesium3DTileset)));
|
||||
function installSelection() {
|
||||
if (
|
||||
clickHandler ||
|
||||
!viewer?.scene?.canvas ||
|
||||
typeof cesium.ScreenSpaceEventHandler !== 'function'
|
||||
)
|
||||
return;
|
||||
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
clickHandler = owner;
|
||||
// Snapshot before sibling bubble listeners can rebuild the overlay hit
|
||||
// rectangles. A vessel selection does that synchronously in the same click.
|
||||
const canvas = viewer.scene.canvas;
|
||||
let capturedHit = null;
|
||||
const overlayHit = (x, y) => {
|
||||
const hit = hitTestOverlay(x, y);
|
||||
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
|
||||
};
|
||||
const capture = (event) => {
|
||||
capturedHit = null;
|
||||
const point = event.changedTouches?.[0] || event;
|
||||
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
|
||||
return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = point.clientX - rect.left,
|
||||
y = point.clientY - rect.top;
|
||||
capturedHit = { x, y, ...overlayHit(x, y) };
|
||||
};
|
||||
const resetCapture = () => {
|
||||
capturedHit = null;
|
||||
};
|
||||
const resetEvents = [
|
||||
'pointerdown',
|
||||
'mousedown',
|
||||
'touchstart',
|
||||
'pointercancel',
|
||||
'touchcancel',
|
||||
];
|
||||
for (const type of resetEvents)
|
||||
canvas.addEventListener?.(type, resetCapture, { capture: true });
|
||||
const events = ['pointerup', 'mouseup', 'touchend'];
|
||||
for (const type of events)
|
||||
canvas.addEventListener?.(type, capture, { capture: true });
|
||||
removeClickCapture = () => {
|
||||
for (const type of events)
|
||||
canvas.removeEventListener?.(type, capture, { capture: true });
|
||||
for (const type of resetEvents)
|
||||
canvas.removeEventListener?.(type, resetCapture, { capture: true });
|
||||
capturedHit = null;
|
||||
};
|
||||
owner.setInputAction((click) => {
|
||||
const nativeHit = capturedHit;
|
||||
capturedHit = null;
|
||||
// Ambient selection yields to draw tools and Director; it never claims
|
||||
// the pointer, camera, or tracking state.
|
||||
if (
|
||||
!enabled ||
|
||||
destroyed ||
|
||||
clickHandler !== owner ||
|
||||
!isPointerFree() ||
|
||||
!click?.position
|
||||
)
|
||||
return;
|
||||
// AIS cards paint above the globe on a pointer-events:none canvas. The
|
||||
// vessel handler resolves this same topmost hit before cyclone geometry.
|
||||
const captureMatches =
|
||||
nativeHit &&
|
||||
Math.abs(nativeHit.x - click.position.x) < 1 &&
|
||||
Math.abs(nativeHit.y - click.position.y) < 1;
|
||||
const hit = captureMatches
|
||||
? nativeHit
|
||||
: overlayHit(click.position.x, click.position.y);
|
||||
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
|
||||
// Storm cards and lead-hour labels paint on the same canvas; a click on
|
||||
// one selects its storm. An id from a superseded advisory changes nothing.
|
||||
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
|
||||
const id = cycloneStormIdFromEntryId(hit.entryId);
|
||||
if (id) layer.setParams({ stormId: id });
|
||||
return;
|
||||
}
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = rendering?.pickStorm(picked);
|
||||
if (id) layer.setParams({ stormId: id });
|
||||
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
|
||||
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
function removeSelection() {
|
||||
const owner = clickHandler;
|
||||
clickHandler = null;
|
||||
removeClickCapture?.();
|
||||
removeClickCapture = null;
|
||||
if (owner && !owner.isDestroyed?.()) owner.destroy();
|
||||
}
|
||||
const layer = {
|
||||
id: 'weather-cyclones',
|
||||
name: 'Cyclone advisories',
|
||||
icon: '◉',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
updateInterval: 300_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({ viewer, cesium, overlayHost });
|
||||
},
|
||||
attachShellServices(services) {
|
||||
runNavigation =
|
||||
typeof services?.runNavigation === 'function'
|
||||
? services.runNavigation
|
||||
: null;
|
||||
notify();
|
||||
},
|
||||
enable() {
|
||||
if (!destroyed && !enabled) {
|
||||
enabled = true;
|
||||
registerPickOwner(
|
||||
'weather-cyclones',
|
||||
(id) => enabled && rendering?.ownsPickId?.(id) === true,
|
||||
);
|
||||
installSelection();
|
||||
}
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
unregisterPickOwner('weather-cyclones');
|
||||
removeSelection();
|
||||
request?.abort();
|
||||
request = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (!enabled || destroyed) return false;
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
request = controller;
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const next = await feed.getSnapshot({ signal: controller.signal });
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (next.unavailable) {
|
||||
// An expired advisory must not remain presented as current hazard context.
|
||||
rendering.clear();
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
error = next.reason || 'Cyclone advisories unavailable';
|
||||
return true;
|
||||
}
|
||||
const applied = await rendering.setSnapshot(next, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
!applied ||
|
||||
!enabled ||
|
||||
controller.signal.aborted ||
|
||||
request !== controller
|
||||
)
|
||||
return false;
|
||||
snapshot = next;
|
||||
error = null;
|
||||
if (!snapshot.storms.some((storm) => storm.id === selectedId))
|
||||
select(
|
||||
selectionIntent === 'cleared'
|
||||
? null
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Cyclone advisories unavailable';
|
||||
// Failed acquisition has no bounded last-good age guarantee at this layer.
|
||||
rendering?.clear();
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
if (!enabled || destroyed) return;
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
snapshot?.storms.some((storm) => storm.id === params.stormId)
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
if (params.focus === true && storm && runNavigation) {
|
||||
const sphere = rendering.getFocusSphere(storm.id);
|
||||
if (sphere) {
|
||||
const generation = ++navigationGeneration;
|
||||
runNavigation(() => {
|
||||
if (
|
||||
!enabled ||
|
||||
destroyed ||
|
||||
generation !== navigationGeneration ||
|
||||
selectedId !== storm.id
|
||||
)
|
||||
return;
|
||||
return viewer.camera.flyToBoundingSphere(sphere, {
|
||||
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
?.matches
|
||||
? 0
|
||||
: 1.4,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if (params.advisory === true && storm?.advisoryUrl)
|
||||
openLink(storm.advisoryUrl);
|
||||
},
|
||||
getRowControls() {
|
||||
const storm = selected();
|
||||
const empty =
|
||||
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
const status =
|
||||
error ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: storm && !coherentCycloneGeometry(storm)
|
||||
? geometry
|
||||
: null);
|
||||
const detail = storm
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
: detail,
|
||||
actions: storm?.advisoryUrl
|
||||
? [
|
||||
{
|
||||
id: 'advisory',
|
||||
label: 'Official advisory ↗',
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
settings: [],
|
||||
lines: storm
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
: [],
|
||||
detail,
|
||||
status,
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
items: (snapshot?.storms || []).map((item, index) => ({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
},
|
||||
chips: [],
|
||||
legendCategorical: true,
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
||||
};
|
||||
return controls;
|
||||
},
|
||||
setRowControlsListener(value) {
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
/**
|
||||
* Snapshot loaded active storms as plain JSON-safe records for the analyst
|
||||
* query engine. On demand only (once per spoken query); [] while the
|
||||
* layer is off.
|
||||
* @param {number} [maxCount=Infinity] Most records to return.
|
||||
* @returns {Array<Object>} {id, name, classification, basin, lat, lon, windKt, pressureHpa}.
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!enabled || destroyed) return [];
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const result = [];
|
||||
for (const storm of snapshot?.storms || []) {
|
||||
if (result.length >= maxCount) break;
|
||||
const lat = storm.position?.latitude;
|
||||
const lon = storm.position?.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
result.push({
|
||||
id: storm.name || storm.id,
|
||||
name: storm.name || null,
|
||||
classification: storm.classification || null,
|
||||
basin: storm.basin || null,
|
||||
lat,
|
||||
lon,
|
||||
windKt: num(storm.windKt),
|
||||
pressureHpa: num(storm.pressureHpa),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
getStats() {
|
||||
const storm = selected();
|
||||
return {
|
||||
count: snapshot?.storms.length || 0,
|
||||
lastUpdate: storm
|
||||
? Date.parse(storm.issuedAt)
|
||||
: snapshot?.fetchedAt || null,
|
||||
loading,
|
||||
error,
|
||||
stale: Boolean(snapshot?.stale),
|
||||
source: 'NOAA NHC / CPHC',
|
||||
advisoryAt: storm?.issuedAt || null,
|
||||
empty: Boolean(
|
||||
snapshot && !snapshot.unavailable && !snapshot.storms.length,
|
||||
),
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
enabled,
|
||||
loading,
|
||||
requestPending: !!request,
|
||||
selectionActive: clickHandler !== null,
|
||||
selectedId,
|
||||
selectionIntent,
|
||||
timerActive: false,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
layer.disable();
|
||||
destroyed = true;
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
viewer = null;
|
||||
listener = null;
|
||||
runNavigation = null;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCyclonesLayer } from './index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
releasePointer,
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
id,
|
||||
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
|
||||
classification: 'PTC',
|
||||
basin: 'EP',
|
||||
position: { longitude: -125.8, latitude: 15.5 },
|
||||
positionAt: time,
|
||||
issuedAt: time,
|
||||
advisoryNumber: '10',
|
||||
windKt: 25,
|
||||
pressureHpa: 1006,
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
});
|
||||
const snapshot = (storms = [storm()]) => ({
|
||||
schemaVersion: 1,
|
||||
storms,
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
fetchedAt: Date.parse(time),
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
reason: null,
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
|
||||
const handlers = [],
|
||||
pickedEntity = {},
|
||||
foreignEntity = {};
|
||||
let picked = { id: pickedEntity },
|
||||
overlayHit = null,
|
||||
overlayTests = 0,
|
||||
picks = 0,
|
||||
notices = 0;
|
||||
const rendering = {
|
||||
setSnapshot: async () => true,
|
||||
setSelection() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
|
||||
ownsPickId: (id) => id === 'cyclone:ep162026:center',
|
||||
getDiagnostics: () => ({}),
|
||||
};
|
||||
const cesium = {
|
||||
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
|
||||
ScreenSpaceEventHandler: class {
|
||||
constructor(canvas) {
|
||||
assert.ok(canvas);
|
||||
handlers.push(this);
|
||||
this.destroyed = false;
|
||||
}
|
||||
setInputAction(callback, type) {
|
||||
assert.equal(type, 'left');
|
||||
this.click = callback;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
},
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
cesium,
|
||||
createRendering: () => rendering,
|
||||
hitTestOverlay: () => {
|
||||
overlayTests++;
|
||||
return overlayHit;
|
||||
},
|
||||
});
|
||||
const canvas = new EventTarget();
|
||||
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
|
||||
const viewer = {
|
||||
scene: {
|
||||
canvas,
|
||||
pick() {
|
||||
picks++;
|
||||
return picked;
|
||||
},
|
||||
},
|
||||
trackedEntity: null,
|
||||
camera: {
|
||||
flyToBoundingSphere() {
|
||||
assert.fail('Click must not move the camera');
|
||||
},
|
||||
},
|
||||
};
|
||||
layer.init(viewer);
|
||||
layer.setRowControlsListener(() => notices++);
|
||||
assert.equal(handlers.length, 0, 'disabled module installs no handler');
|
||||
layer.enable();
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
true,
|
||||
'flight tracking recognizes the cyclone pick and leaves the camera alone',
|
||||
);
|
||||
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
|
||||
false,
|
||||
'registration recognizes exact current IDs, not a prefix',
|
||||
);
|
||||
assert.equal(handlers.length, 1, 'enable is idempotent');
|
||||
await layer.update();
|
||||
const click = { position: { x: 10, y: 20 } };
|
||||
const owner = claimPointer('director');
|
||||
assert.ok(owner);
|
||||
try {
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, 0, 'claimed pointer is checked before picking');
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
} finally {
|
||||
releasePointer(owner);
|
||||
}
|
||||
picked = { id: foreignEntity };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
picked = { primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
|
||||
picked = { id: pickedEntity };
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
const beforeCard = picks;
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
picks,
|
||||
beforeCard,
|
||||
'foreground AIS card wins before scene picking',
|
||||
);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// A native capture runs before an earlier sibling handler clears hit rects.
|
||||
const up = new Event('pointerup');
|
||||
Object.assign(up, { clientX: 110, clientY: 220 });
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep152026',
|
||||
'captured AIS hit survives synchronous sibling overlay rebuild',
|
||||
);
|
||||
// A captured background hit remains background even if a sibling adds a card.
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
// A new gesture clears an unconsumed release snapshot.
|
||||
canvas.dispatchEvent(up);
|
||||
canvas.dispatchEvent(new Event('pointerdown'));
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click({ position: { x: 15, y: 25 } });
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep162026',
|
||||
'a different click cannot reuse the captured card',
|
||||
);
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
|
||||
const before = notices;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.match(layer.getRowControls().summary.detail, /Another system/);
|
||||
assert.equal(layer.getRowControls().list.items[1].active, true);
|
||||
assert.ok(notices > before);
|
||||
assert.equal(viewer.trackedEntity, null);
|
||||
picked = undefined;
|
||||
overlayHit = { sourceId: 'ais-live-vessels' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
picked = { id: pickedEntity };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
picked = { id: 'flight:abc', primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep162026',
|
||||
"another layer's pick leaves the selection alone",
|
||||
);
|
||||
picked = { content: {}, primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
null,
|
||||
'3D Tiles surface content is empty map',
|
||||
);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(
|
||||
isPointerFree(),
|
||||
true,
|
||||
'ambient selection never claims the pointer',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
false,
|
||||
);
|
||||
assert.equal(handlers[0].destroyed, true);
|
||||
assert.equal(layer.getDiagnostics().selectionActive, false);
|
||||
const afterDisable = picks;
|
||||
const afterDisableOverlay = overlayTests;
|
||||
canvas.dispatchEvent(up);
|
||||
assert.equal(
|
||||
overlayTests,
|
||||
afterDisableOverlay,
|
||||
'disable removes native capture listeners',
|
||||
);
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
picks,
|
||||
afterDisable,
|
||||
'superseded handler remains inert after re-enable',
|
||||
);
|
||||
assert.equal(handlers.length, 2);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
false,
|
||||
);
|
||||
assert.equal(handlers[1].destroyed, true);
|
||||
});
|
||||
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
|
||||
const handlers = [];
|
||||
let picks = 0,
|
||||
overlayHit = null,
|
||||
hostArgument;
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
cesium: {
|
||||
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
|
||||
ScreenSpaceEventHandler: class {
|
||||
constructor() {
|
||||
handlers.push(this);
|
||||
}
|
||||
setInputAction(callback) {
|
||||
this.click = callback;
|
||||
}
|
||||
destroy() {}
|
||||
},
|
||||
},
|
||||
overlayHost: 'host',
|
||||
createRendering: ({ overlayHost }) => {
|
||||
hostArgument = overlayHost;
|
||||
return {
|
||||
setSnapshot: async () => true,
|
||||
setSelection() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
pickStorm: () => null,
|
||||
ownsPickId: () => false,
|
||||
getDiagnostics: () => ({}),
|
||||
};
|
||||
},
|
||||
hitTestOverlay: () => overlayHit,
|
||||
});
|
||||
const canvas = new EventTarget();
|
||||
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
||||
layer.init({
|
||||
scene: {
|
||||
canvas,
|
||||
pick() {
|
||||
picks++;
|
||||
return { content: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
const click = { position: { x: 10, y: 20 } };
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// A card from a superseded advisory is not empty map.
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// The native release snapshot carries the card identity too.
|
||||
const up = new Event('pointerup');
|
||||
Object.assign(up, { clientX: 10, clientY: 20 });
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.equal(picks, 0, 'card clicks never fall through to the scene');
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
|
||||
layer.destroy();
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
opened = [];
|
||||
let cleared = 0,
|
||||
destroyed = 0,
|
||||
selection = null;
|
||||
const rendering = {
|
||||
async setSnapshot(value) {
|
||||
applied.push(value);
|
||||
return true;
|
||||
},
|
||||
setSelection(id) {
|
||||
selection = id;
|
||||
},
|
||||
getFocusSphere: () => ({ radius: 500000 }),
|
||||
clear() {
|
||||
cleared++;
|
||||
},
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
getDiagnostics: () => ({
|
||||
entities: applied.at(-1)?.storms.length || 0,
|
||||
timerActive: false,
|
||||
}),
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
});
|
||||
const viewer = {
|
||||
camera: {
|
||||
flyToBoundingSphere: (sphere, options) =>
|
||||
navigation.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
layer.init(viewer);
|
||||
layer.attachShellServices({
|
||||
runNavigation: (fn) => {
|
||||
navigation.push('claimed');
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
return {
|
||||
layer,
|
||||
applied,
|
||||
navigation,
|
||||
opened,
|
||||
get cleared() {
|
||||
return cleared;
|
||||
},
|
||||
get destroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
get selection() {
|
||||
return selection;
|
||||
},
|
||||
};
|
||||
}
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
next = snapshot([storm('ep162026')]);
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
next = snapshot([storm(), storm('ep162026')]);
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
|
||||
assert.equal(h.navigation.length, 0);
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
|
||||
for (const clear of [{ stormId: null }, { clear: true }]) {
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.setParams(clear);
|
||||
assert.equal(h.selection, null);
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, null);
|
||||
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
|
||||
}
|
||||
h.layer.disable();
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
|
||||
const staged = deferred();
|
||||
const staging = deferred();
|
||||
let calls = 0;
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot() },
|
||||
createRendering: () => ({
|
||||
setSnapshot() {
|
||||
if (++calls === 1) return true;
|
||||
staging.resolve();
|
||||
return staged.promise;
|
||||
},
|
||||
setSelection() {},
|
||||
getDiagnostics: () => ({}),
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
layer.init({});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
const pending = layer.update();
|
||||
await staging.promise;
|
||||
layer.setParams({ clear: true });
|
||||
staged.resolve(true);
|
||||
await pending;
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
|
||||
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
const callbacks = [];
|
||||
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
if (change === 'select') {
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
} else if (change === 'clear') h.layer.setParams({ stormId: null });
|
||||
else if (change === 'fallback') {
|
||||
next = snapshot([storm('ep162026')]);
|
||||
await h.layer.update();
|
||||
} else h.layer[change]();
|
||||
callbacks.shift()();
|
||||
assert.equal(h.navigation.length, 0, change);
|
||||
h.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
|
||||
const h = harness();
|
||||
const callbacks = [];
|
||||
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
await h.layer.update();
|
||||
callbacks[0]();
|
||||
assert.equal(h.navigation.length, 0);
|
||||
callbacks[1]();
|
||||
assert.equal(h.navigation.length, 1);
|
||||
assert.equal(h.navigation[0].options.duration, 1.4);
|
||||
h.layer.destroy();
|
||||
});
|
||||
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
|
||||
for (const [code, label] of [
|
||||
['PTC', 'Potential tropical cyclone'],
|
||||
['HU', 'Hurricane'],
|
||||
['TS', 'Tropical storm'],
|
||||
['TD', 'Tropical depression'],
|
||||
['SS', 'Subtropical storm'],
|
||||
['SD', 'Subtropical depression'],
|
||||
['EX', 'EX'],
|
||||
['UNKNOWN', 'UNKNOWN'],
|
||||
]) {
|
||||
const record = { ...storm(), classification: code };
|
||||
const h = harness({ getSnapshot: async () => snapshot([record]) });
|
||||
try {
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.ok(controls.summary.detail.includes(` · ${label} · `));
|
||||
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
|
||||
assert.equal(record.classification, code);
|
||||
assert.equal(h.applied[0].storms[0].classification, code);
|
||||
} finally {
|
||||
h.layer.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
{ reducedMotion: true },
|
||||
);
|
||||
h.layer.enable();
|
||||
assert.equal(await h.layer.update(), true);
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.equal(controls.readout, true);
|
||||
assert.deepEqual(controls.chips, []);
|
||||
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
|
||||
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
|
||||
assert.equal(controls.summary.lines.length, 3);
|
||||
assert.deepEqual(controls.summary.settings, []);
|
||||
assert.deepEqual(controls.summary.actions, [
|
||||
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
|
||||
]);
|
||||
assert.match(controls.summary.compact, /2 active storms · .* selected/);
|
||||
assert.equal(controls.summary.sections, undefined);
|
||||
assert.match(controls.list.ariaLabel, /NHC/);
|
||||
assert.equal(controls.list.items.length, 2);
|
||||
assert.equal(controls.list.items[0].active, true);
|
||||
assert.match(controls.info, /awaiting advisory 10/);
|
||||
assert.equal(
|
||||
controls.info.split('Track/cone awaiting advisory 10').length - 1,
|
||||
1,
|
||||
);
|
||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
||||
assert.match(controls.info, /09-16 03:00 UTC/);
|
||||
assert.match(controls.infoTitle, /not storm size/);
|
||||
assert.match(controls.infoTitle, /follows the surface/);
|
||||
assert.equal(controls.list.items[1].params.focus, true);
|
||||
assert.equal(
|
||||
controls.chips.some((chip) => chip.id === 'focus'),
|
||||
false,
|
||||
);
|
||||
h.layer.setParams(controls.list.items[1].params);
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.navigation[0], 'claimed');
|
||||
assert.equal(h.navigation[1].options.duration, 0);
|
||||
h.layer.setParams(controls.list.items[1].params);
|
||||
assert.equal(h.navigation.length, 4, 'selected row focuses again');
|
||||
h.layer.setParams({ advisory: true });
|
||||
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
h.layer.disable();
|
||||
h.layer.setParams({ focus: true, advisory: true });
|
||||
assert.equal(h.navigation.length, 4);
|
||||
assert.equal(h.opened.length, 1);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||||
h.layer.destroy();
|
||||
assert.equal(h.destroyed, 1);
|
||||
});
|
||||
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
|
||||
let next = snapshot([]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().empty, true);
|
||||
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
assert.match(h.layer.getRowControls().summary.status, /stale/);
|
||||
assert.match(
|
||||
h.layer.getRowControls().info,
|
||||
/Track\/cone awaiting advisory 10/,
|
||||
);
|
||||
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().empty, false);
|
||||
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
|
||||
assert.ok(h.cleared > 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
|
||||
const first = deferred(),
|
||||
second = deferred();
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const h = harness({
|
||||
getSnapshot: ({ signal }) => {
|
||||
signals.push(signal);
|
||||
return (++calls === 1 ? first : second).promise;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
const old = h.layer.update(),
|
||||
latest = h.layer.update();
|
||||
assert.equal(signals[0].aborted, true);
|
||||
second.resolve(snapshot());
|
||||
assert.equal(await latest, true);
|
||||
first.resolve(snapshot([storm('ep162026')]));
|
||||
assert.equal(await old, false);
|
||||
assert.equal(h.applied.length, 1);
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
h.layer.destroy();
|
||||
const delayed = deferred();
|
||||
const disabled = harness({ getSnapshot: () => delayed.promise });
|
||||
disabled.layer.enable();
|
||||
const work = disabled.layer.update();
|
||||
disabled.layer.disable();
|
||||
delayed.resolve(snapshot());
|
||||
assert.equal(await work, false);
|
||||
assert.equal(disabled.applied.length, 0);
|
||||
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
|
||||
disabled.layer.destroy();
|
||||
});
|
||||
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return snapshot();
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
assert.equal(
|
||||
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
|
||||
false,
|
||||
);
|
||||
assert.equal(h.applied.length, 0);
|
||||
await h.layer.update();
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
assert.match(h.layer.getStats().error, /Network/);
|
||||
assert.ok(h.cleared > 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* @module cyclones/labels
|
||||
* @description Storm cards and lead-hour labels on the shared world-overlay
|
||||
* host. The renderer supplies the anchors its points are drawn and
|
||||
* horizon-culled at, plus the selection; the host owns projection, the same
|
||||
* ellipsoid horizon test, collisions, fades and paint.
|
||||
*/
|
||||
|
||||
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
|
||||
/** Lead-hour labels show within 4,000 km of the camera. */
|
||||
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
|
||||
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
|
||||
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
|
||||
/** Advisory centre and forecast track; single source for points and cards. */
|
||||
export const CYCLONE_ACCENT = '#7fe6ed';
|
||||
/** Selected advisory centre. */
|
||||
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
|
||||
/** Point sizes the cards keep clear of. */
|
||||
export const CYCLONE_MARKER_PX = Object.freeze({
|
||||
storm: 9,
|
||||
selected: 12,
|
||||
forecast: 5,
|
||||
});
|
||||
|
||||
const STORM_PRIORITY = 1000;
|
||||
const LEAD_PRIORITY = 500;
|
||||
|
||||
const CYCLONE_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
|
||||
function markerClearance(markerPx) {
|
||||
const gapPx = Math.max(12, markerPx + 8);
|
||||
return {
|
||||
gapPx,
|
||||
leaderOffsetPx: Math.max(2, gapPx - 6),
|
||||
verticalOnly: true,
|
||||
viewportMargin: 4,
|
||||
};
|
||||
}
|
||||
|
||||
const SHARED_CARD = Object.freeze({
|
||||
cardStyle: 'tactical',
|
||||
collisionGroup: 'ambient-card',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// A click on a card selects its storm.
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* One card per storm: name, then the source classification code.
|
||||
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
|
||||
* @param {boolean} selected
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneStormEntry(storm, selected) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(
|
||||
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
|
||||
),
|
||||
id: `storm:${storm.id}`,
|
||||
position: storm.position,
|
||||
variant: selected ? 'selected' : 'card',
|
||||
title: storm.name,
|
||||
details: storm.classification ? [storm.classification] : [],
|
||||
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
|
||||
selected,
|
||||
protected: selected,
|
||||
priority: selected
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: STORM_PRIORITY + (Number(storm.windKt) || 0),
|
||||
maxDistance: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead-hour label at one forecast point of the selected storm. Earlier lead
|
||||
* times win collisions.
|
||||
* @param {string} stormId
|
||||
* @param {{tauHours:number,position:object}} point
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneLeadEntry(stormId, point) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(CYCLONE_MARKER_PX.forecast),
|
||||
id: `lead:${stormId}:${point.tauHours}`,
|
||||
position: point.position,
|
||||
variant: 'card',
|
||||
title: `${point.tauHours} h`,
|
||||
details: [],
|
||||
accent: CYCLONE_ACCENT,
|
||||
selected: false,
|
||||
protected: false,
|
||||
priority: LEAD_PRIORITY - point.tauHours,
|
||||
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
|
||||
// Hard cutoff at the limit, without a fade.
|
||||
distanceFadeStartRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Storm cards for every storm and lead-hour labels for the selected one.
|
||||
* @param {Array<object>} storms Renderer anchors with `forecasts`.
|
||||
* @param {?string} selectedId
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function cycloneOverlayEntries(storms, selectedId) {
|
||||
const entries = [];
|
||||
for (const storm of storms) {
|
||||
const selected = storm.id === selectedId;
|
||||
entries.push(cycloneStormEntry(storm, selected));
|
||||
if (!selected) continue;
|
||||
for (const point of storm.forecasts || [])
|
||||
entries.push(cycloneLeadEntry(storm.id, point));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Storm id behind a published card or lead-hour label id, else null. */
|
||||
export function cycloneStormIdFromEntryId(entryId) {
|
||||
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the renderer's committed anchors and selection to the overlay host.
|
||||
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
|
||||
*/
|
||||
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
|
||||
let storms = [];
|
||||
let selectedId = null;
|
||||
let published = false;
|
||||
function publish() {
|
||||
const entries = cycloneOverlayEntries(storms, selectedId);
|
||||
let ambient = 0;
|
||||
for (const entry of entries) if (!entry.protected) ambient++;
|
||||
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
|
||||
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
|
||||
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
|
||||
moving: false,
|
||||
});
|
||||
published = true;
|
||||
}
|
||||
return {
|
||||
/** Replace every anchor after a snapshot commits. */
|
||||
setSnapshot(nextStorms, nextSelectedId = selectedId) {
|
||||
storms = nextStorms;
|
||||
selectedId = nextSelectedId;
|
||||
publish();
|
||||
},
|
||||
/** Republish only when the selection changes a published snapshot. */
|
||||
setSelection(id) {
|
||||
if (id === selectedId) return;
|
||||
selectedId = id;
|
||||
if (published) publish();
|
||||
},
|
||||
clear() {
|
||||
storms = [];
|
||||
selectedId = null;
|
||||
if (!published) return;
|
||||
published = false;
|
||||
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
|
||||
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
|
||||
CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
createCycloneLabels,
|
||||
cycloneOverlayEntries,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import {
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
WORLD_OVERLAY_PAINT_LANES,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
|
||||
|
||||
const anchor = (longitude, latitude) =>
|
||||
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
|
||||
const storms = () => [
|
||||
{
|
||||
id: 'al062026',
|
||||
name: 'Fay',
|
||||
classification: 'TS',
|
||||
windKt: 50,
|
||||
position: anchor(-60, 20),
|
||||
forecasts: [
|
||||
{ tauHours: 12, position: anchor(-61, 21) },
|
||||
{ tauHours: 24, position: anchor(-62, 22) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
windKt: null,
|
||||
position: anchor(-125, 15),
|
||||
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
|
||||
},
|
||||
];
|
||||
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
|
||||
|
||||
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
|
||||
const input = storms();
|
||||
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
|
||||
assert.deepEqual(
|
||||
[...entries.keys()],
|
||||
[
|
||||
'storm:al062026',
|
||||
'lead:al062026:12',
|
||||
'lead:al062026:24',
|
||||
'storm:ep152026',
|
||||
],
|
||||
'lead-hour labels belong to the selected storm only',
|
||||
);
|
||||
const selected = entries.get('storm:al062026');
|
||||
const ambient = entries.get('storm:ep152026');
|
||||
for (const card of [selected, ambient]) {
|
||||
assert.equal(card.cardStyle, 'tactical');
|
||||
assert.equal(card.collisionGroup, 'ambient-card');
|
||||
assert.equal(card.verticalOnly, true);
|
||||
assert.equal(card.horizonCull, true);
|
||||
assert.equal(card.edgeFade, 'keyhole');
|
||||
assert.equal(card.interactive, true);
|
||||
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
|
||||
assert.equal(selected.title, 'Fay');
|
||||
assert.deepEqual(selected.details, ['TS']);
|
||||
assert.equal(selected.variant, 'selected');
|
||||
assert.equal(selected.selected, true);
|
||||
assert.equal(selected.protected, true);
|
||||
assert.equal(selected.accent, '#ffe19a');
|
||||
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
|
||||
assert.equal(selected.gapPx, 20);
|
||||
assert.equal(selected.leaderOffsetPx, 14);
|
||||
assert.equal(ambient.title, 'Fifteen-E');
|
||||
assert.deepEqual(ambient.details, ['PTC']);
|
||||
assert.equal(ambient.variant, 'card');
|
||||
assert.equal(ambient.protected, false);
|
||||
assert.equal(ambient.accent, '#7fe6ed');
|
||||
assert.equal(ambient.gapPx, 17);
|
||||
assert.equal(ambient.leaderOffsetPx, 11);
|
||||
|
||||
const lead = entries.get('lead:al062026:24');
|
||||
assert.equal(lead.position, input[0].forecasts[1].position);
|
||||
assert.equal(lead.title, '24 h');
|
||||
assert.deepEqual(lead.details, []);
|
||||
assert.equal(lead.cardStyle, 'tactical');
|
||||
assert.equal(lead.collisionGroup, 'ambient-card');
|
||||
assert.equal(lead.protected, false);
|
||||
assert.equal(lead.interactive, true);
|
||||
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
|
||||
assert.equal(lead.gapPx, 13);
|
||||
assert.equal(lead.leaderOffsetPx, 7);
|
||||
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
|
||||
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
|
||||
|
||||
const none = cycloneOverlayEntries(input, null);
|
||||
assert.deepEqual(
|
||||
none.map((entry) => [entry.id, entry.variant]),
|
||||
[
|
||||
['storm:al062026', 'card'],
|
||||
['storm:ep152026', 'card'],
|
||||
],
|
||||
);
|
||||
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
|
||||
});
|
||||
|
||||
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
|
||||
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
|
||||
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
|
||||
);
|
||||
const lane = (entry) =>
|
||||
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
|
||||
const [selected, lead] = entries;
|
||||
assert.equal(lane(selected), 'selected');
|
||||
assert.equal(lane(lead), 'ambient-card');
|
||||
assert.equal(lane(entries.at(-1)), 'ambient-card');
|
||||
for (const entry of entries) {
|
||||
assert.equal(entry.collisionGroup, 'ambient-card');
|
||||
assert.equal(entry.cardStyle, 'tactical');
|
||||
assert.equal(entry.horizonCull, true);
|
||||
}
|
||||
const range = {
|
||||
minDistance: lead.minDistance,
|
||||
maxDistance: lead.maxDistance,
|
||||
fadeStartRatio: lead.distanceFadeStartRatio,
|
||||
};
|
||||
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
|
||||
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
|
||||
assert.equal(
|
||||
distanceFade(40_000_000, {
|
||||
minDistance: selected.minDistance,
|
||||
maxDistance: selected.maxDistance,
|
||||
fadeStartRatio: selected.distanceFadeStartRatio,
|
||||
}),
|
||||
1,
|
||||
'storm cards have no distance limit',
|
||||
);
|
||||
});
|
||||
|
||||
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
|
||||
const calls = [];
|
||||
const host = {
|
||||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
};
|
||||
const labels = createCycloneLabels({ host });
|
||||
labels.setSelection('al062026');
|
||||
labels.clear();
|
||||
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
|
||||
|
||||
labels.setSnapshot(storms(), 'al062026');
|
||||
assert.deepEqual(
|
||||
calls.map(([kind, source]) => [kind, source]),
|
||||
[
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
['entries', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
],
|
||||
);
|
||||
assert.equal(calls[0][2], true);
|
||||
const [, , entries, options] = calls[1];
|
||||
assert.equal(entries.length, 4);
|
||||
assert.deepEqual(options, {
|
||||
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: 3,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
calls.length = 0;
|
||||
labels.setSelection('al062026');
|
||||
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
|
||||
labels.setSelection('ep152026');
|
||||
const published = calls.find(([kind]) => kind === 'entries')[2];
|
||||
assert.deepEqual(
|
||||
published.map((entry) => entry.id),
|
||||
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
|
||||
);
|
||||
assert.equal(published[1].variant, 'selected');
|
||||
|
||||
calls.length = 0;
|
||||
labels.clear();
|
||||
assert.deepEqual(calls, [
|
||||
['clear', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
labels.clear();
|
||||
labels.setSelection('al062026');
|
||||
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
|
||||
});
|
||||
|
||||
test('storm identity resolves from card and lead-hour ids only', () => {
|
||||
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
|
||||
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
|
||||
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
|
||||
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
CYCLONE_ACCENT,
|
||||
CYCLONE_MARKER_PX,
|
||||
CYCLONE_SELECTED_ACCENT,
|
||||
createCycloneLabels,
|
||||
} from './labels.js';
|
||||
|
||||
/** Only geometry from the displayed status advisory is eligible for rendering. */
|
||||
export function coherentCycloneGeometry(storm) {
|
||||
return (
|
||||
storm.geometryStatus === 'current' &&
|
||||
storm.geometryAdvisoryNumber === storm.advisoryNumber
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Cesium entities with owned horizon culling; no timers or clock.
|
||||
* Storm names and lead hours are published to the shared overlay host at the
|
||||
* same anchors the points use.
|
||||
*/
|
||||
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
|
||||
const labels = createCycloneLabels({ host: overlayHost });
|
||||
let source = null,
|
||||
generation = 0,
|
||||
selected = null,
|
||||
destroyed = false;
|
||||
let centers = new Map(),
|
||||
spheres = new Map();
|
||||
let entityStorms = new WeakMap();
|
||||
let entityIds = new Set();
|
||||
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
let horizonStorms = [],
|
||||
removePreRender = null,
|
||||
pointOccluder = null,
|
||||
sphereOccluder = null;
|
||||
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
|
||||
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
|
||||
const white = C.Color.WHITE;
|
||||
const render = () => {
|
||||
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
|
||||
};
|
||||
function cullHorizon() {
|
||||
pointOccluder.cameraPosition = viewer.camera.positionWC;
|
||||
sphereOccluder.cameraPosition = viewer.camera.positionWC;
|
||||
let changed = false;
|
||||
for (let i = 0; i < horizonStorms.length; i++) {
|
||||
const storm = horizonStorms[i];
|
||||
for (let j = 0; j < storm.points.length; j++) {
|
||||
const { entity, position } = storm.points[j];
|
||||
const show = pointOccluder.isPointVisible(position);
|
||||
if (entity.show !== show) {
|
||||
entity.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!storm.shapes.length) continue;
|
||||
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
|
||||
for (let j = 0; j < storm.shapes.length; j++) {
|
||||
const entity = storm.shapes[j];
|
||||
if (entity.show !== show) {
|
||||
entity.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) render();
|
||||
}
|
||||
function syncHorizonListener() {
|
||||
if (!horizonStorms.length) {
|
||||
removePreRender?.();
|
||||
removePreRender = null;
|
||||
return;
|
||||
}
|
||||
if (removePreRender) return;
|
||||
pointOccluder ||= new C.EllipsoidalOccluder(
|
||||
C.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
|
||||
// conservatively culls extents, retaining partially visible tracks/cones.
|
||||
sphereOccluder ||= new C.Occluder(
|
||||
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
|
||||
}
|
||||
function remove(value) {
|
||||
if (!value) return;
|
||||
if (!viewer.dataSources.isDestroyed?.())
|
||||
viewer.dataSources.remove(value, true);
|
||||
value.entities.removeAll();
|
||||
}
|
||||
function select(id) {
|
||||
selected = id;
|
||||
for (const [stormId, entity] of centers) {
|
||||
entity.point.color = stormId === id ? gold : blue;
|
||||
entity.point.pixelSize =
|
||||
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
|
||||
}
|
||||
labels.setSelection(id);
|
||||
render();
|
||||
}
|
||||
return {
|
||||
async setSnapshot(snapshot, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (destroyed) return false;
|
||||
const owner = ++generation;
|
||||
const next = new C.CustomDataSource('weather-cyclones');
|
||||
const nextCenters = new Map(),
|
||||
nextSpheres = new Map();
|
||||
const nextEntityStorms = new WeakMap();
|
||||
const nextEntityIds = new Set();
|
||||
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
const nextHorizonStorms = [];
|
||||
const nextLabels = [];
|
||||
const position = ({ longitude, latitude }) =>
|
||||
C.Cartesian3.fromDegrees(longitude, latitude, 0);
|
||||
const coordinate = (pair) =>
|
||||
position({ longitude: pair[0], latitude: pair[1] });
|
||||
try {
|
||||
for (const storm of snapshot.storms) {
|
||||
const horizon = { points: [], shapes: [], sphere: null };
|
||||
const addEntity = (options) => {
|
||||
const entity = next.entities.add(options);
|
||||
if (options.position)
|
||||
horizon.points.push({ entity, position: options.position });
|
||||
else horizon.shapes.push(entity);
|
||||
nextEntityStorms.set(entity, storm.id);
|
||||
nextEntityIds.add(entity.id);
|
||||
return entity;
|
||||
};
|
||||
const center = position(storm.position),
|
||||
extent = [center];
|
||||
const entity = addEntity({
|
||||
id: `cyclone:${storm.id}:center`,
|
||||
name: storm.name,
|
||||
position: center,
|
||||
point: {
|
||||
heightReference: C.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
pixelSize: CYCLONE_MARKER_PX.storm,
|
||||
color: blue,
|
||||
outlineColor: C.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
});
|
||||
nextCenters.set(storm.id, entity);
|
||||
const label = {
|
||||
id: storm.id,
|
||||
name: storm.name,
|
||||
classification: storm.classification,
|
||||
windKt: storm.windKt,
|
||||
position: center,
|
||||
forecasts: [],
|
||||
};
|
||||
nextLabels.push(label);
|
||||
nextCounts.storms++;
|
||||
if (coherentCycloneGeometry(storm)) {
|
||||
const lines =
|
||||
storm.track?.type === 'LineString'
|
||||
? [storm.track.coordinates]
|
||||
: storm.track?.coordinates || [];
|
||||
lines.forEach((line, index) => {
|
||||
const positions = line.map(coordinate);
|
||||
extent.push(...positions);
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:track:${index}`,
|
||||
polyline: {
|
||||
clampToGround: true,
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
positions,
|
||||
width: 2.5,
|
||||
material: blue,
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
nextCounts.tracks++;
|
||||
});
|
||||
const polygons =
|
||||
storm.cone?.type === 'Polygon'
|
||||
? [storm.cone.coordinates]
|
||||
: storm.cone?.coordinates || [];
|
||||
polygons.forEach((rings, index) => {
|
||||
// Each exterior retains its own interior holes. Native geographic
|
||||
// tessellation handles the antimeridian; never flatten rings.
|
||||
const exterior = rings[0].map(coordinate);
|
||||
extent.push(...exterior);
|
||||
const holes = rings
|
||||
.slice(1)
|
||||
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:cone:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new C.PolygonHierarchy(exterior, holes),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
material: blue.withAlpha(0.16),
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
[exterior, ...holes.map((hole) => hole.positions)].forEach(
|
||||
(positions, ringIndex) => {
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
|
||||
polyline: {
|
||||
positions,
|
||||
width: 1,
|
||||
material: blue.withAlpha(0.55),
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
clampToGround: true,
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
nextCounts.cones++;
|
||||
});
|
||||
for (const [index, point] of storm.forecastPoints.entries()) {
|
||||
if (point.tauHours === 0) continue;
|
||||
const p = position(point.position);
|
||||
extent.push(p);
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:forecast:${index}`,
|
||||
position: p,
|
||||
point: {
|
||||
heightReference: C.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
pixelSize: CYCLONE_MARKER_PX.forecast,
|
||||
color: white,
|
||||
outlineColor: C.Color.BLACK,
|
||||
outlineWidth: 1,
|
||||
},
|
||||
});
|
||||
label.forecasts.push({ tauHours: point.tauHours, position: p });
|
||||
nextCounts.forecastPoints++;
|
||||
}
|
||||
}
|
||||
const sphere = C.BoundingSphere.fromPoints(extent);
|
||||
// A status-only point still has a useful regional camera destination.
|
||||
sphere.radius = Math.max(sphere.radius, 500_000);
|
||||
nextSpheres.set(storm.id, sphere);
|
||||
horizon.sphere = sphere;
|
||||
nextHorizonStorms.push(horizon);
|
||||
}
|
||||
await viewer.dataSources.add(next);
|
||||
if (destroyed || generation !== owner || signal?.aborted) {
|
||||
remove(next);
|
||||
return false;
|
||||
}
|
||||
remove(source);
|
||||
source = next;
|
||||
centers = nextCenters;
|
||||
spheres = nextSpheres;
|
||||
entityStorms = nextEntityStorms;
|
||||
entityIds = nextEntityIds;
|
||||
counts = nextCounts;
|
||||
horizonStorms = nextHorizonStorms;
|
||||
syncHorizonListener();
|
||||
labels.setSnapshot(nextLabels, selected);
|
||||
select(selected);
|
||||
return true;
|
||||
} catch (error) {
|
||||
remove(next);
|
||||
if (signal?.aborted || generation !== owner || destroyed) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
setSelection: select,
|
||||
ownsPickId(id) {
|
||||
return source !== null && typeof id === 'string' && entityIds.has(id);
|
||||
},
|
||||
pickStorm(picked) {
|
||||
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
|
||||
// cannot establish ownership, especially after an advisory replacement.
|
||||
const entity = picked?.id;
|
||||
return source && entity && typeof entity === 'object'
|
||||
? entityStorms.get(entity) || null
|
||||
: null;
|
||||
},
|
||||
getFocusSphere(id) {
|
||||
return spheres.get(id) || null;
|
||||
},
|
||||
clear() {
|
||||
++generation;
|
||||
horizonStorms = [];
|
||||
syncHorizonListener();
|
||||
pointOccluder = null;
|
||||
sphereOccluder = null;
|
||||
remove(source);
|
||||
source = null;
|
||||
labels.clear();
|
||||
centers.clear();
|
||||
spheres.clear();
|
||||
entityStorms = new WeakMap();
|
||||
entityIds.clear();
|
||||
selected = null;
|
||||
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
render();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.clear();
|
||||
destroyed = true;
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
...counts,
|
||||
dataSources: Number(!!source),
|
||||
entities: source?.entities.values.length || 0,
|
||||
selectedId: selected,
|
||||
timerActive: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCycloneRendering } from './rendering.js';
|
||||
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
|
||||
|
||||
/** Records what the renderer publishes to the shared overlay host. */
|
||||
function overlayRecorder() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
setEntries: (source, entries, options) =>
|
||||
calls.push({ kind: 'entries', source, entries, options }),
|
||||
setVisible: (source, visible) =>
|
||||
calls.push({ kind: 'visible', source, visible }),
|
||||
clearSource: (source) => calls.push({ kind: 'clear', source }),
|
||||
},
|
||||
get publishes() {
|
||||
return calls.filter(({ kind }) => kind === 'entries').length;
|
||||
},
|
||||
published() {
|
||||
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
|
||||
},
|
||||
entry(id) {
|
||||
return this.published().find((entry) => entry.id === id) || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ deferred = false } = {}) {
|
||||
const sources = [],
|
||||
completions = [],
|
||||
pointOccluders = [],
|
||||
sphereOccluders = [];
|
||||
const listeners = new Set();
|
||||
const visibility = {
|
||||
points: new Map(),
|
||||
spheres: new Map(),
|
||||
pointCalls: [],
|
||||
sphereCalls: [],
|
||||
writes: 0,
|
||||
};
|
||||
const color = (value) => ({
|
||||
value,
|
||||
withAlpha: (alpha) => ({ value, alpha }),
|
||||
});
|
||||
const cesium = {
|
||||
Color: {
|
||||
fromCssColorString: color,
|
||||
WHITE: color('white'),
|
||||
BLACK: color('black'),
|
||||
},
|
||||
Cartesian2: class {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
},
|
||||
Cartesian3: {
|
||||
ZERO: { x: 0, y: 0, z: 0 },
|
||||
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
|
||||
},
|
||||
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
|
||||
EllipsoidalOccluder: class {
|
||||
constructor(ellipsoid, cameraPosition) {
|
||||
this.ellipsoid = ellipsoid;
|
||||
this.cameraPosition = cameraPosition;
|
||||
pointOccluders.push(this);
|
||||
}
|
||||
isPointVisible(position) {
|
||||
visibility.pointCalls.push(position);
|
||||
return visibility.points.get(position) ?? true;
|
||||
}
|
||||
},
|
||||
Occluder: class {
|
||||
constructor(sphere, cameraPosition) {
|
||||
this.sphere = sphere;
|
||||
this.cameraPosition = cameraPosition;
|
||||
sphereOccluders.push(this);
|
||||
}
|
||||
isBoundingSphereVisible(sphere) {
|
||||
visibility.sphereCalls.push(sphere);
|
||||
return visibility.spheres.get(sphere) ?? true;
|
||||
}
|
||||
},
|
||||
PolygonHierarchy: class {
|
||||
constructor(positions, holes = []) {
|
||||
this.positions = positions;
|
||||
this.holes = holes;
|
||||
}
|
||||
},
|
||||
BoundingSphere: class {
|
||||
constructor(center, radius) {
|
||||
this.center = center;
|
||||
this.radius = radius;
|
||||
}
|
||||
static fromPoints(points) {
|
||||
return { points, radius: 10 };
|
||||
}
|
||||
},
|
||||
LabelStyle: { FILL_AND_OUTLINE: 1 },
|
||||
HorizontalOrigin: { LEFT: 1 },
|
||||
ArcType: { GEODESIC: 1 },
|
||||
HeightReference: Cesium.HeightReference,
|
||||
ClassificationType: Cesium.ClassificationType,
|
||||
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
|
||||
CustomDataSource: class {
|
||||
constructor() {
|
||||
const values = [];
|
||||
this.entities = {
|
||||
values,
|
||||
add: (value) => {
|
||||
let show = true;
|
||||
Object.defineProperty(value, 'show', {
|
||||
get: () => show,
|
||||
set: (next) => {
|
||||
show = next;
|
||||
visibility.writes++;
|
||||
},
|
||||
});
|
||||
values.push(value);
|
||||
return value;
|
||||
},
|
||||
removeAll: () => {
|
||||
values.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
let renders = 0;
|
||||
const overlay = overlayRecorder();
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
|
||||
scene: {
|
||||
requestRender: () => renders++,
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
dataSources: {
|
||||
add(value) {
|
||||
if (deferred)
|
||||
return new Promise((resolve) =>
|
||||
completions.push(() => {
|
||||
sources.push(value);
|
||||
resolve(value);
|
||||
}),
|
||||
);
|
||||
sources.push(value);
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
remove(value) {
|
||||
const i = sources.indexOf(value);
|
||||
if (i >= 0) sources.splice(i, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
rendering: createCycloneRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
overlayHost: overlay.host,
|
||||
}),
|
||||
overlay,
|
||||
sources,
|
||||
completions,
|
||||
viewer,
|
||||
cesium,
|
||||
visibility,
|
||||
pointOccluders,
|
||||
sphereOccluders,
|
||||
listeners,
|
||||
frame() {
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
get renders() {
|
||||
return renders;
|
||||
},
|
||||
};
|
||||
}
|
||||
const storm = () => ({
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
advisoryNumber: '10',
|
||||
geometryAdvisoryNumber: '10',
|
||||
geometryStatus: 'current',
|
||||
position: { longitude: 179, latitude: 15 },
|
||||
forecastPoints: [
|
||||
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
|
||||
],
|
||||
track: {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[179, 15],
|
||||
[-179, 16],
|
||||
],
|
||||
],
|
||||
},
|
||||
cone: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
[178, 10],
|
||||
[-178, 10],
|
||||
[-178, 20],
|
||||
[178, 10],
|
||||
],
|
||||
[
|
||||
[179, 12],
|
||||
[-179, 12],
|
||||
[-179, 14],
|
||||
[179, 12],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[170, 1],
|
||||
[171, 1],
|
||||
[171, 2],
|
||||
[170, 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('horizon culling updates only changed entities and keeps selection independent', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({
|
||||
storms: [storm(), { ...storm(), id: 'near' }],
|
||||
});
|
||||
const entities = h.sources[0].entities.values;
|
||||
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
|
||||
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
|
||||
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
|
||||
const sphere = h.rendering.getFocusSphere('ep152026');
|
||||
for (const entity of far)
|
||||
if (entity.position) h.visibility.points.set(entity.position, false);
|
||||
h.visibility.spheres.set(sphere, false);
|
||||
h.rendering.setSelection('ep152026');
|
||||
const publishes = h.overlay.publishes;
|
||||
const before = h.renders;
|
||||
h.frame();
|
||||
assert.ok(far.every((e) => e.show === false));
|
||||
assert.ok(near.every((e) => e.show === true));
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:ep152026:12').position,
|
||||
forecast.position,
|
||||
'the host culls labels at the same anchors the points use',
|
||||
);
|
||||
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
|
||||
assert.equal(h.renders, before + 1);
|
||||
assert.equal(h.visibility.writes, far.length);
|
||||
assert.equal(h.visibility.pointCalls.length, 4);
|
||||
assert.deepEqual(h.visibility.sphereCalls, [
|
||||
sphere,
|
||||
h.rendering.getFocusSphere('near'),
|
||||
]);
|
||||
h.frame();
|
||||
assert.equal(h.renders, before + 1);
|
||||
assert.equal(h.visibility.writes, far.length);
|
||||
|
||||
// A visible portion of the extent keeps all tracks/cones shown even when
|
||||
// the centre and an individual forecast point remain beyond the horizon.
|
||||
h.visibility.spheres.set(sphere, true);
|
||||
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
|
||||
h.frame();
|
||||
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
|
||||
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
|
||||
assert.equal(h.renders, before + 2);
|
||||
assert.equal(h.pointOccluders.length, 1);
|
||||
assert.equal(h.sphereOccluders.length, 1);
|
||||
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
|
||||
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
|
||||
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
|
||||
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:near:12').position,
|
||||
near.find((e) => e.id.endsWith('forecast:0')).position,
|
||||
);
|
||||
h.visibility.points.set(forecast.position, true);
|
||||
const beforeReveal = h.renders;
|
||||
const revealPublishes = h.overlay.publishes;
|
||||
h.frame();
|
||||
assert.equal(forecast.show, true);
|
||||
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
|
||||
assert.equal(h.overlay.publishes, revealPublishes);
|
||||
assert.equal(far[0].show, false);
|
||||
assert.equal(h.renders, beforeReveal + 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
|
||||
const h = harness();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
const old = [...h.sources[0].entities.values];
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
h.frame();
|
||||
assert.ok(
|
||||
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
|
||||
);
|
||||
await h.rendering.setSnapshot({ storms: [] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
const before = h.renders;
|
||||
h.frame();
|
||||
assert.equal(h.renders, before);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
h.rendering.destroy();
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
|
||||
assert.equal(h.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
|
||||
for (const method of ['clear', 'destroy']) {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
h.rendering[method]();
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.listeners.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
|
||||
for (const globeShow of [true, false]) {
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
|
||||
scene: {
|
||||
globe: { show: globeShow },
|
||||
preRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
},
|
||||
dataSources: {
|
||||
async add(source) {
|
||||
sources.push(source);
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
const overlay = overlayRecorder();
|
||||
const rendering = createCycloneRendering({
|
||||
viewer,
|
||||
cesium: Cesium,
|
||||
overlayHost: overlay.host,
|
||||
});
|
||||
const at = (id, longitude) => ({
|
||||
...storm(),
|
||||
id,
|
||||
position: { longitude, latitude: 0 },
|
||||
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
|
||||
track: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[longitude, 0],
|
||||
[longitude, 1],
|
||||
],
|
||||
},
|
||||
cone: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[longitude, 0],
|
||||
[longitude + 1, 0],
|
||||
[longitude, 1],
|
||||
[longitude, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
await rendering.setSnapshot({
|
||||
storms: [at('near', 0), at('far', 180), at('limb', 5)],
|
||||
});
|
||||
rendering.setSelection('near');
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
const entities = sources[0].entities.values;
|
||||
for (const entity of entities.filter((e) => e.position)) {
|
||||
assert.equal(
|
||||
entity.point.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
|
||||
assert.equal(entity.label, undefined, 'no Cesium label graphics');
|
||||
const published = overlay.entry(
|
||||
entity.id.includes(':forecast:')
|
||||
? `lead:${entity.id.split(':')[1]}:24`
|
||||
: `storm:${entity.id.split(':')[1]}`,
|
||||
);
|
||||
if (entity.id.startsWith('cyclone:near:')) {
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
published.position,
|
||||
entity.position.getValue(),
|
||||
),
|
||||
);
|
||||
assert.equal(published.horizonCull, true);
|
||||
}
|
||||
if (entity.id === 'cyclone:near:forecast:0')
|
||||
assert.equal(published.maxDistance, 4_000_000);
|
||||
}
|
||||
assert.ok(
|
||||
entities
|
||||
.filter((e) => e.id.startsWith('cyclone:near:'))
|
||||
.every((e) => e.show),
|
||||
);
|
||||
assert.ok(
|
||||
entities
|
||||
.filter((e) => e.id.startsWith('cyclone:far:'))
|
||||
.every((e) => !e.show),
|
||||
);
|
||||
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
|
||||
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
|
||||
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
|
||||
rendering.destroy();
|
||||
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const oldEntities = [...h.sources[0].entities.values];
|
||||
assert.equal(oldEntities.length, 8);
|
||||
for (const entity of oldEntities) {
|
||||
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
|
||||
assert.equal(h.rendering.ownsPickId(entity.id), true);
|
||||
assert.equal(
|
||||
h.rendering.pickStorm({ id: { ...entity } }),
|
||||
null,
|
||||
'copied ID is not ownership',
|
||||
);
|
||||
assert.equal(
|
||||
h.rendering.pickStorm({ id: entity.id }),
|
||||
null,
|
||||
'string prefix is not ownership',
|
||||
);
|
||||
}
|
||||
assert.equal(h.rendering.pickStorm(undefined), null);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
for (const entity of oldEntities)
|
||||
assert.equal(h.rendering.pickStorm({ id: entity }), null);
|
||||
const current = h.sources[0].entities.values[0];
|
||||
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
|
||||
h.rendering.clear();
|
||||
assert.equal(h.rendering.pickStorm({ id: current }), null);
|
||||
assert.equal(h.rendering.ownsPickId(current.id), false);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('registry IDs switch only when the next coherent data source commits', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const first = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
|
||||
h.completions.shift()();
|
||||
await first;
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
|
||||
const next = h.rendering.setSnapshot({
|
||||
storms: [{ ...storm(), id: 'ep162026' }],
|
||||
});
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
|
||||
h.completions.shift()();
|
||||
await next;
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
|
||||
});
|
||||
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const entities = h.sources[0].entities.values;
|
||||
const cones = entities.filter((e) => e.polygon);
|
||||
assert.equal(cones.length, 2);
|
||||
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
|
||||
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
|
||||
for (const cone of cones) {
|
||||
assert.equal(
|
||||
cone.polygon.classificationType,
|
||||
h.cesium.ClassificationType.BOTH,
|
||||
);
|
||||
assert.equal(cone.polygon.height, undefined);
|
||||
assert.equal(cone.polygon.extrudedHeight, undefined);
|
||||
assert.equal(cone.polygon.outline, undefined);
|
||||
}
|
||||
const outlines = entities.filter((e) => e.id.includes(':outline:'));
|
||||
assert.equal(outlines.length, 3);
|
||||
assert.deepEqual(
|
||||
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
|
||||
storm().cone.coordinates.flat(),
|
||||
);
|
||||
for (const outline of outlines) {
|
||||
assert.deepEqual(outline.polyline.material, {
|
||||
value: '#7fe6ed',
|
||||
alpha: 0.55,
|
||||
});
|
||||
}
|
||||
for (const { polyline } of entities.filter((e) => e.polyline)) {
|
||||
assert.equal(polyline.clampToGround, true);
|
||||
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
|
||||
assert.ok(polyline.positions.every((p) => p.height === 0));
|
||||
}
|
||||
assert.equal(
|
||||
entities.find((e) => e.polyline).polyline.positions[1].lon,
|
||||
-179,
|
||||
);
|
||||
assert.deepEqual(h.rendering.getDiagnostics(), {
|
||||
storms: 1,
|
||||
tracks: 1,
|
||||
cones: 2,
|
||||
forecastPoints: 1,
|
||||
dataSources: 1,
|
||||
entities: 8,
|
||||
selectedId: null,
|
||||
timerActive: false,
|
||||
});
|
||||
h.rendering.setSelection('ep152026');
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:ep152026:12').position,
|
||||
entities.find((e) => e.id.endsWith('forecast:0')).position,
|
||||
);
|
||||
assert.ok(entities.every((e) => e.label === undefined));
|
||||
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.sources.length, 0);
|
||||
assert.equal(h.rendering.getDiagnostics().entities, 0);
|
||||
});
|
||||
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
|
||||
for (const changed of [
|
||||
{ geometryStatus: 'pending' },
|
||||
{ geometryAdvisoryNumber: '9' },
|
||||
]) {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
|
||||
assert.equal(h.sources[0].entities.values.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().tracks, 0);
|
||||
h.rendering.destroy();
|
||||
}
|
||||
});
|
||||
test('a data-source add settling after disable is removed, without a new owner', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.sources.length, 0);
|
||||
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
|
||||
});
|
||||
test('superseded asynchronous additions cannot replace newer geometry', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const old = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const latest = h.rendering.setSnapshot({ storms: [] });
|
||||
h.completions[1]();
|
||||
assert.equal(await latest, true);
|
||||
h.completions[0]();
|
||||
assert.equal(await old, false);
|
||||
assert.equal(h.sources.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().storms, 0);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
test('aborting a pending add retains the prior complete source', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const initial = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.completions.shift()();
|
||||
await initial;
|
||||
const controller = new AbortController();
|
||||
const update = h.rendering.setSnapshot(
|
||||
{ storms: [] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
h.completions.shift()();
|
||||
assert.equal(await update, false);
|
||||
assert.equal(h.sources.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().storms, 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({
|
||||
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
|
||||
});
|
||||
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
|
||||
assert.deepEqual(
|
||||
h.overlay.calls
|
||||
.slice(0, 2)
|
||||
.map(({ kind, source, visible }) => [kind, source, visible]),
|
||||
[
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
|
||||
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
h.overlay.published().map((entry) => entry.id),
|
||||
['storm:ep152026', 'storm:near'],
|
||||
'no selection, no lead-hour labels',
|
||||
);
|
||||
assert.equal(h.overlay.entry('storm:near').title, 'Near');
|
||||
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
|
||||
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(center('near').point.pixelSize, 12);
|
||||
assert.equal(center('ep152026').point.pixelSize, 9);
|
||||
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
|
||||
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
|
||||
assert.ok(h.overlay.entry('lead:near:12'));
|
||||
const publishes = h.overlay.publishes;
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
|
||||
|
||||
// A refresh republishes the new anchors with the retained selection.
|
||||
const refresh = h.rendering.setSnapshot({
|
||||
storms: [{ ...storm(), id: 'near', name: 'Near' }],
|
||||
});
|
||||
h.completions.shift()();
|
||||
await refresh;
|
||||
assert.deepEqual(
|
||||
h.overlay.published().map((entry) => [entry.id, entry.variant]),
|
||||
[
|
||||
['storm:near', 'selected'],
|
||||
['lead:near:12', 'card'],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
h.overlay.entry('storm:near').position,
|
||||
h.sources[0].entities.values[0].position,
|
||||
);
|
||||
|
||||
// A superseded addition never publishes, and clear hides the source.
|
||||
const stale = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
const afterClear = h.overlay.calls.slice(-2);
|
||||
assert.deepEqual(afterClear, [
|
||||
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
|
||||
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
|
||||
]);
|
||||
h.completions.shift()();
|
||||
assert.equal(await stale, false);
|
||||
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
value.length > max ||
|
||||
/[\u0000-\u001f<>]/.test(value)
|
||||
)
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const number = (value, low, high) => {
|
||||
if (value === null) return null;
|
||||
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
|
||||
return value;
|
||||
};
|
||||
const time = (value) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
||||
!Number.isFinite(Date.parse(value)) ||
|
||||
new Date(value).toISOString() !== value
|
||||
)
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const advisory = (value) => {
|
||||
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const position = (value) => {
|
||||
if (!value || value.longitude === null || value.latitude === null)
|
||||
throw malformed();
|
||||
return {
|
||||
longitude: number(value.longitude, -180, 180),
|
||||
latitude: number(value.latitude, -90, 90),
|
||||
};
|
||||
};
|
||||
|
||||
function officialLink(value, outlook = false) {
|
||||
if (value === null) return null;
|
||||
const url = new URL(text(value, 256));
|
||||
if (
|
||||
url.origin !== 'https://www.nhc.noaa.gov' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
outlook
|
||||
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
|
||||
url.pathname + url.search,
|
||||
)
|
||||
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
|
||||
)
|
||||
throw malformed();
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function geometry(value, kind, budget) {
|
||||
if (value === null) return null;
|
||||
const allowed =
|
||||
kind === 'track'
|
||||
? ['LineString', 'MultiLineString']
|
||||
: ['Polygon', 'MultiPolygon'];
|
||||
if (!allowed.includes(value?.type)) throw malformed();
|
||||
const point = (pair) => {
|
||||
if (
|
||||
!Array.isArray(pair) ||
|
||||
pair.length !== 2 ||
|
||||
pair.some((v) => typeof v !== 'number')
|
||||
)
|
||||
throw malformed();
|
||||
position({ longitude: pair[0], latitude: pair[1] });
|
||||
if (++budget.coordinates > 25_000) throw malformed();
|
||||
return [...pair];
|
||||
};
|
||||
const list = (items, read, min = 1, max = 128) => {
|
||||
if (!Array.isArray(items) || items.length < min || items.length > max)
|
||||
throw malformed();
|
||||
return items.map(read);
|
||||
};
|
||||
const line = (pairs) => list(pairs, point, 2, 10_000);
|
||||
const ring = (pairs) => {
|
||||
const result = list(pairs, point, 4, 10_000);
|
||||
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
|
||||
throw malformed();
|
||||
return result;
|
||||
};
|
||||
const polygon = (rings) => list(rings, ring);
|
||||
const coordinates =
|
||||
value.type === 'LineString'
|
||||
? line(value.coordinates)
|
||||
: value.type === 'MultiLineString'
|
||||
? list(value.coordinates, line)
|
||||
: value.type === 'Polygon'
|
||||
? polygon(value.coordinates)
|
||||
: list(value.coordinates, polygon);
|
||||
return { type: value.type, coordinates };
|
||||
}
|
||||
|
||||
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
|
||||
export function validateCycloneSnapshot(value) {
|
||||
if (
|
||||
!value ||
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.stale !== 'boolean' ||
|
||||
typeof value.unavailable !== 'boolean' ||
|
||||
!Array.isArray(value.storms) ||
|
||||
value.storms.length > 32
|
||||
)
|
||||
throw malformed();
|
||||
const seen = new Set(),
|
||||
budget = { coordinates: 0, points: 0 };
|
||||
const storms = value.storms.map((raw) => {
|
||||
if (
|
||||
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
|
||||
seen.has(raw.id) ||
|
||||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
|
||||
)
|
||||
throw malformed();
|
||||
seen.add(raw.id);
|
||||
const advisoryNumber = advisory(raw.advisoryNumber);
|
||||
const geometryAdvisoryNumber =
|
||||
raw.geometryAdvisoryNumber === null
|
||||
? null
|
||||
: advisory(raw.geometryAdvisoryNumber);
|
||||
if (
|
||||
raw.geometryStatus === 'current' &&
|
||||
geometryAdvisoryNumber !== advisoryNumber
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
!Array.isArray(raw.forecastPoints) ||
|
||||
(budget.points += raw.forecastPoints.length) > 500
|
||||
)
|
||||
throw malformed();
|
||||
const taus = new Set();
|
||||
const forecastPoints = raw.forecastPoints.map((point) => {
|
||||
const tauHours = number(point.tauHours, 0, 168);
|
||||
if (tauHours === null || taus.has(tauHours)) throw malformed();
|
||||
taus.add(tauHours);
|
||||
if (++budget.coordinates > 25_000) throw malformed();
|
||||
return {
|
||||
position: position(point.position),
|
||||
tauHours,
|
||||
windKt: number(point.windKt, 0, 300),
|
||||
gustKt: number(point.gustKt, 0, 350),
|
||||
};
|
||||
});
|
||||
const track = geometry(raw.track, 'track', budget),
|
||||
cone = geometry(raw.cone, 'cone', budget);
|
||||
if (
|
||||
raw.geometryStatus !== 'current' &&
|
||||
(track || cone || forecastPoints.length)
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
raw.geometryStatus === 'current' &&
|
||||
(!track || !cone || !forecastPoints.length)
|
||||
)
|
||||
throw malformed();
|
||||
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
|
||||
return {
|
||||
id: raw.id,
|
||||
name: text(raw.name, 80),
|
||||
classification: text(raw.classification, 16),
|
||||
basin: raw.basin,
|
||||
position: position(raw.position),
|
||||
positionAt: time(raw.positionAt),
|
||||
advisoryNumber,
|
||||
issuedAt: time(raw.issuedAt),
|
||||
windKt: number(raw.windKt, 0, 300),
|
||||
pressureHpa: number(raw.pressureHpa, 800, 1100),
|
||||
movement: {
|
||||
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
|
||||
speedKt: number(raw.movement?.speedKt, 0, 200),
|
||||
},
|
||||
advisoryUrl: officialLink(raw.advisoryUrl),
|
||||
outlookUrl: officialLink(raw.outlookUrl, true),
|
||||
geometryStatus: raw.geometryStatus,
|
||||
geometryAdvisoryNumber,
|
||||
forecastPoints,
|
||||
track,
|
||||
cone,
|
||||
};
|
||||
});
|
||||
if (value.unavailable && storms.length) throw malformed();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: text(value.source),
|
||||
attribution: text(value.attribution, 240),
|
||||
coverage: text(value.coverage, 240),
|
||||
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
|
||||
stale: value.stale,
|
||||
unavailable: value.unavailable,
|
||||
reason: value.reason === null ? null : text(value.reason, 240),
|
||||
storms,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
|
||||
export function createCycloneSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/cyclones', {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Cyclone HTTP ${response.status}`);
|
||||
}
|
||||
const result = await readResponseJsonCapped(
|
||||
response,
|
||||
CYCLONE_RESPONSE_LIMIT,
|
||||
controller.signal,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
return validateCycloneSnapshot(result);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCycloneSource,
|
||||
validateCycloneSnapshot,
|
||||
CYCLONE_RESPONSE_LIMIT,
|
||||
} from './source.js';
|
||||
|
||||
const issuedAt = '2026-09-16T03:00:00.000Z';
|
||||
function fixture() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'NOAA NHC / CPHC',
|
||||
attribution: 'NOAA/NWS',
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
fetchedAt: Date.parse(issuedAt),
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
storms: [
|
||||
{
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
basin: 'EP',
|
||||
position: { longitude: -125.8, latitude: 15.5 },
|
||||
positionAt: issuedAt,
|
||||
issuedAt,
|
||||
advisoryNumber: '10',
|
||||
windKt: 25,
|
||||
pressureHpa: 1006,
|
||||
movement: { directionDegrees: null, speedKt: null },
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function current() {
|
||||
const value = fixture(),
|
||||
storm = value.storms[0];
|
||||
storm.geometryStatus = 'current';
|
||||
storm.geometryAdvisoryNumber = '10';
|
||||
storm.forecastPoints = [
|
||||
{
|
||||
position: { longitude: 179, latitude: 15 },
|
||||
tauHours: 12,
|
||||
windKt: 30,
|
||||
gustKt: 40,
|
||||
},
|
||||
];
|
||||
storm.track = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[179, 15],
|
||||
[-179, 16],
|
||||
],
|
||||
[
|
||||
[170, 14],
|
||||
[179, 15],
|
||||
],
|
||||
],
|
||||
};
|
||||
storm.cone = {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
[178, 10],
|
||||
[-178, 10],
|
||||
[-178, 20],
|
||||
[178, 10],
|
||||
],
|
||||
[
|
||||
[179, 12],
|
||||
[-179, 12],
|
||||
[-179, 14],
|
||||
[179, 12],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[170, 1],
|
||||
[171, 1],
|
||||
[171, 2],
|
||||
[170, 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
return value;
|
||||
}
|
||||
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
|
||||
const value = current();
|
||||
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
|
||||
value.storms[0].cone.properties = {
|
||||
html: '<script>',
|
||||
href: 'https://example.invalid',
|
||||
};
|
||||
const result = validateCycloneSnapshot(value);
|
||||
assert.deepEqual(
|
||||
result.storms[0].cone.coordinates,
|
||||
value.storms[0].cone.coordinates,
|
||||
);
|
||||
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
|
||||
assert.equal(result.storms[0].cone.properties, undefined);
|
||||
assert.notEqual(
|
||||
result.storms[0].cone.coordinates,
|
||||
value.storms[0].cone.coordinates,
|
||||
);
|
||||
});
|
||||
test('successful empty coverage is distinct from unavailable', () => {
|
||||
const value = fixture();
|
||||
value.storms = [];
|
||||
assert.equal(validateCycloneSnapshot(value).unavailable, false);
|
||||
assert.equal(
|
||||
validateCycloneSnapshot({
|
||||
...value,
|
||||
unavailable: true,
|
||||
stale: true,
|
||||
fetchedAt: null,
|
||||
reason: 'Unavailable',
|
||||
}).unavailable,
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
|
||||
const changes = [
|
||||
(storm) => {
|
||||
storm.geometryAdvisoryNumber = '9';
|
||||
},
|
||||
(storm) => {
|
||||
storm.geometryStatus = 'pending';
|
||||
},
|
||||
(storm) => {
|
||||
storm.position.longitude = 181;
|
||||
},
|
||||
(storm) => {
|
||||
storm.track.coordinates[0][0][0] = NaN;
|
||||
},
|
||||
(storm) => {
|
||||
storm.cone.coordinates[0][0].pop();
|
||||
},
|
||||
(storm) => {
|
||||
storm.forecastPoints.push(storm.forecastPoints[0]);
|
||||
},
|
||||
(storm) => {
|
||||
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
|
||||
},
|
||||
(storm) => {
|
||||
storm.issuedAt = '2026-02-31T00:00:00.000Z';
|
||||
},
|
||||
(storm) => {
|
||||
storm.windKt = 9999;
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const value = current();
|
||||
change(value.storms[0]);
|
||||
assert.throws(() => validateCycloneSnapshot(value));
|
||||
}
|
||||
const oversized = current();
|
||||
oversized.storms[0].track = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: Array.from({ length: 3 }, () =>
|
||||
Array.from({ length: 9000 }, () => [1, 2]),
|
||||
),
|
||||
};
|
||||
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateCycloneSnapshot({
|
||||
...fixture(),
|
||||
storms: Array(33).fill(fixture().storms[0]),
|
||||
}),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
|
||||
const calls = [];
|
||||
const source = createCycloneSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json(fixture());
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
await source.getSnapshot();
|
||||
assert.equal(calls[0].url, '/api/cyclones');
|
||||
assert.equal(calls[0].options.redirect, 'error');
|
||||
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const oversized = createCycloneSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('{}', {
|
||||
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(oversized.getSnapshot(), /too large/);
|
||||
});
|
||||
test('source deadline cancels request and removes timer on completion', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let signal;
|
||||
const source = createCycloneSource({
|
||||
timeoutMs: 10,
|
||||
fetchImpl: async (_url, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const pending = source.getSnapshot();
|
||||
const rejected = assert.rejects(pending, /timed out/);
|
||||
t.mock.timers.tick(11);
|
||||
await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -99,52 +99,4 @@ export function mapAnalystRecord(raw, index = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a complete feed before replacing the last good earthquake snapshot. */
|
||||
export function normalizeEarthquakeSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
coordinates.length < 2 ||
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties) ||
|
||||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
|
||||
)
|
||||
return null;
|
||||
const [lon, lat, depthKm] = coordinates;
|
||||
const mag = properties.mag;
|
||||
if (
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
(depthKm != null && !Number.isFinite(depthKm)) ||
|
||||
(mag != null && (!Number.isFinite(mag) || mag > 10))
|
||||
)
|
||||
return null;
|
||||
// A missing magnitude cannot establish that this event meets M2.5+.
|
||||
if (mag == null || mag < 2.5) continue;
|
||||
const stableId =
|
||||
feature.id == null || feature.id === ''
|
||||
? `event-${index + 1}`
|
||||
: String(feature.id);
|
||||
if (ids.has(stableId)) return null;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
usgsId: feature.id ?? null,
|
||||
lon,
|
||||
lat,
|
||||
depthKm: depthKm ?? null,
|
||||
mag,
|
||||
place: typeof properties.place === 'string' ? properties.place : null,
|
||||
time: Number.isFinite(properties.time) ? properties.time : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
export { normalizeEarthquakeSnapshot } from './records.js';
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Validate a complete feed before replacing the last good earthquake snapshot. */
|
||||
export function normalizeEarthquakeSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const [index, feature] of geojson.features.entries()) {
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
coordinates.length < 2 ||
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties) ||
|
||||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
|
||||
)
|
||||
return null;
|
||||
const [lon, lat, depthKm] = coordinates;
|
||||
const mag = properties.mag;
|
||||
if (
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
(depthKm != null && !Number.isFinite(depthKm)) ||
|
||||
(mag != null && (!Number.isFinite(mag) || mag > 10))
|
||||
)
|
||||
return null;
|
||||
// A missing magnitude cannot establish that this event meets M2.5+.
|
||||
if (mag == null || mag < 2.5) continue;
|
||||
const stableId =
|
||||
feature.id == null || feature.id === ''
|
||||
? `event-${index + 1}`
|
||||
: String(feature.id);
|
||||
if (ids.has(stableId)) return null;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
usgsId: feature.id ?? null,
|
||||
lon,
|
||||
lat,
|
||||
depthKm: depthKm ?? null,
|
||||
mag,
|
||||
place: typeof properties.place === 'string' ? properties.place : null,
|
||||
time: Number.isFinite(properties.time) ? properties.time : null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeEarthquakeSnapshot } from './model.js';
|
||||
import { normalizeEarthquakeSnapshot } from './records.js';
|
||||
const API_URL =
|
||||
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
|
||||
/** Request and validate a complete USGS snapshot before it can replace displayed events. */
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -38,6 +38,12 @@ export function createQueries({
|
||||
|
||||
source,
|
||||
|
||||
// The one data-layer control a provider key gates: the proxy answers
|
||||
// 503 {error:'no_key'} without a FIRMS key. Declared as a key-registry id
|
||||
// rather than an env-var string, so the panel can name the key from the
|
||||
// one place that owns what each key is called.
|
||||
requiresKeyId: 'firms',
|
||||
|
||||
// Live layer: the manager calls update() every 10 minutes while enabled,
|
||||
// which refetches through the /api/firms proxy (the proxy's 30 min TTL —
|
||||
// not this interval — is what protects the upstream FIRMS quota).
|
||||
@@ -75,6 +81,11 @@ export function createQueries({
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading: layerState._loading,
|
||||
stale: layerState._stale,
|
||||
// The machine-readable half of the keyless state, ahead of the human
|
||||
// strings below: without it "no key configured" is indistinguishable
|
||||
// from a broken feed, and the row reads as a fault instead of a step
|
||||
// the operator can take.
|
||||
keyRequired: layerState._keyRequired,
|
||||
error: layerState._keyRequired
|
||||
? 'KEY REQUIRED'
|
||||
: layerState._stale
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
fireDetectionKey,
|
||||
} from '../../data/firmsLabels.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { CONTEXT_TOP_N } from './policy.js';
|
||||
|
||||
export function createSelection({
|
||||
@@ -35,6 +36,8 @@ export function createSelection({
|
||||
layerState._viewer,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
const picked = layerState._viewer.scene.pick(click.position);
|
||||
const fire = pickedFire(picked);
|
||||
if (fire) {
|
||||
|
||||
@@ -8,9 +8,9 @@ export function createController({
|
||||
resolveAsset,
|
||||
}) {
|
||||
function _abortActiveUpdates() {
|
||||
for (const controller of flightState._activeUpdateControllers)
|
||||
for (const controller of flightState.feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
flightState._activeUpdateControllers.clear();
|
||||
flightState.feed._activeUpdateControllers.clear();
|
||||
}
|
||||
|
||||
function _flightQuery(viewer) {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createEnrichment({
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
lifetime.signal.throwIfAborted();
|
||||
return flightState._source.getEnrichment?.(job.query, {
|
||||
return flightState.feed._source.getEnrichment?.(job.query, {
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
})
|
||||
@@ -81,7 +81,7 @@ export function createEnrichment({
|
||||
`t:${icao24}`,
|
||||
{ kind: 'type', id: icao24.toLowerCase() },
|
||||
(data) => {
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
if (!meta) return; // evicted while the lookup was in flight
|
||||
meta.typeCode = data.typeCode || meta.typeCode;
|
||||
meta.typeName = data.typeName || meta.typeName;
|
||||
@@ -109,7 +109,7 @@ export function createEnrichment({
|
||||
}
|
||||
|
||||
function _requestRouteEnrichment(icao24) {
|
||||
const cs = String(flightState._flightData.get(icao24)?.callsign || '')
|
||||
const cs = String(flightState.records.data.get(icao24)?.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve
|
||||
@@ -117,7 +117,7 @@ export function createEnrichment({
|
||||
`r:${cs}`,
|
||||
{ kind: 'route', id: cs },
|
||||
(data) => {
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
if (!meta) return;
|
||||
meta.airline = data.airline || meta.airline;
|
||||
if (data.origin && data.destination)
|
||||
@@ -198,7 +198,7 @@ export function createEnrichment({
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
||||
if (flightState._flightData.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||
Cesium.Cartesian3.clone(
|
||||
bb.position,
|
||||
|
||||
@@ -50,10 +50,10 @@ export function createEvidence({
|
||||
for (const bb of flightState._billboards.values())
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.clear();
|
||||
flightState._flightData.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._missingPolls.clear();
|
||||
flightState.records.missingPolls.clear();
|
||||
flightState._focusEvidenceIds.clear();
|
||||
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
@@ -79,9 +79,9 @@ export function createEvidence({
|
||||
lastContactEpochMs: Date.now(),
|
||||
rawLat: record.latitude ?? null,
|
||||
rawLon: record.longitude ?? null,
|
||||
cullPosition: null,
|
||||
};
|
||||
flightState._flightData.set(id, meta);
|
||||
flightState.records.data.set(id, meta);
|
||||
flightState._cullPositions.delete(id);
|
||||
flightState._focusEvidenceIds.add(id);
|
||||
const bb = flightState._billboardCollection.add({
|
||||
position,
|
||||
@@ -100,10 +100,10 @@ export function createEvidence({
|
||||
});
|
||||
flightState._billboards.set(id, bb);
|
||||
}
|
||||
flightState._count = flightState._billboards.size;
|
||||
flightState.feed._count = flightState._billboards.size;
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer.scene.requestRender?.();
|
||||
return { ok: true, count: flightState._count };
|
||||
return { ok: true, count: flightState.feed._count };
|
||||
}
|
||||
|
||||
/** Update explicit evidence positions without rebuilding billboards. */
|
||||
@@ -120,7 +120,7 @@ export function createEvidence({
|
||||
const bb = flightState._billboards.get(id);
|
||||
if (!position || !bb) continue;
|
||||
bb.position = position;
|
||||
const meta = flightState._flightData.get(id);
|
||||
const meta = flightState.records.data.get(id);
|
||||
if (meta) {
|
||||
if (Number.isFinite(record.trackDeg)) meta.true_track = record.trackDeg;
|
||||
if (Number.isFinite(record.velocityMps))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createMotion } from './motion.js';
|
||||
@@ -24,11 +25,34 @@ export function createCivilFlightLayer({
|
||||
parts.tracking = createTracking(context);
|
||||
parts.controller = createController(context);
|
||||
parts.enrichment = createEnrichment(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
const applySnapshot = createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
militaryRegistry: services.militaryRegistry,
|
||||
groundFloor: services.groundFloor,
|
||||
meshFloor: services.meshFloor,
|
||||
rendering: parts.rendering,
|
||||
tracking: parts.tracking,
|
||||
motion: parts.motion,
|
||||
enrichment: parts.enrichment,
|
||||
queries: parts.queries,
|
||||
});
|
||||
parts.ingestion = createIngestion({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
applySnapshot,
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
applyPendingTrackingRestore: () =>
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
+64
-613
@@ -1,661 +1,112 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { stickyNumber, stickyText } from '../../data/aircraftMeta.js';
|
||||
import {
|
||||
geoidSurfaceLastResortM,
|
||||
pickRenderAltitudeM,
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './policy.js';
|
||||
import { ERROR_BACKOFF_INTERVAL } from './recordPolicy.js';
|
||||
|
||||
/** Own acquisition, cancellation, freshness and backoff independently of rendering. */
|
||||
export function createIngestion({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer,
|
||||
resolveAsset,
|
||||
feed,
|
||||
getQuery,
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
}) {
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } =
|
||||
services.militaryRegistry;
|
||||
const { geoidHeight } = services.geoid;
|
||||
const { cachedGroundFloor, floorAltitudeM, warmGroundFloor } =
|
||||
services.groundFloor;
|
||||
const { sampleMeshFloorCells } = services.meshFloor;
|
||||
const flightsLayer = layer;
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Fetch the latest aircraft state vectors from the OpenSky proxy and
|
||||
* reconcile them with the billboard collection.
|
||||
*
|
||||
* Handles HTTP 429 (rate-limit), 401/403 (auth), and transient errors
|
||||
* with exponential-ish backoff. On success, adds/updates/removes
|
||||
* billboards and position history, triggers lerp blending for the
|
||||
* tracked aircraft, and updates its label text.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++flightState._trackingRefreshEpoch;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (flightState._retryAt && nowMs < flightState._retryAt) {
|
||||
flightState._backoff = true;
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
flightState._activeUpdateControllers.add(resourceController);
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await flightState._source.getSnapshot(
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
{ signal: updateSignal },
|
||||
);
|
||||
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
flightState._lastStatus = snapshot.status ?? 200;
|
||||
const usableStates = snapshot.records;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
flightState._backoff = sourceStale;
|
||||
flightState._retryAt = 0;
|
||||
flightState._lastError = sourceStale
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? 'Source snapshot time unavailable'
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
flightState._lastSource = snapshot.source;
|
||||
flightState._lastCoverage = snapshot.coverage;
|
||||
flightsLayer.source = flightState._lastSource;
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
const now = Cesium.JulianDate.now();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
// subpoint + collected floor cells for the viewer-proximate low-contact
|
||||
// clamp below — one carto read per poll, one batch warm after the loop.
|
||||
const viewerCarto =
|
||||
(viewer || flightState._viewer)?.camera?.positionCartographic || null;
|
||||
const viewerLatDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.latitude)
|
||||
: null;
|
||||
const viewerLonDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.longitude)
|
||||
: null;
|
||||
const floorWarmPoints = [];
|
||||
|
||||
// Classification and display policy consume source-independent observations.
|
||||
refreshMilitaryRegistryIfStale();
|
||||
for (const observation of usableStates) {
|
||||
const {
|
||||
id: icao24,
|
||||
callsign,
|
||||
originCountry: origin_country,
|
||||
longitude: lon,
|
||||
latitude: lat,
|
||||
baroAltitudeM: baro_alt,
|
||||
onGround: on_ground,
|
||||
speedMps: velocity,
|
||||
courseDeg: true_track,
|
||||
ellipsoidAltitudeM: geo_alt,
|
||||
category,
|
||||
verticalRateMps: vertical_rate,
|
||||
} = observation;
|
||||
acceptedSnapshotIcaos.add(icao24);
|
||||
const onGround = on_ground === true;
|
||||
|
||||
// Known-military aircraft: the dedicated military layer wins
|
||||
// (icon + track + click) while it is enabled — suppress the
|
||||
// OpenSky duplicate entirely (except a currently tracked one,
|
||||
// which hands off on untrack).
|
||||
const isMil = isMilitaryIcao(icao24);
|
||||
if (isMil && parts.tracking._militaryLayerSuppresses(icao24)) {
|
||||
const dupe = flightState._billboards.get(icao24);
|
||||
if (dupe) {
|
||||
flightState._billboardCollection.remove(dupe);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
flightState._flightData.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._missingPolls.delete(icao24);
|
||||
flightState._geoidNCache.delete(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIcaos.add(icao24);
|
||||
flightState._missingPolls.delete(icao24);
|
||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||
// aircraft it still positions — hold last-known-good instead of
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = flightState._flightData.get(icao24);
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
// barometric/MSL altitude read by labels (FL/altitude readout),
|
||||
// route-plausibility, and follow-camera range heuristics. It is
|
||||
// NEVER overwritten or renamed. Where the aircraft actually RENDERS
|
||||
// on the ellipsoidal globe is a SEPARATE value (renderAltitudeM,
|
||||
// below) — geo_altitude when OpenSky reports it (already WGS84
|
||||
// ellipsoidal), else baro+geoid as a visual fallback, else ground
|
||||
// surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM,
|
||||
// never `alt` directly.
|
||||
const alt = stickyNumber(
|
||||
baro_alt,
|
||||
prevMeta?.altitude,
|
||||
onGround ? 0 : 10000,
|
||||
);
|
||||
|
||||
// geoid undulation N: cached per-aircraft (negligible drift — see
|
||||
// task brief) once the geoid grid has loaded; unavailable pre-load
|
||||
// just means the baro fallback branch below adds N=0 for a beat.
|
||||
let geoidN = flightState._geoidNCache.get(icao24);
|
||||
if (geoidN === undefined && flightState._geoidReady) {
|
||||
geoidN = geoidHeight(lat, lon);
|
||||
flightState._geoidNCache.set(icao24, geoidN);
|
||||
}
|
||||
|
||||
// on_ground surface prior: ONLY synchronous warm-cache reads here —
|
||||
// never a per-aircraft network fetch inside the poll loop (see the
|
||||
// batch resolve call below, which fills this cache for NEXT poll). A
|
||||
// Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied):
|
||||
// the grounded surface is the round-4 choke point and nothing else —
|
||||
// rendered-mesh cell first, real (never fallback-poisoned) DEM cell
|
||||
// second. The old exact-5-decimal warm chain is GONE: it minted a new
|
||||
// key per parked-jitter poll for every grounded contact ON EARTH,
|
||||
// hammering Re:Earth into the very failures that poisoned the cache.
|
||||
let surfaceM = null;
|
||||
if (onGround) {
|
||||
surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell)
|
||||
// Taxiing crosses into a fresh cold cell every poll — always one
|
||||
// step ahead of the warm batch — so fall back to LAST poll's cell
|
||||
// (warmed by last poll's batch; aprons are flat across adjacent
|
||||
// 111 m cells). Round-5 verify caught taxiing contacts stuck at
|
||||
// the geoid without this (round 2's lesson, at cell granularity).
|
||||
if (
|
||||
surfaceM == null &&
|
||||
Number.isFinite(prevMeta?.rawLat) &&
|
||||
Number.isFinite(prevMeta?.rawLon)
|
||||
) {
|
||||
surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon);
|
||||
}
|
||||
// Grounded contacts near the viewer feed the floor warm/sampler
|
||||
// (the only ones whose exact height is visible; far contacts are
|
||||
// subpixel and always-on-top anyway).
|
||||
if (
|
||||
viewerLatDeg != null &&
|
||||
parts.queries._approxDistanceKm(
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
lat,
|
||||
lon,
|
||||
) <= GROUND_FLOOR_CLAMP_RADIUS_KM
|
||||
) {
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
// Last synchronous resort for a BRAND-NEW grounded contact with NO
|
||||
// altitude data at all (nothing warm yet, not even the coarse
|
||||
// cell): the geoid surface. At the sea-level airports where most
|
||||
// grounded traffic sits, geoidN IS the local ellipsoidal ground to
|
||||
// within metres — instantly right — and at elevated fields it is
|
||||
// far less wrong than the raw 0 m ellipsoid default for the one
|
||||
// poll until the coarse cell warms. STRICTLY gated on "no geo, no
|
||||
// baro": a reported baro already reflects the field elevation, and
|
||||
// pickRenderAltitudeM's surfaceM branch would let this crude guess
|
||||
// outrank it (caught by the ground-3d track regression).
|
||||
//
|
||||
// 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds
|
||||
// one more gate — a contact that already HAS a render height keeps
|
||||
// it. Leaving surfaceM null routes it through the sentinel path
|
||||
// below, which holds that height.
|
||||
if (surfaceM == null) {
|
||||
surfaceM = geoidSurfaceLastResortM({
|
||||
geoAltM: geo_alt,
|
||||
baroAltM: baro_alt,
|
||||
priorRenderM: prevMeta?.renderAltitudeM,
|
||||
geoidN,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null;
|
||||
const pickedAltM = pickRenderAltitudeM({
|
||||
geoAltM: geoAltitudeM,
|
||||
baroAltM: Number.isFinite(baro_alt) ? baro_alt : null,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
});
|
||||
// pickRenderAltitudeM returns the sentinel `null` only when NEITHER
|
||||
// geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks,
|
||||
// in priority order:
|
||||
// (1) hold the previous geoid-corrected render height if we have one —
|
||||
// a one-poll baro dropout must NOT snap the plane down by the geoid
|
||||
// undulation N (~46 m in London) and back up next poll. `alt` stays
|
||||
// sticky for labels, so holding the last render height keeps the two
|
||||
// layers consistent through the gap.
|
||||
// (2) otherwise the SAME default policy `alt` already uses, so the two
|
||||
// never disagree on the genuine "no data yet" case (a never-reported
|
||||
// aircraft has no prior render height, so it lands here unchanged).
|
||||
let renderAltitudeM;
|
||||
if (pickedAltM != null) {
|
||||
renderAltitudeM = pickedAltM;
|
||||
} else if (Number.isFinite(prevMeta?.renderAltitudeM)) {
|
||||
renderAltitudeM = prevMeta.renderAltitudeM;
|
||||
} else {
|
||||
renderAltitudeM = alt;
|
||||
}
|
||||
// Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3):
|
||||
// floor a low airborne contact's render height at the local coarse
|
||||
// ground so it can never dive below the mesh. Round 3 (Austin
|
||||
// fleet-underground): baro can read BELOW an elevated field — SWA696
|
||||
// showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi
|
||||
// traffic that OpenSky hasn't flagged on_ground yet renders from that
|
||||
// baro, so the whole fleet sat buried at AUS in 2D. Clamping every
|
||||
// global contact would need unbounded terrain resolution; instead the
|
||||
// clamp covers the TRACKED contact (always) plus every low contact
|
||||
// within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones
|
||||
// whose burial is visible. Cells warm in one batch after the loop.
|
||||
// Airborne only (grounded planes keep the surface-cache path above).
|
||||
if (
|
||||
!onGround &&
|
||||
renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M &&
|
||||
(icao24 === flightState._trackedIcao ||
|
||||
(viewerLatDeg != null &&
|
||||
parts.queries._approxDistanceKm(
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
lat,
|
||||
lon,
|
||||
) <= GROUND_FLOOR_CLAMP_RADIUS_KM))
|
||||
) {
|
||||
renderAltitudeM = floorAltitudeM(
|
||||
renderAltitudeM,
|
||||
cachedGroundFloor(lat, lon),
|
||||
);
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
renderAltitudeM,
|
||||
);
|
||||
// Landing/takeoff transition: the on_ground flip restyles IN PLACE.
|
||||
const groundFlipped =
|
||||
!!prevMeta && (prevMeta.onGround === true) !== onGround;
|
||||
// Either flip direction retires the model's ground snap: a departing plane
|
||||
// flies free of it, a landing plane earns a fresh sample where it rolls out.
|
||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||
|
||||
// Store flight metadata for click-to-track labels
|
||||
const cat = stickyNumber(category, prevMeta?.category, null);
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
altitude: alt,
|
||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||
// untouched aviation `altitude` — never rename/replace it (labels,
|
||||
// FL readout, route-plausibility, and follow-camera range math all
|
||||
// still read `altitude`/baro).
|
||||
geoAltitudeM,
|
||||
renderAltitudeM,
|
||||
onGround,
|
||||
// Round 7: sticky airborne history — the landed fast-cull only
|
||||
// applies to contacts that actually flew this session.
|
||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||
// Round 6: lifted occlusion-test point for contacts rendering
|
||||
// at/below the ellipsoid (fleet pass reads it — see the occluder
|
||||
// note there). Null for the overwhelmingly common airborne case.
|
||||
cullPosition:
|
||||
renderAltitudeM < 10
|
||||
? Cesium.Cartesian3.fromDegrees(lon, lat, 12)
|
||||
: null,
|
||||
velocity: stickyNumber(velocity, prevMeta?.velocity, 0),
|
||||
true_track: stickyNumber(true_track, prevMeta?.true_track, 0),
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
verticalRate: stickyNumber(
|
||||
vertical_rate,
|
||||
prevMeta?.verticalRate,
|
||||
null,
|
||||
),
|
||||
// Analyst seam: OpenSky origin_country (state[2]) — additive, sticky
|
||||
// like callsign so a transient blank row doesn't blank the field.
|
||||
originCountry:
|
||||
stickyText(origin_country, prevMeta?.originCountry) || null,
|
||||
// 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
|
||||
// while fresh velocity/track messages are still arriving.
|
||||
lastContactEpochMs: stickyNumber(
|
||||
observation.contactTimeMs,
|
||||
prevMeta?.lastContactEpochMs,
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: prevMeta?.airline ?? null,
|
||||
route: prevMeta?.route ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
// actual reported fix.
|
||||
rawLat: lat,
|
||||
rawLon: lon,
|
||||
};
|
||||
flightState._flightData.set(icao24, meta);
|
||||
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
|
||||
// Append to position history stamped with the FEED's fix epoch
|
||||
// (time_position), not client receipt time — OpenSky positions arrive
|
||||
// 5-15s stale and receipt-time stamping is what caused the
|
||||
// back/forward oscillation. Only append when the fix actually
|
||||
// advances, so stale repeats don't create zero-dt segments.
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs));
|
||||
if (!flightState._positionHistory.has(icao24)) {
|
||||
flightState._positionHistory.set(icao24, []);
|
||||
}
|
||||
const history = flightState._positionHistory.get(icao24);
|
||||
const newest = history[history.length - 1];
|
||||
if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) {
|
||||
// Per-fix kinematics: the fix's own velocity/track ride along so the
|
||||
// extrapolation paths use the values that BELONG to the fix they
|
||||
// extend, not whatever the latest poll reported.
|
||||
history.push({
|
||||
time: fixTime,
|
||||
epochMs: fixEpochMs,
|
||||
position: position.clone(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
});
|
||||
if (history.length > POSITION_HISTORY_LIMIT) {
|
||||
history.shift();
|
||||
}
|
||||
// Turn rate from the fix-track history — computed once per new fix
|
||||
// (≤5 samples), consumed by the extrapolation paths at tick rate.
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
// Trail accumulation is separate from the 5-fix DR history (PRD F1)
|
||||
// so the visible trail keeps growing while tracked. Ground traffic
|
||||
// appends nothing — a touchdown freezes the existing trail.
|
||||
// Round 2 (owner): ground traffic appends too — taxi history stays
|
||||
// live after touchdown (grounded flights positions are already
|
||||
// surface-clamped via the surfaceM chain, so the ground leg drapes).
|
||||
if (isTracked) parts.tracking._appendTrailFix(position.clone());
|
||||
} else {
|
||||
const modelOwnsGroundVisual =
|
||||
parts.rendering._modelOwnsVisual(icao24);
|
||||
if (!modelOwnsGroundVisual) {
|
||||
liftRepeatedGroundFix(newest, position, meta.onGround);
|
||||
}
|
||||
// Apply fresh kinematics only from a forward synthetic fix. Mutating
|
||||
// the historical fix reprojects the entire stale interval and snaps
|
||||
// the rendered aircraft when course or speed changes late.
|
||||
const kinematicsChanged =
|
||||
newest.velocity !== meta.velocity ||
|
||||
newest.track !== meta.true_track;
|
||||
if (kinematicsChanged) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
});
|
||||
if (synthetic) {
|
||||
history.push(synthetic);
|
||||
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
if (isTracked)
|
||||
parts.tracking._appendTrailFix(synthetic.position.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
// Reclassify if the category resolved/changed (first extended poll);
|
||||
// a ground flip (landing/takeoff) re-scales the SAME billboard in
|
||||
// place — the transition is a restyle, never a removal.
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
if (!isTracked) {
|
||||
// Refresh affiliation hue without clobbering the tick-owned
|
||||
// freshness × focus × horizon alpha composition.
|
||||
bb.color = parts.rendering
|
||||
._fleetBillboardColor(icao24)
|
||||
.withAlpha(bb.color?.alpha ?? 1);
|
||||
}
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
groundFlipped ||
|
||||
flightState._cockpitContactMode
|
||||
) {
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
// Poll-path class change (category updates): same model resync rule
|
||||
// as the enrichment path — the class's GLB/scale may have changed.
|
||||
if (prevMeta?.klass !== meta.klass)
|
||||
parts.rendering._syncModelToClass(icao24);
|
||||
} else {
|
||||
const bb = flightState._billboardCollection.add({
|
||||
position,
|
||||
image: aircraftIcon(
|
||||
parts.rendering._iconKind(icao24, meta.klass),
|
||||
),
|
||||
width: isTracked ? 24 : 20,
|
||||
height: isTracked ? 24 : 20,
|
||||
scale: parts.rendering._fleetBillboardScale(icao24, meta.klass),
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? Cesium.Color.CYAN
|
||||
: parts.rendering._fleetBillboardColor(icao24),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance:
|
||||
parts.rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
// surface — render them depth-test-free so they never vanish up
|
||||
// close (_groundDepthDistance).
|
||||
disableDepthTestDistance: parts.rendering._groundDepthDistance(),
|
||||
id: icao24,
|
||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
||||
});
|
||||
flightState._billboards.set(icao24, bb);
|
||||
parts.rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
|
||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||
// from the fresh airborne history (touchdown needs no action — the
|
||||
// append gate above simply freezes the existing trail).
|
||||
if (isTracked && groundFlipped && !meta.onGround)
|
||||
parts.tracking._startTrail(icao24);
|
||||
|
||||
// If this is the tracked aircraft, update label text
|
||||
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||
if (isTracked && flightState._trackedEntity) {
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove aircraft only after MISSING_POLL_LIMIT consecutive absences.
|
||||
// OpenSky routinely drops aircraft for a single poll; immediate removal
|
||||
// made planes blink and yanked the camera off actively tracked flights.
|
||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
// Partial admissions do not prove absence, but stale retention is bounded.
|
||||
if (
|
||||
!snapshot.complete &&
|
||||
Date.now() -
|
||||
(flightState._flightData.get(icao24)?.observedReceiptMs ?? 0) <
|
||||
300000
|
||||
)
|
||||
continue;
|
||||
const misses = (flightState._missingPolls.get(icao24) || 0) + 1;
|
||||
const limit = parts.queries._likelyLanded(icao24)
|
||||
? LANDED_MISSING_POLL_LIMIT
|
||||
: MISSING_POLL_LIMIT;
|
||||
if (misses < limit) {
|
||||
flightState._missingPolls.set(icao24, misses);
|
||||
if (
|
||||
icao24 === flightState._trackedIcao &&
|
||||
flightState._trackedEntity
|
||||
) {
|
||||
// Honest readout: the tracked plane has no faded billboard (its
|
||||
// entity owns the visual), so refresh the label — with icao24 now
|
||||
// in _missingPolls, _trackedLabelText appends the STALE cue so
|
||||
// last-known velocity/altitude aren't presented as live.
|
||||
parts.tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flightState._missingPolls.delete(icao24);
|
||||
|
||||
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
||||
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
if (icao24 === flightState._trackedIcao) {
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
flightState._flightData.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._geoidNCache.delete(icao24);
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
// Ambient type enrichment: give ON-SCREEN planes real types (bounded
|
||||
// sweep — see _sweepAmbientEnrichment; internally fail-silent).
|
||||
parts.enrichment._sweepAmbientEnrichment();
|
||||
|
||||
// 2026-08-19: the loop above only ever collects FIX cells, but a grounded
|
||||
// contact renders across every cell its dead-reckoned position drifts
|
||||
// through. Add those too, so the display clamp has data where the contact
|
||||
// actually is instead of silently passing.
|
||||
parts.motion._collectDisplayCorridorCells(
|
||||
floorWarmPoints,
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
);
|
||||
|
||||
// Field-test round 3: one batch warm of the viewer-proximate low-contact
|
||||
// floor cells collected in the loop (fire-and-forget, single-flight;
|
||||
// read synchronously by NEXT poll's clamp — the military-layer pattern).
|
||||
warmGroundFloor(floorWarmPoints);
|
||||
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
viewerLat: viewerLatDeg,
|
||||
viewerLon: viewerLonDeg,
|
||||
});
|
||||
|
||||
// Round 5: the grounded exact-key warm that used to live here is gone —
|
||||
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
||||
// viewer-proximate coarse warm + mesh sampler above cover everything
|
||||
// whose height is actually visible.
|
||||
// Round 6: re-floor STALE grounded contacts. A parked plane whose
|
||||
// transponder went quiet stops receiving poll updates, so a floor that
|
||||
// warms AFTER its last fix never applied — it sat frozen at the geoid
|
||||
// (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever).
|
||||
// Grounded contacts are static, so lifting the stored fix + billboard
|
||||
// in place is safe (the DR extrapolates a zero-velocity fix).
|
||||
parts.motion._refloorStaleGroundedContacts(currentIcaos);
|
||||
|
||||
flightState._count = flightState._billboards.size;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
flightState._lastUpdate = sourceEpochMs;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: acceptedSnapshotIcaos,
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
console.log(`[Data:Flights] Updated: ${flightState._count} aircraft`);
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
flightState._backoff = true;
|
||||
flightState._retryAt =
|
||||
feed._backoff = true;
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
flightState._lastStatus = e?.status ?? null;
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
flightState._lastSource = e.source;
|
||||
this.source = flightState._lastSource;
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
flightState._lastError =
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
|
||||
} finally {
|
||||
flightState._activeUpdateControllers.delete(resourceController);
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
/** Construct a fresh source lifetime and its refresh status. */
|
||||
export function createFlightFeed(source) {
|
||||
const feed = {};
|
||||
feed._source = source;
|
||||
feed._count = 0;
|
||||
feed._lastUpdate = null;
|
||||
feed._backoff = false;
|
||||
feed._retryAt = 0;
|
||||
feed._lastError = null;
|
||||
feed._activeUpdateControllers = new Set();
|
||||
feed._lastStatus = null;
|
||||
feed._lastSource = source?.label || 'Aircraft';
|
||||
feed._lastCoverage = 'worldwide upstream snapshot';
|
||||
feed._trackingRefreshEpoch = 0;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: 0,
|
||||
status: 'unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
coverage: null,
|
||||
};
|
||||
return feed;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
|
||||
function setup(source) {
|
||||
const feed = {
|
||||
_source: source,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_trackingRefreshEpoch: 0,
|
||||
_lastSource: 'Fixture',
|
||||
_lastCoverage: 'local',
|
||||
_retryAt: 0,
|
||||
};
|
||||
const accepted = [];
|
||||
const labels = [];
|
||||
const restores = [];
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
getQuery: () => ({ latitude: 39 }),
|
||||
applySnapshot: (snapshot) => {
|
||||
accepted.push(snapshot);
|
||||
return { count: 2, ids: new Set(['abc123']) };
|
||||
},
|
||||
setSourceLabel: (label) => labels.push(label),
|
||||
applyPendingTrackingRestore: () => restores.push(true),
|
||||
});
|
||||
return { feed, accepted, labels, restores, update: methods.update };
|
||||
}
|
||||
|
||||
test('civil acquisition rejects late completion after cancellation without publishing records', async () => {
|
||||
let release;
|
||||
const probe = setup({
|
||||
getSnapshot: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
const request = probe.update(null);
|
||||
assert.equal(probe.feed._activeUpdateControllers.size, 1);
|
||||
for (const controller of probe.feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
release({ records: [] });
|
||||
await assert.rejects(request, { name: 'AbortError' });
|
||||
assert.equal(probe.accepted.length, 0);
|
||||
assert.equal(probe.restores.length, 0);
|
||||
assert.equal(probe.feed._activeUpdateControllers.size, 0);
|
||||
});
|
||||
|
||||
test('civil acquisition publishes source time and uses a replaced source on the next request', async () => {
|
||||
let calls = 0;
|
||||
const probe = setup({
|
||||
async getSnapshot(query) {
|
||||
assert.equal(query.latitude, 39);
|
||||
calls += 1;
|
||||
return {
|
||||
records: [],
|
||||
source: 'First',
|
||||
observedAtMs: 1234,
|
||||
freshness: 'fresh',
|
||||
coverage: 'worldwide',
|
||||
};
|
||||
},
|
||||
});
|
||||
await probe.update(null);
|
||||
assert.equal(probe.feed._lastUpdate, 1234);
|
||||
assert.equal(probe.feed._count, 2);
|
||||
assert.deepEqual([...probe.feed._lastTrackingRefreshOutcome.ids], ['abc123']);
|
||||
probe.feed._source = {
|
||||
async getSnapshot() {
|
||||
return {
|
||||
records: [],
|
||||
source: 'Second',
|
||||
observedAtMs: 1250,
|
||||
freshness: 'unknown',
|
||||
};
|
||||
},
|
||||
};
|
||||
await probe.update(null);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(probe.feed._lastUpdate, 1250);
|
||||
assert.equal(probe.feed._backoff, true);
|
||||
assert.equal(probe.feed._lastError, 'Source snapshot time unavailable');
|
||||
assert.deepEqual(probe.labels, ['First', 'Second']);
|
||||
assert.equal(probe.restores.length, 2);
|
||||
});
|
||||
@@ -26,9 +26,10 @@ export function createLifecycle({
|
||||
throw new Error('Configure the source before layer initialization');
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
flightState._source = source;
|
||||
flightState._lastSource = source.label || flightState._lastSource;
|
||||
this.source = flightState._lastSource;
|
||||
flightState.feed._source = source;
|
||||
flightState.feed._lastSource =
|
||||
source.label || flightState.feed._lastSource;
|
||||
this.source = flightState.feed._lastSource;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,7 @@ export function createLifecycle({
|
||||
init(viewer) {
|
||||
if (flightState._viewer)
|
||||
throw new Error('Flight layer is already initialized');
|
||||
if (typeof flightState._source?.getSnapshot !== 'function')
|
||||
if (typeof flightState.feed._source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
if (flightState.lifetime.signal.aborted)
|
||||
flightState.lifetime = new AbortController();
|
||||
@@ -78,19 +79,21 @@ export function createLifecycle({
|
||||
});
|
||||
}
|
||||
flightState._billboards = new Map();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects = new Map();
|
||||
flightState._flightData = new Map();
|
||||
flightState.records.data = new Map();
|
||||
flightState._positionHistory = new Map();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._count = 0;
|
||||
flightState._lastUpdate = null;
|
||||
flightState._backoff = false;
|
||||
flightState._retryAt = 0;
|
||||
flightState._lastError = null;
|
||||
flightState._lastStatus = null;
|
||||
flightState._lastSource = flightState._source.label || 'Aircraft';
|
||||
flightState._lastCoverage = 'worldwide upstream snapshot';
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
flightState.feed._backoff = false;
|
||||
flightState.feed._retryAt = 0;
|
||||
flightState.feed._lastError = null;
|
||||
flightState.feed._lastStatus = null;
|
||||
flightState.feed._lastSource =
|
||||
flightState.feed._source.label || 'Aircraft';
|
||||
flightState.feed._lastCoverage = 'worldwide upstream snapshot';
|
||||
flightState._trackedIcao = null;
|
||||
parts.tracking._resetTrackedSelectionState();
|
||||
flightState._trackedEntity = null;
|
||||
@@ -142,11 +145,11 @@ export function createLifecycle({
|
||||
// Height-datum fix: warm the geoid grid once per layer-enable. The poll loop
|
||||
// only reads geoidHeight() synchronously after this resolves (guarded by
|
||||
// _geoidReady) — never awaited per-aircraft, never blocking a poll tick.
|
||||
if (!flightState._geoidReady) {
|
||||
if (!flightState.records.geoidReady) {
|
||||
const lifetime = flightState.lifetime;
|
||||
ensureGeoidReady()
|
||||
.then(() => {
|
||||
if (!lifetime.signal.aborted) flightState._geoidReady = true;
|
||||
if (!lifetime.signal.aborted) flightState.records.geoidReady = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
||||
@@ -300,8 +303,9 @@ export function createLifecycle({
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState._flightData.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
@@ -312,20 +316,20 @@ export function createLifecycle({
|
||||
clearTimeout(flightState._enrichDripTimer);
|
||||
flightState._enrichDripTimer = null;
|
||||
}
|
||||
flightState._missingPolls.clear();
|
||||
flightState.records.missingPolls.clear();
|
||||
flightState._focusEvidenceIds.clear();
|
||||
flightState._count = 0;
|
||||
flightState._lastUpdate = null;
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
flightState._cockpitContactMode = false;
|
||||
flightState._cockpitNearContacts = new Set();
|
||||
flightState._cockpitSubjectId = null;
|
||||
flightState._trackingRefreshEpoch += 1;
|
||||
flightState._lastTrackingRefreshOutcome = {
|
||||
epoch: flightState._trackingRefreshEpoch,
|
||||
flightState.feed._trackingRefreshEpoch += 1;
|
||||
flightState.feed._lastTrackingRefreshOutcome = {
|
||||
epoch: flightState.feed._trackingRefreshEpoch,
|
||||
status: 'destroyed',
|
||||
ids: new Set(),
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
source: flightState.feed._lastSource,
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
};
|
||||
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
||||
flightState._viewer = null;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_MODEL_URL,
|
||||
CLASS_SCALE_3D,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
trailAnchorForModel,
|
||||
visualCenterForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* The glTF asset, scale and measured geometry a civil aircraft class renders
|
||||
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
|
||||
* belly and radius); classes without one share airplane.glb at the class's
|
||||
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
|
||||
* the same model for the same class.
|
||||
* @param {string} klass Aircraft class from `classifyAircraft`.
|
||||
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
|
||||
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
|
||||
*/
|
||||
export function civilAircraftModelSpec(klass) {
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
if (real) {
|
||||
return {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
}
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
return {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _refloorStaleGroundedContacts(currentIcaos) {
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
for (const [icao24, info] of flightState.records.data) {
|
||||
if (!info?.onGround || currentIcaos.has(icao24)) continue;
|
||||
if (!Number.isFinite(info.rawLat) || !Number.isFinite(info.rawLon))
|
||||
continue;
|
||||
@@ -94,7 +94,7 @@ export function createMotion({
|
||||
)
|
||||
continue;
|
||||
info.renderAltitudeM = lifted;
|
||||
info.cullPosition = null; // above the ellipsoid now (or floors say otherwise next poll)
|
||||
flightState._cullPositions.delete(icao24); // above the ellipsoid now (or floors say otherwise next poll)
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
info.rawLon,
|
||||
info.rawLat,
|
||||
@@ -125,7 +125,7 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _deadReckon(icao24, result) {
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (FOCUS_EVIDENCE_DEV && flightState._focusEvidenceIds.has(icao24)) {
|
||||
const position = flightState._billboards.get(icao24)?.position;
|
||||
flightState._drCourseDeg = info?.true_track || 0;
|
||||
@@ -323,7 +323,7 @@ export function createMotion({
|
||||
const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position);
|
||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState._flightData.get(icao24)?.klass,
|
||||
flightState.records.data.get(icao24)?.klass,
|
||||
);
|
||||
const scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
@@ -349,7 +349,7 @@ export function createMotion({
|
||||
const height = billboard?.height?.getValue(time) ?? 28;
|
||||
const scale =
|
||||
billboard?.scale?.getValue(time) ??
|
||||
(CLASS_SCALE_2D[flightState._flightData.get(icao24)?.klass] || 1);
|
||||
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
|
||||
const scaleByDistance =
|
||||
billboard?.scaleByDistance?.getValue(time) ??
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
||||
@@ -392,7 +392,7 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const speed = (info && info.velocity) || 0;
|
||||
@@ -499,7 +499,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
return modelVisualAnchor(
|
||||
flightState._trackedModel.modelMatrix,
|
||||
@@ -544,7 +544,7 @@ export function createMotion({
|
||||
)
|
||||
return 0;
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
const scale = Number.isFinite(flightState._trackedModel.computedScale)
|
||||
? flightState._trackedModel.computedScale
|
||||
@@ -558,7 +558,7 @@ export function createMotion({
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
) {
|
||||
const spec = parts.rendering._modelSpec(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
// Through the model's OWN render chain (modelVisualAnchor's hand-rolled
|
||||
// half-correction put this offset on the lateral axis — see
|
||||
@@ -584,7 +584,7 @@ export function createMotion({
|
||||
* the same aircraft can never disagree across the handoff. */
|
||||
|
||||
function _trackedDisplayCourse() {
|
||||
const info = flightState._flightData.get(flightState._trackedIcao);
|
||||
const info = flightState.records.data.get(flightState._trackedIcao);
|
||||
const fallback = (info && info.true_track) || 0;
|
||||
const cacheValid =
|
||||
flightState._drReconcileValid &&
|
||||
@@ -1018,7 +1018,7 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
for (const [icao24, info] of flightState.records.data) {
|
||||
if (!info?.onGround) continue;
|
||||
// T7: a contact whose 3D model is the visual never reads a display floor.
|
||||
if (parts.rendering._modelOwnsVisual(icao24)) continue;
|
||||
|
||||
@@ -56,18 +56,25 @@ test('flight state owns distinct mutable records, scratch objects and floor samp
|
||||
const first = createFlightState({ services: services() });
|
||||
const second = createFlightState({ services: services() });
|
||||
for (const key of [
|
||||
'_flightData',
|
||||
'records',
|
||||
'feed',
|
||||
'_billboards',
|
||||
'_positionHistory',
|
||||
'_displayFloorState',
|
||||
'_groundSnap',
|
||||
'_scratchCarto',
|
||||
'_models',
|
||||
'_activeUpdateControllers',
|
||||
'lifetime',
|
||||
]) {
|
||||
assert.notEqual(first[key], second[key], key);
|
||||
}
|
||||
assert.notEqual(first.records.data, second.records.data);
|
||||
assert.notEqual(first.records.missingPolls, second.records.missingPolls);
|
||||
assert.notEqual(first.records.geoidNCache, second.records.geoidNCache);
|
||||
assert.notEqual(
|
||||
first.feed._activeUpdateControllers,
|
||||
second.feed._activeUpdateControllers,
|
||||
);
|
||||
});
|
||||
|
||||
test('aborted enrichment cannot write into a later lifecycle or decrement its active count', async () => {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export {
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
POSITION_HISTORY_LIMIT,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
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;
|
||||
|
||||
@@ -6,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
|
||||
@@ -101,14 +115,6 @@ export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
|
||||
|
||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
||||
|
||||
/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */
|
||||
|
||||
export const ERROR_BACKOFF_INTERVAL = 20000;
|
||||
// transient error retry
|
||||
/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */
|
||||
|
||||
export const POSITION_HISTORY_LIMIT = 5;
|
||||
|
||||
export const COCKPIT_CONTACT_SIZE_PX = 6;
|
||||
|
||||
export const COCKPIT_CIVILIAN_COLOR =
|
||||
@@ -151,8 +157,6 @@ export const RENDER_DELAY_SEC = 30;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
|
||||
export const MISSING_POLL_LIMIT = 3;
|
||||
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
// lingered ~2 min at airports after touchdown). OpenSky's on_ground flag LAGS
|
||||
// the actual landing, so a landed plane's last airborne-classified fixes show
|
||||
@@ -174,8 +178,6 @@ export const LANDED_SPEED_MAX_MPS = 23;
|
||||
|
||||
/** @constant {number} Missed-poll allowance for likely-landed planes (1 = removed on the first missed poll). */
|
||||
|
||||
export const LANDED_MISSING_POLL_LIMIT = 1;
|
||||
|
||||
// Field-test rounds 1+3 (2026-07-06): below-ground floor clamp scope. Only
|
||||
// contacts rendering below the alt ceiling are ever clamped/warmed (terrain
|
||||
// tops out well under it outside the extreme Himalaya; cruise traffic can't
|
||||
@@ -184,12 +186,8 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
|
||||
// need unbounded terrain resolution (the tracked contact clamps regardless).
|
||||
/** @constant {number} Max render altitude (m, ellipsoidal) eligible for the ground-floor clamp. */
|
||||
|
||||
export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500;
|
||||
|
||||
/** @constant {number} Max viewer distance (km) for the fleet ground-floor clamp. */
|
||||
|
||||
export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon orientation (2026-06-10 playtest fix): rotation is computed by
|
||||
// projecting each aircraft's course vector into WINDOW coordinates
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js';
|
||||
import { displayedKinematics } from '../../data/motionModel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
flightState,
|
||||
@@ -43,13 +45,6 @@ export function createQueries({
|
||||
* @returns {number} Approximate great-circle distance in km.
|
||||
*/
|
||||
|
||||
function _approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
const dLon =
|
||||
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft's latest metadata reads "on or about the runway"
|
||||
* (low + slow) — see the landed fast-cull rationale above. Both gates must
|
||||
@@ -59,7 +54,7 @@ export function createQueries({
|
||||
*/
|
||||
|
||||
function _likelyLanded(icao24) {
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (!info) return false;
|
||||
// Round 7 (owner: "fewer planes than OpenSky's own map" + "parked planes
|
||||
// never heal"): the fast cull only applies to contacts that were AIRBORNE
|
||||
@@ -136,7 +131,7 @@ export function createQueries({
|
||||
// user-visible surface renders `position` as the plane's on-screen location —
|
||||
// every visual consumer reads the floored per-frame cache instead (see
|
||||
// `_trackedDisplayPosition`). If that ever changes, floor this path too.
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
const basePos =
|
||||
parts.motion._deadReckon(icao24) || (bb ? bb.position : null);
|
||||
@@ -170,8 +165,13 @@ export function createQueries({
|
||||
velocityMps: displayed.speedMps,
|
||||
track: displayed.trackDeg,
|
||||
stale: Boolean(
|
||||
flightState._missingPolls.get(icao24) || flightState._backoff,
|
||||
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.
|
||||
@@ -224,7 +224,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(
|
||||
@@ -258,6 +261,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,
|
||||
};
|
||||
@@ -271,7 +288,7 @@ export function createQueries({
|
||||
|
||||
icon: '✈️',
|
||||
|
||||
source: flightState._lastSource,
|
||||
source: flightState.feed._lastSource,
|
||||
|
||||
// Browser-harness seam: isolates synthetic display-floor scenarios without
|
||||
// changing any production lifecycle or cache policy.
|
||||
@@ -429,7 +446,7 @@ export function createQueries({
|
||||
const model = flightState._models.get(icao24);
|
||||
const modelOwnsVisual = parts.rendering._modelOwnsVisual(icao24);
|
||||
if (!isTracked && !bb.show && !modelOwnsVisual) continue;
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
let object = flightState._detectionObjects.get(icao24);
|
||||
if (!object) {
|
||||
object = {
|
||||
@@ -496,7 +513,7 @@ export function createQueries({
|
||||
* Best match with a cloned, dead-reckoned position, or null if none.
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (!flightState._flightData || flightState._flightData.size === 0)
|
||||
if (!flightState.records.data || flightState.records.data.size === 0)
|
||||
return null;
|
||||
const q = String(query || '')
|
||||
.trim()
|
||||
@@ -513,7 +530,7 @@ export function createQueries({
|
||||
// strictly tiered so a registration can never out-rank a real callsign on
|
||||
// feed order alone.
|
||||
let best = null;
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
for (const [icao24, info] of flightState.records.data) {
|
||||
const candidate = {
|
||||
tier: rankContactMatch({
|
||||
query: q,
|
||||
@@ -580,7 +597,7 @@ export function createQueries({
|
||||
const distance = Cesium.Cartesian3.distance(center, pos);
|
||||
if (distance > maxRange) continue;
|
||||
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const callsign = info?.callsign?.trim() || null;
|
||||
nearby.push({
|
||||
// Label. Callers that need identity read `icao24` (Context cohorts do).
|
||||
@@ -664,7 +681,7 @@ export function createQueries({
|
||||
flightState._scratchCarto,
|
||||
);
|
||||
if (!carto) continue;
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
result.push({
|
||||
id: icao24, // identity (trackById resolves this)
|
||||
label: _contactLabel(icao24, info),
|
||||
@@ -703,14 +720,14 @@ export function createQueries({
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
flightState._flightData.size === 0
|
||||
flightState.records.data.size === 0
|
||||
)
|
||||
return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const [icao24, info] of flightState._flightData) {
|
||||
for (const [icao24, info] of flightState.records.data) {
|
||||
const routeOk =
|
||||
!!info?.route && parts.tracking._routeIsPlausible(icao24, info.route);
|
||||
result.push(
|
||||
@@ -754,7 +771,7 @@ export function createQueries({
|
||||
};
|
||||
const id = _normalizeTrackedIcao(icao24);
|
||||
if (!id) return { status: 'missing', reason: 'invalid-target' };
|
||||
const outcome = flightState._lastTrackingRefreshOutcome;
|
||||
const outcome = flightState.feed._lastTrackingRefreshOutcome;
|
||||
if (outcome.status !== 'accepted') {
|
||||
return {
|
||||
status: 'source-unavailable',
|
||||
@@ -925,18 +942,21 @@ export function createQueries({
|
||||
* @returns {{count: number, lastUpdate: number|null, stale: boolean, error: string|null, status: number|null, retryInSec: number}}
|
||||
*/
|
||||
getStats() {
|
||||
const retryInSec = flightState._retryAt
|
||||
? Math.max(0, Math.ceil((flightState._retryAt - Date.now()) / 1000))
|
||||
const retryInSec = flightState.feed._retryAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil((flightState.feed._retryAt - Date.now()) / 1000),
|
||||
)
|
||||
: 0;
|
||||
return {
|
||||
count: flightState._count,
|
||||
lastUpdate: flightState._lastUpdate,
|
||||
stale: flightState._backoff,
|
||||
error: flightState._lastError,
|
||||
status: flightState._lastStatus,
|
||||
count: flightState.feed._count,
|
||||
lastUpdate: flightState.feed._lastUpdate,
|
||||
stale: flightState.feed._backoff,
|
||||
error: flightState.feed._lastError,
|
||||
status: flightState.feed._lastStatus,
|
||||
retryInSec,
|
||||
source: flightState._lastSource,
|
||||
coverage: flightState._lastCoverage,
|
||||
source: flightState.feed._lastSource,
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const GROUND_FLOOR_CLAMP_RADIUS_KM = 150;
|
||||
|
||||
export const GROUND_FLOOR_WARM_MAX_ALT_M = 4500;
|
||||
|
||||
/** Maximum feed fixes retained for dead reckoning. */
|
||||
export const POSITION_HISTORY_LIMIT = 5;
|
||||
|
||||
export const LANDED_MISSING_POLL_LIMIT = 1;
|
||||
|
||||
export const MISSING_POLL_LIMIT = 3;
|
||||
|
||||
/** Cooldown in milliseconds after a transient source error. */
|
||||
export const ERROR_BACKOFF_INTERVAL = 20000;
|
||||
|
||||
/** Local equirectangular distance used for viewer-proximate floor sampling. */
|
||||
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat2 - lat1) * 111.32;
|
||||
const dLon =
|
||||
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
|
||||
return Math.hypot(dLat, dLon);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { stickyNumber, stickyText } from '../../data/aircraftMeta.js';
|
||||
import {
|
||||
geoidSurfaceLastResortM,
|
||||
pickRenderAltitudeM,
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
approxDistanceKm,
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
MISSING_POLL_LIMIT,
|
||||
} from './recordPolicy.js';
|
||||
|
||||
/** Source-independent aircraft metadata and bounded missing-poll retention. */
|
||||
export class FlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
};
|
||||
this.data = new Map();
|
||||
this.missingPolls = new Map();
|
||||
this.geoidNCache = new Map();
|
||||
this.geoidReady = false;
|
||||
}
|
||||
|
||||
receive(
|
||||
observation,
|
||||
{ viewerLatDeg, viewerLonDeg, trackedId, floorWarmPoints },
|
||||
) {
|
||||
const { geoidHeight, cachedGroundFloor, floorAltitudeM } = this.services;
|
||||
const {
|
||||
id: icao24,
|
||||
callsign,
|
||||
originCountry: origin_country,
|
||||
longitude: lon,
|
||||
latitude: lat,
|
||||
baroAltitudeM: baro_alt,
|
||||
onGround: on_ground,
|
||||
speedMps: velocity,
|
||||
courseDeg: true_track,
|
||||
ellipsoidAltitudeM: geo_alt,
|
||||
category,
|
||||
verticalRateMps: vertical_rate,
|
||||
} = observation;
|
||||
|
||||
const onGround = on_ground === true;
|
||||
this.missingPolls.delete(icao24);
|
||||
// Sticky merge: OpenSky intermittently drops callsign/velocity/track for
|
||||
// aircraft it still positions — hold last-known-good instead of
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = this.data.get(icao24);
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
// barometric/MSL altitude read by labels (FL/altitude readout),
|
||||
// route-plausibility, and follow-camera range heuristics. It is
|
||||
// NEVER overwritten or renamed. Where the aircraft actually RENDERS
|
||||
// on the ellipsoidal globe is a SEPARATE value (renderAltitudeM,
|
||||
// below) — geo_altitude when OpenSky reports it (already WGS84
|
||||
// ellipsoidal), else baro+geoid as a visual fallback, else ground
|
||||
// surface when parked. `Cartesian3.fromDegrees` gets renderAltitudeM,
|
||||
// never `alt` directly.
|
||||
const alt = stickyNumber(
|
||||
baro_alt,
|
||||
prevMeta?.altitude,
|
||||
onGround ? 0 : 10000,
|
||||
);
|
||||
|
||||
// geoid undulation N: cached per-aircraft (negligible drift — see
|
||||
// task brief) once the geoid grid has loaded; unavailable pre-load
|
||||
// just means the baro fallback branch below adds N=0 for a beat.
|
||||
let geoidN = this.geoidNCache.get(icao24);
|
||||
if (geoidN === undefined && this.geoidReady) {
|
||||
geoidN = geoidHeight(lat, lon);
|
||||
this.geoidNCache.set(icao24, geoidN);
|
||||
}
|
||||
|
||||
// on_ground surface prior: ONLY synchronous warm-cache reads here —
|
||||
// never a per-aircraft network fetch inside the poll loop (see the
|
||||
// batch resolve call below, which fills this cache for NEXT poll). A
|
||||
// Round 5 SIMPLIFICATION (owner directive: one floor, evenly applied):
|
||||
// the grounded surface is the round-4 choke point and nothing else —
|
||||
// rendered-mesh cell first, real (never fallback-poisoned) DEM cell
|
||||
// second. The old exact-5-decimal warm chain is GONE: it minted a new
|
||||
// key per parked-jitter poll for every grounded contact ON EARTH,
|
||||
// hammering Re:Earth into the very failures that poisoned the cache.
|
||||
let surfaceM = null;
|
||||
if (onGround) {
|
||||
surfaceM = cachedGroundFloor(lat, lon); // mesh ?? real DEM (coarse cell)
|
||||
// Taxiing crosses into a fresh cold cell every poll — always one
|
||||
// step ahead of the warm batch — so fall back to LAST poll's cell
|
||||
// (warmed by last poll's batch; aprons are flat across adjacent
|
||||
// 111 m cells). Round-5 verify caught taxiing contacts stuck at
|
||||
// the geoid without this (round 2's lesson, at cell granularity).
|
||||
if (
|
||||
surfaceM == null &&
|
||||
Number.isFinite(prevMeta?.rawLat) &&
|
||||
Number.isFinite(prevMeta?.rawLon)
|
||||
) {
|
||||
surfaceM = cachedGroundFloor(prevMeta.rawLat, prevMeta.rawLon);
|
||||
}
|
||||
// Grounded contacts near the viewer feed the floor warm/sampler
|
||||
// (the only ones whose exact height is visible; far contacts are
|
||||
// subpixel and always-on-top anyway).
|
||||
if (
|
||||
viewerLatDeg != null &&
|
||||
approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <=
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM
|
||||
) {
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
// Last synchronous resort for a BRAND-NEW grounded contact with NO
|
||||
// altitude data at all (nothing warm yet, not even the coarse
|
||||
// cell): the geoid surface. At the sea-level airports where most
|
||||
// grounded traffic sits, geoidN IS the local ellipsoidal ground to
|
||||
// within metres — instantly right — and at elevated fields it is
|
||||
// far less wrong than the raw 0 m ellipsoid default for the one
|
||||
// poll until the coarse cell warms. STRICTLY gated on "no geo, no
|
||||
// baro": a reported baro already reflects the field elevation, and
|
||||
// pickRenderAltitudeM's surfaceM branch would let this crude guess
|
||||
// outrank it (caught by the ground-3d track regression).
|
||||
//
|
||||
// 2026-08-21: the rule moved to geoidSurfaceLastResortM, which adds
|
||||
// one more gate — a contact that already HAS a render height keeps
|
||||
// it. Leaving surfaceM null routes it through the sentinel path
|
||||
// below, which holds that height.
|
||||
if (surfaceM == null) {
|
||||
surfaceM = geoidSurfaceLastResortM({
|
||||
geoAltM: geo_alt,
|
||||
baroAltM: baro_alt,
|
||||
priorRenderM: prevMeta?.renderAltitudeM,
|
||||
geoidN,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const geoAltitudeM = Number.isFinite(geo_alt) ? geo_alt : null;
|
||||
const pickedAltM = pickRenderAltitudeM({
|
||||
geoAltM: geoAltitudeM,
|
||||
baroAltM: Number.isFinite(baro_alt) ? baro_alt : null,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
});
|
||||
// pickRenderAltitudeM returns the sentinel `null` only when NEITHER
|
||||
// geo_altitude nor baro_altitude was reported THIS poll. Two fallbacks,
|
||||
// in priority order:
|
||||
// (1) hold the previous geoid-corrected render height if we have one —
|
||||
// a one-poll baro dropout must NOT snap the plane down by the geoid
|
||||
// undulation N (~46 m in London) and back up next poll. `alt` stays
|
||||
// sticky for labels, so holding the last render height keeps the two
|
||||
// layers consistent through the gap.
|
||||
// (2) otherwise the SAME default policy `alt` already uses, so the two
|
||||
// never disagree on the genuine "no data yet" case (a never-reported
|
||||
// aircraft has no prior render height, so it lands here unchanged).
|
||||
let renderAltitudeM;
|
||||
if (pickedAltM != null) {
|
||||
renderAltitudeM = pickedAltM;
|
||||
} else if (Number.isFinite(prevMeta?.renderAltitudeM)) {
|
||||
renderAltitudeM = prevMeta.renderAltitudeM;
|
||||
} else {
|
||||
renderAltitudeM = alt;
|
||||
}
|
||||
// Field-test fix (WAKE01/RS46 class, 2026-07-06; widened round 3):
|
||||
// floor a low airborne contact's render height at the local coarse
|
||||
// ground so it can never dive below the mesh. Round 3 (Austin
|
||||
// fleet-underground): baro can read BELOW an elevated field — SWA696
|
||||
// showed 450 ft at Austin's 542 ft field elevation — and rollout/taxi
|
||||
// traffic that OpenSky hasn't flagged on_ground yet renders from that
|
||||
// baro, so the whole fleet sat buried at AUS in 2D. Clamping every
|
||||
// global contact would need unbounded terrain resolution; instead the
|
||||
// clamp covers the TRACKED contact (always) plus every low contact
|
||||
// within GROUND_FLOOR_CLAMP_RADIUS_KM of the viewer — the only ones
|
||||
// whose burial is visible. Cells warm in one batch after the loop.
|
||||
// Airborne only (grounded planes keep the surface-cache path above).
|
||||
if (
|
||||
!onGround &&
|
||||
renderAltitudeM < GROUND_FLOOR_WARM_MAX_ALT_M &&
|
||||
(icao24 === trackedId ||
|
||||
(viewerLatDeg != null &&
|
||||
approxDistanceKm(viewerLatDeg, viewerLonDeg, lat, lon) <=
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM))
|
||||
) {
|
||||
renderAltitudeM = floorAltitudeM(
|
||||
renderAltitudeM,
|
||||
cachedGroundFloor(lat, lon),
|
||||
);
|
||||
floorWarmPoints.push({ lat, lon });
|
||||
}
|
||||
|
||||
// Landing/takeoff transition: the on_ground flip restyles IN PLACE.
|
||||
const groundFlipped =
|
||||
!!prevMeta && (prevMeta.onGround === true) !== onGround;
|
||||
// Store flight metadata for click-to-track labels
|
||||
const cat = stickyNumber(category, prevMeta?.category, null);
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
altitude: alt,
|
||||
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
|
||||
// untouched aviation `altitude` — never rename/replace it (labels,
|
||||
// FL readout, route-plausibility, and follow-camera range math all
|
||||
// still read `altitude`/baro).
|
||||
geoAltitudeM,
|
||||
renderAltitudeM,
|
||||
onGround,
|
||||
// Round 7: sticky airborne history — the landed fast-cull only
|
||||
// applies to contacts that actually flew this session.
|
||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||
velocity: stickyNumber(velocity, prevMeta?.velocity, 0),
|
||||
true_track: stickyNumber(true_track, prevMeta?.true_track, 0),
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
verticalRate: stickyNumber(vertical_rate, prevMeta?.verticalRate, null),
|
||||
// Analyst seam: OpenSky origin_country (state[2]) — additive, sticky
|
||||
// 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
|
||||
// while fresh velocity/track messages are still arriving.
|
||||
lastContactEpochMs: stickyNumber(
|
||||
observation.contactTimeMs,
|
||||
prevMeta?.lastContactEpochMs,
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: prevMeta?.airline ?? null,
|
||||
route: prevMeta?.route ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
// actual reported fix.
|
||||
rawLat: lat,
|
||||
rawLon: lon,
|
||||
};
|
||||
this.data.set(icao24, meta);
|
||||
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
|
||||
}
|
||||
|
||||
absence(id, { complete, likelyLanded }) {
|
||||
// Partial admissions do not prove absence, but retention remains bounded.
|
||||
if (
|
||||
!complete &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
if (misses < limit) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
this.missingPolls.delete(id);
|
||||
return 'remove';
|
||||
}
|
||||
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlightRecords } from './records.js';
|
||||
|
||||
function records() {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 40,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
approxDistanceKm: () => 0,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
const observation = (fields = {}) => ({
|
||||
id: 'abc123',
|
||||
reference: 'abc123',
|
||||
longitude: -77,
|
||||
latitude: 39,
|
||||
callsign: 'TEST1',
|
||||
baroAltitudeM: 1000,
|
||||
ellipsoidAltitudeM: null,
|
||||
onGround: false,
|
||||
speedMps: 120,
|
||||
courseDeg: 90,
|
||||
positionTimeMs: 1700000000000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
test('civil records keep aviation units separate from render height and survive missing fields', () => {
|
||||
const store = records();
|
||||
store.geoidReady = true;
|
||||
const first = store.receive(observation(), view());
|
||||
assert.equal(first.meta.altitude, 1000);
|
||||
assert.equal(first.meta.renderAltitudeM, 1040);
|
||||
assert.equal(first.fixEpochMs, 1700000000000);
|
||||
first.meta.typeCode = 'B738';
|
||||
const second = store.receive(
|
||||
observation({
|
||||
callsign: '',
|
||||
baroAltitudeM: null,
|
||||
speedMps: null,
|
||||
courseDeg: null,
|
||||
}),
|
||||
view(),
|
||||
);
|
||||
assert.equal(second.prevMeta, first.meta);
|
||||
assert.equal(second.meta.callsign, 'TEST1');
|
||||
assert.equal(second.meta.altitude, 1000);
|
||||
assert.equal(second.meta.renderAltitudeM, 1040);
|
||||
assert.equal(second.meta.velocity, 120);
|
||||
assert.equal(second.meta.true_track, 90);
|
||||
assert.equal(second.meta.typeCode, 'B738');
|
||||
assert.equal(Object.hasOwn(second.meta, 'cullPosition'), false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(second.meta)), second.meta);
|
||||
});
|
||||
|
||||
test('civil record owners isolate state and preserve partial, missing-poll and landed retention', () => {
|
||||
const store = records();
|
||||
const other = records();
|
||||
const { meta } = store.receive(observation(), view());
|
||||
assert.equal(other.data.size, 0);
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: false, likelyLanded: false }),
|
||||
'retain',
|
||||
);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
meta.observedReceiptMs = Date.now() - 300001;
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: false, likelyLanded: false }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, likelyLanded: false }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, likelyLanded: false }),
|
||||
'remove',
|
||||
);
|
||||
// Admission decides removal; follow teardown can still read the record first.
|
||||
assert.equal(store.data.get('abc123'), meta);
|
||||
store.forget('abc123');
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.geoidNCache.size, 0);
|
||||
store.receive(observation(), view());
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, likelyLanded: true }),
|
||||
'remove',
|
||||
);
|
||||
});
|
||||
|
||||
test('ground transitions preserve last render height while a surface cell is cold', () => {
|
||||
const store = records();
|
||||
store.receive(observation({ ellipsoidAltitudeM: 1040 }), view());
|
||||
const next = store.receive(
|
||||
observation({ onGround: true, baroAltitudeM: null }),
|
||||
view(),
|
||||
);
|
||||
assert.equal(next.groundFlipped, true);
|
||||
assert.equal(next.meta.renderAltitudeM, 1040);
|
||||
assert.equal(next.meta.wasAirborne, true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,14 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CLASS_SCALE_2D,
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_SCALE_3D,
|
||||
CLASS_MODEL_URL,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
visualCenterForModel,
|
||||
trailAnchorForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -19,12 +14,8 @@ import {
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
EMERGENCY_TINT,
|
||||
GROUND_SCALE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
@@ -65,19 +56,40 @@ 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) {
|
||||
return (
|
||||
(CLASS_SCALE_2D[klass] || 1) *
|
||||
(flightState._flightData.get(icao24)?.onGround ? GROUND_SCALE : 1)
|
||||
(flightState.records.data.get(icao24)?.onGround ? GROUND_SCALE : 1)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,31 +112,7 @@ export function createRendering({
|
||||
function _modelSpec(klass) {
|
||||
const cached = flightState._specCache.get(klass);
|
||||
if (cached) return cached;
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
let spec;
|
||||
if (real) {
|
||||
spec = {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
} else {
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
spec = {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
}
|
||||
const spec = civilAircraftModelSpec(klass);
|
||||
flightState._specCache.set(klass, spec);
|
||||
return spec;
|
||||
}
|
||||
@@ -158,20 +146,18 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
|
||||
if (isCockpitContact && !isCockpitNear) {
|
||||
const freshnessAlpha = bb.color?.alpha ?? 1;
|
||||
const freshnessAlpha = cyberSonarBaseAlpha(bb);
|
||||
bb.image = cockpitContactDotImage();
|
||||
bb.width = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.scale = limbScale;
|
||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||
bb.color = (
|
||||
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
|
||||
).withAlpha(freshnessAlpha);
|
||||
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
|
||||
bb.rotation = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
bb.image = aircraftIcon(
|
||||
_iconKind(icao24, meta?.klass),
|
||||
bb._gevIconLarge ? TRACKED_ICON_PX : undefined,
|
||||
@@ -180,7 +166,7 @@ export function createRendering({
|
||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
|
||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1);
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +183,7 @@ export function createRendering({
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -303,7 +289,7 @@ export function createRendering({
|
||||
* because a resample is mid-backoff. */
|
||||
|
||||
function _modelDisplayPosition(icao24, pos, result) {
|
||||
const meta = flightState._flightData.get(icao24);
|
||||
const meta = flightState.records.data.get(icao24);
|
||||
if (!meta || !meta.onGround) return pos;
|
||||
const h = flightState._groundSnap.heightFor(
|
||||
flightState._viewer,
|
||||
@@ -438,7 +424,7 @@ export function createRendering({
|
||||
* the same rule (its billboard entity is always the fallback visual). */
|
||||
|
||||
function _syncModelToClass(icao24) {
|
||||
const key = _specKeyFor(flightState._flightData.get(icao24)?.klass);
|
||||
const key = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
const current = flightState._models.get(icao24);
|
||||
if (
|
||||
(current && current._gevSpecKey !== key) ||
|
||||
@@ -510,10 +496,10 @@ export function createRendering({
|
||||
// aircraft mid-load, the post-await admission below rejects the stale asset.
|
||||
// Boost state likewise: the creation options bake it in, so a mid-load
|
||||
// toggle must reject too (the reload queue only covers ADMITTED models).
|
||||
const specKey = _specKeyFor(flightState._flightData.get(icao24)?.klass);
|
||||
const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
const loadIrBoost = flightState._irBoost;
|
||||
try {
|
||||
const spec = _modelSpec(flightState._flightData.get(icao24)?.klass);
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||
model = await Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
@@ -559,12 +545,12 @@ export function createRendering({
|
||||
!_modelRegimeActive() ||
|
||||
!flightState._modelCollection ||
|
||||
flightState._modelCollection.isDestroyed() ||
|
||||
!flightState._flightData.has(icao24) ||
|
||||
!flightState.records.data.has(icao24) ||
|
||||
icao24 === flightState._trackedIcao ||
|
||||
flightState._models.has(icao24) ||
|
||||
flightState._models.size >= _modelCap() ||
|
||||
// Class reclassified mid-load → this GLB/scale is for the OLD class.
|
||||
_specKeyFor(flightState._flightData.get(icao24)?.klass) !== specKey ||
|
||||
_specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey ||
|
||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||
flightState._irBoost !== loadIrBoost;
|
||||
if (stale) {
|
||||
@@ -714,10 +700,10 @@ export function createRendering({
|
||||
flightState._trackedModelLoading = true;
|
||||
const gen = flightState._trackedModelGen;
|
||||
const trackedSpec = _modelSpec(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
const trackedKey = _specKeyFor(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
const trackedIrBoost = flightState._irBoost;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
@@ -756,7 +742,7 @@ export function createRendering({
|
||||
// _trackedModel was still null): drop the stale asset; driver reloads.
|
||||
if (
|
||||
_specKeyFor(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
) !== trackedKey ||
|
||||
flightState._irBoost !== trackedIrBoost
|
||||
) {
|
||||
@@ -829,7 +815,7 @@ export function createRendering({
|
||||
);
|
||||
if (!flightState._trackedModel.ready) return;
|
||||
const spec = _modelSpec(
|
||||
flightState._flightData.get(flightState._trackedIcao)?.klass,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
@@ -953,31 +939,11 @@ export function createRendering({
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = new Set();
|
||||
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
|
||||
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
|
||||
// invisible and it re-adds the moment it's back in view).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
@@ -989,7 +955,7 @@ export function createRendering({
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
|
||||
const info = flightState._flightData.get(icao24);
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos);
|
||||
// The dead-reckoned point drifts away from the fix whose cell supplied the
|
||||
@@ -1018,7 +984,7 @@ export function createRendering({
|
||||
// pass would hide a plane that is really just low over high-N terrain
|
||||
// waiting for its floor to warm (ATL grounded contacts at geoid −31 m).
|
||||
const beyondHorizon = !occluder.isPointVisible(
|
||||
info?.cullPosition || bb.position,
|
||||
flightState._cullPositions.get(icao24) || bb.position,
|
||||
);
|
||||
// A billboard flipping INTO view (horizon reveal while the camera idles)
|
||||
// gets its rotation refreshed THIS tick even without a pose change —
|
||||
@@ -1062,19 +1028,18 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor =
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? isMilitaryIcao(icao24)
|
||||
? MIL_TINT
|
||||
: COCKPIT_CIVILIAN_COLOR
|
||||
: _fleetBillboardColor(icao24);
|
||||
? _cockpitContactColor(icao24, info)
|
||||
: _fleetBillboardColor(icao24, info);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? 1
|
||||
: _fleetBillboardScale(icao24, info?.klass),
|
||||
baseAlpha: flightState._missingPolls.get(icao24) ? 0.45 : 1,
|
||||
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
|
||||
baseColor,
|
||||
focusFactor: focus.factor,
|
||||
cameraDistanceM,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
synthesizeForwardKinematicsFix,
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
|
||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||
export function createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records,
|
||||
militaryRegistry,
|
||||
groundFloor,
|
||||
meshFloor,
|
||||
rendering,
|
||||
tracking,
|
||||
motion,
|
||||
enrichment,
|
||||
queries,
|
||||
}) {
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
|
||||
const { warmGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
// subpoint + collected floor cells for the viewer-proximate low-contact
|
||||
// clamp below — one carto read per poll, one batch warm after the loop.
|
||||
const viewerCarto =
|
||||
(viewer || flightState._viewer)?.camera?.positionCartographic || null;
|
||||
const viewerLatDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.latitude)
|
||||
: null;
|
||||
const viewerLonDeg = viewerCarto
|
||||
? Cesium.Math.toDegrees(viewerCarto.longitude)
|
||||
: null;
|
||||
const floorWarmPoints = [];
|
||||
|
||||
// Classification and display policy consume source-independent observations.
|
||||
refreshMilitaryRegistryIfStale();
|
||||
for (const observation of snapshot.records) {
|
||||
const icao24 = observation.id;
|
||||
acceptedSnapshotIcaos.add(icao24);
|
||||
|
||||
// Known-military aircraft: the dedicated military layer wins
|
||||
// (icon + track + click) while it is enabled — suppress the
|
||||
// OpenSky duplicate entirely (except a currently tracked one,
|
||||
// which hands off on untrack).
|
||||
const isMil = isMilitaryIcao(icao24);
|
||||
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
|
||||
const dupe = flightState._billboards.get(icao24);
|
||||
if (dupe) {
|
||||
flightState._billboardCollection.remove(dupe);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
records.forget(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIcaos.add(icao24);
|
||||
const { prevMeta, meta, groundFlipped, fixEpochMs } = records.receive(
|
||||
observation,
|
||||
{
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
trackedId: flightState._trackedIcao,
|
||||
floorWarmPoints,
|
||||
},
|
||||
);
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
meta.rawLon,
|
||||
meta.rawLat,
|
||||
meta.renderAltitudeM,
|
||||
);
|
||||
// Either flip retires the previous one-shot ground sample.
|
||||
if (groundFlipped) flightState._groundSnap.forget(icao24);
|
||||
flightState._cullPositions.set(
|
||||
icao24,
|
||||
meta.renderAltitudeM < 10
|
||||
? Cesium.Cartesian3.fromDegrees(meta.rawLon, meta.rawLat, 12)
|
||||
: null,
|
||||
);
|
||||
const isTracked = icao24 === flightState._trackedIcao;
|
||||
|
||||
// Append to position history stamped with the FEED's fix epoch
|
||||
// (time_position), not client receipt time — OpenSky positions arrive
|
||||
// 5-15s stale and receipt-time stamping is what caused the
|
||||
// back/forward oscillation. Only append when the fix actually
|
||||
// advances, so stale repeats don't create zero-dt segments.
|
||||
const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs));
|
||||
if (!flightState._positionHistory.has(icao24)) {
|
||||
flightState._positionHistory.set(icao24, []);
|
||||
}
|
||||
const history = flightState._positionHistory.get(icao24);
|
||||
const newest = history[history.length - 1];
|
||||
if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) {
|
||||
// Per-fix kinematics: the fix's own velocity/track ride along so the
|
||||
// extrapolation paths use the values that BELONG to the fix they
|
||||
// extend, not whatever the latest poll reported.
|
||||
history.push({
|
||||
time: fixTime,
|
||||
epochMs: fixEpochMs,
|
||||
position: position.clone(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
});
|
||||
if (history.length > POSITION_HISTORY_LIMIT) {
|
||||
history.shift();
|
||||
}
|
||||
// Turn rate from the fix-track history — computed once per new fix
|
||||
// (≤5 samples), consumed by the extrapolation paths at tick rate.
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
// Trail accumulation is separate from the 5-fix DR history (PRD F1)
|
||||
// so the visible trail keeps growing while tracked. Ground traffic
|
||||
// appends nothing — a touchdown freezes the existing trail.
|
||||
// Round 2 (owner): ground traffic appends too — taxi history stays
|
||||
// live after touchdown (grounded flights positions are already
|
||||
// surface-clamped via the surfaceM chain, so the ground leg drapes).
|
||||
if (isTracked) tracking._appendTrailFix(position.clone());
|
||||
} else {
|
||||
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
|
||||
if (!modelOwnsGroundVisual) {
|
||||
liftRepeatedGroundFix(newest, position, meta.onGround);
|
||||
}
|
||||
// Apply fresh kinematics only from a forward synthetic fix. Mutating
|
||||
// the historical fix reprojects the entire stale interval and snaps
|
||||
// the rendered aircraft when course or speed changes late.
|
||||
const kinematicsChanged =
|
||||
newest.velocity !== meta.velocity || newest.track !== meta.true_track;
|
||||
if (kinematicsChanged) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
});
|
||||
if (synthetic) {
|
||||
history.push(synthetic);
|
||||
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
|
||||
meta.turnRateDps = turnRateFromFixHistory(history);
|
||||
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flightState._billboards.has(icao24)) {
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
// Reclassify if the category resolved/changed (first extended poll);
|
||||
// a ground flip (landing/takeoff) re-scales the SAME billboard in
|
||||
// place — the transition is a restyle, never a removal.
|
||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||
// Position AND rotation are owned by the fleet pass (_fleetTick);
|
||||
// course changes land on the next rotation pass (forced below).
|
||||
if (!isTracked) {
|
||||
// Refresh affiliation hue without clobbering the tick-owned
|
||||
// freshness × focus × horizon alpha composition.
|
||||
bb.color = rendering
|
||||
._fleetBillboardColor(icao24)
|
||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
groundFlipped ||
|
||||
flightState._cockpitContactMode
|
||||
) {
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
// Poll-path class change (category updates): same model resync rule
|
||||
// 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: displayed,
|
||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||
width: isTracked ? 24 : 20,
|
||||
height: isTracked ? 24 : 20,
|
||||
scale: rendering._fleetBillboardScale(icao24, meta.klass),
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? Cesium.Color.CYAN
|
||||
: rendering._fleetBillboardColor(icao24),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
// surface — render them depth-test-free so they never vanish up
|
||||
// close (_groundDepthDistance).
|
||||
disableDepthTestDistance: rendering._groundDepthDistance(),
|
||||
id: icao24,
|
||||
show: !isTracked, // hidden if currently tracked (entity replaces it)
|
||||
});
|
||||
flightState._billboards.set(icao24, bb);
|
||||
rendering._applyFleetBillboardPresentation(icao24, bb);
|
||||
}
|
||||
|
||||
// Takeoff while TRACKED: ground traffic drew no trail, so start one
|
||||
// from the fresh airborne history (touchdown needs no action — the
|
||||
// append gate above simply freezes the existing trail).
|
||||
if (isTracked && groundFlipped && !meta.onGround)
|
||||
tracking._startTrail(icao24);
|
||||
|
||||
// If this is the tracked aircraft, update label text
|
||||
// (position updates automatically via dead-reckoning CallbackProperty)
|
||||
if (isTracked && flightState._trackedEntity) {
|
||||
tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove aircraft only after MISSING_POLL_LIMIT consecutive absences.
|
||||
// OpenSky routinely drops aircraft for a single poll; immediate removal
|
||||
// made planes blink and yanked the camera off actively tracked flights.
|
||||
// EXCEPTION: likely-landed planes (last fix low + slow) get only
|
||||
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
// Honest readout: the tracked plane has no faded billboard (its
|
||||
// entity owns the visual), so refresh the label — with icao24 now
|
||||
// in _missingPolls, _trackedLabelText appends the STALE cue so
|
||||
// last-known velocity/altitude aren't presented as live.
|
||||
tracking._updateTrackedLabelModel(icao24);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the tracked flight is truly gone, clear tracking BEFORE deleting
|
||||
// its state (M3 ordering, keep it): teardown reads the maps this loop
|
||||
// is about to delete (billboard restore, DR cache reset), and we must
|
||||
// never leave the camera mid-follow with stale tracking state. The
|
||||
// camera is then RELEASED IN PLACE — it stays where the follow left
|
||||
// it, fully free (owner decision 2026-07-02: no overview flyTo).
|
||||
if (icao24 === flightState._trackedIcao) {
|
||||
tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
records.forget(icao24);
|
||||
flightState._cullPositions.delete(icao24);
|
||||
flightState._positionHistory.delete(icao24);
|
||||
flightState._displayCourse.delete(icao24);
|
||||
flightState._groundSnap.forget(icao24);
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
// Ambient type enrichment: give ON-SCREEN planes real types (bounded
|
||||
// sweep — see _sweepAmbientEnrichment; internally fail-silent).
|
||||
enrichment._sweepAmbientEnrichment();
|
||||
|
||||
// 2026-08-19: the loop above only ever collects FIX cells, but a grounded
|
||||
// contact renders across every cell its dead-reckoned position drifts
|
||||
// through. Add those too, so the display clamp has data where the contact
|
||||
// actually is instead of silently passing.
|
||||
motion._collectDisplayCorridorCells(
|
||||
floorWarmPoints,
|
||||
viewerLatDeg,
|
||||
viewerLonDeg,
|
||||
);
|
||||
|
||||
// Field-test round 3: one batch warm of the viewer-proximate low-contact
|
||||
// floor cells collected in the loop (fire-and-forget, single-flight;
|
||||
// read synchronously by NEXT poll's clamp — the military-layer pattern).
|
||||
warmGroundFloor(floorWarmPoints);
|
||||
// Round 4: sample the RENDERED mesh for those same cells (one-shot per
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
viewerLat: viewerLatDeg,
|
||||
viewerLon: viewerLonDeg,
|
||||
});
|
||||
|
||||
// Round 5: the grounded exact-key warm that used to live here is gone —
|
||||
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
||||
// viewer-proximate coarse warm + mesh sampler above cover everything
|
||||
// whose height is actually visible.
|
||||
// Round 6: re-floor STALE grounded contacts. A parked plane whose
|
||||
// transponder went quiet stops receiving poll updates, so a floor that
|
||||
// warms AFTER its last fix never applied — it sat frozen at the geoid
|
||||
// (ATL verify: FFT4347 at −30.7 m, 305 m under the apron, forever).
|
||||
// Grounded contacts are static, so lifting the stored fix + billboard
|
||||
// in place is safe (the DR extrapolates a zero-velocity fix).
|
||||
motion._refloorStaleGroundedContacts(currentIcaos);
|
||||
|
||||
return { count: flightState._billboards.size, ids: acceptedSnapshotIcaos };
|
||||
};
|
||||
}
|
||||
Loaded 100 of 1521 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user