Merge upstream/main into pr/mapillary-layer

Upstream took share token '2' for fire perimeters, so Street Level moves to
'3'. mapillary-js joins upstream's optimizeDeps include list instead of
overriding it; catalog and registry tests count the new upstream layers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 17:58:27 +01:00
commit 9ba6f96d87
204 files changed
+24525 -314

No files matched your search

+16
View File
@@ -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);
+47
View File
@@ -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°',
);
});
+9 -1
View File
@@ -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,
+28 -14
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
@@ -233,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
@@ -247,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
@@ -261,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
+52
View File
@@ -0,0 +1,52 @@
import {
CLASS_MODEL_REAL,
CLASS_MODEL_URL,
CLASS_SCALE_3D,
} from '../../data/aircraftClass.js';
import {
trailAnchorForModel,
visualCenterForModel,
} from '../../data/modelVisualAnchor.js';
import {
MODEL_BELLY_OFFSET_NATIVE,
MODEL_COLOR_BLEND_AMOUNT,
MODEL_NATIVE_RADIUS_M,
MODEL_SCALE,
PLANE_MODEL_URL,
} from './policy.js';
/**
* The glTF asset, scale and measured geometry a civil aircraft class renders
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
* belly and radius); classes without one share airplane.glb at the class's
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
* the same model for the same class.
* @param {string} klass Aircraft class from `classifyAircraft`.
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
*/
export function civilAircraftModelSpec(klass) {
const real = CLASS_MODEL_REAL[klass];
if (real) {
return {
url: real.url,
scale: 1,
nativeRadiusM: real.radiusM,
bellyM: real.bellyM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
}
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
return {
url,
scale,
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
}
+14 -68
View File
@@ -1,14 +1,8 @@
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 { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -20,11 +14,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_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,
@@ -100,31 +89,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,7 +123,7 @@ 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;
@@ -180,7 +145,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));
}
/**
@@ -953,31 +918,11 @@ export function createRendering({
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
// invisible and it re-adds the moment it's back in view).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => flightState._models.has(icao),
});
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
@@ -1062,12 +1007,13 @@ 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);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
@@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(bb.color?.alpha ?? 1);
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
+2
View File
@@ -250,6 +250,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+24
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID,
@@ -303,6 +307,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
}
function hideReplayVehicleOverlay() {
@@ -502,6 +515,17 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: windowPosition;
track.lastOverlayWindowPosition = renderedWindowPosition;
track.lastOverlayMode = mode;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight)
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
+1 -1
View File
@@ -268,7 +268,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
'.global-context-panel-inner',
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+143
View File
@@ -0,0 +1,143 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import {
BAND_LABELS,
HEARD_BY_RECEIVER,
LOCAL_ADSB_COLOR,
SOURCE_LABELS,
} from './policy.js';
const DASH = '—';
function rounded(value, digits = 0) {
if (!Number.isFinite(value)) return null;
return Number(value.toFixed(digits));
}
function grouped(value) {
return value === null ? DASH : value.toLocaleString('en-US');
}
/**
* Title of a locally heard aircraft: callsign when decoded, else ICAO hex.
* @param {object} record Local ADS-B record.
* @returns {string}
*/
export function localAdsbTitle(record) {
return record?.callsign || String(record?.icao || '').toUpperCase();
}
function recordBands(record) {
if (Array.isArray(record?.bands) && record.bands.length) return record.bands;
return record?.band ? [record.band] : [];
}
function recordSources(record) {
if (Array.isArray(record?.sources) && record.sources.length)
return record.sources;
return record?.source ? [record.source] : [];
}
/**
* Whether an aircraft was heard only on 978 MHz UAT (its marker gets the
* UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft.
* @param {object} record Local ADS-B record.
* @returns {boolean}
*/
export function localAdsbIsUat(record) {
const bands = recordBands(record);
return bands.includes('978') && !bands.includes('1090');
}
/**
* Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz ·
* browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed".
* @param {object} record Local ADS-B record (merged: `bands`, `sources`).
* @returns {string}
*/
export function localAdsbReceiverLine(record) {
const bands = recordBands(record)
.map((band) => BAND_LABELS[band])
.filter(Boolean);
const sources = recordSources(record)
.map((source) => SOURCE_LABELS[source])
.filter(Boolean);
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
.filter(Boolean)
.join(' · ');
}
/**
* Where a heard aircraft came from, for the entity context's source field.
* @param {object} record Local ADS-B record.
* @returns {string}
*/
export function localAdsbSourceText(record) {
const sources = recordSources(record);
if (sources.includes('webusb') && sources.includes('feed'))
return 'Your RTL-SDR receiver and decoder feed';
if (sources.includes('feed')) return 'Your decoder feed';
return 'Your RTL-SDR receiver';
}
/**
* Class line of the click card, e.g. "Helicopter · A7". Empty when neither an
* ICAO type nor an emitter category backs the class.
* @param {object} record Local ADS-B record.
* @param {{klass?:string, evidence?:boolean}} [aircraftClass]
* @returns {string}
*/
export function localAdsbClassLine(record, aircraftClass = {}) {
if (!aircraftClass.evidence) return '';
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
return [label, record?.category].filter(Boolean).join(' · ');
}
/**
* Click-card presentation for one locally heard aircraft.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms, for the position age.
* @param {object} [extras]
* @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and
* whether a type or category backs it.
* @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName,
* airline).
* @param {object|null} [extras.route] A route already judged plausible.
* @returns {{title:string, details:string[], accent:string}}
*/
export function localAdsbCardModel(
record,
nowMs,
{ aircraftClass = null, meta = null, route = null } = {},
) {
const icao = String(record?.icao || '').toUpperCase();
const altitude = rounded(record?.altitudeFt);
const speed = rounded(record?.groundSpeedKt);
const track = rounded(record?.trackDeg);
const vertical = rounded(record?.verticalRateFpm);
const ageS = Number.isFinite(record?.lastPositionAt)
? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000))
: null;
const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0));
// Same identity and route lines the public flight card shows.
const identity = [meta?.airline, meta?.typeName || meta?.typeCode]
.filter(Boolean)
.join(' · ');
const routeLine =
route?.origin?.code && route?.destination?.code
? `${route.origin.code} → ${route.destination.code}`
: '';
return {
title: localAdsbTitle(record),
details: [
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
localAdsbClassLine(record, aircraftClass || {}),
identity,
routeLine,
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
localAdsbReceiverLine(record),
].filter(Boolean),
accent: LOCAL_ADSB_COLOR,
};
}
+166
View File
@@ -0,0 +1,166 @@
import { classifyAircraft } from '../../data/aircraftClass.js';
import { stickyText } from '../../data/aircraftMeta.js';
import {
ENRICH_DISPATCH_GAP_MS,
ENRICH_MAX_INFLIGHT,
} from '../flights/policy.js';
const ICAO = /^[0-9a-f]{6}$/i;
/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */
const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/;
/** Bound on the per-session metadata map. */
const META_LIMIT = 2_000;
/**
* Class of a locally heard aircraft: the adsbdb ICAO type when known, else
* the emitter category it broadcasts. `evidence` is false when neither is
* known, so the card never presents the classifier's default as fact.
* @param {{category?:string|null}} record
* @param {{typeCode?:string|null}} [meta]
* @returns {{klass:string, evidence:boolean}}
*/
export function localAdsbClass(record, meta = null) {
const typeCode = meta?.typeCode || null;
const category = record?.category || null;
return {
klass: classifyAircraft({ typeCode, category }),
evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''),
};
}
/**
* adsbdb enrichment for Local ADS-B through the same cached proxy and the same
* rules as the public Flights layer: each key is asked for at most once per
* session, at most four requests are in flight and dispatches are dripped
* 200 ms apart. Only the selected aircraft (type + route) and aircraft that
* are about to render as 3D models (type) are ever looked up — never one
* request per contact per tick.
* @param {object} options
* @param {{getEnrichment?: Function}|null} options.source Flights source.
* @param {() => void} options.onChange Called when metadata arrives.
* @param {() => number} [options.now]
* @returns {object}
*/
export function createLocalAdsbEnrichment({
source,
onChange,
now = Date.now,
}) {
const meta = new Map();
const seen = new Set();
const queue = [];
let active = 0;
let lastDispatchAt = -Infinity;
let dripTimer = null;
let lifetime = new AbortController();
function entry(icao) {
let value = meta.get(icao);
if (!value) {
value = {};
meta.set(icao, value);
if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value);
}
return value;
}
function enqueue(key, query, apply, priority) {
if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return;
seen.add(key);
const job = { query, apply };
if (priority) queue.unshift(job);
else queue.push(job);
drain();
}
function drain() {
const signal = lifetime.signal;
while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) {
const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt);
if (wait > 0) {
if (!dripTimer)
dripTimer = setTimeout(() => {
dripTimer = null;
drain();
}, wait);
return;
}
lastDispatchAt = now();
const job = queue.shift();
active += 1;
Promise.resolve()
.then(() => source.getEnrichment(job.query, { signal }))
.then((data) => {
if (!signal.aborted && data?.found) {
job.apply(data);
onChange?.();
}
})
.catch(() => {
/* enrichment never surfaces errors */
})
.finally(() => {
if (signal.aborted) return;
active -= 1;
drain();
});
}
}
return {
/** @param {string} icao @returns {object|null} */
get(icao) {
return meta.get(String(icao || '').toLowerCase()) || null;
},
/**
* Ask for the aircraft's type (and, when selected, its route).
* @param {object} record Local ADS-B record.
* @param {{selected?: boolean}} [options]
*/
request(record, { selected = false } = {}) {
const icao = String(record?.icao || '').toLowerCase();
if (!ICAO.test(icao)) return;
enqueue(
`t:${icao}`,
{ kind: 'type', id: icao },
(data) => {
const value = entry(icao);
value.typeCode = stickyText(data.typeCode, value.typeCode) || null;
value.typeName = stickyText(data.typeName, value.typeName) || null;
value.registration =
stickyText(data.registration, value.registration) || null;
},
selected,
);
const callsign = String(record.callsign || '')
.trim()
.toUpperCase();
if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return;
enqueue(
`r:${callsign}`,
{ kind: 'route', id: callsign },
(data) => {
const value = entry(icao);
value.airline = stickyText(data.airline, value.airline) || null;
if (data.origin && data.destination)
value.route = {
callsign,
origin: data.origin,
destination: data.destination,
};
},
true,
);
},
destroy() {
lifetime.abort();
lifetime = new AbortController();
clearTimeout(dripTimer);
dripTimer = null;
queue.length = 0;
active = 0;
},
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Names for the server's decoder feeds, shared by the Local ADS-B row status
* and the Radio card. Pure; no imports, so any surface may use it.
*/
/**
* Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2")
* when another configured feed shares the band.
* @param {object} feed Feed status from the route.
* @param {object[]} [feeds] Every configured feed.
* @returns {string}
*/
export function localReceiverFeedName(feed, feeds = []) {
const band = feed?.band;
if (!band) return feed?.label || 'feed';
const sharing = feeds.filter((other) => other?.band === band);
if (sharing.length < 2) return band;
if (typeof feed.label === 'string' && feed.label.includes('#'))
return feed.label;
return `${feed.label || band} #${sharing.indexOf(feed) + 1}`;
}
+253
View File
@@ -0,0 +1,253 @@
import {
localAdsbRecordIsLive,
localAdsbRecordIsNewer,
} from '../../sources/adsbRecords.js';
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
error.name = 'AbortError';
return error;
}
/** Reject with an AbortError as soon as `signal` aborts. */
function untilAborted(promise, signal) {
if (signal.aborted) return Promise.reject(abortError());
let onAbort = null;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(abortError());
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([promise, aborted]).finally(() =>
signal.removeEventListener('abort', onAbort),
);
}
function shiftTime(value, offset) {
return Number.isFinite(value) ? value + offset : value;
}
/**
* Browser poller for the server's local decoder feeds.
*
* Polls only between `start()` and `stop()` (the Local ADS-B layer calls them
* on enable/disable), and only while the route reports `configured`: an
* unconfigured server, or a build without the route, stops the loop after one
* request. `probe()` makes that one request without polling, so the Radio card
* can show which feeds exist before the layer is turned on.
*
* Records are rebased from the server clock to this browser's clock and kept
* by ICAO and band for up to 60 s after their last message, so a feed that
* drops an aircraft or goes briefly unreachable does not erase it at once.
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl]
* @param {string} [options.url]
* @param {number} [options.intervalMs]
* @param {number} [options.requestTimeoutMs] Deadline per request.
* @param {() => number} [options.now]
* @returns {object} Feed session: start, stop, probe, getState, subscribe.
*/
export function createLocalReceiverFeeds({
fetchImpl = (...args) => globalThis.fetch(...args),
url = LOCAL_RECEIVERS_URL,
intervalMs = LOCAL_RECEIVER_POLL_MS,
requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS,
now = Date.now,
} = {}) {
const listeners = new Set();
const retained = new Map();
/** Abort controllers of requests still in flight. */
const inFlight = new Set();
let state = {
configured: null,
polling: false,
feeds: [],
records: [],
error: null,
updatedAt: null,
};
let generation = 0;
let timer = null;
let probing = null;
function emit(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener(state);
}
function retain(records, at) {
for (const record of records) {
if (!record?.icao) continue;
// Two feeds on one band both report an aircraft: keep the fresher
// record, never simply the one listed (or polled) last.
const key = `${record.icao}|${record.band}`;
const current = retained.get(key);
if (!current || !localAdsbRecordIsNewer(current, record))
retained.set(key, record);
}
for (const [key, record] of retained) {
if (!localAdsbRecordIsLive(record, at)) retained.delete(key);
}
return [...retained.values()];
}
/** One route request with a deadline through the body, abortable by stop. */
async function request() {
const controller = new AbortController();
inFlight.add(controller);
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await untilAborted(
fetchImpl(url, {
cache: 'no-store',
headers: { Accept: 'application/json' },
signal: controller.signal,
}),
controller.signal,
);
if (!response?.ok) throw new Error('local receiver route unavailable');
return await untilAborted(response.json(), controller.signal);
} finally {
clearTimeout(timer);
inFlight.delete(controller);
}
}
function abortInFlight() {
for (const controller of inFlight) controller.abort();
inFlight.clear();
}
function apply(body) {
if (body?.configured !== true) {
retained.clear();
emit({
configured: false,
polling: false,
feeds: [],
records: [],
error: null,
});
return false;
}
const at = now();
const offset = Number.isFinite(body.generatedAt)
? at - body.generatedAt
: 0;
const records = (Array.isArray(body.records) ? body.records : []).map(
(record) => ({
...record,
lastPositionAt: shiftTime(record.lastPositionAt, offset),
lastMessageAt: shiftTime(record.lastMessageAt, offset),
}),
);
emit({
configured: true,
feeds: Array.isArray(body.feeds) ? body.feeds : [],
records: retain(records, at),
error: null,
updatedAt: at,
});
return true;
}
function fail() {
// A route that never answered means there is nothing to poll; a route
// that answered before is reported unreachable and polled again.
if (state.configured !== true) {
emit({ configured: false, polling: false, error: 'unavailable' });
return false;
}
emit({
feeds: state.feeds.map((feed) =>
feed.status === 'invalid'
? feed
: { ...feed, status: 'unreachable', aircraft: 0 },
),
records: retain([], now()),
error: 'unavailable',
});
return true;
}
async function tick(current) {
timer = null;
let keepPolling;
try {
const body = await request();
if (current !== generation) return;
keepPolling = apply(body);
} catch {
if (current !== generation) return;
keepPolling = fail();
}
if (!keepPolling) {
generation += 1;
return;
}
timer = setTimeout(() => void tick(current), intervalMs);
}
return {
/** Start polling (no-op while already polling). */
start() {
if (state.polling) return;
generation += 1;
emit({ polling: true });
void tick(generation);
},
/** Stop polling; the last feed statuses stay readable. */
stop() {
generation += 1;
clearTimeout(timer);
timer = null;
abortInFlight();
if (state.polling) emit({ polling: false });
},
/**
* Ask the route once whether feeds are configured, without polling.
* @returns {Promise<void>}
*/
probe() {
if (state.polling || state.configured !== null) return Promise.resolve();
if (!probing) {
const current = generation;
probing = request()
.then((body) => {
if (current === generation && !state.polling) apply(body);
})
.catch(() => {
if (current === generation && !state.polling)
emit({ configured: false, error: 'unavailable' });
})
.finally(() => {
probing = null;
});
}
return probing;
},
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
this.stop();
listeners.clear();
retained.clear();
},
};
}
+256
View File
@@ -0,0 +1,256 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function route(bodies) {
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
const next = bodies.length > 1 ? bodies.shift() : bodies[0];
if (next instanceof Error) throw next;
if (next === 404) return { ok: false, status: 404 };
return { ok: true, json: async () => structuredClone(next) };
};
return { calls, fetchImpl };
}
function payload(records, generatedAt = 1_000_000) {
return {
configured: true,
generatedAt,
feeds: [
{ band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 },
{ band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 },
],
records,
};
}
function aircraft(overrides = {}) {
return {
icao: 'ae5d8a',
lat: 30.27,
lon: -97.79,
lastPositionAt: 999_000,
lastMessageAt: 999_500,
band: '1090',
source: 'feed',
...overrides,
};
}
test('an unconfigured server is asked once and never polled', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([
{ configured: false, feeds: [], records: [] },
]);
const feeds = createLocalReceiverFeeds({ fetchImpl });
feeds.start();
await flush();
assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]);
assert.equal(feeds.getState().configured, false);
assert.equal(feeds.getState().polling, false);
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
});
test('a build without the route stops after one failed request', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([404]);
const feeds = createLocalReceiverFeeds({ fetchImpl });
feeds.start();
await flush();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
assert.equal(feeds.getState().configured, false);
});
test('configured feeds poll every second between start and stop, on the browser clock', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([payload([aircraft()])]);
const clock = 1_000_250;
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
const seen = [];
feeds.subscribe((state) => seen.push(state.polling));
feeds.start();
await flush();
const state = feeds.getState();
assert.equal(state.configured, true);
assert.equal(state.polling, true);
assert.equal(state.feeds.length, 2);
// Server generated at 1_000_000; received at 1_000_250: rebased +250 ms.
assert.equal(state.records[0].lastPositionAt, 999_250);
assert.equal(state.records[0].lastMessageAt, 999_750);
t.mock.timers.tick(1_000);
await flush();
t.mock.timers.tick(1_000);
await flush();
assert.equal(calls.length, 3);
feeds.stop();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 3, 'no polling once stopped');
assert.equal(feeds.getState().polling, false);
assert.equal(seen.at(-1), false);
});
test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const bodies = [
payload([aircraft(), aircraft({ band: '978' })], 1_000_000),
payload([aircraft()], 1_010_000),
payload([], 1_060_000),
];
let clock = 1_000_000;
const { fetchImpl } = route(bodies);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
feeds.start();
await flush();
assert.equal(feeds.getState().records.length, 2);
clock = 1_010_000;
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(
feeds.getState().records.map((record) => record.band),
['1090', '978'],
'the 978 reception is remembered',
);
clock = 1_060_000;
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s');
feeds.stop();
});
test('a route that answered before is reported unreachable and polled again', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([
payload([aircraft()]),
new TypeError('network down'),
payload([aircraft()]),
]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(
feeds.getState().feeds.map((feed) => feed.status),
['unreachable', 'unreachable'],
);
assert.equal(feeds.getState().records.length, 1, 'recent records remain');
t.mock.timers.tick(1_000);
await flush();
assert.equal(calls.length, 3);
assert.equal(feeds.getState().feeds[0].status, 'live');
feeds.stop();
});
test('probe asks once without polling so the Radio card can list feeds', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([payload([aircraft()])]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
await Promise.all([feeds.probe(), feeds.probe()]);
assert.equal(calls.length, 1);
assert.equal(feeds.getState().configured, true);
assert.equal(feeds.getState().polling, false);
await feeds.probe();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
});
test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 });
// A second 1090 feed that heard the aircraft earlier and has no position.
const older = aircraft({
lat: null,
lon: null,
lastPositionAt: null,
lastMessageAt: 990_000,
});
const { fetchImpl } = route([
payload([fresh, older], 1_000_000),
payload([older], 1_000_000),
]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
let [kept] = feeds.getState().records;
assert.equal(feeds.getState().records.length, 1);
assert.equal(kept.lastPositionAt, 999_000, 'same response');
t.mock.timers.tick(1_000);
await flush();
[kept] = feeds.getState().records;
assert.equal(kept.lastPositionAt, 999_000, 'next poll');
assert.equal(kept.lat, 30.27);
feeds.stop();
});
/** A route whose requests never answer until their signal aborts. */
function stalledRoute({ stallBody = false } = {}) {
const signals = [];
const hang = (signal) =>
new Promise((_, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
});
const fetchImpl = async (url, options = {}) => {
signals.push(options.signal);
if (!stallBody) return hang(options.signal);
return { ok: true, json: () => hang(options.signal) };
};
return { signals, fetchImpl };
}
test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
for (const stallBody of [false, true]) {
const { signals, fetchImpl } = stalledRoute({ stallBody });
const feeds = createLocalReceiverFeeds({
fetchImpl,
now: () => 1_000_000,
requestTimeoutMs: 3_000,
});
feeds.start();
await flush();
assert.equal(signals.length, 1);
assert.ok(signals[0], 'every request carries an abort signal');
t.mock.timers.tick(3_000);
await flush();
assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`);
assert.equal(feeds.getState().configured, false, 'never answered');
feeds.destroy();
}
});
test('stop() aborts the request in flight so start() is never blocked by it', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { signals, fetchImpl } = stalledRoute();
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
feeds.stop();
assert.equal(signals[0].aborted, true);
feeds.start();
await flush();
feeds.stop();
feeds.start();
await flush();
assert.equal(signals.length, 3);
assert.deepEqual(
signals.map((signal) => signal.aborted),
[true, true, false],
'no disable/enable cycle leaves an old request outstanding',
);
feeds.destroy();
assert.equal(signals[2].aborted, true);
});
+846
View File
@@ -0,0 +1,846 @@
import * as Cesium from 'cesium';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import {
horizonOccluder,
screenProjectedRotation,
stabilizeScreenRotation,
} from '../../data/iconOrientation.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { routePlausible } from '../../data/routePlausible.js';
import {
localAdsbPositionIsFresh,
mergeLocalAdsbRecords,
summarizeLocalAdsb,
} from '../../sources/adsbRecords.js';
import { GROUND_SCALE } from '../flights/policy.js';
import {
localAdsbCardModel,
localAdsbIsUat,
localAdsbSourceText,
localAdsbTitle,
} from './card.js';
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
import { createLocalAdsbModels } from './models.js';
import { LocalAdsbMotion } from './motion.js';
import {
ENTITY_PREFIX,
LAYER_ID,
LAYER_NAME,
LAYER_SOURCE,
LOCAL_ADSB_COLOR,
LOCAL_ADSB_SYNC_MS,
LOCAL_ADSB_TICK_MS,
LOCAL_ADSB_UAT_RING_COLOR,
} from './policy.js';
import { localAdsbStatus } from './status.js';
export {
LAYER_ID as LOCAL_ADSB_LAYER_ID,
HEARD_BY_RECEIVER,
} from './policy.js';
export {
localAdsbCardModel,
localAdsbClassLine,
localAdsbIsUat,
localAdsbReceiverLine,
localAdsbTitle,
} from './card.js';
export { localAdsbClass } from './enrichment.js';
export { localAdsbStatus } from './status.js';
const FEET_TO_METERS = 0.3048;
const RENDER_HOLD_ID = 'local-adsb';
/** Picks looked through under a click for a local aircraft. */
const CLICK_DRILL_LIMIT = 8;
/** Trail lines pass through their aircraft; a click never stops on one. */
const TRAIL_PICK_PREFIX = 'gev-trail:';
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
const DEFAULT_DISPLAY = Object.freeze({
models3d: true,
models3dMode: 'proximity',
});
/**
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
*
* Two inputs feed it. The receiver is any object with `getState()` returning
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
* by the server from local 1090/978 MHz decoders; it polls only while this
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
*
* Aircraft get the public Flights treatment in magenta: a class from the
* emitter category or adsbdb type (`aircraftClass.js`), the class silhouette
* and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth
* real-time motion (`./motion.js`, no display delay) and, while selected, a
* trail of the positions the receiver heard.
*
* Markers are never followed by the camera. A marker disappears once its
* position is older than 60 s; a record without any message for 60 s is gone.
* @param {object} options
* @param {object} options.receiver Local receiver session.
* @param {object} [options.feeds] Decoder-feed session (start, stop,
* getState, subscribe).
* @param {object} options.services Render, context, picking, detection and
* readout operations supplied by the application; optionally `display`
* (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source
* with `getEnrichment`), `trails` (`createTrail`), `geoid`
* (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
* (`createGroundSnap`).
* @param {() => number} [options.now] Clock, injectable for tests.
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
* @param {(options: object) => Promise<object>} [options.loadModel] Model
* loader, injectable for tests.
* @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler]
* Click-handler factory, injectable for tests.
* @returns {object} Data-layer module.
*/
export function createLocalAdsbLayer({
receiver,
feeds = null,
services,
now = Date.now,
resolveAsset = (url) => url,
loadModel,
createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
}) {
if (typeof receiver?.getState !== 'function')
throw new TypeError('Local ADS-B requires a receiver session');
const {
governorRequestRender,
holdContinuousRender = () => {},
releaseContinuousRender = () => {},
} = services.render;
const {
registerEntityContext,
selectEntityContext,
clearSelectedEntityContextForLayer,
getSelectedEntityContext,
removeEntityContextsForLayer,
} = services.context;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR);
const uatRingColor = Cesium.Color.fromCssColorString(
LOCAL_ADSB_UAT_RING_COLOR,
);
let viewer = null;
let dataSource = null;
let enabled = false;
let unsubscribe = null;
let unsubscribeFeeds = null;
let tickTimer = null;
let syncTimer = null;
let clickHandler = null;
let removePreRender = null;
let renderHeld = false;
let selectedId = null;
let selectedTrail = null;
let trailHeadSeq = 0;
let models = null;
let geoidReady = false;
let rejectedFixes = 0;
let state = receiver.getState();
const markers = new Map();
// Per-ICAO receptions (band|source -> last message) across merges.
const heardBy = new Map();
const enrichment = createLocalAdsbEnrichment({
source: services.enrichment || null,
onChange: () => {
for (const marker of markers.values()) refreshClass(marker);
refreshSelectedCard();
governorRequestRender('local-adsb-enrichment');
},
});
function displayPreferences() {
return services.display?.getParams?.() || DEFAULT_DISPLAY;
}
function markSourcesChanged(reason) {
services.detection?.markSourcesChanged?.(reason);
}
function mergedRecords(at = now(), receiverState = state) {
return mergeLocalAdsbRecords(
[receiverState.aircraft || [], feeds?.getState?.()?.records || []],
at,
heardBy,
);
}
function freshRecords(at = now()) {
return mergedRecords(at).filter((record) =>
localAdsbPositionIsFresh(record, at),
);
}
function markerVisible(marker) {
if (!viewer?.camera?.positionWC || !marker.position) return true;
return horizonOccluder(viewer.camera).isPointVisible(marker.position);
}
function markerRotation(marker) {
if (!viewer?.scene || !marker.position) return marker.lastRotation;
const projected = screenProjectedRotation(
viewer.scene,
marker.position,
marker.courseDeg ?? 0,
marker.lastRotation,
);
const stable = stabilizeScreenRotation(marker.lastRotation, projected);
if (stable !== null) marker.lastRotation = stable;
return marker.lastRotation;
}
function geoidN(lat, lon) {
if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function')
return null;
try {
const value = services.geoid.geoidHeight(lat, lon);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
/** Barometric feet → ellipsoidal render metres, as the Flights layer does
* for a contact without a geometric altitude: baro + geoid N. */
function renderHeightM(marker, altitudeFt, lat, lon) {
if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon);
const height = pickRenderAltitudeM({
geoAltM: null,
baroAltM: Number.isFinite(altitudeFt)
? altitudeFt * FEET_TO_METERS
: null,
onGround: false,
surfaceM: null,
geoidN: marker.geoidN,
});
return Number.isFinite(height) ? height : (marker.geoidN ?? 0);
}
function fixPosition(marker, fix) {
return Cesium.Cartesian3.fromDegrees(
fix.lon,
fix.lat,
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
);
}
function updateDisplay(marker, at) {
const display = marker.motion.displayAt(at);
if (!display) return;
marker.lat = display.lat;
marker.lon = display.lon;
marker.position = Cesium.Cartesian3.fromDegrees(
display.lon,
display.lat,
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
Cesium.Ellipsoid.WGS84,
marker.position || new Cesium.Cartesian3(),
);
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
}
function billboardScale(marker) {
return (
(CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1)
);
}
/** Class silhouette and per-class scale (×0.8 on the ground), magenta. */
function refreshClass(marker) {
const { klass, evidence } = localAdsbClass(
marker.record,
enrichment.get(marker.record.icao),
);
marker.evidence = evidence;
marker.klass = klass;
const billboard = marker.entity?.billboard;
if (!billboard) return;
if (marker.iconKind !== klass) {
marker.iconKind = klass;
billboard.image = aircraftIcon(klass);
}
const scale = billboardScale(marker);
if (marker.iconScale !== scale) {
marker.iconScale = scale;
billboard.scale = scale;
}
}
function plausibleRoute(marker, meta) {
const route = meta?.route;
if (!route || route.callsign !== marker.record.callsign) return null;
const record = marker.record;
const ok = routePlausible({
latDeg: marker.lat ?? record.lat,
lonDeg: marker.lon ?? record.lon,
altitudeM: Number.isFinite(record.altitudeFt)
? record.altitudeFt * FEET_TO_METERS
: null,
verticalRateMps: Number.isFinite(record.verticalRateFpm)
? (record.verticalRateFpm * FEET_TO_METERS) / 60
: null,
origin: route.origin,
destination: route.destination,
});
return ok ? route : null;
}
function cardModel(marker, at) {
const meta = enrichment.get(marker.record.icao);
return localAdsbCardModel(marker.record, at, {
aircraftClass: { klass: marker.klass, evidence: marker.evidence },
meta,
route: plausibleRoute(marker, meta),
});
}
function contextMetadata(id, marker) {
const record = marker.record;
const meta = enrichment.get(record.icao);
return {
id,
layerId: LAYER_ID,
dataSource,
layerName: LAYER_NAME,
source: localAdsbSourceText(record),
label: localAdsbTitle(record),
latitude: marker.lat ?? record.lat,
longitude: marker.lon ?? record.lon,
properties: {
icao: record.icao,
callsign: record.callsign,
category: record.category ?? null,
aircraftClass: marker.evidence ? marker.klass : null,
typeCode: meta?.typeCode ?? null,
altitudeFt: record.altitudeFt,
groundSpeedKt: record.groundSpeedKt,
trackDeg: record.trackDeg,
verticalRateFpm: record.verticalRateFpm,
lastPositionAt: record.lastPositionAt,
messageCount: record.messageCount,
receiverSource: record.source,
bands: record.bands,
sources: record.sources,
},
};
}
function uatRing(marker) {
return new Cesium.PointGraphics({
pixelSize: 24,
color: Cesium.Color.TRANSPARENT,
outlineColor: uatRingColor,
outlineWidth: 1.5,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
show: new Cesium.CallbackProperty(() => markerVisible(marker), false),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
2_000_000,
),
});
}
// ── Trail of the selected aircraft ──────────────────────────────────────
function releaseTrail() {
if (!selectedTrail) return;
selectedTrail.trail?.destroy();
if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) {
try {
viewer.entities.remove(selectedTrail.head);
} catch {
/* torn down */
}
}
selectedTrail = null;
}
function refreshTrail() {
const marker = selectedId ? markers.get(selectedId) : null;
if (!marker || !selectedTrail?.trail) return;
const positions = marker.motion.fixes.map((fix) =>
fixPosition(marker, fix),
);
selectedTrail.lastFix = positions.at(-1) || null;
selectedTrail.trail.setPositions(positions);
}
function startTrail(marker) {
releaseTrail();
const createTrail = services.trails?.createTrail;
if (typeof createTrail !== 'function' || !viewer?.entities) return;
selectedTrail = {
id: marker.id,
trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }),
head: null,
lastFix: null,
};
// Live head: last heard fix → the extrapolated marker, every frame.
selectedTrail.head = viewer.entities.add({
// 'gev-trail' namespace: claimed by the trail pick owner so a click on
// the head segment never reads as empty space.
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
polyline: {
positions: new Cesium.CallbackProperty(() => {
const current = markers.get(selectedTrail?.id);
const from = selectedTrail?.lastFix;
if (!current?.position || !from) return [];
return [from, Cesium.Cartesian3.clone(current.position)];
}, false),
width: 2.5,
material: color.withAlpha(0.9),
depthFailMaterial: color.withAlpha(0.45),
arcType: Cesium.ArcType.GEODESIC,
},
});
refreshTrail();
}
// ── Markers ─────────────────────────────────────────────────────────────
function upsertMarker(record, at) {
const id = `${ENTITY_PREFIX}${record.icao}`;
let marker = markers.get(id);
if (!marker) {
marker = {
id,
entity: null,
record,
motion: new LocalAdsbMotion(),
position: null,
lat: null,
lon: null,
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
lastRotation: 0,
klass: null,
evidence: false,
iconKind: null,
iconScale: null,
onGround: Boolean(record.onGround),
geoidN: null,
modelOwnsVisual: false,
};
markers.set(id, marker);
}
marker.record = record;
marker.onGround = Boolean(record.onGround);
const rejectedBefore = marker.motion.rejectedFixes;
const newFix = marker.motion.observe(record, at);
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
updateDisplay(marker, at);
if (!marker.entity) {
marker.entity = dataSource.entities.add({
id,
position: new Cesium.CallbackProperty(() => marker.position, false),
billboard: {
image: aircraftIcon('airliner'),
width: 20,
height: 20,
scale: 1,
color,
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
rotation: new Cesium.CallbackProperty(
() => markerRotation(marker),
false,
),
show: new Cesium.CallbackProperty(
() => !marker.modelOwnsVisual && markerVisible(marker),
false,
),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
2_000_000,
),
},
});
marker.entity.gevTrackedId = id;
marker.entity.gevDisplayPosition = () => marker.position;
}
refreshClass(marker);
const uat = localAdsbIsUat(record);
if (uat !== Boolean(marker.entity.point))
marker.entity.point = uat ? uatRing(marker) : undefined;
marker.entity.gevLabelModel = cardModel(marker, at);
registerEntityContext(marker.entity, contextMetadata(id, marker));
if (newFix && id === selectedId) refreshTrail();
return id;
}
function clearSelection({ evicted = false } = {}) {
if (!selectedId) return;
selectedId = null;
releaseTrail();
clearSelectedEntityContextForLayer(LAYER_ID, { evicted });
}
function refreshSelectedCard() {
const marker = selectedId ? markers.get(selectedId) : null;
if (!marker) return;
marker.entity.gevLabelModel = cardModel(marker, now());
services.overlays?.refreshReadout?.(marker.entity);
}
function updateRenderHold() {
const want = enabled && markers.size > 0;
if (want === renderHeld) return;
renderHeld = want;
if (want) holdContinuousRender(RENDER_HOLD_ID);
else releaseContinuousRender(RENDER_HOLD_ID);
}
function requestEnrichment() {
const selected = selectedId ? markers.get(selectedId) : null;
if (selected) enrichment.request(selected.record, { selected: true });
for (const id of models?.eligible || []) {
const marker = markers.get(id);
if (marker) enrichment.request(marker.record);
}
}
function sync() {
clearTimeout(syncTimer);
syncTimer = null;
if (!dataSource) return;
const at = now();
const live = new Set();
let added = false;
if (enabled) {
for (const record of freshRecords(at)) {
const id = `${ENTITY_PREFIX}${record.icao}`;
if (!markers.has(id)) added = true;
live.add(upsertMarker(record, at));
}
}
let removed = false;
for (const [id, marker] of markers) {
if (live.has(id)) continue;
if (marker.entity) dataSource.entities.remove(marker.entity);
markers.delete(id);
removed = true;
}
// Expired aircraft take their 3D models (and pending loads) with them.
if (removed) models?.retain(markers);
if (selectedId && !live.has(selectedId))
clearSelection({ evicted: enabled });
removeEntityContextsForLayer(LAYER_ID, { retainIds: live });
if (selectedId) {
const selected = markers.get(selectedId);
if (getSelectedEntityContext()?.id !== selectedId) {
selectedId = null;
releaseTrail();
} else services.overlays?.refreshReadout?.(selected.entity);
}
requestEnrichment();
updateRenderHold();
if (live.size || removed) governorRequestRender('local-adsb-update');
// Detection re-solves labels only when the set of contacts changes, not
// on every position report.
if (added || removed) markSourcesChanged('local-adsb-update');
}
/** Per-frame pass: smooth positions and courses, then 3D models. */
function frame() {
if (!enabled || !markers.size) return;
const at = now();
for (const marker of markers.values()) updateDisplay(marker, at);
models?.frame(markers, displayPreferences(), at);
}
function scheduleSync(nextState) {
state = nextState;
requestSync();
}
function requestSync() {
if (!enabled || syncTimer) return;
syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS);
}
function selectMarker(id) {
const marker = markers.get(id);
if (!marker) return false;
const reselect = id === selectedId && selectedTrail?.id === id;
selectedId = id;
enrichment.request(marker.record, { selected: true });
marker.entity.gevLabelModel = cardModel(marker, now());
selectEntityContext(marker.entity);
if (!reselect) startTrail(marker);
governorRequestRender('local-adsb-selection');
return true;
}
function pickedId(picked) {
const resolved = services.picking.resolvePickId?.(picked);
if (resolved !== undefined) return resolved;
if (typeof picked?.id === 'string') return picked.id;
if (typeof picked?.id?.id === 'string') return picked.id.id;
return typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null;
}
function ownedElsewhere(id) {
return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id);
}
/**
* What a click at `position` means for this layer: a local aircraft id,
* `'other'` when a sibling layer's contact is under the pointer, `'trail'`
* for a trail line, or null for empty map.
*
* The frontmost pick decides, except that something no layer owns (a
* photoreal tile, a ring) or a trail line drawn over or through a local
* aircraft does not hide it: the click looks underneath for the aircraft.
*/
function clickTarget(position) {
const scene = viewer.scene;
const top = pickedId(scene.pick(position));
if (top && markers.has(top)) return top;
const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX);
if (ownedElsewhere(top) && !trail) return 'other';
let hits = [];
try {
hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || [];
} catch {
hits = [];
}
for (const hit of hits) {
const id = pickedId(hit);
if (id && markers.has(id)) return id;
if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue;
// A sibling layer's contact above ours takes the click.
if (ownedElsewhere(id)) return 'other';
}
return trail ? 'trail' : null;
}
function installInteraction() {
if (clickHandler || !viewer?.scene?.canvas) return;
clickHandler = createInputHandler(viewer.scene.canvas);
clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree() || !enabled) return;
const target = clickTarget(click.position);
// Clicking a local aircraft always (re)selects it and republishes its
// card, including the one already selected: its card may have been
// displaced, and a click on a marker must never read as "deselect".
if (target && markers.has(target)) {
selectMarker(target);
return;
}
// A click on a trail line is not empty map.
if (target === 'trail' || !selectedId) return;
// Another contact or empty map releases only this layer's selection,
// leaving a sibling layer's new selection intact.
clearSelection();
governorRequestRender('local-adsb-selection');
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function releaseInteraction() {
clickHandler?.destroy();
clickHandler = null;
}
function installFrame() {
if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return;
removePreRender = viewer.scene.preRender.addEventListener(frame);
}
function releaseFrame() {
removePreRender?.();
removePreRender = null;
}
function positionRows(maxCount = 500) {
if (!enabled) return [];
const rows = [];
for (const marker of markers.values()) {
if (rows.length >= maxCount) break;
if (!marker.position) continue;
const carto = Cesium.Cartographic.fromCartesian(marker.position);
rows.push({
id: marker.record.icao,
label: localAdsbTitle(marker.record),
callsign: marker.record.callsign,
position: Cesium.Cartesian3.clone(marker.position),
latitude: marker.lat,
longitude: marker.lon,
altitudeM: carto?.height ?? 0,
});
}
return rows;
}
return {
id: LAYER_ID,
name: LAYER_NAME,
icon: '📡',
source: LAYER_SOURCE,
updateInterval: 0,
statsRefreshInterval: LOCAL_ADSB_TICK_MS,
/** The shared receiver session, also driven by the Radio panel card. */
receiver,
/** Decoder-feed session; the Radio card shows its one-line summary. */
feeds,
init(nextViewer) {
viewer = nextViewer;
dataSource = new Cesium.CustomDataSource(LAYER_ID);
dataSource.show = false;
viewer.dataSources.add(dataSource);
if (viewer.scene?.primitives) {
models = createLocalAdsbModels({
viewer,
color,
resolveAsset,
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
...(loadModel ? { loadModel } : {}),
});
}
services.geoid
?.ensureGeoidReady?.()
?.then(() => {
geoidReady = true;
for (const marker of markers.values()) marker.geoidN = null;
})
?.catch(() => {
/* the baro path stays un-geoid-corrected */
});
unsubscribe = receiver.subscribe?.(scheduleSync) || null;
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
return true;
},
async enable() {
enabled = true;
if (dataSource) dataSource.show = true;
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
installInteraction();
installFrame();
clearInterval(tickTimer);
tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS);
feeds?.start?.();
// The layer asks the shared tuner for 1090 MHz. Disabling it leaves the
// receiver in whatever mode the Radio card shows, so turning the layer
// off never starts FM audio on its own.
if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb');
state = receiver.getState();
sync();
return true;
},
async disable() {
enabled = false;
feeds?.stop?.();
clearInterval(tickTimer);
tickTimer = null;
releaseInteraction();
releaseFrame();
unregisterPickOwner(LAYER_ID);
sync();
models?.clear();
if (dataSource) dataSource.show = false;
markSourcesChanged('local-adsb-disabled');
return true;
},
async update() {
state = receiver.getState();
sync();
frame();
return true;
},
destroy() {
enabled = false;
clearInterval(tickTimer);
clearTimeout(syncTimer);
tickTimer = null;
syncTimer = null;
releaseInteraction();
releaseFrame();
unregisterPickOwner(LAYER_ID);
unsubscribe?.();
unsubscribe = null;
unsubscribeFeeds?.();
unsubscribeFeeds = null;
feeds?.destroy?.();
enrichment.destroy();
clearSelection();
removeEntityContextsForLayer(LAYER_ID);
markers.clear();
updateRenderHold();
models?.destroy();
models = null;
if (viewer && dataSource) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
void receiver.destroy?.();
},
getStats() {
const current = receiver.getState();
const at = now();
const records = mergedRecords(at, current);
const { heard, positioned } = summarizeLocalAdsb(records, at);
const lastUpdate =
records.reduce(
(latest, record) => Math.max(latest, record.lastMessageAt || 0),
0,
) || null;
return {
count: positioned,
lastUpdate,
source: LAYER_SOURCE,
// Fixes refused by the speed check: the browser decoder's own count
// plus the layer's check on every merged record.
rejectedPositions:
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
...localAdsbStatus({
receiver: current,
feedState: feeds?.getState?.() || null,
heard,
}),
};
},
/**
* Select a heard aircraft's marker and publish its readout card, exactly
* as a click does. The camera does not follow it.
* @param {string} icao Lowercase ICAO hex.
* @returns {boolean} Whether a fresh marker was selected.
*/
selectAircraft(icao) {
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
},
getAllPositions(maxCount = 500) {
return positionRows(maxCount);
},
getDetectableObjects(options = {}) {
return positionRows(options.maxCount).map((row) => ({
position: row.position,
sourceId: row.id,
// Label only a decoded callsign; never substitute the raw ICAO hex.
id: row.callsign || '',
type: 'AIR',
skipLabel: false,
}));
},
};
}
File diff suppressed because it is too large. Load diff
+282
View File
@@ -0,0 +1,282 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
import {
FLEET_DR_INTERVAL_MS,
MODEL_ALL_ADD_M,
MODEL_ALL_KEEP_M,
MODEL_ALT_CEIL_M,
MODEL_HEADING_OFFSET_DEG,
MODEL_MAX,
MODEL_MAX_ALL,
MODEL_MIN_PX,
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
/**
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
*
* Mirrors the public Flights fleet: models only below the altitude ceiling,
* the same proximity/all caps and add/keep radii, the same visible-first
* eligibility (`selectModelEligible`), the same per-class asset and scale
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
* magenta, as Flights tints military amber). Each model owns its matrix. A
* billboard stays the visual until its model is loaded AND placed; a grounded
* aircraft's model rides the one-shot ground snap plus the class belly
* offset, and without a resolved ground it stays a billboard.
* @param {object} options
* @param {object} options.viewer Cesium viewer.
* @param {Cesium.Color} options.color Model tint.
* @param {(url: string) => string} options.resolveAsset
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
* @param {(options: object) => Promise<object>} [options.loadModel]
* @returns {object}
*/
export function createLocalAdsbModels({
viewer,
color,
resolveAsset,
groundSnap = null,
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
}) {
const collection = new Cesium.PrimitiveCollection();
viewer.scene.primitives.add(collection);
/** @type {Map<string, object>} id -> admitted model */
const models = new Map();
/** @type {Map<string, number>} id -> generation of an in-flight load */
const pending = new Map();
let generation = 0;
let destroyed = false;
let lastEligibilityAt = 0;
let eligible = new Set();
const scratchHpr = new Cesium.HeadingPitchRoll();
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
const scratchCarto = new Cesium.Cartographic();
const scratchGround = new Cesium.Cartesian3();
function release(id) {
pending.delete(id);
const model = models.get(id);
if (!model) return;
models.delete(id);
if (!collection.isDestroyed()) collection.remove(model);
groundSnap?.forget?.(id);
}
function releaseAll() {
for (const id of [...models.keys()]) release(id);
pending.clear();
generation += 1;
}
async function ensure(id, marker, cap) {
const spec = civilAircraftModelSpec(marker.klass);
const key = specKey(spec);
const current = models.get(id);
if (current && current._gevSpecKey === key) return;
if (current) release(id);
if (pending.has(id) || models.size + pending.size >= cap) return;
const loadGeneration = generation;
pending.set(id, loadGeneration);
let model;
try {
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: spec.blendAmount,
id,
});
} catch {
if (pending.get(id) === loadGeneration) pending.delete(id);
return;
}
const stale =
destroyed ||
pending.get(id) !== loadGeneration ||
generation !== loadGeneration ||
!eligible.has(id) ||
models.has(id) ||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
if (pending.get(id) === loadGeneration) pending.delete(id);
if (stale) {
model.destroy?.();
return;
}
model.id = id;
model._gevSpecKey = key;
model._gevBellyM = spec.bellyM;
// Admitted, not yet the visual: placement turns it on.
model.show = false;
collection.add(model);
models.set(id, model);
}
function regime(preferences) {
if (!preferences?.models3d) return false;
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
return height < MODEL_ALT_CEIL_M;
}
function refreshEligibility(markers, preferences) {
const all = preferences.models3dMode === 'all';
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
const camera = viewer.camera;
const cameraPosition = camera.positionWC;
const cull = camera.frustum.computeCullingVolume(
cameraPosition,
camera.directionWC,
camera.upWC,
);
const candidates = [];
for (const [id, marker] of markers) {
if (!marker.position) continue;
const distanceSq = Cesium.Cartesian3.distanceSquared(
cameraPosition,
marker.position,
);
if (distanceSq > keepM * keepM) continue;
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
candidates.push([
id,
distanceSq,
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
]);
}
candidates.sort((a, b) => a[1] - b[1]);
eligible = selectModelEligible(candidates, {
cap,
addDistSq: addM * addM,
isModeled: (id) => models.has(id),
});
for (const id of [...models.keys(), ...pending.keys()])
if (!eligible.has(id)) release(id);
for (const id of eligible) void ensure(id, markers.get(id), cap);
}
function placement(id, marker) {
if (!marker.onGround) return marker.position;
const model = models.get(id);
// The vertical sample must hit the tile skin, not this aircraft's own
// billboard or model.
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
[marker.entity, model].filter(Boolean),
);
if (!Number.isFinite(height)) return null;
const carto = Cesium.Cartographic.fromCartesian(
marker.position,
Cesium.Ellipsoid.WGS84,
scratchCarto,
);
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
height + (model?._gevBellyM || 0),
Cesium.Ellipsoid.WGS84,
scratchGround,
);
}
return {
/** Ids whose model admission is current; the layer enriches these. */
get eligible() {
return eligible;
},
/**
* Per-frame pass: re-derive eligibility at the fleet cadence, then place
* every admitted model and hand each marker's visual to its model only
* once the model is ready and placed.
* @param {Map<string, object>} markers Layer markers (position, courseDeg,
* klass, onGround); `modelOwnsVisual` is written back.
* @param {object} preferences `{ models3d, models3dMode }`.
* @param {number} nowMs
*/
frame(markers, preferences, nowMs) {
if (destroyed) return;
if (!regime(preferences)) {
if (models.size || pending.size) releaseAll();
eligible = new Set();
for (const marker of markers.values()) marker.modelOwnsVisual = false;
return;
}
if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) {
lastEligibilityAt = nowMs;
refreshEligibility(markers, preferences);
}
const occluder = horizonOccluder(viewer.camera);
for (const [id, marker] of markers) {
const model = models.get(id);
marker.modelOwnsVisual = false;
if (!model || !marker.position) continue;
const position = placement(id, marker);
if (!position || !occluder.isPointVisible(marker.position)) {
model.show = false;
continue;
}
scratchHpr.heading = Cesium.Math.toRadians(
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
);
scratchHpr.pitch = 0;
scratchHpr.roll = 0;
Cesium.Transforms.headingPitchRollToFixedFrame(
position,
scratchHpr,
Cesium.Ellipsoid.WGS84,
undefined,
model.modelMatrix,
);
if (!model.ready) {
model.show = false;
continue;
}
model.show = true;
marker.modelOwnsVisual = true;
}
for (const id of [...models.keys()]) if (!markers.has(id)) release(id);
},
/**
* Drop the model, and any load still in flight, of every aircraft that
* is no longer a marker. The layer calls this whenever markers expire:
* the per-frame pass does not run once the last marker is gone.
* @param {Map<string, object>|Set<string>} live Current marker ids.
*/
retain(live) {
if (destroyed) return;
let dropped = false;
for (const id of [...models.keys(), ...pending.keys()]) {
if (live.has(id)) continue;
release(id);
eligible.delete(id);
dropped = true;
}
if (dropped) eligible = new Set(eligible);
},
/** Drop every model (3D off, layer disabled). */
clear: releaseAll,
/** Number of admitted models (tests and diagnostics). */
get size() {
return models.size;
},
destroy() {
destroyed = true;
releaseAll();
groundSnap?.clear?.();
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
viewer.scene.primitives.remove(collection);
},
};
}
+427
View File
@@ -0,0 +1,427 @@
import * as Cesium from 'cesium';
import {
COURSE_HOLD_SPEED_MPS,
TURN_MIN_SPEED_MPS,
courseBetweenCartesians,
courseSlewCapDps,
estimateTurnRateDps,
lerpAngleDeg,
limitCourseStep,
norm360,
projectGroundArcLatLon,
speedRamp,
staleCoastLimitSeconds,
} from '../../data/motionModel.js';
import {
LOCAL_ADSB_REANCHOR_AFTER,
localAdsbFixIsPlausible,
} from '../../sources/adsbRecords.js';
import {
COURSE_MAX_DPS,
COURSE_SLEW_DT_MAX_SEC,
DR_CORRECTION_MS,
} from '../flights/policy.js';
/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
export const LOCAL_ADSB_HISTORY_POINTS = 600;
/** A display correction larger than this snaps instead of sliding. */
export const LOCAL_ADSB_SNAP_M = 3_000;
/** Chord course uses the newest fix at least this much older than the anchor. */
const CHORD_BASELINE_MS = 2_000;
/** Turn rate is estimated over this window of heard fixes. */
const TURN_WINDOW_MS = 30_000;
/** Coast at least this long past a fix; longer while messages keep coming. */
const COAST_MINIMUM_SEC = 10;
const COAST_CONTACT_GRACE_SEC = 10;
const COAST_MAXIMUM_SEC = 60;
const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8;
const DEG = Math.PI / 180;
const _chordFrom = new Cesium.Cartesian3();
const _chordTo = new Cesium.Cartesian3();
function finite(value) {
return Number.isFinite(value) ? value : null;
}
/**
* When a record's telemetry (altitude, speed, track, climb) was last heard:
* its newest message, which velocity-only and altitude-only messages advance
* without a new position.
*/
function telemetryTime(record) {
const messageAt = finite(record?.lastMessageAt);
const positionAt = finite(record?.lastPositionAt);
if (messageAt === null) return positionAt;
return positionAt === null ? messageAt : Math.max(messageAt, positionAt);
}
function chordCourse(from, to) {
Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom);
Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo);
return courseBetweenCartesians(_chordFrom, _chordTo);
}
/**
* Real-time display motion for one locally heard aircraft.
*
* Unlike the public flight layers there is no deliberate one-poll display
* delay: local fixes arrive about once a second, so the marker is
* extrapolated FORWARD from the newest fix with the shared motion model
* (constant-rate-turn arc, path-derived course blended over the reported
* track by speed, rate-limited slew) and a new fix is absorbed by a
* correction offset that decays over the same window the tracked public
* flight uses, so the marker never snaps. Every fix also passes the same
* speed check the decoder applies, which covers decoder-feed records too.
*
* Positions are geodetic (degrees, barometric feet); callers add the render
* datum. The heard history doubles as the selected aircraft's trail.
*/
export class LocalAdsbMotion {
constructor() {
/** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null,
* trackDeg:number|null, speedMps:number|null}>} */
this.fixes = [];
this.anchor = null;
this.correction = null;
this.courseDeg = null;
this.lastCourseAt = null;
this.lastContactAt = null;
this.rejectStreak = 0;
this.rejectedFixes = 0;
// The newest position evaluated, accepted or refused. Re-reading it (the
// layer re-syncs the same record several times a second) is not a new
// observation and must not count toward a re-anchor.
this.lastCandidate = null;
// When the anchor's telemetry was heard. Telemetry has its own freshness:
// position freshness only decides trail points and re-anchoring.
this.telemetryAt = null;
}
/**
* Take a record's newest position. Returns true when it became the new
* anchor; a repeated, out-of-order or implausible fix returns false.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms (display clock).
* @returns {boolean}
*/
observe(record, nowMs) {
if (Number.isFinite(record?.lastMessageAt))
this.lastContactAt = Math.max(
this.lastContactAt ?? -Infinity,
record.lastMessageAt,
);
const at = finite(record?.lastPositionAt);
const lat = finite(record?.lat);
const lon = finite(record?.lon);
if (at === null || lat === null || lon === null) return false;
const newest = this.fixes.at(-1);
const seen = this.lastCandidate;
// No new position, but velocity and altitude messages arrive between
// position messages: newer telemetry still reaches the anchor.
if (
(newest && at <= newest.at) ||
(seen && at <= seen.at) ||
// A decoder feed re-reads the same fix every poll and its rebased time
// jitters with `seen_pos` rounding: the same position is not a new fix.
(seen && !seen.accepted && lat === seen.lat && lon === seen.lon)
) {
this._refreshTelemetry(record, nowMs);
return false;
}
if (newest && lat === newest.lat && lon === newest.lon) {
// Same coordinates, but altitude and velocity may still be newer (a
// hovering helicopter climbing, an aircraft coming to a stop): update
// the motion telemetry without adding a trail point.
this.lastCandidate = { at, lat, lon, accepted: true };
this.rejectStreak = 0;
this._refreshTelemetry(record, nowMs);
return false;
}
const speedMps = Number.isFinite(record.groundSpeedKt)
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
: (newest?.speedMps ?? null);
const fix = {
at,
lat,
lon,
altitudeFt: finite(record.altitudeFt),
trackDeg: Number.isFinite(record.trackDeg)
? norm360(record.trackDeg)
: null,
speedMps,
};
if (
newest &&
!localAdsbFixIsPlausible(newest, fix, {
// The last known speed stands in while a record carries none.
groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null,
category: record.category,
})
) {
this.rejectedFixes += 1;
this.rejectStreak += 1;
this.lastCandidate = { at, lat, lon, accepted: false };
if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) {
// The position is refused, not the record's telemetry.
this._refreshTelemetry(record, nowMs);
return false;
}
// Three refusals in a row: the stream moved on and the old anchor was
// the outlier. Restart the history from the new fix.
this.fixes = [];
this.correction = null;
}
this.rejectStreak = 0;
this.lastCandidate = { at, lat, lon, accepted: true };
const prior = this.anchor;
const before = prior ? this.displayAt(nowMs, { slew: false }) : null;
this.fixes.push(fix);
this._trimHistory(at);
// Position and telemetry are accepted independently: merged receivers
// can deliver a newer position in a record whose last message is OLDER
// than the telemetry the anchor already holds. The trail point keeps the
// record's values as heard; the anchor keeps the newer telemetry.
const recordTelemetryAt = telemetryTime(record);
const keepTelemetry =
prior &&
this.telemetryAt !== null &&
(recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt);
if (keepTelemetry) {
this.anchor = {
...this._anchorFrom(
{
...fix,
altitudeFt: prior.altitudeFt,
speedMps: prior.speedMps,
trackDeg: prior.trackDeg,
},
{ verticalRateFpm: prior.verticalRateFpm },
),
altitudeAt: Number.isFinite(prior.altitudeAt)
? prior.altitudeAt
: prior.at,
};
} else {
this.anchor = this._anchorFrom(fix, record);
this.telemetryAt = recordTelemetryAt;
}
this._absorb(before, nowMs);
return true;
}
/**
* Apply a record's altitude, speed, track and vertical rate to the current
* anchor when they were heard after the anchor's telemetry, whether or not
* its position is new.
*/
_refreshTelemetry(record, nowMs) {
const anchor = this.anchor;
const newest = this.fixes.at(-1);
if (!anchor || !newest) return;
const at = telemetryTime(record);
if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt))
return;
this.telemetryAt = at;
const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt;
const speedMps = Number.isFinite(record.groundSpeedKt)
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
: anchor.speedMps;
const trackDeg = Number.isFinite(record.trackDeg)
? norm360(record.trackDeg)
: anchor.trackDeg;
const verticalRateFpm = finite(record.verticalRateFpm);
if (
altitudeFt === anchor.altitudeFt &&
speedMps === anchor.speedMps &&
trackDeg === anchor.trackDeg &&
verticalRateFpm === anchor.verticalRateFpm
)
return;
const before = this.displayAt(nowMs, { slew: false });
// The trail point keeps its heard position; the anchor takes the newer
// telemetry, with altitude extrapolated from when it was reported.
this.anchor = {
...this._anchorFrom(
{ ...newest, altitudeFt, speedMps, trackDeg },
{ verticalRateFpm },
),
altitudeAt: at,
};
this._absorb(before, nowMs);
}
/** Slide from where the marker was drawn to the new anchor, or snap. */
_absorb(before, nowMs) {
if (before) {
const after = this._raw(nowMs);
const cosLat = Math.cos(after.lat * DEG);
const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M;
const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat;
const up =
Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt)
? before.altitudeFt - after.altitudeFt
: 0;
this.correction =
Math.hypot(north, east) > LOCAL_ADSB_SNAP_M
? null
: { east, north, upFt: up, startAt: nowMs };
}
}
_trimHistory(newestAt) {
let drop = 0;
while (
drop < this.fixes.length - 1 &&
(newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS ||
this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS)
)
drop += 1;
if (drop) this.fixes.splice(0, drop);
}
_anchorFrom(fix, record) {
let chord = null;
for (let index = this.fixes.length - 2; index >= 0; index -= 1) {
if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue;
chord = chordCourse(this.fixes[index], fix);
break;
}
let courseDeg = fix.trackDeg;
if (chord !== null)
courseDeg =
courseDeg === null
? chord
: lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps));
const samples = [];
let lastSampleAt = -Infinity;
for (const sample of this.fixes) {
if (fix.at - sample.at > TURN_WINDOW_MS) continue;
if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue;
lastSampleAt = sample.at;
samples.push({
tSec: sample.at / 1000,
trackDeg: sample.trackDeg,
speedMps: sample.speedMps,
});
}
return {
...fix,
courseDeg,
turnRateDps: estimateTurnRateDps(
samples,
undefined,
undefined,
TURN_MIN_SPEED_MPS,
),
verticalRateFpm: finite(record.verticalRateFpm),
};
}
_coastSeconds(nowMs) {
const anchor = this.anchor;
const limit = staleCoastLimitSeconds({
fixEpochMs: anchor.at,
lastContactEpochMs: this.lastContactAt,
minimumSec: COAST_MINIMUM_SEC,
contactGraceSec: COAST_CONTACT_GRACE_SEC,
maximumSec: COAST_MAXIMUM_SEC,
});
return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000));
}
_raw(nowMs) {
const anchor = this.anchor;
const dtSec = this._coastSeconds(nowMs);
const moving =
Number.isFinite(anchor.speedMps) &&
anchor.speedMps >= COURSE_HOLD_SPEED_MPS &&
Number.isFinite(anchor.courseDeg);
const projected = moving
? projectGroundArcLatLon(
anchor.lat,
anchor.lon,
anchor.courseDeg,
anchor.speedMps,
anchor.turnRateDps,
dtSec,
)
: { lat: anchor.lat, lon: anchor.lon };
const altitudeDtSec = Number.isFinite(anchor.altitudeAt)
? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000))
: dtSec;
const altitudeFt =
Number.isFinite(anchor.altitudeFt) &&
Number.isFinite(anchor.verticalRateFpm)
? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60
: anchor.altitudeFt;
return {
lat: projected.lat,
lon: projected.lon,
altitudeFt,
courseDeg: moving
? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec)
: null,
speedMps: anchor.speedMps,
};
}
/**
* Where to draw the aircraft now.
* @param {number} nowMs Current epoch ms.
* @param {{slew?: boolean}} [options] `slew: false` reads without advancing
* the rate-limited course.
* @returns {{lat:number, lon:number, altitudeFt:number|null,
* courseDeg:number|null}|null}
*/
displayAt(nowMs, { slew = true } = {}) {
if (!this.anchor) return null;
const raw = this._raw(nowMs);
const correction = this.correction;
if (correction) {
const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS;
if (weight <= 0) this.correction = null;
else {
const w = Math.min(1, weight);
raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG;
raw.lon +=
(correction.east * w) /
(EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) /
DEG;
if (Number.isFinite(raw.altitudeFt))
raw.altitudeFt += correction.upFt * w;
}
}
let courseDeg = this.courseDeg;
// Hover and taxi noise: below the hold speed the course is not updated.
const target =
raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null);
if (slew && target !== null) {
const dtSec =
this.lastCourseAt === null
? 0
: Math.min(
COURSE_SLEW_DT_MAX_SEC,
Math.max(0, (nowMs - this.lastCourseAt) / 1000),
);
courseDeg = limitCourseStep(
courseDeg,
target,
courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS),
dtSec,
);
this.courseDeg = courseDeg;
this.lastCourseAt = nowMs;
} else if (courseDeg === null && target !== null) {
courseDeg = target;
}
return {
lat: raw.lat,
lon: raw.lon,
altitudeFt: raw.altitudeFt,
courseDeg,
};
}
}
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LOCAL_ADSB_HISTORY_MS,
LOCAL_ADSB_HISTORY_POINTS,
LocalAdsbMotion,
} from './motion.js';
function fix(at, lat, overrides = {}) {
return {
icao: 'abc123',
lat,
lon: 0,
altitudeFt: 5_000,
groundSpeedKt: 120,
trackDeg: 0,
verticalRateFpm: 0,
lastPositionAt: at,
lastMessageAt: at,
...overrides,
};
}
// 120 kt north: degrees of latitude per second.
const STEP = (120 * 1852) / 3600 / 111_195;
test('the heard history is bounded to 600 fixes and 10 minutes', () => {
const motion = new LocalAdsbMotion();
for (let second = 0; second < 700; second += 1)
motion.observe(fix(second * 1_000, second * STEP), second * 1_000);
assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS);
assert.equal(motion.fixes[0].at, 100_000);
const sparse = new LocalAdsbMotion();
for (let minute = 0; minute < 15; minute += 1) {
const at = minute * 60_000;
sparse.observe(fix(at, minute * 60 * STEP), at);
}
assert.ok(
sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS,
);
assert.equal(sparse.fixes.length, 11);
});
test('repeated and out-of-order fixes do not move the anchor', () => {
const motion = new LocalAdsbMotion();
assert.equal(motion.observe(fix(10_000, 0), 10_000), true);
assert.equal(motion.observe(fix(10_000, 0), 10_500), false);
assert.equal(motion.observe(fix(9_000, 0), 10_500), false);
// A feed re-reading the same fix with a jittered, later time.
assert.equal(motion.observe(fix(10_080, 0), 11_000), false);
assert.equal(motion.fixes.length, 1);
});
test('coasting stops 10 s after the last message', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
const coasted = motion.displayAt(30_000).lat;
assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`);
});
test('three refused fixes in a row restart the track from the new stream', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
for (let index = 1; index <= 2; index += 1)
assert.equal(
motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000),
false,
);
assert.equal(motion.rejectedFixes, 2);
assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true);
assert.equal(motion.rejectedFixes, 3);
assert.equal(
motion.fixes.length,
1,
'the history restarts at the new stream',
);
assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9);
});
test('the last known speed bounds a fix whose record carries none', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
// 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far
// beyond the 120 kt the aircraft last reported.
assert.equal(
motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000),
false,
);
assert.equal(motion.rejectedFixes, 1);
});
test('re-observing one refused fix never re-anchors on it', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
const outlier = fix(1_000, 1);
// The layer re-syncs the same record several times a second.
for (let sync = 0; sync < 6; sync += 1)
assert.equal(motion.observe(outlier, 1_000 + sync * 250), false);
assert.equal(motion.rejectedFixes, 1, 'one fix is refused once');
assert.equal(motion.rejectStreak, 1);
assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track');
// A jittered re-read of the same refused position is the same fix too.
assert.equal(motion.observe(fix(1_080, 1), 2_500), false);
assert.equal(motion.rejectStreak, 1);
});
test('an unchanged position still delivers newer altitude and velocity', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0);
// A hovering helicopter: same coordinates, climbing, now stationary.
motion.observe(
fix(5_000, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
verticalRateFpm: 0,
trackDeg: 90,
}),
5_000,
);
assert.equal(motion.fixes.length, 1, 'no duplicate trail point');
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.anchor.trackDeg, 90);
// Past the correction window the display settles on the new telemetry.
const display = motion.displayAt(20_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
});
test('a delayed position never rolls back newer telemetry', () => {
const motion = new LocalAdsbMotion();
const step100 = (100 * 1852) / 3600 / 111_195;
assert.equal(
motion.observe(
fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }),
10_000,
),
true,
);
// Velocity and altitude heard at 20 s, no new position.
motion.observe(
fix(10_000, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
lastMessageAt: 20_000,
}),
20_000,
);
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.telemetryAt, 20_000);
// Another receiver's record: a newer position (15 s) whose telemetry is
// older than what the anchor already holds.
const delayed = fix(15_000, 5 * step100, {
altitudeFt: 1_000,
groundSpeedKt: 100,
});
assert.equal(motion.observe(delayed, 21_000), true, 'the position is new');
assert.equal(motion.fixes.length, 2);
assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves');
assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt');
assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft');
assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back');
assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard');
const display = motion.displayAt(40_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(
Math.abs(display.lat - 5 * step100) < 1e-9,
'a stopped aircraft does not coast',
);
});
test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0);
// No new position message: only velocity and altitude are newer.
const newer = fix(0, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
trackDeg: 90,
lastMessageAt: 5_000,
});
assert.equal(motion.observe(newer, 5_000), false, 'no new trail point');
assert.equal(motion.fixes.length, 1);
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.anchor.altitudeFt, 1_500);
assert.equal(motion.anchor.trackDeg, 90);
// Re-reading that record is not newer telemetry, and an older message
// never rolls it back.
motion.observe(newer, 5_500);
motion.observe(
fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }),
6_000,
);
assert.equal(motion.anchor.altitudeFt, 1_500);
assert.equal(motion.anchor.speedMps, 0);
const display = motion.displayAt(20_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
});
+21
View File
@@ -0,0 +1,21 @@
export const LAYER_ID = 'local-adsb';
export const LAYER_NAME = 'Local ADS-B';
export const LAYER_SOURCE = 'RTL-SDR · WebUSB';
/** Distinct from the public Flights palette so both can be drawn together. */
export const LOCAL_ADSB_COLOR = '#ff4fd8';
/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */
export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef';
export const ENTITY_PREFIX = `${LAYER_ID}:`;
/** Re-evaluate marker freshness and the card's position age this often. */
export const LOCAL_ADSB_TICK_MS = 1_000;
/** Coalesce receiver bursts into at most one scene sync per interval. */
export const LOCAL_ADSB_SYNC_MS = 200;
export const HEARD_BY_RECEIVER = 'Heard by your receiver';
export const BAND_LABELS = Object.freeze({
1090: '1090 MHz',
978: '978 MHz UAT',
});
export const SOURCE_LABELS = Object.freeze({
webusb: 'browser SDR',
feed: 'decoder feed',
});
+141
View File
@@ -0,0 +1,141 @@
import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.js';
/**
* Layers-panel status for Local ADS-B, from its two inputs: the browser
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
*/
export const FEED_SOURCE = 'Decoder feeds';
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
function plural(count, one, many) {
return count === 1 ? one : many;
}
/**
* Describe the feeds that are not live, grouped by status, e.g.
* "feed 978 unreachable" or "feeds 1090, 978 stale".
* @param {object[]} feeds Feed statuses from the route.
* @returns {string}
*/
export function describeFeedProblems(feeds) {
const byStatus = new Map();
for (const feed of feeds) {
if (feed?.status === 'live') continue;
const status = feed?.status || 'unreachable';
if (!byStatus.has(status)) byStatus.set(status, new Set());
byStatus.get(status).add(localReceiverFeedName(feed, feeds));
}
return [...byStatus]
.map(([status, names]) => {
const list = [...names];
return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`;
})
.join(' · ');
}
/** Whether the server reported at least one configured feed. */
export function feedsConfigured(feedState) {
return Boolean(
feedState?.configured === true &&
Array.isArray(feedState.feeds) &&
feedState.feeds.length,
);
}
function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: 'opening receiver' };
if (
feedState?.polling &&
feedState.configured === null &&
!receiver.connected
)
return { loading: true, loadingLabel: 'checking decoder feeds' };
if (!receiver.webUsbSupported)
return {
status: 'idle',
statusMessage: 'WebUSB needs desktop Chrome or Edge',
};
if (!receiver.connected)
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
if (receiver.mode !== 'adsb')
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: 'listening';
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
/**
* Status fields of `getStats()` for the Local ADS-B row.
*
* Without configured feeds this is exactly the WebUSB status. With feeds:
* "2 feeds live · 14 heard" when every feed is live. While any input is
* producing (the browser receiver streaming, or a feed live) the row stays
* nominal and names the other feeds as a trailing note, e.g. "3 heard · USB
* 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the
* dongle is not a fault. With nothing producing, every readable feed stale is
* STALE and anything else is an error.
* @param {object} options
* @param {object} options.receiver WebUSB receiver snapshot.
* @param {object|null} options.feedState Feed poller snapshot.
* @param {number} options.heard Merged aircraft heard in the last 60 s.
* @returns {object} status / statusMessage / loading / loadingLabel / error /
* stale / source.
*/
export function localAdsbStatus({ receiver, feedState, heard }) {
if (!feedsConfigured(feedState))
return {
source: LAYER_SOURCE,
...webUsbStatus(receiver, heard, feedState),
};
const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb');
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
const live = feedState.feeds.filter((feed) => feed.status === 'live');
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
if (usbActive && receiver.status === 'error') problems.push('USB error');
const rate =
usbActive &&
receiver.status !== 'error' &&
Number.isFinite(receiver.messagesPerSecond)
? ` · USB ${receiver.messagesPerSecond} msg/s`
: '';
const heardText = `${heard} heard`;
if (!problems.length) {
return {
source,
status: 'streaming',
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
};
}
const problemText = problems.join(' · ');
const usbProducing = usbActive && receiver.status !== 'error';
if (!live.length && !usbProducing) {
// Decoders that stopped but still serve their last aircraft.json are
// stale, not down: only an unreachable or unusable feed is an error.
const readable = feedState.feeds.filter(
(feed) => feed.status !== 'invalid',
);
if (readable.length && readable.every((feed) => feed.status === 'stale'))
return {
source,
status: 'stale',
stale: true,
statusMessage: `${problemText} · ${heardText}`,
};
return {
source,
status: 'error',
error: heard > 0 ? `${problemText} · ${heardText}` : problemText,
};
}
return {
source,
status: 'streaming',
loadingLabel: `${heardText}${rate} · ${problemText}`,
};
}
+11 -22
View File
@@ -8,6 +8,8 @@ import {
trailAnchorForModel,
} from '../../data/modelVisualAnchor.js';
import * as Cesium from 'cesium';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -151,7 +153,7 @@ 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;
@@ -171,7 +173,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1);
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -892,25 +894,11 @@ export function createRendering({
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
} // 1. KEEP on-screen
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
} // 2. ADD on-screen
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
} // 3. KEEP off-screen (can't starve visible)
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
} // 4. ADD off-screen leftover
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))
@@ -977,6 +965,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -984,7 +973,7 @@ export function createRendering({
? 1
: _militaryBillboardScale(icao24),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseColor: MIL_ICON_COLOR,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
+2
View File
@@ -227,6 +227,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+157
View File
@@ -0,0 +1,157 @@
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
/** CSS accent for a perimeter card by containment progress (mirrors the fill). */
export function containmentAccent(containedPct) {
if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30';
if (containedPct < 50) return '#ff7a00';
if (containedPct < 100) return '#ffb300';
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
sum += x1 * y2 - x2 * y1;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
export function perimeterAnchorDegrees(polygons) {
let best = null;
let bestArea = -1;
for (const rings of polygons) {
const outer = rings[0];
const area = ringArea(outer);
if (area > bestArea) {
bestArea = area;
best = outer;
}
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let lat = 0;
const count = best.length - 1;
for (let i = 0; i < count; i++) {
lon += best[i][0];
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
}
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
function formatCost(dollars) {
if (!Number.isFinite(dollars) || dollars < 1000) return null;
const units = [
[1e3, 'K'],
[1e6, 'M'],
[1e9, 'B'],
];
let index = 0;
for (let i = units.length - 1; i >= 0; i--) {
if (dollars >= units[i][0]) {
index = i;
break;
}
}
// 999,500 must promote to $1M, never render as $1000K.
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index][0];
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
}
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
function titleCase(name) {
return String(name)
.toLowerCase()
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = Math.floor(deltaMs / 3600000);
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* Build the overlay-host entry for one selected incident. The caller supplies
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
* @param {Object} row - Normalized perimeter row (see records.js).
* @param {number} nowMs - Current epoch milliseconds.
* @param {{link: ?string}} [extras] - Optional InciWeb page for this incident.
* @returns {Object} World-overlay entry without `position`.
*/
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
const facts = [];
if (Number.isFinite(row.acres))
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
facts.push(
Number.isFinite(row.containedPct)
? `${Math.round(row.containedPct)}% contained`
: 'containment unknown',
);
if (row.state) facts.push(row.state);
else if (row.category) facts.push(row.category);
const situation = [];
if (row.cause) situation.push(`${row.cause} cause`);
if (row.behavior) situation.push(row.behavior);
if (row.complexity) situation.push(row.complexity);
const response = [];
if (Number.isFinite(row.personnel))
response.push(
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
);
if (row.county) response.push(`${row.county} County`);
if (Number.isFinite(row.costToDate)) {
const cost = formatCost(row.costToDate);
if (cost) response.push(`${cost} to date`);
}
const ages = [];
const discovered = formatAge(nowMs - row.discoveredTime);
if (row.discoveredTime != null && discovered)
ages.push(`discovered ${discovered} ago`);
const updated = formatAge(nowMs - row.updatedTime);
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
const details = [facts.join(' · ')];
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
if (situation.length) details.push(situation.join(' · '));
if (response.length) details.push(response.join(' · '));
if (ages.length) details.push(ages.join(' · '));
if (link) details.push('InciWeb ↗ · click card to open');
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
return {
id: `fire-perimeter-card:${row.stableId}`,
// `selected` picks the paint lane and collision protection; an explicit
// paintLane would override it in the host's lane resolver.
selected: true,
// Only a linked card is clickable — the overlay host registers hit
// rectangles solely for interactive entries.
interactive: Boolean(link),
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
title,
details,
accent: containmentAccent(row.containedPct),
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js';
const square = [
[-108.2, 35.0],
[-108.0, 35.0],
[-108.0, 35.2],
[-108.2, 35.2],
[-108.2, 35.0],
];
const tiny = [
[-107.01, 34.0],
[-107.0, 34.0],
[-107.0, 34.01],
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
{
stableId: '2026-NMGNF-000123',
name: 'Frijoles',
acres: 15956.4,
containedPct: 74,
state: 'US-NM',
category: 'WF',
discoveredTime: now - 36 * 3600000,
updatedTime: now - 2 * 3600000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
},
now,
);
assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(card.title, 'FIRE · Frijoles');
assert.deepEqual(card.details, [
'15,956 ac · 74% contained · US-NM',
'Natural cause · Active · Type 3 Incident',
'380 personnel · Sandoval County · $4.2M to date',
'discovered 1d ago · updated 2h ago',
]);
assert.equal(card.selected, true);
assert.equal(typeof card.accent, 'string');
});
test('missing attributes degrade to available facts instead of placeholders', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: null,
acres: null,
containedPct: null,
state: null,
category: 'RX',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.title, 'FIRE · Unnamed incident');
assert.deepEqual(card.details, ['containment unknown · RX']);
});
test('a known InciWeb page adds a link line to the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Timber',
acres: 25426,
containedPct: 44,
state: 'US-CA',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
{
link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire',
},
);
assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open');
assert.equal(card.interactive, true);
assert.match(card.accessibilityLabel, /InciWeb/);
});
test('the selected card claims the selected paint lane, not the ambient lane', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Any',
acres: 1,
containedPct: 1,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
1758000000000,
);
assert.equal(card.selected, true);
// An explicit paintLane would override `selected` in the overlay host's
// lane resolver and push the card under other selected cards.
assert.equal('paintLane' in card, false);
assert.equal('collisionGroup' in card, false);
});
test('costs near a unit boundary promote to the larger unit', () => {
const base = {
stableId: 'x',
name: 'Any',
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
complexity: null,
complexName: null,
};
const costLine = (costToDate) =>
buildIncidentCard({ ...base, costToDate }, 0).details.find((line) =>
line.includes('to date'),
);
assert.equal(costLine(999500), '$1M to date');
assert.equal(costLine(999499), '$999K to date');
assert.equal(costLine(999600000), '$1B to date');
assert.equal(costLine(4200000), '$4.2M to date');
});
test('a complex member names its complex on the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 342923,
containedPct: 99,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: 'ROWE CREEK COMPLEX',
},
1758000000000,
);
assert.ok(card.details.includes('part of Rowe Creek Complex'));
});
test('a card without a link is not interactive', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 1,
containedPct: 1,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.interactive, false);
});
test('partial incident details render only the facts that exist', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Whiskey',
acres: 542,
containedPct: 48,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: 'Human',
behavior: null,
personnel: null,
county: 'Catron',
costToDate: 85000,
complexity: null,
},
1758000000000,
);
assert.deepEqual(card.details, [
'542 ac · 48% contained · US-NM',
'Human cause',
'Catron County · $85K to date',
]);
});
+173
View File
@@ -0,0 +1,173 @@
/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/**
* Normalize an incident title for matching: lowercase, collapsed whitespace,
* a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped
* ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and
* WFIGS names carry neither.
*/
function normalizeName(name) {
return String(name)
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/^(19|20)\d{2} /, '')
.replace(/ fire$/, '');
}
/**
* Resolve one WFIGS incident against the publication catalog.
* Only rows whose normalized title equals the incident's normalized name
* count. Ambiguity resolves by the tau dispatch unit's state prefix, then
* by newest incident id (same-state duplicates are usually reburns of the
* same name). The `/node/{id}` link 301s to the canonical page, so no slug
* construction is needed.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string}} incident - WFIGS row facts.
* @returns {?string} InciWeb node URL.
*/
export function resolveInciwebNodeLink(publications, { name, state }) {
if (!Array.isArray(publications) || !name) return null;
const wanted = normalizeName(name);
let candidates = publications.filter(
(row) =>
typeof row?.incident_title === 'string' &&
/^\d+$/.test(String(row?.incident_id ?? '')) &&
normalizeName(row.incident_title) === wanted,
);
// The catalog is all-time and nationwide, so the dispatch unit's state
// must corroborate every match — a globally unique title is NOT evidence
// it's the same fire (live data shows same-name incidents across states).
const statePrefix =
typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state)
? state.slice(3).toLowerCase()
: null;
if (statePrefix) {
candidates = candidates.filter(
(row) =>
typeof row.tau === 'string' &&
row.tau.slice(0, 2).toLowerCase() === statePrefix,
);
} else if (candidates.length > 1) {
return null;
}
if (candidates.length > 1) {
// Same-state duplicates are usually reburns of the same name — newest
// wins here, and the publication-currency check guards the final link.
candidates = [
candidates.reduce((a, b) =>
Number(a.incident_id) >= Number(b.incident_id) ? a : b,
),
];
}
return candidates.length === 1
? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}`
: null;
}
/** The publication id inside a node link produced above, or null. */
export function inciwebNodeId(link) {
const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec(
String(link ?? ''),
);
return match ? match[1] : null;
}
// A fire's InciWeb page is created around the incident's start; a match
// whose page predates discovery by more than this is an archived
// same-name incident from a earlier season.
const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000;
// Without a discovery date, require recent editorial activity on the page.
const CHANGED_WITHIN_MS = 180 * 24 * 3600000;
/**
* Whether a publication plausibly describes the given current incident.
* Fails closed on unusable timestamps.
* @param {{createdMs: ?number, changedMs: ?number}} publication
* @param {{discoveredTime: ?number, nowMs?: number}} incident
* @returns {boolean}
*/
export function isCurrentPublication(
{ createdMs, changedMs },
{ discoveredTime, nowMs = Date.now() },
) {
if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) {
return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS;
}
if (Number.isFinite(changedMs)) {
return nowMs - changedMs <= CHANGED_WITHIN_MS;
}
return false;
}
/** Fetch one publication's created/changed timestamps for currency checks. */
export function createInciwebPublicationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getPublication(id, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`,
{ signal },
);
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
512 * 1024,
signal,
);
signal?.throwIfAborted();
return {
createdMs: Number.isFinite(payload?.createdMs)
? payload.createdMs
: null,
changedMs: Number.isFinite(payload?.changedMs)
? payload.changedMs
: null,
};
},
};
}
/**
* Resolve one WFIGS incident to its InciWeb page, or null.
* The incident's own name is tried first; a member of a complex whose own
* name has no page falls back to the complex's page (InciWeb tracks the
* managing complex, not each member fire). Anything ambiguous yields null
* rather than a wrong page.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string, complexName: ?string}} incident
* - WFIGS row facts.
* @returns {?string} InciWeb URL.
*/
export function findInciwebLink(publications, { name, state, complexName }) {
return (
resolveInciwebNodeLink(publications, { name, state }) ??
resolveInciwebNodeLink(publications, { name: complexName, state })
);
}
/** Fetch the full InciWeb incident catalog. */
export function createInciwebIndexSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getIndex({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters/inciweb/index', {
signal,
});
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const rows = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
return Array.isArray(rows) ? rows : [];
},
};
}
+334
View File
@@ -0,0 +1,334 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
findInciwebLink,
resolveInciwebNodeLink,
createInciwebIndexSource,
createInciwebPublicationSource,
isCurrentPublication,
inciwebNodeId,
} from './inciweb.js';
const catalog = [
{
incident_id: '322812',
tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District',
incident_title: 'Gobernador Pile Burn - Coyote Ranger District',
},
{
incident_id: '327504',
tau: 'SDBKF Coyote Flats Fire',
incident_title: 'Coyote Flats Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '329195',
tau: 'ORBUD Second Flat',
incident_title: 'Second Flat',
},
{
incident_id: '329273',
tau: 'ORBUD 2026 Coleman Creek',
incident_title: '2026 Coleman Creek',
},
{
incident_id: '328923',
tau: 'ORPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
{
incident_id: '291765',
tau: 'CALPF Timber Fire',
incident_title: 'Timber Fire',
},
];
test('incidents resolve by exact normalized title to a node link', () => {
// Title formats vary: with/without a 'Fire' suffix, with/without a year
// prefix ('2026 Coleman Creek'). All normalize to the WFIGS name.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329195',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329273',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
// Substring-style near-misses never match.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }),
null,
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }),
null,
);
assert.equal(
resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }),
null,
);
});
test('a unique candidate in the wrong state is rejected, not linked', () => {
// The catalog is all-time and nationwide: a current Arizona "Willow"
// must not link to the only "Willow Fire" on file when it's a Montana
// incident.
const willow = [
{
incident_id: '300001',
tau: 'MTFNF Willow Fire',
incident_title: 'Willow Fire',
},
];
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }),
null,
);
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }),
'https://inciweb.wildfire.gov/node/300001',
);
});
test('ambiguous titles disambiguate by state, then newest id', () => {
const twoCoyotes = [
{
incident_id: '100',
tau: 'AZASF Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }),
'https://inciweb.wildfire.gov/node/100',
);
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }),
null,
);
const twoSameState = [
{
incident_id: '100',
tau: 'ORXXX Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
// Same-state duplicates are usually reburns of the same name — newest wins.
assert.equal(
resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
});
test('a complex member falls back to its complex page', () => {
assert.equal(
findInciwebLink(catalog, {
name: 'Crosswhite',
state: 'US-OR',
complexName: 'ROWE CREEK COMPLEX',
}),
'https://inciweb.wildfire.gov/node/328923',
);
// A fire with its own page keeps it even when it belongs to a complex.
assert.equal(
findInciwebLink(catalog, {
name: 'Timber',
state: 'US-CA',
complexName: 'SOME COMPLEX',
}),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
findInciwebLink(catalog, {
name: 'Nope',
state: 'US-NM',
complexName: null,
}),
null,
);
});
test('a node link exposes its publication id', () => {
assert.equal(
inciwebNodeId('https://inciweb.wildfire.gov/node/329195'),
'329195',
);
assert.equal(inciwebNodeId('https://example.com/other'), null);
assert.equal(inciwebNodeId(null), null);
});
test('publication currency accepts pages created around or after discovery', () => {
const day = 24 * 3600000;
const discovery = 1785000000000;
// Created shortly before discovery (page opened as the fire started).
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 10 * day, changedMs: discovery + 30 * day },
{ discoveredTime: discovery },
),
true,
);
// Created years before discovery: an archived same-name incident.
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 900 * day, changedMs: discovery - 800 * day },
{ discoveredTime: discovery },
),
false,
);
// No discovery date: fall back to recent activity on the page.
const now = 1787600000000;
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 20 * day },
{ discoveredTime: null, nowMs: now },
),
true,
);
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 400 * day },
{ discoveredTime: null, nowMs: now },
),
false,
);
// Unusable timestamps fail closed.
assert.equal(
isCurrentPublication(
{ createdMs: null, changedMs: null },
{ discoveredTime: discovery },
),
false,
);
});
test('the publication source fetches timestamps and honors cancellation', async () => {
let requested;
const source = createInciwebPublicationSource({
fetchImpl: async (url) => {
requested = String(url);
return {
ok: true,
headers: new Headers(),
text: async () =>
JSON.stringify({
createdMs: 1785025540000,
changedMs: 1787589503000,
}),
};
},
});
const publication = await source.getPublication('329195');
assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195');
assert.deepEqual(publication, {
createdMs: 1785025540000,
changedMs: 1787589503000,
});
const malformed = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getPublication('1'), {
createdMs: null,
changedMs: null,
});
const failing = createInciwebPublicationSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/);
const abort = new AbortController();
const cancelled = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({});
},
}),
});
await assert.rejects(
cancelled.getPublication('1', { signal: abort.signal }),
{
name: 'AbortError',
},
);
});
test('the index source requests the same-origin full catalog', async () => {
let request;
const source = createInciwebIndexSource({
fetchImpl: async (url, options) => {
request = { url: String(url), options };
return {
ok: true,
headers: new Headers(),
text: async () => JSON.stringify(catalog),
};
},
});
const rows = await source.getIndex();
assert.equal(rows.length, catalog.length);
assert.equal(request.url, '/api/fire-perimeters/inciweb/index');
assert.equal(request.options.method, undefined);
assert.equal(request.options.body, undefined);
});
test('the index source rejects failures and honors cancellation', async () => {
const failing = createInciwebIndexSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/);
const malformed = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getIndex(), []);
const abort = new AbortController();
const cancelled = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify(catalog);
},
}),
});
await assert.rejects(cancelled.getIndex({ signal: abort.signal }), {
name: 'AbortError',
});
});
+448
View File
@@ -0,0 +1,448 @@
import * as Cesium from 'cesium';
import {
PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees,
buildIncidentCard,
containmentAccent,
} from './cards.js';
import {
findInciwebLink,
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js';
export * from './inciweb.js';
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
export function containmentColor(containedPct) {
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const PICK_PREFIX = 'fire-perimeter:';
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
// The InciWeb catalog changes on the order of days; refreshing it on the
// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB
// of near-identical bytes.
const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
export function createFirePerimetersLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
inciwebSource = null,
inciwebPublications = null,
openExternal = null,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Fire perimeters require a snapshot source');
let _viewer = null;
let _request = null;
let _linkRequest = null;
let _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
let _selectedId = null;
let _selectedLink = null;
let _selectedCardId = null;
let _inciwebIndex = [];
let _indexFetchedAt = 0;
let _indexFetchInFlight = false;
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
// Link → currency verdict from the publication check; unknown links are
// absent. Failed checks are not cached so a transient outage retries.
const _linkVerdicts = new Map();
function abortLinkVerification() {
_linkRequest?.controller.abort();
_linkRequest = null;
}
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
/**
* Refresh the InciWeb catalog off the render path: a hanging or failed
* fetch only costs the link line, never the perimeter refresh — the
* catalog is consumed solely by the selected card.
*/
function refreshInciwebIndex(signal) {
if (!inciwebSource || _indexFetchInFlight) return;
if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return;
_indexFetchInFlight = true;
inciwebSource
.getIndex({ signal })
.then((rows) => {
if (signal.aborted || !_enabled || !Array.isArray(rows)) return;
_inciwebIndex = rows;
_indexFetchedAt = Date.now();
if (_selectedId) publishSelectedCard();
})
.catch(() => {
// Catalog unavailable — cards stay linkless until the next attempt.
})
.finally(() => {
_indexFetchInFlight = false;
});
}
/**
* Verify that a matched publication describes the current incident, not
* an archived same-name fire — the catalog is all-time. Async: the card
* republishes with the link once (and only if) the check passes.
*/
function verifyLink(link, row) {
const id = inciwebNodeId(link);
if (!id || !inciwebPublications || _linkRequest?.link === link) return;
abortLinkVerification();
const request = { link, controller: new AbortController() };
_linkRequest = request;
const { signal } = request.controller;
inciwebPublications
.getPublication(id, { signal })
.then((publication) => {
if (signal.aborted || _linkRequest !== request || !_enabled) return;
_linkVerdicts.set(
link,
isCurrentPublication(publication, {
discoveredTime: row.discoveredTime,
}),
);
if (_selectedId === row.stableId) publishSelectedCard();
})
.catch(() => {
// Fail closed without caching: no link now, retry on reselection.
})
.finally(() => {
if (_linkRequest === request) _linkRequest = null;
});
}
function publishSelectedCard() {
if (!canSelect()) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const candidate = findInciwebLink(_inciwebIndex, row);
if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row);
_selectedLink =
candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
const card = {
...buildIncidentCard(row, Date.now(), { link: _selectedLink }),
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
};
if (!openExternal) card.interactive = false;
if (_selectedLink && openExternal) {
const link = _selectedLink;
// Keyboard/assistive activation mirrors the pointer click-through.
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
}
/** Resolve a scene pick to one of this layer's incident ids, or null. */
function pickedIncidentId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0];
return _rowById.has(incidentId) ? incidentId : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a perimeter. Unowned, our picks read as
// "empty space" to siblings — the pre-existing behavior for the globe.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
// A click on the incident card itself opens its InciWeb page (when the
// incident has one) and never disturbs the selection.
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: PERIMETER_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const incidentId = picked ? pickedIncidentId(picked) : null;
if (incidentId) {
abortLinkVerification();
_selectedId = incidentId;
publishSelectedCard();
return;
}
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
// "empty space" — leave the selection alone and let that layer handle it.
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
}
if (_selectedId) {
abortLinkVerification();
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
}
function clearSelection() {
abortLinkVerification();
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
if (overlayHost) {
overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
}
}
const layer = {
id: 'fire-perimeters',
name: 'Fire Perimeters',
icon: '🔥',
source: 'NIFC WFIGS',
updateInterval: 300000,
init(viewer) {
if (_viewer)
throw new Error('Fire perimeter layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('fire-perimeters');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
rows
.map(({ polygons, ...facts }) => facts)
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastError = null;
return true;
}
abortLinkVerification();
const nextEntities = [];
_rowById.clear();
for (const row of rows) {
const color = containmentColor(row.containedPct);
for (const [index, rings] of row.polygons.entries()) {
const [outer, ...holes] = rings;
const outerPositions = ringPositions(outer);
nextEntities.push(
new Cesium.Entity({
id: `fire-perimeter:${row.stableId}:${index}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
outerPositions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(ringPositions(hole)),
),
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.25),
),
},
// The fire line: ground-clamped outline (entity polygons cannot
// outline clamped geometry themselves).
polyline: {
positions: outerPositions,
clampToGround: true,
width: 2,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.9),
),
},
}),
);
}
// Keep a geometry-free projection: the coordinate arrays would
// otherwise be retained for the layer's lifetime while only the
// anchor is ever read again.
const { polygons, ...facts } = row;
_rowById.set(row.stableId, {
...facts,
anchor: perimeterAnchorDegrees(polygons),
});
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_snapshotSignature = signature;
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
);
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || 'Perimeter source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
removeClickHandler();
clearSelection();
_rowById.clear();
_snapshotSignature = null;
_inciwebIndex = [];
_indexFetchedAt = 0;
_linkVerdicts.clear();
_viewer = null;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const row of _rowById.values()) {
if (result.length >= limit) break;
const { anchor, stableId, ...facts } = row;
result.push({
id: stableId,
...facts,
lat: anchor.lat,
lon: anchor.lon,
});
}
return result;
},
getRowControls() {
const bands = [
{ label: 'Not contained or unknown', color: containmentAccent(0) },
{ label: 'Under 50% contained', color: containmentAccent(1) },
{ label: '50–99% contained', color: containmentAccent(50) },
{ label: 'Fully contained', color: containmentAccent(100) },
];
return {
chips: [],
legend: bands.map((band, index) => ({
...band,
count: [..._rowById.values()].filter(
(row) => containmentAccent(row.containedPct) === band.color,
).length,
...(index === 0
? {
blurb:
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
+554
View File
@@ -0,0 +1,554 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFirePerimetersLayer } from './index.js';
function harness(
source,
{
pick = () => null,
inciwebIndex = null,
cardHit = () => null,
publication = async () => ({
createdMs: 1757000000000,
changedMs: 1757900000000,
}),
} = {},
) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const opened = [];
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createFirePerimetersLayer({
source,
overlayHost: {
setEntries(sourceId, entries) {
overlay.entries.set(sourceId, entries);
},
setVisible(sourceId, visible) {
overlay.visible = visible;
},
clearSource(sourceId) {
overlay.entries.delete(sourceId);
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
String(pickedId).startsWith('other-layer:'),
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
unregisterPickOwner: (layerId) => owners.delete(layerId),
},
pointer: { isPointerFree: () => true },
inciwebSource: inciwebIndex
? { getIndex: async () => inciwebIndex }
: { getIndex: async () => [] },
inciwebPublications: { getPublication: publication },
openExternal: (url) => opened.push(url),
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners, opened };
}
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const row = {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
};
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
assert.ok(entities[0].polygon, 'perimeter fill missing');
assert.ok(entities[0].polyline, 'fire line missing');
assert.equal(entities[1].id, 'fire-perimeter:other:0');
assert.equal(entities[2].id, 'fire-perimeter:other:1');
assert.equal(h.layer.getStats().count, 2);
});
test('late refresh cannot publish after disable or destroy', async () => {
for (const action of ['disable', 'destroy']) {
let resolve, signal;
const h = harness({
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
}
});
test('clicking a perimeter publishes its incident card; empty space clears it', async () => {
let pickResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => pickResult,
},
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
// Deliberately NOT registered as a pick owner: sibling layers' click
// handlers yield to owned picks before their own card hit-tests, so
// claiming these large ground polygons would make FIRMS/vessel/CCTV
// overlay cards inert anywhere over a perimeter.
assert.equal(h.owners.size, 0);
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(entries[0].title, 'FIRE · Fixture Fire');
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('fire-perimeters')?.length,
1,
'sibling-layer picks must not clear the selection',
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('disable removes the click handler, pick ownership, and any card', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
h.layer.disable(h.viewer);
assert.equal(h.clicks.destroyed, 1);
assert.equal(h.owners.has('fire-perimeters'), false);
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
});
test('a refresh that drops the selected incident also drops its card', async () => {
let rows = [row];
const h = harness(
{ getSnapshot: async () => rows },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
rows = [{ ...row, stableId: 'different' }];
await h.layer.update(h.viewer);
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('a matched incident card carries the InciWeb line and click-through', async () => {
const inciwebIndex = [
{
incident_id: '329300',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [{ ...row, name: 'Fixture' }] },
{
pick: () => null,
inciwebIndex,
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
// Select via a perimeter pick.
h.viewer.scene.pick = () => ({
id: 'fire-perimeter:2026-NMGNF-000123:0',
});
h.clicks.handler({ position: { x: 10, y: 10 } });
// The card publishes immediately; the link line lands once the
// publication's currency is validated.
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
// Clicking the card itself opens the InciWeb page.
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
assert.equal(
h.overlay.entries.get('fire-perimeters').length,
1,
'opening the link must not clear the selection',
);
});
test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => {
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [],
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, []);
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
});
test('a complex member resolves to its complex page from the catalog', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [
{
// Same state as the member fire: the state filter applies to
// complex fallbacks too.
incident_id: '328923',
tau: 'NMPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
],
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
});
test('a stale or unverifiable publication yields no link', async () => {
const inciwebIndex = [
{
incident_id: '100',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
for (const publication of [
// Created two years before this fire's discovery: an archived page.
async () => ({
createdMs: 1757900000000 - 700 * 86400000,
changedMs: null,
}),
// Validation endpoint down: fail closed, no link.
async () => {
throw new Error('InciWeb HTTP 503');
},
]) {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex,
publication,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
assert.equal(entries[0].interactive, false);
h.layer.destroy(h.viewer);
}
});
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
// Replace the index source with one that never resolves.
h.layer.destroy(h.viewer);
const hanging = harness({ getSnapshot: async () => [row] });
hanging.layer.destroy(hanging.viewer);
const sources = [];
const viewer = {
scene: { pick: () => null },
dataSources: {
add: (v) => sources.push(v),
remove: (v) => sources.splice(sources.indexOf(v), 1),
},
};
const layer = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: { getIndex: () => new Promise(() => {}) },
inciwebPublications: {
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
},
openExternal: () => {},
});
layer.init(viewer);
layer.enable(viewer);
assert.equal(await layer.update(viewer), true);
assert.equal(layer.getStats().count, 1);
layer.destroy(viewer);
});
test('an InciWeb outage never breaks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] });
const failing = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: {
getIndex: async () => {
throw new Error('InciWeb HTTP 503');
},
},
openExternal: () => {},
});
const viewer = {
scene: { pick: () => null },
dataSources: { add() {}, remove() {} },
};
failing.init(viewer);
failing.enable(viewer);
assert.equal(await failing.update(viewer), true);
assert.equal(failing.getStats().count, 1);
failing.destroy(viewer);
h.layer.destroy(h.viewer);
});
test('analyst records expose incident facts without geometry payloads', async () => {
const h = harness({ getSnapshot: async () => [row] });
await h.layer.update(h.viewer);
const records = h.layer.getAnalystRecords();
assert.equal(records.length, 1);
assert.equal(records[0].name, 'Fixture Fire');
assert.equal(records[0].acres, 512.5);
assert.equal(records[0].containedPct, 40);
assert.equal(records[0].state, 'US-NM');
assert.equal(records[0].cause, 'Natural');
assert.equal(records[0].behavior, 'Active');
assert.equal(records[0].personnel, 380);
assert.equal(records[0].county, 'Sandoval');
assert.equal(records[0].costToDate, 4200000);
assert.equal(records[0].complexity, 'Type 3 Incident');
// Spatial scoping in the analyst engine requires coordinates.
assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01);
assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01);
assert.equal('polygons' in records[0], false);
});
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
let rows = [row, { ...row, stableId: 'other' }];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update();
const original = [...h.sources[0].entities.values];
let removals = 0;
const entities = h.sources[0].entities;
const removeAll = entities.removeAll.bind(entities);
entities.removeAll = () => {
removals++;
removeAll();
};
rows = [...rows].reverse();
await h.layer.update();
assert.equal(removals, 0);
assert.equal(entities.values[0], original[0]);
rows = rows.map((value) => ({
...value,
updatedTime: value.updatedTime + 1,
}));
await h.layer.update();
assert.equal(removals, 1);
assert.notEqual(entities.values[0], original[0]);
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
await h.layer.update();
assert.equal(
removals,
2,
'incident facts can change without a new polygon timestamp',
);
assert.equal(h.layer.getRowControls().legend[3].count, 2);
rows = [];
await h.layer.update();
assert.equal(entities.values.length, 0);
h.layer.destroy();
});
test('containment legend uses the existing thresholds, colours and incident counts', async () => {
const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101];
const h = harness({
getSnapshot: async () =>
values.map((containedPct, index) => ({
...row,
stableId: String(index),
containedPct,
})),
});
await h.layer.update();
const controls = h.layer.getRowControls();
assert.deepEqual(controls.chips, []);
assert.deepEqual(
controls.legend.map(({ color, count }) => [color, count]),
[
['#ff3b30', 3],
['#ff7a00', 2],
['#ffb300', 2],
['#8bc34a', 2],
],
);
assert.ok(
controls.legend.every(
({ label }) => typeof label === 'string' && label.length,
),
);
assert.equal(
controls.legend[0].blurb,
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
);
h.layer.destroy();
assert.deepEqual(
h.layer.getRowControls().legend.map(({ count }) => count),
[0, 0, 0, 0],
);
});
for (const action of ['disable', 'destroy', 'selection']) {
test(`incident-link verification aborts on ${action} and ignores late completion`, async () => {
let finish, signal;
let pick = { id: `fire-perimeter:${row.stableId}:0` };
const h = harness(
{
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', name: 'Other Fire' },
],
},
{
pick: () => pick,
inciwebIndex: [
{ incident_id: '123', incident_title: row.name, tau: 'nm' },
],
publication: (_id, options) => {
signal = options.signal;
return new Promise((resolve) => {
finish = resolve;
});
},
},
);
await h.layer.update();
h.clicks.handler({ position: { x: 1, y: 1 } });
assert.equal(signal.aborted, false);
if (action === 'selection') {
pick = { id: 'fire-perimeter:other:0' };
h.clicks.handler({ position: { x: 1, y: 1 } });
} else h.layer[action]();
assert.equal(signal.aborted, true);
const entries = h.overlay.entries.get('fire-perimeters');
finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime });
await new Promise(setImmediate);
assert.equal(h.overlay.entries.get('fire-perimeters'), entries);
if (action !== 'destroy') h.layer.destroy();
});
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Normalize a WFIGS feed, skipping individually invalid features.
* Only a payload that is not a feature collection at all rejects the whole
* snapshot: a single degenerate incident (ArcGIS emits geometry:null when
* maxAllowableOffset generalizes a tiny polygon away) must never blank the
* layer — a failed first update makes the lifecycle disable it entirely.
*/
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
const textOrNull = (value) => (typeof value === 'string' ? value : null);
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return false;
for (const position of ring) {
if (!Array.isArray(position) || position.length < 2) return false;
const [lon, lat] = position;
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
}
return true;
}
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
* array of rings). Returns null on malformed geometry, [] when empty. */
function normalizePolygons(geometry) {
if (!geometry || typeof geometry !== 'object') return null;
let polygons;
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
if (!Array.isArray(polygons)) return null;
const result = [];
for (const rings of polygons) {
if (!Array.isArray(rings)) return null;
if (!rings.length) continue;
if (!rings.every(validRing)) return null;
result.push(rings);
}
return result;
}
export function normalizeFirePerimeterSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
continue;
const polygons = normalizePolygons(feature.geometry);
if (polygons === null || !polygons.length) continue;
const uniqueId = properties.attr_UniqueFireIdentifier;
const stableId =
typeof uniqueId === 'string' && uniqueId !== ''
? uniqueId
: feature.id == null || feature.id === ''
? null
: String(feature.id);
if (stableId == null || ids.has(stableId)) continue;
ids.add(stableId);
rows.push({
stableId,
name:
typeof properties.poly_IncidentName === 'string'
? properties.poly_IncidentName
: null,
acres: finiteOrNull(properties.attr_IncidentSize),
containedPct: finiteOrNull(properties.attr_PercentContained),
state:
typeof properties.attr_POOState === 'string'
? properties.attr_POOState
: null,
category:
typeof properties.attr_IncidentTypeCategory === 'string'
? properties.attr_IncidentTypeCategory
: null,
discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime),
updatedTime: finiteOrNull(properties.poly_DateCurrent),
cause: textOrNull(properties.attr_FireCause),
behavior: textOrNull(properties.attr_FireBehaviorGeneral),
personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel),
county: textOrNull(properties.attr_POOCounty),
costToDate: finiteOrNull(properties.attr_EstimatedCostToDate),
complexity: textOrNull(properties.attr_IncidentComplexityLevel),
complexName: textOrNull(properties.attr_CpxName),
polygons,
});
}
return rows;
}
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeFirePerimeterSnapshot } from './records.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
function feature(overrides = {}) {
return {
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
poly_IncidentName: 'Fixture Fire',
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 512.5,
attr_PercentContained: 40,
attr_POOState: 'US-NM',
attr_IncidentTypeCategory: 'WF',
attr_FireDiscoveryDateTime: 1757900000000,
poly_DateCurrent: 1757950000000,
attr_FireCause: 'Natural',
attr_FireBehaviorGeneral: 'Active',
attr_TotalIncidentPersonnel: 380,
attr_POOCounty: 'Sandoval',
attr_EstimatedCostToDate: 4200000,
attr_IncidentComplexityLevel: 'Type 3 Incident',
attr_CpxName: 'ROWE CREEK COMPLEX',
...overrides.properties,
},
...overrides,
};
}
test('a valid snapshot yields one row per incident with polygons preserved', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
id: 2,
geometry: {
type: 'MultiPolygon',
coordinates: [[ring], [ring]],
},
properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' },
}),
],
});
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
});
assert.equal(rows[1].polygons.length, 2);
});
test('a missing unique fire identifier falls back to the feature id', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [feature({ properties: { attr_UniqueFireIdentifier: null } })],
});
assert.equal(rows[0].stableId, '1');
});
test('optional attributes normalize to null rather than leaking undefined', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({
properties: {
poly_IncidentName: null,
attr_IncidentSize: null,
attr_PercentContained: null,
attr_POOState: null,
attr_IncidentTypeCategory: null,
attr_FireDiscoveryDateTime: null,
poly_DateCurrent: null,
attr_FireCause: null,
attr_FireBehaviorGeneral: null,
attr_TotalIncidentPersonnel: null,
attr_POOCounty: null,
attr_EstimatedCostToDate: null,
attr_IncidentComplexityLevel: null,
attr_CpxName: null,
},
}),
],
});
assert.deepEqual(
{
name: rows[0].name,
acres: rows[0].acres,
containedPct: rows[0].containedPct,
state: rows[0].state,
category: rows[0].category,
discoveredTime: rows[0].discoveredTime,
updatedTime: rows[0].updatedTime,
cause: rows[0].cause,
behavior: rows[0].behavior,
personnel: rows[0].personnel,
county: rows[0].county,
costToDate: rows[0].costToDate,
complexity: rows[0].complexity,
complexName: rows[0].complexName,
},
{
name: null,
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
);
});
test('a feed that is not a feature collection is rejected as a whole', () => {
for (const payload of [null, {}, { features: null }, { features: {} }]) {
assert.equal(normalizeFirePerimeterSnapshot(payload), null);
}
});
test('an invalid feature is skipped so one bad incident cannot blank the layer', () => {
const good = feature({
id: 2,
properties: { attr_UniqueFireIdentifier: 'good' },
});
for (const bad of [
feature({ geometry: null }),
feature({ geometry: { type: 'Point', coordinates: [0, 1] } }),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[Infinity, 35.2], ...ring.slice(1)]],
},
}),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[-200, 35.2], ...ring.slice(1)]],
},
}),
feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }),
feature({ properties: null }),
feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }),
]) {
const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] });
assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal');
assert.equal(rows[0].stableId, 'good');
}
});
test('a duplicate incident id keeps the first occurrence and skips the rest', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
properties: {
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 999,
},
}),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].acres, 512.5);
});
test('a perimeter with no rings is skipped rather than rendered empty', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({ geometry: { type: 'Polygon', coordinates: [] } }),
feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, 'other');
});
+23
View File
@@ -0,0 +1,23 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot');
return payload.rows;
},
};
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWfigsPerimeterSource } from './source.js';
import { normalizeFirePerimeterSnapshot } from './records.js';
import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const validPayload = {
features: [
{
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' },
},
],
};
test('a successful response yields normalized perimeter rows', async () => {
let requested;
const source = createWfigsPerimeterSource({
fetchImpl: async (url) => {
requested = String(url);
return Response.json({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
});
const rows = await source.getSnapshot();
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, '2026-NMGNF-000123');
assert.equal(requested, '/api/fire-perimeters');
});
test('a truncated response pages until the feed is complete', async () => {
const pageRing = (id) => ({
id,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: `fire-${id}` },
});
const requests = [];
let handler;
const plugin = firePerimetersProxy({
fetchImpl: async (url) => {
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
const page = requests.length;
return Response.json({
features: [pageRing(page)],
properties: { exceededTransferLimit: page < 3 },
});
},
});
plugin.configureServer({
middlewares: {
use: (_path, callback) => {
handler = callback;
},
},
});
let payload;
await handler(
{ url: '/', method: 'GET' },
{
writeHead(status) {
assert.equal(status, 200);
},
end(body) {
payload = JSON.parse(body);
},
},
);
const rows = payload.rows;
assert.deepEqual(
rows.map((row) => row.stableId),
['fire-1', 'fire-2', 'fire-3'],
);
assert.deepEqual(requests, [null, '1', '2']);
});
test('an upstream failure surfaces its HTTP status', async () => {
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/);
});
test('a malformed successful response is never accepted as an empty snapshot', async () => {
for (const payload of [{}, { rows: null }, { rows: {} }]) {
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json(payload),
});
await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/);
}
});
test('response-body completion honors cancellation without replacing records', async () => {
const abort = new AbortController();
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
}),
});
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
name: 'AbortError',
});
});
+12 -3
View File
@@ -4,6 +4,7 @@ import {
TILE_CACHE_MAX_ENTRIES,
FAST_FETCH_ALTITUDE,
} from './policy.js';
import { RoadRequestError, roadRequestError } from './source.js';
export function createIngestion({
state: layerState,
@@ -67,7 +68,7 @@ export function createIngestion({
);
if (!response.ok) {
throw new Error(`Overpass API returned ${response.status}`);
throw roadRequestError(response.status);
}
if (!state) {
@@ -307,8 +308,16 @@ export function createIngestion({
renderedSomething = true;
} catch (e) {
if (e?.name === 'AbortError') return;
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadError = 'Road data temporarily unavailable';
if (generation === layerState._loadGeneration && !renderedSomething) {
// A refusal we classified carries its own line; anything else — a
// dropped connection, a malformed snapshot — keeps the general one,
// because `e.message` from the platform is not a sentence anyone
// should have to read off a panel.
layerState._roadError =
e instanceof RoadRequestError
? e.message
: 'Road data temporarily unavailable';
}
console.warn('[Data:Traffic] Fetch error:', e);
} finally {
if (generation === layerState._loadGeneration) {
+36
View File
@@ -216,3 +216,39 @@ test('parked failures back off and disabling cancels the scheduled retry', async
assert.equal(calls, 2);
assert.equal(layer.getStats().error, null);
});
test('a declined road request reaches the row as the reason, not as a shrug', async (t) => {
// Issue #661: with every public Overpass mirror refusing a network, the row
// read "Road data temporarily unavailable" while the console already held
// "Overpass API returned 406". The reporter went looking for a bad TomTom
// key — the other half of this layer, and never the problem. The status the
// code already has must survive the trip to the panel.
let status = 406;
const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status }));
layer.enable(viewer);
await tick(400);
assert.equal(
layer.getStats().error,
'Overpass refused the road query (HTTP 406)',
);
status = 429;
for (let i = 0; i < 20; i++) await tick(1500);
assert.equal(
layer.getStats().error,
'Overpass rate-limited',
'a rate limit is a different instruction to the reader than a refusal',
);
});
test('an unclassified road failure keeps the general line', async (t) => {
// The complement of the test above, and the reason this is not just
// `e.message`: a dropped socket throws whatever the platform felt like
// saying, and "Failed to fetch" on a panel row helps nobody.
const { layer, viewer, tick } = setup(t, async () => {
throw new Error('socket hang up');
});
layer.enable(viewer);
await tick(400);
assert.equal(layer.getStats().error, 'Road data temporarily unavailable');
});
+62
View File
@@ -15,6 +15,68 @@ function buildOverpassQuery(
return `[out:json][timeout:${timeoutSec}];(way["highway"~"${regex}"](${south},${west},${north},${east}););out geom qt;`;
}
/**
* A road request upstream declined, carrying a line fit to show an operator.
*
* The status travels beside the message instead of only inside it, so a caller
* can branch on the code without parsing English — the same shape
* `LiveSourceError` uses in `src/sources/live/contract.js`.
*/
export class RoadRequestError extends Error {
constructor(message, { status = null } = {}) {
super(message);
this.name = 'RoadRequestError';
this.status = status;
}
}
/**
* Name an Overpass refusal in the words the traffic row will print.
*
* This layer has two upstreams — OpenStreetMap for road geometry and TomTom
* for flow — and only one of them can be down at a time. A row that says
* nothing more than "road data unavailable" sends the reader to check their
* TomTom key, which is the wrong half of the layer and costs them the
* afternoon. So the status upstream actually returned is reported: 406 from a
* public mirror is not a configuration problem the reader can fix, and saying
* so is the difference between a dead end and a next step.
*
* The first two lines match `alpr`'s Overpass source word for word; this is
* the same vocabulary, not a new one. What the status adds is *which* upstream
* and *how* it declined — the part a single-source layer never needs to say.
*
* Only what the browser can see is claimed. The proxy rotates mirrors behind
* `/api/overpass`, so a refusal arriving here means the proxy had nothing
* better to offer; how many mirrors it tried is not something this side knows,
* and the message does not pretend otherwise.
* @param {number} status - HTTP status the proxy returned; a code that is
* not a finite number is treated as absent rather than printed.
* @returns {RoadRequestError}
*/
export function roadRequestError(status) {
const code = Number.isFinite(status) ? status : null;
const message =
code === 429
? 'Overpass rate-limited'
: code === 504
? 'Overpass timed out'
: // Our own proxy answers these, not a mirror: 502 when every mirror
// failed at the network level, 503 from its local concurrency
// limiter before any mirror was asked. Neither is a refusal.
code === 502
? 'Overpass mirrors unreachable'
: code === 503
? 'Overpass temporarily unavailable'
: // A source is injected, so a caller's adapter may hand back a
// refusal with no status on it. "HTTP undefined" on a panel row is
// worse than not naming a number, so an unreadable code falls back
// to what `alpr` says when it cannot be more specific either.
code === null
? 'Overpass temporarily unavailable'
: `Overpass refused the road query (HTTP ${code})`;
return new RoadRequestError(message, { status: code });
}
/** Supply road responses, flow availability and one decoded flow cache. */
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
+47 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createTrafficSource } from './source.js';
import {
RoadRequestError,
createTrafficSource,
roadRequestError,
} from './source.js';
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
const fixture = readFileSync(
@@ -188,3 +192,45 @@ test('road sources decode direction and coordinates before scene construction',
],
});
});
test('an Overpass refusal is named by status, and keeps the status beside the words', () => {
// The panel prints this string verbatim, so the mapping is part of the
// layer's contract with the reader, not an implementation detail.
assert.equal(roadRequestError(429).message, 'Overpass rate-limited');
assert.equal(roadRequestError(504).message, 'Overpass timed out');
assert.equal(
roadRequestError(406).message,
'Overpass refused the road query (HTTP 406)',
);
assert.equal(
roadRequestError(500).message,
'Overpass refused the road query (HTTP 500)',
);
// 502 and 503 come from the local proxy (mirrors unreachable, local
// limiter busy), so they must not read as a mirror refusing the query.
assert.equal(roadRequestError(502).message, 'Overpass mirrors unreachable');
assert.equal(
roadRequestError(503).message,
'Overpass temporarily unavailable',
);
assert.equal(roadRequestError(503).status, 503);
// An injected source may refuse without a readable code; a row must never
// print "HTTP undefined" at a reader.
for (const missing of [undefined, null, NaN, 'four-oh-six']) {
const vague = roadRequestError(missing);
assert.equal(vague.message, 'Overpass temporarily unavailable');
assert.equal(vague.status, null, 'an unreadable code is absent, not zero');
}
const refused = roadRequestError(406);
assert.ok(refused instanceof RoadRequestError);
assert.ok(refused instanceof Error);
assert.equal(refused.name, 'RoadRequestError');
assert.equal(
refused.status,
406,
'a caller must be able to branch on the code without parsing English',
);
});
+10 -2
View File
@@ -4,13 +4,21 @@ import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
/** Apply the display transfer once to a decoded image. */
export function processInfraredImage(image, mode, createCanvas) {
/** Apply the display transfer once to a decoded image; `flipY` draws a
* bottom-up image back upright. */
export function processInfraredImage(
image,
mode,
createCanvas,
{ flipY = false } = {},
) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
+12
View File
@@ -10,8 +10,12 @@ import {
function canvas() {
const output = {};
output.getContext = () => ({
setTransform: (...matrix) => {
output.transforms = [...(output.transforms ?? []), matrix];
},
drawImage: (image) => {
output.image = image;
output.drawnWith = output.transforms?.at(-1) ?? null;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
putImageData: (pixels) => {
@@ -34,6 +38,14 @@ test('decoded pixels are processed once on a same-sized canvas', () => {
);
});
test('flipY draws a bottom-up image upright, then restores the transform', () => {
const image = { width: 4, height: 8 };
assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null);
const output = processInfraredImage(image, 'full', canvas, { flipY: true });
assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]);
assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]);
});
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
const controller = new AbortController();
let closes = 0,
+7 -1
View File
@@ -381,8 +381,14 @@ function createGlobeRendering({
if (request) frame.requests.add(request);
return Promise.resolve(result)
.then((image) => {
// Cesium decodes tiles as ImageBitmaps already flipped, since WebGL
// ignores UNPACK_FLIP_Y for them; a canvas upload flips again.
if (!frame.closed && snapshot.product === 'clouds-regional')
image = processInfraredImage(image, infrared, createCanvas);
image = processInfraredImage(image, infrared, createCanvas, {
flipY:
typeof ImageBitmap !== 'undefined' &&
image instanceof ImageBitmap,
});
frame.retries.delete(tileKey);
frame.loaded++;
return image;
+2
View File
@@ -71,6 +71,8 @@ export function createShellCesium({ maximumTextureSize = 0 } = {}) {
GeometryInstance,
BlendingState: Cesium.BlendingState,
Cartesian4: Cesium.Cartesian4,
Ellipsoid: Cesium.Ellipsoid,
Matrix4: Cesium.Matrix4,
Math: Cesium.Math,
Rectangle: Cesium.Rectangle,
ContextLimits: { maximumTextureSize },
+25
View File
@@ -25,6 +25,12 @@ const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
const DETAIL_MIN_WIDTH = 6;
const DETAIL_GRID = 0.5;
const shellHeights = new WeakMap();
// From far away Google's coarse 3D tiles rise tens of kilometres above the
// ground and hide the shells, so the shells rise with the camera: 4 m per
// kilometre of camera height, at most 60 km, in 500 m steps.
const LIFT_PER_METRE = 0.004;
const MAX_LIFT = 60_000;
const LIFT_STEP = 500;
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
@@ -71,6 +77,13 @@ const sameEdges = (a, b) =>
a.east === b.east &&
a.north === b.north;
/** Metres every shell rises for a camera this high above the ellipsoid. */
export function shellLift(cameraHeight) {
if (!(cameraHeight > 0)) return 0;
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
}
/** Keep weather shells first in the primitive collection, in ascending height:
* higher shells draw over lower ones and other opaque content draws over both. */
export function orderWeatherShells(primitives, primitive, height) {
@@ -135,6 +148,17 @@ export function createShellSurface({
});
primitives.add(primitive);
orderWeatherShells(primitives, primitive, height);
// A uniform scale about the Earth's centre lifts every shell alike and keeps
// their order.
let lift = 0;
const offLift = scene.preRender?.addEventListener(() => {
const next = shellLift(scene.camera?.positionCartographic?.height);
if (next === lift) return;
lift = next;
primitive.modelMatrix = cesium.Matrix4.fromUniformScale(
1 + lift / cesium.Ellipsoid.WGS84.maximumRadius,
);
});
let image = null;
let frames = 0;
// The detail image, its own upload count and a window waiting for it to draw.
@@ -265,6 +289,7 @@ export function createShellSurface({
destroyed = true;
offRender?.();
offRender = null;
offLift?.();
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
@@ -6,6 +6,7 @@ import {
createWeatherShell,
detailWindow,
orderWeatherShells,
shellLift,
WEATHER_SHELL_CACHE_BYTES,
WEATHER_SHELL_HEIGHTS,
} from './shellRendering.js';
@@ -297,6 +298,44 @@ test('a surface applies a new detail window only once its image has drawn; the s
assert.equal(scene.postRender.size, 0);
});
test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => {
assert.equal(shellLift(undefined), 0);
assert.equal(shellLift(1_200), 0, 'city views keep the shell heights');
assert.equal(shellLift(3_000_000), 12_000);
assert.equal(shellLift(8_000_000), 32_000);
assert.equal(shellLift(18_500_000), 60_000);
const camera = { positionCartographic: { height: 1_200 } };
const scene = {
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
camera,
requestRender() {},
};
const surface = createShellSurface({
viewer: { scene },
cesium: Cesium,
rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
height: 5_800,
});
const primitive = scene.primitives.get(0);
const identity = primitive.modelMatrix;
scene.preRender.raiseEvent();
assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix');
camera.positionCartographic.height = 8_000_000;
scene.preRender.raiseEvent();
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
assert.ok(
Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3,
);
const lifted = primitive.modelMatrix;
camera.positionCartographic.height = 8_010_000;
scene.preRender.raiseEvent();
assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix');
surface.destroy();
assert.equal(scene.preRender.numberOfListeners, 0);
});
test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => {
const scene = {
primitives: new Cesium.PrimitiveCollection(),
+35
View File
@@ -75,9 +75,13 @@ function target(extra = {}) {
function mockCanvas() {
const canvas = { width: 0, height: 0 };
canvas.getContext = () => ({
setTransform(...matrix) {
canvas.transforms = [...(canvas.transforms ?? []), matrix];
},
drawImage(source, ...crop) {
canvas.source = source;
canvas.crop = crop;
canvas.drawnWith = canvas.transforms?.at(-1) ?? null;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
putImageData(pixels) {
@@ -1510,6 +1514,37 @@ test('regional transfer preserves deferrals, original Request, rejection and can
assert.equal(await stage, false);
});
test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => {
const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap');
const previous = globalThis.ImageBitmap;
globalThis.ImageBitmap = class ImageBitmap {
width = 256;
height = 256;
};
try {
const h = renderingHarness();
void h.rendering.setFrame(
{ ...snapshot, product: 'clouds-regional' },
times[0],
);
const provider = h.providers[0];
// Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again.
provider.response = { promise: Promise.resolve(new ImageBitmap()) };
const bitmapTile = await provider.requestImage(0, 0, 0, {});
assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]);
// An HTMLImageElement fallback is not pre-flipped.
provider.response = {
promise: Promise.resolve({ width: 256, height: 256 }),
};
const imageTile = await provider.requestImage(1, 0, 0, {});
assert.equal(imageTile.drawnWith, null);
h.rendering.clear();
} finally {
if (hadBitmap) globalThis.ImageBitmap = previous;
else delete globalThis.ImageBitmap;
}
});
for (const kind of ['globe']) {
test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => {
let host,