mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
@@ -0,0 +1,90 @@
|
||||
const KNOT_TO_MPS = 0.514444;
|
||||
const FOOT_TO_M = 0.3048;
|
||||
const FPM_TO_MPS = 0.00508;
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function emitterCategory(value) {
|
||||
const category = String(value || '').trim().toUpperCase();
|
||||
const categories = {
|
||||
A1: 2,
|
||||
A2: 3,
|
||||
A3: 4,
|
||||
A4: 5,
|
||||
A5: 6,
|
||||
A6: 7,
|
||||
A7: 8,
|
||||
B1: 9,
|
||||
B2: 10,
|
||||
B3: 11,
|
||||
B4: 12,
|
||||
B6: 14,
|
||||
B7: 15,
|
||||
};
|
||||
return categories[category] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one adsb.lol v2 aircraft record into the OpenSky state-vector shape
|
||||
* consumed by the existing Flights renderer.
|
||||
* @param {object} aircraft adsb.lol aircraft record.
|
||||
* @param {number} nowSeconds Feed response time in epoch seconds.
|
||||
* @returns {Array|null} OpenSky-compatible state vector, or null when invalid.
|
||||
*/
|
||||
export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
|
||||
const hex = String(aircraft?.hex || '').trim().toLowerCase();
|
||||
const latitude = finiteNumber(aircraft?.lat);
|
||||
const longitude = finiteNumber(aircraft?.lon);
|
||||
if (!hex || latitude === null || longitude === null) return null;
|
||||
|
||||
const seenPosition = Math.max(0, finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0);
|
||||
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
|
||||
const onGround = aircraft?.alt_baro === 'ground';
|
||||
const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro);
|
||||
const geometricFeet = finiteNumber(aircraft?.alt_geom);
|
||||
const groundSpeedKnots = finiteNumber(aircraft?.gs);
|
||||
const verticalRateFpm = finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate);
|
||||
const track = finiteNumber(aircraft?.track);
|
||||
|
||||
return [
|
||||
hex,
|
||||
String(aircraft?.flight || aircraft?.r || '').trim() || null,
|
||||
null,
|
||||
Math.max(0, nowSeconds - seenPosition),
|
||||
Math.max(0, nowSeconds - seen),
|
||||
longitude,
|
||||
latitude,
|
||||
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
|
||||
onGround,
|
||||
groundSpeedKnots === null ? null : groundSpeedKnots * KNOT_TO_MPS,
|
||||
track,
|
||||
verticalRateFpm === null ? null : verticalRateFpm * FPM_TO_MPS,
|
||||
null,
|
||||
geometricFeet === null ? null : geometricFeet * FOOT_TO_M,
|
||||
aircraft?.squawk || null,
|
||||
aircraft?.spi === 1,
|
||||
0,
|
||||
emitterCategory(aircraft?.category),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an adsb.lol point response to an OpenSky-compatible response.
|
||||
* Invalid rows and positionless contacts are intentionally excluded.
|
||||
* @param {object} payload adsb.lol v2 response.
|
||||
* @returns {{time:number,states:Array[]}}
|
||||
*/
|
||||
export function normalizeAdsbLolPointResponse(payload) {
|
||||
const responseNow = finiteNumber(payload?.now);
|
||||
const nowSeconds = responseNow === null
|
||||
? Math.floor(Date.now() / 1000)
|
||||
: Math.floor(responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow);
|
||||
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
|
||||
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
|
||||
.filter(Boolean);
|
||||
return { time: nowSeconds, states };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeAdsbLolAircraftState,
|
||||
normalizeAdsbLolPointResponse,
|
||||
} from './adsbLolFallback.js';
|
||||
|
||||
test('normalizes adsb.lol units into an OpenSky-compatible state vector', () => {
|
||||
const state = normalizeAdsbLolAircraftState({
|
||||
hex: 'A1B2C3',
|
||||
flight: 'UAL123 ',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
alt_baro: 10000,
|
||||
alt_geom: 10200,
|
||||
gs: 200,
|
||||
track: 90,
|
||||
baro_rate: 600,
|
||||
seen_pos: 2,
|
||||
seen: 1,
|
||||
category: 'A3',
|
||||
}, 1000);
|
||||
|
||||
assert.equal(state[0], 'a1b2c3');
|
||||
assert.equal(state[1], 'UAL123');
|
||||
assert.equal(state[2], null);
|
||||
assert.equal(state[3], 998);
|
||||
assert.equal(state[5], -97);
|
||||
assert.equal(state[6], 30);
|
||||
assert.equal(state[7], 3048);
|
||||
assert.ok(Math.abs(state[9] - 102.8888) < 0.001);
|
||||
assert.equal(state[10], 90);
|
||||
assert.ok(Math.abs(state[11] - 3.048) < 0.001);
|
||||
assert.equal(state[13], 3108.96);
|
||||
assert.equal(state[17], 4);
|
||||
});
|
||||
|
||||
test('keeps grounded fallback contacts and rejects rows without positions', () => {
|
||||
const normalized = normalizeAdsbLolPointResponse({
|
||||
now: 1_700_000_000_000,
|
||||
ac: [
|
||||
{ hex: 'abc123', lat: 0, lon: 0, alt_baro: 'ground', gs: 8 },
|
||||
{ hex: 'def456', lat: null, lon: 10, alt_baro: 5000 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(normalized.time, 1_700_000_000);
|
||||
assert.equal(normalized.states.length, 1);
|
||||
assert.equal(normalized.states[0][7], null);
|
||||
assert.equal(normalized.states[0][8], true);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/data/aircraftClass.js
|
||||
/**
|
||||
* Type-aware aircraft classification. ICAO type-designator sets adapted from
|
||||
* skylight (https://github.com/cpaczek/skylight, MIT) web/src/display/
|
||||
* aircraftGlyph.ts, extended with a fast-jet set for the military layer and
|
||||
* C17/K35R in the widebody set. Category fallbacks cover the OpenSky extended
|
||||
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
|
||||
*/
|
||||
|
||||
const HELI = new Set([
|
||||
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
|
||||
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
|
||||
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
|
||||
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
|
||||
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
|
||||
]);
|
||||
const QUAD = new Set([
|
||||
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
|
||||
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
|
||||
]);
|
||||
const WIDE = new Set([
|
||||
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
|
||||
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
|
||||
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
|
||||
'C17', 'K35R', // military heavies that read as widebodies
|
||||
]);
|
||||
const TPROP = new Set([
|
||||
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
|
||||
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
|
||||
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
|
||||
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
|
||||
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
|
||||
'A400',
|
||||
]);
|
||||
const GLIDER = new Set([
|
||||
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
|
||||
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
|
||||
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
|
||||
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
|
||||
]);
|
||||
const LIGHT = new Set([
|
||||
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
|
||||
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
|
||||
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
|
||||
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
|
||||
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
|
||||
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
|
||||
// Citation II GLB + a slimmer glyph read apart from airliners.
|
||||
const BIZJET = new Set([
|
||||
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
|
||||
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
|
||||
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
|
||||
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
|
||||
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
|
||||
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
|
||||
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
|
||||
'PRM1', 'BE40', 'ASTR', 'WW24',
|
||||
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
|
||||
]);
|
||||
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
|
||||
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
|
||||
const UAV = new Set([
|
||||
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
|
||||
]);
|
||||
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
|
||||
const FASTJET = new Set([
|
||||
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
|
||||
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
|
||||
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
|
||||
'T38', 'HAWK', 'L39', 'M346', 'T7A',
|
||||
]);
|
||||
|
||||
/** OpenSky extended-states integer category → class. */
|
||||
const OPENSKY_CATEGORY = {
|
||||
2: 'light', // Light (< 15 500 lbs)
|
||||
3: 'airliner', // Small
|
||||
4: 'airliner', // Large
|
||||
5: 'airliner', // High-vortex large (B757)
|
||||
6: 'widebody', // Heavy
|
||||
7: 'fastjet', // High performance (>5g, >400 kt)
|
||||
8: 'helicopter', // Rotorcraft
|
||||
9: 'glider',
|
||||
};
|
||||
|
||||
/** ADS-B emitter-category string → class. */
|
||||
const EMITTER_CATEGORY = {
|
||||
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
|
||||
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
|
||||
};
|
||||
|
||||
export function classifyAircraft({ typeCode, category } = {}) {
|
||||
const code = String(typeCode || '').trim().toUpperCase();
|
||||
if (code) {
|
||||
if (FASTJET.has(code)) return 'fastjet';
|
||||
if (UAV.has(code)) return 'uav';
|
||||
if (HELI.has(code)) return 'helicopter';
|
||||
if (QUAD.has(code)) return 'quadjet';
|
||||
if (WIDE.has(code)) return 'widebody';
|
||||
if (TPROP.has(code)) return 'turboprop';
|
||||
if (GLIDER.has(code)) return 'glider';
|
||||
if (BIZJET.has(code)) return 'bizjet';
|
||||
if (LIGHT.has(code)) return 'light';
|
||||
return 'airliner'; // known type code, not in a special set → default jet
|
||||
}
|
||||
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
|
||||
const cat = String(category || '').trim().toUpperCase();
|
||||
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
export const CLASS_SCALE_2D = {
|
||||
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
|
||||
bizjet: 0.72, uav: 0.75,
|
||||
};
|
||||
|
||||
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
|
||||
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
|
||||
* real per-class models land in CLASS_MODEL_URL. */
|
||||
export const CLASS_SCALE_3D = {
|
||||
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
|
||||
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
|
||||
bizjet: 0.8, uav: 0.8,
|
||||
};
|
||||
|
||||
/** Per-class glTF — all the shared airplane today; drop real assets in here.
|
||||
* NOTE for future models: each asset may need its own heading offset (the
|
||||
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
|
||||
export const CLASS_MODEL_URL = {
|
||||
light: '/models/airplane.glb', glider: '/models/airplane.glb',
|
||||
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
|
||||
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
|
||||
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
|
||||
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
|
||||
};
|
||||
|
||||
/** Real per-class GLBs (2026-08-15 Hangar fleet, selected assets; CC-BY 4.0 —
|
||||
* provenance in public/models/README.md). Every asset is vertex-baked to the
|
||||
* airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers'
|
||||
* MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre,
|
||||
* REAL-WORLD METERS (render at scale 1 — no MODEL_SCALE, no CLASS_SCALE_3D).
|
||||
* bellyM = origin→lowest-vertex distance (grounded-model lift, replaces the
|
||||
* airplane.glb MODEL_BELLY_OFFSET_NATIVE formula); radiusM = bounding-sphere
|
||||
* radius in meters (pixel-size math). Values measured from the shipped GLBs —
|
||||
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
|
||||
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
|
||||
export const CLASS_MODEL_REAL = {
|
||||
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
|
||||
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
|
||||
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
|
||||
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
|
||||
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
|
||||
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
// src/data/aircraftClass.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js';
|
||||
|
||||
test('type-code classification (military layer path)', () => {
|
||||
assert.equal(classifyAircraft({ typeCode: 'F16' }), 'fastjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'EC35' }), 'helicopter');
|
||||
assert.equal(classifyAircraft({ typeCode: 'B744' }), 'quadjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'B77W' }), 'widebody');
|
||||
assert.equal(classifyAircraft({ typeCode: 'C17' }), 'widebody');
|
||||
assert.equal(classifyAircraft({ typeCode: 'AT76' }), 'turboprop');
|
||||
assert.equal(classifyAircraft({ typeCode: 'C172' }), 'light');
|
||||
assert.equal(classifyAircraft({ typeCode: 'b738' }), 'airliner'); // case-insensitive default-jet
|
||||
});
|
||||
|
||||
test('bizjet + uav classes (2026-08-16 Hangar fleet)', () => {
|
||||
assert.equal(classifyAircraft({ typeCode: 'C25A' }), 'bizjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'C56X' }), 'bizjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'GLF5' }), 'bizjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'CL60' }), 'bizjet');
|
||||
// SF50 Cirrus Vision Jet is a JET — it must NOT fall back to the light/prop class.
|
||||
assert.equal(classifyAircraft({ typeCode: 'SF50' }), 'bizjet');
|
||||
assert.equal(classifyAircraft({ typeCode: 'MQ9' }), 'uav');
|
||||
assert.equal(classifyAircraft({ typeCode: 'Q4' }), 'uav');
|
||||
assert.equal(classifyAircraft({ typeCode: 'TB2' }), 'uav');
|
||||
});
|
||||
|
||||
test('category fallback (flights layer path — OpenSky extended int)', () => {
|
||||
assert.equal(classifyAircraft({ category: 8 }), 'helicopter');
|
||||
assert.equal(classifyAircraft({ category: 6 }), 'widebody');
|
||||
assert.equal(classifyAircraft({ category: 7 }), 'fastjet');
|
||||
assert.equal(classifyAircraft({ category: 2 }), 'light');
|
||||
assert.equal(classifyAircraft({ category: 9 }), 'glider');
|
||||
assert.equal(classifyAircraft({ category: 4 }), 'airliner');
|
||||
});
|
||||
|
||||
test('ADS-B emitter string categories', () => {
|
||||
assert.equal(classifyAircraft({ category: 'A7' }), 'helicopter');
|
||||
assert.equal(classifyAircraft({ category: 'A5' }), 'widebody');
|
||||
assert.equal(classifyAircraft({ category: 'A1' }), 'light');
|
||||
assert.equal(classifyAircraft({ category: 'B1' }), 'glider');
|
||||
});
|
||||
|
||||
test('typeCode outranks category; unknown → airliner', () => {
|
||||
assert.equal(classifyAircraft({ typeCode: 'F18', category: 6 }), 'fastjet');
|
||||
assert.equal(classifyAircraft({}), 'airliner');
|
||||
assert.equal(classifyAircraft({ typeCode: null, category: 0 }), 'airliner');
|
||||
});
|
||||
|
||||
test('scale/url tables cover every class', () => {
|
||||
for (const kind of ['light','glider','turboprop','airliner','widebody','quadjet','helicopter','fastjet','bizjet','uav']) {
|
||||
assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind);
|
||||
assert.ok(CLASS_SCALE_3D[kind] >= 0.75 && CLASS_SCALE_3D[kind] <= 1.45, kind);
|
||||
assert.ok(typeof CLASS_MODEL_URL[kind] === 'string', kind);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLASS_MODEL_REAL entries carry the fields the layers consume', () => {
|
||||
for (const [kind, spec] of Object.entries(CLASS_MODEL_REAL)) {
|
||||
assert.ok(spec.url.startsWith('/models/') && spec.url.endsWith('.glb'), kind);
|
||||
assert.ok(spec.radiusM > 0 && spec.bellyM > 0, kind);
|
||||
// every real-model class must also exist in the classifier tables
|
||||
assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
// src/data/aircraftIcons.js
|
||||
/**
|
||||
* Nose-up white aircraft silhouettes, one per classifyAircraft() kind, as SVG
|
||||
* data URIs for Cesium billboards.
|
||||
*
|
||||
* FIDELITY NOTE (2026-07-02): redrawn at a 96×96 viewBox (glyph centered at
|
||||
* 48,48) instead of the old 32×32. The billboards themselves are still added at
|
||||
* width/height 20 (24 tracked) in flights.js / militaryFlights.js, so Cesium
|
||||
* DOWN-samples this larger, higher-precision source to the same on-screen
|
||||
* footprint — crisp anti-aliased edges instead of an upscaled 32px bitmap. The
|
||||
* on-screen size is unchanged and CLASS_SCALE_2D is untouched (no scale
|
||||
* compensation needed: the billboard's explicit width/height, not the SVG's,
|
||||
* sets the pixel size — the intrinsic SVG size only controls raster fidelity).
|
||||
*
|
||||
* All glyphs are nose-up (nose toward -Y at rotation 0; the screen-projected
|
||||
* rotation pipeline in iconOrientation.js spins the whole glyph). Fill is white
|
||||
* with a subtle dark hairline stroke so the tint pipeline (billboard.color =
|
||||
* white / cyan-tracked / amber-military, plus .withAlpha fades) keeps working —
|
||||
* no per-glyph hardcoded colors that would fight the tint.
|
||||
*
|
||||
* MIXED-SET UPDATE (2026-08-15, selected asset set): airliner/widebody/
|
||||
* turboprop/helicopter use the "refined" recognition-chart redraw;
|
||||
* quadjet/glider use the "bold" chart-symbol redraw; light/fastjet keep the
|
||||
* original drawings. Raster fidelity doubled (192px source, same 96 coords).
|
||||
*
|
||||
* Silhouette language follows skylight's type-aware glyphs (MIT,
|
||||
* https://github.com/cpaczek/skylight): slender fuselages, swept wings for
|
||||
* jets, long high-aspect wings for the glider, straight wings + prop discs for
|
||||
* props, a rotor disc + tail boom + tail-rotor for the helicopter, and a delta
|
||||
* for the fast jet. Each class has a DISTINCT planform so it reads at ~20px by
|
||||
* shape, not just by footprint size.
|
||||
*/
|
||||
|
||||
const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
|
||||
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
|
||||
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
|
||||
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
|
||||
// Softer white for translucent detail (prop discs, rotor disc) — still white so
|
||||
// the tint multiplies cleanly; only the alpha differs.
|
||||
const DISC = 'fill="white" fill-opacity="0.5"';
|
||||
|
||||
// Each body is drawn in a centred coordinate frame (origin 0,0 = glyph centre),
|
||||
// nose toward -Y. Numbers are in the 96-unit space (half-extent up to ~45).
|
||||
const BODIES = {
|
||||
// ── Airliner: slender swept-wing narrow-body, two underwing engine pods,
|
||||
// swept tailplane. The canonical jet; everything else reads against it.
|
||||
airliner: `
|
||||
<path d="M0,-42 C 3.8,-40 4.6,-34 4.6,-26 L 4.6,-14
|
||||
L 32,4 L 34,6 L 34,10 L 31.4,9.2 L 4.6,2.4
|
||||
L 4.2,20
|
||||
L 14,28 L 14,32 L 0,28.6 L -14,32 L -14,28 L -4.2,20
|
||||
L -4.6,2.4 L -31.4,9.2 L -34,10 L -34,6 L -32,4 L -4.6,-14
|
||||
L -4.6,-26 C -4.6,-34 -3.8,-40 0,-42 Z" fill="white" ${STROKE}/>
|
||||
<path d="M-15.5,-1.5 l3,7.6 4,-1.4 -1.5,-8.4 Z" fill="white"/>
|
||||
<path d="M15.5,-1.5 l-3,7.6 -4,-1.4 1.5,-8.4 Z" fill="white"/>
|
||||
<path d="M-1.6,33.5 L 1.6,33.5 L 1.6,40 L -1.6,40 Z" fill="white"/>`,
|
||||
|
||||
// ── Widebody: same swept jet but noticeably BIGGER span + fatter fuselage,
|
||||
// twin engines set further out. Reads as a heavier airliner.
|
||||
widebody: `
|
||||
<path d="M0,-45 C 5.6,-43 6.8,-36 6.8,-27 L 6.8,-12
|
||||
L 38,9 L 41.5,12.4 L 41.5,16.6 L 37.6,15 L 6.8,6
|
||||
L 6.3,21
|
||||
L 17,30 L 17,34.6 L 0,30.4 L -17,34.6 L -17,30 L -6.3,21
|
||||
L -6.8,6 L -37.6,15 L -41.5,16.6 L -41.5,12.4 L -38,9 L -6.8,-12
|
||||
L -6.8,-27 C -6.8,-36 -5.6,-43 0,-45 Z" fill="white" ${STROKE}/>
|
||||
<path d="M-19,2 l3.6,9 4.8,-1.7 -1.8,-10 Z" fill="white"/>
|
||||
<path d="M19,2 l-3.6,9 -4.8,-1.7 1.8,-10 Z" fill="white"/>
|
||||
<path d="M-2,35.5 L 2,35.5 L 2,42.5 L -2,42.5 Z" fill="white"/>`,
|
||||
|
||||
// ── Quadjet: the widest swept jet, with FOUR bold underwing engine pods (two
|
||||
// per wing) that hang well below the trailing edge — the wing reads as a
|
||||
// 4-lumped bar even when the individual pods blur together at ~20px.
|
||||
quadjet: `
|
||||
<path d="M0,-45 C 7,-42 9,-34 9,-25 L 9,-11
|
||||
L 46,12 L 46,21 L 9,11.5
|
||||
L 8.4,21 L 20,31 L 20,37.5 L 0,32 L -20,37.5 L -20,31 L -8.4,21
|
||||
L -9,11.5 L -46,21 L -46,12 L -9,-11
|
||||
L -9,-25 C -9,-34 -7,-42 0,-45 Z" fill="white" ${STROKE_BOLD}/>
|
||||
<rect x="-31" y="9" width="7" height="12" rx="2" fill="white"/>
|
||||
<rect x="-17" y="4.5" width="7" height="12" rx="2" fill="white"/>
|
||||
<rect x="10" y="4.5" width="7" height="12" rx="2" fill="white"/>
|
||||
<rect x="24" y="9" width="7" height="12" rx="2" fill="white"/>`,
|
||||
|
||||
// ── Turboprop: bold STRAIGHT (unswept) wings — thicker chord than a jet — two
|
||||
// fat nacelles that jut ahead of the leading edge, each capped by a solid
|
||||
// prop disc. Reads as "straight wing with two lumps + discs" at ~20px.
|
||||
turboprop: `
|
||||
<path d="M0,-40 C 3.4,-38.5 4.2,-33 4.2,-26 L 4.2,-18
|
||||
L 36,-15.5 L 36,-7.5 L 4.2,-8
|
||||
L 3.8,22
|
||||
L 13,27.5 L 13,31.5 L 0,28.6 L -13,31.5 L -13,27.5 L -3.8,22
|
||||
L -4.2,-8 L -36,-7.5 L -36,-15.5 L -4.2,-18
|
||||
L -4.2,-26 C -4.2,-33 -3.4,-38.5 0,-40 Z" fill="white" ${STROKE}/>
|
||||
<circle cx="-17.5" cy="-16.5" r="7.5" fill="white" fill-opacity="0.5"/>
|
||||
<circle cx="17.5" cy="-16.5" r="7.5" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M-19.5,-19 h4 v5 h-4 Z" fill="white"/>
|
||||
<path d="M15.5,-19 h4 v5 h-4 Z" fill="white"/>
|
||||
<path d="M-1.7,31.5 L 1.7,31.5 L 1.7,38.5 L -1.7,38.5 Z" fill="white"/>`,
|
||||
|
||||
// ── Light GA: small and CHUNKY — short, DEEP-chord straight wings and a fat
|
||||
// stubby fuselage make it read as a solid little block (not a thin cross),
|
||||
// with a bold nose prop disc. The short deep wing is the anti-glider cue.
|
||||
light: `
|
||||
<path d="M0,-27
|
||||
C 4,-25 5.2,-20 5.2,-13
|
||||
L 5.2,-9
|
||||
L 27,-9 L 27,6 L 5.2,6
|
||||
L 5.2,16
|
||||
L 11.5,23 L 11.5,27 L 0,23.5 L -11.5,27 L -11.5,23 L -5.2,16
|
||||
L -5.2,6
|
||||
L -27,6 L -27,-9 L -5.2,-9
|
||||
L -5.2,-13
|
||||
C -5.2,-20 -4,-25 0,-27 Z" fill="white" ${STROKE}/>
|
||||
<ellipse cx="0" cy="-29" rx="12" ry="3.8" ${DISC}/>`,
|
||||
|
||||
// ── Glider: VERY long, high-aspect TAPERED wings (fat root → thin tips) on a
|
||||
// slim fuselage with a T-tail. The extreme span is the whole identity — it
|
||||
// reads as "the wide skinny one" against light's short stubby wings.
|
||||
glider: `
|
||||
<path d="M0,-35 C 2.4,-33 3,-29 3,-25 L 3,-14
|
||||
L 45,-10.5 L 45,-3.5 L 2.9,-6
|
||||
L 2.2,32 L -2.2,32 L -2.9,-6
|
||||
L -45,-3.5 L -45,-10.5 L -3,-14
|
||||
L -3,-25 C -3,-29 -2.4,-33 0,-35 Z" fill="white" ${STROKE_BOLD}/>
|
||||
<rect x="-10.5" y="32.5" width="21" height="5" rx="1.5" fill="white" ${STROKE_BOLD}/>`,
|
||||
|
||||
// ── Helicopter: a SOLID translucent main-rotor disc (survives downscale as an
|
||||
// obvious circle, unlike a thin ring) with a bold two-blade cross, a
|
||||
// teardrop cabin, a tail boom, and a tail rotor. Unmistakable "disc on top"
|
||||
// silhouette that never reads as a fixed-wing.
|
||||
helicopter: `
|
||||
<circle cx="0" cy="-6" r="31" fill="white" fill-opacity="0.22"/>
|
||||
<g transform="rotate(45 0 -6)">
|
||||
<rect x="-30.5" y="-8.2" width="61" height="4.4" rx="2.2" fill="white" fill-opacity="0.9"/>
|
||||
<rect x="-30.5" y="-8.2" width="61" height="4.4" rx="2.2" fill="white" fill-opacity="0.9" transform="rotate(90 0 -6)"/>
|
||||
</g>
|
||||
<path d="M0,-22 C 8,-20 10.5,-13 10.5,-6 C 10.5,2 7.5,7 0,8.5
|
||||
C -7.5,7 -10.5,2 -10.5,-6 C -10.5,-13 -8,-20 0,-22 Z" fill="white" ${STROKE}/>
|
||||
<path d="M-2.6,8 L 2.6,8 L 1.8,32 L -1.8,32 Z" fill="white" ${STROKE}/>
|
||||
<path d="M-8,27 L 8,27 L 8,30.6 L -8,30.6 Z" fill="white"/>
|
||||
<circle cx="5.6" cy="35" r="6" fill="white" fill-opacity="0.6"/>
|
||||
<circle cx="5.6" cy="35" r="2.1" fill="white"/>`,
|
||||
|
||||
// ── Fast jet: sharp delta/cropped-delta with a pointed nose, LERX root
|
||||
// blend, and twin tail fins. Aggressive, all-wing.
|
||||
fastjet: `
|
||||
<path d="M0,-43
|
||||
L 3.5,-30
|
||||
C 4,-24 4.6,-16 5,-8
|
||||
L 27,20 L 27,26 L 6,16
|
||||
L 8,30 L 8,34 L 3,31
|
||||
L 3,38 L 6.5,42 L 6.5,44 L 0,41.5
|
||||
L -6.5,44 L -6.5,42 L -3,38
|
||||
L -3,31 L -8,34 L -8,30 L -6,16
|
||||
L -27,26 L -27,20 L -5,-8
|
||||
C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`,
|
||||
// 2026-08-15 Hangar additions: two NEW classes shipped with the real-model
|
||||
// fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up,
|
||||
// white fill, hairline stroke, tint-safe.
|
||||
// Business jet — slender fuselage, modest swept wing, aft engines, T-tail.
|
||||
bizjet: `
|
||||
<path d="M0,-42
|
||||
L 2.6,-36 L 3.4,-26 L 3.4,-8
|
||||
L 27,8 L 27,13 L 3.6,6
|
||||
L 3.6,16
|
||||
L 8,18 L 8,26 L 3.8,25
|
||||
L 3.2,30 L 15,34 L 15,38 L 2.4,36
|
||||
L 0,40
|
||||
L -2.4,36 L -15,38 L -15,34 L -3.2,30
|
||||
L -3.8,25 L -8,26 L -8,18 L -3.6,16
|
||||
L -3.6,6 L -27,13 L -27,8 L -3.4,-8
|
||||
L -3.4,-26 L -2.6,-36 Z" fill="white" ${STROKE}/>`,
|
||||
// ── TR-3B (hidden 9th kind, Easter egg — NOT a classifyAircraft() output).
|
||||
// The canonical black-triangle silhouette: a dark isosceles delta with a
|
||||
// light at each corner and a dimmer one at the centre. Reached only by
|
||||
// explicit user conversion (see tr3bRegistry.js), never by classification.
|
||||
//
|
||||
// DELIBERATE BREAK from the white-fill contract above: this glyph is a
|
||||
// SHADOW, so it carries its own near-black fill. The billboard tint still
|
||||
// multiplies (white fleet / cyan tracked / amber military), which darkens
|
||||
// rather than lightens — the triangle stays a silhouette in every layer,
|
||||
// and the corner lights pick the tint up as their hue.
|
||||
tr3b: `
|
||||
<path d="M0,-38 L 40,30 L -40,30 Z"
|
||||
fill="#0d1014" stroke="rgba(158,184,210,0.34)" stroke-width="1.6"
|
||||
stroke-linejoin="round"/>
|
||||
<circle cx="0" cy="-24" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
|
||||
<circle cx="-28" cy="21" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
|
||||
<circle cx="28" cy="21" r="8.5" fill="#c9dcf0" fill-opacity="0.13"/>
|
||||
<circle cx="0" cy="-24" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
|
||||
<circle cx="-28" cy="21" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
|
||||
<circle cx="28" cy="21" r="4.6" fill="#dceaf8" fill-opacity="0.52"/>
|
||||
<circle cx="0" cy="6" r="3.4" fill="#dceaf8" fill-opacity="0.26"/>`,
|
||||
|
||||
// ── TR-3B, thermal-reactive variant. Same cold airframe, but the three
|
||||
// corner emitters + the centre one render HOT: near-white cores inside a
|
||||
// baked radial glow, so the FLIR/NVG luminance mapping (and any bloom)
|
||||
// lights them up while the hull stays cold. Selected by the layers
|
||||
// whenever their `irBoost` style param is on (surveillance/thermal/nvg).
|
||||
tr3bHot: `
|
||||
<defs>
|
||||
<radialGradient id="tr3bGlow">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.92"/>
|
||||
<stop offset="32%" stop-color="#f2f9ff" stop-opacity="0.44"/>
|
||||
<stop offset="100%" stop-color="#dcefff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<path d="M0,-38 L 40,30 L -40,30 Z"
|
||||
fill="#0b0e12" stroke="rgba(126,148,172,0.3)" stroke-width="1.6"
|
||||
stroke-linejoin="round"/>
|
||||
<circle cx="0" cy="-24" r="15" fill="url(#tr3bGlow)"/>
|
||||
<circle cx="-28" cy="21" r="15" fill="url(#tr3bGlow)"/>
|
||||
<circle cx="28" cy="21" r="15" fill="url(#tr3bGlow)"/>
|
||||
<circle cx="0" cy="6" r="10" fill="url(#tr3bGlow)"/>
|
||||
<circle cx="0" cy="-24" r="5.2" fill="#ffffff"/>
|
||||
<circle cx="-28" cy="21" r="5.2" fill="#ffffff"/>
|
||||
<circle cx="28" cy="21" r="5.2" fill="#ffffff"/>
|
||||
<circle cx="0" cy="6" r="3.6" fill="#ffffff" fill-opacity="0.82"/>`,
|
||||
|
||||
// Large UAV (Reaper-class) — bulbous sensor nose, very long slender
|
||||
// straight wings, slim tail boom, canted V-tail.
|
||||
uav: `
|
||||
<path d="M0,-40
|
||||
C 3.6,-40 4.6,-35 4.4,-30
|
||||
L 2.4,-12
|
||||
L 43,-7 L 43,-2.5 L 2.3,0
|
||||
L 2.1,24
|
||||
L 13,32 L 13,36 L 1.6,30
|
||||
L 0,38
|
||||
L -1.6,30 L -13,36 L -13,32 L -2.1,24
|
||||
L -2.3,0 L -43,-2.5 L -43,-7 L -2.4,-12
|
||||
L -4.4,-30 C -4.6,-35 -3.6,-40 0,-40 Z" fill="white" ${STROKE}/>`,
|
||||
};
|
||||
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
|
||||
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
|
||||
* texture gets GPU-minified into mush (192 px ÷ 40 = 4.8× — the owner's
|
||||
* "soft" glyphs; the pre-2026-08-15 96 px raster aliased instead). Rastering
|
||||
* NEAR the display size lets the browser's SVG AA do the work: crisp AND
|
||||
* smooth. 64 covers the 40–58 px fleet band with ≤1.6× minification. */
|
||||
const FLEET_RASTER_PX = 64;
|
||||
/** Tracked raster: the tracked billboard is the one SUSTAINED large 2D glyph
|
||||
* (close-zoom fleet flybys hand off to 3D models). Keep the 192 px texture so
|
||||
* it stays crisp at its biggest on-screen sizes. */
|
||||
const TRACKED_RASTER_PX = 192;
|
||||
|
||||
/** Data URI for a class silhouette (lazily built, cached per kind+size).
|
||||
* Default size serves the fleet; pass `aircraftIcon(kind, TRACKED_ICON_PX)`
|
||||
* (re-exported below) for the tracked billboard. */
|
||||
export const TRACKED_ICON_PX = TRACKED_RASTER_PX;
|
||||
export function aircraftIcon(kind, px = FLEET_RASTER_PX) {
|
||||
const k = BODIES[kind] ? kind : 'airliner';
|
||||
const key = `${k}@${px}`;
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${VIEW} ${VIEW}"><g transform="translate(${C},${C})">${BODIES[k]}</g></svg>`;
|
||||
uri = 'data:image/svg+xml;base64,' + _b64(svg);
|
||||
_iconCache.set(key, uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// src/data/aircraftMeta.js
|
||||
/**
|
||||
* Sticky per-aircraft metadata merge: once a field has resolved for an
|
||||
* aircraft, a later snapshot that MISSES the field (empty/null — OpenSky and
|
||||
* adsb.lol both do this intermittently) must not regress it. A later snapshot
|
||||
* that CHANGES the field always wins. Pattern from skylight (MIT)
|
||||
* server/src/datasource.ts "sticky enrichment".
|
||||
*/
|
||||
|
||||
export function stickyText(next, prev) {
|
||||
const n = String(next || '').trim();
|
||||
if (n) return n;
|
||||
const p = String(prev || '').trim();
|
||||
return p || '';
|
||||
}
|
||||
|
||||
export function stickyNumber(next, prev, fallback) {
|
||||
if (Number.isFinite(next)) return next;
|
||||
if (Number.isFinite(prev)) return prev;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// src/data/aircraftMeta.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { stickyText, stickyNumber } from './aircraftMeta.js';
|
||||
|
||||
test('stickyText holds last non-empty value', () => {
|
||||
assert.equal(stickyText('UAL123 ', undefined), 'UAL123');
|
||||
assert.equal(stickyText('', 'UAL123'), 'UAL123');
|
||||
assert.equal(stickyText(' ', 'UAL123'), 'UAL123');
|
||||
assert.equal(stickyText('DAL9', 'UAL123'), 'DAL9'); // new value wins
|
||||
assert.equal(stickyText(null, null), '');
|
||||
});
|
||||
|
||||
test('stickyNumber holds last finite value, keeps 0, honors fallback', () => {
|
||||
assert.equal(stickyNumber(250, 100, 0), 250);
|
||||
assert.equal(stickyNumber(null, 100, 0), 100);
|
||||
assert.equal(stickyNumber(0, 100, 7), 0); // 0 is a REAL value
|
||||
assert.equal(stickyNumber(NaN, undefined, 7), 7);
|
||||
assert.equal(stickyNumber(undefined, undefined, null), null);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Decide whether a loaded aircraft participates in a proximity query.
|
||||
*
|
||||
* `modelRendering` is the OWNERSHIP question, not `model.show`: a model that
|
||||
* exists but is hidden, unplaced, or still loading is not what the operator sees,
|
||||
* and the billboard flag is what answers for the contact in those states. Reading
|
||||
* a bare `show` here counted a contact whose model had been admitted but was
|
||||
* drawing nothing. */
|
||||
export function aircraftIncludedInNearby({
|
||||
isTracked = false,
|
||||
billboardShown = false,
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
|
||||
|
||||
test('awareness proximity includes horizon-hidden loaded aircraft without changing the default', () => {
|
||||
const horizonHidden = {
|
||||
isTracked: false,
|
||||
billboardShown: false,
|
||||
modelRendering: false,
|
||||
};
|
||||
assert.equal(aircraftIncludedInNearby(horizonHidden), false);
|
||||
assert.equal(aircraftIncludedInNearby({ ...horizonHidden, includeHidden: true }), true);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Limb-relative recession for distant aircraft billboards.
|
||||
*
|
||||
* This multiplies the billboard's ordinary scale, which Cesium then composes
|
||||
* with the locked NearFarScalar. It never edits that scalar, culls a contact,
|
||||
* or allows alpha to reach zero.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
|
||||
startLimbRatio: 0.5,
|
||||
scaleFloor: 0.45,
|
||||
alphaFloor: 0.35,
|
||||
combinedAlphaFloor: 0.20,
|
||||
globeViewBlendStartM: 3_500_000,
|
||||
globeViewBlendEndM: 4_500_000,
|
||||
earthRadiusM: 6_378_137,
|
||||
writeEpsilon: 0.005,
|
||||
});
|
||||
|
||||
let _params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||
const _scratchFactors = { scale: 1, alpha: 1, limbRatio: null };
|
||||
const _treatmentResult = {
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
scaleWrites: 0,
|
||||
alphaWrites: 0,
|
||||
factors: _scratchFactors,
|
||||
};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function resolvedParams(overrides) {
|
||||
if (!overrides || overrides === _params) return _params;
|
||||
return { ..._params, ...overrides };
|
||||
}
|
||||
|
||||
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
|
||||
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|
||||
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|
||||
|| cameraHeightM >= tuning.globeViewBlendEndM) {
|
||||
result.scale = 1;
|
||||
result.alpha = 1;
|
||||
result.limbRatio = null;
|
||||
return result;
|
||||
}
|
||||
const limbDistance = cameraLimbDistanceM(cameraHeightM, tuning.earthRadiusM);
|
||||
if (!Number.isFinite(limbDistance) || limbDistance <= 0) {
|
||||
result.scale = 1;
|
||||
result.alpha = 1;
|
||||
result.limbRatio = null;
|
||||
return result;
|
||||
}
|
||||
const limbRatio = cameraDistanceM / limbDistance;
|
||||
if (limbRatio <= tuning.startLimbRatio) {
|
||||
result.scale = 1;
|
||||
result.alpha = 1;
|
||||
result.limbRatio = limbRatio;
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
|
||||
const t = clamp(rawT, 0, 1);
|
||||
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
|
||||
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
|
||||
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
|
||||
const globeT = clamp(globeRawT, 0, 1);
|
||||
const globeEase = globeT * globeT * (3 - 2 * globeT);
|
||||
const strength = 1 - globeEase;
|
||||
result.scale = 1 + (tuning.scaleFloor - 1) * limbEase * strength;
|
||||
result.alpha = 1 + (tuning.alphaFloor - 1) * limbEase * strength;
|
||||
result.limbRatio = limbRatio;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch runtime taper tuning for evidence capture/A-B work.
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [patch]
|
||||
* @returns {typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS}
|
||||
*/
|
||||
export function setAircraftRecessionParams(patch = {}) {
|
||||
const next = { ..._params };
|
||||
if (Number.isFinite(patch.startLimbRatio)) {
|
||||
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
|
||||
}
|
||||
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
|
||||
if (Number.isFinite(patch.combinedAlphaFloor)) {
|
||||
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
|
||||
}
|
||||
if (Number.isFinite(patch.globeViewBlendStartM)) {
|
||||
next.globeViewBlendStartM = Math.max(1, patch.globeViewBlendStartM);
|
||||
}
|
||||
if (Number.isFinite(patch.globeViewBlendEndM)) {
|
||||
next.globeViewBlendEndM = Math.max(
|
||||
next.globeViewBlendStartM + 1,
|
||||
patch.globeViewBlendEndM,
|
||||
);
|
||||
}
|
||||
// Backward-compatible evidence override: the former hard threshold now
|
||||
// names the identity end of the blend band.
|
||||
if (Number.isFinite(patch.globeViewHeightM)) {
|
||||
next.globeViewBlendEndM = Math.max(
|
||||
next.globeViewBlendStartM + 1,
|
||||
patch.globeViewHeightM,
|
||||
);
|
||||
}
|
||||
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
|
||||
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
|
||||
}
|
||||
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
|
||||
/** @returns {typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS} Current tuning. */
|
||||
export function getAircraftRecessionParams() {
|
||||
return _params;
|
||||
}
|
||||
|
||||
/** Restore production defaults for deterministic QA. */
|
||||
export function resetAircraftRecessionParams() {
|
||||
_params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight-line tangent distance from a camera above a spherical Earth to its
|
||||
* geometric limb. The WGS84 semi-major radius is sufficient for a visual
|
||||
* taper; the occluder remains the authoritative far-side visibility test.
|
||||
* @param {number} cameraHeightM
|
||||
* @param {number} [earthRadiusM]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cameraLimbDistanceM(
|
||||
cameraHeightM,
|
||||
earthRadiusM = DEFAULT_AIRCRAFT_RECESSION_PARAMS.earthRadiusM,
|
||||
) {
|
||||
if (!Number.isFinite(cameraHeightM) || cameraHeightM <= 0) return Number.NaN;
|
||||
return Math.sqrt(cameraHeightM * (2 * earthRadiusM + cameraHeightM));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure recession factors for one aircraft.
|
||||
* @param {object} input
|
||||
* @param {number} input.cameraDistanceM Sprite-to-camera distance.
|
||||
* @param {number} input.cameraHeightM Camera height above the ellipsoid.
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
|
||||
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
|
||||
*/
|
||||
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
|
||||
const tuning = resolvedParams(params);
|
||||
return aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
tuning,
|
||||
{ scale: 1, alpha: 1, limbRatio: null },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Production wire helper: compose base scale/alpha, focus emphasis, and limb
|
||||
* recession into one deadband-gated billboard write site.
|
||||
* @param {object} input
|
||||
* @param {object} input.billboard
|
||||
* @param {number} input.baseScale
|
||||
* @param {number} input.baseAlpha
|
||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||
* @param {number} input.focusFactor
|
||||
* @param {number} input.cameraDistanceM
|
||||
* @param {number} input.cameraHeightM
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
*
|
||||
* IMPORTANT: the returned object (including `factors`) is a module-owned
|
||||
* mutable singleton. Consume every needed property before the next call;
|
||||
* never retain it as a snapshot.
|
||||
* @returns {{scale:number,alpha:number,scaleWrites:number,alphaWrites:number,factors:object}}
|
||||
*/
|
||||
export function applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale,
|
||||
baseAlpha,
|
||||
baseColor,
|
||||
focusFactor,
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
params,
|
||||
}) {
|
||||
const tuning = resolvedParams(params);
|
||||
const factors = aircraftRecessionFactorsResolved(
|
||||
cameraDistanceM,
|
||||
cameraHeightM,
|
||||
tuning,
|
||||
_scratchFactors,
|
||||
);
|
||||
const scale = baseScale * factors.scale;
|
||||
const composedTreatment = Math.max(
|
||||
tuning.combinedAlphaFloor,
|
||||
clamp(focusFactor, 0, 1) * factors.alpha,
|
||||
);
|
||||
const alpha = baseAlpha * composedTreatment;
|
||||
let scaleWrites = 0;
|
||||
let alphaWrites = 0;
|
||||
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
|
||||
billboard.scale = scale;
|
||||
scaleWrites = 1;
|
||||
}
|
||||
if (!Number.isFinite(billboard.color?.alpha)
|
||||
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
|
||||
billboard.color = baseColor.withAlpha(alpha);
|
||||
alphaWrites = 1;
|
||||
}
|
||||
_treatmentResult.scale = scale;
|
||||
_treatmentResult.alpha = alpha;
|
||||
_treatmentResult.scaleWrites = scaleWrites;
|
||||
_treatmentResult.alphaWrites = alphaWrites;
|
||||
return _treatmentResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the already-composed aircraft alpha to an ambient glTF model without
|
||||
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
|
||||
* @param {object} input
|
||||
* @param {object} input.model
|
||||
* @param {{withAlpha:(alpha:number)=>object}} input.baseColor
|
||||
* @param {number} input.alpha
|
||||
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
|
||||
* @returns {number} One when a color write occurred, otherwise zero.
|
||||
*/
|
||||
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
|
||||
const tuning = resolvedParams(params);
|
||||
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
|
||||
if (Number.isFinite(model.color?.alpha)
|
||||
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
|
||||
model.color = baseColor.withAlpha(alpha);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_AIRCRAFT_RECESSION_PARAMS,
|
||||
aircraftRecessionFactors,
|
||||
applyAircraftBillboardTreatment,
|
||||
applyAircraftModelTreatment,
|
||||
cameraLimbDistanceM,
|
||||
} from './aircraftRecession.js';
|
||||
|
||||
const params = { ...DEFAULT_AIRCRAFT_RECESSION_PARAMS };
|
||||
const cameraHeightM = 1_000_000;
|
||||
const limb = cameraLimbDistanceM(cameraHeightM, params.earthRadiusM);
|
||||
|
||||
test('aircraft recession is a no-op below the limb-relative start threshold', () => {
|
||||
assert.deepEqual(
|
||||
aircraftRecessionFactors({ cameraDistanceM: limb * 0.49, cameraHeightM }, params),
|
||||
{ scale: 1, alpha: 1, limbRatio: 0.49 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
aircraftRecessionFactors({ cameraDistanceM: limb * 0.5, cameraHeightM }, params),
|
||||
{ scale: 1, alpha: 1, limbRatio: 0.5 },
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft recession reaches the tunable scale and haze floors at the limb', () => {
|
||||
const atLimb = aircraftRecessionFactors({ cameraDistanceM: limb, cameraHeightM }, params);
|
||||
assert.ok(Math.abs(atLimb.scale - 0.45) < 1e-12);
|
||||
assert.ok(Math.abs(atLimb.alpha - 0.35) < 1e-12);
|
||||
const beyond = aircraftRecessionFactors({ cameraDistanceM: limb * 1.2, cameraHeightM }, params);
|
||||
assert.ok(Math.abs(beyond.scale - 0.45) < 1e-12);
|
||||
assert.ok(Math.abs(beyond.alpha - 0.35) < 1e-12);
|
||||
});
|
||||
|
||||
test('aircraft recession is a globe-view no-op instead of a global distance fade', () => {
|
||||
assert.deepEqual(
|
||||
aircraftRecessionFactors({ cameraDistanceM: 20_000_000, cameraHeightM: 5_000_000 }, params),
|
||||
{ scale: 1, alpha: 1, limbRatio: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('globe-view transition eases to identity without a threshold pop', () => {
|
||||
let prior = null;
|
||||
for (let height = 3_500_000; height <= 4_500_000; height += 50_000) {
|
||||
const atLimbDistance = cameraLimbDistanceM(height, params.earthRadiusM);
|
||||
const current = aircraftRecessionFactors({
|
||||
cameraDistanceM: atLimbDistance,
|
||||
cameraHeightM: height,
|
||||
}, params);
|
||||
if (prior) {
|
||||
assert.ok(Math.abs(current.alpha - prior.alpha) <= 0.05, `${height}: alpha pop`);
|
||||
assert.ok(Math.abs(current.scale - prior.scale) <= 0.05, `${height}: scale pop`);
|
||||
}
|
||||
prior = current;
|
||||
}
|
||||
const start = aircraftRecessionFactors({
|
||||
cameraDistanceM: cameraLimbDistanceM(3_500_000, params.earthRadiusM),
|
||||
cameraHeightM: 3_500_000,
|
||||
}, params);
|
||||
const end = aircraftRecessionFactors({
|
||||
cameraDistanceM: cameraLimbDistanceM(4_500_000, params.earthRadiusM),
|
||||
cameraHeightM: 4_500_000,
|
||||
}, params);
|
||||
assert.ok(Math.abs(start.scale - params.scaleFloor) < 1e-12);
|
||||
assert.ok(Math.abs(start.alpha - params.alphaFloor) < 1e-12);
|
||||
assert.deepEqual(end, { scale: 1, alpha: 1, limbRatio: null });
|
||||
});
|
||||
|
||||
test('aircraft billboard wire writes scale and alpha only for the far treatment', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const near = { scale: 1.2, color: { alpha: 0.8 } };
|
||||
const nearResult = applyAircraftBillboardTreatment({
|
||||
billboard: near,
|
||||
baseScale: 1.2,
|
||||
baseAlpha: 0.8,
|
||||
baseColor: color,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: limb * 0.4,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(nearResult.scaleWrites, 0);
|
||||
assert.equal(nearResult.alphaWrites, 0);
|
||||
|
||||
const far = { scale: 1.2, color: { alpha: 0.8 } };
|
||||
const farResult = applyAircraftBillboardTreatment({
|
||||
billboard: far,
|
||||
baseScale: 1.2,
|
||||
baseAlpha: 0.8,
|
||||
baseColor: color,
|
||||
focusFactor: 0.5,
|
||||
cameraDistanceM: limb,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(farResult.scaleWrites, 1);
|
||||
assert.equal(farResult.alphaWrites, 1);
|
||||
assert.ok(Math.abs(far.scale - (1.2 * 0.45)) < 1e-12);
|
||||
assert.ok(Math.abs(far.color.alpha - (0.8 * 0.20)) < 1e-12);
|
||||
});
|
||||
|
||||
test('combined focus and haze product is clamped at the composed alpha floor', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 0.45, color: { alpha: 1 } };
|
||||
const result = applyAircraftBillboardTreatment({
|
||||
billboard,
|
||||
baseScale: 1,
|
||||
baseAlpha: 1,
|
||||
baseColor: color,
|
||||
focusFactor: 0.25,
|
||||
cameraDistanceM: limb,
|
||||
cameraHeightM,
|
||||
params,
|
||||
});
|
||||
assert.equal(result.alpha, 0.20);
|
||||
assert.equal(billboard.color.alpha, 0.20);
|
||||
});
|
||||
|
||||
test('applyAircraftBillboardTreatment returns its documented module singleton', () => {
|
||||
const color = { withAlpha: (alpha) => ({ alpha }) };
|
||||
const billboard = { scale: 1, color: { alpha: 1 } };
|
||||
const input = {
|
||||
billboard,
|
||||
baseAlpha: 1,
|
||||
baseColor: color,
|
||||
focusFactor: 1,
|
||||
cameraDistanceM: 0,
|
||||
cameraHeightM,
|
||||
params,
|
||||
};
|
||||
const first = applyAircraftBillboardTreatment({ ...input, baseScale: 1 });
|
||||
const second = applyAircraftBillboardTreatment({ ...input, baseScale: 0.5 });
|
||||
assert.strictEqual(first, second);
|
||||
assert.equal(first.scale, 0.5, 'the prior reference reflects the next call');
|
||||
});
|
||||
|
||||
test('ambient model presentation receives composed alpha without changing blend semantics', () => {
|
||||
const model = { color: { alpha: 1 }, colorBlendAmount: 0.9 };
|
||||
const color = { withAlpha: (alpha) => ({ alpha, rgb: 'amber' }) };
|
||||
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 1);
|
||||
assert.deepEqual(model.color, { alpha: 0.2, rgb: 'amber' });
|
||||
assert.equal(model.colorBlendAmount, 0.9);
|
||||
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// src/data/aisLiveVessels.analyst.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Separate file from aisLiveVessels.test.mjs (feed-status helper) by design.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||
|
||||
const FULL_RECORD = {
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
lon: -94.98,
|
||||
speed: 12.3,
|
||||
course: 214.0,
|
||||
type: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
};
|
||||
|
||||
test('ais analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RECORD);
|
||||
assert.deepEqual(r, {
|
||||
id: 'EVER GIVEN',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
lon: -94.98,
|
||||
speedKts: 12.3,
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
||||
const r = mapAnalystRecord({ ...FULL_RECORD, name: ' ' });
|
||||
assert.equal(r.id, '353136000');
|
||||
assert.equal(r.name, null);
|
||||
});
|
||||
|
||||
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||
assert.equal(r.mmsi, null);
|
||||
assert.equal(r.speedKts, null);
|
||||
assert.equal(r.shipType, null);
|
||||
assert.equal(r.destination, null);
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ais analyst record: output is JSON-safe (no Cesium types leak from the record)', () => {
|
||||
// Real records carry Cesium positions/billboards — the mapper must not copy them.
|
||||
const r = mapAnalystRecord({ ...FULL_RECORD, position: { x: 1 }, billboard: {}, normal: {} });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
|
||||
assert.equal('position' in r, false);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,530 @@
|
||||
// Transport adapter for the AISStream watchdog.
|
||||
//
|
||||
// Extracted from vite.config.js so the socket lifecycle — the part that has
|
||||
// historically carried the defects — can be exercised offline with mock
|
||||
// sockets. The adapter owns sockets and translates watchdog ACTIONS into
|
||||
// transport calls; the watchdog owns policy and owns no I/O.
|
||||
//
|
||||
// Two ownership rules make the single-socket invariant hold even when events
|
||||
// arrive late, out of order, or across a dev-server restart:
|
||||
//
|
||||
// 1. Socket generations are MONOTONIC for the lifetime of this module and
|
||||
// are never reused, including across dispose(). A recycled generation
|
||||
// would let a pre-disposal handler act on its successor's socket.
|
||||
// 2. Every mutation of the socket map is IDENTITY-checked: an entry is only
|
||||
// deleted or acted on when the map still holds *that* socket object.
|
||||
//
|
||||
// Liveness credit is granted only to a frame that (a) arrived on a socket we
|
||||
// still own, and (b) decoded into a real AIS record. Handshakes, malformed
|
||||
// frames and error envelopes are never liveness.
|
||||
|
||||
import { createAisWatchdog } from './aisWatchdog.js';
|
||||
|
||||
const DEFAULT_CLOCK = Object.freeze({
|
||||
wall: () => Date.now(),
|
||||
mono: () => performance.now(),
|
||||
});
|
||||
|
||||
/** Upstream text that identifies a credential rejection. */
|
||||
const AUTH_TEXT = /(unauthoriz|unauthoris|forbidden|invalid\s*api[\s_-]*key|invalid\s*key|bad\s*api[\s_-]*key|authentic|api\s*key\s*(is\s*)?(invalid|required|missing|not\s*valid))/i;
|
||||
|
||||
/** Upstream text that identifies a rate limit / connection cap. */
|
||||
const RATE_TEXT = /(rate[\s_-]*limit|too\s*many\s*(requests|connections)|quota\s*exceeded|429)/i;
|
||||
|
||||
/**
|
||||
* Parse an HTTP `Retry-After` header into milliseconds.
|
||||
* Accepts delta-seconds or an HTTP-date. Returns 0 when absent/unparseable.
|
||||
* @param {string|number|undefined|null} raw
|
||||
* @param {number} [nowMs] Wall-clock reference for the HTTP-date form.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function parseRetryAfterMs(raw, nowMs = Date.now()) {
|
||||
if (raw === null || raw === undefined) return 0;
|
||||
const text = String(raw).trim();
|
||||
if (!text) return 0;
|
||||
if (/^\d+$/.test(text)) return Number(text) * 1000;
|
||||
const date = Date.parse(text);
|
||||
if (Number.isFinite(date)) return Math.max(0, date - nowMs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a transport or upstream failure.
|
||||
*
|
||||
* Only 'transport' may walk the fast backoff ladder. An auth rejection cannot
|
||||
* be fixed by retrying, and a rate limit must honour the server's own pacing —
|
||||
* treating either as a generic error is how a watchdog turns into a hammer.
|
||||
*
|
||||
* @param {{httpStatus?: number|string, message?: string,
|
||||
* retryAfterHeader?: string|number|null, nowMs?: number}} input
|
||||
* @returns {{kind: string, message: string, retryAfterMs?: number}}
|
||||
*/
|
||||
export function classifyAisFailure(input = {}) {
|
||||
const text = String(input.message || '').trim();
|
||||
const fromHeader = () => parseRetryAfterMs(input.retryAfterHeader, input.nowMs);
|
||||
|
||||
let status = Number(input.httpStatus);
|
||||
if (!Number.isFinite(status)) {
|
||||
// ws reports a failed upgrade as "Unexpected server response: 401" when no
|
||||
// 'unexpected-response' listener consumed it.
|
||||
const match = /unexpected server response:\s*(\d{3})/i.exec(text);
|
||||
status = match ? Number(match[1]) : NaN;
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return { kind: 'auth', message: `AISStream rejected the API key (HTTP ${status})` };
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
kind: 'rate-limit',
|
||||
message: 'AISStream rate-limited this key (HTTP 429)',
|
||||
retryAfterMs: fromHeader(),
|
||||
};
|
||||
}
|
||||
if (Number.isFinite(status) && status >= 400) {
|
||||
return { kind: 'transport', message: `AISStream upgrade failed (HTTP ${status})` };
|
||||
}
|
||||
if (AUTH_TEXT.test(text)) return { kind: 'auth', message: text };
|
||||
if (RATE_TEXT.test(text)) return { kind: 'rate-limit', message: text, retryAfterMs: fromHeader() };
|
||||
return { kind: 'transport', message: text || 'AISStream websocket error' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a frame to text WITHOUT suspending, or return null if that is
|
||||
* impossible (only the Blob shape needs an await).
|
||||
*
|
||||
* This exists to keep the message pipeline synchronous for every shape `ws`
|
||||
* actually delivers. An await inside the pipeline is a suspension point, and a
|
||||
* 'close' event arriving in the same tick would otherwise overtake the frame —
|
||||
* which for an auth error envelope means the rejection is dropped and the
|
||||
* generic transport ladder runs instead.
|
||||
*
|
||||
* @param {*} data
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function decodeAisFrameSync(data) {
|
||||
if (typeof data === 'string') return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new TextDecoder().decode(
|
||||
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
// ws delivers a fragmented message as an array of Buffers.
|
||||
const parts = data.map(decodeAisFrameSync);
|
||||
return parts.some((part) => part === null) ? null : parts.join('');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one websocket frame to text. Handles the String (built-in WebSocket),
|
||||
* Buffer/TypedArray (ws), ArrayBuffer and Blob shapes.
|
||||
* @param {*} data
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function decodeAisFrame(data) {
|
||||
const sync = decodeAisFrameSync(data);
|
||||
if (sync !== null) return sync;
|
||||
if (data && typeof data.text === 'function') return data.text();
|
||||
return String(data ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on a single frame before decoding.
|
||||
*
|
||||
* AIS envelopes are a couple of KB; anything approaching this is not a feed
|
||||
* message. The bound is applied BEFORE the synchronous decode so a hostile or
|
||||
* corrupt frame cannot stall the event loop building a huge string.
|
||||
*/
|
||||
export const AIS_MAX_FRAME_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* Approximate a frame's size without decoding it. String length is in code
|
||||
* units rather than UTF-8 bytes, which only ever under-counts — fine for a
|
||||
* rejection threshold.
|
||||
* @param {*} data
|
||||
* @returns {number}
|
||||
*/
|
||||
export function aisFrameByteLength(data) {
|
||||
if (typeof data === 'string') return data.length;
|
||||
if (data instanceof ArrayBuffer) return data.byteLength;
|
||||
if (ArrayBuffer.isView(data)) return data.byteLength;
|
||||
if (Array.isArray(data)) return data.reduce((sum, part) => sum + aisFrameByteLength(part), 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* AISStream message types that count as feed traffic.
|
||||
*
|
||||
* This is the COMPLETE `AisMessageTypes` enum from AISStream's own
|
||||
* type-definition.yaml (github.com/aisstream/ais-message-models, master),
|
||||
* transcribed wholesale rather than hand-picked: a type missing from this list
|
||||
* is never credited as liveness, so a subscription filtered to it would read
|
||||
* as dead and be recycled forever. An earlier hand-picked version omitted
|
||||
* AddressedBinaryMessage, DataLinkManagementMessageData and UnknownMessage for
|
||||
* exactly that reason.
|
||||
*
|
||||
* If AISStream adds a type, add it here — do not infer it.
|
||||
*/
|
||||
export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze(new Set([
|
||||
'PositionReport',
|
||||
'UnknownMessage',
|
||||
'AddressedSafetyMessage',
|
||||
'AddressedBinaryMessage',
|
||||
'AidsToNavigationReport',
|
||||
'AssignedModeCommand',
|
||||
'BaseStationReport',
|
||||
'BinaryAcknowledge',
|
||||
'BinaryBroadcastMessage',
|
||||
'ChannelManagement',
|
||||
'CoordinatedUTCInquiry',
|
||||
'DataLinkManagementMessage',
|
||||
'DataLinkManagementMessageData',
|
||||
'ExtendedClassBPositionReport',
|
||||
'GroupAssignmentCommand',
|
||||
'GnssBroadcastBinaryMessage',
|
||||
'Interrogation',
|
||||
'LongRangeAisBroadcastMessage',
|
||||
'MultiSlotBinaryMessage',
|
||||
'SafetyBroadcastMessage',
|
||||
'ShipStaticData',
|
||||
'SingleSlotBinaryMessage',
|
||||
'StandardClassBPositionReport',
|
||||
'StandardSearchAndRescueAircraftReport',
|
||||
'StaticDataReport',
|
||||
]));
|
||||
|
||||
/**
|
||||
* Resolve an envelope's MMSI, or null.
|
||||
* @param {Object} envelope
|
||||
* @param {Object} body
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function aisEnvelopeMmsi(envelope, body = {}) {
|
||||
const metadata = envelope?.MetaData || envelope?.Metadata || {};
|
||||
const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi;
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const text = String(raw).trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this envelope real AIS traffic — the feed's only liveness proof?
|
||||
*
|
||||
* Requires a recognised MessageType, a body filed under that type, AND an
|
||||
* MMSI. An envelope carrying nothing but an MMSI is not evidence the feed
|
||||
* works: that is exactly the shape a malformed or synthetic frame takes.
|
||||
*
|
||||
* @param {Object} envelope
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRecognizedAisEnvelope(envelope) {
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return false;
|
||||
const messageType = envelope.MessageType;
|
||||
if (typeof messageType !== 'string' || !AIS_RECOGNIZED_MESSAGE_TYPES.has(messageType)) return false;
|
||||
const body = envelope.Message?.[messageType];
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
return aisEnvelopeMmsi(envelope, body) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decoded frame into a classified envelope.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{kind: 'malformed'}|{kind: 'error', message: string}|{kind: 'data', envelope: Object}}
|
||||
*/
|
||||
export function parseAisEnvelope(text) {
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(text);
|
||||
} catch {
|
||||
return { kind: 'malformed' };
|
||||
}
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
||||
return { kind: 'malformed' };
|
||||
}
|
||||
if (envelope.error) return { kind: 'error', message: String(envelope.error) };
|
||||
return { kind: 'data', envelope };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the AISStream transport adapter.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {(url: string) => Object} options.createSocket Socket factory.
|
||||
* @param {() => string} options.resolveUrl Upstream URL, read per connect.
|
||||
* @param {() => Object} options.buildSubscription Subscription payload.
|
||||
* @param {(envelope: Object) => boolean} options.ingestEnvelope Returns true
|
||||
* only when the envelope was a real AIS record — the sole liveness proof.
|
||||
* @param {{wall: function, mono: function}} [options.clock]
|
||||
* @param {(message: string) => void} [options.warn]
|
||||
* @returns {Object} adapter handle
|
||||
*/
|
||||
export function createAisStreamAdapter(options) {
|
||||
const {
|
||||
createSocket,
|
||||
resolveUrl,
|
||||
buildSubscription,
|
||||
ingestEnvelope,
|
||||
clock = DEFAULT_CLOCK,
|
||||
warn = () => {},
|
||||
} = options;
|
||||
|
||||
/** generation -> socket. Mutated only through the identity-checked helpers. */
|
||||
const sockets = new Map();
|
||||
/** Never reset, including across dispose(). See rule 1 at the top. */
|
||||
let generationHighWater = 0;
|
||||
let watchdog = null;
|
||||
|
||||
/** (Re)build the state machine, preserving the generation namespace. */
|
||||
function setWatchdogOptions(watchdogOptions = {}) {
|
||||
if (watchdog) {
|
||||
generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration());
|
||||
}
|
||||
watchdog = createAisWatchdog({
|
||||
...watchdogOptions,
|
||||
startGeneration: generationHighWater,
|
||||
clock,
|
||||
});
|
||||
return watchdog;
|
||||
}
|
||||
|
||||
/** True while `socket` is still the adapter's socket for `generation`. */
|
||||
function ownsSocket(generation, socket) {
|
||||
return sockets.get(generation) === socket;
|
||||
}
|
||||
|
||||
/** Drop the map entry only if it still holds this exact socket. */
|
||||
function releaseSocketEntry(generation, socket) {
|
||||
if (sockets.get(generation) === socket) sockets.delete(generation);
|
||||
}
|
||||
|
||||
/** Hard-abort a socket. Always terminate(), never close(). */
|
||||
function abort(socket, reason) {
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch (error) {
|
||||
warn(`[AISStream] terminate(${reason || 'unknown'}) failed: ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate the socket registered for `generation`, if any. */
|
||||
function terminateGeneration(generation, reason) {
|
||||
const socket = sockets.get(generation);
|
||||
if (!socket) return;
|
||||
sockets.delete(generation);
|
||||
abort(socket, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform watchdog actions in order.
|
||||
* @param {Object} owner Watchdog instance that produced the actions.
|
||||
* @param {Array<Object>} actions
|
||||
*/
|
||||
function runActions(owner, actions) {
|
||||
for (const action of actions || []) {
|
||||
if (action.type === 'terminate') terminateGeneration(action.generation, action.reason);
|
||||
else if (action.type === 'connect') openSocket(owner, action.generation);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail a generation through its owning watchdog, with classification. */
|
||||
function failGeneration(owner, generation, detail) {
|
||||
runActions(owner, owner.onFailure(generation, detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a socket for `generation`.
|
||||
*
|
||||
* Handlers capture BOTH the socket object and the watchdog instance that
|
||||
* commissioned them, so an event arriving after a dispose cannot reach the
|
||||
* replacement machine or a replacement socket.
|
||||
*/
|
||||
function openSocket(owner, generation) {
|
||||
generationHighWater = Math.max(generationHighWater, generation);
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = createSocket(resolveUrl());
|
||||
} catch (error) {
|
||||
failGeneration(owner, generation, classifyAisFailure({ message: error?.message }));
|
||||
return;
|
||||
}
|
||||
if (!socket) {
|
||||
failGeneration(owner, generation, { kind: 'transport', message: 'socket factory returned nothing' });
|
||||
return;
|
||||
}
|
||||
sockets.set(generation, socket);
|
||||
|
||||
// `ws` emitter semantics only. The built-in WebSocket's addEventListener
|
||||
// hands the handler an Event rather than the frame data, so accepting it
|
||||
// here would silently mis-decode every frame — and it cannot be used
|
||||
// anyway, having no terminate().
|
||||
if (typeof socket.on !== 'function') {
|
||||
sockets.delete(generation);
|
||||
abort(socket, 'unsupported-transport');
|
||||
failGeneration(owner, generation, {
|
||||
kind: 'transport',
|
||||
message: 'AIS socket does not expose ws emitter semantics',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const on = (event, handler) => socket.on(event, handler);
|
||||
|
||||
on('open', () => {
|
||||
if (!ownsSocket(generation, socket)) {
|
||||
abort(socket, 'orphan-open');
|
||||
return;
|
||||
}
|
||||
const actions = owner.onOpen(generation);
|
||||
if (actions.length) {
|
||||
runActions(owner, actions);
|
||||
return; // orphan — hung up, never subscribed
|
||||
}
|
||||
try {
|
||||
socket.send(JSON.stringify(buildSubscription()));
|
||||
} catch (error) {
|
||||
failGeneration(owner, generation, classifyAisFailure({ message: error?.message }));
|
||||
}
|
||||
});
|
||||
|
||||
on('message', (data) => {
|
||||
// The whole body is guarded: this is an EventEmitter handler, so an
|
||||
// escaping throw (including from the decode itself) would reach the
|
||||
// process-level handler and take the dev server down.
|
||||
try {
|
||||
const size = aisFrameByteLength(data);
|
||||
if (size > AIS_MAX_FRAME_BYTES) {
|
||||
warn(`[AISStream] dropping oversized frame (${size} bytes)`);
|
||||
return; // no decode, no state effect
|
||||
}
|
||||
// Fast path: every shape ws delivers decodes without suspending, so
|
||||
// the frame is fully classified before any same-tick 'close' runs.
|
||||
const text = decodeAisFrameSync(data);
|
||||
if (text !== null) {
|
||||
handleDecodedMessage(owner, generation, socket, text);
|
||||
return;
|
||||
}
|
||||
handleMessage(owner, generation, socket, data).catch((error) => {
|
||||
warn(`[AISStream] message handling failed: ${error?.message || error}`);
|
||||
});
|
||||
} catch (error) {
|
||||
// A frame that cannot even be decoded is a malformed frame: dropped,
|
||||
// with no liveness credit and no other state effect.
|
||||
warn(`[AISStream] message handling failed: ${error?.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Consuming 'unexpected-response' gives the real HTTP status and headers,
|
||||
// which is what separates an auth rejection from a generic fault.
|
||||
on('unexpected-response', (_request, response) => {
|
||||
const detail = classifyAisFailure({
|
||||
httpStatus: response?.statusCode,
|
||||
retryAfterHeader: response?.headers?.['retry-after'],
|
||||
nowMs: clock.wall(),
|
||||
});
|
||||
try {
|
||||
response?.destroy?.();
|
||||
} catch { /* already gone */ }
|
||||
releaseSocketEntry(generation, socket);
|
||||
abort(socket, 'unexpected-response');
|
||||
failGeneration(owner, generation, detail);
|
||||
});
|
||||
|
||||
on('error', (error) => {
|
||||
// An error on a socket we already abandoned yields no watchdog action,
|
||||
// so it has to be hung up here or it would linger holding the slot.
|
||||
if (!ownsSocket(generation, socket)) {
|
||||
abort(socket, 'orphan-error');
|
||||
return;
|
||||
}
|
||||
failGeneration(owner, generation, classifyAisFailure({
|
||||
message: error?.message || String(error || ''),
|
||||
nowMs: clock.wall(),
|
||||
}));
|
||||
});
|
||||
|
||||
on('close', () => {
|
||||
releaseSocketEntry(generation, socket);
|
||||
runActions(owner, owner.onClose(generation));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The message pipeline. Ownership is verified before any work AND again
|
||||
* after the decode await, because that suspension point is long enough for a
|
||||
* terminate to land.
|
||||
*/
|
||||
async function handleMessage(owner, generation, socket, data) {
|
||||
if (!ownsSocket(generation, socket)) {
|
||||
abort(socket, 'orphan-message');
|
||||
return;
|
||||
}
|
||||
// Only the Blob shape reaches here, and only it needs an await.
|
||||
const text = await decodeAisFrame(data);
|
||||
handleDecodedMessage(owner, generation, socket, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify and apply one decoded frame. Ownership is re-verified here because
|
||||
* the async path suspends before reaching it.
|
||||
*/
|
||||
function handleDecodedMessage(owner, generation, socket, text) {
|
||||
if (!ownsSocket(generation, socket)) {
|
||||
abort(socket, 'orphan-message');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseAisEnvelope(text);
|
||||
if (parsed.kind === 'malformed') return; // not liveness, not data
|
||||
if (parsed.kind === 'error') {
|
||||
// An error envelope is the OPPOSITE of liveness. Classifying it keeps a
|
||||
// rejected key out of the fast ladder.
|
||||
failGeneration(owner, generation, classifyAisFailure({
|
||||
message: parsed.message,
|
||||
nowMs: clock.wall(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = Boolean(ingestEnvelope(parsed.envelope));
|
||||
if (!accepted) return; // valid JSON, but no AIS record — proves nothing
|
||||
runActions(owner, owner.onMessage(generation));
|
||||
}
|
||||
|
||||
/** Drive the machine once: re-declare the environment, then advance time. */
|
||||
function ensure(env) {
|
||||
if (!watchdog) setWatchdogOptions();
|
||||
runActions(watchdog, watchdog.configure(env));
|
||||
runActions(watchdog, watchdog.tick());
|
||||
}
|
||||
|
||||
/** Tear down sockets and machine state, preserving the generation namespace. */
|
||||
function dispose() {
|
||||
if (watchdog) {
|
||||
generationHighWater = Math.max(generationHighWater, watchdog.highWaterGeneration());
|
||||
runActions(watchdog, watchdog.dispose());
|
||||
}
|
||||
for (const [generation, socket] of [...sockets]) {
|
||||
sockets.delete(generation);
|
||||
abort(socket, 'dispose');
|
||||
}
|
||||
sockets.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
setWatchdogOptions,
|
||||
ensure,
|
||||
dispose,
|
||||
snapshot: () => (watchdog ? watchdog.snapshot() : null),
|
||||
/** Diagnostics for tests. */
|
||||
debug: () => ({
|
||||
liveSockets: sockets.size,
|
||||
generations: [...sockets.keys()],
|
||||
generationHighWater,
|
||||
watchdog: watchdog ? watchdog.debugState() : null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
// AISStream transport adapter. Exercises the REAL adapter — the same code the
|
||||
// Vite plugin runs — against mock sockets that reproduce `ws` semantics,
|
||||
// including the window between terminate() and its asynchronous close event.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AIS_MAX_FRAME_BYTES,
|
||||
AIS_RECOGNIZED_MESSAGE_TYPES,
|
||||
aisFrameByteLength,
|
||||
classifyAisFailure,
|
||||
createAisStreamAdapter,
|
||||
decodeAisFrame,
|
||||
decodeAisFrameSync,
|
||||
isRecognizedAisEnvelope,
|
||||
parseAisEnvelope,
|
||||
parseRetryAfterMs,
|
||||
} from './aisStreamAdapter.js';
|
||||
|
||||
const ENV = { hasKey: true, hasTransport: true, keyFingerprint: 'key-a' };
|
||||
|
||||
const BUDGETS = Object.freeze({
|
||||
staleMs: 1_000,
|
||||
recycleAfterMs: 2_500,
|
||||
backoffMs: [5_000, 15_000],
|
||||
downRetryMs: 60_000,
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
function fakeClock(startWall = 1_700_000_000_000, startMono = 10_000) {
|
||||
let wall = startWall;
|
||||
let mono = startMono;
|
||||
return {
|
||||
clock: { wall: () => wall, mono: () => mono },
|
||||
advance(ms) { wall += ms; mono += ms; },
|
||||
get wall() { return wall; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock socket with `ws` semantics. terminate() destroys the connection
|
||||
* synchronously (that is the whole reason ws is used) but, like ws, the
|
||||
* 'close' event lands on a later turn — the window in which a stale handler
|
||||
* can still fire.
|
||||
*/
|
||||
function makeMockTransport() {
|
||||
const created = [];
|
||||
|
||||
class MockSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.terminated = false;
|
||||
this.selfClosed = false;
|
||||
this.sent = [];
|
||||
this.handlers = new Map();
|
||||
this.pendingClose = false;
|
||||
created.push(this);
|
||||
}
|
||||
|
||||
get connectionOpen() {
|
||||
return !this.terminated && !this.selfClosed;
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
if (!this.handlers.has(event)) this.handlers.set(event, []);
|
||||
this.handlers.get(event).push(handler);
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
for (const handler of [...(this.handlers.get(event) || [])]) handler(...args);
|
||||
}
|
||||
|
||||
send(payload) {
|
||||
if (!this.connectionOpen) throw new Error('send on a dead socket');
|
||||
this.sent.push(payload);
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.terminated) return;
|
||||
this.terminated = true;
|
||||
this.pendingClose = true; // close lands later, as in ws
|
||||
}
|
||||
|
||||
/** Deliver the close event ws would emit after terminate(). */
|
||||
flushClose() {
|
||||
if (!this.pendingClose) return;
|
||||
this.pendingClose = false;
|
||||
this.emit('close');
|
||||
}
|
||||
|
||||
/** The server hangs up on its own. */
|
||||
serverClose() {
|
||||
this.selfClosed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockSocket,
|
||||
created,
|
||||
/** Sockets still holding a connection right now. */
|
||||
openConnections: () => created.filter((socket) => socket.connectionOpen).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Let the adapter's async message pipeline settle. */
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function setup(options = {}) {
|
||||
const time = fakeClock();
|
||||
const transport = makeMockTransport();
|
||||
const ingested = [];
|
||||
const warnings = [];
|
||||
|
||||
const adapter = createAisStreamAdapter({
|
||||
createSocket: (url) => new transport.MockSocket(url),
|
||||
resolveUrl: () => 'ws://mock.invalid/stream',
|
||||
buildSubscription: () => ({ APIKey: 'redacted' }),
|
||||
ingestEnvelope: (envelope) => {
|
||||
ingested.push(envelope);
|
||||
if (typeof options.ingestResult === 'function') return options.ingestResult(envelope);
|
||||
// The REAL production predicate, not a stand-in — otherwise these tests
|
||||
// would pass against a liveness rule that never ships.
|
||||
return isRecognizedAisEnvelope(envelope);
|
||||
},
|
||||
clock: time.clock,
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
adapter.setWatchdogOptions({ ...BUDGETS, ...options.budgets });
|
||||
|
||||
return { adapter, transport, time, ingested, warnings };
|
||||
}
|
||||
|
||||
/** A well-formed AIS position report. */
|
||||
function aisFrame(mmsi = '123456789') {
|
||||
return JSON.stringify({
|
||||
MessageType: 'PositionReport',
|
||||
MetaData: { MMSI: mmsi, latitude: 1, longitude: 2 },
|
||||
Message: { PositionReport: { Latitude: 1, Longitude: 2 } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Bring the adapter to one live, data-flowing socket. */
|
||||
async function goLive(context) {
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
socket.emit('message', aisFrame());
|
||||
await flush();
|
||||
assert.equal(context.adapter.snapshot().status, 'live');
|
||||
return socket;
|
||||
}
|
||||
|
||||
// --- FINDING 1 [P0]: generation reuse across disposal ----------------------
|
||||
|
||||
test('P0: a pre-disposal close event cannot erase the post-disposal socket', async () => {
|
||||
const context = setup();
|
||||
const first = await goLive(context);
|
||||
|
||||
// Dev server restarts in-process: dispose, re-arm, reconnect.
|
||||
context.adapter.dispose();
|
||||
assert.equal(first.terminated, true, 'dispose hard-aborts the live socket');
|
||||
context.adapter.setWatchdogOptions(BUDGETS);
|
||||
context.adapter.ensure(ENV);
|
||||
|
||||
const second = context.transport.created[1];
|
||||
assert.ok(second, 'a replacement socket was opened');
|
||||
assert.deepEqual(context.adapter.debug().generations, [2],
|
||||
'the replacement must NOT reuse generation 1');
|
||||
|
||||
// The pre-disposal socket's close finally lands — review's exact interleaving.
|
||||
first.flushClose();
|
||||
|
||||
const debug = context.adapter.debug();
|
||||
assert.equal(debug.liveSockets, 1, 'the live socket survives the stale close');
|
||||
assert.deepEqual(debug.generations, [2]);
|
||||
assert.equal(second.terminated, false, 'the replacement was not hung up');
|
||||
assert.equal(context.transport.created.length, 2, 'no third socket was opened');
|
||||
assert.equal(context.transport.openConnections(), 1, 'single-socket invariant holds');
|
||||
});
|
||||
|
||||
test('P0: the same interleaving without an explicit re-arm is equally safe', async () => {
|
||||
const context = setup();
|
||||
const first = await goLive(context);
|
||||
|
||||
context.adapter.dispose();
|
||||
context.adapter.ensure(ENV); // no setWatchdogOptions this time
|
||||
const second = context.transport.created[1];
|
||||
|
||||
first.flushClose();
|
||||
|
||||
assert.equal(context.adapter.debug().liveSockets, 1);
|
||||
assert.equal(second.terminated, false);
|
||||
assert.equal(context.transport.openConnections(), 1);
|
||||
});
|
||||
|
||||
test('P0: the generation namespace never repeats across many disposals', async () => {
|
||||
const context = setup();
|
||||
const seen = new Set();
|
||||
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
context.adapter.ensure(ENV);
|
||||
for (const generation of context.adapter.debug().generations) {
|
||||
assert.equal(seen.has(generation), false, `generation ${generation} was reused`);
|
||||
seen.add(generation);
|
||||
}
|
||||
context.adapter.dispose();
|
||||
context.adapter.setWatchdogOptions(BUDGETS);
|
||||
}
|
||||
assert.equal(seen.size, 6);
|
||||
});
|
||||
|
||||
test('P0: at most one connection is ever open across a full failure cycle', async () => {
|
||||
const context = setup();
|
||||
await goLive(context);
|
||||
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
assert.ok(context.transport.openConnections() <= 1,
|
||||
`round ${round}: ${context.transport.openConnections()} sockets open`);
|
||||
|
||||
// Silence trips the recycle, which terminates before any reconnect.
|
||||
context.time.advance(BUDGETS.recycleAfterMs + 1);
|
||||
context.adapter.ensure(ENV);
|
||||
assert.ok(context.transport.openConnections() <= 1, 'terminate precedes reconnect');
|
||||
|
||||
// The terminated socket's close lands only later — the ownership window.
|
||||
context.time.advance(BUDGETS.backoffMs[0] + 1);
|
||||
context.adapter.ensure(ENV);
|
||||
for (const socket of context.transport.created) socket.flushClose();
|
||||
assert.ok(context.transport.openConnections() <= 1, 'late closes open nothing extra');
|
||||
}
|
||||
});
|
||||
|
||||
// --- FINDING 2 [P1]: liveness credit and orphan frames --------------------
|
||||
|
||||
test('an orphan frame is dropped entirely — no ingestion, no liveness', async () => {
|
||||
const context = setup();
|
||||
const first = await goLive(context);
|
||||
|
||||
context.time.advance(BUDGETS.recycleAfterMs + 1);
|
||||
context.adapter.ensure(ENV); // terminates gen 1
|
||||
assert.equal(first.terminated, true);
|
||||
const ingestedBefore = context.ingested.length;
|
||||
const statusBefore = context.adapter.snapshot().status;
|
||||
|
||||
// The dead socket delivers a perfectly valid frame.
|
||||
first.emit('message', aisFrame('999999999'));
|
||||
await flush();
|
||||
|
||||
assert.equal(context.ingested.length, ingestedBefore, 'orphan data must not be ingested');
|
||||
assert.equal(context.adapter.snapshot().status, statusBefore, 'orphan data must not grant liveness');
|
||||
});
|
||||
|
||||
test('a malformed frame is not liveness and is never ingested', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', 'not json at all {{{');
|
||||
await flush();
|
||||
|
||||
assert.equal(context.ingested.length, 0);
|
||||
assert.equal(context.adapter.snapshot().status, 'connecting', 'garbage is not a working feed');
|
||||
assert.equal(context.adapter.snapshot().lastMessageAt, null);
|
||||
});
|
||||
|
||||
test('a valid JSON frame carrying no AIS record is not liveness', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', JSON.stringify({ MessageType: 'Heartbeat' }));
|
||||
await flush();
|
||||
|
||||
assert.equal(context.ingested.length, 1, 'it reached the ingester');
|
||||
assert.equal(context.adapter.snapshot().status, 'connecting', 'but proved nothing');
|
||||
});
|
||||
|
||||
test('an MMSI-bearing envelope with no recognised message is NOT liveness', async () => {
|
||||
// The shape a synthetic or corrupt frame takes: an MMSI and nothing else.
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', JSON.stringify({ MetaData: { MMSI: '123456789' } }));
|
||||
socket.emit('message', JSON.stringify({
|
||||
MessageType: 'NotARealType',
|
||||
MetaData: { MMSI: '123456789' },
|
||||
Message: { NotARealType: { Latitude: 1 } },
|
||||
}));
|
||||
socket.emit('message', JSON.stringify({
|
||||
MessageType: 'PositionReport', // recognised type, but no body filed under it
|
||||
MetaData: { MMSI: '123456789' },
|
||||
}));
|
||||
await flush();
|
||||
|
||||
assert.equal(
|
||||
context.adapter.snapshot().status, 'connecting',
|
||||
'an MMSI alone must never stand in for a working feed',
|
||||
);
|
||||
assert.equal(context.adapter.snapshot().lastMessageAt, null);
|
||||
});
|
||||
|
||||
test('every message type in AISStream\'s own enum counts as liveness', () => {
|
||||
// The complete AisMessageTypes enum from AISStream's type-definition.yaml.
|
||||
// A type missing from the allowlist would make a subscription filtered to it
|
||||
// read as permanently dead, so each one is pinned individually.
|
||||
const officialTypes = [
|
||||
'PositionReport', 'UnknownMessage', 'AddressedSafetyMessage',
|
||||
'AddressedBinaryMessage', 'AidsToNavigationReport', 'AssignedModeCommand',
|
||||
'BaseStationReport', 'BinaryAcknowledge', 'BinaryBroadcastMessage',
|
||||
'ChannelManagement', 'CoordinatedUTCInquiry', 'DataLinkManagementMessage',
|
||||
'DataLinkManagementMessageData', 'ExtendedClassBPositionReport',
|
||||
'GroupAssignmentCommand', 'GnssBroadcastBinaryMessage', 'Interrogation',
|
||||
'LongRangeAisBroadcastMessage', 'MultiSlotBinaryMessage',
|
||||
'SafetyBroadcastMessage', 'ShipStaticData', 'SingleSlotBinaryMessage',
|
||||
'StandardClassBPositionReport', 'StandardSearchAndRescueAircraftReport',
|
||||
'StaticDataReport',
|
||||
];
|
||||
assert.equal(officialTypes.length, 25);
|
||||
|
||||
for (const messageType of officialTypes) {
|
||||
assert.equal(
|
||||
isRecognizedAisEnvelope({
|
||||
MessageType: messageType,
|
||||
MetaData: { MMSI: '123456789' },
|
||||
Message: { [messageType]: { UserID: 123456789 } },
|
||||
}),
|
||||
true,
|
||||
`${messageType} must count as liveness`,
|
||||
);
|
||||
}
|
||||
|
||||
// The allowlist and the enum must not drift apart in either direction.
|
||||
assert.deepEqual(
|
||||
[...AIS_RECOGNIZED_MESSAGE_TYPES].sort(),
|
||||
[...officialTypes].sort(),
|
||||
'allowlist has drifted from the official enum',
|
||||
);
|
||||
});
|
||||
|
||||
test('the previously-omitted types are live end to end, not just in the predicate', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', JSON.stringify({
|
||||
MessageType: 'AddressedBinaryMessage',
|
||||
MetaData: { MMSI: '987654321' },
|
||||
Message: { AddressedBinaryMessage: { UserID: 987654321 } },
|
||||
}));
|
||||
await flush();
|
||||
|
||||
assert.equal(
|
||||
context.adapter.snapshot().status, 'live',
|
||||
'a subscription filtered to this type must not read as dead',
|
||||
);
|
||||
});
|
||||
|
||||
test('the shared recognition predicate is what production actually ships', () => {
|
||||
assert.equal(isRecognizedAisEnvelope(JSON.parse(aisFrame())), true);
|
||||
assert.equal(isRecognizedAisEnvelope({ MetaData: { MMSI: '1' } }), false);
|
||||
assert.equal(isRecognizedAisEnvelope({ MessageType: 'PositionReport' }), false);
|
||||
assert.equal(isRecognizedAisEnvelope({
|
||||
MessageType: 'PositionReport',
|
||||
Message: { PositionReport: {} },
|
||||
}), false, 'no MMSI');
|
||||
assert.equal(isRecognizedAisEnvelope({
|
||||
MessageType: 'Nonsense',
|
||||
MetaData: { MMSI: '1' },
|
||||
Message: { Nonsense: {} },
|
||||
}), false, 'unrecognised type');
|
||||
// Static data has no position but is still real feed traffic.
|
||||
assert.equal(isRecognizedAisEnvelope({
|
||||
MessageType: 'ShipStaticData',
|
||||
MetaData: { MMSI: '1' },
|
||||
Message: { ShipStaticData: { Destination: 'X' } },
|
||||
}), true);
|
||||
assert.equal(isRecognizedAisEnvelope(null), false);
|
||||
assert.equal(isRecognizedAisEnvelope([]), false);
|
||||
});
|
||||
|
||||
test('an error envelope is never liveness and never resets the ladder', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', JSON.stringify({ error: 'temporary upstream glitch' }));
|
||||
await flush();
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.notEqual(snap.status, 'live', 'an error frame must never read as a working feed');
|
||||
assert.equal(snap.lastMessageAt, null, 'an error frame is not a message');
|
||||
assert.equal(context.ingested.length, 0);
|
||||
});
|
||||
|
||||
test('liveness is granted only once the frame decodes into a real AIS record', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
assert.equal(context.adapter.snapshot().status, 'connecting');
|
||||
|
||||
socket.emit('message', Buffer.from(aisFrame())); // ws delivers Buffers
|
||||
await flush();
|
||||
|
||||
assert.equal(context.adapter.snapshot().status, 'live');
|
||||
assert.equal(context.adapter.snapshot().lastMessageAt, context.time.wall);
|
||||
});
|
||||
|
||||
test('an orphan open never subscribes on a socket the adapter gave up on', async () => {
|
||||
const context = setup();
|
||||
const first = await goLive(context);
|
||||
|
||||
context.time.advance(BUDGETS.recycleAfterMs + 1);
|
||||
context.adapter.ensure(ENV);
|
||||
const sentBefore = first.sent.length;
|
||||
|
||||
first.emit('open'); // late handshake on the dead socket
|
||||
assert.equal(first.sent.length, sentBefore, 'an orphan must never be subscribed');
|
||||
});
|
||||
|
||||
test('an owned open subscribes exactly once', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
assert.equal(socket.sent.length, 1);
|
||||
assert.deepEqual(JSON.parse(socket.sent[0]), { APIKey: 'redacted' });
|
||||
});
|
||||
|
||||
// --- FINDINGS 3 & 4 [P1]: auth and rate-limit classification --------------
|
||||
|
||||
test('an auth error envelope goes terminal instead of into the fast ladder', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', JSON.stringify({ error: 'Invalid API key' }));
|
||||
await flush();
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed');
|
||||
assert.equal(socket.terminated, true, 'the rejected socket is hung up');
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - context.time.wall, BUDGETS.authProbeMs,
|
||||
'an hour, not five seconds',
|
||||
);
|
||||
});
|
||||
|
||||
test('repeated auth envelopes never accelerate — the 5s-forever loop is closed', async () => {
|
||||
const context = setup();
|
||||
let connects = 0;
|
||||
|
||||
// An hour of one-second ticking against a key that is always refused.
|
||||
for (let second = 0; second <= 3600; second += 1) {
|
||||
const before = context.transport.created.length;
|
||||
context.adapter.ensure(ENV);
|
||||
for (let i = before; i < context.transport.created.length; i += 1) {
|
||||
connects += 1;
|
||||
const socket = context.transport.created[i];
|
||||
socket.emit('open');
|
||||
socket.emit('message', JSON.stringify({ error: 'Invalid API key' }));
|
||||
await flush();
|
||||
}
|
||||
context.time.advance(1_000);
|
||||
}
|
||||
|
||||
assert.ok(connects <= 2, `auth-rejected key produced ${connects} connections in an hour`);
|
||||
assert.equal(context.adapter.snapshot().status, 'auth-failed');
|
||||
});
|
||||
|
||||
test('an auth envelope followed by a same-tick close is still classified as auth', () => {
|
||||
// Worst-case ordering: the peer sends the rejection and hangs up in the same
|
||||
// breath. If the pipeline suspended mid-frame, the close handler would run
|
||||
// first, schedule the 5s transport rung, and the frame would then be dropped
|
||||
// as an orphan — losing the rejection entirely.
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', Buffer.from(JSON.stringify({ error: 'Invalid API key' })));
|
||||
socket.serverClose(); // same tick, no awaits in between
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed', 'the rejection must survive the race');
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - context.time.wall, BUDGETS.authProbeMs,
|
||||
'and must not fall back to the fast ladder',
|
||||
);
|
||||
});
|
||||
|
||||
test('the sync decode path covers every shape ws delivers', () => {
|
||||
assert.equal(decodeAisFrameSync('text'), 'text');
|
||||
assert.equal(decodeAisFrameSync(Buffer.from('buf')), 'buf');
|
||||
assert.equal(decodeAisFrameSync(new TextEncoder().encode('typed').buffer), 'typed');
|
||||
assert.equal(decodeAisFrameSync([Buffer.from('a'), Buffer.from('b')]), 'ab');
|
||||
// Only the Blob shape needs the async fallback.
|
||||
assert.equal(decodeAisFrameSync({ text: async () => 'blob' }), null);
|
||||
});
|
||||
|
||||
test('a Blob frame still works through the async fallback', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', { text: async () => aisFrame() });
|
||||
await flush();
|
||||
|
||||
assert.equal(context.adapter.snapshot().status, 'live');
|
||||
});
|
||||
|
||||
test('an HTTP 401 upgrade failure is classified as auth, not a transport fault', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
|
||||
socket.emit('unexpected-response', {}, { statusCode: 401, headers: {} });
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed');
|
||||
assert.match(snap.error, /HTTP 401/);
|
||||
assert.equal(snap.nextAttemptAt - context.time.wall, BUDGETS.authProbeMs);
|
||||
});
|
||||
|
||||
test('an HTTP 403 upgrade failure is also terminal', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
context.transport.created[0].emit('unexpected-response', {}, { statusCode: 403, headers: {} });
|
||||
assert.equal(context.adapter.snapshot().status, 'auth-failed');
|
||||
});
|
||||
|
||||
test('an HTTP 429 honours Retry-After', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
context.transport.created[0].emit('unexpected-response', {}, {
|
||||
statusCode: 429,
|
||||
headers: { 'retry-after': '120' },
|
||||
});
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.equal(snap.status, 'reconnecting');
|
||||
assert.equal(snap.nextAttemptAt - context.time.wall, 120_000);
|
||||
});
|
||||
|
||||
test('an HTTP 429 without Retry-After falls back to the slowest rung', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
context.transport.created[0].emit('unexpected-response', {}, { statusCode: 429, headers: {} });
|
||||
|
||||
const slowest = BUDGETS.backoffMs[BUDGETS.backoffMs.length - 1];
|
||||
assert.equal(context.adapter.snapshot().nextAttemptAt - context.time.wall, slowest);
|
||||
});
|
||||
|
||||
test('an error on an abandoned socket hangs it up instead of leaking the slot', async () => {
|
||||
const context = setup();
|
||||
const first = await goLive(context);
|
||||
|
||||
context.time.advance(BUDGETS.recycleAfterMs + 1);
|
||||
context.adapter.ensure(ENV); // terminates gen 1
|
||||
first.terminated = false; // pretend the abort did not take, to isolate the guard
|
||||
|
||||
first.emit('error', new Error('late failure on a dead socket'));
|
||||
assert.equal(first.terminated, true, 'the orphan was hung up by the error handler');
|
||||
assert.equal(context.transport.openConnections(), 0);
|
||||
});
|
||||
|
||||
test('a genuine transport error still uses the fast first rung', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
context.transport.created[0].emit('error', new Error('ECONNRESET'));
|
||||
|
||||
const snap = context.adapter.snapshot();
|
||||
assert.equal(snap.status, 'reconnecting');
|
||||
assert.equal(snap.nextAttemptAt - context.time.wall, BUDGETS.backoffMs[0]);
|
||||
});
|
||||
|
||||
// --- Pure classification helpers ------------------------------------------
|
||||
|
||||
test('failure classification separates auth, rate limits and transport faults', () => {
|
||||
assert.equal(classifyAisFailure({ httpStatus: 401 }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ httpStatus: 403 }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ httpStatus: 429 }).kind, 'rate-limit');
|
||||
assert.equal(classifyAisFailure({ httpStatus: 500 }).kind, 'transport');
|
||||
// ws's own wording when nothing consumed 'unexpected-response'.
|
||||
assert.equal(classifyAisFailure({ message: 'Unexpected server response: 401' }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ message: 'Unexpected server response: 429' }).kind, 'rate-limit');
|
||||
// Upstream envelope wording.
|
||||
assert.equal(classifyAisFailure({ message: 'Invalid API key' }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ message: 'unauthorized' }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ message: 'API key is required' }).kind, 'auth');
|
||||
assert.equal(classifyAisFailure({ message: 'rate limit exceeded' }).kind, 'rate-limit');
|
||||
assert.equal(classifyAisFailure({ message: 'too many connections' }).kind, 'rate-limit');
|
||||
assert.equal(classifyAisFailure({ message: 'ECONNRESET' }).kind, 'transport');
|
||||
assert.equal(classifyAisFailure({}).kind, 'transport');
|
||||
});
|
||||
|
||||
test('Retry-After accepts delta-seconds and HTTP-dates', () => {
|
||||
const now = Date.parse('2026-08-18T12:00:00Z');
|
||||
assert.equal(parseRetryAfterMs('30'), 30_000);
|
||||
assert.equal(parseRetryAfterMs(' 45 '), 45_000);
|
||||
assert.equal(parseRetryAfterMs('Tue, 18 Aug 2026 12:02:00 GMT', now), 120_000);
|
||||
assert.equal(parseRetryAfterMs(undefined), 0);
|
||||
assert.equal(parseRetryAfterMs(''), 0);
|
||||
assert.equal(parseRetryAfterMs('soon'), 0);
|
||||
});
|
||||
|
||||
test('frame decoding covers the string, Buffer, ArrayBuffer and fragment shapes', async () => {
|
||||
assert.equal(await decodeAisFrame('plain'), 'plain');
|
||||
assert.equal(await decodeAisFrame(Buffer.from('buffered')), 'buffered');
|
||||
assert.equal(await decodeAisFrame(new TextEncoder().encode('typed').buffer), 'typed');
|
||||
assert.equal(await decodeAisFrame([Buffer.from('frag'), Buffer.from('ments')]), 'fragments');
|
||||
});
|
||||
|
||||
test('envelope parsing separates malformed, error and data frames', () => {
|
||||
assert.equal(parseAisEnvelope('nope').kind, 'malformed');
|
||||
assert.equal(parseAisEnvelope('[]').kind, 'malformed');
|
||||
assert.equal(parseAisEnvelope('null').kind, 'malformed');
|
||||
assert.deepEqual(parseAisEnvelope('{"error":"bad key"}'), { kind: 'error', message: 'bad key' });
|
||||
assert.equal(parseAisEnvelope(aisFrame()).kind, 'data');
|
||||
});
|
||||
|
||||
// --- Lifecycle -------------------------------------------------------------
|
||||
|
||||
test('a server-initiated close reconnects on the ladder without leaking sockets', async () => {
|
||||
const context = setup();
|
||||
const socket = await goLive(context);
|
||||
|
||||
socket.serverClose();
|
||||
assert.equal(context.adapter.debug().liveSockets, 0, 'the closed socket is off the books');
|
||||
assert.equal(context.adapter.snapshot().status, 'reconnecting');
|
||||
|
||||
context.time.advance(BUDGETS.backoffMs[0] + 1);
|
||||
context.adapter.ensure(ENV);
|
||||
assert.equal(context.transport.openConnections(), 1);
|
||||
});
|
||||
|
||||
test('a keyless environment opens nothing and terminates anything held', async () => {
|
||||
const context = setup();
|
||||
const socket = await goLive(context);
|
||||
|
||||
context.adapter.ensure({ hasKey: false });
|
||||
assert.equal(socket.terminated, true);
|
||||
assert.equal(context.adapter.snapshot().status, 'missing-key');
|
||||
|
||||
context.time.advance(600_000);
|
||||
context.adapter.ensure({ hasKey: false });
|
||||
assert.equal(context.transport.created.length, 1, 'no sockets while keyless');
|
||||
});
|
||||
|
||||
test('a socket without ws emitter semantics is rejected, not silently mis-read', () => {
|
||||
const time = fakeClock();
|
||||
const adapter = createAisStreamAdapter({
|
||||
// A built-in-WebSocket-shaped object: addEventListener only, no terminate.
|
||||
createSocket: () => ({ addEventListener() {}, close() {} }),
|
||||
resolveUrl: () => 'ws://mock.invalid/stream',
|
||||
buildSubscription: () => ({}),
|
||||
ingestEnvelope: () => false,
|
||||
clock: time.clock,
|
||||
});
|
||||
adapter.setWatchdogOptions(BUDGETS);
|
||||
|
||||
adapter.ensure(ENV);
|
||||
assert.equal(adapter.snapshot().status, 'reconnecting');
|
||||
assert.match(adapter.snapshot().error, /ws emitter semantics/);
|
||||
assert.equal(adapter.debug().liveSockets, 0, 'the unusable socket is not retained');
|
||||
});
|
||||
|
||||
test('a socket factory failure is classified rather than thrown', () => {
|
||||
const time = fakeClock();
|
||||
const adapter = createAisStreamAdapter({
|
||||
createSocket: () => { throw new Error('ws transport unavailable'); },
|
||||
resolveUrl: () => 'ws://mock.invalid/stream',
|
||||
buildSubscription: () => ({}),
|
||||
ingestEnvelope: () => false,
|
||||
clock: time.clock,
|
||||
});
|
||||
adapter.setWatchdogOptions(BUDGETS);
|
||||
|
||||
assert.doesNotThrow(() => adapter.ensure(ENV));
|
||||
assert.equal(adapter.snapshot().status, 'reconnecting');
|
||||
});
|
||||
|
||||
test('an oversized frame is dropped before it is ever decoded', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', Buffer.alloc(AIS_MAX_FRAME_BYTES + 1));
|
||||
await flush();
|
||||
|
||||
assert.equal(context.ingested.length, 0, 'never decoded, never ingested');
|
||||
assert.equal(context.adapter.snapshot().status, 'connecting', 'and never liveness');
|
||||
assert.equal(context.warnings.length, 1);
|
||||
assert.match(context.warnings[0], /oversized frame/);
|
||||
|
||||
// A normal frame on the same socket still works.
|
||||
socket.emit('message', aisFrame());
|
||||
await flush();
|
||||
assert.equal(context.adapter.snapshot().status, 'live');
|
||||
});
|
||||
|
||||
test('frame sizing covers every shape without decoding', () => {
|
||||
assert.equal(aisFrameByteLength('abcd'), 4);
|
||||
assert.equal(aisFrameByteLength(Buffer.alloc(11)), 11);
|
||||
assert.equal(aisFrameByteLength(new ArrayBuffer(7)), 7);
|
||||
assert.equal(aisFrameByteLength([Buffer.alloc(3), Buffer.alloc(4)]), 7);
|
||||
assert.equal(aisFrameByteLength({ text: async () => '' }), 0, 'Blob defers to the async path');
|
||||
});
|
||||
|
||||
test('a frame whose decode throws is treated as malformed, not fatal', async () => {
|
||||
const context = setup();
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
// A view whose backing buffer is detached: .byteLength reads fine, the
|
||||
// decode throws. The handler must absorb it rather than let it escape the
|
||||
// EventEmitter and reach the process-level handler.
|
||||
const view = new Uint8Array(new ArrayBuffer(8));
|
||||
Object.defineProperty(view, 'buffer', {
|
||||
get() { throw new TypeError('detached ArrayBuffer'); },
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => socket.emit('message', view));
|
||||
await flush();
|
||||
|
||||
assert.equal(context.warnings.length, 1);
|
||||
assert.match(context.warnings[0], /message handling failed/);
|
||||
assert.equal(context.adapter.snapshot().status, 'connecting', 'no liveness from a bad frame');
|
||||
|
||||
socket.emit('message', aisFrame());
|
||||
await flush();
|
||||
assert.equal(context.adapter.snapshot().status, 'live', 'the socket still works afterwards');
|
||||
});
|
||||
|
||||
test('an ingester that throws does not take the process down', async () => {
|
||||
const context = setup({ ingestResult: () => { throw new Error('boom'); } });
|
||||
context.adapter.ensure(ENV);
|
||||
const socket = context.transport.created[0];
|
||||
socket.emit('open');
|
||||
|
||||
socket.emit('message', aisFrame());
|
||||
await flush();
|
||||
|
||||
assert.equal(context.warnings.length, 1, 'the failure was reported, not fatal');
|
||||
assert.match(context.warnings[0], /message handling failed/);
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
// Pure state machine for the AISStream live-feed watchdog.
|
||||
//
|
||||
// Liveness is judged by DATA, not by socket state. AISStream can complete the
|
||||
// websocket handshake and then deliver nothing at all — no frames, no error,
|
||||
// no close — so `readyState === OPEN` proves only that a handshake once
|
||||
// succeeded. Every health verdict here is derived from when a valid AIS
|
||||
// message last arrived. A handshake, a malformed frame, and an error envelope
|
||||
// are all explicitly NOT liveness.
|
||||
//
|
||||
// Two budgets, deliberately decoupled, so the feed can be reported honestly
|
||||
// without thrashing the single connection AISStream allows per key:
|
||||
//
|
||||
// staleMs when to TELL the user the feed stopped delivering (fast)
|
||||
// recycleAfterMs when to actually hard-abort and reconnect (slow)
|
||||
//
|
||||
// Failures are CLASSIFIED, because "retry harder" is the wrong answer to most
|
||||
// of them. Only genuine transport faults walk the backoff ladder; an auth
|
||||
// rejection is terminal until the key changes, and a rate limit honours the
|
||||
// server's own Retry-After. Steady-state failure must cost single-digit
|
||||
// connection attempts per hour in every class.
|
||||
//
|
||||
// The machine owns no sockets, no timers and no environment. It returns
|
||||
// ACTIONS for a transport adapter to perform, which keeps the entire policy
|
||||
// exercisable by the offline node:test suite.
|
||||
//
|
||||
// The only teardown action is 'terminate' — never 'close'. Node's built-in
|
||||
// WebSocket has no terminate(), and its close() parks a black-holed socket in
|
||||
// CLOSING indefinitely (measured: no close event after 30s, TCP slot never
|
||||
// released), which is exactly how the reverted watchdog wedged. The adapter
|
||||
// binds 'terminate' to ws.terminate(), which destroys the underlying socket.
|
||||
//
|
||||
// Durations are measured on a MONOTONIC clock. Wall time is used only for
|
||||
// display timestamps, so a system clock rollback cannot suppress staleness.
|
||||
|
||||
/** Default watchdog budgets. */
|
||||
export const AIS_WATCHDOG_DEFAULTS = Object.freeze({
|
||||
/** Silence after which the feed is REPORTED stale. */
|
||||
staleMs: 120_000,
|
||||
/** Silence after which the socket is actually recycled. */
|
||||
recycleAfterMs: 300_000,
|
||||
/** Backoff ladder; its length is also the attempt budget before 'down'. */
|
||||
backoffMs: Object.freeze([5_000, 15_000, 60_000, 300_000]),
|
||||
/** Slow retry cadence once the ladder is exhausted. */
|
||||
downRetryMs: 900_000,
|
||||
/** Probe cadence while the key is being rejected. Deliberately very slow. */
|
||||
authProbeMs: 3_600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Statuses reported to the browser. 'missing-key' and 'unsupported' keep their
|
||||
* pre-watchdog meaning so a keyless install still reads as "feature off".
|
||||
*/
|
||||
export const AIS_WATCHDOG_STATUSES = Object.freeze([
|
||||
'idle',
|
||||
'missing-key',
|
||||
'unsupported',
|
||||
'connecting',
|
||||
'live',
|
||||
'stale',
|
||||
'reconnecting',
|
||||
'down',
|
||||
'auth-failed',
|
||||
]);
|
||||
|
||||
/** Failure classes. Only 'transport' is allowed to walk the fast ladder. */
|
||||
export const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']);
|
||||
|
||||
/** Statuses that must not flip back to a hopeful 'connecting' on a retry. */
|
||||
const QUIET_TERMINAL = new Set(['down', 'auth-failed']);
|
||||
|
||||
/** Statuses in which fresh data is genuinely flowing. */
|
||||
export function isLiveAisStatus(status) {
|
||||
return status === 'live';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly interpret an AISSTREAM_SILENCE_TIMEOUT_MS-style override.
|
||||
*
|
||||
* Deliberately not `Number(raw)`: an exported-but-empty or whitespace value
|
||||
* coerces to 0, which would silently disable the watchdog — a failure mode
|
||||
* that hides itself, because the feature it disables is the one that reports
|
||||
* failure. Only a literal 0 is the kill switch; anything unparseable falls
|
||||
* back to the default and says so once.
|
||||
*
|
||||
* @param {string|number|undefined|null} raw
|
||||
* @param {(message: string) => void} [warn]
|
||||
* @returns {{kind: 'default'|'off'|'timeout', value?: number}}
|
||||
*/
|
||||
export function parseSilenceTimeoutEnv(raw, warn) {
|
||||
if (raw === undefined || raw === null) return { kind: 'default' };
|
||||
const text = String(raw).trim();
|
||||
if (!text) return { kind: 'default' };
|
||||
if (!/^\d+(\.\d+)?$/.test(text)) {
|
||||
warn?.(
|
||||
`[AISStream] Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="${raw}" (not a non-negative number); `
|
||||
+ `using the ${AIS_WATCHDOG_DEFAULTS.staleMs}ms default.`,
|
||||
);
|
||||
return { kind: 'default' };
|
||||
}
|
||||
const value = Number(text);
|
||||
if (!Number.isFinite(value)) return { kind: 'default' };
|
||||
if (value === 0) return { kind: 'off' };
|
||||
return { kind: 'timeout', value };
|
||||
}
|
||||
|
||||
const DEFAULT_CLOCK = Object.freeze({
|
||||
wall: () => Date.now(),
|
||||
mono: () => performance.now(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an AISStream watchdog state machine.
|
||||
*
|
||||
* The caller must invoke `configure()` before the first `tick()` so the
|
||||
* machine knows whether a key and a websocket transport exist.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.staleMs]
|
||||
* @param {number} [options.recycleAfterMs]
|
||||
* @param {number[]} [options.backoffMs]
|
||||
* @param {number} [options.downRetryMs]
|
||||
* @param {number} [options.authProbeMs]
|
||||
* @param {number} [options.startGeneration] Seed for the socket-generation
|
||||
* counter. MUST be the caller's module-lifetime high-water mark so that a
|
||||
* disposal never re-issues a generation a late handler still refers to.
|
||||
* @param {{wall: function, mono: function}} [options.clock]
|
||||
* @returns {Object} watchdog handle
|
||||
*/
|
||||
export function createAisWatchdog(options = {}) {
|
||||
const staleMs = positiveOr(options.staleMs, AIS_WATCHDOG_DEFAULTS.staleMs);
|
||||
const recycleAfterMs = Math.max(
|
||||
staleMs,
|
||||
positiveOr(options.recycleAfterMs, AIS_WATCHDOG_DEFAULTS.recycleAfterMs),
|
||||
);
|
||||
const backoffMs = Array.isArray(options.backoffMs) && options.backoffMs.length
|
||||
? options.backoffMs.map((ms) => positiveOr(ms, 5_000))
|
||||
: [...AIS_WATCHDOG_DEFAULTS.backoffMs];
|
||||
const downRetryMs = positiveOr(options.downRetryMs, AIS_WATCHDOG_DEFAULTS.downRetryMs);
|
||||
const authProbeMs = positiveOr(options.authProbeMs, AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
const clock = options.clock || DEFAULT_CLOCK;
|
||||
|
||||
let status = 'idle';
|
||||
/**
|
||||
* Monotonic id for every socket the adapter is told to open. Seeded from the
|
||||
* caller's high-water mark and never reset — a recycled generation would let
|
||||
* a pre-disposal handler act on its successor's socket.
|
||||
*/
|
||||
let generation = Math.max(0, Number(options.startGeneration) || 0);
|
||||
/** Generation of the socket the machine currently owns; null = slot free. */
|
||||
let owned = null;
|
||||
/** Monotonic ms at which the current silence window began (connect or data). */
|
||||
let silenceSinceMono = 0;
|
||||
/** Last DATA message. 0 = never — a handshake does not count. */
|
||||
let lastMessageWall = 0;
|
||||
/** Consecutive failed sessions since data last flowed. */
|
||||
let reconnectAttempt = 0;
|
||||
/** Monotonic deadline before which no new connect may be issued. */
|
||||
let nextAttemptMono = 0;
|
||||
let error = null;
|
||||
/** False for custom subscriptions, where silence can be legitimate. */
|
||||
let silenceWatchArmed = true;
|
||||
/** Identifies the credential in use, so a key change can clear auth-failed. */
|
||||
let keyFingerprint = null;
|
||||
|
||||
function release() {
|
||||
owned = null;
|
||||
}
|
||||
|
||||
/** True once the backoff ladder has been spent without any data. */
|
||||
function isExhausted() {
|
||||
return reconnectAttempt > backoffMs.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a failed session and pick the next attempt time, BY FAILURE CLASS.
|
||||
* @param {string} kind 'transport' | 'auth' | 'rate-limit'
|
||||
* @param {number} [retryAfterMs] Server-supplied delay (429 Retry-After).
|
||||
*/
|
||||
function scheduleRetry(kind, retryAfterMs) {
|
||||
const monoNow = clock.mono();
|
||||
// While the key is being refused, NO outcome of a probe may return the
|
||||
// feed to the fast ladder — not a close, not a transport error, not
|
||||
// silence. A probe that dies for any reason still leaves the credential
|
||||
// unproven, and hammering on an unproven key is the behaviour this whole
|
||||
// state exists to prevent. Only valid data or a key rotation leaves
|
||||
// auth-failed.
|
||||
const effective = status === 'auth-failed' && kind !== 'auth' ? 'auth' : kind;
|
||||
reconnectAttempt += 1;
|
||||
|
||||
if (effective === 'auth') {
|
||||
// The key is being refused. Retrying cannot fix that, so this is
|
||||
// terminal until the credential changes or the server restarts; the
|
||||
// slow probe exists only to recover from an upstream-side mistake.
|
||||
status = 'auth-failed';
|
||||
nextAttemptMono = monoNow + authProbeMs;
|
||||
return;
|
||||
}
|
||||
|
||||
if (effective === 'rate-limit') {
|
||||
// Never re-enter at the fast rungs after being told to slow down.
|
||||
reconnectAttempt = Math.max(reconnectAttempt, backoffMs.length);
|
||||
const wait = positiveOr(retryAfterMs, backoffMs[backoffMs.length - 1]);
|
||||
status = isExhausted() ? 'down' : 'reconnecting';
|
||||
nextAttemptMono = monoNow + (isExhausted() ? Math.max(wait, downRetryMs) : wait);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExhausted()) {
|
||||
status = 'down';
|
||||
nextAttemptMono = monoNow + downRetryMs;
|
||||
return;
|
||||
}
|
||||
status = 'reconnecting';
|
||||
nextAttemptMono = monoNow + backoffMs[reconnectAttempt - 1];
|
||||
}
|
||||
|
||||
/** Hard-abort whatever socket we hold, if any. */
|
||||
function terminateOwned(reason) {
|
||||
if (owned === null) return [];
|
||||
const generationToKill = owned;
|
||||
release();
|
||||
return [{ type: 'terminate', generation: generationToKill, reason }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the environment. Safe to call repeatedly (the adapter calls it on
|
||||
* every request so a key added to .env mid-session is picked up).
|
||||
*
|
||||
* @param {{hasKey: boolean, hasTransport?: boolean, silenceWatch?: boolean,
|
||||
* keyFingerprint?: string|null}} env
|
||||
* @returns {Array<Object>} actions
|
||||
*/
|
||||
function configure(env) {
|
||||
silenceWatchArmed = env.silenceWatch !== false;
|
||||
|
||||
if (!env.hasKey) {
|
||||
const actions = terminateOwned('key-absent');
|
||||
status = 'missing-key';
|
||||
error = 'AISSTREAM_API_KEY is not set';
|
||||
reconnectAttempt = 0;
|
||||
keyFingerprint = null;
|
||||
return actions;
|
||||
}
|
||||
if (env.hasTransport === false) {
|
||||
const actions = terminateOwned('no-transport');
|
||||
status = 'unsupported';
|
||||
error = 'Node WebSocket transport is unavailable';
|
||||
return actions;
|
||||
}
|
||||
|
||||
// A changed credential is the one event that can plausibly fix an auth
|
||||
// rejection, so it clears the terminal state immediately.
|
||||
const nextFingerprint = env.keyFingerprint ?? null;
|
||||
const credentialChanged = keyFingerprint !== null && nextFingerprint !== keyFingerprint;
|
||||
keyFingerprint = nextFingerprint;
|
||||
if (credentialChanged) {
|
||||
// Any socket in flight belongs to the OLD key — it subscribed with that
|
||||
// credential, so its eventual rejection says nothing about the new one.
|
||||
// Terminating it here also releases the generation, which is what makes
|
||||
// every later event from that socket an orphan: without this, an
|
||||
// in-flight probe's rejection would arrive after the rotation and shove
|
||||
// the *new* key straight back into auth-failed for another hour.
|
||||
const actions = terminateOwned('key-rotated');
|
||||
status = 'idle';
|
||||
error = null;
|
||||
reconnectAttempt = 0;
|
||||
nextAttemptMono = clock.mono();
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Key/transport just arrived — clear the terminal state and allow an
|
||||
// immediate attempt rather than waiting out a stale backoff.
|
||||
if (status === 'missing-key' || status === 'unsupported') {
|
||||
status = 'idle';
|
||||
error = null;
|
||||
reconnectAttempt = 0;
|
||||
nextAttemptMono = clock.mono();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance time. Returns the actions the adapter must perform, in order.
|
||||
*
|
||||
* A 'connect' is never returned while a socket is owned, and the machine
|
||||
* releases the slot in the same step it emits a 'terminate' — so a reconnect
|
||||
* can only ever follow a terminate, never race it.
|
||||
*
|
||||
* @returns {Array<Object>} actions
|
||||
*/
|
||||
function tick() {
|
||||
if (status === 'missing-key' || status === 'unsupported') return [];
|
||||
const monoNow = clock.mono();
|
||||
|
||||
if (owned !== null) {
|
||||
// AUTH-FAILED is STICKY against staleness processing. Relabelling the
|
||||
// state to 'stale' at the 120s mark would put the next close, error,
|
||||
// rate limit or recycle OUTSIDE the auth coercion in scheduleRetry —
|
||||
// laundering a refused key onto the fast ladder purely by the passage of
|
||||
// time. While the key is refused the hourly probe cadence is the only
|
||||
// clock that runs; the state leaves auth-failed exclusively via valid
|
||||
// data (onMessage) or a key rotation (configure).
|
||||
if (status === 'auth-failed') {
|
||||
// The probe is still bounded — on the auth cadence, not the silence
|
||||
// budget — so a probe that opens and then says nothing cannot pin the
|
||||
// one-connection-per-key slot indefinitely.
|
||||
if (monoNow - silenceSinceMono >= authProbeMs) {
|
||||
const actions = terminateOwned('auth-probe-expired');
|
||||
scheduleRetry('auth');
|
||||
return actions;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!silenceWatchArmed) return [];
|
||||
const silentFor = monoNow - silenceSinceMono;
|
||||
if (silentFor >= recycleAfterMs) {
|
||||
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
|
||||
const actions = terminateOwned('silent');
|
||||
scheduleRetry('transport');
|
||||
return actions;
|
||||
}
|
||||
if (silentFor >= staleMs) status = 'stale';
|
||||
return [];
|
||||
}
|
||||
|
||||
if (monoNow < nextAttemptMono) return [];
|
||||
generation += 1;
|
||||
owned = generation;
|
||||
// The silence budget starts when the socket is COMMISSIONED, not when it
|
||||
// opens: a handshake that completes late must not buy extra silence.
|
||||
silenceSinceMono = monoNow;
|
||||
// Once DOWN or AUTH-FAILED, the slow retry runs *behind* the reported
|
||||
// state: the chip must not flicker back to a hopeful "connecting". Only
|
||||
// real data clears them.
|
||||
if (!QUIET_TERMINAL.has(status)) status = 'connecting';
|
||||
return [{ type: 'connect', generation }];
|
||||
}
|
||||
|
||||
/** True when an event belongs to the socket we still own. */
|
||||
function ownsGeneration(eventGeneration) {
|
||||
return owned !== null && eventGeneration === owned;
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket finished its handshake.
|
||||
*
|
||||
* Deliberately touches NEITHER the silence clock nor the ladder: a handshake
|
||||
* is not data, and a socket that opens late then goes silent must recycle on
|
||||
* its original schedule. An orphan (a socket we already gave up on, opening
|
||||
* late) is told to hang itself up so it cannot hold the one-connection-per-key
|
||||
* slot.
|
||||
*/
|
||||
function onOpen(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) {
|
||||
return [{ type: 'terminate', generation: eventGeneration, reason: 'orphan' }];
|
||||
}
|
||||
if (!QUIET_TERMINAL.has(status) && status !== 'stale') status = 'connecting';
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A VALID AIS message arrived — the only event that proves the feed works.
|
||||
* The adapter must call this only after the frame has decoded and been
|
||||
* recognised as an AIS payload; malformed frames and error envelopes are
|
||||
* never liveness.
|
||||
*/
|
||||
function onMessage(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) {
|
||||
return [{ type: 'terminate', generation: eventGeneration, reason: 'orphan' }];
|
||||
}
|
||||
const monoNow = clock.mono();
|
||||
silenceSinceMono = monoNow;
|
||||
lastMessageWall = clock.wall();
|
||||
reconnectAttempt = 0;
|
||||
error = null;
|
||||
status = 'live';
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The socket closed on its own. */
|
||||
function onClose(eventGeneration) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
release();
|
||||
if (!error) error = 'AISStream websocket closed';
|
||||
scheduleRetry('transport');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A classified failure. Treated as definitive: terminate now and schedule
|
||||
* by class, rather than waiting out the silence budget for a socket we
|
||||
* already know is bad.
|
||||
*
|
||||
* @param {number} eventGeneration
|
||||
* @param {{kind?: string, message?: string, retryAfterMs?: number}} [detail]
|
||||
*/
|
||||
function onFailure(eventGeneration, detail = {}) {
|
||||
if (!ownsGeneration(eventGeneration)) return [];
|
||||
const kind = AIS_FAILURE_KINDS.includes(detail.kind) ? detail.kind : 'transport';
|
||||
// A probe dying of some unrelated fault while the key is refused keeps the
|
||||
// credential message — surfacing "ECONNRESET" would send the operator
|
||||
// chasing the network instead of the key.
|
||||
if (!(status === 'auth-failed' && kind !== 'auth')) {
|
||||
error = detail.message || defaultFailureMessage(kind);
|
||||
}
|
||||
const actions = terminateOwned(kind);
|
||||
scheduleRetry(kind, detail.retryAfterMs);
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Tear everything down (dev-server restart / plugin close). */
|
||||
function dispose() {
|
||||
const actions = terminateOwned('dispose');
|
||||
status = 'idle';
|
||||
reconnectAttempt = 0;
|
||||
nextAttemptMono = 0;
|
||||
silenceSinceMono = 0;
|
||||
lastMessageWall = 0;
|
||||
error = null;
|
||||
keyFingerprint = null;
|
||||
// `generation` is intentionally NOT reset: a recycled generation would let
|
||||
// a pre-disposal handler act on its successor's socket.
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** Highest generation ever issued — the seed for any replacement machine. */
|
||||
function highWaterGeneration() {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
const silentForMs = owned !== null ? Math.max(0, monoNow - silenceSinceMono) : null;
|
||||
const waitingMs = Math.max(0, nextAttemptMono - monoNow);
|
||||
return {
|
||||
status,
|
||||
error,
|
||||
lastMessageAt: lastMessageWall || null,
|
||||
silentForMs,
|
||||
reconnectAttempt,
|
||||
// Projected onto the wall clock purely for display.
|
||||
nextAttemptAt: (status === 'reconnecting' || status === 'down' || status === 'auth-failed')
|
||||
? wallNow + waitingMs
|
||||
: null,
|
||||
watchdog: silenceWatchArmed ? 'armed' : 'custom-subscription-off',
|
||||
staleAfterMs: staleMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test/diagnostic view of internal ownership. */
|
||||
function debugState() {
|
||||
return { status, owned, generation, reconnectAttempt, lastMessageAt: lastMessageWall };
|
||||
}
|
||||
|
||||
return {
|
||||
configure,
|
||||
tick,
|
||||
onOpen,
|
||||
onMessage,
|
||||
onClose,
|
||||
onFailure,
|
||||
dispose,
|
||||
snapshot,
|
||||
debugState,
|
||||
highWaterGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultFailureMessage(kind) {
|
||||
if (kind === 'auth') return 'AISStream rejected the API key';
|
||||
if (kind === 'rate-limit') return 'AISStream rate-limited this key';
|
||||
return 'AISStream websocket error';
|
||||
}
|
||||
|
||||
function positiveOr(value, fallback) {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
// AISStream watchdog state machine. Both clocks are injected and sockets are
|
||||
// fakes, so every policy branch runs offline with no timers and no network.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
AIS_WATCHDOG_DEFAULTS,
|
||||
createAisWatchdog,
|
||||
isLiveAisStatus,
|
||||
parseSilenceTimeoutEnv,
|
||||
} from './aisWatchdog.js';
|
||||
|
||||
const LIVE_ENV = { hasKey: true, hasTransport: true, keyFingerprint: 'key-a' };
|
||||
|
||||
/**
|
||||
* Separately controllable wall and monotonic clocks. `advance` moves both, as
|
||||
* real time does; `rollbackWall` moves only the wall clock, which is what an
|
||||
* NTP correction or a manual clock change looks like.
|
||||
*/
|
||||
function fakeClock(startWall = 1_700_000_000_000, startMono = 10_000) {
|
||||
let wall = startWall;
|
||||
let mono = startMono;
|
||||
return {
|
||||
clock: { wall: () => wall, mono: () => mono },
|
||||
advance(ms) { wall += ms; mono += ms; },
|
||||
rollbackWall(ms) { wall -= ms; },
|
||||
get wall() { return wall; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a watchdog, recording every action in order and asserting the
|
||||
* single-socket invariant on each connect.
|
||||
*/
|
||||
function harness(options = {}) {
|
||||
const time = fakeClock();
|
||||
const watchdog = createAisWatchdog({ ...options, clock: time.clock });
|
||||
const actions = [];
|
||||
const openSockets = new Set();
|
||||
|
||||
function record(list) {
|
||||
for (const action of list || []) {
|
||||
actions.push(action);
|
||||
if (action.type === 'connect') {
|
||||
assert.equal(
|
||||
openSockets.size, 0,
|
||||
`connect(gen ${action.generation}) issued while sockets ${[...openSockets]} were still live`,
|
||||
);
|
||||
openSockets.add(action.generation);
|
||||
} else if (action.type === 'terminate') {
|
||||
openSockets.delete(action.generation);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
return {
|
||||
watchdog,
|
||||
actions,
|
||||
openSockets,
|
||||
time,
|
||||
advance(ms) { time.advance(ms); },
|
||||
configure(env = LIVE_ENV) { return record(watchdog.configure(env)); },
|
||||
tick() { return record(watchdog.tick()); },
|
||||
open(generation) { return record(watchdog.onOpen(generation)); },
|
||||
message(generation) { return record(watchdog.onMessage(generation)); },
|
||||
close(generation) {
|
||||
openSockets.delete(generation); // a socket that closed itself is gone
|
||||
return record(watchdog.onClose(generation));
|
||||
},
|
||||
fail(generation, detail) { return record(watchdog.onFailure(generation, detail)); },
|
||||
dispose() { return record(watchdog.dispose()); },
|
||||
snapshot() { return watchdog.snapshot(); },
|
||||
types() { return actions.map((a) => `${a.type}:${a.generation}`); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Bring a harness to a healthy, data-flowing socket on generation 1. */
|
||||
function goLive(h) {
|
||||
h.configure();
|
||||
h.tick();
|
||||
h.open(1);
|
||||
h.message(1);
|
||||
assert.equal(h.snapshot().status, 'live');
|
||||
return h;
|
||||
}
|
||||
|
||||
test('keyless install reports the feature as off and never opens a socket', () => {
|
||||
const h = harness();
|
||||
h.configure({ hasKey: false });
|
||||
h.tick();
|
||||
h.advance(10 * 60_000);
|
||||
h.tick();
|
||||
|
||||
assert.deepEqual(h.types(), []);
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'missing-key');
|
||||
assert.equal(snap.error, 'AISSTREAM_API_KEY is not set');
|
||||
assert.equal(snap.reconnectAttempt, 0);
|
||||
assert.equal(snap.nextAttemptAt, null);
|
||||
});
|
||||
|
||||
test('a key added mid-session clears the terminal state and connects at once', () => {
|
||||
const h = harness();
|
||||
h.configure({ hasKey: false });
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), []);
|
||||
|
||||
h.configure(LIVE_ENV);
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), ['connect:1']);
|
||||
assert.equal(h.snapshot().status, 'connecting');
|
||||
});
|
||||
|
||||
test('a key removed mid-session terminates the live socket', () => {
|
||||
const h = goLive(harness());
|
||||
h.configure({ hasKey: false });
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1']);
|
||||
assert.equal(h.snapshot().status, 'missing-key');
|
||||
assert.equal(h.openSockets.size, 0);
|
||||
});
|
||||
|
||||
test('an open socket is not live until data actually arrives', () => {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
h.tick();
|
||||
h.open(1);
|
||||
|
||||
assert.equal(h.snapshot().status, 'connecting');
|
||||
assert.equal(h.snapshot().lastMessageAt, null);
|
||||
assert.equal(isLiveAisStatus(h.snapshot().status), false);
|
||||
|
||||
h.message(1);
|
||||
assert.equal(h.snapshot().status, 'live');
|
||||
assert.equal(h.snapshot().lastMessageAt, h.time.wall);
|
||||
});
|
||||
|
||||
test('liveness is data, not socket state: silence past staleMs reports stale', () => {
|
||||
const h = goLive(harness());
|
||||
const startedAt = h.time.wall;
|
||||
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.staleMs - 1_000);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'live', 'inside the budget the feed is still live');
|
||||
|
||||
h.advance(2_000);
|
||||
h.tick();
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'stale');
|
||||
assert.equal(snap.lastMessageAt, startedAt, 'the last good message is still reported');
|
||||
assert.ok(snap.silentForMs >= AIS_WATCHDOG_DEFAULTS.staleMs);
|
||||
assert.deepEqual(h.types(), ['connect:1'], 'reporting stale must not touch the socket');
|
||||
});
|
||||
|
||||
test('reporting is fast but recycling is slow — no reconnect at the stale threshold', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.staleMs + 1_000);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'stale');
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
h.advance(8_000);
|
||||
h.tick();
|
||||
}
|
||||
assert.equal(h.snapshot().status, 'stale');
|
||||
assert.deepEqual(h.types(), ['connect:1'], 'no socket churn between staleMs and recycleAfterMs');
|
||||
});
|
||||
|
||||
// --- FINDING 5: a handshake must not buy extra silence budget --------------
|
||||
|
||||
test('a late handshake does NOT reset the silence clock', () => {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
h.tick(); // connect:1 — the silence budget starts here
|
||||
|
||||
// The socket opens just before the recycle deadline.
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs - 1_000);
|
||||
h.open(1);
|
||||
assert.ok(
|
||||
h.snapshot().silentForMs >= AIS_WATCHDOG_DEFAULTS.recycleAfterMs - 1_000,
|
||||
'the handshake must not rewind the silence measurement',
|
||||
);
|
||||
|
||||
// ...and it still recycles on the ORIGINAL schedule, not open + 300s.
|
||||
h.advance(1_001);
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1'],
|
||||
'an opened-but-silent socket recycles on its commissioning schedule');
|
||||
});
|
||||
|
||||
test('a handshake does not clear a stale verdict or reset the ladder', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.staleMs + 1_000);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'stale');
|
||||
|
||||
h.open(1); // a duplicate/late open event
|
||||
assert.equal(h.snapshot().status, 'stale', 'only data clears stale');
|
||||
});
|
||||
|
||||
// --- FINDING 8: durations use a monotonic clock ---------------------------
|
||||
|
||||
test('a wall-clock rollback cannot suppress stale detection', () => {
|
||||
const h = goLive(harness());
|
||||
|
||||
// The operator's clock jumps back an hour mid-silence.
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.staleMs + 1_000);
|
||||
h.time.rollbackWall(3_600_000);
|
||||
h.tick();
|
||||
|
||||
assert.equal(h.snapshot().status, 'stale', 'staleness is measured monotonically');
|
||||
assert.ok(h.snapshot().silentForMs > 0, 'silence must never read negative');
|
||||
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs);
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1'], 'recycle still fires after a rollback');
|
||||
});
|
||||
|
||||
test('a silent socket is terminated once, then reconnected after backoff', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1']);
|
||||
const afterKill = h.snapshot();
|
||||
assert.equal(afterKill.status, 'reconnecting');
|
||||
assert.equal(afterKill.reconnectAttempt, 1);
|
||||
assert.match(afterKill.error, /delivered no data for \d+s/);
|
||||
|
||||
h.advance(4_000);
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1']);
|
||||
|
||||
h.advance(1_001);
|
||||
h.tick();
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1', 'connect:2']);
|
||||
});
|
||||
|
||||
test('single-socket invariant: a reconnect always terminates the old socket first', () => {
|
||||
const h = goLive(harness());
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.downRetryMs + 1);
|
||||
h.tick();
|
||||
}
|
||||
|
||||
let held = null;
|
||||
for (const entry of h.types()) {
|
||||
const [type, generation] = entry.split(':');
|
||||
if (type === 'connect') {
|
||||
assert.equal(held, null, `connect ${generation} while ${held} was still held`);
|
||||
held = generation;
|
||||
} else {
|
||||
assert.equal(held, generation, `terminate ${generation} but ${held} was held`);
|
||||
held = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the machine never emits a graceful close — only terminate', () => {
|
||||
const h = goLive(harness());
|
||||
for (let round = 0; round < 4; round += 1) {
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.downRetryMs + 1);
|
||||
h.tick();
|
||||
}
|
||||
h.fail(99, { kind: 'transport' });
|
||||
h.dispose();
|
||||
|
||||
const kinds = new Set(h.actions.map((a) => a.type));
|
||||
assert.deepEqual([...kinds].sort(), ['connect', 'terminate']);
|
||||
});
|
||||
|
||||
test('backoff is exponential, not a fixed thrash cycle', () => {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
h.tick();
|
||||
|
||||
const waits = [];
|
||||
for (let attempt = 0; attempt < AIS_WATCHDOG_DEFAULTS.backoffMs.length; attempt += 1) {
|
||||
const generation = attempt + 1;
|
||||
h.open(generation);
|
||||
h.close(generation);
|
||||
waits.push(h.snapshot().nextAttemptAt - h.time.wall);
|
||||
h.advance(waits[waits.length - 1]);
|
||||
h.tick();
|
||||
}
|
||||
|
||||
assert.deepEqual(waits, [5_000, 15_000, 60_000, 300_000]);
|
||||
assert.equal(waits.every((w) => w === 90_000), false, 'not the reverted fixed cycle');
|
||||
});
|
||||
|
||||
/** Fail every rung plus one, leaving the watchdog DOWN with no socket held. */
|
||||
function exhaustLadder(h) {
|
||||
h.configure();
|
||||
h.tick();
|
||||
const rungs = AIS_WATCHDOG_DEFAULTS.backoffMs.length;
|
||||
for (let attempt = 0; attempt <= rungs; attempt += 1) {
|
||||
const generation = attempt + 1;
|
||||
h.open(generation);
|
||||
h.close(generation);
|
||||
if (attempt < rungs) {
|
||||
assert.equal(h.snapshot().status, 'reconnecting', `attempt ${attempt + 1} should still be retrying`);
|
||||
h.advance(h.snapshot().nextAttemptAt - h.time.wall);
|
||||
h.tick();
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
test('backoff exhaustion enters DOWN with a slow retry, not silent infinite retry', () => {
|
||||
const h = exhaustLadder(harness());
|
||||
const down = h.snapshot();
|
||||
assert.equal(down.status, 'down');
|
||||
assert.equal(down.reconnectAttempt, AIS_WATCHDOG_DEFAULTS.backoffMs.length + 1);
|
||||
assert.equal(h.watchdog.debugState().owned, null, 'DOWN holds no socket');
|
||||
|
||||
const before = h.types().length;
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.downRetryMs - 1_000);
|
||||
h.tick();
|
||||
assert.equal(h.types().length, before, 'no retry inside the DOWN cadence');
|
||||
|
||||
h.advance(2_000);
|
||||
h.tick();
|
||||
assert.equal(h.types().length, before + 1, 'exactly one slow retry after the cadence');
|
||||
});
|
||||
|
||||
test('the DOWN chip does not flicker hopeful while the slow retry runs behind it', () => {
|
||||
const h = exhaustLadder(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.downRetryMs + 1);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'down', 'a retry attempt must not read as recovery');
|
||||
|
||||
h.open(h.watchdog.debugState().owned);
|
||||
assert.equal(h.snapshot().status, 'down', 'a handshake proves nothing — still DOWN');
|
||||
});
|
||||
|
||||
test('DOWN clears only when data actually flows again', () => {
|
||||
const h = exhaustLadder(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.downRetryMs + 1);
|
||||
h.tick();
|
||||
const generation = h.watchdog.debugState().owned;
|
||||
h.open(generation);
|
||||
h.message(generation);
|
||||
|
||||
const recovered = h.snapshot();
|
||||
assert.equal(recovered.status, 'live');
|
||||
assert.equal(recovered.reconnectAttempt, 0, 'the ladder resets only on real data');
|
||||
assert.equal(recovered.error, null);
|
||||
|
||||
h.close(generation);
|
||||
assert.equal(h.snapshot().nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.backoffMs[0]);
|
||||
});
|
||||
|
||||
test('an open socket that never delivers does not reset the backoff ladder', () => {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
h.tick();
|
||||
|
||||
h.open(1);
|
||||
h.close(1);
|
||||
assert.equal(h.snapshot().reconnectAttempt, 1);
|
||||
|
||||
h.advance(h.snapshot().nextAttemptAt - h.time.wall);
|
||||
h.tick();
|
||||
h.open(2);
|
||||
h.close(2);
|
||||
assert.equal(h.snapshot().reconnectAttempt, 2, 'open alone must not count as success');
|
||||
});
|
||||
|
||||
test('a late-opening orphan is told to hang itself up', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
h.advance(6_000);
|
||||
h.tick();
|
||||
|
||||
assert.deepEqual(h.watchdog.onOpen(1), [{ type: 'terminate', generation: 1, reason: 'orphan' }]);
|
||||
assert.deepEqual(h.watchdog.onMessage(1), [{ type: 'terminate', generation: 1, reason: 'orphan' }]);
|
||||
assert.equal(h.snapshot().status, 'connecting');
|
||||
assert.equal(h.watchdog.debugState().owned, 2);
|
||||
});
|
||||
|
||||
test('a stale generation closing does not disturb the socket that replaced it', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
h.advance(6_000);
|
||||
h.tick();
|
||||
h.open(2);
|
||||
h.message(2);
|
||||
|
||||
h.close(1); // the old socket's close finally lands
|
||||
assert.equal(h.snapshot().status, 'live', 'orphan close must not knock the live feed over');
|
||||
assert.equal(h.snapshot().reconnectAttempt, 0);
|
||||
assert.equal(h.watchdog.debugState().owned, 2);
|
||||
});
|
||||
|
||||
test('generations are never reused across dispose', () => {
|
||||
const h = goLive(harness());
|
||||
assert.equal(h.watchdog.highWaterGeneration(), 1);
|
||||
|
||||
h.dispose();
|
||||
h.configure();
|
||||
h.tick();
|
||||
assert.equal(h.watchdog.debugState().owned, 2, 'the namespace continues past the disposal');
|
||||
assert.equal(h.watchdog.highWaterGeneration(), 2);
|
||||
});
|
||||
|
||||
test('a replacement machine can be seeded past the old generation namespace', () => {
|
||||
const time = fakeClock();
|
||||
const first = createAisWatchdog({ clock: time.clock });
|
||||
first.configure(LIVE_ENV);
|
||||
first.tick();
|
||||
first.tick();
|
||||
const high = first.highWaterGeneration();
|
||||
|
||||
const second = createAisWatchdog({ clock: time.clock, startGeneration: high });
|
||||
second.configure(LIVE_ENV);
|
||||
const actions = second.tick();
|
||||
assert.ok(actions[0].generation > high, 'a replacement must not re-issue a live generation');
|
||||
});
|
||||
|
||||
// --- FINDINGS 3 & 4: failures are classified ------------------------------
|
||||
|
||||
test('a transport error terminates immediately and walks the ladder', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'transport', message: 'ECONNRESET' });
|
||||
|
||||
assert.deepEqual(h.types(), ['connect:1', 'terminate:1']);
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'reconnecting');
|
||||
assert.equal(snap.error, 'ECONNRESET');
|
||||
assert.equal(snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.backoffMs[0]);
|
||||
});
|
||||
|
||||
test('an auth failure is terminal — no ladder, only a very slow probe', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth', message: 'AISStream rejected the API key (HTTP 401)' });
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed');
|
||||
assert.equal(snap.error, 'AISStream rejected the API key (HTTP 401)');
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs,
|
||||
'an auth rejection waits an hour, not five seconds',
|
||||
);
|
||||
|
||||
// Nothing happens for the whole hour, however often we are ticked.
|
||||
const before = h.types().length;
|
||||
for (let i = 0; i < 719; i += 1) { // 3595s of 5-second ticks
|
||||
h.advance(5_000);
|
||||
h.tick();
|
||||
}
|
||||
assert.equal(h.types().length, before, 'no attempt inside the probe cadence');
|
||||
|
||||
h.advance(10_000);
|
||||
h.tick();
|
||||
assert.equal(h.types().length, before + 1, 'exactly one probe after the cadence');
|
||||
});
|
||||
|
||||
test('a silent probe socket cannot launder an auth rejection into the fast ladder', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick(); // the hourly probe connects
|
||||
|
||||
// The probe opens and then says nothing at all. The silence budget does not
|
||||
// apply while the key is refused, so nothing happens at 300s...
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'auth-failed', 'silence is not a transport fault here');
|
||||
|
||||
// ...and when the probe is finally hung up, it is on the auth cadence.
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed', 'still terminal');
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs,
|
||||
'the next probe stays an hour out, not five seconds',
|
||||
);
|
||||
});
|
||||
|
||||
test('repeated auth rejections never accelerate into the ladder', () => {
|
||||
const h = goLive(harness());
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const generation = h.watchdog.debugState().owned;
|
||||
h.fail(generation, { kind: 'auth', message: 'Invalid API key' });
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed', `round ${round} must stay terminal`);
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs,
|
||||
`round ${round} must keep the slow cadence`,
|
||||
);
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
}
|
||||
});
|
||||
|
||||
test('the AUTH-FAILED chip does not flicker hopeful while probing', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
h.open(h.watchdog.debugState().owned);
|
||||
assert.equal(h.snapshot().status, 'auth-failed', 'a handshake does not mean the key works');
|
||||
});
|
||||
|
||||
test('a changed API key clears the terminal auth state immediately', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
|
||||
h.configure({ hasKey: true, hasTransport: true, keyFingerprint: 'key-b' });
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'connecting', 'a new credential earns a fresh attempt');
|
||||
assert.equal(h.snapshot().reconnectAttempt, 0);
|
||||
});
|
||||
|
||||
test('rotating the key mid-probe terminates the old key socket first', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick(); // the hourly probe opens generation 2, still on key A
|
||||
assert.equal(h.watchdog.debugState().owned, 2);
|
||||
|
||||
// The operator swaps the key while that probe is in flight.
|
||||
const rotation = h.configure({ hasKey: true, hasTransport: true, keyFingerprint: 'key-b' });
|
||||
assert.deepEqual(
|
||||
rotation, [{ type: 'terminate', generation: 2, reason: 'key-rotated' }],
|
||||
'the in-flight old-key socket must be hung up, not left running',
|
||||
);
|
||||
assert.equal(h.openSockets.size, 0);
|
||||
|
||||
h.tick(); // generation 3, now on key B
|
||||
assert.equal(h.watchdog.debugState().owned, 3);
|
||||
assert.equal(h.snapshot().status, 'connecting');
|
||||
});
|
||||
|
||||
test("an old key's late rejection cannot push the new key into auth-failed", () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick(); // probe on key A = generation 2
|
||||
|
||||
h.configure({ hasKey: true, hasTransport: true, keyFingerprint: 'key-b' });
|
||||
h.tick(); // generation 3 on key B
|
||||
h.open(3);
|
||||
h.message(3);
|
||||
assert.equal(h.snapshot().status, 'live', 'the new key works');
|
||||
|
||||
// Generation 2's rejection finally lands, an hour late and about key A.
|
||||
const late = h.watchdog.onFailure(2, { kind: 'auth', message: 'Invalid API key' });
|
||||
assert.deepEqual(late, [], 'an old-key rejection is an orphan with no effect');
|
||||
assert.equal(h.snapshot().status, 'live', 'the new key must not inherit the old one\'s verdict');
|
||||
assert.equal(h.snapshot().reconnectAttempt, 0);
|
||||
});
|
||||
|
||||
test('rotating the key also replaces a healthy socket built on the old credential', () => {
|
||||
const h = goLive(harness());
|
||||
const rotation = h.configure({ hasKey: true, hasTransport: true, keyFingerprint: 'key-b' });
|
||||
assert.deepEqual(rotation, [{ type: 'terminate', generation: 1, reason: 'key-rotated' }],
|
||||
'the live socket subscribed with the old key and must not outlive it');
|
||||
h.tick();
|
||||
assert.equal(h.watchdog.debugState().owned, 2);
|
||||
});
|
||||
|
||||
// --- No probe outcome may relaunder auth into the fast ladder -------------
|
||||
|
||||
test('a probe CLOSE keeps auth-failed at the hourly cadence', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
|
||||
h.close(h.watchdog.debugState().owned);
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed', 'a normal close does not mean the key is fine');
|
||||
assert.equal(snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
});
|
||||
|
||||
test('a probe TRANSPORT ERROR keeps auth-failed at the hourly cadence', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth', message: 'AISStream rejected the API key (HTTP 401)' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
|
||||
h.fail(h.watchdog.debugState().owned, { kind: 'transport', message: 'ECONNRESET' });
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed');
|
||||
assert.equal(snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
assert.match(snap.error, /rejected the API key/,
|
||||
'the chip keeps pointing at the key, not at the network');
|
||||
});
|
||||
|
||||
test('a probe RATE LIMIT keeps auth-failed at the hourly cadence', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
|
||||
h.fail(h.watchdog.debugState().owned, { kind: 'rate-limit', retryAfterMs: 5_000 });
|
||||
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
assert.equal(h.snapshot().nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
});
|
||||
|
||||
test('ticking past staleMs must not relabel an auth-failed probe', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick(); // hourly probe opens; it will never speak
|
||||
|
||||
// Tick right through the stale threshold and the silent-recycle threshold.
|
||||
for (let second = 0; second < 400; second += 1) {
|
||||
h.advance(1_000);
|
||||
h.tick();
|
||||
}
|
||||
assert.ok(400_000 > AIS_WATCHDOG_DEFAULTS.recycleAfterMs, 'window covers both thresholds');
|
||||
assert.equal(h.snapshot().status, 'auth-failed', 'time alone must not relabel the state');
|
||||
});
|
||||
|
||||
test('a probe outcome AFTER the stale window still keeps the hourly cadence', () => {
|
||||
// The exact laundering path: tick past staleMs so the old code would have
|
||||
// flipped the state to 'stale', THEN inject the outcome. From 'stale' the
|
||||
// auth coercion in scheduleRetry would no longer match.
|
||||
for (const outcome of ['close', 'transport', 'rate-limit']) {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth', message: 'AISStream rejected the API key (HTTP 401)' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
const probe = h.watchdog.debugState().owned;
|
||||
|
||||
for (let second = 0; second < 200; second += 1) { // past staleMs (120s)
|
||||
h.advance(1_000);
|
||||
h.tick();
|
||||
}
|
||||
assert.equal(h.snapshot().status, 'auth-failed', `${outcome}: still terminal before the outcome`);
|
||||
|
||||
if (outcome === 'close') h.close(probe);
|
||||
else h.fail(probe, { kind: outcome });
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'auth-failed', `${outcome} after the stale window must stay terminal`);
|
||||
assert.equal(
|
||||
snap.nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.authProbeMs,
|
||||
`${outcome} after the stale window must keep the hourly cadence`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a silent probe is recycled on the auth cadence, never the silence budget', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
const before = h.types().length;
|
||||
|
||||
// The silence budget would have recycled at 300s; the auth cadence must not.
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1_000);
|
||||
h.tick();
|
||||
assert.equal(h.types().length, before, 'the silence budget does not apply while auth-failed');
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
|
||||
// It IS bounded, though — the probe is hung up on the auth cadence.
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.authProbeMs);
|
||||
h.tick();
|
||||
assert.equal(h.types().length, before + 1, 'the probe is still terminated, on its own clock');
|
||||
assert.equal(h.watchdog.debugState().owned, null);
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
});
|
||||
|
||||
test('every probe outcome costs at most one attempt per hour', () => {
|
||||
// Cycle through close / transport error / rate limit / silence in turn; an
|
||||
// unproven key must never buy more than the hourly probe.
|
||||
for (const outcome of ['close', 'transport', 'rate-limit', 'silence']) {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
let connects = 0;
|
||||
|
||||
for (let second = 0; second <= 3600; second += 1) {
|
||||
for (const action of h.tick()) {
|
||||
if (action.type !== 'connect') continue;
|
||||
connects += 1;
|
||||
if (outcome === 'close') h.close(action.generation);
|
||||
else if (outcome === 'transport') h.fail(action.generation, { kind: 'transport' });
|
||||
else if (outcome === 'rate-limit') h.fail(action.generation, { kind: 'rate-limit' });
|
||||
// 'silence' simply never responds.
|
||||
}
|
||||
h.advance(1_000);
|
||||
}
|
||||
assert.ok(connects <= 1, `${outcome}: ${connects} probes in an hour`);
|
||||
assert.equal(h.snapshot().status, 'auth-failed', `${outcome} must stay terminal`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unchanged key does not clear the terminal auth state', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'auth' });
|
||||
const before = h.types().length;
|
||||
|
||||
h.configure(LIVE_ENV); // same fingerprint
|
||||
h.tick();
|
||||
assert.equal(h.snapshot().status, 'auth-failed');
|
||||
assert.equal(h.types().length, before, 'no attempt while the same key is being refused');
|
||||
});
|
||||
|
||||
test('a rate limit honours Retry-After', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'rate-limit', message: '429', retryAfterMs: 120_000 });
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.equal(snap.status, 'reconnecting');
|
||||
assert.equal(snap.nextAttemptAt - h.time.wall, 120_000, "the server's own pacing wins");
|
||||
});
|
||||
|
||||
test('a rate limit without Retry-After enters at the SLOWEST rung', () => {
|
||||
const h = goLive(harness());
|
||||
h.fail(1, { kind: 'rate-limit', message: '429' });
|
||||
|
||||
const slowest = AIS_WATCHDOG_DEFAULTS.backoffMs[AIS_WATCHDOG_DEFAULTS.backoffMs.length - 1];
|
||||
assert.equal(h.snapshot().nextAttemptAt - h.time.wall, slowest);
|
||||
assert.notEqual(h.snapshot().nextAttemptAt - h.time.wall, AIS_WATCHDOG_DEFAULTS.backoffMs[0]);
|
||||
});
|
||||
|
||||
// --- Worst-case connection rate, per failure class -------------------------
|
||||
|
||||
/**
|
||||
* Simulate an hour of unbroken failure with the watchdog ticked once a second
|
||||
* — far more often than either the 15s interval or the 60s browser poll — and
|
||||
* count connection attempts. Steady-state failure must cost single digits.
|
||||
*/
|
||||
function attemptsPerHour(kind, detail = {}) {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
let connects = 0;
|
||||
for (let second = 0; second <= 3600; second += 1) {
|
||||
for (const action of h.tick()) {
|
||||
if (action.type !== 'connect') continue;
|
||||
connects += 1;
|
||||
h.fail(action.generation, { kind, ...detail });
|
||||
}
|
||||
h.advance(1_000);
|
||||
}
|
||||
return connects;
|
||||
}
|
||||
|
||||
test('worst-case attempt rate is single digits per hour in every failure class', () => {
|
||||
const transport = attemptsPerHour('transport');
|
||||
const auth = attemptsPerHour('auth');
|
||||
const rateLimit = attemptsPerHour('rate-limit');
|
||||
|
||||
// The reverted design's fixed 5s loop would be 720/hour here.
|
||||
assert.ok(transport < 10, `transport: ${transport} attempts/hour`);
|
||||
assert.ok(auth <= 2, `auth: ${auth} attempts/hour`);
|
||||
assert.ok(rateLimit < 10, `rate-limit: ${rateLimit} attempts/hour`);
|
||||
|
||||
// Pin the exact figures so a budget change has to be deliberate.
|
||||
assert.equal(transport, 8);
|
||||
assert.equal(auth, 2);
|
||||
assert.equal(rateLimit, 5);
|
||||
});
|
||||
|
||||
test('a silent-socket recycle loop also stays in single digits per hour', () => {
|
||||
const h = harness();
|
||||
h.configure();
|
||||
let connects = 0;
|
||||
for (let second = 0; second <= 3600; second += 1) {
|
||||
for (const action of h.tick()) {
|
||||
if (action.type === 'connect') connects += 1; // then simply never speaks
|
||||
}
|
||||
h.advance(1_000);
|
||||
}
|
||||
assert.ok(connects < 10, `silent-recycle: ${connects} attempts/hour`);
|
||||
// Pinned exactly, like the other classes, so a budget change is deliberate.
|
||||
assert.equal(connects, 6);
|
||||
});
|
||||
|
||||
// --- Remaining policy ------------------------------------------------------
|
||||
|
||||
test('a custom subscription disarms the silence watch instead of recycling forever', () => {
|
||||
const h = harness();
|
||||
h.configure({ hasKey: true, hasTransport: true, silenceWatch: false, keyFingerprint: 'key-a' });
|
||||
h.tick();
|
||||
h.open(1);
|
||||
h.message(1);
|
||||
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs * 4);
|
||||
h.tick();
|
||||
|
||||
assert.deepEqual(h.types(), ['connect:1'], 'a legitimately quiet filter is never recycled');
|
||||
assert.equal(h.snapshot().watchdog, 'custom-subscription-off');
|
||||
});
|
||||
|
||||
test('dispose terminates the live socket and resets the ladder', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
h.advance(6_000);
|
||||
h.tick();
|
||||
|
||||
assert.deepEqual(h.dispose(), [{ type: 'terminate', generation: 2, reason: 'dispose' }]);
|
||||
assert.equal(h.openSockets.size, 0);
|
||||
assert.equal(h.snapshot().status, 'idle');
|
||||
assert.equal(h.snapshot().reconnectAttempt, 0);
|
||||
});
|
||||
|
||||
test('dispose on an idle watchdog is a no-op', () => {
|
||||
const h = harness();
|
||||
h.configure({ hasKey: false });
|
||||
assert.deepEqual(h.dispose(), []);
|
||||
});
|
||||
|
||||
test('snapshot exposes the honest fields the browser chip needs', () => {
|
||||
const h = goLive(harness());
|
||||
h.advance(AIS_WATCHDOG_DEFAULTS.recycleAfterMs + 1);
|
||||
h.tick();
|
||||
|
||||
const snap = h.snapshot();
|
||||
assert.deepEqual(Object.keys(snap).sort(), [
|
||||
'error',
|
||||
'lastMessageAt',
|
||||
'nextAttemptAt',
|
||||
'reconnectAttempt',
|
||||
'silentForMs',
|
||||
'staleAfterMs',
|
||||
'status',
|
||||
'watchdog',
|
||||
]);
|
||||
assert.equal(snap.staleAfterMs, AIS_WATCHDOG_DEFAULTS.staleMs);
|
||||
assert.ok(snap.nextAttemptAt > h.time.wall);
|
||||
});
|
||||
|
||||
// --- FINDING 7: the silence override is parsed strictly -------------------
|
||||
|
||||
test('an empty or unparseable silence override never silently disables the watchdog', () => {
|
||||
const warnings = [];
|
||||
const warn = (message) => warnings.push(message);
|
||||
|
||||
// The dangerous cases: Number('') and Number(' ') are both 0, which under a
|
||||
// loose parse would read as the kill switch and mute the whole feature.
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('', warn), { kind: 'default' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv(' ', warn), { kind: 'default' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv(undefined, warn), { kind: 'default' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv(null, warn), { kind: 'default' });
|
||||
assert.equal(warnings.length, 0, 'an absent value is normal, not worth warning about');
|
||||
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('abc', warn), { kind: 'default' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('-5', warn), { kind: 'default' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('12s', warn), { kind: 'default' });
|
||||
assert.equal(warnings.length, 3, 'each bad value says so exactly once');
|
||||
assert.match(warnings[0], /Ignoring AISSTREAM_SILENCE_TIMEOUT_MS="abc"/);
|
||||
});
|
||||
|
||||
test('only a literal zero is the documented kill switch', () => {
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('0'), { kind: 'off' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv(0), { kind: 'off' });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv('90000'), { kind: 'timeout', value: 90_000 });
|
||||
assert.deepEqual(parseSilenceTimeoutEnv(' 90000 '), { kind: 'timeout', value: 90_000 });
|
||||
});
|
||||
|
||||
test('recycleAfterMs can never be tuned below staleMs', () => {
|
||||
const time = fakeClock();
|
||||
const watchdog = createAisWatchdog({ staleMs: 60_000, recycleAfterMs: 1_000, clock: time.clock });
|
||||
watchdog.configure(LIVE_ENV);
|
||||
watchdog.tick();
|
||||
watchdog.onMessage(1);
|
||||
|
||||
time.advance(30_000);
|
||||
assert.deepEqual(watchdog.tick(), []);
|
||||
time.advance(31_000);
|
||||
assert.deepEqual(watchdog.tick().map((a) => a.type), ['terminate']);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Transport proof for the AISStream watchdog's central design choice:
|
||||
// teardown must be ws.terminate(), never a graceful close().
|
||||
//
|
||||
// This is the assumption the reverted watchdog got wrong. It is pinned here
|
||||
// against a real black-hole peer — a server that completes the websocket
|
||||
// handshake and then never speaks again, which is exactly how AISStream fails
|
||||
// (upstream: handshake succeeds, no frames, no error, no close). Everything
|
||||
// runs on an ephemeral localhost port; no network access is involved.
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import WebSocketImpl from 'ws';
|
||||
|
||||
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
/**
|
||||
* A websocket peer that upgrades the connection and then goes silent forever.
|
||||
* It never sends frames and never answers a close frame, so a graceful close
|
||||
* handshake can never complete.
|
||||
*/
|
||||
function startBlackHoleServer() {
|
||||
let liveConnections = 0;
|
||||
/** Every accepted socket, so teardown can destroy the wedged ones. */
|
||||
const accepted = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
liveConnections += 1;
|
||||
accepted.add(socket);
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('latin1');
|
||||
if (!buffer.includes('\r\n\r\n')) return;
|
||||
const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buffer)?.[1]?.trim();
|
||||
if (!key) return;
|
||||
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
|
||||
socket.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
+ 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
||||
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
|
||||
);
|
||||
buffer = '';
|
||||
// ...and then nothing, ever. Incoming CLOSE frames are ignored.
|
||||
});
|
||||
socket.on('close', () => {
|
||||
liveConnections -= 1;
|
||||
accepted.delete(socket);
|
||||
});
|
||||
socket.on('error', () => {});
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
listen: () => new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve(`ws://127.0.0.1:${server.address().port}`));
|
||||
}),
|
||||
get liveConnections() { return liveConnections; },
|
||||
close: () => new Promise((resolve) => {
|
||||
// A socket this suite deliberately wedged in CLOSING keeps the event loop
|
||||
// alive forever; server.close() only stops new accepts, so the wedged
|
||||
// connections have to be destroyed by hand.
|
||||
for (const socket of [...accepted]) socket.destroy();
|
||||
accepted.clear();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve to true if `event` fires on `emitter` within `ms`. */
|
||||
function firedWithin(subscribe, ms) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), ms);
|
||||
subscribe(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const blackHole = startBlackHoleServer();
|
||||
const url = await blackHole.listen();
|
||||
after(() => blackHole.close());
|
||||
|
||||
test('ws.terminate() hard-aborts a black-holed socket and frees the connection slot', async () => {
|
||||
const socket = new WebSocketImpl(url);
|
||||
await new Promise((resolve) => socket.once('open', resolve));
|
||||
assert.equal(socket.readyState, WebSocketImpl.OPEN);
|
||||
assert.equal(typeof socket.terminate, 'function', 'the adapter depends on terminate()');
|
||||
assert.equal(blackHole.liveConnections, 1);
|
||||
|
||||
const closed = firedWithin((done) => socket.once('close', done), 1_000);
|
||||
const startedAt = Date.now();
|
||||
socket.terminate();
|
||||
|
||||
assert.equal(await closed, true, 'terminate() must complete against a silent peer');
|
||||
assert.ok(Date.now() - startedAt < 1_000);
|
||||
assert.equal(socket.readyState, WebSocketImpl.CLOSED);
|
||||
|
||||
// The TCP connection is genuinely gone — this is what protects AISStream's
|
||||
// one-connection-per-key limit when the watchdog recycles.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.equal(blackHole.liveConnections, 0, 'the socket slot must actually be released');
|
||||
});
|
||||
|
||||
test('the built-in WebSocket close() never completes on a black-holed socket', async () => {
|
||||
// This is the regression that wedged the reverted watchdog: Node's global
|
||||
// WebSocket exposes no terminate(), and close() waits for a close frame the
|
||||
// dead peer will never send. If a future Node release fixes this, the
|
||||
// assertion below fails loudly and the design can be revisited.
|
||||
assert.equal(typeof WebSocket, 'function');
|
||||
assert.equal(
|
||||
typeof (new WebSocket(url)).terminate, 'undefined',
|
||||
'built-in WebSocket has no hard-abort; that is why ws is a dependency',
|
||||
);
|
||||
|
||||
const socket = new WebSocket(url);
|
||||
await new Promise((resolve) => socket.addEventListener('open', resolve, { once: true }));
|
||||
assert.equal(socket.readyState, WebSocket.OPEN);
|
||||
|
||||
const closed = firedWithin(
|
||||
(done) => socket.addEventListener('close', done, { once: true }),
|
||||
1_500,
|
||||
);
|
||||
socket.close(1000, 'watchdog recycle');
|
||||
assert.equal(socket.readyState, WebSocket.CLOSING);
|
||||
|
||||
assert.equal(await closed, false, 'close() hangs in CLOSING — measured well past 30s by hand');
|
||||
assert.equal(socket.readyState, WebSocket.CLOSING, 'the socket slot is never handed back');
|
||||
});
|
||||
|
||||
test('a terminated socket hands the single connection slot straight to its replacement', async () => {
|
||||
const first = new WebSocketImpl(url);
|
||||
await new Promise((resolve) => first.once('open', resolve));
|
||||
first.terminate();
|
||||
await new Promise((resolve) => first.once('close', resolve));
|
||||
|
||||
// The watchdog's reconnect happens right here; it must not be refused.
|
||||
const second = new WebSocketImpl(url);
|
||||
await new Promise((resolve) => second.once('open', resolve));
|
||||
assert.equal(second.readyState, WebSocketImpl.OPEN);
|
||||
|
||||
second.terminate();
|
||||
await new Promise((resolve) => second.once('close', resolve));
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Analyst query engine — answers spoken questions over data ALREADY sitting
|
||||
* client-side in the layers ("how many flights over Texas?", "biggest fire
|
||||
* near LA?", "which ships are headed to Oakland?").
|
||||
*
|
||||
* Analyst-query behavior is documented in docs/CURRENT-STATE.md:
|
||||
* - ENGINE (this module) is pure query logic over plain record arrays; it
|
||||
* renders nothing. SURFACES (voice narration, panels, detection brackets)
|
||||
* consume the returned result set — the engine/surface seam is the
|
||||
* `items` array with stable {layerKey, id} identities.
|
||||
* - v1 scope is CLIENT-SIDE DATA ONLY. The one enrichment path (flight
|
||||
* routes) reads the already-cached adsbdb results surfaced by the layer
|
||||
* accessor; the engine never fetches. Fleet-wide route search is
|
||||
* explicitly out of scope.
|
||||
* - Follow-up memory: the previous result set can be re-queried ("which of
|
||||
* those is closest?") via `followUp: true`. Held per engine instance,
|
||||
* cleared by `reset()` (layer toggles should reset via the caller).
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
*/
|
||||
|
||||
import { pointInRing } from './naturalEarthRegions.js';
|
||||
|
||||
/** Layers the engine understands, with the fields queries may reference. */
|
||||
export const ANALYST_LAYERS = {
|
||||
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
|
||||
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
|
||||
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
||||
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
|
||||
};
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
|
||||
/** Great-circle distance in km. */
|
||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const d2r = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * d2r;
|
||||
const dLon = (lon2 - lon1) * d2r;
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/** One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. */
|
||||
export function applyFilter(records, filter) {
|
||||
const { field, op, value } = filter || {};
|
||||
if (!field || !op) return records;
|
||||
return records.filter((r) => {
|
||||
const got = r[field];
|
||||
if (got === null || got === undefined) return false;
|
||||
switch (op) {
|
||||
case 'gt': return Number(got) > Number(value);
|
||||
case 'gte': return Number(got) >= Number(value);
|
||||
case 'lt': return Number(got) < Number(value);
|
||||
case 'lte': return Number(got) <= Number(value);
|
||||
case 'eq': {
|
||||
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
|
||||
return String(got).toLowerCase() === String(value).toLowerCase();
|
||||
}
|
||||
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
|
||||
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
|
||||
default: return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
|
||||
export function applyScope(records, scope, resolved) {
|
||||
if (!scope || scope.kind === 'anywhere') return records;
|
||||
if (scope.kind === 'region' && resolved?.ring) {
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& pointInRing(resolved.ring, r.lat, r.lon));
|
||||
}
|
||||
if (scope.kind === 'radius' || scope.kind === 'view') {
|
||||
const c = resolved?.center;
|
||||
const km = resolved?.km;
|
||||
if (!c || !Number.isFinite(km)) return records;
|
||||
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
|
||||
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/** Numeric summary for the narration layer. */
|
||||
function summarize(items, sortField) {
|
||||
const summary = { count: items.length };
|
||||
if (sortField && items.length) {
|
||||
const vals = items.map((r) => Number(r[sortField])).filter(Number.isFinite);
|
||||
if (vals.length) {
|
||||
summary[`${sortField}Min`] = Math.min(...vals);
|
||||
summary[`${sortField}Max`] = Math.max(...vals);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an engine bound to live providers. All spatial/text/number logic is
|
||||
* in the pure helpers above; this closure only sequences and remembers.
|
||||
*/
|
||||
export function createAnalystEngine(providers) {
|
||||
let lastResult = null;
|
||||
|
||||
async function query(spec = {}) {
|
||||
const layers = (spec.followUp && lastResult)
|
||||
? null // follow-up: re-filter the remembered set, no re-snapshot
|
||||
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
|
||||
|
||||
// 1) Source records
|
||||
let records;
|
||||
let layersQueried;
|
||||
if (layers === null) {
|
||||
records = lastResult.items.slice();
|
||||
layersQueried = lastResult.coverage.layersQueried;
|
||||
} else {
|
||||
records = [];
|
||||
layersQueried = [];
|
||||
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
|
||||
if (unknown.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `I can't query ${unknown.join(', ')} yet — supported layers: ${Object.keys(ANALYST_LAYERS).join(', ')}.`,
|
||||
coverage: { layersQueried: [], scope: 'unsupported-layer' },
|
||||
};
|
||||
}
|
||||
for (const key of layers) {
|
||||
if (!ANALYST_LAYERS[key]) continue;
|
||||
const rows = providers.getRecords(key) || [];
|
||||
layersQueried.push({ layerKey: key, records: rows.length });
|
||||
for (const row of rows) records.push({ layerKey: key, ...row });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Spatial scope
|
||||
let resolvedScope = null;
|
||||
let scopeNote = 'anywhere';
|
||||
// Human phrasing for the same scope, so every spoken count can name what it
|
||||
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
|
||||
// arriving as a bare number that contradicts the panel.
|
||||
let scopeLabel = 'anywhere in the loaded data';
|
||||
const scope = spec.scope || { kind: 'view' };
|
||||
if (scope.kind === 'region' && scope.name) {
|
||||
const region = await providers.resolveRegionRing(scope.name);
|
||||
if (!region?.ring) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `I couldn't resolve a boundary for "${scope.name}" — try a state, country, or a named natural region.`,
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
|
||||
};
|
||||
}
|
||||
resolvedScope = region;
|
||||
scopeNote = `region:${region.name}`;
|
||||
scopeLabel = `over ${region.name}`;
|
||||
} else if (scope.kind === 'radius') {
|
||||
// An explicit center always wins. Otherwise, when Contacts is active its
|
||||
// SUBJECT is the centre the operator is actually reasoning about: the
|
||||
// panel counts a contact-centred window, so centring the radius on the
|
||||
// camera made the two disagree — a parked, high-altitude camera answered
|
||||
// "46 within 250 km" while the panel showed a far larger contact-centred
|
||||
// count. Same question, two numbers.
|
||||
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
|
||||
? scope.center
|
||||
: null;
|
||||
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
|
||||
const view = providers.getViewContext();
|
||||
// Only a subject that actually SUPPLIED the centre may name it. A
|
||||
// subject present but without usable coordinates silently fell back to
|
||||
// the camera while the label still read "within 250 km of <contact>" —
|
||||
// a count centred on one place, reported as centred on another, with
|
||||
// nothing in the payload to show which.
|
||||
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
|
||||
? { lat: subject.lat, lon: subject.lon }
|
||||
: null;
|
||||
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
|
||||
resolvedScope = { center, km: Number(scope.km) || 100 };
|
||||
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
|
||||
scopeNote = resolvedScope.centeredOn
|
||||
? `radius:${resolvedScope.km}km@${resolvedScope.centeredOn}`
|
||||
: `radius:${resolvedScope.km}km`;
|
||||
scopeLabel = resolvedScope.centeredOn
|
||||
? `within ${resolvedScope.km} km of ${resolvedScope.centeredOn}`
|
||||
: `within ${resolvedScope.km} km`;
|
||||
} else if (scope.kind === 'view') {
|
||||
const view = providers.getViewContext();
|
||||
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
|
||||
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
|
||||
scopeLabel = 'in view';
|
||||
} else {
|
||||
scopeNote = 'anywhere';
|
||||
scopeLabel = 'anywhere in the loaded data';
|
||||
}
|
||||
let items = applyScope(records, scope, resolvedScope);
|
||||
|
||||
// 3) Attribute filters
|
||||
for (const f of spec.filters || []) items = applyFilter(items, f);
|
||||
|
||||
// 4) Aggregate / sort / limit — nearest needs a reference point
|
||||
const sortBy = spec.sortBy || null;
|
||||
if (sortBy === 'distance') {
|
||||
const ref = resolvedScope?.center || providers.getViewContext();
|
||||
for (const it of items) {
|
||||
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
|
||||
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
|
||||
}
|
||||
}
|
||||
if (sortBy) {
|
||||
const dir = spec.sortDir === 'asc' ? 1 : -1;
|
||||
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|
||||
|| String(a.id).localeCompare(String(b.id)));
|
||||
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
|
||||
}
|
||||
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
|
||||
const top = items.slice(0, limit);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
count: items.length,
|
||||
items: top,
|
||||
truncated: items.length > top.length,
|
||||
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
|
||||
scopeLabel,
|
||||
coverage: {
|
||||
layersQueried,
|
||||
scope: scopeNote,
|
||||
followUp: Boolean(spec.followUp && lastResult),
|
||||
note: 'client-side data only — answers cover what the enabled layers currently hold',
|
||||
},
|
||||
// Surfaced so the narration can name the centre it measured from rather
|
||||
// than implying a view-centred answer.
|
||||
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
|
||||
};
|
||||
lastResult = { items, coverage: result.coverage };
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
reset() { lastResult = null; },
|
||||
hasMemory() { return Boolean(lastResult); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
const FLIGHTS = [
|
||||
{ id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' },
|
||||
{ id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null },
|
||||
{ id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, routeOrigin: null, routeDestination: null },
|
||||
{ id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null },
|
||||
];
|
||||
const SHIPS = [
|
||||
{ id: 'EVERGIVEN', lat: 29.5, lon: -94.9, speedKts: 12, shipType: 'Cargo', destination: 'OAKLAND', navStatus: 'under way' },
|
||||
{ id: 'SLOWBOAT', lat: 29.6, lon: -95.0, speedKts: 0.2, shipType: 'Tanker', destination: 'HOUSTON', navStatus: 'anchored' },
|
||||
];
|
||||
const FIRES = [
|
||||
{ id: 'FIRE-1', lat: 30.5, lon: -98.2, frp: 1500 },
|
||||
{ id: 'FIRE-2', lat: 30.6, lon: -98.1, frp: 90 },
|
||||
{ id: 'FIRE-3', lat: 51.9, lon: -121.9, frp: 2400 },
|
||||
];
|
||||
|
||||
function makeEngine() {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Same world, but Contacts is up with a subject far from the parked camera. */
|
||||
function makeContactsEngine(subject) {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
// Parked far away, as a high-altitude camera often is.
|
||||
getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }),
|
||||
getContextSubject: () => subject,
|
||||
});
|
||||
}
|
||||
|
||||
test('analyst: a radius query centers on the active contact, not the parked camera', async () => {
|
||||
// Field case: the Contacts panel counted a contact-centred window while the
|
||||
// camera sat off-coast at 441 km, so "how many within 250 km" answered from
|
||||
// the camera and disagreed with what the operator could see.
|
||||
const subject = { lat: 30.2, lon: -97.7, label: 'SWA1' };
|
||||
const r = await makeContactsEngine(subject).query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.ok, true);
|
||||
// Austin-area flights, not the Oregon one the camera is parked over.
|
||||
assert.equal(r.count, 3);
|
||||
assert.equal(r.centeredOn, 'SWA1', 'the answer names the centre it measured from');
|
||||
assert.ok(r.coverage.scope.includes('@SWA1'));
|
||||
});
|
||||
|
||||
test('analyst: an explicit center still wins over the active contact', async () => {
|
||||
const subject = { lat: 30.2, lon: -97.7, label: 'SWA1' };
|
||||
const r = await makeContactsEngine(subject).query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250, center: { lat: 45.0, lon: -122.0 } },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'only the Oregon flight is within 250 km of the given center');
|
||||
assert.equal(r.centeredOn, undefined, 'an explicit center is not relabelled');
|
||||
});
|
||||
|
||||
test('analyst: with Contacts off, radius still centers on the view', async () => {
|
||||
const r = await makeContactsEngine(null).query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'view-centred behaviour is unchanged outside Contacts');
|
||||
assert.equal(r.centeredOn, undefined);
|
||||
assert.equal(r.coverage.scope, 'radius:250km');
|
||||
});
|
||||
|
||||
test('analyst: a subject without usable coordinates cannot lend its name to a camera-centred count', async () => {
|
||||
// The label is the only thing telling the operator WHICH centre produced the
|
||||
// number. A subject present but position-less fell back to the camera and
|
||||
// kept the contact's name on the answer, so a camera-centred count read as
|
||||
// contact-centred with nothing in the payload to catch it.
|
||||
const r = await makeContactsEngine({ lat: null, lon: null, label: 'SWA1' }).query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'the count is the camera-centred one it actually measured');
|
||||
assert.equal(r.centeredOn, undefined, 'and it must not claim a centre it did not use');
|
||||
assert.equal(r.coverage.scope, 'radius:250km');
|
||||
assert.equal(r.scopeLabel, 'within 250 km');
|
||||
});
|
||||
|
||||
test('analyst: every scope names itself in words', async () => {
|
||||
// Rule 3 of the counting contract: a bare number is what made two honest
|
||||
// answers look like a contradiction, so each scope carries its own phrasing.
|
||||
const subject = { lat: 30.2, lon: -97.7, label: 'DYNO11' };
|
||||
const centred = await makeContactsEngine(subject).query({
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1,
|
||||
});
|
||||
assert.equal(centred.scopeLabel, 'within 250 km of DYNO11');
|
||||
|
||||
const plainRadius = await makeContactsEngine(null).query({
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1,
|
||||
});
|
||||
assert.equal(plainRadius.scopeLabel, 'within 250 km');
|
||||
|
||||
const inView = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'view' }, limit: 1,
|
||||
});
|
||||
assert.equal(inView.scopeLabel, 'in view');
|
||||
|
||||
const region = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 1,
|
||||
});
|
||||
assert.equal(region.scopeLabel, 'over Texland');
|
||||
|
||||
const anywhere = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' }, limit: 1,
|
||||
});
|
||||
assert.equal(anywhere.scopeLabel, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('analyst: count flights over a region', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 50 });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.count, 3, 'Oregon flight excluded');
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
});
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['RCH01']);
|
||||
});
|
||||
|
||||
test('analyst: military flag + region compose', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texland' },
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'RCH01');
|
||||
});
|
||||
|
||||
test('analyst: ships headed to Oakland (destination contains)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['ais-live-vessels'], scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'destination', op: 'contains', value: 'oakland' }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['EVERGIVEN']);
|
||||
});
|
||||
|
||||
test('analyst: superlative — biggest fire in view radius', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['local-firms'], scope: { kind: 'view' }, sortBy: 'frp', limit: 1,
|
||||
});
|
||||
assert.equal(r.items[0].id, 'FIRE-1', 'BC monster is out of view scope');
|
||||
assert.equal(r.summary.frpMax, 1500);
|
||||
});
|
||||
|
||||
test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'view' }, sortBy: 'distance', limit: 3,
|
||||
});
|
||||
assert.ok(r.items[0].distanceKm <= r.items[1].distanceKm);
|
||||
assert.ok(Number.isFinite(r.items[0].distanceKm));
|
||||
});
|
||||
|
||||
test('analyst: follow-up re-filters the remembered set without re-snapshot', async () => {
|
||||
const eng = makeEngine();
|
||||
await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' } });
|
||||
const r = await eng.query({ followUp: true, filters: [{ field: 'onGround', op: 'eq', value: true }] });
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'GND1');
|
||||
assert.equal(r.coverage.followUp, true);
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /Atlantis/);
|
||||
});
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'routeDestination', op: 'eq', value: 'LAX' }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
|
||||
});
|
||||
|
||||
test('helpers: haversine sanity + scope radius', () => {
|
||||
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
|
||||
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
|
||||
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
|
||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
_clearBikeshareSelectionForTest,
|
||||
_selectBikeshareStationForTest,
|
||||
_setBikeshareSelectionStateForTest,
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
} from './bikeshare.js';
|
||||
|
||||
function makeRecord() {
|
||||
return {
|
||||
stationId: '3790',
|
||||
stationName: 'Congress & 6th',
|
||||
bikesAvailable: 7,
|
||||
docksAvailable: 4,
|
||||
capacity: 11,
|
||||
isInstalled: true,
|
||||
isRenting: false,
|
||||
isReturning: true,
|
||||
point: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 2),
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('selected bikeshare entry preserves source copy and protected-lane policy', () => {
|
||||
const record = makeRecord();
|
||||
const entry = createBikeshareSelectedOverlayEntry('austin-capmetro:3790', record);
|
||||
assert.equal(entry.position, record.point.position);
|
||||
assert.equal(entry.title, 'Congress & 6th');
|
||||
assert.deepEqual(entry.details, [
|
||||
'🚲 7 avail · 4 docks · 11 cap',
|
||||
'⚠️ Not renting',
|
||||
]);
|
||||
assert.equal(entry.variant, 'selected');
|
||||
assert.equal(entry.selected, true);
|
||||
assert.equal(entry.protected, true);
|
||||
assert.equal(entry.paintLane, 'selected');
|
||||
assert.equal(entry.collisionGroup, 'ambient-card');
|
||||
assert.equal(entry.edgeFade, 'keyhole');
|
||||
assert.equal(entry.horizonCull, true);
|
||||
});
|
||||
|
||||
test('real station select/clear path publishes one card and creates no native label graphic', () => {
|
||||
const calls = [];
|
||||
const overlayHost = {
|
||||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
};
|
||||
const key = 'austin-capmetro:3790';
|
||||
const record = makeRecord();
|
||||
const viewer = { entities: new Cesium.EntityCollection() };
|
||||
_setBikeshareSelectionStateForTest({ viewer, key, record, overlayHost });
|
||||
try {
|
||||
_selectBikeshareStationForTest(key);
|
||||
assert.equal(record.point.show, false);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real selected entity');
|
||||
assert.equal(viewer.entities.values[0].label, undefined);
|
||||
assert.ok(viewer.entities.values[0].point, 'selected point highlight remains native');
|
||||
|
||||
const publication = calls.find(([type]) => type === 'entries');
|
||||
assert.ok(publication);
|
||||
assert.equal(publication[1], 'bikeshare-selected');
|
||||
assert.equal(publication[2].length, 1);
|
||||
assert.equal(publication[2][0].position, record.point.position);
|
||||
assert.deepEqual(publication[3], BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS);
|
||||
|
||||
_clearBikeshareSelectionForTest();
|
||||
assert.equal(record.point.show, true);
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.deepEqual(calls.at(-1), ['clear', 'bikeshare-selected']);
|
||||
} finally {
|
||||
_clearBikeshareSelectionForTest();
|
||||
}
|
||||
});
|
||||
+4825
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* @module cctvCards
|
||||
* @description Screen-space thumbnail cards for the citywide ambient CCTV
|
||||
* tier described in `docs/CURRENT-STATE.md`.
|
||||
* Replaces the rejected world-space static-plane ring with small canvas cards
|
||||
* anchored to each LOD-selected camera's screen position, showing its latest
|
||||
* paced static frame.
|
||||
*
|
||||
* The shared world-overlay host owns projection, collision, paint, and hit
|
||||
* rectangles. This module owns the source-side entry policy and every frame
|
||||
* lifecycle concern: fetching, stable slots, retry pacing, persistence, and
|
||||
* cache pruning.
|
||||
*
|
||||
* Division of labor mirrors vesselLabels: cctv.js OWNS selection and frame
|
||||
* fetching (moveEnd-driven LOD + grace + the policy-gated pacer: cold-fill
|
||||
* burst, then the 1-fetch/s steady gate — never per
|
||||
* frame); this module owns the pure card policy helpers (declutter, frame
|
||||
* persistence, retry pacing, cache pruning) and supplies ready-to-draw stable
|
||||
* frame-slot references to the host.
|
||||
*
|
||||
* Zero-flicker contract (product requirement):
|
||||
* - An AMBIENT entry whose frame slot has no drawn frame yet (`stamp === 0`)
|
||||
* renders NOTHING — no placeholder, no chip. The camera icon alone carries
|
||||
* it. Sole documented exception (follow-up round 2, item B): a PINNED entry
|
||||
* (hover-summoned, `entry.pinned === true`) paints its chrome immediately —
|
||||
* explicit user gesture wants instant feedback — with an empty thumb area
|
||||
* until its fast-tracked frame lands.
|
||||
* - Entries hold a LIVE reference to their per-camera frame slot; when a
|
||||
* fetched frame lands (applyFrameResult assigned onto the stable slot) the
|
||||
* card appears/updates on the next postRender without any entry rebuild.
|
||||
* - A failed fetch never clears a drawn frame (applyFrameResult persistence).
|
||||
*
|
||||
* Edge fade consistency (owner note): cards fade toward the screen edges via
|
||||
* the SAME radial keyhole ramp the detection overlay uses
|
||||
* (keyholeLabelAlphaFromGeometry — opaque inside the central keyhole, linear
|
||||
* feather outward), multiplied with the shared distance ramp (cardAlpha).
|
||||
*/
|
||||
|
||||
import { CCTV_THUMBNAIL_STYLE } from '../overlays/worldOverlayTokens.js';
|
||||
|
||||
// ─── Card geometry / style constants ───────────────────────────────────────
|
||||
|
||||
/** Thumbnail display size (CSS px, 16:9). */
|
||||
export const CCTV_CARD_THUMB_W = 96;
|
||||
export const CCTV_CARD_THUMB_H = 54;
|
||||
/** Offscreen frame-canvas size (2x thumb for DPI crispness). */
|
||||
export const CCTV_FRAME_CANVAS_W = 192;
|
||||
export const CCTV_FRAME_CANVAS_H = 108;
|
||||
/**
|
||||
* Min screen separation between accepted card anchors (greedy declutter).
|
||||
* Field test 2026-07-30: 130 read too sparse once the HUD safe-zone
|
||||
* filter started dropping cards as well. 112 still exceeds the card box width
|
||||
* (104 px) so accepted boxes cannot overlap. This is THE density knob.
|
||||
*/
|
||||
export const CCTV_CARD_MIN_SEP_PX = 112;
|
||||
/** Ambient cards yield the top HUD band to mission text and controls. */
|
||||
export const CCTV_CARD_SAFE_TOP_RATIO = 0.18;
|
||||
export const CCTV_CARD_SAFE_TOP_MAX_PX = 150;
|
||||
/** Bounded thumbnail cache (frame slots kept beyond the live card set). */
|
||||
export const CCTV_FRAME_CACHE_MAX = 96;
|
||||
|
||||
// ─── Altitude scaling (field test finding 5, 2026-07-29) ──────────────
|
||||
// Validated curve: cards are full size at street level, "start to get
|
||||
// smaller" from ~1,800 m, "scale down progressively" to ~0.45 by 6,000 m,
|
||||
// keep shrinking slightly and alpha-fade out across 7,500→9,500 m, and are
|
||||
// fully hidden above that ("just the icons" at the highest zooms).
|
||||
export const CCTV_CARD_SCALE_FULL_M = 1_800;
|
||||
export const CCTV_CARD_SCALE_MID_M = 6_000;
|
||||
export const CCTV_CARD_FADE_START_M = 7_500;
|
||||
export const CCTV_CARD_FADE_END_M = 9_500;
|
||||
export const CCTV_CARD_SCALE_AT_MID = 0.45;
|
||||
export const CCTV_CARD_SCALE_MIN = 0.35;
|
||||
|
||||
// ─── Frame-fetch pacing (field test finding 3, 2026-07-29) ────────────
|
||||
/** Steady-state global gate: one card-frame fetch per second. */
|
||||
export const CCTV_CARD_FETCH_STEADY_SPACING_MS = 1_000;
|
||||
/** Cold-fill burst spacing between fetch launches. */
|
||||
export const CCTV_CARD_FETCH_BURST_SPACING_MS = 250;
|
||||
/** Cold-fill burst max concurrent in-flight fetches. */
|
||||
export const CCTV_CARD_FETCH_BURST_LIMIT = 4;
|
||||
|
||||
/** Distance fade: full inside 70%, gone past this (metro scale stays live). */
|
||||
const FADE_DISTANCE_M = 150000;
|
||||
/** Base per-camera retry spacing; doubles per consecutive failure. */
|
||||
const RETRY_BASE_MS = 15_000;
|
||||
const RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
|
||||
export const CCTV_OVERLAY_SOURCE_ID = 'cctv';
|
||||
export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
|
||||
fullEnd: CCTV_CARD_SCALE_FULL_M,
|
||||
midEnd: CCTV_CARD_SCALE_MID_M,
|
||||
end: CCTV_CARD_FADE_END_M,
|
||||
midValue: CCTV_CARD_SCALE_AT_MID,
|
||||
endValue: CCTV_CARD_SCALE_MIN,
|
||||
smoothToMid: true,
|
||||
});
|
||||
|
||||
// ─── Pure helpers (exported for unit tests — no Cesium, no DOM) ────────────
|
||||
|
||||
/**
|
||||
* Returns whether an ambient card anchor is outside the protected top HUD
|
||||
* band. A user-pinned hover card may intentionally enter the band because it
|
||||
* is temporary and requested; persistent ambient cards yield to the HUD.
|
||||
*
|
||||
* @param {Object} input
|
||||
* @param {number} input.sy - Anchor Y in CSS pixels.
|
||||
* @param {number} input.viewH - Viewport height in CSS pixels.
|
||||
* @param {boolean} [input.pinned=false]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
|
||||
if (pinned) return Number.isFinite(sy);
|
||||
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
|
||||
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
|
||||
return sy >= safeTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy nearest-first screen-space declutter over LOD-selected candidates
|
||||
* (the vessel/FIRMS min-separation idiom): accept a candidate only when its
|
||||
* anchor keeps `minSepPx` from every already-accepted anchor, so cards never
|
||||
* pile onto each other or bury neighboring camera icons.
|
||||
* @param {Array<{id:string,sx:number,sy:number,distanceKm:number}>} candidates
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.minSepPx]
|
||||
* @param {number} [options.limit]
|
||||
* @returns {string[]} Accepted ids, nearest-first.
|
||||
*/
|
||||
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
|
||||
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
|
||||
const minSq = minSepPx * minSepPx;
|
||||
const accepted = [];
|
||||
const acceptedIds = [];
|
||||
for (const candidate of valid) {
|
||||
if (acceptedIds.length >= limit) break;
|
||||
let clear = true;
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const dx = candidate.sx - accepted[i].sx;
|
||||
const dy = candidate.sy - accepted[i].sy;
|
||||
if (dx * dx + dy * dy < minSq) { clear = false; break; }
|
||||
}
|
||||
if (!clear) continue;
|
||||
accepted.push(candidate);
|
||||
acceptedIds.push(candidate.id);
|
||||
}
|
||||
return acceptedIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude-driven card scale + opacity (field test finding 5,
|
||||
* 2026-07-29 — curve constants above). Piecewise, monotonic non-increasing
|
||||
* in both channels:
|
||||
* - ≤1,800 m: full size, fully opaque.
|
||||
* - 1,800→6,000 m: smoothstep shrink 1.0 → 0.45 (opaque).
|
||||
* - 6,000→9,500 m: slight continued linear shrink 0.45 → 0.35; opaque
|
||||
* until 7,500 m, then a linear alpha fade to 0 at 9,500 m.
|
||||
* - ≥9,500 m: fully hidden (alpha 0) — entries may persist, nothing paints.
|
||||
* @param {number} cameraHeightM - Viewer camera height above the ellipsoid.
|
||||
* @returns {{scale:number, alpha:number}}
|
||||
*/
|
||||
export function cardScaleForAltitude(cameraHeightM) {
|
||||
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
|
||||
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
|
||||
if (h <= CCTV_CARD_SCALE_MID_M) {
|
||||
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
|
||||
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
|
||||
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
|
||||
}
|
||||
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
|
||||
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
|
||||
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
|
||||
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
|
||||
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
|
||||
return { scale, alpha };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold-fill burst pacing policy (field test finding 3, 2026-07-29), as
|
||||
* a pure decision so the pacer tick stays trivially testable. While any
|
||||
* selected card still lacks its FIRST frame (`coldFill`), up to
|
||||
* `CCTV_CARD_FETCH_BURST_LIMIT` fetches may be in flight with
|
||||
* `CCTV_CARD_FETCH_BURST_SPACING_MS` between launches; once every selected
|
||||
* card has a first frame the layer drops back to the steady-state global
|
||||
* gate (single in-flight fetch, one launch per second — an in-flight fetch
|
||||
* still blocks the tick, so slow responses only lower the rate).
|
||||
* @param {Object} [input]
|
||||
* @param {boolean} [input.coldFill] - A selected card lacks its first frame.
|
||||
* @param {number} [input.inFlight] - Current in-flight fetch count.
|
||||
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
|
||||
* @returns {{mode:('burst'|'steady'), launch:boolean}}
|
||||
*/
|
||||
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
|
||||
if (coldFill) {
|
||||
return {
|
||||
mode: 'burst',
|
||||
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
|
||||
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'steady',
|
||||
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty per-camera frame slot. Slots are STABLE objects — card
|
||||
* entries reference them directly, and fetch results are Object.assign-ed
|
||||
* onto them (via applyFrameResult) so the renderer sees new frames without
|
||||
* an entry rebuild.
|
||||
* @returns {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}}
|
||||
*/
|
||||
export function createFrameSlot() {
|
||||
return { frame: null, stamp: 0, failCount: 0, lastAttemptAt: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-flicker persistence rule, as a pure state transition. A successful
|
||||
* fetch replaces the frame and stamps it; a FAILED fetch keeps the previous
|
||||
* frame and stamp untouched (the drawn card persists) and only bumps the
|
||||
* failure count for retry backoff.
|
||||
* @param {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}} prev
|
||||
* @param {{ok:boolean, frame?:*}} result
|
||||
* @param {number} nowMs
|
||||
* @returns {{frame:*, stamp:number, failCount:number, lastAttemptAt:number}}
|
||||
*/
|
||||
export function applyFrameResult(prev, result, nowMs) {
|
||||
const base = prev || createFrameSlot();
|
||||
if (result?.ok && result.frame) {
|
||||
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
|
||||
}
|
||||
return {
|
||||
frame: base.frame,
|
||||
stamp: base.stamp,
|
||||
failCount: (base.failCount || 0) + 1,
|
||||
lastAttemptAt: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-camera attempt spacing: a base gap between attempts, doubling per
|
||||
* consecutive failure, capped (a dead source settles at one try per 5 min).
|
||||
* @param {number} failCount
|
||||
* @returns {number}
|
||||
*/
|
||||
export function frameRetryDelayMs(failCount) {
|
||||
const fails = Math.max(0, Number(failCount) || 0);
|
||||
return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** fails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a frame fetch should be attempted for a slot: the slot must be
|
||||
* stale against its source cadence (or never filled), and past the retry
|
||||
* spacing since its last attempt.
|
||||
* @param {{stamp:number, failCount:number, lastAttemptAt:number}|null} slot
|
||||
* @param {number} refreshMs - Source cadence (staticFrameRefreshMs).
|
||||
* @param {number} nowMs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function frameFetchDue(slot, refreshMs, nowMs) {
|
||||
if (!slot) return false;
|
||||
const stale = !(slot.stamp > 0) || nowMs - slot.stamp >= refreshMs;
|
||||
if (!stale) return false;
|
||||
if (!(slot.lastAttemptAt > 0)) return true;
|
||||
return nowMs - slot.lastAttemptAt >= frameRetryDelayMs(slot.failCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans the bounded-LRU prune of the thumbnail cache: live card ids are
|
||||
* NEVER dropped (their persisted frames are the no-flicker guarantee, grace
|
||||
* included); beyond them the newest-stamped slots fill the cap and the rest
|
||||
* are dropped.
|
||||
* @param {Array<{id:string, stamp:number}>} slots - Cached slot ids + stamps.
|
||||
* @param {Iterable<string>} keepIds - Live card ids (selection + grace).
|
||||
* @param {number} [cap]
|
||||
* @returns {string[]} Ids to drop from the cache.
|
||||
*/
|
||||
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
|
||||
const keep = new Set(keepIds);
|
||||
const spare = (Array.isArray(slots) ? slots : [])
|
||||
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
|
||||
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
|
||||
const capacity = Math.max(0, cap - keep.size);
|
||||
return spare.slice(capacity).map((slot) => slot.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the normalized source-side presentation for one CCTV thumbnail. The
|
||||
* stable frame slot is passed by reference; the host reads `slot.frame` on
|
||||
* every paint, so a successful fetch appears without rebuilding the entry and
|
||||
* a failed fetch cannot clear the last successful pixels.
|
||||
* @param {object} input
|
||||
* @param {string} input.id
|
||||
* @param {object} input.position
|
||||
* @param {string} input.title
|
||||
* @param {{frame:*,stamp:number}} input.frameSlot
|
||||
* @param {number} [input.rank=0]
|
||||
* @param {boolean} [input.pinned=false]
|
||||
* @param {boolean} [input.active=false]
|
||||
* @param {number} [input.gapPx=16]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createCctvThumbnailOverlayEntry({
|
||||
id,
|
||||
position,
|
||||
title,
|
||||
frameSlot,
|
||||
rank = 0,
|
||||
pinned = false,
|
||||
active = false,
|
||||
gapPx = 16,
|
||||
} = {}) {
|
||||
const hostGap = Math.max(14, (Number(gapPx) || 14) + 6);
|
||||
return {
|
||||
id,
|
||||
position,
|
||||
variant: 'thumbnail',
|
||||
paintLane: 'thumbnail',
|
||||
title,
|
||||
details: [],
|
||||
image: frameSlot,
|
||||
requireImage: true,
|
||||
accent: CCTV_THUMBNAIL_STYLE.accent,
|
||||
priority: active ? 1_000_000 : pinned ? 900_000 : 100_000 - rank,
|
||||
selected: false,
|
||||
pinned,
|
||||
protected: active,
|
||||
active,
|
||||
collisionGroup: 'ambient-card',
|
||||
zIndex: 50,
|
||||
interactive: true,
|
||||
minDistance: 0,
|
||||
maxDistance: FADE_DISTANCE_M,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
altitudeScale: CCTV_THUMBNAIL_ALTITUDE_SCALE,
|
||||
altitudeFadeStart: CCTV_CARD_FADE_START_M,
|
||||
altitudeFadeEnd: CCTV_CARD_FADE_END_M,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: hostGap,
|
||||
leaderOffsetPx: Math.max(2, hostGap - 6),
|
||||
verticalOnly: true,
|
||||
// CCTV shipped as a STATELESS per-frame rebuild: cards popped in and out on
|
||||
// the frame the geometry said so, and above/below was re-decided from
|
||||
// geometry every frame with no memory. Opting out of the shared arbiter's
|
||||
// min-lifetime, re-entry cooldown, fades and sticky corner restores that
|
||||
// feel — the hysteresis is what produced sticky mid-screen below-placements
|
||||
// and the ~1.5 s delay before a card could come back after a nadir sweep.
|
||||
stateless: true,
|
||||
// The shipped per-frame pass rejected cards whose ANCHORS were closer than
|
||||
// this (scaled with the card). Rectangle overlap alone let them stack about
|
||||
// twice as densely, because the leader gap does not shrink with the card.
|
||||
minAnchorSeparationPx: CCTV_CARD_MIN_SEP_PX,
|
||||
viewportMargin: 4,
|
||||
viewportPadding: 60,
|
||||
safeTopRatio: CCTV_CARD_SAFE_TOP_RATIO,
|
||||
safeTopMaxPx: CCTV_CARD_SAFE_TOP_MAX_PX,
|
||||
pinnedBypassesSafeTop: true,
|
||||
thumbnailWidth: CCTV_CARD_THUMB_W,
|
||||
thumbnailHeight: CCTV_CARD_THUMB_H,
|
||||
thumbnailPadX: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadTop: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadBottom: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailTitleGap: 2,
|
||||
thumbnailTitleHeight: CCTV_THUMBNAIL_STYLE.titleHeight,
|
||||
thumbnailTitleChars: CCTV_THUMBNAIL_STYLE.titleChars,
|
||||
thumbnailBackground: CCTV_THUMBNAIL_STYLE.background,
|
||||
thumbnailTitleColor: CCTV_THUMBNAIL_STYLE.titleColor,
|
||||
thumbnailTitleFont: CCTV_THUMBNAIL_STYLE.titleFont,
|
||||
thumbnailLeaderColor: CCTV_THUMBNAIL_STYLE.leader,
|
||||
thumbnailRuleColor: CCTV_THUMBNAIL_STYLE.rule,
|
||||
thumbnailRuleHeight: CCTV_THUMBNAIL_STYLE.ruleHeight,
|
||||
thumbnailRadius: CCTV_THUMBNAIL_STYLE.radius,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// src/data/cctvCards.test.mjs
|
||||
// Pure-helper tests for the ambient CCTV thumbnail-card overlay: greedy
|
||||
// declutter, the no-flicker frame-persistence rule, retry pacing, and the
|
||||
// bounded thumbnail-cache prune. Rendering geometry (placeCard/cardAlpha) is
|
||||
// shared with firmsLabels.js and covered by firmsLabels.test.mjs; the radial
|
||||
// keyhole edge fade is covered by the celestialRing suite.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
CCTV_CARD_FADE_END_M,
|
||||
CCTV_CARD_FADE_START_M,
|
||||
CCTV_CARD_FETCH_BURST_LIMIT,
|
||||
CCTV_CARD_FETCH_BURST_SPACING_MS,
|
||||
CCTV_CARD_FETCH_STEADY_SPACING_MS,
|
||||
CCTV_CARD_MIN_SEP_PX,
|
||||
CCTV_CARD_SCALE_AT_MID,
|
||||
CCTV_CARD_SCALE_FULL_M,
|
||||
CCTV_CARD_SCALE_MID_M,
|
||||
CCTV_CARD_SCALE_MIN,
|
||||
CCTV_FRAME_CACHE_MAX,
|
||||
applyFrameResult,
|
||||
cardFetchPolicy,
|
||||
cardScaleForAltitude,
|
||||
createCctvThumbnailOverlayEntry,
|
||||
createFrameSlot,
|
||||
declutterCctvCards,
|
||||
frameFetchDue,
|
||||
frameRetryDelayMs,
|
||||
isCctvCardAnchorSafe,
|
||||
planFrameCachePrune,
|
||||
} from './cctvCards.js';
|
||||
|
||||
test('isCctvCardAnchorSafe protects the top HUD band for ambient cards', () => {
|
||||
assert.equal(isCctvCardAnchorSafe({ sy: 100, viewH: 1000 }), false);
|
||||
assert.equal(isCctvCardAnchorSafe({ sy: 150, viewH: 1000 }), true);
|
||||
assert.equal(isCctvCardAnchorSafe({ sy: 100, viewH: 1000, pinned: true }), true);
|
||||
assert.equal(isCctvCardAnchorSafe({ sy: NaN, viewH: 1000 }), false);
|
||||
});
|
||||
|
||||
// ─── declutterCctvCards ──────────────────────────────────────────────────────
|
||||
|
||||
test('declutterCctvCards: nearest-first greedy accept with min separation', () => {
|
||||
const kept = declutterCctvCards([
|
||||
{ id: 'far', sx: 100, sy: 100, distanceKm: 5 },
|
||||
{ id: 'near', sx: 110, sy: 110, distanceKm: 1 }, // wins the overlap: nearer
|
||||
{ id: 'clear', sx: 400, sy: 400, distanceKm: 9 },
|
||||
], { minSepPx: 130 });
|
||||
assert.deepEqual(kept, ['near', 'clear']);
|
||||
});
|
||||
|
||||
test('declutterCctvCards: separation is radial (Euclidean), matching the vessel pass', () => {
|
||||
// 100px apart on one axis: inside a 130px separation, outside a 90px one.
|
||||
const candidates = [
|
||||
{ id: 'a', sx: 0, sy: 0, distanceKm: 1 },
|
||||
{ id: 'b', sx: 100, sy: 0, distanceKm: 2 },
|
||||
];
|
||||
assert.deepEqual(declutterCctvCards(candidates, { minSepPx: 130 }), ['a']);
|
||||
assert.deepEqual(declutterCctvCards(candidates, { minSepPx: 90 }), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('declutterCctvCards: respects the limit and drops malformed rows', () => {
|
||||
const kept = declutterCctvCards([
|
||||
{ id: 'a', sx: 0, sy: 0, distanceKm: 1 },
|
||||
{ id: 'b', sx: 500, sy: 0, distanceKm: 2 },
|
||||
{ id: 'c', sx: 1000, sy: 0, distanceKm: 3 },
|
||||
{ id: '', sx: 200, sy: 200, distanceKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 0, distanceKm: 0 },
|
||||
null,
|
||||
], { minSepPx: 130, limit: 2 });
|
||||
assert.deepEqual(kept, ['a', 'b']);
|
||||
});
|
||||
|
||||
|
||||
test('thumbnail entry carries the exact shipped contract and stable frame-slot reference', () => {
|
||||
const frameSlot = createFrameSlot();
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: 'cam-a',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
title: 'Main & 5th',
|
||||
frameSlot,
|
||||
rank: 2,
|
||||
});
|
||||
assert.equal(entry.variant, 'thumbnail');
|
||||
assert.equal(entry.collisionGroup, 'ambient-card');
|
||||
assert.equal(entry.paintLane, 'thumbnail');
|
||||
assert.equal(entry.interactive, true);
|
||||
assert.equal(entry.image, frameSlot, 'host receives the source-owned stable slot by reference');
|
||||
assert.equal(entry.thumbnailWidth, 96);
|
||||
assert.equal(entry.thumbnailHeight, 54);
|
||||
assert.equal(entry.thumbnailPadX, 4);
|
||||
assert.equal(entry.thumbnailTitleHeight, 13);
|
||||
assert.equal(entry.requireImage, true);
|
||||
assert.equal(entry.edgeFade, 'keyhole');
|
||||
assert.equal(entry.verticalOnly, true);
|
||||
assert.equal(entry.gapPx, 22);
|
||||
assert.equal(entry.leaderOffsetPx, 16);
|
||||
// CCTV shipped as a stateless per-frame rebuild with an anchor-separation
|
||||
// pass. Both are opt-ins on the shared host: without them the arbiter's
|
||||
// min-lifetime/cooldown/sticky-corner hysteresis makes cards stick below
|
||||
// their icon and take ~1.5 s to return, and rectangle overlap alone lets them
|
||||
// stack about twice as densely as shipped.
|
||||
assert.equal(entry.stateless, true);
|
||||
assert.equal(entry.minAnchorSeparationPx, CCTV_CARD_MIN_SEP_PX);
|
||||
});
|
||||
|
||||
test('active CCTV thumbnail is protected while ambient and pinned policies stay distinct', () => {
|
||||
const base = { id: 'cam-a', position: { x: 1, y: 2, z: 3 }, title: 'A', frameSlot: createFrameSlot() };
|
||||
const ambient = createCctvThumbnailOverlayEntry(base);
|
||||
const pinned = createCctvThumbnailOverlayEntry({ ...base, pinned: true });
|
||||
const active = createCctvThumbnailOverlayEntry({ ...base, active: true });
|
||||
assert.equal(ambient.protected, false);
|
||||
assert.equal(ambient.pinned, false);
|
||||
assert.equal(pinned.pinned, true);
|
||||
assert.equal(active.protected, true);
|
||||
assert.ok(active.priority > pinned.priority && pinned.priority > ambient.priority);
|
||||
});
|
||||
|
||||
test('CCTV card module cannot resurrect a canvas, projection, listener, or private hit store', () => {
|
||||
const source = readFileSync(new URL('./cctvCards.js', import.meta.url), 'utf8');
|
||||
const forbidden = [
|
||||
/createElement\(\s*['"]canvas['"]\s*\)/,
|
||||
/postRender\.addEventListener/,
|
||||
/cartesianToCanvasCoordinates/,
|
||||
/SceneTransforms/,
|
||||
/hitTestCctvCard/,
|
||||
/setCctvCardHitRects/,
|
||||
];
|
||||
for (const pattern of forbidden) assert.doesNotMatch(source, pattern);
|
||||
});
|
||||
|
||||
// ─── cardScaleForAltitude — the validated altitude curve (finding 5) ────
|
||||
|
||||
test('cardScaleForAltitude: full size and opacity at or below 1,800 m', () => {
|
||||
assert.deepEqual(cardScaleForAltitude(0), { scale: 1, alpha: 1 });
|
||||
assert.deepEqual(cardScaleForAltitude(CCTV_CARD_SCALE_FULL_M), { scale: 1, alpha: 1 });
|
||||
assert.deepEqual(cardScaleForAltitude(NaN), { scale: 1, alpha: 1 });
|
||||
});
|
||||
|
||||
test('cardScaleForAltitude: hits the validated waypoints', () => {
|
||||
const mid = cardScaleForAltitude(CCTV_CARD_SCALE_MID_M);
|
||||
assert.ok(Math.abs(mid.scale - CCTV_CARD_SCALE_AT_MID) < 1e-9, 'scale ~0.45 at 6,000 m');
|
||||
assert.equal(mid.alpha, 1);
|
||||
const fadeStart = cardScaleForAltitude(CCTV_CARD_FADE_START_M);
|
||||
assert.equal(fadeStart.alpha, 1, 'still opaque at 7,500 m');
|
||||
assert.ok(fadeStart.scale < CCTV_CARD_SCALE_AT_MID, 'keeps shrinking slightly past 6,000 m');
|
||||
const midFade = cardScaleForAltitude((CCTV_CARD_FADE_START_M + CCTV_CARD_FADE_END_M) / 2);
|
||||
assert.ok(Math.abs(midFade.alpha - 0.5) < 1e-9, 'linear fade midpoint');
|
||||
});
|
||||
|
||||
test('cardScaleForAltitude: fully hidden at and above the 9,500 m ceiling', () => {
|
||||
assert.equal(cardScaleForAltitude(CCTV_CARD_FADE_END_M).alpha, 0);
|
||||
assert.equal(cardScaleForAltitude(50_000).alpha, 0);
|
||||
assert.equal(cardScaleForAltitude(50_000).scale, CCTV_CARD_SCALE_MIN);
|
||||
});
|
||||
|
||||
test('cardScaleForAltitude: scale and alpha are monotonic non-increasing', () => {
|
||||
let prev = cardScaleForAltitude(0);
|
||||
for (let h = 100; h <= 12_000; h += 100) {
|
||||
const cur = cardScaleForAltitude(h);
|
||||
assert.ok(cur.scale <= prev.scale + 1e-12, `scale rises at ${h} m`);
|
||||
assert.ok(cur.alpha <= prev.alpha + 1e-12, `alpha rises at ${h} m`);
|
||||
assert.ok(cur.scale >= CCTV_CARD_SCALE_MIN - 1e-12);
|
||||
prev = cur;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── cardFetchPolicy — cold-fill burst pacing (finding 3) ───────────────────
|
||||
|
||||
test('cardFetchPolicy: cold fill bursts up to 4 in flight at 250 ms spacing', () => {
|
||||
assert.deepEqual(
|
||||
cardFetchPolicy({ coldFill: true, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_BURST_SPACING_MS }),
|
||||
{ mode: 'burst', launch: true }
|
||||
);
|
||||
assert.deepEqual(
|
||||
cardFetchPolicy({ coldFill: true, inFlight: CCTV_CARD_FETCH_BURST_LIMIT - 1, sinceLastLaunchMs: 260 }),
|
||||
{ mode: 'burst', launch: true }
|
||||
);
|
||||
// Blocked at the concurrency cap and inside the launch spacing.
|
||||
assert.equal(cardFetchPolicy({ coldFill: true, inFlight: CCTV_CARD_FETCH_BURST_LIMIT, sinceLastLaunchMs: 9_999 }).launch, false);
|
||||
assert.equal(cardFetchPolicy({ coldFill: true, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_BURST_SPACING_MS - 1 }).launch, false);
|
||||
});
|
||||
|
||||
test('cardFetchPolicy: steady state keeps the 1-fetch/s single-flight gate', () => {
|
||||
assert.deepEqual(
|
||||
cardFetchPolicy({ coldFill: false, inFlight: 0, sinceLastLaunchMs: CCTV_CARD_FETCH_STEADY_SPACING_MS }),
|
||||
{ mode: 'steady', launch: true }
|
||||
);
|
||||
// An in-flight fetch blocks the tick; so does the 1 s spacing.
|
||||
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 1, sinceLastLaunchMs: 9_999 }).launch, false);
|
||||
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 0, sinceLastLaunchMs: 999 }).launch, false);
|
||||
// First-ever launch (no prior fetch) is immediate.
|
||||
assert.equal(cardFetchPolicy({ coldFill: false, inFlight: 0 }).launch, true);
|
||||
});
|
||||
|
||||
// ─── applyFrameResult — the no-flicker persistence rule ─────────────────────
|
||||
|
||||
test('applyFrameResult: success replaces the frame and stamps it', () => {
|
||||
const slot = createFrameSlot();
|
||||
const next = applyFrameResult(slot, { ok: true, frame: 'FRAME-1' }, 5_000);
|
||||
assert.deepEqual(next, { frame: 'FRAME-1', stamp: 5_000, failCount: 0, lastAttemptAt: 5_000 });
|
||||
});
|
||||
|
||||
test('applyFrameResult: a FAILED fetch persists the drawn frame and stamp', () => {
|
||||
const drawn = applyFrameResult(createFrameSlot(), { ok: true, frame: 'FRAME-1' }, 5_000);
|
||||
const afterFail = applyFrameResult(drawn, { ok: false }, 65_000);
|
||||
// The owner's zero-flicker rule: the card keeps rendering FRAME-1.
|
||||
assert.equal(afterFail.frame, 'FRAME-1');
|
||||
assert.equal(afterFail.stamp, 5_000);
|
||||
assert.equal(afterFail.failCount, 1);
|
||||
assert.equal(afterFail.lastAttemptAt, 65_000);
|
||||
// A later success replaces it and clears the failure streak.
|
||||
const recovered = applyFrameResult(afterFail, { ok: true, frame: 'FRAME-2' }, 130_000);
|
||||
assert.equal(recovered.frame, 'FRAME-2');
|
||||
assert.equal(recovered.stamp, 130_000);
|
||||
assert.equal(recovered.failCount, 0);
|
||||
});
|
||||
|
||||
test('applyFrameResult: an ok result without a frame counts as a failure', () => {
|
||||
const drawn = applyFrameResult(createFrameSlot(), { ok: true, frame: 'FRAME-1' }, 5_000);
|
||||
const next = applyFrameResult(drawn, { ok: true }, 9_000);
|
||||
assert.equal(next.frame, 'FRAME-1');
|
||||
assert.equal(next.failCount, 1);
|
||||
});
|
||||
|
||||
// ─── frameRetryDelayMs / frameFetchDue ──────────────────────────────────────
|
||||
|
||||
test('frameRetryDelayMs: doubles per failure, capped at 5 minutes', () => {
|
||||
assert.equal(frameRetryDelayMs(0), 15_000);
|
||||
assert.equal(frameRetryDelayMs(1), 30_000);
|
||||
assert.equal(frameRetryDelayMs(2), 60_000);
|
||||
assert.equal(frameRetryDelayMs(10), 300_000);
|
||||
});
|
||||
|
||||
test('frameFetchDue: an untouched slot is due immediately', () => {
|
||||
assert.equal(frameFetchDue(createFrameSlot(), 300_000, 1_000), true);
|
||||
assert.equal(frameFetchDue(null, 300_000, 1_000), false);
|
||||
});
|
||||
|
||||
test('frameFetchDue: a fresh frame is not due until its cadence elapses', () => {
|
||||
const slot = applyFrameResult(createFrameSlot(), { ok: true, frame: 'F' }, 10_000);
|
||||
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 299_999), false);
|
||||
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 300_000), true);
|
||||
});
|
||||
|
||||
test('frameFetchDue: failures back off instead of hammering the source', () => {
|
||||
let slot = applyFrameResult(createFrameSlot(), { ok: false }, 10_000);
|
||||
// First retry waits the doubled delay (failCount=1 -> 30s).
|
||||
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 29_999), false);
|
||||
assert.equal(frameFetchDue(slot, 300_000, 10_000 + 30_000), true);
|
||||
// A slot with a PERSISTED frame still backs off between failed refreshes.
|
||||
slot = applyFrameResult(createFrameSlot(), { ok: true, frame: 'F' }, 0);
|
||||
slot = applyFrameResult(slot, { ok: false }, 300_000);
|
||||
assert.equal(frameFetchDue(slot, 300_000, 300_000 + 20_000), false);
|
||||
assert.equal(frameFetchDue(slot, 300_000, 300_000 + 30_000), true);
|
||||
});
|
||||
|
||||
// ─── planFrameCachePrune ────────────────────────────────────────────────────
|
||||
|
||||
test('planFrameCachePrune: never drops live card ids (grace included)', () => {
|
||||
const slots = [
|
||||
{ id: 'live-1', stamp: 10 },
|
||||
{ id: 'graced', stamp: 5 },
|
||||
{ id: 'old', stamp: 1 },
|
||||
];
|
||||
const drops = planFrameCachePrune(slots, ['live-1', 'graced'], 2);
|
||||
assert.deepEqual(drops, ['old']);
|
||||
});
|
||||
|
||||
test('planFrameCachePrune: spare slots kept newest-first up to the cap', () => {
|
||||
const slots = [
|
||||
{ id: 'live', stamp: 100 },
|
||||
{ id: 'spare-new', stamp: 90 },
|
||||
{ id: 'spare-mid', stamp: 50 },
|
||||
{ id: 'spare-old', stamp: 10 },
|
||||
];
|
||||
const drops = planFrameCachePrune(slots, ['live'], 3);
|
||||
assert.deepEqual(drops.sort(), ['spare-old']);
|
||||
assert.deepEqual(planFrameCachePrune(slots, ['live'], 1).sort(),
|
||||
['spare-mid', 'spare-new', 'spare-old']);
|
||||
});
|
||||
|
||||
test('planFrameCachePrune: default cap is the exported cache bound', () => {
|
||||
const slots = Array.from({ length: CCTV_FRAME_CACHE_MAX + 5 }, (_, i) => ({
|
||||
id: `cam-${String(i).padStart(3, '0')}`,
|
||||
stamp: i,
|
||||
}));
|
||||
const drops = planFrameCachePrune(slots, []);
|
||||
assert.equal(drops.length, 5);
|
||||
// Oldest stamps go first.
|
||||
assert.deepEqual(drops.sort(), ['cam-000', 'cam-001', 'cam-002', 'cam-003', 'cam-004']);
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* @module cctvGizmo
|
||||
*
|
||||
* Direct-manipulation calibration gizmo for the CCTV layer (design:
|
||||
* the CCTV calibration contract in `docs/CURRENT-STATE.md`).
|
||||
*
|
||||
* Two layers:
|
||||
* - Pure drag math (this top section): ray↔axis closest-point, ray↔plane
|
||||
* intersection with a grazing-angle guard, ring angles. All unit-tested
|
||||
* under node:test with zero scene dependencies.
|
||||
* - createCalibrationGizmo (below): the entity + ScreenSpaceEventHandler
|
||||
* controller that renders grab-able handles on the ACTIVE camera and turns
|
||||
* drags into calibration patches through a narrow callback interface. It
|
||||
* never touches the layer's records/stores directly.
|
||||
*
|
||||
* Every one of the 7 calibration DOF maps to a handle:
|
||||
* heading ring (yaw, local-up plane) · pitch ring (around local right) ·
|
||||
* East/North/Up arrows (offsets + mount height) · range handle at the
|
||||
* far-cap center (monitor-plane distance) · FOV handles at the cap's
|
||||
* left/right edge midpoints (plane size).
|
||||
*
|
||||
* Scene interaction is strictly event-driven (single pick on LEFT_DOWN,
|
||||
* throttled hover picks while ADJUST mode is on) — the layer's zero-steady-state-query
|
||||
* invariant is untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Grazing guard (spec §5): reject plane intersections when the view ray is
|
||||
// nearly parallel to the constraint plane — the hit point races to infinity
|
||||
// and a 1 px mouse move would slam the value.
|
||||
const GRAZING_DOT_MIN = 0.08;
|
||||
// Near-parallel guard for ray/axis closest-point (denominator 1 - (d·a)²).
|
||||
const PARALLEL_EPS = 1e-6;
|
||||
|
||||
const scratchW = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Parameter t (metres) along an axis line of the point on that axis closest
|
||||
* to a mouse ray. Both directions must be normalized.
|
||||
*
|
||||
* Minimizing |(rayOrigin + s·rayDir) − (axisOrigin + t·axisDir)|² gives
|
||||
* t = (e − b·d) / (1 − b²) with b = rayDir·axisDir, w = rayOrigin − axisOrigin,
|
||||
* d = rayDir·w, e = axisDir·w.
|
||||
*
|
||||
* @param {Cesium.Cartesian3} rayOrigin
|
||||
* @param {Cesium.Cartesian3} rayDir - Unit.
|
||||
* @param {Cesium.Cartesian3} axisOrigin
|
||||
* @param {Cesium.Cartesian3} axisDir - Unit.
|
||||
* @returns {number|null} Axis parameter in metres, or null when near-parallel.
|
||||
*/
|
||||
export function closestParamOnAxis(rayOrigin, rayDir, axisOrigin, axisDir) {
|
||||
const b = Cesium.Cartesian3.dot(rayDir, axisDir);
|
||||
const denom = 1 - b * b;
|
||||
if (Math.abs(denom) < PARALLEL_EPS) return null;
|
||||
const w = Cesium.Cartesian3.subtract(rayOrigin, axisOrigin, scratchW);
|
||||
const d = Cesium.Cartesian3.dot(rayDir, w);
|
||||
const e = Cesium.Cartesian3.dot(axisDir, w);
|
||||
return (e - b * d) / denom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects a mouse ray with a plane, refusing grazing configurations
|
||||
* (|rayDir·normal| < 0.08) and hits behind the ray origin.
|
||||
* @param {Cesium.Cartesian3} rayOrigin
|
||||
* @param {Cesium.Cartesian3} rayDir - Unit.
|
||||
* @param {Cesium.Cartesian3} planeOrigin
|
||||
* @param {Cesium.Cartesian3} planeNormal - Unit.
|
||||
* @returns {Cesium.Cartesian3|null} Hit point (new instance), or null.
|
||||
*/
|
||||
export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
|
||||
const denom = Cesium.Cartesian3.dot(rayDir, planeNormal);
|
||||
if (Math.abs(denom) < GRAZING_DOT_MIN) return null;
|
||||
const toPlane = Cesium.Cartesian3.subtract(planeOrigin, rayOrigin, scratchW);
|
||||
const s = Cesium.Cartesian3.dot(toPlane, planeNormal) / denom;
|
||||
if (s < 0) return null;
|
||||
const hit = Cesium.Cartesian3.multiplyByScalar(rayDir, s, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angle (radians, atan2 convention) of a point around a ring center in the
|
||||
* plane spanned by two orthonormal basis vectors.
|
||||
* @param {Cesium.Cartesian3} hitPoint - Point on/near the ring plane.
|
||||
* @param {Cesium.Cartesian3} center - Ring center.
|
||||
* @param {Cesium.Cartesian3} basisA - In-plane unit vector (angle 0).
|
||||
* @param {Cesium.Cartesian3} basisB - In-plane unit vector (angle +90°).
|
||||
* @returns {number} Angle in (−π, π].
|
||||
*/
|
||||
export function ringAngle(hitPoint, center, basisA, basisB) {
|
||||
const v = Cesium.Cartesian3.subtract(hitPoint, center, scratchW);
|
||||
return Math.atan2(Cesium.Cartesian3.dot(v, basisB), Cesium.Cartesian3.dot(v, basisA));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest signed angular delta from → to, wrap-safe.
|
||||
* @param {number} fromRad
|
||||
* @param {number} toRad
|
||||
* @returns {number} Delta in (−π, π].
|
||||
*/
|
||||
export function signedAngleDelta(fromRad, toRad) {
|
||||
const twoPi = 2 * Math.PI;
|
||||
let delta = (toRad - fromRad) % twoPi;
|
||||
if (delta > Math.PI) delta -= twoPi;
|
||||
if (delta <= -Math.PI) delta += twoPi;
|
||||
return delta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gizmo controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Entity id prefix for every gizmo part — the layer's pick-owner regex and
|
||||
* click-to-select guard key off this. Gizmo entities NEVER carry a
|
||||
* `cctvCameraId` property, so the layer's selection path ignores them. */
|
||||
export const GIZMO_ID_PREFIX = 'cctv-gizmo-';
|
||||
|
||||
const RING_SEGMENTS = 96;
|
||||
const RING_RADIUS_FACTOR = 0.12;
|
||||
const RING_RADIUS_MIN_M = 6;
|
||||
const RING_RADIUS_MAX_M = 40;
|
||||
const ARROW_LENGTH_FACTOR = 1.6;
|
||||
const DRAG_THROTTLE_MS = 16;
|
||||
const HOVER_THROTTLE_MS = 120;
|
||||
|
||||
const COLOR_RING_HEADING = Cesium.Color.fromCssColorString('#35d8ff');
|
||||
const COLOR_RING_PITCH = Cesium.Color.fromCssColorString('#ff5fd0');
|
||||
const COLOR_MOVE_EAST = Cesium.Color.fromCssColorString('#ff5252');
|
||||
const COLOR_MOVE_NORTH = Cesium.Color.fromCssColorString('#52ff7a');
|
||||
const COLOR_MOVE_UP = Cesium.Color.fromCssColorString('#5b8cff');
|
||||
const COLOR_HANDLE = Cesium.Color.fromCssColorString('#ffd97a');
|
||||
|
||||
const toRadians = (deg) => (deg * Math.PI) / 180;
|
||||
const toDeg = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Local ENU unit axes at an ECEF position.
|
||||
* @param {Cesium.Cartesian3} position
|
||||
* @returns {{east: Cesium.Cartesian3, north: Cesium.Cartesian3, up: Cesium.Cartesian3}}
|
||||
*/
|
||||
function enuAxes(position) {
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||
const rot = Cesium.Matrix4.getMatrix3(frame, new Cesium.Matrix3());
|
||||
return {
|
||||
east: Cesium.Matrix3.getColumn(rot, 0, new Cesium.Cartesian3()),
|
||||
north: Cesium.Matrix3.getColumn(rot, 1, new Cesium.Cartesian3()),
|
||||
up: Cesium.Matrix3.getColumn(rot, 2, new Cesium.Cartesian3()),
|
||||
};
|
||||
}
|
||||
|
||||
/** a*sa + b*sb (fresh Cartesian3). */
|
||||
function combine2(a, sa, b, sb) {
|
||||
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
|
||||
const t = Cesium.Cartesian3.multiplyByScalar(b, sb, new Cesium.Cartesian3());
|
||||
return Cesium.Cartesian3.add(out, t, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* View-frame unit vectors for a pose within a local ENU frame: horizontal
|
||||
* forward, viewer-right, and the full pitched view axis.
|
||||
* @param {number} headingDeg - Compass heading (0 = north, +east).
|
||||
* @param {number} pitchDeg - Elevation (+up).
|
||||
* @param {{east, north, up}} axes - ENU axes at the mount.
|
||||
*/
|
||||
function viewAxesFor(headingDeg, pitchDeg, axes) {
|
||||
const h = toRadians(headingDeg);
|
||||
const p = toRadians(pitchDeg);
|
||||
const forwardHoriz = combine2(axes.east, Math.sin(h), axes.north, Math.cos(h));
|
||||
const right = combine2(axes.east, Math.cos(h), axes.north, -Math.sin(h));
|
||||
const view = combine2(forwardHoriz, Math.cos(p), axes.up, Math.sin(p));
|
||||
return { forwardHoriz, right, view };
|
||||
}
|
||||
|
||||
/** Circle polyline positions around `center` in the (basisA, basisB) plane. */
|
||||
function ringPositions(center, radius, basisA, basisB) {
|
||||
const positions = [];
|
||||
for (let i = 0; i <= RING_SEGMENTS; i++) {
|
||||
const angle = (i / RING_SEGMENTS) * 2 * Math.PI;
|
||||
const offset = combine2(basisA, Math.cos(angle) * radius, basisB, Math.sin(angle) * radius);
|
||||
positions.push(Cesium.Cartesian3.add(center, offset, offset));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the calibration gizmo controller (design §3c). Renders grab-able
|
||||
* handles for the ACTIVE camera's 7 calibration DOF and turns pointer drags
|
||||
* into calibration patches through a narrow callback interface — the gizmo
|
||||
* never touches layer records/stores directly.
|
||||
*
|
||||
* @param {Object} deps
|
||||
* @param {Cesium.Viewer} deps.viewer
|
||||
* @param {function(): Object|null} deps.getActiveRecord - Returns the record
|
||||
* the gizmo should attach to, or null to hide (layer decides: enabled +
|
||||
* calibration mode + active camera).
|
||||
* @param {function(Object, Object): void} deps.applyPatch - Transient
|
||||
* per-mousemove calibration patch (partial 7-field object, absolute offset
|
||||
* values) + the PINNED drag record it applies to.
|
||||
* @param {function(Object): void} deps.endPatch - Commit-grade tail on drag
|
||||
* end, for the PINNED drag record.
|
||||
* @returns {{setEnabled: function(boolean): void, refresh: function(): void,
|
||||
* destroy: function(): void, isDragging: function(): boolean,
|
||||
* isEnabled: function(): boolean}}
|
||||
*/
|
||||
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
|
||||
let enabled = false;
|
||||
let drag = null; // { part, startCal, basePose, refs... }
|
||||
let hoveredId = null;
|
||||
let lastDragAt = 0;
|
||||
let lastHoverAt = 0;
|
||||
const entities = new Map(); // part -> Entity
|
||||
|
||||
const scene = viewer.scene;
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
|
||||
|
||||
/** Adds (or re-adds) a gizmo entity, clearing any stale duplicate id. */
|
||||
function addEntity(part, options) {
|
||||
const id = `${GIZMO_ID_PREFIX}${part}`;
|
||||
const stale = viewer.entities.getById(id);
|
||||
if (stale) viewer.entities.remove(stale);
|
||||
const entity = viewer.entities.add({ id, show: false, ...options });
|
||||
entities.set(part, entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function polylinePart(part, color, width, arrow = false) {
|
||||
addEntity(part, {
|
||||
...(arrow ? {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
} : {}),
|
||||
polyline: {
|
||||
positions: [],
|
||||
width,
|
||||
material: arrow ? new Cesium.PolylineArrowMaterialProperty(color) : color.withAlpha(0.9),
|
||||
// Standard gizmo convention: parts behind geometry stay clearly
|
||||
// visible, just dimmed (a street-level mount buries half the heading
|
||||
// ring in sloped photogrammetry tiles — smoke-tested 2026-07-05).
|
||||
depthFailMaterial: color.withAlpha(0.45),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function pointPart(part, color, pixelSize) {
|
||||
addEntity(part, {
|
||||
position: Cesium.Cartesian3.ZERO,
|
||||
point: {
|
||||
pixelSize,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.7),
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
polylinePart('ring-heading', COLOR_RING_HEADING, 3);
|
||||
polylinePart('ring-pitch', COLOR_RING_PITCH, 3);
|
||||
polylinePart('move-east', COLOR_MOVE_EAST, 12, true);
|
||||
polylinePart('move-north', COLOR_MOVE_NORTH, 12, true);
|
||||
polylinePart('move-up', COLOR_MOVE_UP, 12, true);
|
||||
pointPart('handle-range', COLOR_HANDLE, 13);
|
||||
pointPart('handle-fov-l', COLOR_HANDLE, 10);
|
||||
pointPart('handle-fov-r', COLOR_HANDLE, 10);
|
||||
|
||||
function hideAll() {
|
||||
for (const entity of entities.values()) entity.show = false;
|
||||
}
|
||||
|
||||
/** Recomputes every handle's geometry from the active record's CURRENT pose
|
||||
* (the PINNED record while a drag is live, so handles never jump to another
|
||||
* camera mid-interaction). */
|
||||
function refresh() {
|
||||
const record = enabled ? (drag ? drag.record : getActiveRecord()) : null;
|
||||
const positions = record?.frustumPositions;
|
||||
const camera = record?.camera;
|
||||
if (!record || !positions || !camera) {
|
||||
hideAll();
|
||||
return;
|
||||
}
|
||||
const mount = positions.mount;
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const rangeM = Math.max(1, Number(camera.rangeM) || 1);
|
||||
const radius = Math.min(RING_RADIUS_MAX_M, Math.max(RING_RADIUS_MIN_M, rangeM * RING_RADIUS_FACTOR));
|
||||
const arrowLen = radius * ARROW_LENGTH_FACTOR;
|
||||
|
||||
entities.get('ring-heading').polyline.positions = ringPositions(mount, radius, axes.north, axes.east);
|
||||
entities.get('ring-pitch').polyline.positions = ringPositions(mount, radius, forwardHoriz, axes.up);
|
||||
const arrowTo = (dir) => [mount, Cesium.Cartesian3.add(
|
||||
mount, Cesium.Cartesian3.multiplyByScalar(dir, arrowLen, new Cesium.Cartesian3()), new Cesium.Cartesian3()
|
||||
)];
|
||||
const eastArrow = arrowTo(axes.east);
|
||||
const northArrow = arrowTo(axes.north);
|
||||
const upArrow = arrowTo(axes.up);
|
||||
entities.get('move-east').polyline.positions = eastArrow;
|
||||
entities.get('move-east').position = eastArrow[1];
|
||||
entities.get('move-north').polyline.positions = northArrow;
|
||||
entities.get('move-north').position = northArrow[1];
|
||||
entities.get('move-up').polyline.positions = upArrow;
|
||||
entities.get('move-up').position = upArrow[1];
|
||||
entities.get('handle-range').position = positions.capCenter;
|
||||
entities.get('handle-fov-l').position = Cesium.Cartesian3.midpoint(positions.tl, positions.bl, new Cesium.Cartesian3());
|
||||
entities.get('handle-fov-r').position = Cesium.Cartesian3.midpoint(positions.tr, positions.br, new Cesium.Cartesian3());
|
||||
for (const entity of entities.values()) entity.show = true;
|
||||
}
|
||||
|
||||
/** Extracts a gizmo part name from a pick result, or null. */
|
||||
function gizmoPartFrom(picked) {
|
||||
const id = picked?.id?.id ?? picked?.id;
|
||||
if (typeof id !== 'string' || !id.startsWith(GIZMO_ID_PREFIX)) return null;
|
||||
return id.slice(GIZMO_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
function pickGizmoPart(windowPosition) {
|
||||
// Gizmo primitives render depth-test-free and should be the topmost pick.
|
||||
// Take the cheap single-result path first: a full drillPick can stall for
|
||||
// tens of seconds under software GL even when its first result is already
|
||||
// the gizmo. Retain drillPick for uncommon overlap/fallback cases.
|
||||
try {
|
||||
const picked = scene.pick(windowPosition, 14, 14);
|
||||
const part = gizmoPartFrom(picked);
|
||||
if (part && entities.get(part)?.show) return part;
|
||||
} catch (err) {
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] pick threw:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
let results = [];
|
||||
try {
|
||||
results = scene.drillPick(windowPosition, 6, 14, 14) || [];
|
||||
} catch (err) {
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] drillPick threw:', err?.message || err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo] drillPick @', windowPosition?.x, windowPosition?.y, '→',
|
||||
JSON.stringify(results.map((r) => String(r?.id?.id ?? r?.id ?? r?.primitive?.constructor?.name))));
|
||||
}
|
||||
for (const picked of results) {
|
||||
const part = gizmoPartFrom(picked);
|
||||
if (part && entities.get(part)?.show) return part;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickRay(windowPosition) {
|
||||
try {
|
||||
return viewer.camera.getPickRay(windowPosition) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCursor(value) {
|
||||
if (scene.canvas?.style) scene.canvas.style.cursor = value;
|
||||
}
|
||||
|
||||
function setHovered(part) {
|
||||
const nextId = part ? `${GIZMO_ID_PREFIX}${part}` : null;
|
||||
if (nextId === hoveredId) return;
|
||||
hoveredId = nextId;
|
||||
// Hover feedback must NEVER touch polyline geometry: a width change makes
|
||||
// Cesium rebuild the batched polyline primitive, and until the next render
|
||||
// the part vanishes from the pick buffer — so the very hover that finds a
|
||||
// ring makes the following LEFT_DOWN miss it (root-caused via the QA
|
||||
// harness's synthetic drag, 2026-07-05). Rings/arrows get cursor feedback
|
||||
// only; point handles are PointPrimitives (live-updatable, no rebuild), so
|
||||
// they keep the size bump.
|
||||
for (const [name, entity] of entities.entries()) {
|
||||
if (!entity.point) continue;
|
||||
const hot = hoveredId === `${GIZMO_ID_PREFIX}${name}`;
|
||||
entity.point.pixelSize = (name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
|
||||
}
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
}
|
||||
|
||||
/** Captures the fixed drag reference frame + start values for a part. */
|
||||
function beginDrag(part, windowPosition) {
|
||||
const record = getActiveRecord();
|
||||
const positions = record?.frustumPositions;
|
||||
const camera = record?.camera;
|
||||
const geometry = record?.frustumGeometry;
|
||||
if (!record || !positions || !camera?.basePose || !geometry) return false;
|
||||
const ray = pickRay(windowPosition);
|
||||
if (!ray) return false;
|
||||
|
||||
const mount = Cesium.Cartesian3.clone(positions.mount);
|
||||
const capCenter = Cesium.Cartesian3.clone(positions.capCenter);
|
||||
const axes = enuAxes(mount);
|
||||
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
|
||||
const startCal = { ...camera.calibration };
|
||||
const basePose = { ...camera.basePose };
|
||||
const state = {
|
||||
// The drag is PINNED to this record: patches must never follow a
|
||||
// mid-drag active-camera switch (voice select / auto-hop) onto a
|
||||
// different camera — its basePose makes the captured offsets wrong.
|
||||
record,
|
||||
part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view,
|
||||
effectiveRangeM: geometry.rangeM,
|
||||
startAngle: null,
|
||||
startParam: null,
|
||||
};
|
||||
|
||||
if (part === 'ring-heading') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||
if (!hit) return false;
|
||||
state.startAngle = ringAngle(hit, mount, axes.north, axes.east); // compass convention
|
||||
} else if (part === 'ring-pitch') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return false;
|
||||
state.startAngle = ringAngle(hit, mount, forwardHoriz, axes.up); // 0 = level, + = up
|
||||
} else if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const dir = part === 'move-east' ? axes.east : part === 'move-north' ? axes.north : axes.up;
|
||||
state.axisDir = dir;
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, dir);
|
||||
if (t === null) return false;
|
||||
state.startParam = t;
|
||||
} else if (part === 'handle-range') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, view);
|
||||
if (t === null) return false;
|
||||
state.startParam = t;
|
||||
} else if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||
if (!hit) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
drag = state;
|
||||
setCursor('grabbing');
|
||||
if (scene.screenSpaceCameraController) {
|
||||
scene.screenSpaceCameraController.enableInputs = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Converts the current mouse ray into a calibration patch for the drag part. */
|
||||
function dragPatch(windowPosition) {
|
||||
const ray = pickRay(windowPosition);
|
||||
if (!ray || !drag) return null;
|
||||
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
|
||||
|
||||
if (part === 'ring-heading') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, axes.up);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, axes.north, axes.east);
|
||||
return { headingDeg: startCal.headingDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
}
|
||||
if (part === 'ring-pitch') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, mount, right);
|
||||
if (!hit) return null;
|
||||
const angle = ringAngle(hit, mount, forwardHoriz, axes.up);
|
||||
return { pitchDeg: startCal.pitchDeg + toDeg(signedAngleDelta(drag.startAngle, angle)) };
|
||||
}
|
||||
if (part === 'move-east' || part === 'move-north' || part === 'move-up') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, drag.axisDir);
|
||||
if (t === null) return null;
|
||||
const delta = t - drag.startParam;
|
||||
if (part === 'move-east') return { offsetEastM: startCal.offsetEastM + delta };
|
||||
if (part === 'move-north') return { offsetNorthM: startCal.offsetNorthM + delta };
|
||||
return { heightM: startCal.heightM + delta };
|
||||
}
|
||||
if (part === 'handle-range') {
|
||||
const t = closestParamOnAxis(ray.origin, ray.direction, mount, view);
|
||||
if (t === null || !(basePose.rangeM > 0)) return null;
|
||||
return { rangeScale: Math.max(0.01, t) / basePose.rangeM };
|
||||
}
|
||||
if (part === 'handle-fov-l' || part === 'handle-fov-r') {
|
||||
const hit = rayPlaneIntersect(ray.origin, ray.direction, capCenter, view);
|
||||
if (!hit) return null;
|
||||
const lateral = Math.abs(Cesium.Cartesian3.dot(
|
||||
Cesium.Cartesian3.subtract(hit, capCenter, new Cesium.Cartesian3()), right
|
||||
));
|
||||
const halfW = Math.max(0.5, lateral);
|
||||
const fovDeg = toDeg(2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)));
|
||||
return { fovDeg: fovDeg - basePose.fovDeg };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (!drag) return;
|
||||
const record = drag.record;
|
||||
drag = null;
|
||||
if (scene.screenSpaceCameraController) {
|
||||
scene.screenSpaceCameraController.enableInputs = true;
|
||||
}
|
||||
setCursor(hoveredId ? 'grab' : '');
|
||||
endPatch(record);
|
||||
}
|
||||
|
||||
/** QA/debug tracing, on when the page sets `window.__gevGizmoDebug = true`. */
|
||||
function debugLog(...args) {
|
||||
if (typeof window !== 'undefined' && window.__gevGizmoDebug) {
|
||||
console.debug('[CCTV:gizmo]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
if (!enabled) return;
|
||||
const part = pickGizmoPart(event.position);
|
||||
debugLog('LEFT_DOWN', event.position, 'part:', part);
|
||||
if (!part) return;
|
||||
const started = beginDrag(part, event.position);
|
||||
debugLog('beginDrag', part, '→', started);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
|
||||
handler.setInputAction((event) => {
|
||||
if (!enabled) return;
|
||||
const now = Date.now();
|
||||
if (drag) {
|
||||
if (now - lastDragAt < DRAG_THROTTLE_MS) return;
|
||||
lastDragAt = now;
|
||||
// A mid-drag active-camera switch (voice select / auto-hop) ends the
|
||||
// drag: what was dragged so far stays committed to the PINNED record.
|
||||
if (getActiveRecord() !== drag.record) {
|
||||
debugLog('drag ended: active camera changed mid-drag');
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
const patch = dragPatch(event.endPosition);
|
||||
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
|
||||
if (patch) applyPatch(patch, drag.record);
|
||||
return;
|
||||
}
|
||||
if (now - lastHoverAt < HOVER_THROTTLE_MS) return;
|
||||
lastHoverAt = now;
|
||||
setHovered(pickGizmoPart(event.endPosition));
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
|
||||
handler.setInputAction(() => {
|
||||
if (!enabled) return;
|
||||
endDrag();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
|
||||
return {
|
||||
setEnabled(value) {
|
||||
enabled = !!value;
|
||||
if (!enabled) {
|
||||
if (drag) endDrag();
|
||||
setHovered(null);
|
||||
hideAll();
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
destroy() {
|
||||
if (drag) endDrag();
|
||||
handler.destroy();
|
||||
for (const entity of entities.values()) {
|
||||
viewer.entities.remove(entity);
|
||||
}
|
||||
entities.clear();
|
||||
setCursor('');
|
||||
},
|
||||
isDragging: () => !!drag,
|
||||
isEnabled: () => enabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// src/data/cctvGizmo.test.mjs — pure drag math for the CCTV calibration gizmo
|
||||
// documented in docs/CURRENT-STATE.md.
|
||||
//
|
||||
// Locks:
|
||||
// - closestParamOnAxis returns the metre-parameter along the AXIS of the
|
||||
// point nearest the mouse ray (the workhorse for arrow/range drags), and
|
||||
// refuses near-parallel configurations instead of exploding;
|
||||
// - rayPlaneIntersect refuses grazing rays (|dir·normal| < 0.08 — spec §5
|
||||
// precision guard) and behind-origin hits;
|
||||
// - ringAngle/signedAngleDelta give quadrant-correct, wrap-safe angles for
|
||||
// the heading/pitch ring drags.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
closestParamOnAxis,
|
||||
rayPlaneIntersect,
|
||||
ringAngle,
|
||||
signedAngleDelta,
|
||||
} from './cctvGizmo.js';
|
||||
|
||||
const c3 = (x, y, z) => new Cesium.Cartesian3(x, y, z);
|
||||
|
||||
test('closestParamOnAxis: perpendicular ray hits the axis at its own offset', () => {
|
||||
// Axis along +X from origin. Ray shooting straight down (-Z) from (10, 0, 5):
|
||||
// nearest axis point is x=10 → t=10.
|
||||
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(0, 0, 0), c3(1, 0, 0));
|
||||
assert.ok(Math.abs(t - 10) < 1e-9, `expected 10, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: skew ray resolves to the geometric closest point', () => {
|
||||
// Axis +X. Ray from (0, 10, 0) toward (1, -1, 0)/√2 passes closest to the
|
||||
// axis around x=10 (it reaches y=0 at x=10).
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, -1, 0), new Cesium.Cartesian3());
|
||||
const t = closestParamOnAxis(c3(0, 10, 0), dir, c3(0, 0, 0), c3(1, 0, 0));
|
||||
assert.ok(Math.abs(t - 10) < 1e-6, `expected ~10, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: axis origin offset shifts the parameter', () => {
|
||||
const t = closestParamOnAxis(c3(10, 0, 5), c3(0, 0, -1), c3(4, 0, 0), c3(1, 0, 0));
|
||||
assert.ok(Math.abs(t - 6) < 1e-9, `expected 6, got ${t}`);
|
||||
});
|
||||
|
||||
test('closestParamOnAxis: near-parallel ray/axis returns null', () => {
|
||||
assert.equal(closestParamOnAxis(c3(0, 1, 0), c3(1, 0, 0), c3(0, 0, 0), c3(1, 0, 0)), null);
|
||||
const nearly = Cesium.Cartesian3.normalize(c3(1, 1e-9, 0), new Cesium.Cartesian3());
|
||||
assert.equal(closestParamOnAxis(c3(0, 1, 0), nearly, c3(0, 0, 0), c3(1, 0, 0)), null);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: straight-on hit lands at the expected point', () => {
|
||||
const hit = rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, -1), c3(0, 0, 0), c3(0, 0, 1));
|
||||
assert.ok(hit, 'expected a hit');
|
||||
assert.ok(Cesium.Cartesian3.distance(hit, c3(0, 0, 0)) < 1e-9);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: oblique hit resolves correctly', () => {
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -1), new Cesium.Cartesian3());
|
||||
const hit = rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1));
|
||||
assert.ok(hit);
|
||||
assert.ok(Cesium.Cartesian3.distance(hit, c3(5, 0, 0)) < 1e-9, `got ${hit}`);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: grazing ray (|dir·n| < 0.08) returns null', () => {
|
||||
// dir almost in-plane: z component 0.05 < 0.08 threshold.
|
||||
const dir = Cesium.Cartesian3.normalize(c3(1, 0, -0.05), new Cesium.Cartesian3());
|
||||
assert.equal(rayPlaneIntersect(c3(0, 0, 5), dir, c3(0, 0, 0), c3(0, 0, 1)), null);
|
||||
});
|
||||
|
||||
test('rayPlaneIntersect: plane behind the ray origin returns null', () => {
|
||||
assert.equal(rayPlaneIntersect(c3(0, 0, 10), c3(0, 0, 1), c3(0, 0, 0), c3(0, 0, 1)), null);
|
||||
});
|
||||
|
||||
test('ringAngle: quadrant sweep in the (basisA, basisB) frame', () => {
|
||||
const center = c3(0, 0, 0);
|
||||
const a = c3(1, 0, 0);
|
||||
const b = c3(0, 1, 0);
|
||||
assert.ok(Math.abs(ringAngle(c3(5, 0, 0), center, a, b) - 0) < 1e-9);
|
||||
assert.ok(Math.abs(ringAngle(c3(0, 5, 0), center, a, b) - Math.PI / 2) < 1e-9);
|
||||
assert.ok(Math.abs(Math.abs(ringAngle(c3(-5, 0, 0), center, a, b)) - Math.PI) < 1e-9);
|
||||
assert.ok(Math.abs(ringAngle(c3(0, -5, 0), center, a, b) + Math.PI / 2) < 1e-9);
|
||||
});
|
||||
|
||||
test('signedAngleDelta: shortest-path wrap', () => {
|
||||
const d2r = (d) => (d * Math.PI) / 180;
|
||||
assert.ok(Math.abs(signedAngleDelta(d2r(170), d2r(-170)) - d2r(20)) < 1e-9);
|
||||
assert.ok(Math.abs(signedAngleDelta(d2r(-170), d2r(170)) - d2r(-20)) < 1e-9);
|
||||
assert.ok(Math.abs(signedAngleDelta(d2r(10), d2r(30)) - d2r(20)) < 1e-9);
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* @module cctvLod
|
||||
*
|
||||
* Pure selection policy for the citywide ambient CCTV card tier.
|
||||
* Cesium-specific visibility projection stays in cctv.js; this module turns
|
||||
* the resulting in-view candidates into a bounded, nearest-first ambient
|
||||
* card set, keeps that set stable across small camera moves (eviction
|
||||
* grace), and paces static-frame refreshes per source.
|
||||
*
|
||||
* The engine retains zoom-scaled budgets, distance-ranked in-view selection,
|
||||
* an eviction-grace planner, and source-aware refresh cadences, retargeted
|
||||
* from the rejected world-space static-plane ring to the
|
||||
* screen-space thumbnail cards (see
|
||||
* the ambient-card behavior documented in `docs/CURRENT-STATE.md`).
|
||||
*/
|
||||
|
||||
// Ambient-card budgets (follow-up round 2, item C: raised 16/24/32 → 20/28/40 —
|
||||
// "a lot of empty space"). Tunable as a set, together with the card
|
||||
// scale waypoints (1,800/6,000/9,500 m, cctvCards.js) and
|
||||
// CCTV_CARD_MIN_SEP_PX: budgets say how many cameras HOLD cards, the
|
||||
// waypoints and separation say how many fit on screen.
|
||||
export const CCTV_AMBIENT_CARD_MAX = 40;
|
||||
export const CCTV_AMBIENT_CARD_MID = 28;
|
||||
export const CCTV_AMBIENT_CARD_MIN = 20;
|
||||
|
||||
const STREET_HEIGHT_M = 2_500;
|
||||
const CITY_HEIGHT_M = 15_000;
|
||||
const DEFAULT_STATIC_REFRESH_MS = 5 * 60 * 1000;
|
||||
const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'austin transportation & public works': 5 * 60 * 1000,
|
||||
'transport for london': 3 * 60 * 1000,
|
||||
caltrans: 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the bounded ambient-card budget for the current viewer height.
|
||||
* Card counts stay inside the 20..40 range (follow-up round 2): street level
|
||||
* keeps the overlay sparse, metro scale earns the full ring.
|
||||
*
|
||||
* @param {number} cameraHeightM
|
||||
* @returns {{cardLimit:number}}
|
||||
*/
|
||||
export function cctvLodBudgets(cameraHeightM) {
|
||||
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
|
||||
if (height <= STREET_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
|
||||
}
|
||||
if (height <= CITY_HEIGHT_M) {
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MID };
|
||||
}
|
||||
return { cardLimit: CCTV_AMBIENT_CARD_MAX };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-level incumbency (field test finding 4, 2026-07-29): a
|
||||
* camera currently holding a card ranks with its distance discounted by this
|
||||
* factor, so a small camera move never batch-swaps the ring — a non-carded
|
||||
* camera displaces a carded one only when it is meaningfully (>20%) closer.
|
||||
*/
|
||||
export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
|
||||
|
||||
/**
|
||||
* Effective ranking distance for the ambient-card selection: incumbents
|
||||
* (cameras that already hold a card) get the 20% distance discount.
|
||||
* @param {number} distanceKm
|
||||
* @param {boolean} isIncumbent
|
||||
* @param {number} [factor]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
return isIncumbent ? km * factor : km;
|
||||
}
|
||||
|
||||
/** Fraction of the ambient-card rank contributed by screen-center distance. */
|
||||
export const CCTV_CARD_CENTER_WEIGHT = 0.5;
|
||||
|
||||
/** Robust pool-distance percentile used to scale the screen-space term. */
|
||||
export const CCTV_CARD_SPREAD_PERCENTILE = 0.9;
|
||||
|
||||
/** True only when both viewport dimensions are finite positive pixels. */
|
||||
export function hasFiniteCctvViewport(viewW, viewH) {
|
||||
return Number.isFinite(viewW) && viewW > 0
|
||||
&& Number.isFinite(viewH) && viewH > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an anchor's normalized distance from screen center: zero at center,
|
||||
* one at or beyond a viewport corner.
|
||||
* @param {number} sx
|
||||
* @param {number} sy
|
||||
* @param {number} viewW
|
||||
* @param {number} viewH
|
||||
* @returns {number}
|
||||
*/
|
||||
export function screenCenterFraction(sx, sy, viewW, viewH) {
|
||||
if (!hasFiniteCctvViewport(viewW, viewH)) return 0;
|
||||
if (!Number.isFinite(sx) || !Number.isFinite(sy)) return 1;
|
||||
const centerX = viewW / 2;
|
||||
const centerY = viewH / 2;
|
||||
const halfDiagonalPx = Math.hypot(centerX, centerY) || 1;
|
||||
return Math.min(1, Math.hypot(sx - centerX, sy - centerY) / halfDiagonalPx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a robust distance scale for the eligible candidate pool.
|
||||
* @param {number[]} distancesKm
|
||||
* @param {number} [percentile]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cctvCandidateSpreadKm(
|
||||
distancesKm,
|
||||
percentile = CCTV_CARD_SPREAD_PERCENTILE,
|
||||
) {
|
||||
const finite = (Array.isArray(distancesKm) ? distancesKm : [])
|
||||
.filter((distanceKm) => Number.isFinite(distanceKm) && distanceKm >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!finite.length) return 0;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(percentile) ? percentile : 0));
|
||||
return finite[Math.round(p * (finite.length - 1))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blends viewer distance with screen-center distance in common km units.
|
||||
* @param {number} distanceKm
|
||||
* @param {number} centerFraction
|
||||
* @param {number} spreadKm
|
||||
* @param {number} [weight]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function blendCenterRankKm(
|
||||
distanceKm,
|
||||
centerFraction,
|
||||
spreadKm,
|
||||
weight = CCTV_CARD_CENTER_WEIGHT,
|
||||
) {
|
||||
const km = Number.isFinite(distanceKm) ? Math.max(0, distanceKm) : Infinity;
|
||||
if (!Number.isFinite(km)) return Infinity;
|
||||
const w = Math.min(1, Math.max(0, Number.isFinite(weight) ? weight : 0));
|
||||
const spread = Number.isFinite(spreadKm) && spreadKm > 0 ? spreadKm : 0;
|
||||
const fraction = Number.isFinite(centerFraction)
|
||||
? Math.min(1, Math.max(0, centerFraction))
|
||||
: 1;
|
||||
return (1 - w) * km + w * spread * fraction;
|
||||
}
|
||||
|
||||
// Screen-distribution grid (follow-up round 2, item C — "a lot of empty space"):
|
||||
// pure nearest-first selection clusters winners at screen center (nearest ==
|
||||
// most central at typical view pitch) and leaves the periphery bare. The
|
||||
// viewport is bucketed into this grid and every occupied cell gets its best
|
||||
// candidate before global rank fills the rest. Tunable together with
|
||||
// the budgets above.
|
||||
export const CCTV_CARD_GRID_COLS = 5;
|
||||
export const CCTV_CARD_GRID_ROWS = 4;
|
||||
|
||||
/**
|
||||
* Screen-space distribution pass (follow-up round 2, item C). Buckets the
|
||||
* viewport into a CCTV_CARD_GRID_COLS × CCTV_CARD_GRID_ROWS grid, assigns
|
||||
* each candidate to its cell (anchors just outside the viewport clamp to
|
||||
* the edge cells), ranks within cells by effective distance (`rankKm` —
|
||||
* incumbency discount already applied by the caller), then takes each
|
||||
* occupied cell's best — nearest cell-winners first, so an under-budget cut
|
||||
* stays deterministic — until the budget is filled; leftover budget goes to
|
||||
* the remaining candidates in global rank order. Empty cells are skipped.
|
||||
* Pure and allocation-honest: decides only WHICH cameras hold cards — the
|
||||
* per-frame draw-pass declutter stays authoritative over what paints.
|
||||
*
|
||||
* @param {Array<{id:string,sx:number,sy:number,rankKm:number}>} candidates
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.budget] - Max ids returned.
|
||||
* @param {number} [options.viewW] - Viewport width (CSS px).
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @param {number} [options.cols]
|
||||
* @param {number} [options.rows]
|
||||
* @returns {string[]} Winner ids in priority order (cell winners, then
|
||||
* global-rank fill).
|
||||
*/
|
||||
export function distributeCctvCards(candidates, {
|
||||
budget = CCTV_AMBIENT_CARD_MAX,
|
||||
viewW = 0,
|
||||
viewH = 0,
|
||||
cols = CCTV_CARD_GRID_COLS,
|
||||
rows = CCTV_CARD_GRID_ROWS,
|
||||
} = {}) {
|
||||
const cap = Number.isFinite(budget) ? Math.max(0, Math.floor(budget)) : 0;
|
||||
const valid = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((c) => c && typeof c.id === 'string' && c.id
|
||||
&& Number.isFinite(c.sx) && Number.isFinite(c.sy));
|
||||
if (!cap || !valid.length) return [];
|
||||
const validViewport = hasFiniteCctvViewport(viewW, viewH);
|
||||
const width = validViewport ? viewW : 1;
|
||||
const height = validViewport ? viewH : 1;
|
||||
const nCols = Math.max(1, Math.floor(cols));
|
||||
const nRows = Math.max(1, Math.floor(rows));
|
||||
const byRank = (a, b) => (Number.isFinite(a.rankKm) ? a.rankKm : Infinity)
|
||||
- (Number.isFinite(b.rankKm) ? b.rankKm : Infinity)
|
||||
|| a.id.localeCompare(b.id);
|
||||
|
||||
/** @type {Map<number, Array>} occupied cell -> its candidates */
|
||||
const cells = new Map();
|
||||
for (const candidate of valid) {
|
||||
const col = Math.min(nCols - 1, Math.max(0, Math.floor((candidate.sx / width) * nCols)));
|
||||
const row = Math.min(nRows - 1, Math.max(0, Math.floor((candidate.sy / height) * nRows)));
|
||||
const key = row * nCols + col;
|
||||
const bucket = cells.get(key);
|
||||
if (bucket) bucket.push(candidate);
|
||||
else cells.set(key, [candidate]);
|
||||
}
|
||||
|
||||
const cellBest = [];
|
||||
const rest = [];
|
||||
for (const bucket of cells.values()) {
|
||||
bucket.sort(byRank);
|
||||
cellBest.push(bucket[0]);
|
||||
for (let i = 1; i < bucket.length; i++) rest.push(bucket[i]);
|
||||
}
|
||||
cellBest.sort(byRank);
|
||||
rest.sort(byRank);
|
||||
|
||||
const picked = cellBest.slice(0, cap);
|
||||
for (const candidate of rest) {
|
||||
if (picked.length >= cap) break;
|
||||
picked.push(candidate);
|
||||
}
|
||||
return picked.map((candidate) => candidate.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the ambient card cameras from already projected in-view
|
||||
* candidates: nearest-first up to the zoom budget. Static image cameras
|
||||
* only — video cameras stay icon-only until explicitly activated (the frame
|
||||
* endpoint pacing is built for stills), and the ACTIVE camera is excluded
|
||||
* upstream by the caller (its monitor plane is the richer representation).
|
||||
* Cameras in `incumbentIds` (current card holders) rank with the 20%
|
||||
* incumbency discount (finding 4) so the ring changes gradually.
|
||||
*
|
||||
* When the caller supplies viewport dimensions (`viewW`/`viewH`) and screen
|
||||
* anchors (`sx`/`sy` per candidate), ranking blends eye distance with
|
||||
* screen-center offset before the existing screen-distribution pass. Without
|
||||
* screen info the original nearest-first cap applies unchanged.
|
||||
*
|
||||
* @param {Array<{id:string,distanceKm:number,inView:boolean,isVideo?:boolean,sx?:number,sy?:number}>} candidates
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.cameraHeightM]
|
||||
* @param {Iterable<string>|Set<string>} [options.incumbentIds]
|
||||
* @param {number} [options.viewW] - Viewport width (CSS px) — enables the
|
||||
* screen-distribution pass.
|
||||
* @param {number} [options.viewH] - Viewport height (CSS px).
|
||||
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
|
||||
*/
|
||||
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
|
||||
const budgets = cctvLodBudgets(cameraHeightM);
|
||||
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
|
||||
const screened = hasFiniteCctvViewport(viewW, viewH);
|
||||
if (!screened) {
|
||||
// Preserve the pre-refinement nearest-first path byte-for-behavior,
|
||||
// including its acceptance of any non-empty string ID. The stricter
|
||||
// valid-viewport rules below must not leak into this compatibility branch.
|
||||
const inView = (Array.isArray(candidates) ? candidates : [])
|
||||
.filter((candidate) => candidate && typeof candidate.id === 'string' && candidate.id)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
distanceKm: Number.isFinite(candidate.distanceKm) ? Math.max(0, candidate.distanceKm) : Infinity,
|
||||
rankKm: incumbentRankKm(candidate.distanceKm, incumbents.has(candidate.id)),
|
||||
inView: candidate.inView === true,
|
||||
isVideo: candidate.isVideo === true,
|
||||
}))
|
||||
.filter((candidate) => candidate.inView)
|
||||
.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
const cardIds = [];
|
||||
const seen = new Set();
|
||||
for (const candidate of inView) {
|
||||
if (candidate.isVideo || seen.has(candidate.id)) continue;
|
||||
cardIds.push(candidate.id);
|
||||
seen.add(candidate.id);
|
||||
if (cardIds.length >= budgets.cardLimit) break;
|
||||
}
|
||||
return { cardIds, budgets };
|
||||
}
|
||||
const eligibleById = new Map();
|
||||
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
||||
if (!candidate || typeof candidate.id !== 'string' || !candidate.id.trim()
|
||||
|| candidate.inView !== true || candidate.isVideo === true
|
||||
|| !Number.isFinite(candidate.distanceKm)) continue;
|
||||
const normalized = {
|
||||
id: candidate.id,
|
||||
distanceKm: Math.max(0, candidate.distanceKm),
|
||||
rankKm: Infinity,
|
||||
sx: Number.isFinite(candidate.sx) ? candidate.sx : NaN,
|
||||
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
|
||||
};
|
||||
const current = eligibleById.get(normalized.id);
|
||||
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
|
||||
eligibleById.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
// Only rankable, still-image, in-view, deduplicated candidates influence the
|
||||
// robust distance scale or consume a bounded ambient-card slot.
|
||||
const stills = [...eligibleById.values()];
|
||||
|
||||
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
|
||||
for (const candidate of stills) {
|
||||
const blendedKm = blendCenterRankKm(
|
||||
candidate.distanceKm,
|
||||
screenCenterFraction(candidate.sx, candidate.sy, viewW, viewH),
|
||||
spreadKm,
|
||||
);
|
||||
candidate.rankKm = incumbentRankKm(blendedKm, incumbents.has(candidate.id));
|
||||
}
|
||||
stills.sort((a, b) => (
|
||||
a.rankKm - b.rankKm
|
||||
|| a.distanceKm - b.distanceKm
|
||||
|| a.id.localeCompare(b.id)
|
||||
));
|
||||
|
||||
let cardIds;
|
||||
// Item C: screen-distributed fill. Candidates lacking screen anchors are
|
||||
// dropped by the distribution pass; top up from the ranked pool
|
||||
// (defensive — cctv.js always projects anchors for in-view candidates).
|
||||
cardIds = distributeCctvCards(stills, { budget: budgets.cardLimit, viewW, viewH });
|
||||
if (cardIds.length < budgets.cardLimit) {
|
||||
const chosen = new Set(cardIds);
|
||||
for (const candidate of stills) {
|
||||
if (cardIds.length >= budgets.cardLimit) break;
|
||||
if (!chosen.has(candidate.id)) cardIds.push(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { cardIds, budgets };
|
||||
}
|
||||
|
||||
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
|
||||
if (screened) {
|
||||
const centerDelta = screenCenterFraction(a.sx, a.sy, viewW, viewH)
|
||||
- screenCenterFraction(b.sx, b.sy, viewW, viewH);
|
||||
if (centerDelta) return centerDelta;
|
||||
}
|
||||
const ax = Number.isFinite(a.sx) ? a.sx : Infinity;
|
||||
const bx = Number.isFinite(b.sx) ? b.sx : Infinity;
|
||||
if (ax !== bx) return ax < bx ? -1 : 1;
|
||||
const ay = Number.isFinite(a.sy) ? a.sy : Infinity;
|
||||
const by = Number.isFinite(b.sy) ? b.sy : Infinity;
|
||||
if (ay !== by) return ay < by ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const CCTV_LOD_GRACE_PASSES = 2;
|
||||
export const CCTV_LOD_GRACE_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Eviction-grace hysteresis for the ambient card set (Part C review finding
|
||||
* P2, kept verbatim).
|
||||
*
|
||||
* Raw per-pass selection has no memory: a camera sitting at the edge of the
|
||||
* card budget churns in/out on every small camera move, blinking its card
|
||||
* while orbiting. This planner keeps an already-built card alive for a short
|
||||
* grace window after it falls out of the selection — it is only dropped once
|
||||
* it STAYS unselected for `gracePasses` consecutive selection passes or
|
||||
* `graceMs` of wall time, whichever is crossed first. A newly selected
|
||||
* camera always enters immediately, and the hard `cardLimit` cap is never
|
||||
* exceeded: when selected + graced cards would overflow the budget,
|
||||
* grace-period cards are evicted first, oldest-in-grace first.
|
||||
*
|
||||
* Pure function: `graceState` is never mutated; the returned `graceState`
|
||||
* replaces it for the next pass.
|
||||
*
|
||||
* @param {Object} [input]
|
||||
* @param {string[]} [input.selectedIds] - This pass's selected card ids
|
||||
* (already budget-capped, nearest-first — `selectCctvLod().cardIds` after
|
||||
* declutter).
|
||||
* @param {string[]} [input.builtIds] - Ids that currently have a live card.
|
||||
* @param {Map<string,{misses:number,since:number}>} [input.graceState] - Prior
|
||||
* pass's grace bookkeeping (id -> consecutive misses + first-miss timestamp).
|
||||
* @param {number} [input.nowMs] - Current wall time in ms.
|
||||
* @param {number} [input.cardLimit] - Hard cap on total kept cards.
|
||||
* @param {number} [input.gracePasses] - Consecutive unselected passes tolerated.
|
||||
* @param {number} [input.graceMs] - Max wall-time a card may linger in grace.
|
||||
* @returns {{keepIds:string[], evictIds:string[], graceState:Map<string,{misses:number,since:number}>}}
|
||||
*/
|
||||
export function applyEvictionGrace({
|
||||
selectedIds = [],
|
||||
builtIds = [],
|
||||
graceState = new Map(),
|
||||
nowMs = 0,
|
||||
cardLimit = CCTV_AMBIENT_CARD_MAX,
|
||||
gracePasses = CCTV_LOD_GRACE_PASSES,
|
||||
graceMs = CCTV_LOD_GRACE_MS,
|
||||
} = {}) {
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const keepIds = [...selectedSet];
|
||||
const evictIds = [];
|
||||
const nextGrace = new Map();
|
||||
|
||||
const graced = [];
|
||||
for (const id of builtIds) {
|
||||
if (typeof id !== 'string' || !id || selectedSet.has(id)) continue;
|
||||
const prior = graceState.get(id);
|
||||
const misses = (prior?.misses || 0) + 1;
|
||||
const since = Number.isFinite(prior?.since) ? prior.since : nowMs;
|
||||
if (misses > gracePasses || nowMs - since >= graceMs) {
|
||||
evictIds.push(id);
|
||||
} else {
|
||||
graced.push({ id, misses, since });
|
||||
}
|
||||
}
|
||||
|
||||
// Under cap pressure the grace-period cards go first, oldest-in-grace
|
||||
// first (they have had the longest chance to return); ties break on more
|
||||
// misses, then id for determinism.
|
||||
graced.sort((a, b) => a.since - b.since || b.misses - a.misses || a.id.localeCompare(b.id));
|
||||
const capacity = Math.max(0, cardLimit - keepIds.length);
|
||||
const overflow = Math.max(0, graced.length - capacity);
|
||||
for (let i = 0; i < graced.length; i++) {
|
||||
if (i < overflow) {
|
||||
evictIds.push(graced[i].id);
|
||||
continue;
|
||||
}
|
||||
keepIds.push(graced[i].id);
|
||||
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
|
||||
}
|
||||
|
||||
return { keepIds, evictIds, graceState: nextGrace };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the source-aware static-frame refresh cadence. An explicit source
|
||||
* value wins, bounded to one minute through twenty minutes; otherwise known
|
||||
* public-pack cadences provide conservative defaults.
|
||||
*
|
||||
* @param {Object} camera
|
||||
* @returns {number}
|
||||
*/
|
||||
export function staticFrameRefreshMs(camera) {
|
||||
const explicit = Number(camera?.frameRefreshMs);
|
||||
if (Number.isFinite(explicit) && explicit > 0) {
|
||||
return Math.max(60_000, Math.min(20 * 60 * 1000, Math.round(explicit)));
|
||||
}
|
||||
const provider = String(camera?.provider || '').trim().toLowerCase();
|
||||
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// src/data/cctvLod.test.mjs
|
||||
// Pure LOD-engine tests, adapted from earlier Part C suite
|
||||
// zoom-scaled card budgets, nearest-first in-view
|
||||
// selection, video exclusion, the eviction-grace planner, and source-aware
|
||||
// static-frame pacing.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CCTV_CARD_CENTER_WEIGHT,
|
||||
CCTV_CARD_INCUMBENT_FACTOR,
|
||||
CCTV_LOD_GRACE_MS,
|
||||
CCTV_LOD_GRACE_PASSES,
|
||||
CCTV_AMBIENT_CARD_MAX,
|
||||
CCTV_AMBIENT_CARD_MID,
|
||||
CCTV_AMBIENT_CARD_MIN,
|
||||
applyEvictionGrace,
|
||||
blendCenterRankKm,
|
||||
cctvCandidateSpreadKm,
|
||||
cctvLodBudgets,
|
||||
distributeCctvCards,
|
||||
hasFiniteCctvViewport,
|
||||
incumbentRankKm,
|
||||
screenCenterFraction,
|
||||
selectCctvLod,
|
||||
staticFrameRefreshMs,
|
||||
} from './cctvLod.js';
|
||||
|
||||
function candidates(count, options = {}) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `cam-${String(index).padStart(3, '0')}`,
|
||||
distanceKm: index / 10,
|
||||
inView: options.hiddenAt !== index,
|
||||
isVideo: options.videoAt === index,
|
||||
}));
|
||||
}
|
||||
|
||||
test('cctvLodBudgets scales the card budget from 20 to 40 with view height', () => {
|
||||
// Follow-up round 2 (item C): budgets raised 16/24/32 -> 20/28/40.
|
||||
assert.equal(CCTV_AMBIENT_CARD_MIN, 20);
|
||||
assert.equal(CCTV_AMBIENT_CARD_MID, 28);
|
||||
assert.equal(CCTV_AMBIENT_CARD_MAX, 40);
|
||||
assert.deepEqual(cctvLodBudgets(500), { cardLimit: CCTV_AMBIENT_CARD_MIN });
|
||||
assert.deepEqual(cctvLodBudgets(8_000), { cardLimit: CCTV_AMBIENT_CARD_MID });
|
||||
assert.deepEqual(cctvLodBudgets(30_000), { cardLimit: CCTV_AMBIENT_CARD_MAX });
|
||||
});
|
||||
|
||||
test('selectCctvLod caps ambient cards at the 40 hard cap regardless of catalog size', () => {
|
||||
const selected = selectCctvLod(candidates(900), { cameraHeightM: 30_000 });
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MAX);
|
||||
assert.equal(selected.budgets.cardLimit, CCTV_AMBIENT_CARD_MAX);
|
||||
});
|
||||
|
||||
test('selectCctvLod uses only in-view cameras and orders by viewer distance', () => {
|
||||
const input = candidates(20, { hiddenAt: 0 }).reverse();
|
||||
const selected = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.deepEqual(selected.cardIds.slice(0, 3), ['cam-001', 'cam-002', 'cam-003']);
|
||||
});
|
||||
|
||||
test('video cameras never consume ambient card slots', () => {
|
||||
const selected = selectCctvLod(candidates(30, { videoAt: 0 }), { cameraHeightM: 500 });
|
||||
assert.equal(selected.cardIds.includes('cam-000'), false);
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
});
|
||||
|
||||
test('selectCctvLod tolerates malformed candidate rows', () => {
|
||||
const selected = selectCctvLod(
|
||||
[null, {}, { id: '', inView: true }, { id: 'cam-ok', inView: true, distanceKm: NaN }],
|
||||
{ cameraHeightM: 500 }
|
||||
);
|
||||
assert.deepEqual(selected.cardIds, ['cam-ok']);
|
||||
});
|
||||
|
||||
// ─── Selection-level incumbency (field test finding 4) ────────────────
|
||||
|
||||
test('incumbentRankKm discounts incumbents by the 20% factor', () => {
|
||||
assert.equal(incumbentRankKm(10, false), 10);
|
||||
assert.equal(incumbentRankKm(10, true), 10 * CCTV_CARD_INCUMBENT_FACTOR);
|
||||
assert.equal(incumbentRankKm(NaN, true), Infinity);
|
||||
});
|
||||
|
||||
test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one', () => {
|
||||
// Street budget filled: budget-1 anchors + one contested slot. The
|
||||
// incumbent at 5.0 km holds its card against a newcomer at 4.5 km (only
|
||||
// 10% closer).
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
{ id: 'newcomer', distanceKm: 4.5, inView: true },
|
||||
];
|
||||
const selected = selectCctvLod([...anchors, ...contested], {
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(['incumbent']),
|
||||
});
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
assert.ok(selected.cardIds.includes('incumbent'), 'incumbent keeps its card');
|
||||
assert.equal(selected.cardIds.includes('newcomer'), false);
|
||||
});
|
||||
|
||||
test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incumbent', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, i) => ({
|
||||
id: `anchor-${String(i).padStart(2, '0')}`, distanceKm: i / 100, inView: true,
|
||||
}));
|
||||
const contested = [
|
||||
{ id: 'incumbent', distanceKm: 5.0, inView: true },
|
||||
{ id: 'newcomer', distanceKm: 3.9, inView: true }, // beats 5.0 * 0.8 = 4.0
|
||||
];
|
||||
const selected = selectCctvLod([...anchors, ...contested], {
|
||||
cameraHeightM: 500,
|
||||
incumbentIds: new Set(['incumbent']),
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('newcomer'), 'meaningfully closer camera wins');
|
||||
assert.equal(selected.cardIds.includes('incumbent'), false);
|
||||
});
|
||||
|
||||
test('selectCctvLod: no incumbents means plain nearest-first (unchanged behavior)', () => {
|
||||
const plain = selectCctvLod(candidates(20), { cameraHeightM: 500 });
|
||||
const withEmpty = selectCctvLod(candidates(20), { cameraHeightM: 500, incumbentIds: new Set() });
|
||||
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
|
||||
});
|
||||
|
||||
// ─── distributeCctvCards — screen distribution (follow-up round 2, item C) ──────
|
||||
// Grid: 5 cols x 4 rows. At viewW 1000 / viewH 800 each cell is 200x200 px.
|
||||
|
||||
test('distributeCctvCards spreads clustered candidates across cells', () => {
|
||||
// Four nearest candidates cluster in the top-left cell; a farther one
|
||||
// sits alone bottom-right. Nearest-first would take the whole cluster —
|
||||
// distribution gives every occupied cell its best first.
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 2, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery']
|
||||
);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: leftover budget falls back to global rank order', () => {
|
||||
const clustered = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: `center-${i}`, sx: 50 + i * 10, sy: 50, rankKm: i,
|
||||
}));
|
||||
const lonely = { id: 'periphery', sx: 950, sy: 750, rankKm: 30 };
|
||||
// 2 occupied cells, budget 4: both cell winners, then the cluster's next
|
||||
// two by rank.
|
||||
assert.deepEqual(
|
||||
distributeCctvCards([...clustered, lonely], { budget: 4, viewW: 1000, viewH: 800 }),
|
||||
['center-0', 'periphery', 'center-1', 'center-2']
|
||||
);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: empty cells are skipped, budget respected', () => {
|
||||
// Everything in ONE cell — the 19 empty cells claim nothing; the single
|
||||
// occupied cell fills the whole budget in rank order.
|
||||
const oneCell = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `c-${i}`, sx: 20 + i * 5, sy: 20, rankKm: i,
|
||||
}));
|
||||
const ids = distributeCctvCards(oneCell, { budget: 3, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(ids, ['c-0', 'c-1', 'c-2']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: an under-budget cut keeps the nearest cell winners', () => {
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'far-cell', sx: 900, sy: 700, rankKm: 9 },
|
||||
{ id: 'near-cell', sx: 100, sy: 100, rankKm: 1 },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(ids, ['near-cell']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: incumbency (effective distance) is honored inside a cell', () => {
|
||||
// Same cell: the incumbent at 5.0 km (discounted to 4.0) beats the
|
||||
// non-carded newcomer at 4.5 km for the cell slot.
|
||||
const ids = distributeCctvCards([
|
||||
{ id: 'newcomer', sx: 100, sy: 100, rankKm: incumbentRankKm(4.5, false) },
|
||||
{ id: 'incumbent', sx: 120, sy: 100, rankKm: incumbentRankKm(5.0, true) },
|
||||
], { budget: 1, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(ids, ['incumbent']);
|
||||
});
|
||||
|
||||
test('distributeCctvCards: malformed rows dropped; offscreen anchors clamp to edge cells', () => {
|
||||
const ids = distributeCctvCards([
|
||||
null,
|
||||
{ id: '', sx: 10, sy: 10, rankKm: 0 },
|
||||
{ id: 'nan', sx: NaN, sy: 10, rankKm: 0 },
|
||||
{ id: 'ok', sx: 10, sy: 10, rankKm: 1 },
|
||||
// Margin candidates just outside the viewport land in the edge cells.
|
||||
{ id: 'edge', sx: -20, sy: 850, rankKm: 2 },
|
||||
], { budget: 5, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(ids.sort(), ['edge', 'ok']);
|
||||
assert.deepEqual(distributeCctvCards([], { budget: 5, viewW: 1000, viewH: 800 }), []);
|
||||
});
|
||||
|
||||
test('selectCctvLod with viewport dims routes the fill through screen distribution', () => {
|
||||
// A cluster larger than the street budget sits mid-screen; a periphery
|
||||
// camera ranks past the budget. Nearest-first drops it — distribution
|
||||
// keeps it because its cell is otherwise empty.
|
||||
const clustered = Array.from({ length: CCTV_AMBIENT_CARD_MIN + 2 }, (_, i) => ({
|
||||
id: `center-${String(i).padStart(2, '0')}`,
|
||||
distanceKm: i / 10,
|
||||
inView: true,
|
||||
sx: 400 + (i % 5) * 8,
|
||||
sy: 300 + Math.floor(i / 5) * 8,
|
||||
}));
|
||||
const periphery = { id: 'periphery', distanceKm: 9, inView: true, sx: 950, sy: 750 };
|
||||
const selected = selectCctvLod([...clustered, periphery], {
|
||||
cameraHeightM: 500, viewW: 1000, viewH: 800,
|
||||
});
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN, 'budget respected');
|
||||
assert.ok(selected.cardIds.includes('periphery'), 'periphery cell holds a card');
|
||||
// Without screen info the original nearest-first cap applies unchanged.
|
||||
const plain = selectCctvLod(
|
||||
[...clustered, periphery].map(({ sx, sy, ...rest }) => rest),
|
||||
{ cameraHeightM: 500 }
|
||||
);
|
||||
assert.equal(plain.cardIds.includes('periphery'), false);
|
||||
});
|
||||
|
||||
test('center-weight helpers are bounded and robust to invalid anchors and outliers', () => {
|
||||
assert.equal(CCTV_CARD_CENTER_WEIGHT, 0.5);
|
||||
assert.equal(hasFiniteCctvViewport(1000, 800), true);
|
||||
assert.equal(hasFiniteCctvViewport(Infinity, 800), false);
|
||||
assert.equal(screenCenterFraction(500, 400, 1000, 800), 0);
|
||||
assert.equal(screenCenterFraction(1000, 800, 1000, 800), 1);
|
||||
assert.equal(screenCenterFraction(NaN, 400, 1000, 800), 1);
|
||||
assert.equal(screenCenterFraction(-200, -200, 1000, 800), 1);
|
||||
assert.equal(screenCenterFraction(500, 400, 0, 0), 0);
|
||||
const near = Array.from({ length: 20 }, (_, index) => 1 + index / 100);
|
||||
assert.equal(cctvCandidateSpreadKm([...near, 900]), 1.18);
|
||||
assert.equal(cctvCandidateSpreadKm([Infinity, NaN]), 0);
|
||||
assert.equal(blendCenterRankKm(10, 0, 20), 5);
|
||||
assert.equal(blendCenterRankKm(10, 1, 20), 15);
|
||||
assert.equal(blendCenterRankKm(10, 0.5, 20, 0), 10);
|
||||
});
|
||||
|
||||
test('non-finite positive viewport dimensions retain exact legacy nearest-first selection', () => {
|
||||
const near = Array.from({ length: CCTV_AMBIENT_CARD_MIN }, (_, index) => ({
|
||||
id: `near-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index,
|
||||
inView: true,
|
||||
sx: 100,
|
||||
sy: 100,
|
||||
}));
|
||||
const far = {
|
||||
id: 'far-other-cell',
|
||||
distanceKm: 100,
|
||||
inView: true,
|
||||
sx: 900,
|
||||
sy: 700,
|
||||
};
|
||||
const input = [...near, far];
|
||||
const legacy = selectCctvLod(input, { cameraHeightM: 500 });
|
||||
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 }).cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity }).cardIds,
|
||||
legacy.cardIds,
|
||||
);
|
||||
|
||||
const legacyEdgeIds = [
|
||||
{ id: ' ', distanceKm: 1, inView: true },
|
||||
{ id: 'normal', distanceKm: 2, inView: true },
|
||||
];
|
||||
assert.deepEqual(
|
||||
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 }).cardIds,
|
||||
[' ', 'normal'],
|
||||
'invalid viewport preserves every non-empty string ID accepted by the legacy path',
|
||||
);
|
||||
});
|
||||
|
||||
function edgeVsCenterPool() {
|
||||
const edge = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `edge-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: 1 + index / 100,
|
||||
inView: true,
|
||||
sx: 20 + (index % 5) * 2,
|
||||
sy: 780 - Math.floor(index / 5) * 2,
|
||||
}));
|
||||
const center = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `center-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: 12 + index / 100,
|
||||
inView: true,
|
||||
sx: 490 + (index % 5) * 4,
|
||||
sy: 390 + (index % 5) * 2,
|
||||
}));
|
||||
return { edge, center };
|
||||
}
|
||||
|
||||
test('center weighting deterministically favors center without starving peripheral coverage', () => {
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const selected = selectCctvLod([...edge, ...center], view);
|
||||
const reordered = selectCctvLod([...center].reverse().concat([...edge].reverse()), view);
|
||||
|
||||
assert.equal(selected.cardIds.length, CCTV_AMBIENT_CARD_MIN);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('center-')).length, 10);
|
||||
assert.equal(selected.cardIds.filter((id) => id.startsWith('edge-')).length, 10);
|
||||
assert.deepEqual(reordered.cardIds, selected.cardIds, 'input order cannot affect winners or ordering');
|
||||
|
||||
const legacy = selectCctvLod(
|
||||
[...edge, ...center].map(({ sx, sy, ...candidate }) => candidate),
|
||||
{ cameraHeightM: 500 },
|
||||
);
|
||||
assert.equal(legacy.cardIds.filter((id) => id.startsWith('center-')).length, 0);
|
||||
});
|
||||
|
||||
test('ineligible video, hidden, and malformed-distance rows cannot alter eligible winners', () => {
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const eligible = [...edge, ...center];
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const baseline = selectCctvLod(eligible, view).cardIds;
|
||||
const noise = [
|
||||
...Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `video-${index}`,
|
||||
distanceKm: 1_000 + index,
|
||||
inView: true,
|
||||
isVideo: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
})),
|
||||
{ id: 'hidden-outlier', distanceKm: 9_000, inView: false, sx: 900, sy: 700 },
|
||||
{ id: 'bad-distance', distanceKm: NaN, inView: true, sx: 900, sy: 700 },
|
||||
{ id: '', distanceKm: 0, inView: true, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...noise], view).cardIds, baseline);
|
||||
assert.deepEqual(selectCctvLod([...noise].reverse().concat([...eligible].reverse()), view).cardIds, baseline);
|
||||
});
|
||||
|
||||
test('malformed-distance rows remain slot-neutral when valid candidates leave spare capacity', () => {
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const candidates = [
|
||||
{ id: 'valid', distanceKm: 1, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'bad', distanceKm: NaN, inView: true, sx: 510, sy: 410 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod(candidates, view).cardIds, ['valid']);
|
||||
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, ['valid']);
|
||||
});
|
||||
|
||||
test('duplicate IDs are slot-neutral and choose a deterministic representative', () => {
|
||||
const { edge, center } = edgeVsCenterPool();
|
||||
const eligible = [...edge, ...center];
|
||||
const view = { cameraHeightM: 500, viewW: 1000, viewH: 800 };
|
||||
const baseline = selectCctvLod(eligible, view).cardIds;
|
||||
const duplicates = [
|
||||
{ ...edge[0], distanceKm: 1_000, sx: 500, sy: 400 },
|
||||
{ ...center[0], sx: 20, sy: 780 },
|
||||
{ ...center[0], distanceKm: NaN, sx: 500, sy: 400 },
|
||||
];
|
||||
|
||||
assert.deepEqual(selectCctvLod([...eligible, ...duplicates], view).cardIds, baseline);
|
||||
assert.deepEqual(
|
||||
selectCctvLod([...duplicates, ...eligible].reverse(), view).cardIds,
|
||||
baseline,
|
||||
'input order cannot choose a different duplicate anchor or affect spread',
|
||||
);
|
||||
});
|
||||
|
||||
test('center weighting has deterministic ID ties and preserves invalid-anchor eligibility', () => {
|
||||
const anchors = Array.from({ length: CCTV_AMBIENT_CARD_MIN - 1 }, (_, index) => ({
|
||||
id: `anchor-${String(index).padStart(2, '0')}`,
|
||||
distanceKm: index / 100,
|
||||
inView: true,
|
||||
sx: 500,
|
||||
sy: 400,
|
||||
}));
|
||||
const tied = [
|
||||
{ id: 'tie-b', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'tie-a', distanceKm: 10, inView: true, sx: 500, sy: 400 },
|
||||
{ id: 'invalid-anchor', distanceKm: 30, inView: true, sx: NaN, sy: NaN },
|
||||
];
|
||||
const selected = selectCctvLod([...anchors, ...tied], {
|
||||
cameraHeightM: 500,
|
||||
viewW: 1000,
|
||||
viewH: 800,
|
||||
});
|
||||
assert.ok(selected.cardIds.includes('tie-a'));
|
||||
assert.equal(selected.cardIds.includes('tie-b'), false, 'ID breaks an exact contested tie');
|
||||
assert.equal(selected.cardIds.includes('invalid-anchor'), false, 'invalid anchor does not displace valid winners');
|
||||
|
||||
const onlyInvalid = selectCctvLod([
|
||||
{ id: 'invalid-only', distanceKm: 1, inView: true, sx: NaN, sy: NaN },
|
||||
], { cameraHeightM: 500, viewW: 1000, viewH: 800 });
|
||||
assert.deepEqual(onlyInvalid.cardIds, ['invalid-only'], 'defensive top-up preserves eligibility');
|
||||
});
|
||||
|
||||
test('applyEvictionGrace keeps a fallen-out card and clears grace when it returns', () => {
|
||||
// Pass 1: cam-b falls out of the selection but keeps its live card.
|
||||
const pass1 = applyEvictionGrace({
|
||||
selectedIds: ['cam-a'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState: new Map(),
|
||||
nowMs: 1_000,
|
||||
cardLimit: 16,
|
||||
});
|
||||
assert.ok(pass1.keepIds.includes('cam-b'), 'graced card stays alive');
|
||||
assert.deepEqual(pass1.evictIds, []);
|
||||
assert.deepEqual(pass1.graceState.get('cam-b'), { misses: 1, since: 1_000 });
|
||||
|
||||
// Pass 2: cam-b is selected again — card survived, grace entry cleared.
|
||||
const pass2 = applyEvictionGrace({
|
||||
selectedIds: ['cam-a', 'cam-b'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState: pass1.graceState,
|
||||
nowMs: 1_500,
|
||||
cardLimit: 16,
|
||||
});
|
||||
assert.ok(pass2.keepIds.includes('cam-b'));
|
||||
assert.deepEqual(pass2.evictIds, []);
|
||||
assert.equal(pass2.graceState.size, 0);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace evicts a card that stays unselected past the pass budget', () => {
|
||||
let graceState = new Map();
|
||||
let result;
|
||||
// cam-b stays out: survives CCTV_LOD_GRACE_PASSES consecutive misses...
|
||||
for (let pass = 1; pass <= CCTV_LOD_GRACE_PASSES; pass++) {
|
||||
result = applyEvictionGrace({
|
||||
selectedIds: ['cam-a'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState,
|
||||
nowMs: 1_000 + pass * 100,
|
||||
cardLimit: 16,
|
||||
});
|
||||
graceState = result.graceState;
|
||||
assert.ok(result.keepIds.includes('cam-b'), `still graced at miss ${pass}`);
|
||||
}
|
||||
// ...and is dropped on the next pass.
|
||||
result = applyEvictionGrace({
|
||||
selectedIds: ['cam-a'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState,
|
||||
nowMs: 1_400,
|
||||
cardLimit: 16,
|
||||
});
|
||||
assert.deepEqual(result.evictIds, ['cam-b']);
|
||||
assert.equal(result.keepIds.includes('cam-b'), false);
|
||||
assert.equal(result.graceState.has('cam-b'), false);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace evicts on wall-time even with few passes', () => {
|
||||
const pass1 = applyEvictionGrace({
|
||||
selectedIds: ['cam-a'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState: new Map(),
|
||||
nowMs: 10_000,
|
||||
cardLimit: 16,
|
||||
});
|
||||
assert.ok(pass1.keepIds.includes('cam-b'));
|
||||
const pass2 = applyEvictionGrace({
|
||||
selectedIds: ['cam-a'],
|
||||
builtIds: ['cam-a', 'cam-b'],
|
||||
graceState: pass1.graceState,
|
||||
nowMs: 10_000 + CCTV_LOD_GRACE_MS,
|
||||
cardLimit: 16,
|
||||
});
|
||||
assert.deepEqual(pass2.evictIds, ['cam-b']);
|
||||
});
|
||||
|
||||
test('applyEvictionGrace never exceeds the hard cap; graced cards go first, oldest first', () => {
|
||||
// Two cards in grace, cam-old fell out before cam-new.
|
||||
const graceState = new Map([
|
||||
['cam-old', { misses: 1, since: 1_000 }],
|
||||
['cam-new', { misses: 1, since: 2_000 }],
|
||||
]);
|
||||
// Selection fills 3 of 4 slots: only one grace slot remains.
|
||||
const result = applyEvictionGrace({
|
||||
selectedIds: ['cam-a', 'cam-b', 'cam-c'],
|
||||
builtIds: ['cam-a', 'cam-b', 'cam-old', 'cam-new'],
|
||||
graceState,
|
||||
nowMs: 2_100,
|
||||
cardLimit: 4,
|
||||
});
|
||||
assert.equal(result.keepIds.length, 4);
|
||||
// cam-c is newly selected and enters immediately despite grace pressure.
|
||||
assert.ok(result.keepIds.includes('cam-c'));
|
||||
assert.ok(result.keepIds.includes('cam-new'), 'newest grace entry survives');
|
||||
assert.deepEqual(result.evictIds, ['cam-old'], 'oldest grace entry evicted for the cap');
|
||||
});
|
||||
|
||||
test('applyEvictionGrace with a full selection keeps no graced cards', () => {
|
||||
const selectedIds = Array.from({ length: 4 }, (_, i) => `cam-${i}`);
|
||||
const result = applyEvictionGrace({
|
||||
selectedIds,
|
||||
builtIds: [...selectedIds, 'cam-extra'],
|
||||
graceState: new Map(),
|
||||
nowMs: 1_000,
|
||||
cardLimit: 4,
|
||||
});
|
||||
assert.deepEqual(result.keepIds.sort(), selectedIds.sort());
|
||||
assert.deepEqual(result.evictIds, ['cam-extra']);
|
||||
});
|
||||
|
||||
test('staticFrameRefreshMs follows known pack cadences and bounds explicit values', () => {
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Austin Transportation & Public Works' }), 300_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Transport for London' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
fetchCctvImageFromUpstream,
|
||||
} from '../../vite.config.js';
|
||||
|
||||
test('CCTV upstream frame fetch supplies a bounded abort signal', async () => {
|
||||
let observedSignal = null;
|
||||
const startedAt = Date.now();
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 20,
|
||||
fetchImpl: (_url, options) => new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.ok(observedSignal instanceof AbortSignal);
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
assert.ok(Date.now() - startedAt < 500, 'test timeout should settle promptly');
|
||||
assert.ok(CCTV_FRAME_FETCH_TIMEOUT_MS < 10_000, 'production timeout must beat the active refresh cadence');
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch returns a valid image response', async () => {
|
||||
const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', {
|
||||
timeoutMs: 100,
|
||||
fetchImpl: async () => new Response(Uint8Array.from([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result?.ok, true);
|
||||
assert.equal(result?.contentType, 'image/jpeg');
|
||||
assert.deepEqual(result?.body, Buffer.from([1, 2, 3]));
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @module cctvViewshed
|
||||
*
|
||||
* Viewshed presentation for the CCTV layer (design:
|
||||
* the CCTV viewshed geometry contract).
|
||||
*
|
||||
* Two responsibilities, both pure of layer state:
|
||||
* - Color identity: a stable per-camera hue (golden-angle spaced over the
|
||||
* id-sorted catalog index) and the derived fill/line Cesium colors.
|
||||
* - Volume geometry: a translucent frustum pyramid primitive built FROM the
|
||||
* exact frustumCartesians positions the wireframe uses — welded by
|
||||
* construction, never recomputed independently. 5 vertices, 6 triangles,
|
||||
* synchronous build (trivially small), no picking.
|
||||
*
|
||||
* Nothing here queries the scene or persists anything: colors are
|
||||
* presentation-only and volumes are rebuilt only at the moments the frustum
|
||||
* wireframe already rewrites (pose edits / visible-set changes).
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Golden angle in degrees — maximally spreads consecutive indices around the hue wheel. */
|
||||
const GOLDEN_ANGLE_DEG = 137.50776405003785;
|
||||
|
||||
const FILL_ALPHA_IDLE = 0.12;
|
||||
const FILL_ALPHA_ACTIVE = 0.22;
|
||||
const LINE_ALPHA_IDLE = 0.85;
|
||||
const LINE_ALPHA_ACTIVE = 1.0;
|
||||
|
||||
/**
|
||||
* Stable hue (degrees, [0, 360)) for a camera's position in the id-sorted
|
||||
* catalog. Golden-angle spacing keeps any local cluster of neighbor cameras
|
||||
* visually separated; id-sorting makes the assignment deterministic across
|
||||
* sessions for a stable catalog (design §3a, open question Q4).
|
||||
* @param {number} index - Camera index in the id-sorted catalog.
|
||||
* @returns {number} Hue in degrees.
|
||||
*/
|
||||
export function cameraHue(index) {
|
||||
const i = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
|
||||
return (i * GOLDEN_ANGLE_DEG) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived viewshed colors for a hue: translucent volume fills (idle/active)
|
||||
* and wireframe line tints (idle/active), all in the same hue family.
|
||||
* @param {number} hueDeg - Hue in degrees.
|
||||
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
|
||||
*/
|
||||
export function viewshedColors(hueDeg) {
|
||||
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
|
||||
return {
|
||||
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
|
||||
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
|
||||
line: Cesium.Color.fromHsl(hue, 0.9, 0.65, LINE_ALPHA_IDLE),
|
||||
lineActive: Cesium.Color.fromHsl(hue, 0.9, 0.7, LINE_ALPHA_ACTIVE),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens frustumCartesians positions into the raw vertex/index buffers of
|
||||
* the frustum volume: vertex order [mount, tl, tr, br, bl]; 4 side faces from
|
||||
* the apex + the far cap split into 2 triangles. Pure — this is the ONLY
|
||||
* geometry definition (weld-by-construction with the wireframe, which draws
|
||||
* its rays/cap from the same 5 Cartesians).
|
||||
* @param {{mount: Cesium.Cartesian3, tl: Cesium.Cartesian3, tr: Cesium.Cartesian3,
|
||||
* br: Cesium.Cartesian3, bl: Cesium.Cartesian3}} positions - frustumCartesians shape.
|
||||
* @returns {{positions: Float64Array, indices: Uint16Array}}
|
||||
*/
|
||||
export function frustumVolumeGeometryData(positions) {
|
||||
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
||||
const flat = new Float64Array(15);
|
||||
pts.forEach((p, i) => {
|
||||
flat[i * 3] = p.x;
|
||||
flat[i * 3 + 1] = p.y;
|
||||
flat[i * 3 + 2] = p.z;
|
||||
});
|
||||
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
|
||||
const indices = new Uint16Array([
|
||||
0, 1, 2,
|
||||
0, 2, 3,
|
||||
0, 3, 4,
|
||||
0, 4, 1,
|
||||
1, 2, 3,
|
||||
1, 3, 4,
|
||||
]);
|
||||
return { positions: flat, indices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the translucent frustum-volume primitive for one camera.
|
||||
* Synchronous (6 triangles — cheaper than the async geometry pipeline's
|
||||
* bookkeeping), unlit flat color, both faces visible (the viewer is routinely
|
||||
* inside or behind a cone), never pickable (clicks fall through to the
|
||||
* billboard/wireframe/plane, whose pick semantics are established).
|
||||
* @param {Object} positions - frustumCartesians shape (see frustumVolumeGeometryData).
|
||||
* @param {Cesium.Color} color - Per-camera fill color (already alpha'd).
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
export function createFrustumVolumePrimitive(positions, color) {
|
||||
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
|
||||
const geometry = new Cesium.Geometry({
|
||||
attributes: {
|
||||
position: new Cesium.GeometryAttribute({
|
||||
componentDatatype: Cesium.ComponentDatatype.DOUBLE,
|
||||
componentsPerAttribute: 3,
|
||||
values: flat,
|
||||
}),
|
||||
},
|
||||
indices,
|
||||
primitiveType: Cesium.PrimitiveType.TRIANGLES,
|
||||
boundingSphere: Cesium.BoundingSphere.fromVertices(Array.from(flat)),
|
||||
});
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
|
||||
},
|
||||
}),
|
||||
appearance: new Cesium.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
renderState: {
|
||||
cull: { enabled: false },
|
||||
},
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// src/data/cctvViewshed.test.mjs — viewshed hue assignment + frustum volume
|
||||
// geometry documented in docs/CURRENT-STATE.md.
|
||||
//
|
||||
// Locks:
|
||||
// - cameraHue is golden-angle spaced and deterministic (color identity is
|
||||
// stable across sessions for a stable catalog);
|
||||
// - nearby catalog indices get well-separated hues (the whole point: a local
|
||||
// cluster of neighbor cameras must be tellable-apart);
|
||||
// - frustumVolumeGeometryData is welded BY CONSTRUCTION to the wireframe:
|
||||
// its 5 vertices are exactly the caller's frustumCartesians positions and
|
||||
// its 18 indices only reference those 5 vertices (4 side faces + 2 cap
|
||||
// triangles) — no independent geometry recompute anywhere.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cameraHue,
|
||||
viewshedColors,
|
||||
frustumVolumeGeometryData,
|
||||
} from './cctvViewshed.js';
|
||||
|
||||
const GOLDEN_ANGLE = 137.50776405003785;
|
||||
|
||||
function circularDeltaDeg(a, b) {
|
||||
const d = Math.abs(a - b) % 360;
|
||||
return Math.min(d, 360 - d);
|
||||
}
|
||||
|
||||
test('cameraHue: deterministic golden-angle spacing in [0, 360)', () => {
|
||||
assert.equal(cameraHue(0), 0);
|
||||
assert.ok(Math.abs(cameraHue(1) - GOLDEN_ANGLE) < 1e-9);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const hue = cameraHue(i);
|
||||
assert.ok(hue >= 0 && hue < 360, `hue ${hue} out of range at index ${i}`);
|
||||
assert.equal(hue, cameraHue(i), 'must be pure/deterministic');
|
||||
}
|
||||
});
|
||||
|
||||
// NB: 20 points on a 360° wheel cap the best-possible min pairwise gap at 18°
|
||||
// (pigeonhole), so "≥20°" is unachievable for any assignment. Golden-angle
|
||||
// gives 12.4° min over the first 20 (measured) — assert ≥12° so a regression
|
||||
// to a worse spacing scheme (e.g. raw id-hash clustering) fails loudly, and
|
||||
// separately assert CONSECUTIVE indices (the likeliest co-visible neighbors)
|
||||
// stay far apart.
|
||||
test('cameraHue: first 20 indices pairwise separated by >= 12 degrees', () => {
|
||||
const hues = Array.from({ length: 20 }, (_, i) => cameraHue(i));
|
||||
for (let i = 0; i < hues.length; i++) {
|
||||
for (let j = i + 1; j < hues.length; j++) {
|
||||
const delta = circularDeltaDeg(hues[i], hues[j]);
|
||||
assert.ok(delta >= 12, `hue(${i})=${hues[i].toFixed(1)} vs hue(${j})=${hues[j].toFixed(1)} only ${delta.toFixed(1)}° apart`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('cameraHue: consecutive indices separated by >= 80 degrees', () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const delta = circularDeltaDeg(cameraHue(i), cameraHue(i + 1));
|
||||
assert.ok(delta >= 80, `consecutive hues ${i}/${i + 1} only ${delta.toFixed(1)}° apart`);
|
||||
}
|
||||
});
|
||||
|
||||
test('viewshedColors: fill/line pairs with the designed alphas', () => {
|
||||
const colors = viewshedColors(210);
|
||||
for (const key of ['fill', 'fillActive', 'line', 'lineActive']) {
|
||||
assert.ok(colors[key] instanceof Cesium.Color, `${key} must be a Cesium.Color`);
|
||||
}
|
||||
assert.ok(Math.abs(colors.fill.alpha - 0.12) < 1e-6);
|
||||
assert.ok(Math.abs(colors.fillActive.alpha - 0.22) < 1e-6);
|
||||
assert.ok(Math.abs(colors.line.alpha - 0.85) < 1e-6);
|
||||
assert.ok(Math.abs(colors.lineActive.alpha - 1.0) < 1e-6);
|
||||
// Same hue family: fill and line of the same hue must not be gray.
|
||||
assert.ok(colors.line.red !== colors.line.green || colors.line.green !== colors.line.blue);
|
||||
});
|
||||
|
||||
/** Minimal frustumCartesians-shaped fixture. */
|
||||
function positionsFixture() {
|
||||
return {
|
||||
mount: new Cesium.Cartesian3(1000, 2000, 3000),
|
||||
tl: new Cesium.Cartesian3(1100, 2100, 3050),
|
||||
tr: new Cesium.Cartesian3(1200, 2050, 3050),
|
||||
br: new Cesium.Cartesian3(1200, 2050, 2950),
|
||||
bl: new Cesium.Cartesian3(1100, 2100, 2950),
|
||||
};
|
||||
}
|
||||
|
||||
test('frustumVolumeGeometryData: 5 vertices are exactly the input Cartesians', () => {
|
||||
const positions = positionsFixture();
|
||||
const { positions: flat, indices } = frustumVolumeGeometryData(positions);
|
||||
assert.equal(flat.length, 15);
|
||||
assert.equal(indices.length, 18);
|
||||
const order = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
|
||||
order.forEach((p, i) => {
|
||||
assert.equal(flat[i * 3], p.x);
|
||||
assert.equal(flat[i * 3 + 1], p.y);
|
||||
assert.equal(flat[i * 3 + 2], p.z);
|
||||
});
|
||||
});
|
||||
|
||||
test('frustumVolumeGeometryData: indices form 4 side faces + 2 cap triangles over the 5 vertices', () => {
|
||||
const { indices } = frustumVolumeGeometryData(positionsFixture());
|
||||
const counts = new Map();
|
||||
for (const idx of indices) {
|
||||
assert.ok(idx >= 0 && idx <= 4, `index ${idx} out of vertex range`);
|
||||
counts.set(idx, (counts.get(idx) || 0) + 1);
|
||||
}
|
||||
// Apex (0) appears in exactly the 4 side triangles; every corner in >= 2.
|
||||
assert.equal(counts.get(0), 4, 'apex must appear in exactly 4 side triangles');
|
||||
for (const corner of [1, 2, 3, 4]) {
|
||||
assert.ok(counts.get(corner) >= 2, `corner ${corner} underused`);
|
||||
}
|
||||
// 6 triangles, none degenerate (three distinct vertices each).
|
||||
for (let t = 0; t < indices.length; t += 3) {
|
||||
const tri = new Set([indices[t], indices[t + 1], indices[t + 2]]);
|
||||
assert.equal(tri.size, 3, `triangle at ${t} is degenerate`);
|
||||
}
|
||||
});
|
||||
|
||||
test('frustumVolumeGeometryData: no NaN for a tight (probe-clamped) pyramid', () => {
|
||||
const near = {
|
||||
mount: new Cesium.Cartesian3(0, 0, 0),
|
||||
tl: new Cesium.Cartesian3(12, 1, 1),
|
||||
tr: new Cesium.Cartesian3(12, -1, 1),
|
||||
br: new Cesium.Cartesian3(12, -1, -1),
|
||||
bl: new Cesium.Cartesian3(12, 1, -1),
|
||||
};
|
||||
const { positions: flat } = frustumVolumeGeometryData(near);
|
||||
for (const v of flat) assert.ok(Number.isFinite(v));
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Derive the AIR-contact near band for Cockpit presentation.
|
||||
*
|
||||
* This state is deliberately independent from 3D model admission. A contact
|
||||
* inside the band stays an aircraft silhouette even when 3D is off, its model
|
||||
* is still loading, or the model budget is full. Contacts outside the band are
|
||||
* compact dots. Retained contacts use the wider KEEP radius so crossing the
|
||||
* ADD boundary cannot make the visual oscillate.
|
||||
*
|
||||
* @param {Set<string>} previousIds - Contacts retained by the prior update.
|
||||
* @param {Iterable<[string, number]>} distancesSquared - Contact id and squared distance in metres.
|
||||
* @param {number} addDistanceM - Radius for contacts entering the near band.
|
||||
* @param {number} keepDistanceM - Radius for contacts already in the near band.
|
||||
* @returns {Set<string>} The next near-contact set.
|
||||
*/
|
||||
export function nextCockpitNearContacts(
|
||||
previousIds,
|
||||
distancesSquared,
|
||||
addDistanceM,
|
||||
keepDistanceM,
|
||||
) {
|
||||
const previous = previousIds instanceof Set ? previousIds : new Set();
|
||||
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
|
||||
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
|
||||
const next = new Set();
|
||||
|
||||
for (const [id, distanceSq] of distancesSquared || []) {
|
||||
if (!id || !Number.isFinite(distanceSq) || distanceSq < 0) continue;
|
||||
const limitSq = previous.has(id) ? keepSq : addSq;
|
||||
if (distanceSq <= limitSq) next.add(id);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { nextCockpitNearContacts } from './cockpitAirLod.js';
|
||||
|
||||
test('Cockpit AIR LOD admits at ADD and retains through KEEP', () => {
|
||||
const previous = new Set(['retained', 'expired']);
|
||||
const next = nextCockpitNearContacts(previous, [
|
||||
['new-near', 149_000 ** 2],
|
||||
['new-outside-add', 151_000 ** 2],
|
||||
['retained', 184_000 ** 2],
|
||||
['expired', 186_000 ** 2],
|
||||
], 150_000, 185_000);
|
||||
|
||||
assert.deepEqual([...next].sort(), ['new-near', 'retained']);
|
||||
});
|
||||
|
||||
test('Cockpit AIR LOD switches to the All range without a model-budget dependency', () => {
|
||||
const next = nextCockpitNearContacts(new Set(), [
|
||||
['inside-all', 399_000 ** 2],
|
||||
['outside-all', 401_000 ** 2],
|
||||
], 400_000, 450_000);
|
||||
|
||||
assert.deepEqual([...next], ['inside-all']);
|
||||
});
|
||||
|
||||
test('Cockpit AIR LOD drops absent and invalid contacts', () => {
|
||||
const next = nextCockpitNearContacts(new Set(['gone']), [
|
||||
['nan', Number.NaN],
|
||||
['', 1],
|
||||
], 150_000, 185_000);
|
||||
|
||||
assert.equal(next.size, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Lazily create the shared white contact pip used by aircraft layers while the
|
||||
* first-person cockpit is active. Cesium multiplies this white texture by the
|
||||
* billboard color, so civilian and military owners retain their provenance
|
||||
* colors without maintaining separate image assets.
|
||||
*
|
||||
* @returns {string} One stable data-URL identity shared by every billboard.
|
||||
*/
|
||||
export function cockpitContactDotImage() {
|
||||
if (cockpitContactDotImage._dataUrl) return cockpitContactDotImage._dataUrl;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, 16, 16);
|
||||
|
||||
// Match the visor's fine-line symbology: a restrained luminous ring with a
|
||||
// crisp center fix, rather than a solid map-marker blob. The layer tint
|
||||
// supplies civilian cyan-white or military amber provenance.
|
||||
ctx.save();
|
||||
ctx.shadowBlur = 2.5;
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(8, 8, 4.25, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.82)';
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.arc(8, 8, 1.55, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Billboard.image assigns a fresh texture-atlas id to non-string sources.
|
||||
// Returning the same canvas for hundreds of contacts therefore still made
|
||||
// Cesium upload/repack hundreds of identical textures at cockpit entry.
|
||||
// A stable URL is keyed once and shared by the entire fleet.
|
||||
cockpitContactDotImage._dataUrl = canvas.toDataURL('image/png');
|
||||
return cockpitContactDotImage._dataUrl;
|
||||
}
|
||||
|
||||
cockpitContactDotImage._dataUrl = null;
|
||||
@@ -0,0 +1,192 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setFlightsCockpitSubject,
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
} from './flights.js';
|
||||
import militaryFlightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject,
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
} from './militaryFlights.js';
|
||||
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
|
||||
const UI_SOURCE = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
const FLIGHTS_SOURCE = readFileSync(new URL('./flights.js', import.meta.url), 'utf8');
|
||||
const MILITARY_SOURCE = readFileSync(new URL('./militaryFlights.js', import.meta.url), 'utf8');
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
name: 'commercial',
|
||||
layer: flightsLayer,
|
||||
setSubject: setFlightsCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: candidateViewer(),
|
||||
tracked: false,
|
||||
meta: {
|
||||
callsign: `${icao24.toUpperCase()} `,
|
||||
altitude: 10_668,
|
||||
klass: 'airliner',
|
||||
onGround: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'military',
|
||||
layer: militaryFlightsLayer,
|
||||
setSubject: setMilitaryCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: candidateViewer(),
|
||||
tracked: false,
|
||||
meta: {
|
||||
callsign: `${icao24.toUpperCase()} `,
|
||||
altitudeFt: 35_000,
|
||||
klass: 'fighter',
|
||||
onGround: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function candidateBillboard(icao24) {
|
||||
const offset = Number.parseInt(icao24.slice(-2), 16) || 1;
|
||||
return {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7 + offset * 0.001, 30.2, 10_668),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
|
||||
function candidateViewer() {
|
||||
return {
|
||||
camera: { positionCartographic: null },
|
||||
scene: {},
|
||||
};
|
||||
}
|
||||
|
||||
function candidateIds(layer) {
|
||||
return layer.getDetectableObjects({ maxCount: 10 }).map((candidate) => candidate.sourceId);
|
||||
}
|
||||
|
||||
test('Cockpit lifecycle publishes one normalized aircraft identity to both detection owners', () => {
|
||||
const dispatcher = /dispatchCockpitModeChanged\(active, info = null\) \{[\s\S]*?\n \}/
|
||||
.exec(UI_SOURCE)?.[0];
|
||||
assert.ok(dispatcher, 'Cockpit event dispatcher is defined');
|
||||
assert.match(dispatcher, /info\?\.icao24/);
|
||||
assert.match(dispatcher, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.match(dispatcher, /\['flights', 'military'\]\.includes\(info\?\.layerId\)/);
|
||||
assert.match(dispatcher, /detail: \{ active: active === true, subjectId, layerId \}/);
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(true, info\);/,
|
||||
'entry and in-Cockpit handoff publish the active subject');
|
||||
assert.match(UI_SOURCE, /this\.dispatchCockpitModeChanged\(false\);/,
|
||||
'exit clears the active subject');
|
||||
|
||||
for (const [name, source] of [
|
||||
['commercial', FLIGHTS_SOURCE],
|
||||
['military', MILITARY_SOURCE],
|
||||
]) {
|
||||
const consumer = /function _applyCockpitState\(detail = \{\}\) \{[\s\S]*?\n\}/
|
||||
.exec(source)?.[0];
|
||||
assert.ok(consumer, `${name} Cockpit consumer is defined`);
|
||||
assert.match(consumer, /detail\?\.subjectId/);
|
||||
assert.match(consumer, /\.trim\(\)\.toLowerCase\(\)/);
|
||||
assert.doesNotMatch(consumer, /layerId/,
|
||||
`${name} must also suppress a duplicate subject originating in the sibling AIR feed`);
|
||||
}
|
||||
});
|
||||
|
||||
for (const fixture of LAYERS) {
|
||||
test(`${fixture.name} detection omits only the active Cockpit AIR subject`, () => {
|
||||
try {
|
||||
// The event contract normalizes ICAO24 before storing it. Exercise that
|
||||
// boundary with the uppercase form while the real candidate key stays
|
||||
// lowercase.
|
||||
fixture.setSubject(true, SUBJECT.toUpperCase());
|
||||
|
||||
fixture.seed(SUBJECT);
|
||||
assert.deepEqual(
|
||||
candidateIds(fixture.layer),
|
||||
[],
|
||||
'the active first-person subject must not emit its own moving bracket',
|
||||
);
|
||||
|
||||
fixture.seed(NEXT_SUBJECT);
|
||||
assert.deepEqual(
|
||||
candidateIds(fixture.layer),
|
||||
[NEXT_SUBJECT],
|
||||
'a nearby aircraft must keep its detection bracket in Cockpit',
|
||||
);
|
||||
|
||||
// A Cockpit handoff flips the suppression atomically: the old subject
|
||||
// becomes an ordinary nearby contact, and the new subject disappears.
|
||||
fixture.setSubject(true, NEXT_SUBJECT.toUpperCase());
|
||||
fixture.seed(SUBJECT);
|
||||
assert.deepEqual(candidateIds(fixture.layer), [SUBJECT], 'handoff restores the old subject');
|
||||
fixture.seed(NEXT_SUBJECT);
|
||||
assert.deepEqual(candidateIds(fixture.layer), [], 'handoff suppresses the new subject');
|
||||
|
||||
// Exiting Cockpit restores the same candidate without touching Detection
|
||||
// mode or the aircraft layer.
|
||||
fixture.setSubject(false, null);
|
||||
fixture.seed(NEXT_SUBJECT);
|
||||
assert.deepEqual(candidateIds(fixture.layer), [NEXT_SUBJECT], 'Cockpit exit restores the bracket');
|
||||
} finally {
|
||||
fixture.setSubject(false, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a Cockpit subject duplicated across commercial and military feeds is suppressed in both', () => {
|
||||
const realDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
return {
|
||||
getContext() {
|
||||
return {
|
||||
clearRect() {},
|
||||
save() {},
|
||||
beginPath() {},
|
||||
arc() {},
|
||||
stroke() {},
|
||||
fill() {},
|
||||
restore() {},
|
||||
};
|
||||
},
|
||||
toDataURL() { return 'data:image/png;base64,cockpit-contact-test'; },
|
||||
};
|
||||
},
|
||||
};
|
||||
try {
|
||||
// Both layers consume the shared subject identity regardless of which
|
||||
// aircraft layer originated the Cockpit event. This prevents one feed's
|
||||
// duplicate from leaving a second bracket on the pilot's own aircraft.
|
||||
setFlightsCockpitSubject(true, SUBJECT);
|
||||
setMilitaryCockpitSubject(true, SUBJECT);
|
||||
LAYERS[0].seed(SUBJECT);
|
||||
LAYERS[1].seed(SUBJECT);
|
||||
|
||||
assert.deepEqual(candidateIds(flightsLayer), []);
|
||||
assert.deepEqual(candidateIds(militaryFlightsLayer), []);
|
||||
} finally {
|
||||
setFlightsCockpitSubject(false, null);
|
||||
setMilitaryCockpitSubject(false, null);
|
||||
if (realDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = realDocument;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Cockpit 3D aircraft policy.
|
||||
*
|
||||
* Cockpit renders NEARBY traffic with the existing fleet models and leaves
|
||||
* everything beyond the band as the shipped contact pips. The behaviour itself
|
||||
* is only observable in a browser, but the policy is expressed as a handful of
|
||||
* decisions and constants in the two flight layers, and those are exactly what a
|
||||
* regression would silently revert. These assertions pin the decisions.
|
||||
*/
|
||||
|
||||
const LAYERS = [
|
||||
{ name: 'flights', path: new URL('./flights.js', import.meta.url) },
|
||||
{ name: 'militaryFlights', path: new URL('./militaryFlights.js', import.meta.url) },
|
||||
];
|
||||
|
||||
/** Read a `const NAME = <number>;` declaration out of a module's source. */
|
||||
function numericConstant(source, name) {
|
||||
const match = new RegExp(`const ${name}\\s*=\\s*(\\d+(?:\\.\\d+)?)`).exec(source);
|
||||
assert.ok(match, `${name} is declared`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
const source = readFileSync(layer.path, 'utf8');
|
||||
|
||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
||||
const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)];
|
||||
assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`);
|
||||
for (const [index, call] of calls.entries()) {
|
||||
assert.match(call[1], /\basynchronous:\s*false\b/,
|
||||
`Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
|
||||
const regime = /function _modelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(regime, '_modelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_models3dEnabled\) return false;/,
|
||||
'OFF must keep Cockpit AIR contacts in 2D');
|
||||
assert.doesNotMatch(regime, /!_models3dEnabled\s*&&\s*!_cockpitContactMode/,
|
||||
'Cockpit must not bypass the user-visible Display toggle');
|
||||
});
|
||||
|
||||
test(`${layer.name}: the pilot's own airframe stays hidden in cockpit`, () => {
|
||||
// Extra suppressions are allowed (the TR-3B Easter egg shares this guard,
|
||||
// pinned in tr3bRegistry.test.mjs); the cockpit exclusion is what this test
|
||||
// owns. The tracked regime is DEFAULT-ON by camera distance (2026-08-19), so
|
||||
// it no longer routes through the toggle-gated `_modelRegimeActive` — the
|
||||
// suppression is now an explicit early return.
|
||||
const regime = /function _trackedModelRegimeActive\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(regime, '_trackedModelRegimeActive is defined');
|
||||
assert.match(regime, /if \(!_trackedIcao \|\| _cockpitContactMode \|\|[\s\S]*?return false;/,
|
||||
'_trackedModelRegimeActive excludes cockpit');
|
||||
const tracked = /function _updateTrackedModel\(\)[\s\S]*?\n if \(!active\)/.exec(source)?.[0];
|
||||
assert.ok(tracked, '_updateTrackedModel is defined');
|
||||
assert.match(tracked, /_trackedModelRegimeActive\(\)/,
|
||||
'the tracked-model driver uses the cockpit-aware predicate');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit uses standard Proximity and All radii with a lower cap`, () => {
|
||||
assert.equal(numericConstant(source, 'MODEL_PROX_ADD_M'), 150_000);
|
||||
assert.equal(numericConstant(source, 'MODEL_PROX_KEEP_M'), 185_000);
|
||||
assert.equal(numericConstant(source, 'MODEL_ALL_ADD_M'), 400_000);
|
||||
assert.equal(numericConstant(source, 'MODEL_ALL_KEEP_M'), 450_000);
|
||||
assert.equal(numericConstant(source, 'COCKPIT_MODEL_MAX'), 60);
|
||||
|
||||
const add = /function _modelAddDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
const keep = /function _modelKeepDistM\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(add, /_models3dMode === 'all' \? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M/);
|
||||
assert.match(keep, /_models3dMode === 'all' \? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M/);
|
||||
assert.doesNotMatch(add, /COCKPIT_MODEL_ADD_M/);
|
||||
assert.doesNotMatch(keep, /COCKPIT_MODEL_KEEP_M/);
|
||||
|
||||
const cap = /function _modelCap\(\) \{[\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(cap, /Math\.min\(COCKPIT_MODEL_MAX/,
|
||||
'Cockpit keeps its 60-model performance ceiling');
|
||||
});
|
||||
|
||||
test(`${layer.name}: near AIR state is independent from model admission`, () => {
|
||||
assert.match(source, /nextCockpitNearContacts\(/,
|
||||
'Cockpit derives a separate near-contact hysteresis set');
|
||||
assert.match(source, /isCockpitContact && !isCockpitNear[\s\S]*cockpitContactDotImage\(\)/,
|
||||
'only out-of-range Cockpit contacts become dots');
|
||||
// `_iconKind` is identity for every unconverted contact (see
|
||||
// tr3bRegistry.test.mjs) — it only swaps the glyph for a contact the
|
||||
// operator explicitly converted into a TR-3B.
|
||||
assert.match(source, /bb\.image = aircraftIcon\(_iconKind\(icao24, meta\?\.klass\)(, bb\._gevIconLarge \? TRACKED_ICON_PX : undefined)?\)/,
|
||||
'near contacts and model fallbacks retain the class-derived aircraft silhouette');
|
||||
assert.match(source, /bb\.rotation = 0;/,
|
||||
'far dots are reset to a rotation-free presentation');
|
||||
assert.match(source, /\(!_cockpitContactMode \|\| isCockpitNear\) && \(doRotations \|\| revealed\)/,
|
||||
'near 2D silhouettes continue to receive projected course');
|
||||
assert.match(source, /if \(bb\.show\) bb\.show = false; \/\/ hand off ONLY once the model renders/,
|
||||
'the gap-proof billboard-to-model handoff remains intact');
|
||||
});
|
||||
|
||||
test(`${layer.name}: Cockpit exit clears near state before restoring map presentation`, () => {
|
||||
const setMode = /function _setCockpitContactMode\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.match(setMode, /else _cockpitNearContacts = new Set\(\);/);
|
||||
assert.match(setMode, /for \(const \[icao24, bb\] of _billboards\) _applyFleetBillboardPresentation\(icao24, bb\);/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Shared identity matching for aircraft contact lookup (`findByQuery`).
|
||||
*
|
||||
* Voice resolves a spoken or model-supplied identity against the loaded fleet,
|
||||
* and the analyst → track_entity handoff feeds it a DISPLAY label — callsign,
|
||||
* else registration, else hex. Both aircraft layers scan their own maps, so
|
||||
* the precedence and canonicalization live here: two copies of this ranking
|
||||
* would drift, and the tie-breaking is exactly what makes the answer stable.
|
||||
* @module contactMatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* Match strength, strongest first. A lower tier ALWAYS wins regardless of feed
|
||||
* order — that is the point: with callsign and registration ranked together, a
|
||||
* registration that happened to equal another contact's callsign was resolved
|
||||
* by upstream `Map` insertion order, so the same spoken query could follow a
|
||||
* different aircraft between polls.
|
||||
*
|
||||
* Callsign outranks registration at equal strength because the callsign is the
|
||||
* identity air traffic uses and the one the operator is most likely saying.
|
||||
* @enum {number}
|
||||
*/
|
||||
export const CONTACT_MATCH_TIER = Object.freeze({
|
||||
HEX_EXACT: 0,
|
||||
CALLSIGN_EXACT: 1,
|
||||
REGISTRATION_EXACT: 2,
|
||||
CALLSIGN_PREFIX: 3,
|
||||
REGISTRATION_PREFIX: 4,
|
||||
CALLSIGN_SUBSTRING: 5,
|
||||
REGISTRATION_SUBSTRING: 6,
|
||||
NONE: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reduce an identity to its comparable core: uppercase alphanumerics only.
|
||||
*
|
||||
* Registrations are written with separators the speaker does not say and the
|
||||
* model does not always reproduce — `G-ABCD` / `GABCD`, `05-8152` / `058152`,
|
||||
* `N123AB` / `N-123AB`. The DISPLAYED value is never rewritten; only the
|
||||
* comparison is canonical.
|
||||
* @param {*} value Raw identity text.
|
||||
* @returns {string} Canonical form, or '' when there is nothing to compare.
|
||||
*/
|
||||
export function canonicalizeContactId(value) {
|
||||
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank one contact against a query.
|
||||
*
|
||||
* Hex is checked first and exactly: it is the unique key, so a hex query must
|
||||
* never be out-competed by a substring hit elsewhere in the fleet.
|
||||
* @param {object} params
|
||||
* @param {string} params.query Caller's raw query text.
|
||||
* @param {string} [params.hex] Contact's unique transponder address.
|
||||
* @param {string} [params.callsign] Contact's callsign, if any.
|
||||
* @param {string} [params.registration] Contact's registration, if any.
|
||||
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
|
||||
*/
|
||||
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
|
||||
const rawQuery = String(query ?? '').trim().toLowerCase();
|
||||
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
|
||||
|
||||
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
|
||||
|
||||
const callsignText = String(callsign ?? '').trim().toLowerCase();
|
||||
// Callsigns carry no separators, so they compare raw — canonicalizing them
|
||||
// would silently widen the long-standing callsign contract.
|
||||
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
|
||||
|
||||
const canonicalQuery = canonicalizeContactId(rawQuery);
|
||||
const canonicalRegistration = canonicalizeContactId(registration);
|
||||
if (canonicalQuery && canonicalRegistration === canonicalQuery) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
|
||||
}
|
||||
|
||||
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
|
||||
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
|
||||
}
|
||||
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
|
||||
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
|
||||
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
|
||||
}
|
||||
return CONTACT_MATCH_TIER.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should `candidate` replace the best match found so far?
|
||||
*
|
||||
* Ties are broken on the transponder hex, ascending. The tool this feeds
|
||||
* (`track_entity`) is a MUTATION fulfilling "follow that one" — returning an
|
||||
* ambiguity for the caller to resolve would cost a round-trip mid-demo, and
|
||||
* the model's observed response to a non-ok track result is to retry with
|
||||
* different guesses rather than to ask (field session 2026-08-21,
|
||||
* 23:48). So the lookup always commits. What it owes the caller is STABILITY:
|
||||
* hex is unique and always present, so the same query resolves to the same
|
||||
* contact for as long as both are loaded, instead of flipping between polls
|
||||
* with feed order. Ambiguity that matters is visible to the operator anyway —
|
||||
* the camera moves to a named contact and the card says which.
|
||||
* @param {{tier: number, id: string}} candidate Contact just ranked.
|
||||
* @param {{tier: number, id: string}|null} incumbent Best match so far.
|
||||
* @returns {boolean} True when the candidate should take over.
|
||||
*/
|
||||
export function contactMatchWins(candidate, incumbent) {
|
||||
if (!candidate || candidate.tier === CONTACT_MATCH_TIER.NONE) return false;
|
||||
if (!incumbent) return true;
|
||||
if (candidate.tier !== incumbent.tier) return candidate.tier < incumbent.tier;
|
||||
return String(candidate.id) < String(incumbent.id);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
CONTACT_MATCH_TIER,
|
||||
canonicalizeContactId,
|
||||
contactMatchWins,
|
||||
rankContactMatch,
|
||||
} from './contactMatch.js';
|
||||
|
||||
/**
|
||||
* Voice resolves a spoken or model-supplied identity against the loaded fleet.
|
||||
* Ranking callsign and registration together let a registration that happened
|
||||
* to equal another contact's callsign win on `Map` insertion order, so the same
|
||||
* query could follow a different aircraft between polls. These pin the
|
||||
* precedence and the separator-insensitive registration comparison.
|
||||
*/
|
||||
|
||||
test('separators and case fall out of an identity comparison', () => {
|
||||
assert.equal(canonicalizeContactId('G-ABCD'), 'GABCD');
|
||||
assert.equal(canonicalizeContactId('gabcd'), 'GABCD');
|
||||
assert.equal(canonicalizeContactId('05-8152'), '058152');
|
||||
assert.equal(canonicalizeContactId('N123AB'), 'N123AB');
|
||||
assert.equal(canonicalizeContactId(null), '');
|
||||
assert.equal(canonicalizeContactId(' '), '');
|
||||
});
|
||||
|
||||
test('hex outranks every softer match', () => {
|
||||
assert.equal(
|
||||
rankContactMatch({ query: 'ae7f01', hex: 'ae7f01', callsign: 'RCH451' }),
|
||||
CONTACT_MATCH_TIER.HEX_EXACT,
|
||||
);
|
||||
// A hex query must not be out-competed by a substring hit elsewhere.
|
||||
assert.equal(
|
||||
rankContactMatch({ query: 'ae7f01', hex: 'bbb999', callsign: 'AE7F01X' }),
|
||||
CONTACT_MATCH_TIER.CALLSIGN_PREFIX,
|
||||
);
|
||||
});
|
||||
|
||||
test('an exact callsign beats an exact registration, whatever the feed order', () => {
|
||||
const asCallsign = rankContactMatch({ query: '6606', hex: 'aaa111', callsign: '6606' });
|
||||
const asRegistration = rankContactMatch({ query: '6606', hex: 'bbb222', registration: '6606' });
|
||||
assert.equal(asCallsign, CONTACT_MATCH_TIER.CALLSIGN_EXACT);
|
||||
assert.equal(asRegistration, CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
assert.ok(
|
||||
asCallsign < asRegistration,
|
||||
'the callsign contract must not depend on which contact the feed listed first',
|
||||
);
|
||||
});
|
||||
|
||||
test('a registration matches in the form the operator says it', () => {
|
||||
const hyphenated = { hex: 'aaa111', registration: 'G-ABCD' };
|
||||
assert.equal(rankContactMatch({ query: 'GABCD', ...hyphenated }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
assert.equal(rankContactMatch({ query: 'g-abcd', ...hyphenated }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
const military = { hex: 'ae7f01', registration: '05-8152' };
|
||||
assert.equal(rankContactMatch({ query: '058152', ...military }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
assert.equal(rankContactMatch({ query: '05-8152', ...military }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
});
|
||||
|
||||
test('a civilian N-number resolves in every form it is written or spoken', () => {
|
||||
const civilian = { hex: 'a1b2c3', registration: 'N123AB' };
|
||||
assert.equal(rankContactMatch({ query: 'N123AB', ...civilian }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
assert.equal(rankContactMatch({ query: 'n-123ab', ...civilian }), CONTACT_MATCH_TIER.REGISTRATION_EXACT);
|
||||
assert.equal(rankContactMatch({ query: 'n123', ...civilian }), CONTACT_MATCH_TIER.REGISTRATION_PREFIX);
|
||||
assert.equal(rankContactMatch({ query: '123ab', ...civilian }), CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING);
|
||||
});
|
||||
|
||||
test('prefix beats substring, and callsign beats registration at equal strength', () => {
|
||||
assert.equal(
|
||||
rankContactMatch({ query: 'swa', hex: 'a1', callsign: 'SWA123' }),
|
||||
CONTACT_MATCH_TIER.CALLSIGN_PREFIX,
|
||||
);
|
||||
assert.equal(
|
||||
rankContactMatch({ query: '123', hex: 'a1', callsign: 'SWA123' }),
|
||||
CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING,
|
||||
);
|
||||
// Same contact, both fields hit: the callsign tier is the one reported.
|
||||
assert.equal(
|
||||
rankContactMatch({ query: 'n12', hex: 'a1', callsign: 'N12X', registration: 'N123AB' }),
|
||||
CONTACT_MATCH_TIER.CALLSIGN_PREFIX,
|
||||
);
|
||||
});
|
||||
|
||||
test('nothing matches an empty or unknown query', () => {
|
||||
assert.equal(rankContactMatch({ query: '', hex: 'a1', callsign: 'SWA123' }), CONTACT_MATCH_TIER.NONE);
|
||||
assert.equal(rankContactMatch({ query: ' ', hex: 'a1', callsign: 'SWA123' }), CONTACT_MATCH_TIER.NONE);
|
||||
assert.equal(rankContactMatch({ query: 'zzz', hex: 'a1', callsign: 'SWA123' }), CONTACT_MATCH_TIER.NONE);
|
||||
// A contact with no identity at all cannot be matched by a non-empty query.
|
||||
assert.equal(rankContactMatch({ query: 'zzz', hex: '' }), CONTACT_MATCH_TIER.NONE);
|
||||
});
|
||||
|
||||
test('a same-tier tie resolves the same way every time', () => {
|
||||
// Two contacts whose registrations both match at the same strength. Without
|
||||
// a stable key the winner is upstream Map order, so the same spoken query
|
||||
// could follow a different aircraft between polls.
|
||||
const a = { tier: CONTACT_MATCH_TIER.REGISTRATION_PREFIX, id: 'bbb222' };
|
||||
const b = { tier: CONTACT_MATCH_TIER.REGISTRATION_PREFIX, id: 'aaa111' };
|
||||
assert.equal(contactMatchWins(b, a), true, 'the lower hex takes over');
|
||||
assert.equal(contactMatchWins(a, b), false, 'and the higher hex never displaces it');
|
||||
});
|
||||
|
||||
test('a stronger tier always beats a weaker one, whichever arrived first', () => {
|
||||
const exact = { tier: CONTACT_MATCH_TIER.CALLSIGN_EXACT, id: 'zzz999' };
|
||||
const prefix = { tier: CONTACT_MATCH_TIER.REGISTRATION_PREFIX, id: 'aaa111' };
|
||||
assert.equal(contactMatchWins(exact, prefix), true, 'tier outranks the tiebreak key');
|
||||
assert.equal(contactMatchWins(prefix, exact), false);
|
||||
});
|
||||
|
||||
test('a non-match never becomes the best match', () => {
|
||||
assert.equal(contactMatchWins({ tier: CONTACT_MATCH_TIER.NONE, id: 'aaa111' }, null), false);
|
||||
assert.equal(
|
||||
contactMatchWins(
|
||||
{ tier: CONTACT_MATCH_TIER.NONE, id: 'aaa111' },
|
||||
{ tier: CONTACT_MATCH_TIER.CALLSIGN_EXACT, id: 'zzz999' },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
const STORE_KEY = '__gevContextStore';
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
entities: new Map(),
|
||||
selectedEntityId: null,
|
||||
selectedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextStore() {
|
||||
if (!window[STORE_KEY]) {
|
||||
window[STORE_KEY] = createStore();
|
||||
}
|
||||
return window[STORE_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a window to hang the store on?
|
||||
*
|
||||
* The tracking-subject helpers run inside the aircraft layers' per-poll
|
||||
* refresh, which unit tests drive with no DOM at all. Without this guard the
|
||||
* bare `window` read throws mid-poll and takes the rest of the refresh with
|
||||
* it.
|
||||
* @returns {boolean} True when the store is reachable.
|
||||
*/
|
||||
function hasContextHost() {
|
||||
return typeof window !== 'undefined' && Boolean(window);
|
||||
}
|
||||
|
||||
export function registerEntityContext(entity, metadata) {
|
||||
if (!entity || !metadata?.id) return null;
|
||||
const store = getContextStore();
|
||||
const record = {
|
||||
...metadata,
|
||||
entity,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
entity.__gevContextId = metadata.id;
|
||||
store.entities.set(metadata.id, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function selectEntityContext(entity) {
|
||||
const store = getContextStore();
|
||||
const contextId = entity?.__gevContextId;
|
||||
if (!contextId || !store.entities.has(contextId)) return null;
|
||||
store.selectedEntityId = contextId;
|
||||
store.selectedAt = Date.now();
|
||||
const record = store.entities.get(contextId);
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the live subject of a TRACKING layer (aircraft) into the shared
|
||||
* selection slot.
|
||||
*
|
||||
* Selecting a contact is a click-selection like any other — voice's
|
||||
* `scope:'selected'` and the Cockpit's selected-target lookup both read this
|
||||
* one slot, so a tracking layer that stays out of it is invisible to them
|
||||
* even while its readout card is on screen.
|
||||
*
|
||||
* Deliberately does NOT dispatch `gev:entity-selected`: tracking layers own a
|
||||
* separate publication lane (`gev:awareness-subject-selected`) that the
|
||||
* readout and Contacts panel already consume, and a second event for the same
|
||||
* click would make those two surfaces fight over one subject.
|
||||
*
|
||||
* A tracking layer has at most one subject, so any earlier record it left
|
||||
* behind is dropped — its feed refreshes continuously and a frozen snapshot
|
||||
* must never reach the visible-entity scan.
|
||||
*
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when identity is missing.
|
||||
*/
|
||||
export function selectTrackedSubjectContext(metadata) {
|
||||
if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
|
||||
}
|
||||
// Reuse the existing carrier so a per-poll refresh does not churn identity.
|
||||
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
|
||||
const record = registerEntityContext(carrier, { ...metadata, id });
|
||||
if (!record) return null;
|
||||
store.selectedEntityId = id;
|
||||
store.selectedAt = Date.now();
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a tracking layer's subject in place WITHOUT claiming the selection.
|
||||
*
|
||||
* The per-poll position/identity refresh must not resurrect a subject the
|
||||
* operator has since replaced by clicking something else.
|
||||
* @param {object} metadata Record fields; `id` and `layerId` required.
|
||||
* @returns {object|null} The stored record, or null when it is not registered.
|
||||
*/
|
||||
export function refreshTrackedSubjectContext(metadata) {
|
||||
if (!hasContextHost() || !metadata?.id || !metadata?.layerId) return null;
|
||||
const store = getContextStore();
|
||||
const id = String(metadata.id);
|
||||
const existing = store.entities.get(id);
|
||||
if (!existing || existing.layerId !== metadata.layerId) return null;
|
||||
return registerEntityContext(existing.entity, { ...metadata, id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a tracking layer's subject when the operator deselects it.
|
||||
*
|
||||
* Pairs with {@link selectTrackedSubjectContext} and stays event-free for the
|
||||
* same reason: `gev:awareness-subject-cleared` is the tracking layers' lane.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clearTrackedSubjectContext(layerId) {
|
||||
if (!hasContextHost() || !layerId) return;
|
||||
const store = getContextStore();
|
||||
for (const [key, record] of store.entities) {
|
||||
if (record?.layerId === layerId) store.entities.delete(key);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSelectedEntityContext({ dataManager = null } = {}) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return null;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (!record || !isContextRecordActive(record, dataManager)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
return null;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the selected context record owned by a layer.
|
||||
* @param {string} layerId Owning layer.
|
||||
* @param {object} [options] Clear origin.
|
||||
* @param {boolean} [options.evicted=false] The record aged out of its feed
|
||||
* rather than being deselected. Readouts that stay on screen hold their
|
||||
* last-known values for an eviction and only tear down on a deliberate clear.
|
||||
*/
|
||||
export function clearSelectedEntityContextForLayer(layerId, { evicted = false } = {}) {
|
||||
const store = getContextStore();
|
||||
if (!store.selectedEntityId) return;
|
||||
const record = store.entities.get(store.selectedEntityId);
|
||||
if (record?.layerId === layerId) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: evicted ? 'evicted' : 'deliberate' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove obsolete context records when a viewport-scoped layer refreshes. */
|
||||
export function removeEntityContextsForLayer(layerId) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId) store.entities.delete(id);
|
||||
}
|
||||
if (store.selectedEntityId && !store.entities.has(store.selectedEntityId)) {
|
||||
store.selectedEntityId = null;
|
||||
store.selectedAt = null;
|
||||
// A viewport refresh dropped the record out from under the selection —
|
||||
// the user did not deselect anything.
|
||||
window.dispatchEvent(new CustomEvent('gev:entity-selection-cleared', {
|
||||
detail: { layerId, reason: 'evicted' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function isContextRecordActive(record, dataManager = null) {
|
||||
if (!record) return false;
|
||||
if (record.entity?.show === false) return false;
|
||||
if (record.dataSource && record.dataSource.show === false) return false;
|
||||
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
clearTrackedSubjectContext,
|
||||
getContextStore,
|
||||
getSelectedEntityContext,
|
||||
refreshTrackedSubjectContext,
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
selectTrackedSubjectContext,
|
||||
} from './contextStore.js';
|
||||
|
||||
/**
|
||||
* The tracking layers (aircraft) publish selection on their own awareness
|
||||
* lane, which the readout card and Contacts panel consume. These tests pin the
|
||||
* OTHER half of that contract: the same click must also reach the shared
|
||||
* selection slot, because voice's `get_entity_context {scope:'selected'}` and
|
||||
* the Cockpit's selected-target lookup read only this slot. When they did not,
|
||||
* a plainly selected plane answered "there isn't a plane currently selected".
|
||||
*/
|
||||
|
||||
function withWindow(run) {
|
||||
const realWindow = globalThis.window;
|
||||
const host = new EventTarget();
|
||||
globalThis.window = host;
|
||||
try {
|
||||
return run(host);
|
||||
} finally {
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
}
|
||||
|
||||
const flightSubject = (id, extra = {}) => ({
|
||||
id,
|
||||
layerId: 'flights',
|
||||
layerName: 'Live Flights',
|
||||
source: 'OpenSky Network',
|
||||
label: id.toUpperCase(),
|
||||
latitude: 30.2672,
|
||||
longitude: -97.7431,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('a tracking layer subject becomes the selected entity context', () => {
|
||||
withWindow(() => {
|
||||
const record = selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
assert.equal(record?.id, 'aaa001');
|
||||
const selected = getSelectedEntityContext();
|
||||
assert.equal(selected?.id, 'aaa001');
|
||||
assert.equal(selected?.layerId, 'flights');
|
||||
assert.equal(selected?.latitude, 30.2672);
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting a tracking subject stays off the overlay-click event lane', () => {
|
||||
withWindow((host) => {
|
||||
const seen = [];
|
||||
host.addEventListener('gev:entity-selected', () => seen.push('selected'));
|
||||
host.addEventListener('gev:entity-selection-cleared', () => seen.push('cleared'));
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
clearTrackedSubjectContext('flights');
|
||||
assert.deepEqual(
|
||||
seen,
|
||||
[],
|
||||
'aircraft already publish gev:awareness-subject-* — a second lane would make two surfaces fight over one subject',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching contacts moves the subject and leaves no stale record behind', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
selectTrackedSubjectContext(flightSubject('aaa002', { latitude: 31.1, longitude: -96.9 }));
|
||||
const store = getContextStore();
|
||||
const flightIds = [...store.entities.values()]
|
||||
.filter((record) => record.layerId === 'flights')
|
||||
.map((record) => record.id);
|
||||
assert.deepEqual(flightIds, ['aaa002']);
|
||||
assert.equal(getSelectedEntityContext()?.id, 'aaa002');
|
||||
});
|
||||
});
|
||||
|
||||
test('a sibling tracking layer keeps its own subject slot', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
selectTrackedSubjectContext({
|
||||
id: 'bbb101', layerId: 'military', label: 'MIL101', latitude: 30.27, longitude: -97.75,
|
||||
});
|
||||
const store = getContextStore();
|
||||
assert.equal(getSelectedEntityContext()?.id, 'bbb101');
|
||||
assert.ok(store.entities.has('aaa001'), 'the flights record survives; only the SELECTION moved');
|
||||
});
|
||||
});
|
||||
|
||||
test('the per-poll refresh updates values without stealing the selection', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
const other = registerEntityContext({ __gevContextId: 'dc-7' }, {
|
||||
id: 'dc-7', layerId: 'local-datacenters', label: 'Datacenter 7',
|
||||
});
|
||||
selectEntityContext(other.entity);
|
||||
assert.equal(getSelectedEntityContext()?.id, 'dc-7');
|
||||
|
||||
refreshTrackedSubjectContext(flightSubject('aaa001', { latitude: 31.5 }));
|
||||
assert.equal(
|
||||
getSelectedEntityContext()?.id,
|
||||
'dc-7',
|
||||
'a background position refresh must not resurrect a subject the operator replaced',
|
||||
);
|
||||
assert.equal(getContextStore().entities.get('aaa001').latitude, 31.5);
|
||||
});
|
||||
});
|
||||
|
||||
test('the per-poll refresh will not register a subject that was never selected', () => {
|
||||
withWindow(() => {
|
||||
assert.equal(refreshTrackedSubjectContext(flightSubject('aaa009')), null);
|
||||
assert.equal(getContextStore().entities.has('aaa009'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('deselecting a contact releases the shared slot', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
clearTrackedSubjectContext('flights');
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(getContextStore().entities.has('aaa001'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('clearing one tracking layer leaves another layer selection alone', () => {
|
||||
withWindow(() => {
|
||||
const other = registerEntityContext({ __gevContextId: 'dam-3' }, {
|
||||
id: 'dam-3', layerId: 'local-dams', label: 'Dam 3',
|
||||
});
|
||||
selectEntityContext(other.entity);
|
||||
clearTrackedSubjectContext('flights');
|
||||
assert.equal(getSelectedEntityContext()?.id, 'dam-3');
|
||||
});
|
||||
});
|
||||
|
||||
test('the tracking-subject helpers are inert with no window to host the store', () => {
|
||||
const realWindow = globalThis.window;
|
||||
globalThis.window = undefined;
|
||||
try {
|
||||
// The aircraft layers call these inside their per-poll refresh; a throw
|
||||
// here would abort the rest of the poll.
|
||||
assert.equal(selectTrackedSubjectContext(flightSubject('aaa001')), null);
|
||||
assert.equal(refreshTrackedSubjectContext(flightSubject('aaa001')), null);
|
||||
assert.doesNotThrow(() => clearTrackedSubjectContext('flights'));
|
||||
} finally {
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('a satellite subject uses the same shared slot as aircraft', () => {
|
||||
// Satellites were named in the same PRD gap: they publish only the awareness
|
||||
// event, so a tracked satellite was invisible to the voice entity tools.
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
selectTrackedSubjectContext({
|
||||
id: '25544',
|
||||
layerId: 'satellites',
|
||||
layerName: 'Satellites',
|
||||
source: 'CelesTrak',
|
||||
label: 'ISS (ZARYA)',
|
||||
latitude: 12.5,
|
||||
longitude: -45.2,
|
||||
properties: { name: 'ISS (ZARYA)', noradId: '25544', altitude: '420 km' },
|
||||
});
|
||||
const selected = getSelectedEntityContext();
|
||||
assert.equal(selected?.id, '25544');
|
||||
assert.equal(selected?.layerId, 'satellites');
|
||||
assert.equal(selected?.properties?.noradId, '25544');
|
||||
});
|
||||
});
|
||||
|
||||
test('switching satellites leaves no stale orbit record behind', () => {
|
||||
withWindow(() => {
|
||||
const sat = (id, label) => ({
|
||||
id, layerId: 'satellites', label, latitude: 1, longitude: 2,
|
||||
});
|
||||
selectTrackedSubjectContext(sat('25544', 'ISS (ZARYA)'));
|
||||
selectTrackedSubjectContext(sat('20580', 'HST'));
|
||||
const ids = [...getContextStore().entities.values()]
|
||||
.filter((record) => record.layerId === 'satellites')
|
||||
.map((record) => record.id);
|
||||
assert.deepEqual(ids, ['20580']);
|
||||
assert.equal(getSelectedEntityContext()?.id, '20580');
|
||||
});
|
||||
});
|
||||
|
||||
test('deselecting a satellite releases the slot without touching aircraft', () => {
|
||||
withWindow(() => {
|
||||
selectTrackedSubjectContext(flightSubject('aaa001'));
|
||||
selectTrackedSubjectContext({ id: '25544', layerId: 'satellites', label: 'ISS', latitude: 1, longitude: 2 });
|
||||
clearTrackedSubjectContext('satellites');
|
||||
assert.equal(getSelectedEntityContext(), null, 'the satellite gave the slot back');
|
||||
assert.ok(getContextStore().entities.has('aaa001'), 'and the aircraft record is untouched');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Per-layer data attribution registered into Cesium's credit display.
|
||||
*
|
||||
* Legal requirement (see DATA_SOURCES.md, findings H10/H11 in
|
||||
* every third-party data layer this app can
|
||||
* display carries its own license and required attribution — ODbL (OSM
|
||||
* datacenters/dams, adsb.lol, Overpass roads), CC BY-NC-SA (TeleGeography
|
||||
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS operators, OpenSky.
|
||||
* The MIT code license does NOT cover this data.
|
||||
*
|
||||
* These credits are registered ONCE at init as STATIC credits with
|
||||
* showOnScreen=false, so they live in the expandable bottom-left "Data
|
||||
* attribution" lightbox (Cesium's credit popover) rather than cluttering the
|
||||
* on-globe line. Always-present is intentional and reversible: the lightbox is
|
||||
* the app's canonical attribution surface and DATA_SOURCES.md is the
|
||||
* machine-readable index. Strings are copied verbatim from DATA_SOURCES.md — if
|
||||
* you add a data source, add it there AND here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attribution entries. `html` is the credit markup; keep it minimal and
|
||||
* link out where DATA_SOURCES.md provides a canonical URL. Order roughly
|
||||
* follows DATA_SOURCES.md (live sources, then bundled snapshots).
|
||||
* @type {{ key: string, html: string }[]}
|
||||
*/
|
||||
export const DATA_CREDITS = [
|
||||
// ── Live sources ────────────────────────────────────────────────
|
||||
{
|
||||
key: 'opensky',
|
||||
html:
|
||||
'Flights: OpenSky Network — Schäfer et al., ' +
|
||||
'“Bringing Up OpenSky”, IPSN 2014 · ' +
|
||||
'<a href="https://opensky-network.org" target="_blank" rel="noopener">opensky-network.org</a> ' +
|
||||
'(non-commercial)',
|
||||
},
|
||||
{
|
||||
key: 'adsblol',
|
||||
html:
|
||||
'Military flights, aircraft traces & bounded regional flight fallback: ' +
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
'Live vessels (AIS): ' +
|
||||
'<a href="https://aisstream.io" target="_blank" rel="noopener">AISStream.io</a>',
|
||||
},
|
||||
{
|
||||
key: 'celestrak',
|
||||
html:
|
||||
'Satellites (TLEs): CelesTrak ' +
|
||||
'(<a href="https://celestrak.org" target="_blank" rel="noopener">celestrak.org</a>), ' +
|
||||
'Dr. T.S. Kelso',
|
||||
},
|
||||
{
|
||||
key: 'launch-library-2',
|
||||
html:
|
||||
'Space mission launch, payload & recovery metadata: ' +
|
||||
'<a href="https://ll.thespacedevs.com/docs/" target="_blank" rel="noopener">Launch Library 2 — The Space Devs</a> ' +
|
||||
'(API documentation and rate limits)',
|
||||
},
|
||||
{
|
||||
key: 'usgs',
|
||||
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
|
||||
},
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
'Road geometry (traffic): ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'military-installations-osm',
|
||||
html:
|
||||
'Mapped installation context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0; incomplete mapped context)',
|
||||
},
|
||||
{
|
||||
key: 'cockpit-place-osm',
|
||||
html:
|
||||
'Cockpit place context: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'via Nominatim (ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'open-meteo',
|
||||
html:
|
||||
'Cockpit current conditions: ' +
|
||||
'<a href="https://open-meteo.com/en/licence" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a> ' +
|
||||
'(CC BY 4.0)',
|
||||
},
|
||||
{
|
||||
key: 'google-news-rss',
|
||||
html:
|
||||
'Cockpit regional headlines: ' +
|
||||
'<a href="https://policies.google.com/terms" target="_blank" rel="noopener">Google News RSS</a> ' +
|
||||
'(location-matched article links; publisher terms apply)',
|
||||
},
|
||||
{
|
||||
key: 'gdelt',
|
||||
html:
|
||||
'Cockpit regional headlines: ' +
|
||||
'<a href="https://www.gdeltproject.org/about.html" target="_blank" rel="noopener">GDELT Project</a> ' +
|
||||
'(location-matched article links; publisher terms apply)',
|
||||
},
|
||||
{
|
||||
key: 'austin-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames: City of Austin, TX — ' +
|
||||
'<a href="https://data.austintexas.gov" target="_blank" rel="noopener">data.austintexas.gov</a>',
|
||||
},
|
||||
{
|
||||
key: 'caltrans-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (California): Caltrans — ' +
|
||||
'<a href="https://cwwp2.dot.ca.gov/" target="_blank" rel="noopener">cwwp2.dot.ca.gov</a>',
|
||||
},
|
||||
{
|
||||
key: 'tfl-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (London): ' +
|
||||
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
|
||||
'Contains OS data © Crown copyright and database rights.',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
html:
|
||||
'Internet-radio station directory: ' +
|
||||
'<a href="https://www.radio-browser.info/" target="_blank" rel="noopener">Radio Browser</a> ' +
|
||||
'(public domain; audio delivered directly by each broadcaster)',
|
||||
},
|
||||
{
|
||||
key: 'reearth-terrain',
|
||||
html:
|
||||
'Terrain (keyless globe stacks): ' +
|
||||
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
|
||||
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
html:
|
||||
'Datacenters: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'dams',
|
||||
html:
|
||||
'Dams: ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
|
||||
'(ODbL 1.0) + Open Infrastructure Map',
|
||||
},
|
||||
{
|
||||
key: 'firms',
|
||||
html:
|
||||
'Active fires: NASA FIRMS — we acknowledge the use of data and/or imagery ' +
|
||||
'from NASA’s Fire Information for Resource Management System ' +
|
||||
'(<a href="https://earthdata.nasa.gov/firms" target="_blank" rel="noopener">earthdata.nasa.gov/firms</a>), ' +
|
||||
'part of NASA’s Earth Observing System Data and Information System (EOSDIS)',
|
||||
},
|
||||
{
|
||||
key: 'telegeography',
|
||||
html:
|
||||
'Submarine cables: © TeleGeography — ' +
|
||||
'<a href="https://www.submarinecablemap.com" target="_blank" rel="noopener">submarinecablemap.com</a> ' +
|
||||
'(CC BY-NC-SA 3.0 — NonCommercial)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Conditional credits — registered via `registerDynamicCredit` only when the
|
||||
* corresponding capability actually activates (deliberately NOT part of
|
||||
* DATA_CREDITS, which is always-on). TomTom terms require attribution when
|
||||
* their flow data is displayed; keyless installs never show it, so the
|
||||
* credit only appears once live traffic-flow mode activates.
|
||||
* @type {{ key: string, html: string }}
|
||||
*/
|
||||
export const TOMTOM_CREDIT = {
|
||||
key: 'tomtom',
|
||||
html:
|
||||
'Traffic flow data © ' +
|
||||
'<a href="https://www.tomtom.com" target="_blank" rel="noopener">TomTom</a>',
|
||||
};
|
||||
|
||||
/** Registered when the first Natural Earth region outline resolves (public
|
||||
* domain — no attribution required; credited as a courtesy). */
|
||||
export const NATURAL_EARTH_CREDIT = {
|
||||
key: 'natural-earth',
|
||||
html:
|
||||
'Physical region boundaries from ' +
|
||||
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
|
||||
};
|
||||
|
||||
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
|
||||
const _dynamicCreditKeys = new Set();
|
||||
|
||||
/**
|
||||
* Register a conditional credit at the moment its data source activates.
|
||||
* Idempotent per `credit.key`; lands in the same "Data attribution" popover
|
||||
* as the static credits (showOnScreen=false).
|
||||
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
|
||||
* @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT`
|
||||
* @returns {boolean} True when the credit is (now) registered.
|
||||
*/
|
||||
export function registerDynamicCredit(viewer, credit) {
|
||||
const creditDisplay = viewer?.creditDisplay;
|
||||
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
|
||||
return false;
|
||||
}
|
||||
if (!credit?.key || !credit?.html) return false;
|
||||
if (_dynamicCreditKeys.has(credit.key)) return true;
|
||||
creditDisplay.addStaticCredit(new Cesium.Credit(credit.html, false));
|
||||
_dynamicCreditKeys.add(credit.key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every per-layer data credit into the viewer's credit display.
|
||||
* Idempotent: safe to call once at init. Credits are static and always
|
||||
* present in the "Data attribution" popover.
|
||||
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
|
||||
*/
|
||||
export function registerDataCredits(viewer) {
|
||||
const creditDisplay = viewer?.creditDisplay;
|
||||
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
|
||||
return;
|
||||
}
|
||||
for (const { html } of DATA_CREDITS) {
|
||||
// showOnScreen=false → lives in the expandable "Data attribution" popover,
|
||||
// not the on-globe credit line.
|
||||
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,96 @@
|
||||
const DEFAULT_MAX_COHORT = 256;
|
||||
|
||||
/** Resolve the review-mandated bounded materialization cap for a layer quota. */
|
||||
export function cohortCapForQuota(quota) {
|
||||
const normalized = Math.max(0, Math.floor(Number(quota) || 0));
|
||||
return Math.min(DEFAULT_MAX_COHORT, Math.max(64, normalized * 4));
|
||||
}
|
||||
|
||||
/** Stable FNV-1a identity hash used only on solve ticks. */
|
||||
export function stableIdentityHash(layerId, sourceId) {
|
||||
const text = `${String(layerId)}\u0000${String(sourceId)}`;
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** Negative means a is a better deterministic contender than b. */
|
||||
export function compareCohortContenders(a, b) {
|
||||
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
|
||||
if (priorityDelta) return priorityDelta;
|
||||
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
|
||||
if (bandDelta) return bandDelta;
|
||||
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
|
||||
if (hashDelta) return hashDelta;
|
||||
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
|
||||
}
|
||||
|
||||
function isWorse(a, b) {
|
||||
return compareCohortContenders(a, b) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming deterministic contender reservoir. The heap retains at most 256
|
||||
* non-incumbents per layer, so a 12k-object scan never creates or sorts a full
|
||||
* rich-candidate population.
|
||||
*/
|
||||
export class BoundedCohort {
|
||||
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
|
||||
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
|
||||
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
|
||||
this.incumbents = [];
|
||||
this.heap = [];
|
||||
}
|
||||
|
||||
consider(observation, incumbent = false) {
|
||||
if (!observation) return;
|
||||
if (incumbent) {
|
||||
this.incumbents.push(observation);
|
||||
return;
|
||||
}
|
||||
if (this.heap.length < this.maxSize) {
|
||||
this.heap.push(observation);
|
||||
this._bubbleUp(this.heap.length - 1);
|
||||
return;
|
||||
}
|
||||
if (compareCohortContenders(observation, this.heap[0]) >= 0) return;
|
||||
this.heap[0] = observation;
|
||||
this._siftDown(0);
|
||||
}
|
||||
|
||||
values(limit = this.maxSize) {
|
||||
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
|
||||
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
|
||||
const remaining = Math.max(0, cap - incumbents.length);
|
||||
if (remaining === 0) return incumbents;
|
||||
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
|
||||
return incumbents.concat(contenders);
|
||||
}
|
||||
|
||||
_bubbleUp(index) {
|
||||
let current = index;
|
||||
while (current > 0) {
|
||||
const parent = Math.floor((current - 1) / 2);
|
||||
if (!isWorse(this.heap[current], this.heap[parent])) break;
|
||||
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
_siftDown(index) {
|
||||
let current = index;
|
||||
while (true) {
|
||||
const left = current * 2 + 1;
|
||||
const right = left + 1;
|
||||
let worst = current;
|
||||
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
|
||||
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
|
||||
if (worst === current) return;
|
||||
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
|
||||
current = worst;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
BoundedCohort,
|
||||
cohortCapForQuota,
|
||||
stableIdentityHash,
|
||||
} from './detectionCohort.js';
|
||||
|
||||
function observation(index, priority = 25, band = 8) {
|
||||
return {
|
||||
_cohortSourceId: index,
|
||||
_cohortPriority: priority,
|
||||
_cohortBand: band,
|
||||
_cohortHash: stableIdentityHash('satellites', index),
|
||||
};
|
||||
}
|
||||
|
||||
test('cohort cap follows min(256, max(64, 4 × quota))', () => {
|
||||
assert.equal(cohortCapForQuota(0), 64);
|
||||
assert.equal(cohortCapForQuota(10), 64);
|
||||
assert.equal(cohortCapForQuota(20), 80);
|
||||
assert.equal(cohortCapForQuota(90), 256);
|
||||
});
|
||||
|
||||
test('12k contender population stays bounded and registration-order independent', () => {
|
||||
const items = Array.from({ length: 12000 }, (_, index) => observation(index));
|
||||
const forward = new BoundedCohort();
|
||||
const reverse = new BoundedCohort();
|
||||
for (const item of items) forward.consider(item);
|
||||
for (let i = items.length - 1; i >= 0; i--) reverse.consider(items[i]);
|
||||
const a = forward.values(256).map((item) => item._cohortSourceId);
|
||||
const b = reverse.values(256).map((item) => item._cohortSourceId);
|
||||
assert.equal(a.length, 256);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test('incumbents are admitted before stronger new contenders', () => {
|
||||
const cohort = new BoundedCohort();
|
||||
cohort.consider(observation('incumbent', 1, 1), true);
|
||||
for (let i = 0; i < 100; i++) cohort.consider(observation(i, 120, 8));
|
||||
const selected = cohort.values(64);
|
||||
assert.equal(selected[0]._cohortSourceId, 'incumbent');
|
||||
assert.equal(selected.length, 64);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @module detectionDraw
|
||||
* @description Pure, renderer-agnostic helpers for the detection overlay.
|
||||
*
|
||||
* Deliberately free of Cesium and DOM dependencies so the label composition,
|
||||
* metric formatting, glyph-width estimation, fade ramp, and corner-bracket
|
||||
* geometry can be unit-tested in isolation — and reused verbatim by a future
|
||||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||||
* supplies the actual drawing surface.
|
||||
*/
|
||||
|
||||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||||
const MAX_PRIMARY = 18;
|
||||
/** @constant {number} Max characters shown for a label's secondary (class · metric) line. */
|
||||
const MAX_SECONDARY = 26;
|
||||
/** @constant {number} Metres-to-feet conversion factor. */
|
||||
const M_TO_FT = 3.28084;
|
||||
|
||||
/**
|
||||
* Formats an altitude in metres as an aviation flight level (hundreds of feet),
|
||||
* zero-padded to three digits. Returns '' for missing/zero/negative altitude so
|
||||
* the label gracefully degrades to id-only.
|
||||
* @param {number} altitudeMeters - Altitude above sea level in metres.
|
||||
* @returns {string} e.g. 'FL340', or '' when not applicable.
|
||||
*/
|
||||
export function formatFlightLevel(altitudeMeters) {
|
||||
if (!Number.isFinite(altitudeMeters) || altitudeMeters <= 0) return '';
|
||||
const fl = Math.round((altitudeMeters * M_TO_FT) / 100);
|
||||
return 'FL' + String(fl).padStart(3, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a speed in knots as a short rounded label. Returns '' for
|
||||
* non-positive or non-finite speeds (declutters moored/unknown vessels).
|
||||
* @param {number} knots - Speed over ground in knots.
|
||||
* @returns {string} e.g. '14 kn', or '' when not applicable.
|
||||
*/
|
||||
export function formatKnots(knots) {
|
||||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||||
return Math.round(knots) + ' kn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the pixel width of a monospace string without touching the canvas.
|
||||
* For a fixed-advance font, width is exactly length × advance — this avoids a
|
||||
* per-label measureText() call (the old per-frame hot-path stall).
|
||||
* @param {string} text - The string to measure.
|
||||
* @param {number} charWidth - Per-glyph advance width in pixels.
|
||||
* @returns {number} Estimated width in pixels (0 for empty/null).
|
||||
*/
|
||||
export function monoTextWidth(text, charWidth) {
|
||||
if (!text) return 0;
|
||||
return text.length * charWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes a detection label into a two-tier structure: a bright primary line
|
||||
* (the id/callsign) and a dimmer secondary line ("class · metric"). Missing
|
||||
* class/metric collapse the secondary to '' so the label degrades to id-only.
|
||||
* @param {{id?: string, klass?: string, metric?: string}} obj - Label source fields.
|
||||
* @returns {{primary: string, secondary: string}} Truncated label lines.
|
||||
*/
|
||||
export function composeLabel(obj) {
|
||||
const src = obj || {};
|
||||
const primary = String(src.id || '').slice(0, MAX_PRIMARY);
|
||||
const parts = [src.klass, src.metric]
|
||||
.map((s) => (s == null ? '' : String(s).trim()))
|
||||
.filter((s) => s.length > 0);
|
||||
const secondary = parts.join(' · ').slice(0, MAX_SECONDARY);
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a 0→1 fade-in alpha for a subtle "acquire" animation, ramping over
|
||||
* fadeMs from the first-seen timestamp. Defaults to fully visible (1) when the
|
||||
* timestamp is unknown or the fade window is non-positive.
|
||||
* @param {number} firstSeenMs - Timestamp the target first appeared.
|
||||
* @param {number} nowMs - Current timestamp.
|
||||
* @param {number} fadeMs - Fade-in duration in milliseconds.
|
||||
* @returns {number} Alpha in [0, 1].
|
||||
*/
|
||||
export function acquireAlpha(firstSeenMs, nowMs, fadeMs) {
|
||||
if (!Number.isFinite(firstSeenMs) || !(fadeMs > 0)) return 1;
|
||||
const elapsed = nowMs - firstSeenMs;
|
||||
if (elapsed <= 0) return 0;
|
||||
if (elapsed >= fadeMs) return 1;
|
||||
return elapsed / fadeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one bracket-style bounding box (four L-shaped corner segments only)
|
||||
* to a path-like sink exposing moveTo(x, y) / lineTo(x, y). Designed so many
|
||||
* boxes accumulate into a single Path2D for one batched stroke() — replacing
|
||||
* the old per-box beginPath/stroke. Geometry matches the prior reticle exactly.
|
||||
* @param {{moveTo: Function, lineTo: Function}} sink - Path2D or compatible recorder.
|
||||
* @param {number} sx - Screen X centre of the box.
|
||||
* @param {number} sy - Screen Y centre of the box.
|
||||
* @param {number} halfW - Half-width of the box.
|
||||
* @param {number} halfH - Half-height of the box.
|
||||
*/
|
||||
export function appendCornerBracket(sink, sx, sy, halfW, halfH) {
|
||||
const x0 = sx - halfW;
|
||||
const y0 = sy - halfH;
|
||||
const x1 = sx + halfW;
|
||||
const y1 = sy + halfH;
|
||||
const seg = Math.max(4, Math.floor(Math.min(halfW, halfH) * 0.55));
|
||||
|
||||
// top-left
|
||||
sink.moveTo(x0, y0 + seg);
|
||||
sink.lineTo(x0, y0);
|
||||
sink.lineTo(x0 + seg, y0);
|
||||
// top-right
|
||||
sink.moveTo(x1 - seg, y0);
|
||||
sink.lineTo(x1, y0);
|
||||
sink.lineTo(x1, y0 + seg);
|
||||
// bottom-right
|
||||
sink.moveTo(x1, y1 - seg);
|
||||
sink.lineTo(x1, y1);
|
||||
sink.lineTo(x1 - seg, y1);
|
||||
// bottom-left
|
||||
sink.moveTo(x0 + seg, y1);
|
||||
sink.lineTo(x0, y1);
|
||||
sink.lineTo(x0, y1 - seg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a detectable object's threat tier — the key into a theme's color
|
||||
* ramp (civil/military/sea/space/vehicle). A layer-supplied `tier` wins;
|
||||
* otherwise it derives from the coarse `type`. Drives per-tier box/card color.
|
||||
* @param {{type?: string, tier?: string}} obj - Detectable object.
|
||||
* @returns {string} Tier key.
|
||||
*/
|
||||
export function resolveTier(obj) {
|
||||
if (obj && obj.tier) return obj.tier;
|
||||
const t = obj && obj.type;
|
||||
if (t === 'SEA') return 'sea';
|
||||
if (t === 'SAT') return 'space';
|
||||
if (t === 'VEH') return 'vehicle';
|
||||
return 'civil';
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the geometry of a two-tier label "card": overall width/height plus
|
||||
* the text baselines, sized so the second line and its descenders always fit
|
||||
* (the clipping bug in the throwaway spike). Pure layout — the renderer draws
|
||||
* the rounded rect, accent bar, and text from these numbers.
|
||||
* @param {string} primary - Bright first line (id/callsign).
|
||||
* @param {string} secondary - Dim second line (class · metric); '' collapses to one line.
|
||||
* @param {number} charWidth - Monospace glyph advance, px.
|
||||
* @returns {{w: number, h: number, idBase: number, subBase: number, textX: number, hasSec: boolean}}
|
||||
*/
|
||||
export function measureLabelCard(primary, secondary, charWidth) {
|
||||
const padX = 6;
|
||||
const padY = 4;
|
||||
const lineH = 11;
|
||||
const ASC = 8; // cap height above the first baseline
|
||||
const DESC = 3; // descender depth below the last baseline
|
||||
const accentW = 3; // left accent bar width
|
||||
const accentGap = 4; // gap between accent bar and text (doubles as left pad)
|
||||
const hasSec = !!(secondary && secondary.length);
|
||||
const wId = monoTextWidth(primary, charWidth);
|
||||
const wSub = hasSec ? monoTextWidth(secondary, charWidth) : 0;
|
||||
const textW = Math.max(wId, wSub);
|
||||
const w = Math.ceil(accentW + accentGap + textW + padX);
|
||||
const idBase = padY + ASC;
|
||||
const subBase = hasSec ? idBase + lineH : 0;
|
||||
const lastBase = hasSec ? subBase : idBase;
|
||||
const h = lastBase + DESC + padY;
|
||||
const textX = accentW + accentGap;
|
||||
return { w, h, idBase, subBase, textX, hasSec };
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the geometry of a SINGLE-LINE track label: callsign (bright) followed
|
||||
* on the same baseline by a dim altitude/metric micro-field. This is the
|
||||
* persistent label — type is carried by the platform icon, not text — replacing
|
||||
* the two-line card for the ambient majority of tracks.
|
||||
* @param {string} primary - Callsign / id (bright).
|
||||
* @param {string} micro - Short metric, e.g. flight level digits or '14kn' ('' = none).
|
||||
* @param {number} charWidth - Monospace glyph advance for the primary text, px.
|
||||
* @returns {{w: number, h: number, baseline: number, primaryX: number, microX: number, hasMicro: boolean}}
|
||||
*/
|
||||
export function measureTrackLabel(primary, micro, charWidth) {
|
||||
const padX = 6;
|
||||
const padY = 4;
|
||||
const ASC = 8;
|
||||
const DESC = 3;
|
||||
const tickW = 3; // affiliation/tier tick at the left
|
||||
const tickGap = 4;
|
||||
const microGap = 6; // space between callsign and the micro-field
|
||||
const MICRO_SCALE = 0.9; // micro renders slightly smaller than the callsign
|
||||
const hasMicro = !!(micro && micro.length);
|
||||
const wPrimary = monoTextWidth(primary, charWidth);
|
||||
const wMicro = hasMicro ? monoTextWidth(micro, charWidth * MICRO_SCALE) : 0;
|
||||
const textW = wPrimary + (hasMicro ? microGap + wMicro : 0);
|
||||
const w = Math.ceil(tickW + tickGap + textW + padX);
|
||||
const baseline = padY + ASC;
|
||||
const h = baseline + DESC + padY;
|
||||
const primaryX = tickW + tickGap;
|
||||
const microX = primaryX + wPrimary + microGap;
|
||||
return { w, h, baseline, primaryX, microX, hasMicro };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a screen-space card intersects any reserved UI rectangle.
|
||||
* Touching edges are allowed; positive padding expands the exclusion gap.
|
||||
* @param {{x:number,y:number,w:number,h:number}} rect - Candidate card rectangle.
|
||||
* @param {Array<{x:number,y:number,w:number,h:number}>} obstacles - HUD rectangles.
|
||||
* @param {number} [padding=0] - Additional separation in CSS pixels.
|
||||
* @returns {boolean} True when the card would be covered by a HUD rectangle.
|
||||
*/
|
||||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||||
if (!rect || !Array.isArray(obstacles)) return false;
|
||||
return obstacles.some((obstacle) => obstacle
|
||||
&& rect.x < obstacle.x + obstacle.w + padding
|
||||
&& rect.x + rect.w + padding > obstacle.x
|
||||
&& rect.y < obstacle.y + obstacle.h + padding
|
||||
&& rect.y + rect.h + padding > obstacle.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear near/far interpolation matching Cesium's NearFarScalar — maps a camera
|
||||
* distance to a scale, clamped to the near value when closer than `near` and the
|
||||
* far value when beyond `far`. Lets the detection reticle track a billboard's
|
||||
* on-screen size (which uses the same scaleByDistance curve).
|
||||
* @param {number} distance - Camera-to-object distance, metres.
|
||||
* @param {number} near - Near distance, metres.
|
||||
* @param {number} nearValue - Scale at/under the near distance.
|
||||
* @param {number} far - Far distance, metres.
|
||||
* @param {number} farValue - Scale at/over the far distance.
|
||||
* @returns {number} Interpolated scale.
|
||||
*/
|
||||
export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
if (!(far > near)) return nearValue;
|
||||
if (distance <= near) return nearValue;
|
||||
if (distance >= far) return farValue;
|
||||
const t = (distance - near) / (far - near);
|
||||
return nearValue + t * (farValue - nearValue);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Unit tests for the pure drawing/format helpers behind the detection overlay.
|
||||
// These are renderer-agnostic (no Cesium, no DOM) so they pin the Phase-1 label
|
||||
// + batching behavior and carry straight into the Phase-2 GPU renderer.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatFlightLevel,
|
||||
formatKnots,
|
||||
monoTextWidth,
|
||||
composeLabel,
|
||||
acquireAlpha,
|
||||
appendCornerBracket,
|
||||
resolveTier,
|
||||
measureLabelCard,
|
||||
measureTrackLabel,
|
||||
nearFarScale,
|
||||
rectIntersectsAny,
|
||||
} from './detectionDraw.js';
|
||||
|
||||
test('formatFlightLevel converts metres to a 3-digit flight level', () => {
|
||||
assert.equal(formatFlightLevel(10363), 'FL340'); // 34,000 ft
|
||||
assert.equal(formatFlightLevel(1524), 'FL050'); // 5,000 ft, zero-padded
|
||||
});
|
||||
|
||||
test('formatFlightLevel returns empty string for missing/zero/negative altitude', () => {
|
||||
assert.equal(formatFlightLevel(0), '');
|
||||
assert.equal(formatFlightLevel(-50), '');
|
||||
assert.equal(formatFlightLevel(null), '');
|
||||
assert.equal(formatFlightLevel(undefined), '');
|
||||
assert.equal(formatFlightLevel(NaN), '');
|
||||
});
|
||||
|
||||
test('formatKnots rounds and suffixes; omits non-positive/non-finite', () => {
|
||||
assert.equal(formatKnots(14), '14 kn');
|
||||
assert.equal(formatKnots(14.6), '15 kn');
|
||||
assert.equal(formatKnots(0), '');
|
||||
assert.equal(formatKnots(null), '');
|
||||
assert.equal(formatKnots(NaN), '');
|
||||
});
|
||||
|
||||
test('monoTextWidth multiplies length by advance, 0 for empty', () => {
|
||||
assert.equal(monoTextWidth('ABCD', 6), 24);
|
||||
assert.equal(monoTextWidth('', 6), 0);
|
||||
assert.equal(monoTextWidth(null, 6), 0);
|
||||
});
|
||||
|
||||
test('composeLabel: id only -> empty secondary (degrades to today)', () => {
|
||||
assert.deepEqual(composeLabel({ id: 'VEH-0001' }), { primary: 'VEH-0001', secondary: '' });
|
||||
});
|
||||
|
||||
test('composeLabel: id + metric -> secondary is the metric', () => {
|
||||
assert.deepEqual(composeLabel({ id: 'UAL2476', metric: 'FL340' }), {
|
||||
primary: 'UAL2476',
|
||||
secondary: 'FL340',
|
||||
});
|
||||
});
|
||||
|
||||
test('composeLabel: id + class + metric -> class · metric secondary', () => {
|
||||
assert.deepEqual(composeLabel({ id: 'VIPER11', klass: 'MIL', metric: 'FL280' }), {
|
||||
primary: 'VIPER11',
|
||||
secondary: 'MIL · FL280',
|
||||
});
|
||||
});
|
||||
|
||||
test('composeLabel truncates an over-long primary and is defensive about empties', () => {
|
||||
assert.equal(composeLabel({ id: 'SUPERLONGVESSELNAME12345' }).primary, 'SUPERLONGVESSELNAM'); // 18
|
||||
assert.deepEqual(composeLabel({}), { primary: '', secondary: '' });
|
||||
});
|
||||
|
||||
test('acquireAlpha ramps 0->1 across the fade window', () => {
|
||||
assert.equal(acquireAlpha(1000, 1000, 200), 0);
|
||||
assert.equal(acquireAlpha(1000, 1100, 200), 0.5);
|
||||
assert.equal(acquireAlpha(1000, 1300, 200), 1);
|
||||
});
|
||||
|
||||
test('acquireAlpha clamps and defaults safely', () => {
|
||||
assert.equal(acquireAlpha(1000, 900, 200), 0); // before first-seen
|
||||
assert.equal(acquireAlpha(1000, 5000, 200), 1); // long after
|
||||
assert.equal(acquireAlpha(NaN, 5000, 200), 1); // no timestamp -> visible
|
||||
assert.equal(acquireAlpha(1000, 1100, 0), 1); // no fade -> visible
|
||||
});
|
||||
|
||||
test('appendCornerBracket emits 4 L-shaped corners (4 moveTo + 8 lineTo)', () => {
|
||||
const calls = [];
|
||||
const sink = {
|
||||
moveTo: (x, y) => calls.push(['m', x, y]),
|
||||
lineTo: (x, y) => calls.push(['l', x, y]),
|
||||
};
|
||||
appendCornerBracket(sink, 100, 100, 20, 10);
|
||||
|
||||
assert.equal(calls.length, 12);
|
||||
assert.equal(calls.filter((c) => c[0] === 'm').length, 4);
|
||||
assert.equal(calls.filter((c) => c[0] === 'l').length, 8);
|
||||
// top-left corner of box (x0=80, y0=90), seg = max(4, floor(10*0.55)) = 5
|
||||
assert.deepEqual(calls.slice(0, 3), [
|
||||
['m', 80, 95],
|
||||
['l', 80, 90],
|
||||
['l', 85, 90],
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveTier maps type to a threat tier, with explicit override winning', () => {
|
||||
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
|
||||
assert.equal(resolveTier({ type: 'AIR', tier: 'military' }), 'military'); // layer-supplied override
|
||||
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
|
||||
assert.equal(resolveTier({ type: 'SAT' }), 'space');
|
||||
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
|
||||
assert.equal(resolveTier({}), 'civil');
|
||||
assert.equal(resolveTier(null), 'civil');
|
||||
// Live-traffic congestion tiers ride the same override: keyless VEH
|
||||
// contacts carry no tier and keep the stock 'vehicle' color.
|
||||
assert.equal(resolveTier({ type: 'VEH', tier: 'veh_jam' }), 'veh_jam');
|
||||
assert.equal(resolveTier({ type: 'VEH', tier: 'veh_nodata' }), 'veh_nodata');
|
||||
});
|
||||
|
||||
test('measureLabelCard sizes a two-line card so the second line never clips', () => {
|
||||
const card = measureLabelCard('UAL2476', 'B738 · FL340', 6);
|
||||
// bottom of the last baseline + descender must fit inside the card height
|
||||
assert.ok(card.subBase + 3 <= card.h, `subBase+desc ${card.subBase + 3} must fit in h ${card.h}`);
|
||||
assert.ok(card.idBase < card.subBase, 'id line sits above sub line');
|
||||
assert.ok(card.w >= 12 * 6, 'width covers the wider (sub) text');
|
||||
assert.equal(card.hasSec, true);
|
||||
});
|
||||
|
||||
test('measureLabelCard collapses to a single line when there is no secondary', () => {
|
||||
const card = measureLabelCard('VEH-0001', '', 6);
|
||||
assert.equal(card.hasSec, false);
|
||||
assert.equal(card.subBase, 0);
|
||||
assert.ok(card.idBase + 3 <= card.h, 'single line + descender fits');
|
||||
const two = measureLabelCard('VEH-0001', 'VEH · 38mph', 6);
|
||||
assert.ok(two.h > card.h, 'two-line card is taller than one-line');
|
||||
});
|
||||
|
||||
test('measureTrackLabel lays out callsign + altitude on one line', () => {
|
||||
const c = measureTrackLabel('WOLF21', '270', 6);
|
||||
assert.equal(c.hasMicro, true);
|
||||
assert.ok(c.microX > c.primaryX, 'altitude sits to the right of the callsign');
|
||||
assert.ok(c.baseline + 3 <= c.h, 'single line + descender fits inside height');
|
||||
assert.ok(c.w >= 6 * 6, 'width covers the callsign');
|
||||
});
|
||||
|
||||
test('measureTrackLabel handles a missing altitude (callsign only)', () => {
|
||||
const c = measureTrackLabel('SAT-12345', '', 6);
|
||||
assert.equal(c.hasMicro, false);
|
||||
assert.ok(c.w >= 9 * 6, 'width covers the longer callsign');
|
||||
});
|
||||
|
||||
test('callout cards avoid live HUD rectangles without rejecting edge-adjacent space', () => {
|
||||
const hud = [{ x: 100, y: 100, w: 80, h: 60 }];
|
||||
assert.equal(rectIntersectsAny({ x: 120, y: 80, w: 40, h: 40 }, hud), true);
|
||||
assert.equal(rectIntersectsAny({ x: 60, y: 100, w: 40, h: 20 }, hud), false);
|
||||
assert.equal(rectIntersectsAny({ x: 60, y: 100, w: 40, h: 20 }, hud, 1), true);
|
||||
});
|
||||
|
||||
test('nearFarScale interpolates by distance and clamps to the near/far values', () => {
|
||||
// mirrors Cesium NearFarScalar(1000, 3.0, 8000000, 0.5) used by the flight billboards
|
||||
assert.equal(nearFarScale(1000, 1000, 3.0, 8000000, 0.5), 3.0);
|
||||
assert.equal(nearFarScale(8000000, 1000, 3.0, 8000000, 0.5), 0.5);
|
||||
assert.equal(nearFarScale(500, 1000, 3.0, 8000000, 0.5), 3.0); // below near -> clamp
|
||||
assert.equal(nearFarScale(9e6, 1000, 3.0, 8000000, 0.5), 0.5); // beyond far -> clamp
|
||||
const mid = nearFarScale((1000 + 8000000) / 2, 1000, 3.0, 8000000, 0.5);
|
||||
assert.ok(Math.abs(mid - 1.75) < 1e-6, `midpoint ~1.75, got ${mid}`);
|
||||
});
|
||||
@@ -0,0 +1,874 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
countFadingRenderEntries,
|
||||
destroyDetection,
|
||||
detectionDebugRequested,
|
||||
getDetectionDiagnostics,
|
||||
getDetectionTheme,
|
||||
getMode,
|
||||
initDetection,
|
||||
isDetectionSuspended,
|
||||
resumeDetection,
|
||||
setDetectionStyle,
|
||||
setDetectionTuning,
|
||||
setMode,
|
||||
suspendDetection,
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
|
||||
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
|
||||
assert.equal(countFadingRenderEntries([
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
{ selected: true },
|
||||
{ selected: false },
|
||||
]), 2);
|
||||
assert.equal(countFadingRenderEntries([{ selected: true }, { selected: true }]), 0);
|
||||
});
|
||||
|
||||
class MockEvent {
|
||||
constructor() { this.listeners = new Set(); }
|
||||
|
||||
addEventListener(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
raise() {
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
}
|
||||
|
||||
class MockPath2D {
|
||||
moveTo() {}
|
||||
lineTo() {}
|
||||
roundRect() {}
|
||||
arcTo() {}
|
||||
closePath() {}
|
||||
}
|
||||
|
||||
function mockContext(target, trace) {
|
||||
const calls = [];
|
||||
const record = (...call) => {
|
||||
calls.push(call);
|
||||
trace.push([target, ...call]);
|
||||
};
|
||||
let filter = 'none';
|
||||
let strokeStyle = '';
|
||||
let globalCompositeOperation = 'source-over';
|
||||
return {
|
||||
calls,
|
||||
font: '',
|
||||
get filter() { return filter; },
|
||||
set filter(value) { filter = value; record('filter', value); },
|
||||
fillStyle: '',
|
||||
get strokeStyle() { return strokeStyle; },
|
||||
set strokeStyle(value) { strokeStyle = value; record('strokeStyle', value); },
|
||||
globalAlpha: 1,
|
||||
get globalCompositeOperation() { return globalCompositeOperation; },
|
||||
set globalCompositeOperation(value) { globalCompositeOperation = value; record('globalCompositeOperation', value); },
|
||||
lineWidth: 1,
|
||||
measureText(text) { return { width: String(text).length * 6 }; },
|
||||
setTransform(...args) { record('setTransform', ...args); },
|
||||
clearRect(...args) { record('clearRect', ...args); },
|
||||
save() { record('save'); },
|
||||
restore() { record('restore'); },
|
||||
translate(...args) { record('translate', ...args); },
|
||||
scale(...args) { record('scale', ...args); },
|
||||
beginPath() { record('beginPath'); },
|
||||
rect(...args) { record('rect', ...args); },
|
||||
clip(...args) { record('clip', ...args); },
|
||||
moveTo(...args) { record('moveTo', ...args); },
|
||||
lineTo(...args) { record('lineTo', ...args); },
|
||||
arc(...args) { record('arc', ...args); },
|
||||
arcTo(...args) { record('arcTo', ...args); },
|
||||
closePath() { record('closePath'); },
|
||||
// The style is recorded AT fill time, not on assignment, so a pin can prove
|
||||
// which plate token actually reached the canvas rather than which one was
|
||||
// set at some point during the frame. globalAlpha rides along for the same
|
||||
// reason: the backdrop feather is an alpha, not a colour.
|
||||
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
|
||||
stroke(path) { record('stroke', path); },
|
||||
fillRect(...args) { record('fillRect', ...args); },
|
||||
fillText(...args) { record('fillText', ...args); },
|
||||
drawImage(...args) { record('drawImage', ...args); },
|
||||
};
|
||||
}
|
||||
|
||||
// `search` seeds window.location.search. The detection mode banner is developer
|
||||
// telemetry gated behind ?detectDebug=1, so any test that uses the banner as its
|
||||
// "did this frame repaint" probe must opt in explicitly.
|
||||
function installEnvironment({ width = 800, height = 600, dpr = 2, search = '' } = {}) {
|
||||
const byId = new Map();
|
||||
const paintTrace = [];
|
||||
const ctx = mockContext('shared', paintTrace);
|
||||
const detectionCtx = mockContext('detection', paintTrace);
|
||||
const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
||||
const originalDateNow = Date.now;
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
let currentTime = 1_000;
|
||||
let performanceStep = 0;
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
configurable: true,
|
||||
value: { now: () => {
|
||||
const value = currentTime;
|
||||
currentTime += performanceStep;
|
||||
return value;
|
||||
} },
|
||||
});
|
||||
Date.now = () => currentTime;
|
||||
globalThis.Path2D = MockPath2D;
|
||||
|
||||
class MockElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.id = '';
|
||||
this.children = [];
|
||||
this.parentElement = null;
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
this.hidden = false;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.clientWidth = width;
|
||||
this.clientHeight = height;
|
||||
this._rect = { left: 0, top: 0, width, height };
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentElement = this;
|
||||
this.children.push(child);
|
||||
register(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child, before) {
|
||||
child.parentElement = this;
|
||||
const index = this.children.indexOf(before);
|
||||
if (index < 0) this.children.push(child);
|
||||
else this.children.splice(index, 0, child);
|
||||
register(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
if (name === 'id') this.id = String(value);
|
||||
else this[name] = String(value);
|
||||
register(this);
|
||||
}
|
||||
|
||||
getBoundingClientRect() { return this._rect; }
|
||||
getContext() {
|
||||
if (this.tagName !== 'CANVAS') return null;
|
||||
return this.id === 'world-overlay-detection-surface' ? detectionCtx : ctx;
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
const wanted = selector.slice(1);
|
||||
return this.children.find((child) => child.id === wanted) || null;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.parentElement) {
|
||||
this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
|
||||
}
|
||||
unregister(this);
|
||||
this.parentElement = null;
|
||||
}
|
||||
|
||||
get nextSibling() { return null; }
|
||||
}
|
||||
|
||||
function register(element) {
|
||||
if (element.id) byId.set(element.id, element);
|
||||
for (const child of element.children) register(child);
|
||||
}
|
||||
|
||||
function unregister(element) {
|
||||
if (element.id) byId.delete(element.id);
|
||||
for (const child of element.children) unregister(child);
|
||||
}
|
||||
|
||||
const body = new MockElement('body');
|
||||
body.classList = { contains() { return false; } };
|
||||
const document = {
|
||||
body,
|
||||
createElement(tagName) { return new MockElement(tagName); },
|
||||
getElementById(id) { return byId.get(id) || null; },
|
||||
querySelector(selector) { return byId.get(selector.slice(1)) || null; },
|
||||
querySelectorAll(selector) {
|
||||
const element = selector.startsWith('#') ? byId.get(selector.slice(1)) : null;
|
||||
return element ? [element] : [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
devicePixelRatio: dpr,
|
||||
location: { search },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
|
||||
};
|
||||
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.MutationObserver = class { observe() {} disconnect() {} };
|
||||
globalThis.document = document;
|
||||
globalThis.window = window;
|
||||
|
||||
const container = new MockElement('div');
|
||||
container.id = 'cesiumContainer';
|
||||
body.appendChild(container);
|
||||
const root = new MockElement('div');
|
||||
root.id = 'world-overlay-root';
|
||||
body.appendChild(root);
|
||||
const canvas = new MockElement('canvas');
|
||||
canvas.id = 'world-overlay-canvas';
|
||||
root.appendChild(canvas);
|
||||
|
||||
const postRender = new MockEvent();
|
||||
const viewer = {
|
||||
container,
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
camera: {
|
||||
positionWC: new Cesium.Cartesian3(0, 0, 10_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
viewMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY) },
|
||||
moveEnd: new MockEvent(),
|
||||
},
|
||||
scene: { postRender, requestRender() {} },
|
||||
};
|
||||
|
||||
return {
|
||||
viewer,
|
||||
postRender,
|
||||
document,
|
||||
ctx,
|
||||
detectionCtx,
|
||||
paintTrace,
|
||||
advance(ms) { currentTime += ms; },
|
||||
setPerformanceStep(ms) { performanceStep = ms; },
|
||||
cleanup() {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
Date.now = originalDateNow;
|
||||
if (originalPath2D === undefined) delete globalThis.Path2D;
|
||||
else globalThis.Path2D = originalPath2D;
|
||||
delete globalThis.document;
|
||||
delete globalThis.window;
|
||||
delete globalThis.ResizeObserver;
|
||||
delete globalThis.MutationObserver;
|
||||
if (originalPerformance) Object.defineProperty(globalThis, 'performance', originalPerformance);
|
||||
else delete globalThis.performance;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function detectableLayer() {
|
||||
const positions = [
|
||||
new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
new Cesium.Cartesian3(10_000, 0, 6_356_740),
|
||||
];
|
||||
return {
|
||||
id: 'flights',
|
||||
getDetectableObjects() {
|
||||
return positions.map((position, index) => ({
|
||||
position,
|
||||
sourceId: `flight-${index}`,
|
||||
id: `TEST${index}`,
|
||||
metric: `FL${300 + index}`,
|
||||
type: 'AIR',
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function militaryLayer() {
|
||||
return {
|
||||
id: 'military',
|
||||
getDetectableObjects() {
|
||||
return [{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'military-pin',
|
||||
id: 'MIL-PIN',
|
||||
metric: 'FL450',
|
||||
type: 'AIR',
|
||||
tier: 'military',
|
||||
}];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// One air contact and one space contact, both on screen. The mock camera
|
||||
// projects through an identity view-projection, so a position's x/y ARE its
|
||||
// normalized device coordinates; these two land ~320px apart and both survive
|
||||
// the declutter.
|
||||
function mixedTierLayer() {
|
||||
return {
|
||||
id: 'mixed',
|
||||
getDetectableObjects() {
|
||||
return [
|
||||
{
|
||||
position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752),
|
||||
sourceId: 'air-1',
|
||||
id: 'AIRONE',
|
||||
metric: 'FL350',
|
||||
type: 'AIR',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0.4, -0.2, 6_356_752),
|
||||
sourceId: 'sat-1',
|
||||
id: 'SATONE',
|
||||
metric: '412KM',
|
||||
type: 'SAT',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Fill styles that actually reached the shared normal-blend canvas. */
|
||||
function sharedFillStyles(env) {
|
||||
return env.ctx.calls
|
||||
.filter(([name]) => name === 'fill')
|
||||
.map(([, , fillStyle]) => fillStyle);
|
||||
}
|
||||
|
||||
/** Settle a frame so the arbiter has promoted its identities to render entries. */
|
||||
function settleFrame(env) {
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
}
|
||||
|
||||
test('detection lifecycle re-hosts unchanged painters behind the sole host listener', () => {
|
||||
// Opts into the telemetry banner: this test reads it as the repaint probe.
|
||||
const env = installEnvironment({ search: '?detectDebug=1' });
|
||||
const modes = [];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [detectableLayer()], (mode) => modes.push(mode));
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
assert.equal(env.document.getElementById('detection-overlay'), null);
|
||||
assert.ok(env.document.getElementById('world-overlay-canvas'));
|
||||
const surface = env.document.getElementById('world-overlay-detection-surface');
|
||||
const root = env.document.getElementById('world-overlay-root');
|
||||
const mainCanvas = env.document.getElementById('world-overlay-canvas');
|
||||
assert.ok(surface);
|
||||
// The surface must sit in the Cesium container (no stacking context
|
||||
// between it and the WebGL canvas) or its `screen` blend is discarded;
|
||||
// ordering under the card canvas is carried by z-index, not siblinghood.
|
||||
assert.equal(surface.parentElement, env.viewer.container);
|
||||
assert.deepEqual(root.children, [mainCanvas]);
|
||||
assert.equal(surface.style.mixBlendMode, 'screen');
|
||||
assert.equal(
|
||||
surface.style.filter,
|
||||
'contrast(1.05) saturate(1.05) drop-shadow(0 0 3px rgba(0, 244, 255, 0.4))',
|
||||
);
|
||||
assert.equal(surface.style.display, 'none');
|
||||
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const first = getDetectionDiagnostics();
|
||||
assert.equal(first.profile, 'DENSE');
|
||||
assert.equal(first.observationCount, 2);
|
||||
assert.equal(first.didSolve, true);
|
||||
assert.ok(first.solveRevision > 0);
|
||||
assert.ok(first.collectiveLabelBudget > 0);
|
||||
assert.ok(first.bracketOpacityCounts.full + first.bracketOpacityCounts.partial > 0);
|
||||
assert.ok(env.detectionCtx.calls.some(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')));
|
||||
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D));
|
||||
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'fillRect'), 'scanlines and banner still paint');
|
||||
assert.equal(env.detectionCtx.globalCompositeOperation, 'source-over');
|
||||
assert.equal(env.detectionCtx.filter, 'none');
|
||||
assert.equal(surface.style.display, 'block');
|
||||
const canvas = env.document.getElementById('world-overlay-canvas');
|
||||
assert.equal(Number(canvas.dataset.solveRevision), first.solveRevision);
|
||||
assert.equal(canvas.dataset.profile, 'DENSE');
|
||||
|
||||
// The existing 125 ms arbiter cadence remains authoritative.
|
||||
env.advance(124);
|
||||
env.postRender.raise();
|
||||
assert.equal(getDetectionDiagnostics().solveRevision, first.solveRevision);
|
||||
// Callsigns paint on the SHARED normal-blend canvas, never on the
|
||||
// screen-blended sensor surface: `screen` can only lighten, so a callout's
|
||||
// dark backing plate is a no-op over sunlit ground and the text dissolves
|
||||
// into it. Brackets, banner and scanlines stay on the sensor surface.
|
||||
assert.ok(env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
|
||||
assert.ok(!env.detectionCtx.calls.some(([name, text]) => name === 'fillText' && text === 'TEST0'));
|
||||
env.advance(1);
|
||||
env.postRender.raise();
|
||||
assert.ok(getDetectionDiagnostics().solveRevision > first.solveRevision);
|
||||
|
||||
setDetectionTuning({ densityPct: 25, allocationStrategy: 'WEIGHTED' });
|
||||
env.postRender.raise();
|
||||
assert.equal(getMode(), 'SPARSE');
|
||||
assert.equal(getDetectionDiagnostics().profile, 'SPARSE');
|
||||
assert.equal(getDetectionDiagnostics().allocationStrategy, 'WEIGHTED');
|
||||
assert.ok(env.detectionCtx.calls.some(([name]) => name === 'arc'), 'sparse focus ring remains available');
|
||||
|
||||
setMode('BALANCED');
|
||||
env.postRender.raise();
|
||||
assert.equal(getDetectionDiagnostics().profile, 'BALANCED');
|
||||
setMode('DENSE');
|
||||
env.postRender.raise();
|
||||
assert.equal(getDetectionDiagnostics().profile, 'DENSE');
|
||||
|
||||
const bannerCount = () => env.detectionCtx.calls.filter(([name, text]) => (
|
||||
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
|
||||
)).length;
|
||||
const beforeSuspend = bannerCount();
|
||||
suspendDetection('intercity');
|
||||
assert.equal(isDetectionSuspended(), true);
|
||||
assert.equal(surface.style.opacity, '0');
|
||||
env.postRender.raise();
|
||||
assert.equal(bannerCount(), beforeSuspend);
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
resumeDetection();
|
||||
env.postRender.raise();
|
||||
assert.equal(isDetectionSuspended(), false);
|
||||
assert.equal(surface.style.opacity, '1');
|
||||
assert.ok(bannerCount() > beforeSuspend);
|
||||
|
||||
// These are the exact engine calls made by military style presets after
|
||||
// their production UI gate chooses CRT/NVG/FLIR defaults.
|
||||
const expectedThemes = {
|
||||
retro: 'contrast(1.08) saturate(1.04) drop-shadow(0 0 3px rgba(255, 176, 56, 0.45))',
|
||||
surveillance: 'contrast(1.12) saturate(1.12) drop-shadow(0 0 3px rgba(120, 255, 120, 0.42))',
|
||||
thermal: 'contrast(1.1) saturate(1.08) drop-shadow(0 0 3px rgba(255, 224, 170, 0.42))',
|
||||
};
|
||||
for (const style of ['retro', 'surveillance', 'thermal']) {
|
||||
setMode('OFF');
|
||||
setDetectionStyle(style);
|
||||
assert.equal(surface.style.mixBlendMode, 'screen');
|
||||
assert.equal(surface.style.filter, expectedThemes[style]);
|
||||
setDetectionTuning({ densityPct: 75 });
|
||||
setMode('DENSE');
|
||||
env.postRender.raise();
|
||||
assert.equal(getDetectionDiagnostics().profile, 'DENSE');
|
||||
assert.ok(getDetectionTheme().line);
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
}
|
||||
|
||||
setMode('OFF');
|
||||
setDetectionStyle('normal');
|
||||
assert.equal(surface.style.mixBlendMode, 'screen');
|
||||
assert.equal(
|
||||
surface.style.filter,
|
||||
'contrast(1.05) saturate(1.05) drop-shadow(0 0 3px rgba(0, 244, 255, 0.4))',
|
||||
);
|
||||
env.postRender.raise();
|
||||
assert.equal(getMode(), 'OFF');
|
||||
assert.equal(surface.style.display, 'none');
|
||||
assert.equal(env.postRender.listeners.size, 1);
|
||||
assert.ok(modes.includes('SPARSE') && modes.includes('BALANCED') && modes.includes('DENSE'));
|
||||
|
||||
destroyDetection();
|
||||
assert.equal(env.postRender.listeners.size, 1, 'detection never owns the listener');
|
||||
setMode('DENSE');
|
||||
env.postRender.raise();
|
||||
assert.equal(env.document.getElementById('detection-overlay'), null);
|
||||
destroyWorldOverlay();
|
||||
assert.equal(env.postRender.listeners.size, 0);
|
||||
assert.equal(env.document.getElementById('world-overlay-canvas'), null);
|
||||
assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('callouts stop painting the moment the last detectable object goes away', () => {
|
||||
// The callout lane replays the previous solve on frames the sensor lane
|
||||
// skips under load, which is what keeps plates from strobing against
|
||||
// persistent brackets. The cost of that design is that an empty field MUST
|
||||
// clear the replay buffer, or the final callsigns stay stranded on the
|
||||
// shared canvas after the last data layer is switched off.
|
||||
const env = installEnvironment();
|
||||
let objects = [{
|
||||
position: new Cesium.Cartesian3(0, 0, 6_356_752),
|
||||
sourceId: 'lonely-1',
|
||||
id: 'LASTONE',
|
||||
metric: 'FL120',
|
||||
type: 'AIR',
|
||||
}];
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
// The first frame solves; the arbiter hands the identity back as a render
|
||||
// entry on the frame after that.
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
assert.ok(
|
||||
env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
|
||||
'the callout paints on the shared canvas while the contact exists',
|
||||
);
|
||||
|
||||
objects = [];
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
assert.ok(
|
||||
!env.ctx.calls.some(([name, text]) => name === 'fillText' && text === 'LASTONE'),
|
||||
'an empty field must drop the replay buffer, not strand the last callsign',
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('every style paints its OWN plate token through the production callout path', () => {
|
||||
// The token table and the direct painter are both pinned elsewhere, but a
|
||||
// pin on either one still passes if the runtime stops consulting the active
|
||||
// theme and freezes on a single plate. This drives the real stash → replay
|
||||
// path once per style and reads the fill style the shared canvas received.
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [mixedTierLayer()], () => {});
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
settleFrame(env);
|
||||
|
||||
for (const style of ['surveillance', 'retro', 'thermal', 'normal']) {
|
||||
setDetectionStyle(style);
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const theme = DETECTION_THEME_MAP[style] || DETECTION_THEME_MAP._default;
|
||||
const fills = sharedFillStyles(env);
|
||||
assert.ok(
|
||||
fills.includes(theme.calloutPlate),
|
||||
`${style} must paint its own callout plate (${theme.calloutPlate})`,
|
||||
);
|
||||
for (const [otherName, other] of Object.entries(DETECTION_THEME_MAP)) {
|
||||
if (other.calloutPlate === theme.calloutPlate) continue;
|
||||
assert.ok(
|
||||
!fills.includes(other.calloutPlate),
|
||||
`${style} must not paint the ${otherName} plate`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
// tier split is invisible to the token table — only a frame carrying BOTH
|
||||
// tiers at once can show it.
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [mixedTierLayer()], () => {});
|
||||
setDetectionStyle('surveillance');
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const theme = DETECTION_THEME_MAP.surveillance;
|
||||
assert.notEqual(
|
||||
theme.calloutPlate,
|
||||
theme.calloutPlateSpace,
|
||||
'the fixture is only meaningful while the two tiers differ',
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
assert.ok(painted('AIRONE') && painted('SATONE'), 'both tiers reached the frame under test');
|
||||
|
||||
const fills = sharedFillStyles(env);
|
||||
assert.ok(fills.includes(theme.calloutPlate), 'the air contact keeps the light plate');
|
||||
assert.ok(fills.includes(theme.calloutPlateSpace), 'the space contact takes the heavier plate');
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('surveillance military tier reaches the dedicated render target as shipped red', () => {
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [militaryLayer()], () => {});
|
||||
setDetectionStyle('surveillance');
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(([name, value]) => name === 'strokeStyle' && value === '#ff5a47'),
|
||||
'the production projection/tier/batched-bracket paint path emits surveillance military red',
|
||||
);
|
||||
assert.ok(env.detectionCtx.calls.some(([name, path]) => name === 'stroke'
|
||||
&& path instanceof MockPath2D));
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
|
||||
// Opts into the telemetry banner: the held-frame assertions count banner paints.
|
||||
const env = installEnvironment({ search: '?detectDebug=1' });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [detectableLayer()], () => {});
|
||||
setOverlayEntries('valve-card', [{
|
||||
id: 'ambient-card',
|
||||
position: new Cesium.Cartesian3(0, 0, 0),
|
||||
variant: 'label',
|
||||
title: 'AMBIENT-CONTINUES',
|
||||
selected: true,
|
||||
protected: true,
|
||||
horizonCull: false,
|
||||
edgeFade: 'none',
|
||||
}]);
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.setPerformanceStep(30);
|
||||
|
||||
env.postRender.raise();
|
||||
env.postRender.raise();
|
||||
const detectionClears = env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length;
|
||||
const detectionBanners = env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')).length;
|
||||
const sharedClears = env.ctx.calls.filter(([name]) => name === 'clearRect').length;
|
||||
const sharedCards = env.ctx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& text === 'AMBIENT-CONTINUES').length;
|
||||
|
||||
env.postRender.raise();
|
||||
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length, detectionClears,
|
||||
'the host preserves detection pixels on the held odd frame');
|
||||
assert.equal(env.detectionCtx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& String(text).startsWith('DENSE VIS:')).length, detectionBanners);
|
||||
assert.equal(env.ctx.calls.filter(([name]) => name === 'clearRect').length, sharedClears + 1,
|
||||
'the shared surface still clears');
|
||||
assert.equal(env.ctx.calls.filter(([name, text]) => name === 'fillText'
|
||||
&& text === 'AMBIENT-CONTINUES').length, sharedCards + 1,
|
||||
'the shared lane still repaints');
|
||||
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.equal(env.detectionCtx.calls.filter(([name]) => name === 'clearRect').length,
|
||||
detectionClears + 1, 'the next even frame repaints detection');
|
||||
assert.equal(getDetectionDiagnostics().throttleSkipCount, 1);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('detection cannot resurrect a private canvas, listener, matrix, resize, clear, or UI inventory', () => {
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const uiSource = readFileSync(new URL('../ui.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
|
||||
assert.doesNotMatch(source, /postRender\.addEventListener/);
|
||||
assert.doesNotMatch(source, /['"]detection-overlay['"]/);
|
||||
assert.doesNotMatch(source, /devicePixelRatio/);
|
||||
assert.doesNotMatch(source, /clearRect\(/);
|
||||
assert.doesNotMatch(source, /Matrix4\.multiply/);
|
||||
assert.doesNotMatch(source, /CALLOUT_OCCLUSION_SELECTORS/);
|
||||
assert.doesNotMatch(source, /_ctx\.(?:filter|globalCompositeOperation)\s*=/);
|
||||
assert.match(source, /_hostSurface\.style\.mixBlendMode = _theme\.blend;/);
|
||||
assert.match(source, /_hostSurface\.style\.filter = `\$\{_theme\.filter\} drop-shadow\(0 0 \$\{GLOW_PX\}px \$\{_theme\.glow\}\)`;/);
|
||||
assert.match(source, /const LABEL_SOLVE_INTERVAL_MS = 125;/);
|
||||
assert.match(source, /const _labelArbiter = new LabelArbiter\(\);/);
|
||||
assert.match(source, /const clipW = vp3 \* px \+ vp7 \* py \+ vp11 \* pz \+ vp15;/);
|
||||
assert.match(source, /registerWorldOverlayPaintLane\('detection', _paintDetectionLane/);
|
||||
assert.match(source, /target: 'detection'/);
|
||||
assert.match(source, /shouldPaint: _shouldPaintDetectionLane/);
|
||||
// The three military styles used to carry three copies of these numbers.
|
||||
// They now share ONE frozen object, which Cockpit's force-on reuses too, so
|
||||
// the pin moved from "three identical literals" to "one preset, referenced
|
||||
// three times" — same guarantee, and the copies can no longer drift.
|
||||
assert.match(
|
||||
uiSource,
|
||||
/const MILITARY_DETECTION_PRESET = Object\.freeze\(\{ mode: 'dense', densityPct: 75 \}\);/,
|
||||
'the tactical detection default is still Dense @ 75%',
|
||||
);
|
||||
assert.equal(
|
||||
uiSource.match(/detection: MILITARY_DETECTION_PRESET,/g)?.length,
|
||||
3,
|
||||
'CRT, NVG, and FLIR retain their Dense auto-enable defaults',
|
||||
);
|
||||
assert.match(uiSource, /preset\.detection && !this\._detectionUserOverridden/);
|
||||
});
|
||||
|
||||
// ── Developer telemetry gate (2026-08-20 QA hunt) ───────────────────────────
|
||||
// The orange mode banner ("DENSE VIS:15 SRC:1036 DENS:100% ELASTIC 0.4ms")
|
||||
// painted for every user: CRT/NVG/FLIR auto-enable detection, so engine
|
||||
// telemetry was the first thing a visitor saw, overlapping the cockpit callsign
|
||||
// block. It is kept as a debug affordance, but must default OFF.
|
||||
test('detectionDebugRequested parses the query-string gate and nothing else', () => {
|
||||
assert.equal(detectionDebugRequested('?detectDebug=1'), true);
|
||||
assert.equal(detectionDebugRequested('?foo=bar&detectDebug=1'), true);
|
||||
assert.equal(detectionDebugRequested(''), false);
|
||||
assert.equal(detectionDebugRequested('?detectDebug=0'), false);
|
||||
assert.equal(detectionDebugRequested('?detectDebug'), false);
|
||||
assert.equal(detectionDebugRequested('?detectdebug=1'), false, 'the flag is case-sensitive');
|
||||
assert.equal(detectionDebugRequested(undefined), false);
|
||||
assert.equal(detectionDebugRequested(null), false);
|
||||
});
|
||||
|
||||
test('the mode banner is absent by default and present behind the flag', () => {
|
||||
const bannerPaints = (env) => env.detectionCtx.calls.filter(([name, text]) => (
|
||||
name === 'fillText' && /^(SPARSE|BALANCED|DENSE) VIS:/.test(String(text))
|
||||
)).length;
|
||||
|
||||
const painted = (search) => {
|
||||
const env = installEnvironment({ search });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [detectableLayer()], () => {});
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
// The overlay is alive either way — brackets still stroke.
|
||||
assert.ok(
|
||||
env.detectionCtx.calls.some(([name, path]) => name === 'stroke' && path instanceof MockPath2D),
|
||||
'detection still paints its contacts regardless of the debug gate',
|
||||
);
|
||||
return bannerPaints(env);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(painted(''), 0, 'users must never see the engine telemetry banner');
|
||||
assert.ok(painted('?detectDebug=1') > 0, 'the flag brings the telemetry back');
|
||||
});
|
||||
|
||||
test('detection telemetry stays reachable programmatically with the banner hidden', () => {
|
||||
// Hiding the banner must not remove the numbers — getDetectionDiagnostics()
|
||||
// is the supported way to read them, and is what the QA harnesses use.
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [detectableLayer()], () => {});
|
||||
setMode('DENSE');
|
||||
env.advance(250);
|
||||
env.postRender.raise();
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.equal(diagnostics.profile, 'DENSE');
|
||||
assert.equal(typeof diagnostics.observationCount, 'number');
|
||||
assert.equal(typeof diagnostics.visibleCount, 'number');
|
||||
assert.equal(typeof diagnostics.densityPct, 'number');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('civilian and military AIR brackets cover front, left, and right at Sparse density', () => {
|
||||
for (const layerId of ['flights', 'military']) {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
try {
|
||||
const objects = [-0.82, 0, 0.82].map((x, index) => ({
|
||||
position: new Cesium.Cartesian3(x, 0, 6_356_752),
|
||||
sourceId: `${layerId}-${index}`,
|
||||
id: `${layerId.toUpperCase()}-${index}`,
|
||||
metric: 'FL120',
|
||||
type: 'AIR',
|
||||
}));
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
|
||||
setDetectionTuning({ densityPct: 25 });
|
||||
setMode('SPARSE');
|
||||
settleFrame(env);
|
||||
const diagnostics = getDetectionDiagnostics();
|
||||
assert.deepEqual(
|
||||
diagnostics.aircraftBracketSectors,
|
||||
{ left: 1, front: 1, right: 1 },
|
||||
`${layerId} side brackets must not be starved by the central keyhole`,
|
||||
);
|
||||
assert.equal(diagnostics.visibleCount, 3);
|
||||
assert.equal(diagnostics.densityPct, 25);
|
||||
assert.equal(diagnostics.profile, 'SPARSE');
|
||||
assert.ok(diagnostics.selectedCount <= diagnostics.collectiveLabelBudget,
|
||||
'Sparse keeps its documented budget instead of silently becoming Dense');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Two air contacts at the same screen radius, one with the planet behind it and
|
||||
* one with sky. The mock camera sits at (0, 0, 10 000 km) and projects through
|
||||
* an identity view-projection, so a position's x/y ARE its NDC and z is free:
|
||||
* the pole-surface contact looks straight down the axis into the planet, while
|
||||
* its mirror sits 10 000 km FURTHER out, so the view ray through it escapes.
|
||||
* Equal screen radius keeps the keyhole's radial fade identical for both, which
|
||||
* makes the two painted plate alphas directly comparable.
|
||||
*/
|
||||
function backdropLayer() {
|
||||
return {
|
||||
id: 'flights',
|
||||
getDetectableObjects() {
|
||||
return [
|
||||
{
|
||||
position: new Cesium.Cartesian3(-0.4, 0.2, 6_356_752),
|
||||
sourceId: 'ground-1', id: 'GROUNDED', metric: 'FL100', type: 'AIR',
|
||||
},
|
||||
{
|
||||
position: new Cesium.Cartesian3(0.4, -0.2, 20_000_000),
|
||||
sourceId: 'sky-1', id: 'SKYBACK', metric: 'FL400', type: 'AIR',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the backdrop feather reaches the canvas as a lighter plate against sky', () => {
|
||||
// The discriminator and the painter are pinned in their own modules, but both
|
||||
// pins still pass if detection stops carrying the factor between them. This
|
||||
// drives the real collect → solve → stash → replay path and reads the alpha
|
||||
// the shared canvas actually received for each plate.
|
||||
const env = installEnvironment();
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [backdropLayer()], () => {});
|
||||
setDetectionStyle('normal');
|
||||
setMode('DENSE');
|
||||
settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
assert.ok(painted('GROUNDED') && painted('SKYBACK'), 'both backdrops reached the frame under test');
|
||||
|
||||
const plate = DETECTION_THEME_MAP._default.calloutPlate;
|
||||
const plateAlphas = env.ctx.calls
|
||||
.filter(([name, , fillStyle]) => name === 'fill' && fillStyle === plate)
|
||||
.map(([, , , globalAlpha]) => globalAlpha);
|
||||
assert.equal(plateAlphas.length, 2, 'exactly one plate per contact');
|
||||
|
||||
const heaviest = Math.max(...plateAlphas);
|
||||
const lightest = Math.min(...plateAlphas);
|
||||
assert.ok(heaviest > 0, 'the grounded contact still gets a plate');
|
||||
assert.ok(lightest > 0, 'the sky contact keeps a whisper rather than vanishing');
|
||||
// Reverting the feature makes both plates equal, which fails here first.
|
||||
assert.ok(
|
||||
lightest < heaviest * 0.5,
|
||||
`sky plate ${lightest} must be markedly lighter than ground plate ${heaviest}`,
|
||||
);
|
||||
} finally {
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @module detectionPolicy
|
||||
* @description Pure detection density/profile and collective-budget helpers.
|
||||
* The five-stop UI, share links, voice actions, and renderer all use this
|
||||
* module so density cannot drift into contradictory mode semantics again.
|
||||
*/
|
||||
|
||||
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
|
||||
export const ALLOCATION_ELASTIC = 'ELASTIC';
|
||||
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
|
||||
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
|
||||
|
||||
// The keyhole's default 1% outside opacity is appropriate for text, but it
|
||||
// made side aircraft brackets effectively disappear while the same contacts
|
||||
// remained eligible. AIR geometry therefore gets a readable floor, without
|
||||
// changing label budgets.
|
||||
//
|
||||
// The floor is 0.35 AT THE DEFAULT OUTSIDE OPACITY, and it SCALES with the
|
||||
// OUTSIDE slider rather than overriding it. A flat 0.35 made that slider a
|
||||
// no-op for aircraft: dialling it down to 1% still painted brackets at 35%, so
|
||||
// the control did nothing across a third of its travel while visibly working
|
||||
// for labels. A setting that moves everything except the thing the operator is
|
||||
// looking at is a broken control, not a safety net.
|
||||
export const AIRCRAFT_BRACKET_ALPHA_FLOOR = 0.35;
|
||||
|
||||
/**
|
||||
* Mirror of celestialRing's KEYHOLE_OUTSIDE_OPACITY_DEFAULT — the OUTSIDE
|
||||
* setting the bracket look is calibrated at, so `AIRCRAFT_BRACKET_ALPHA_FLOOR`
|
||||
* lands EXACTLY at the shipped default. Mirrored rather than imported so this
|
||||
* stays a pure policy module with no Cesium dependency; detectionPolicy.test.mjs
|
||||
* imports the real constant and pins the two together so they cannot drift.
|
||||
*
|
||||
* It MOVES WITH THE DEFAULT (0.05 → 0.03 → 0.01; final value 2026-08-24).
|
||||
* That pin is the tripwire
|
||||
* for exactly this change, and the decision it forces is which of two things the
|
||||
* approval attaches to: the bracket BRIGHTNESS, or the slider POSITION. It is
|
||||
* the brightness — the floor exists so side aircraft brackets stay readable at
|
||||
* whatever the default is, and the owner's directive moved the label default,
|
||||
* not the bracket look. Holding the anchor at 0.05 instead would dim brackets to
|
||||
* 0.07 on a first run at the 1% default, which is the dead-zone complaint in
|
||||
* reverse.
|
||||
*/
|
||||
export const AIRCRAFT_BRACKET_FLOOR_ANCHOR = 0.01;
|
||||
|
||||
/**
|
||||
* The AIR bracket alpha floor for a given OUTSIDE opacity setting.
|
||||
*
|
||||
* Piecewise linear through three fixed points, chosen so the control is honest
|
||||
* across its whole travel while the shipped look does not move:
|
||||
*
|
||||
* slider 0 → 0 off means off; the operator's zero stays authoritative
|
||||
* slider 0.01 → 0.35 EXACTLY the shipped constant at the default, so the
|
||||
* approved look is reproduced unchanged (above the
|
||||
* anchor the floor rises smoothly — 0.05 ≈ 0.376)
|
||||
* slider 1.0 → 1.0 the boost decays to nothing: at full opacity the floor
|
||||
* equals the label alpha and stops overriding at all
|
||||
*
|
||||
* Strictly increasing, so every step of the slider changes the picture and
|
||||
* there is no dead zone at either end — which was the whole complaint.
|
||||
*
|
||||
* A non-finite input falls back to the anchor: an unreadable setting should
|
||||
* reproduce the approved default, never silently blank the brackets.
|
||||
*
|
||||
* @param {number} outsideOpacity - Keyhole outside opacity, 0-1.
|
||||
* @returns {number} Alpha floor for AIR brackets, 0-1.
|
||||
*/
|
||||
export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
const raw = Number(outsideOpacity);
|
||||
const outside = Number.isFinite(raw)
|
||||
? Math.max(0, Math.min(1, raw))
|
||||
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
|
||||
if (outside <= 0) return 0;
|
||||
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
}
|
||||
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
|
||||
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the paint alpha for a detection bracket without altering admission.
|
||||
* @param {string} type - Detection object type; only AIR is floored.
|
||||
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
|
||||
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
}
|
||||
|
||||
/** Stable left/front/right bucket for bracket coverage diagnostics and QA. */
|
||||
export function detectionHorizontalSector(screenX, viewportWidth) {
|
||||
const width = Number(viewportWidth);
|
||||
const x = Number(screenX);
|
||||
if (!(width > 0) || !Number.isFinite(x)) return 'front';
|
||||
if (x < width / 3) return 'left';
|
||||
if (x > width * 2 / 3) return 'right';
|
||||
return 'front';
|
||||
}
|
||||
|
||||
export const VIEW_SCALE_BUDGETS = Object.freeze({
|
||||
street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }),
|
||||
city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }),
|
||||
metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }),
|
||||
regional: Object.freeze({ 0: 4, 25: 10, 50: 20, 75: 30, 100: 40 }),
|
||||
// Full-globe views leave substantial radial screen space around the Earth.
|
||||
// Dense satellite fields can use that space without card overlap, so the
|
||||
// global row intentionally has a larger ceiling than the regional row.
|
||||
global: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 42, 100: 56 }),
|
||||
});
|
||||
|
||||
/** Clamp and canonicalize arbitrary input onto the five approved density stops. */
|
||||
export function canonicalizeDensity(inputPct, fallback = 50) {
|
||||
const raw = Number(inputPct);
|
||||
if (!Number.isFinite(raw)) return canonicalizeDensity(fallback, 50);
|
||||
const pct = Math.max(0, Math.min(100, raw));
|
||||
if (pct <= 25) return pct < 12.5 ? 0 : 25;
|
||||
if (pct < 75) return 50;
|
||||
return pct < 87.5 ? 75 : 100;
|
||||
}
|
||||
|
||||
/** Derive the only valid enabled profile from a canonical density stop. */
|
||||
export function profileForDensity(inputPct) {
|
||||
const stop = canonicalizeDensity(inputPct);
|
||||
if (stop <= 25) return 'SPARSE';
|
||||
if (stop >= 75) return 'DENSE';
|
||||
return 'BALANCED';
|
||||
}
|
||||
|
||||
/** Return the canonical default stop for an enabled profile or legacy alias. */
|
||||
export function defaultDensityForProfile(profile) {
|
||||
const normalized = normalizeProfile(profile);
|
||||
if (normalized === 'SPARSE') return 25;
|
||||
if (normalized === 'DENSE') return 75;
|
||||
return 50;
|
||||
}
|
||||
|
||||
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
|
||||
export function normalizeProfile(profile) {
|
||||
const raw = String(profile || '').trim().toUpperCase();
|
||||
if (raw === 'OFF') return 'OFF';
|
||||
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
|
||||
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
|
||||
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize the user-selectable layer allocation strategy. */
|
||||
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
|
||||
const raw = String(strategy || '').trim().toUpperCase();
|
||||
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
|
||||
const normalizedFallback = String(fallback || '').trim().toUpperCase();
|
||||
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
|
||||
? normalizedFallback
|
||||
: ALLOCATION_ELASTIC;
|
||||
}
|
||||
|
||||
/** Classify camera altitude into the shared label-budget view scale. */
|
||||
export function viewScaleForAltitude(altitudeM) {
|
||||
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
|
||||
if (altitude < 1200) return 'street';
|
||||
if (altitude < 4500) return 'city';
|
||||
if (altitude < 20000) return 'metro';
|
||||
if (altitude < 150000) return 'regional';
|
||||
return 'global';
|
||||
}
|
||||
|
||||
/** Resolve the collective text-callout cap for an altitude and density stop. */
|
||||
export function labelBudgetFor(altitudeM, densityPct) {
|
||||
const scale = viewScaleForAltitude(altitudeM);
|
||||
const stop = canonicalizeDensity(densityPct);
|
||||
return VIEW_SCALE_BUDGETS[scale][stop];
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a persisted/legacy mode+density pair into one canonical state.
|
||||
* Legacy Panoptic/God intent resolves to Dense even when its historical
|
||||
* density was below 75; legacy Sparse remains inside the Sparse band.
|
||||
*/
|
||||
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
|
||||
const rawMode = String(mode || '').trim().toUpperCase();
|
||||
const normalized = normalizeProfile(rawMode);
|
||||
if (normalized === 'OFF') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
|
||||
const stop = canonicalizeDensity(supplied, fallbackDensity);
|
||||
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
|
||||
}
|
||||
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
|
||||
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
|
||||
}
|
||||
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
|
||||
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
|
||||
const stop = canonicalizeDensity(density, 25);
|
||||
return { enabled: true, profile: 'SPARSE', densityPct: stop };
|
||||
}
|
||||
if (normalized === 'BALANCED' || normalized === 'DENSE') {
|
||||
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
|
||||
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
|
||||
return { enabled: true, profile: normalized, densityPct: stop };
|
||||
}
|
||||
const stop = canonicalizeDensity(densityPct, fallbackDensity);
|
||||
return { enabled: true, profile: profileForDensity(stop), densityPct: stop };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR,
|
||||
AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
aircraftBracketAlphaFloor,
|
||||
canonicalizeDensity,
|
||||
defaultDensityForProfile,
|
||||
detectionBracketAlpha,
|
||||
detectionHorizontalSector,
|
||||
labelBudgetFor,
|
||||
migrateDetectionState,
|
||||
normalizeAllocationStrategy,
|
||||
normalizeProfile,
|
||||
profileForDensity,
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
|
||||
|
||||
const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
|
||||
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
|
||||
assert.equal(detectionBracketAlpha('AIR', 0), 0);
|
||||
assert.equal(detectionBracketAlpha('AIR', 0.05), AIRCRAFT_BRACKET_ALPHA_FLOOR);
|
||||
assert.equal(detectionBracketAlpha('AIR', 1), 1);
|
||||
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
|
||||
});
|
||||
|
||||
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
|
||||
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
|
||||
// default ever moves, this fails rather than silently shifting the shipped
|
||||
// look — the whole point of the anchor is that 0.35 lands AT the default.
|
||||
assert.equal(AIRCRAFT_BRACKET_FLOOR_ANCHOR, KEYHOLE_OUTSIDE_OPACITY_DEFAULT);
|
||||
});
|
||||
|
||||
test('the default OUTSIDE setting reproduces the approved 0.35 floor exactly', () => {
|
||||
// Byte-identical at the default, at every keyhole alpha, with the setting
|
||||
// passed explicitly and with it omitted. This is the accepted look; only
|
||||
// the off-default range is allowed to change.
|
||||
assert.equal(aircraftBracketAlphaFloor(KEYHOLE_OUTSIDE_OPACITY_DEFAULT), AIRCRAFT_BRACKET_ALPHA_FLOOR);
|
||||
for (const alpha of [0.01, 0.05, 0.2, 0.34, 0.35, 0.36, 0.7, 1]) {
|
||||
assert.equal(
|
||||
detectionBracketAlpha('AIR', alpha, KEYHOLE_OUTSIDE_OPACITY_DEFAULT),
|
||||
Math.max(AIRCRAFT_BRACKET_ALPHA_FLOOR, alpha),
|
||||
`alpha ${alpha}: the default must reproduce the flat-0.35 result exactly`,
|
||||
);
|
||||
assert.equal(
|
||||
detectionBracketAlpha('AIR', alpha),
|
||||
detectionBracketAlpha('AIR', alpha, KEYHOLE_OUTSIDE_OPACITY_DEFAULT),
|
||||
`alpha ${alpha}: omitting the setting must mean the default, not zero`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the OUTSIDE slider genuinely dims aircraft brackets below the default', () => {
|
||||
// The defect: a flat floor made every sub-default setting indistinguishable
|
||||
// from the default for AIR.
|
||||
assert.equal(aircraftBracketAlphaFloor(0), 0, 'off means off');
|
||||
assert.equal(detectionBracketAlpha('AIR', 0, 0), 0);
|
||||
// A third of the default paints a third of the floor — derived from the
|
||||
// constants rather than hardcoded, so moving the default moves this with it.
|
||||
const third = AIRCRAFT_BRACKET_FLOOR_ANCHOR / 3;
|
||||
assert.ok(
|
||||
Math.abs(aircraftBracketAlphaFloor(third) - AIRCRAFT_BRACKET_ALPHA_FLOOR / 3) < 1e-9,
|
||||
'a third of the default setting must paint a third of the floor, not all of it',
|
||||
);
|
||||
assert.ok(
|
||||
aircraftBracketAlphaFloor(third) < AIRCRAFT_BRACKET_ALPHA_FLOOR / 2,
|
||||
'and it must be visibly dim, not a rounding away from the floor',
|
||||
);
|
||||
assert.ok(
|
||||
aircraftBracketAlphaFloor(0.005) < aircraftBracketAlphaFloor(0.01),
|
||||
'every step below the default must visibly move',
|
||||
);
|
||||
// The old behaviour, stated as the thing that must NOT come back.
|
||||
assert.notEqual(aircraftBracketAlphaFloor(0.005), AIRCRAFT_BRACKET_ALPHA_FLOOR);
|
||||
assert.notEqual(aircraftBracketAlphaFloor(0.2), AIRCRAFT_BRACKET_ALPHA_FLOOR);
|
||||
});
|
||||
|
||||
test('the range around the default is REACHABLE from the handle', () => {
|
||||
// The mapping above is continuous from 0, but the operator can only ask for
|
||||
// values the slider will stop on. At the shipped step of 5 the whole
|
||||
// sub-default range was one stop wide — 0 or 5, nothing between — so the low
|
||||
// stops were not settings anyone could choose. The control has to be able to
|
||||
// express what the policy can render, and the default itself now lives at 1 %.
|
||||
assert.match(
|
||||
indexHtml,
|
||||
/id="detection-opacity-slider"[^>]*\smin="0"[^>]*\smax="100"[^>]*\sstep="1"/,
|
||||
'index.html: the OUTSIDE slider steps by 1 so every integer percent is reachable',
|
||||
);
|
||||
|
||||
// Each newly reachable stop is a distinct, ordered picture — otherwise
|
||||
// widening the control would just add handle positions that paint the same
|
||||
// thing.
|
||||
const reachable = [1, 2, 3, 4, 5].map((pct) => aircraftBracketAlphaFloor(pct / 100));
|
||||
for (let i = 1; i < reachable.length; i += 1) {
|
||||
assert.ok(
|
||||
reachable[i] > reachable[i - 1],
|
||||
`${i}% -> ${i + 1}%: each newly reachable stop must paint brighter than the last`,
|
||||
);
|
||||
}
|
||||
// The default lands on the approved floor exactly, wherever the default is.
|
||||
assert.equal(
|
||||
aircraftBracketAlphaFloor(AIRCRAFT_BRACKET_FLOOR_ANCHOR),
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR,
|
||||
'the first-run setting still paints the approved bracket floor',
|
||||
);
|
||||
assert.equal(AIRCRAFT_BRACKET_FLOOR_ANCHOR, 0.01, 'and that setting is 1% (owner, 2026-08-24)');
|
||||
});
|
||||
|
||||
test('the bracket floor is strictly increasing and stops overriding at full opacity', () => {
|
||||
const steps = Array.from({ length: 101 }, (_, i) => i / 100);
|
||||
let previous = -1;
|
||||
for (const outside of steps) {
|
||||
const floor = aircraftBracketAlphaFloor(outside);
|
||||
assert.ok(floor >= 0 && floor <= 1, `floor ${floor} out of range at ${outside}`);
|
||||
assert.ok(floor > previous, `floor must strictly increase; ${outside} gave ${floor} after ${previous}`);
|
||||
previous = floor;
|
||||
}
|
||||
// At full opacity the floor equals the label alpha, so the boost is gone —
|
||||
// there is no dead zone at the top of the slider either.
|
||||
assert.equal(aircraftBracketAlphaFloor(1), 1);
|
||||
assert.equal(detectionBracketAlpha('AIR', 1, 1), 1);
|
||||
});
|
||||
|
||||
test('an unreadable OUTSIDE setting falls back to the approved default, never to blank', () => {
|
||||
for (const bad of [Number.NaN, undefined, 'x', Infinity]) {
|
||||
assert.equal(
|
||||
aircraftBracketAlphaFloor(bad),
|
||||
AIRCRAFT_BRACKET_ALPHA_FLOOR,
|
||||
`${String(bad)} must fail toward the shipped look`,
|
||||
);
|
||||
}
|
||||
assert.equal(aircraftBracketAlphaFloor(-5), 0);
|
||||
assert.equal(aircraftBracketAlphaFloor(5), 1);
|
||||
});
|
||||
|
||||
test('the bracket floor never touches non-aircraft geometry at any setting', () => {
|
||||
for (const outside of [0, 0.05, 0.5, 1]) {
|
||||
assert.equal(detectionBracketAlpha('SAT', 0.05, outside), 0.05);
|
||||
assert.equal(detectionBracketAlpha('SEA', 0.2, outside), 0.2);
|
||||
}
|
||||
});
|
||||
|
||||
test('aircraft coverage diagnostics use stable left/front/right thirds', () => {
|
||||
assert.equal(detectionHorizontalSector(100, 900), 'left');
|
||||
assert.equal(detectionHorizontalSector(450, 900), 'front');
|
||||
assert.equal(detectionHorizontalSector(800, 900), 'right');
|
||||
});
|
||||
|
||||
test('density canonicalization respects profile thresholds and five stops', () => {
|
||||
assert.deepEqual(
|
||||
[-10, 0, 12.49, 12.5, 25, 26, 49, 74, 75, 87.49, 87.5, 100, 120]
|
||||
.map(canonicalizeDensity),
|
||||
[0, 0, 0, 25, 25, 50, 50, 50, 75, 75, 100, 100, 100],
|
||||
);
|
||||
});
|
||||
|
||||
test('profiles are derived from canonical density', () => {
|
||||
assert.equal(profileForDensity(0), 'SPARSE');
|
||||
assert.equal(profileForDensity(25), 'SPARSE');
|
||||
assert.equal(profileForDensity(50), 'BALANCED');
|
||||
assert.equal(profileForDensity(75), 'DENSE');
|
||||
assert.equal(profileForDensity(100), 'DENSE');
|
||||
assert.equal(defaultDensityForProfile('survey'), 25);
|
||||
assert.equal(defaultDensityForProfile('normal'), 50);
|
||||
assert.equal(defaultDensityForProfile('panoptic'), 75);
|
||||
assert.equal(normalizeProfile('god'), 'DENSE');
|
||||
});
|
||||
|
||||
test('allocation strategy defaults safely to Elastic', () => {
|
||||
assert.equal(normalizeAllocationStrategy('weighted'), 'WEIGHTED');
|
||||
assert.equal(normalizeAllocationStrategy('elastic'), 'ELASTIC');
|
||||
assert.equal(normalizeAllocationStrategy('unknown'), 'ELASTIC');
|
||||
});
|
||||
|
||||
test('collective budgets follow view scale and canonical stop', () => {
|
||||
assert.equal(viewScaleForAltitude(1000), 'street');
|
||||
assert.equal(viewScaleForAltitude(2000), 'city');
|
||||
assert.equal(viewScaleForAltitude(10000), 'metro');
|
||||
assert.equal(viewScaleForAltitude(100000), 'regional');
|
||||
assert.equal(viewScaleForAltitude(1000000), 'global');
|
||||
assert.equal(labelBudgetFor(1000, 100), 90);
|
||||
assert.equal(labelBudgetFor(1e9, 100), 56);
|
||||
assert.equal(labelBudgetFor(1e9, 75), 42);
|
||||
assert.equal(labelBudgetFor(1e9, 28), 28);
|
||||
});
|
||||
|
||||
test('legacy state migration removes contradictory mode/density pairs', () => {
|
||||
assert.deepEqual(migrateDetectionState('PANOPTIC', 0), {
|
||||
enabled: true, profile: 'DENSE', densityPct: 75,
|
||||
});
|
||||
assert.deepEqual(migrateDetectionState('SPARSE', 100), {
|
||||
enabled: true, profile: 'SPARSE', densityPct: 25,
|
||||
});
|
||||
assert.deepEqual(migrateDetectionState('BALANCED', 100), {
|
||||
enabled: true, profile: 'BALANCED', densityPct: 50,
|
||||
});
|
||||
assert.equal(migrateDetectionState('OFF', 25).enabled, false);
|
||||
assert.deepEqual(migrateDetectionState('OFF', 25, 50), {
|
||||
enabled: false, profile: 'SPARSE', densityPct: 25,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Cockpit brackets stay legible without dominating the first-person view. */
|
||||
export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
|
||||
/**
|
||||
* Resolve the bracket-only opacity multiplier for the current presentation.
|
||||
* Detection callouts and user tuning are intentionally unaffected.
|
||||
* @param {boolean} cockpitActive - Whether Cockpit view owns the viewport.
|
||||
* @returns {number} Bracket stroke opacity multiplier.
|
||||
*/
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
COCKPIT_BRACKET_OPACITY,
|
||||
detectionBracketOpacity,
|
||||
} from './detectionPresentation.js';
|
||||
|
||||
test('Cockpit reduces only the bracket presentation multiplier', () => {
|
||||
assert.equal(COCKPIT_BRACKET_OPACITY, 0.45);
|
||||
assert.equal(detectionBracketOpacity(true), 0.45);
|
||||
assert.equal(detectionBracketOpacity(false), 1);
|
||||
assert.equal(detectionBracketOpacity(undefined), 1);
|
||||
});
|
||||
|
||||
test('detection owns and releases the Cockpit lifecycle listener', async () => {
|
||||
const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
assert.match(source, /addEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/);
|
||||
assert.match(source, /removeEventListener\('gev:cockpit-mode-changed', _cockpitModeListener\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/fade \* entry\.alpha \* bracketPresentationOpacity/,
|
||||
'Cockpit opacity must multiply bracket strokes only',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/_drawCallout\(entry, fade \* bracketPresentationOpacity/,
|
||||
'Cockpit bracket de-emphasis must not dim callouts',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Detection's render demand — when the overlay actually needs another frame.
|
||||
*
|
||||
* Detection used to take an unconditional continuous-render hold for as long as
|
||||
* it was on (`holdContinuousRender('detection')`). That was honest when it was
|
||||
* written: the overlay repainted from scratch every frame, so "on" really did
|
||||
* mean "per-frame work". It stopped being honest once the world-overlay host
|
||||
* became demand-driven — `invalidateHost()` already requests a frame on every
|
||||
* content change, and Cesium's `requestRenderMode` already renders on camera
|
||||
* input and tile loads, so a parked scene with detection on was paying 60 fps
|
||||
* for pixels that do not change.
|
||||
*
|
||||
* Nobody noticed because detection was OFF by default. Turning it ON by default
|
||||
* (2026-08-22) would have shipped that hot loop to every idle first-run tab —
|
||||
* defeating the render governor, whose whole reason for existing is the ~60% GPU
|
||||
* and ~54% of a core a parked scene used to burn (`src/renderGovernor.js`).
|
||||
*
|
||||
* So detection no longer holds. It is driven the way the rest of the host is:
|
||||
* repaint on CHANGE, and — for work that spans frames — ask for exactly one more
|
||||
* frame while that work is outstanding. That is the pattern `drawWorldOverlay`
|
||||
* already uses for its own fades, and the same shape as the earthquake-disc fix
|
||||
* (static geometry, hold removed) that this follows.
|
||||
*
|
||||
* ── THE ONE RULE ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Every input below must be able to reach a state where it stops asking. A
|
||||
* predicate that can stay true forever is the old hold under another name, and
|
||||
* that is the property the unit pins defend hardest.
|
||||
*
|
||||
* Three kinds of outstanding work qualify, and each terminates:
|
||||
*
|
||||
* 1. the fade-in when detection (re)activates — a bounded window;
|
||||
* 2. label fades, IN as well as out — the arbiter drives every one of them to
|
||||
* a settled alpha;
|
||||
* 3. a label solve the frame could not run — the throttle defers it by at most
|
||||
* one interval, a skipped paint by exactly one frame.
|
||||
*
|
||||
* (2) said "out" only, once. A newly selected label fading IN is `selected`, so
|
||||
* counting the fade-OUT tail missed it entirely: on a parked scene a label could
|
||||
* be selected and then never painted past its first frame — invisible until some
|
||||
* unrelated frame happened along. Fade-in and fade-out are the same animation
|
||||
* seen from two ends, with the same termination. Both count.
|
||||
*
|
||||
* ── ONE CLOCK ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The timestamp is the host frame's own `frame.timestamp` — `performance.now()`,
|
||||
* sampled ONCE per frame by `drawWorldOverlay` — and the SAME value must reach
|
||||
* both the paint and this policy.
|
||||
*
|
||||
* Two samples of a clock taken a millisecond apart inside one frame is a real
|
||||
* defect, not pedantry: paint at age 219 ms draws alpha 0.99545, and a policy
|
||||
* re-sampling at 220 ms answers "done", so the frame that would have painted the
|
||||
* settled alpha is never requested and the fade visibly stops a hair short.
|
||||
* Sharing one timestamp makes the frame that paints final state the same frame
|
||||
* that ends demand, by construction. Monotonicity removes the other half of that
|
||||
* defect: a wall clock can jump backwards and leave an elapsed-time test true
|
||||
* until it catches up, which is exactly the "asks forever" failure this module
|
||||
* exists to prevent.
|
||||
*
|
||||
* Pure functions + constants so the policy is unit-testable without a viewer or
|
||||
* a canvas; detection.js owns the plumbing.
|
||||
*/
|
||||
|
||||
/** Duration of the fade-in when detection (re)activates, in ms. */
|
||||
export const DETECTION_ENABLE_FADE_MS = 220;
|
||||
|
||||
/**
|
||||
* Cadence of the scanline's scroll, in ms per 2px step — one frame at 60 fps.
|
||||
*
|
||||
* Derived rather than written as `16`: a rounded 16 ms is 62.5 Hz, which against
|
||||
* a real 60 fps frame clock beats slowly and repeats an offset every few frames
|
||||
* instead of alternating. Deriving the cadence from a clock rather than from a
|
||||
* frame counter is what lets the animation rest without a render hold.
|
||||
*/
|
||||
export const SCANLINE_STEP_MS = 1000 / 60;
|
||||
|
||||
/** Scanline pattern period in px — the offset walks 0, 2, 0, 2, … */
|
||||
export const SCANLINE_PERIOD_PX = 4;
|
||||
|
||||
/**
|
||||
* Scanline scroll offset for a monotonic frame timestamp.
|
||||
*
|
||||
* @param {number} nowMs - Frame timestamp in ms (`performance.now()`).
|
||||
* @returns {number} Vertical offset in px, within [0, SCANLINE_PERIOD_PX).
|
||||
*/
|
||||
export function scanlineOffsetPx(nowMs) {
|
||||
if (!Number.isFinite(nowMs)) return 0;
|
||||
return (Math.floor(nowMs / SCANLINE_STEP_MS) * 2) % SCANLINE_PERIOD_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether detection still has work spanning frames, and therefore needs one more
|
||||
* frame after the one it just painted.
|
||||
*
|
||||
* Returns false for everything change-driven: those paths already request their
|
||||
* own frame through `invalidateHost()`, and asking here as well would turn a
|
||||
* bounded chain into a permanent one.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {boolean} input.active - Detection is on and not suspended.
|
||||
* @param {number} input.nowMs - The frame's monotonic timestamp (see header).
|
||||
* @param {number} input.enabledAtMs - When detection last (re)activated, same clock.
|
||||
* @param {number} [input.fadeMs] - Enable fade-in duration.
|
||||
* @param {number} [input.animatingLabelCount] - Labels mid-fade, IN or out.
|
||||
* @param {boolean} [input.solvePending] - A label solve is owed but did not run.
|
||||
* @returns {boolean} True when one more frame is owed.
|
||||
*/
|
||||
export function detectionNeedsFollowUpFrame({
|
||||
active,
|
||||
nowMs,
|
||||
enabledAtMs,
|
||||
fadeMs = DETECTION_ENABLE_FADE_MS,
|
||||
animatingLabelCount = 0,
|
||||
solvePending = false,
|
||||
} = {}) {
|
||||
if (!active) return false;
|
||||
if (Number(animatingLabelCount) > 0) return true;
|
||||
if (solvePending === true) return true;
|
||||
if (!Number.isFinite(nowMs) || !Number.isFinite(enabledAtMs)) return false;
|
||||
const age = nowMs - enabledAtMs;
|
||||
// `age >= 0` is the fail-toward-idle guard. It cannot trip on the monotonic
|
||||
// clock this is fed, but if a caller ever mixes clocks the wrong answer must
|
||||
// be "stop asking", never "keep asking until time catches up".
|
||||
return age >= 0 && age < fadeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint cost above which the relief valve may skip alternate detection paints.
|
||||
* Unchanged from when the valve was introduced; only its demand accounting moved.
|
||||
*/
|
||||
export const DETECTION_PAINT_SKIP_THRESHOLD_MS = 22;
|
||||
|
||||
/**
|
||||
* The pathological-load relief valve: may this frame's detection paint be
|
||||
* skipped, and if so, what does the skip owe?
|
||||
*
|
||||
* Extracted as a pure decision because the answer has two halves that MUST stay
|
||||
* welded together. Under the old continuous hold a skip cost nothing — the next
|
||||
* frame was guaranteed — so the valve could simply return false and forget. With
|
||||
* no hold, the frame it declines may be the only one anybody asked for, and a
|
||||
* skip that forgets strands whatever requested it: a reactivation, a new label,
|
||||
* a fresh solve. Skipping is a DEFERRAL, never a cancellation.
|
||||
*
|
||||
* `requestFollowUp` is therefore exactly `skip`, and that equality is the
|
||||
* invariant worth pinning — a wrapper cannot skip without re-requesting, because
|
||||
* the same decision produces both.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {boolean} input.layoutChanged - The host's layout revision moved this frame.
|
||||
* @param {number} input.lastPaintMs - Cost of the previous detection paint.
|
||||
* @param {number} input.frameCount - Monotonic detection frame counter.
|
||||
* @param {number} [input.thresholdMs]
|
||||
* @returns {{skip: boolean, requestFollowUp: boolean}}
|
||||
*/
|
||||
export function detectionPaintSkipDecision({
|
||||
layoutChanged,
|
||||
lastPaintMs,
|
||||
frameCount,
|
||||
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
||||
} = {}) {
|
||||
const skip = !layoutChanged
|
||||
&& Number(lastPaintMs) > thresholdMs
|
||||
&& Number(frameCount) % 2 !== 0;
|
||||
return { skip, requestFollowUp: skip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a rendered label row's alpha will still change on a later frame.
|
||||
*
|
||||
* A `selected` row is still climbing while its alpha is below 1. An unselected
|
||||
* row only survives the arbiter's render pass while its exit tail is above 0, so
|
||||
* any that reaches here is by definition still moving. Stateless rows have hard
|
||||
* cliffs (exactly 1, or gone) and never animate.
|
||||
*
|
||||
* @param {{selected?: boolean, temporalAlpha?: number}} entry - Arbiter render row.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function renderEntryIsAnimating(entry) {
|
||||
if (!entry) return false;
|
||||
if (!entry.selected) return true;
|
||||
return Number(entry.temporalAlpha) < 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count rendered rows whose alpha is still moving, in either direction.
|
||||
* @param {Array<{selected?: boolean, temporalAlpha?: number}>} renderEntries
|
||||
* @returns {number}
|
||||
*/
|
||||
export function countAnimatingRenderEntries(renderEntries) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < renderEntries.length; i++) {
|
||||
if (renderEntryIsAnimating(renderEntries[i])) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// src/data/detectionRenderDemand.test.mjs
|
||||
//
|
||||
// Detection must not hold the render loop open — and must not drop work on the
|
||||
// floor once it stops holding.
|
||||
//
|
||||
// It used to take an unconditional continuous-render hold for as long as it was
|
||||
// on. That was invisible while detection defaulted OFF; turning it ON by default
|
||||
// (2026-08-22) would have shipped a permanent 60 fps loop to every idle
|
||||
// first-run tab, defeating the render governor whose entire purpose is that a
|
||||
// parked scene costs nothing.
|
||||
//
|
||||
// Removing a hold moves the burden of proof. Under the hold a frame was always
|
||||
// coming, so anything could be deferred for free; without it, the frame a piece
|
||||
// of work was given may be the ONLY one, and whatever consumes that frame
|
||||
// without finishing the work strands it indefinitely. The first cut of this
|
||||
// module stranded four different things — a review reproduced all four live on a
|
||||
// parked scene — so these pins are organized around that failure mode:
|
||||
//
|
||||
// 1. POLICY — what counts as outstanding work, and that every kind of it can
|
||||
// reach a state where it stops asking. A predicate that can stay true
|
||||
// forever is the old hold under another name.
|
||||
// 2. ANIMATION accounting — fade-IN counts, not only the fade-out tail. A
|
||||
// newly selected label is `selected`, so counting only unselected rows left
|
||||
// it invisible on a parked scene until some unrelated frame arrived.
|
||||
// 3. ONE CLOCK — paint and demand read the same per-frame timestamp, so the
|
||||
// frame that paints an animation's final state is the frame that ends its
|
||||
// demand, and a non-monotonic clock cannot make demand permanent.
|
||||
// 4. WIRING — the hold is really gone; a skipped paint re-requests instead of
|
||||
// swallowing; a layer whose contents changed dirties the solve.
|
||||
//
|
||||
// The runtime counterpart — parked scene, detection ON vs OFF, near-zero renders
|
||||
// either way, and the overlay still painting — is `scripts/qa-perf.mjs` §1b.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
DETECTION_ENABLE_FADE_MS,
|
||||
DETECTION_PAINT_SKIP_THRESHOLD_MS,
|
||||
SCANLINE_PERIOD_PX,
|
||||
SCANLINE_STEP_MS,
|
||||
countAnimatingRenderEntries,
|
||||
detectionNeedsFollowUpFrame,
|
||||
detectionPaintSkipDecision,
|
||||
renderEntryIsAnimating,
|
||||
scanlineOffsetPx,
|
||||
} from './detectionRenderDemand.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Policy — outstanding work, and that all of it terminates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a follow-up frame is owed only while work spans frames', () => {
|
||||
const base = { active: true, enabledAtMs: 1_000, fadeMs: DETECTION_ENABLE_FADE_MS };
|
||||
|
||||
// Enable fade-in: open during the window, closed at and after its end. The
|
||||
// boundary IS the termination condition — at exactly `fadeMs` the fade has
|
||||
// landed on its final alpha, and asking for a frame to repaint an unchanged
|
||||
// pixel is the off-by-one that never lets the chain stop.
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 1_000 }), true);
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 1_000 + DETECTION_ENABLE_FADE_MS - 1 }), true);
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 1_000 + DETECTION_ENABLE_FADE_MS }), false,
|
||||
'the chain must terminate exactly when the fade lands');
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 9_999_999 }), false);
|
||||
|
||||
// Labels mid-fade, in EITHER direction, owe a frame regardless of how long
|
||||
// detection has been on.
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 9_999_999, animatingLabelCount: 3 }), true);
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 9_999_999, animatingLabelCount: 0 }), false);
|
||||
|
||||
// A solve the frame could not run is outstanding work too: the frame that
|
||||
// carried its request cannot be the last one.
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 9_999_999, solvePending: true }), true);
|
||||
assert.equal(detectionNeedsFollowUpFrame({ ...base, nowMs: 9_999_999, solvePending: false }), false);
|
||||
});
|
||||
|
||||
test('nothing is owed while detection is off or suspended — that is the whole point', () => {
|
||||
for (const extra of [{}, { animatingLabelCount: 5 }, { solvePending: true }]) {
|
||||
assert.equal(
|
||||
detectionNeedsFollowUpFrame({ active: false, nowMs: 1_000, enabledAtMs: 1_000, ...extra }),
|
||||
false,
|
||||
'an inactive overlay must never keep the scene awake',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a broken or backward clock terminates demand instead of extending it', () => {
|
||||
// The failure mode that matters is the one that keeps rendering, so it is the
|
||||
// one pinned. A non-finite timestamp reads as "no animation".
|
||||
for (const nowMs of [undefined, null, NaN, Infinity]) {
|
||||
assert.equal(detectionNeedsFollowUpFrame({ active: true, nowMs, enabledAtMs: 0 }), false);
|
||||
}
|
||||
for (const enabledAtMs of [undefined, null, NaN, Infinity]) {
|
||||
assert.equal(detectionNeedsFollowUpFrame({ active: true, nowMs: 1_000, enabledAtMs }), false);
|
||||
}
|
||||
assert.equal(detectionNeedsFollowUpFrame(), false, 'no argument at all is still not an animation');
|
||||
|
||||
// And a timestamp BEHIND the enable stamp — impossible on the monotonic clock
|
||||
// this is fed, but the answer must be "stop", not "ask until time catches up",
|
||||
// which is what a wall-clock jump used to produce.
|
||||
assert.equal(
|
||||
detectionNeedsFollowUpFrame({ active: true, nowMs: 500, enabledAtMs: 1_000 }),
|
||||
false,
|
||||
'a backward clock must fail toward idle',
|
||||
);
|
||||
assert.equal(
|
||||
detectionNeedsFollowUpFrame({ active: true, nowMs: -5_000, enabledAtMs: 1_000 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Animation accounting — fade-IN is an animation too
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a label fading IN is animating, not just one fading out', () => {
|
||||
// The regression this pins: counting only the fade-out tail (`!selected`) meant
|
||||
// a freshly selected label registered no demand at all. On a parked scene it
|
||||
// was painted once at a partial alpha and then left invisible until some
|
||||
// unrelated frame happened along.
|
||||
assert.equal(renderEntryIsAnimating({ selected: true, temporalAlpha: 0.01 }), true,
|
||||
'a label that has just been selected is still climbing');
|
||||
assert.equal(renderEntryIsAnimating({ selected: true, temporalAlpha: 0.5 }), true);
|
||||
assert.equal(renderEntryIsAnimating({ selected: true, temporalAlpha: 1 }), false,
|
||||
'a settled label is done — otherwise every visible label would demand frames forever');
|
||||
|
||||
// Unselected rows only survive the arbiter's render pass while their exit tail
|
||||
// is above zero, so any that reaches here is still moving.
|
||||
assert.equal(renderEntryIsAnimating({ selected: false, temporalAlpha: 1 }), true);
|
||||
assert.equal(renderEntryIsAnimating({ selected: false, temporalAlpha: 0.2 }), true);
|
||||
|
||||
assert.equal(renderEntryIsAnimating(null), false);
|
||||
assert.equal(renderEntryIsAnimating(undefined), false);
|
||||
|
||||
assert.equal(countAnimatingRenderEntries([]), 0);
|
||||
assert.equal(countAnimatingRenderEntries([
|
||||
{ selected: true, temporalAlpha: 1 }, // settled — no demand
|
||||
{ selected: true, temporalAlpha: 0.4 }, // fading IN
|
||||
{ selected: false, temporalAlpha: 0.7 }, // fading out
|
||||
{ selected: true, temporalAlpha: 1 }, // settled
|
||||
]), 2);
|
||||
|
||||
// A screen full of settled labels must demand nothing — this is the property
|
||||
// that keeps a parked scene at zero renders.
|
||||
const settled = Array.from({ length: 40 }, () => ({ selected: true, temporalAlpha: 1 }));
|
||||
assert.equal(countAnimatingRenderEntries(settled), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2b. The relief valve defers, it never cancels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a skipped paint always hands its frame forward — skip and re-request are one decision', () => {
|
||||
// The regression this pins: under the old continuous hold a skipped paint cost
|
||||
// nothing, because the next frame was guaranteed. With no hold, the frame the
|
||||
// valve declines may be the ONLY one anybody requested — a reactivation, a new
|
||||
// label, a fresh solve — and swallowing it strands that work indefinitely.
|
||||
//
|
||||
// These drive the REAL decision the production valve calls, not a description
|
||||
// of it: the earlier version of this check hand-fed frames to a stub, which is
|
||||
// exactly how the defect survived review.
|
||||
const heavy = DETECTION_PAINT_SKIP_THRESHOLD_MS + 13; // a 35 ms paint
|
||||
|
||||
// The one case that skips: expensive previous paint, odd frame, stable layout.
|
||||
const skipped = detectionPaintSkipDecision({ layoutChanged: false, lastPaintMs: heavy, frameCount: 1 });
|
||||
assert.deepEqual(skipped, { skip: true, requestFollowUp: true },
|
||||
'a skip must carry its own follow-up request');
|
||||
|
||||
// Every other case paints, and a paint needs no deferral.
|
||||
for (const input of [
|
||||
{ layoutChanged: true, lastPaintMs: heavy, frameCount: 1 }, // layout moved — never skip
|
||||
{ layoutChanged: false, lastPaintMs: heavy, frameCount: 2 }, // even frame — the valve's duty cycle
|
||||
{ layoutChanged: false, lastPaintMs: 1, frameCount: 1 }, // cheap paint — no relief needed
|
||||
{ layoutChanged: false, lastPaintMs: DETECTION_PAINT_SKIP_THRESHOLD_MS, frameCount: 1 }, // at the threshold, not past it
|
||||
]) {
|
||||
assert.deepEqual(detectionPaintSkipDecision(input), { skip: false, requestFollowUp: false },
|
||||
`expected a paint for ${JSON.stringify(input)}`);
|
||||
}
|
||||
|
||||
// The invariant, stated as one law over the whole input space: the valve can
|
||||
// never skip without re-requesting. A wrapper cannot break this, because both
|
||||
// halves come from the same decision.
|
||||
for (const layoutChanged of [true, false]) {
|
||||
for (const lastPaintMs of [0, 1, 22, 23, 35, 500, NaN]) {
|
||||
for (const frameCount of [0, 1, 2, 3, 41, 100]) {
|
||||
const d = detectionPaintSkipDecision({ layoutChanged, lastPaintMs, frameCount });
|
||||
assert.equal(d.requestFollowUp, d.skip,
|
||||
`skip/re-request diverged at ${JSON.stringify({ layoutChanged, lastPaintMs, frameCount })}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the deferral is bounded: consecutive frames cannot both skip, so the
|
||||
// work waits one frame rather than forever.
|
||||
const first = detectionPaintSkipDecision({ layoutChanged: false, lastPaintMs: heavy, frameCount: 7 });
|
||||
const second = detectionPaintSkipDecision({ layoutChanged: false, lastPaintMs: heavy, frameCount: 8 });
|
||||
assert.equal(first.skip, true);
|
||||
assert.equal(second.skip, false, 'the very next frame paints — a skip costs one frame, not the work');
|
||||
|
||||
assert.deepEqual(detectionPaintSkipDecision(), { skip: false, requestFollowUp: false },
|
||||
'no input is not a reason to drop a frame');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. One clock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('the scanline scrolls off the clock, at an exact 60 fps cadence', () => {
|
||||
// Derived, not the rounded 16 it used to be: 16 ms is 62.5 Hz, which beats
|
||||
// against a real 60 fps frame clock and repeats an offset every few frames
|
||||
// instead of alternating.
|
||||
assert.equal(SCANLINE_STEP_MS, 1000 / 60);
|
||||
assert.equal(SCANLINE_PERIOD_PX, 4);
|
||||
assert.equal(scanlineOffsetPx(0), 0);
|
||||
assert.equal(scanlineOffsetPx(SCANLINE_STEP_MS), 2);
|
||||
assert.equal(scanlineOffsetPx(SCANLINE_STEP_MS * 2), 0);
|
||||
assert.equal(scanlineOffsetPx(SCANLINE_STEP_MS * 3), 2);
|
||||
|
||||
// At an ideal 60 fps the offset alternates on EVERY frame — no repeats.
|
||||
const walk = Array.from({ length: 12 }, (_, i) => scanlineOffsetPx(SCANLINE_STEP_MS * i + 0.001));
|
||||
for (let i = 1; i < walk.length; i++) {
|
||||
assert.notEqual(walk[i], walk[i - 1], `frame ${i} repeated the previous offset`);
|
||||
}
|
||||
|
||||
// It stays inside the pattern period for any timestamp — an offset that walked
|
||||
// past the period would leave a visible seam at the top of the canvas.
|
||||
for (const t of [1, 1_000, 1_700_000_000_000, 1_700_000_000_016]) {
|
||||
const offset = scanlineOffsetPx(t);
|
||||
assert.ok(offset >= 0 && offset < SCANLINE_PERIOD_PX, `offset ${offset} outside the period at t=${t}`);
|
||||
}
|
||||
|
||||
// Two paints inside one step render identically — the animation is a function
|
||||
// of elapsed TIME, so a parked scene rests instead of demanding frames.
|
||||
const stepStart = SCANLINE_STEP_MS * 100;
|
||||
assert.equal(scanlineOffsetPx(stepStart), scanlineOffsetPx(stepStart + SCANLINE_STEP_MS - 1));
|
||||
assert.equal(scanlineOffsetPx(Number.NaN), 0, 'a broken clock paints a stable pattern, not a wandering one');
|
||||
});
|
||||
|
||||
test('paint and demand share one timestamp, so the terminal frame is painted', async () => {
|
||||
// The dropped-frame defect: paint sampled the clock, then demand sampled it
|
||||
// again a millisecond later. At age 219 ms the paint drew alpha 0.99545 while
|
||||
// the policy, re-reading at 220 ms, answered "done" — so the frame that would
|
||||
// have painted the settled alpha was never requested and the fade stopped a
|
||||
// hair short of finished.
|
||||
//
|
||||
// With ONE timestamp the two cannot disagree. Walk the boundary: every frame
|
||||
// whose alpha is still short of 1 must also be owed a successor, and the first
|
||||
// frame that reaches alpha 1 must be the one that ends demand.
|
||||
const { acquireAlpha } = await import('./detectionDraw.js');
|
||||
const enabledAtMs = 10_000;
|
||||
for (const age of [0, 1, 100, 219, 219.9, DETECTION_ENABLE_FADE_MS, DETECTION_ENABLE_FADE_MS + 1]) {
|
||||
const nowMs = enabledAtMs + age;
|
||||
const alpha = acquireAlpha(enabledAtMs, nowMs, DETECTION_ENABLE_FADE_MS);
|
||||
const owed = detectionNeedsFollowUpFrame({
|
||||
active: true, nowMs, enabledAtMs, fadeMs: DETECTION_ENABLE_FADE_MS,
|
||||
});
|
||||
if (alpha < 1) {
|
||||
assert.equal(owed, true, `age ${age}: painted alpha ${alpha} but no successor frame was owed`);
|
||||
} else {
|
||||
assert.equal(owed, false, `age ${age}: alpha has settled, demand must end on this frame`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('detection holds nothing, and asks for its own frames instead', async () => {
|
||||
const source = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /holdContinuousRender/,
|
||||
'detection must never take a continuous-render hold — that is the regression');
|
||||
assert.doesNotMatch(source, /releaseContinuousRender/,
|
||||
'and with no hold there is nothing to release');
|
||||
|
||||
assert.match(source, /governorRequestRender\('detection-visibility'\)/,
|
||||
'mode/suspend transitions request their own repaint');
|
||||
assert.match(source, /if \(detectionNeedsFollowUpFrame\(\{[\s\S]*?\}\)\) \{\s*\n\s*governorRequestRender\('detection-animation'\);/,
|
||||
'the follow-up frame is gated on the policy, never unconditional');
|
||||
|
||||
// The demand call must read the FRAME's timestamp, not a fresh sample.
|
||||
const paintLane = /function _paintDetectionLane\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(paintLane, 'detection.js still has a paint lane');
|
||||
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
|
||||
'demand must not re-sample the clock — that is the dropped terminal frame');
|
||||
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
|
||||
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
|
||||
'demand counts fades in both directions');
|
||||
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
|
||||
// fade-OUT-only counter into it is exactly the original defect, and it is
|
||||
// invisible to every other assertion here — the field name would still line up
|
||||
// while a fading-in label registered nothing.
|
||||
assert.match(source, /const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
|
||||
'the demand count comes from the both-directions counter, not the fade-out tail');
|
||||
assert.match(source, /const fadingCount = countFadingRenderEntries\(renderEntries\);/,
|
||||
'and the published fade-out diagnostic keeps its own, unchanged meaning');
|
||||
assert.match(paintLane, /solvePending: result\.solvePending === true/,
|
||||
'and an unrun solve keeps the chain alive');
|
||||
|
||||
// No exit from the draw pass may report work it cannot finish. The
|
||||
// zero-objects exit skips the solve block entirely, so carrying the dirty flag
|
||||
// out of it reports "a solve is still owed" on every future frame — which,
|
||||
// paired with the follow-up frame an owed solve earns, is a permanent chain on
|
||||
// the emptiest possible scene. (Caught by the governor gate at 301 renders/5 s
|
||||
// with zero layers; the fix settles the solve there, because with nothing
|
||||
// detectable it is vacuously complete.)
|
||||
const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{ didSolve: false[^\n]*\n/.exec(source)?.[0];
|
||||
assert.ok(emptyExit, 'the zero-objects exit is still identifiable');
|
||||
assert.match(emptyExit, /_labelSolveDirty = false;/,
|
||||
'the zero-objects exit settles the solve instead of carrying it forward');
|
||||
assert.match(emptyExit, /solvePending: false/,
|
||||
'and reports no outstanding solve — nothing detectable means nothing to place');
|
||||
|
||||
// No wall clock may drive anything the demand model reasons about.
|
||||
const drawOverlay = /function _drawOverlay\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(drawOverlay, 'detection.js still has a draw pass');
|
||||
assert.doesNotMatch(drawOverlay, /Date\.now\(\)/,
|
||||
'the draw pass must run on the frame timestamp, not a wall clock');
|
||||
assert.match(drawOverlay, /const now = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/);
|
||||
assert.match(source, /_enableTime = _nowMs\(\);/,
|
||||
'the enable stamp shares the monotonic clock the fade is measured against');
|
||||
|
||||
// A skipped paint DEFERS demand; it must not consume it. The valve must route
|
||||
// through the shared decision rather than re-implementing the threshold, or
|
||||
// the skip/re-request pairing proven above would not bind it.
|
||||
const shouldPaint = /function _shouldPaintDetectionLane\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(shouldPaint, 'the relief valve is still there');
|
||||
assert.match(shouldPaint, /detectionPaintSkipDecision\(\{/,
|
||||
'the valve asks the shared policy instead of inlining its own threshold');
|
||||
assert.doesNotMatch(shouldPaint, /_lastPaintMs > 22/,
|
||||
'the old inline threshold must be gone, not shadowing the policy');
|
||||
assert.match(shouldPaint, /if \(decision\.requestFollowUp\) governorRequestRender\('detection-paint-skipped'\)/,
|
||||
'skipping a paint must hand the request forward, not swallow it');
|
||||
|
||||
// The scanline must not go back to the frame counter: that is what made a
|
||||
// decorative texture demand a permanently hot loop.
|
||||
const scanlines = /function _drawScanlines\([\s\S]*?\n\}/.exec(source)?.[0];
|
||||
assert.ok(scanlines, 'detection.js still draws scanlines');
|
||||
assert.doesNotMatch(scanlines, /_frameCount/,
|
||||
'the scroll offset must come from the clock, not from a frame counter');
|
||||
assert.match(scanlines, /scanlineOffsetPx\(nowMs\)/,
|
||||
'and from the shared policy so the two cannot drift');
|
||||
});
|
||||
|
||||
test('a layer whose detectable set changed dirties the solve', async () => {
|
||||
// Detection PULLS candidates per paint but re-solves on a private 125 ms
|
||||
// throttle. A poll tick that replaces contact A with contact B requests one
|
||||
// frame; without this, that frame could be spent on a paint which declined to
|
||||
// re-solve — leaving A labelled, B unlabelled, and nothing left to ask again.
|
||||
const detection = await readFile(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
assert.match(detection, /export function markDetectionSourcesChanged\(/,
|
||||
'detection exposes the source-change hook');
|
||||
const hook = /export function markDetectionSourcesChanged\([\s\S]*?\n\}/.exec(detection)[0];
|
||||
assert.match(hook, /_labelSolveDirty = true;/, 'and it really dirties the solve');
|
||||
assert.doesNotMatch(hook, /governorRequestRender/,
|
||||
'the caller already requests the frame — asking twice would double every tick');
|
||||
assert.match(hook, /if \(_mode === MODE_OFF\) return;/,
|
||||
'and it stays inert while detection is off');
|
||||
|
||||
const manager = await readFile(new URL('./manager.js', import.meta.url), 'utf8');
|
||||
assert.match(manager, /import \{ markDetectionSourcesChanged \} from '\.\/detection\.js';/);
|
||||
// Both discrete events that can change the detectable set, next to the render
|
||||
// request each already made.
|
||||
assert.match(manager, /governorRequestRender\(`layer-tick:\$\{layerId\}`\);[\s\S]{0,700}?markDetectionSourcesChanged\(`layer-tick:\$\{layerId\}`\);/,
|
||||
'a poll tick marks the solve dirty alongside its render request');
|
||||
assert.match(manager, /governorRequestRender\('layer-visibility'\);[\s\S]{0,400}?markDetectionSourcesChanged\('layer-visibility'\);/,
|
||||
'so does a layer appearing or disappearing');
|
||||
});
|
||||
|
||||
test('the render-governor gate covers the parked case, with teeth on the painter', async () => {
|
||||
// The unit tests above cannot see a duty cycle, and a render count alone
|
||||
// cannot see whether the painter still runs — a disabled painter would score a
|
||||
// perfect idle. If either half of the runtime gate is dropped, this fails and
|
||||
// says so rather than leaving the policy pinned but unobserved.
|
||||
const gate = await readFile(new URL('../../scripts/qa-perf.mjs', import.meta.url), 'utf8');
|
||||
assert.match(gate, /detection ON takes NO continuous-render hold/);
|
||||
assert.match(gate, /idle parked scene with detection ON \(≤4 fires \/ 5s\)/);
|
||||
assert.match(gate, /detection ON costs no more idle frames than detection OFF/);
|
||||
assert.match(gate, /detection ON still repaints promptly on camera motion/,
|
||||
'quiet must not be allowed to mean stale');
|
||||
assert.match(gate, /detection is still PAINTING, not merely quiet/,
|
||||
'and the activity check must reach the painter, not just the scene');
|
||||
// The idle windows must START at a settled quiet RUN, not a fixed sleep and
|
||||
// not one empty second. The harness's own teardown marks the HUD summary
|
||||
// dirty, and the 15 s tick that notices types the new text in — reflowing an
|
||||
// occluder ~16 s later, inside the old counted window. The scene is honestly
|
||||
// idle for that whole gap, so only a run longer than one refresh period can
|
||||
// tell "settled" from "about to be interrupted".
|
||||
assert.match(gate, /const settleUntilQuiet = async \(/,
|
||||
'the idle windows wait for quiet rather than sleeping a fixed time');
|
||||
assert.match(gate, /const HUD_SUMMARY_INTERVAL_MS = 15_000;/,
|
||||
'the quiet run is derived from the app cadence it has to outlast');
|
||||
assert.match(gate, /QUIET_RUN_WINDOWS = Math\.ceil\(HUD_SUMMARY_INTERVAL_MS \/ 1_000\) \+ 1/,
|
||||
'one full summary refresh period, plus a second of margin');
|
||||
assert.match(gate, /requiredConsecutive = QUIET_RUN_WINDOWS/,
|
||||
'and the settle actually requires that run, rather than a shorter constant');
|
||||
assert.match(gate, /else \{ if \(consecutive > 0\) restarts \+= 1; consecutive = 0; \}/,
|
||||
'any activity restarts the run, so it cannot straddle the deferred burst');
|
||||
assert.match(gate, /check\('the parked scene holds a settled quiet run before the idle window is counted', idleSettle\.quiet/,
|
||||
'quiet that never arrives is asserted as a failure, not skipped past');
|
||||
assert.match(gate, /check\('the scene holds a settled quiet run again before the second idle window', teardownSettle\.quiet/);
|
||||
assert.match(gate, /idle parked scene stops rendering \(≤4 fires \/ 5s\)/,
|
||||
'the threshold inside the settled window stays untouched');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. What keeps aircraft brackets prompt without a detection hold
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Detection paints AIR brackets — including the alpha-floored ones — inside
|
||||
// _drawOverlay, from live positions, and does NOT take part in any
|
||||
// "objects may have changed" notification. With detection's own render hold
|
||||
// gone, the obvious worry is a floored bracket sitting stale on a parked scene
|
||||
// until some unrelated frame happens along.
|
||||
//
|
||||
// It cannot, and the reason is structural rather than lucky: an AIR bracket can
|
||||
// only exist while an aircraft layer is enabled, and both aircraft layers take a
|
||||
// continuous-render hold for their own per-frame fleet animation. So for exactly
|
||||
// as long as there is anything to bracket, the scene is rendering every frame
|
||||
// and the overlay repaints with it. (Measured live 2026-08-23 on a parked
|
||||
// camera: an outside-aircraft population change moved the painted bracket count
|
||||
// with no camera input, holds = ["flights"], requestRenderMode = false.)
|
||||
//
|
||||
// That is a COUPLING, so it deserves a pin. If a later perf pass strips these
|
||||
// holds the way it stripped detection's — a reasonable-looking change — bracket
|
||||
// promptness goes with them, silently. This test is where that shows up.
|
||||
test('aircraft brackets stay prompt because the aircraft layers hold the render loop', async () => {
|
||||
for (const file of ['./flights.js', './militaryFlights.js']) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
const enable = /\n enable\([\s\S]*?\n \},/.exec(source)?.[0];
|
||||
assert.ok(enable, `${file}: enable() is still identifiable`);
|
||||
assert.match(
|
||||
enable,
|
||||
/holdContinuousRender\('(flights|military)'\)/,
|
||||
`${file}: enabling the layer must hold continuous render — detection no longer ` +
|
||||
'holds one, so this is what keeps its AIR brackets repainting on a parked scene',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/releaseContinuousRender\('(flights|military)'\)/,
|
||||
`${file}: and the hold must be released, or the governor can never idle`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Convert a cardinal/intercardinal direction string to a compass heading.
|
||||
*
|
||||
* Two matching modes, because the same text is parsed from two very different
|
||||
* kinds of source field:
|
||||
*
|
||||
* 1. Dedicated direction fields (Caltrans `location.direction`, Austin
|
||||
* `travel_direction`/`facing`, …) hold a real facing value. There, bare
|
||||
* cardinal words ("West", "North") ARE the answer — pass `allowBare=true`.
|
||||
*
|
||||
* 2. Free-form name/description text ("5TH ST / WEST AVE", "N LAMAR BLVD") is
|
||||
* full of STREET names that merely contain a cardinal word. Reading a bare
|
||||
* "West" there as a facing direction mis-orients the camera with false
|
||||
* confidence (59 of ~1000 Austin cameras hit this — field review
|
||||
* 2026-07-04). There, only explicit travel forms ("WESTBOUND"/"WB") count —
|
||||
* leave `allowBare=false` (the default).
|
||||
*
|
||||
* Recognizes full travel words ("NORTHBOUND"), abbreviations ("NB"), and —
|
||||
* only when `allowBare` is set — bare cardinals ("NORTH").
|
||||
*
|
||||
* @param {string} value - Direction text (case-insensitive).
|
||||
* @param {boolean} [allowBare=false] - Match bare cardinal words. Use ONLY for
|
||||
* dedicated direction fields, never for free-form name inference.
|
||||
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
|
||||
*/
|
||||
export function directionToHeading(value, allowBare = false) {
|
||||
const text = String(value || '').trim().toUpperCase();
|
||||
if (!text) return NaN;
|
||||
// Explicit travel/intercardinal forms — safe on free-form text (a street
|
||||
// name almost never contains "NORTHBOUND" or a lone "NB" token).
|
||||
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
|
||||
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
|
||||
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
|
||||
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
|
||||
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
|
||||
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
|
||||
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
|
||||
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
|
||||
// Bare cardinals — dedicated direction fields only.
|
||||
if (allowBare) {
|
||||
if (/\bNORTH\b/.test(text)) return 0;
|
||||
if (/\bSOUTH\b/.test(text)) return 180;
|
||||
if (/\bEAST\b/.test(text)) return 90;
|
||||
if (/\bWEST\b/.test(text)) return 270;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// directionToHeading — two matching modes. The regression that motivated the
|
||||
// split (adversarial field review, 2026-07-04): bare cardinal words were
|
||||
// matched in free-form Austin camera names, so a street like "5TH ST / WEST
|
||||
// AVE" was mis-read as a west-facing camera with false high confidence.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { directionToHeading } from './directionText.js';
|
||||
|
||||
test('explicit travel forms resolve in BOTH modes (bound words + abbreviations)', () => {
|
||||
for (const allowBare of [false, true]) {
|
||||
assert.equal(directionToHeading('NORTHBOUND', allowBare), 0);
|
||||
assert.equal(directionToHeading('SB', allowBare), 180);
|
||||
assert.equal(directionToHeading('EASTBOUND lanes', allowBare), 90);
|
||||
assert.equal(directionToHeading('WB', allowBare), 270);
|
||||
assert.equal(directionToHeading('NORTHEAST', allowBare), 45);
|
||||
assert.equal(directionToHeading('SW', allowBare), 225);
|
||||
}
|
||||
});
|
||||
|
||||
test('bare cardinal words resolve ONLY when allowBare (dedicated direction field)', () => {
|
||||
assert.equal(directionToHeading('West', true), 270);
|
||||
assert.equal(directionToHeading('North', true), 0);
|
||||
assert.equal(directionToHeading('South', true), 180);
|
||||
assert.equal(directionToHeading('East', true), 90);
|
||||
// Caltrans-style field with trailing text still extracts the bare cardinal.
|
||||
assert.equal(directionToHeading('West / SR-24', true), 270);
|
||||
});
|
||||
|
||||
test('REGRESSION: bare cardinals in free-form names do NOT resolve (allowBare=false)', () => {
|
||||
// These are real Austin camera name shapes (live-sampled). Each contains a
|
||||
// cardinal word as part of a STREET NAME, not a facing direction — they must
|
||||
// return NaN so the camera falls back to low-confidence id-hash heading.
|
||||
assert.ok(Number.isNaN(directionToHeading('5TH ST / WEST AVE')), 'WEST AVE street name');
|
||||
assert.ok(Number.isNaN(directionToHeading('LAMAR BLVD / NORTH LOOP BLVD')), 'NORTH LOOP street name');
|
||||
assert.ok(Number.isNaN(directionToHeading('SOUTH CONGRESS AVE / RIVERSIDE')), 'SOUTH CONGRESS street name');
|
||||
assert.ok(Number.isNaN(directionToHeading('EAST 7TH ST / I-35')), 'EAST 7TH street name');
|
||||
// …but a name that genuinely carries a travel form still resolves.
|
||||
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH'), 180);
|
||||
});
|
||||
|
||||
test('compound words are not shadowed and word boundaries hold', () => {
|
||||
// "NORTH" must not match inside "NORTHBOUND" (returns via the bound branch)
|
||||
// nor inside "NORTHLAND" (no boundary → no match even with allowBare).
|
||||
assert.equal(directionToHeading('NORTHBOUND', true), 0);
|
||||
assert.ok(Number.isNaN(directionToHeading('NORTHLAND DR', true)), 'NORTHLAND is not NORTH');
|
||||
assert.ok(Number.isNaN(directionToHeading('EASTON PARK', true)), 'EASTON is not EAST');
|
||||
});
|
||||
|
||||
test('empty / junk input returns NaN', () => {
|
||||
assert.ok(Number.isNaN(directionToHeading('')));
|
||||
assert.ok(Number.isNaN(directionToHeading(null)));
|
||||
assert.ok(Number.isNaN(directionToHeading('somewhere', true)));
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* USGS earthquake discs — last 24 hours, M2.5+.
|
||||
*
|
||||
* Ellipse axes are STATIC (plain numbers), redefined only when a poll brings
|
||||
* new data. They must never become a `CallbackProperty` again: every entity
|
||||
* here is a `CLAMP_TO_GROUND` ellipse, and a per-frame axis re-tessellates its
|
||||
* ground primitive on EVERY frame. Measured on the shipped 58-event feed
|
||||
* (2026-08-20 QA hunt, parked camera over SF at 40 km):
|
||||
*
|
||||
* 58 discs, callback axes → 32.4 ms/frame, 30 fps
|
||||
* 58 discs, static axes → 1.4 ms/frame, 60 fps
|
||||
*
|
||||
* The former ±15 % radius "pulse" was DROPPED to buy that back: an
|
||||
* imperceptible breathing wobble on a translucent ground disc is not worth
|
||||
* rebuilding 58 ground primitives 60 times a second. With no per-frame
|
||||
* animator left, the layer also no longer holds the render governor
|
||||
* continuous — the manager's `layer-tick` / `layer-visibility` requests
|
||||
* already cover every discrete mutation this layer makes.
|
||||
*/
|
||||
|
||||
const API_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
|
||||
|
||||
export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
|
||||
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
|
||||
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
|
||||
|
||||
const DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
/**
|
||||
* Color by depth:
|
||||
* - Shallow (<70km): Red
|
||||
* - Intermediate (70-300km): Orange
|
||||
* - Deep (>300km): Yellow
|
||||
*/
|
||||
function depthColor(depthKm) {
|
||||
if (depthKm < 70) return Cesium.Color.RED;
|
||||
if (depthKm < 300) return Cesium.Color.ORANGE;
|
||||
return Cesium.Color.YELLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the source-owned presentation for one ambient magnitude label.
|
||||
* Magnitude formatting deliberately remains here instead of moving into the
|
||||
* shared renderer.
|
||||
* @param {object} input
|
||||
* @param {string} input.id Stable USGS or deterministic fallback id.
|
||||
* @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse.
|
||||
* @param {number} input.magnitude USGS magnitude.
|
||||
* @param {string} input.accent Source-owned depth-band color.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createEarthquakeOverlayEntry({ id, position, magnitude, accent }) {
|
||||
const mag = Number(magnitude);
|
||||
return {
|
||||
id: String(id),
|
||||
position,
|
||||
variant: 'label',
|
||||
title: `M${mag.toFixed(1)}`,
|
||||
accent,
|
||||
priority: Math.round(mag * 1000),
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the largest events, with stable identity as the tie-break. */
|
||||
export function selectEarthquakeOverlayCohort(
|
||||
entries,
|
||||
limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
) {
|
||||
const cap = Math.max(0, Math.min(
|
||||
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
Math.floor(Number(limit) || 0),
|
||||
));
|
||||
if (!Array.isArray(entries) || cap === 0) return [];
|
||||
return entries.slice().sort((a, b) => (
|
||||
b.priority - a.priority || String(a.id).localeCompare(String(b.id))
|
||||
)).slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one earthquake's raw plain values to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. Falls back to an index-based id
|
||||
* when the USGS event id is absent.
|
||||
* @param {Object|null|undefined} raw - Plain values pulled off the entity:
|
||||
* {id, mag, place, time, depth, lat, lon}.
|
||||
* @param {number} [index=0] - Position in the snapshot (fallback id only).
|
||||
* @returns {{id: string, magnitude: number|null, depthKm: number|null,
|
||||
* lat: number|null, lon: number|null, timeMs: number|null, place: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw, index = 0) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
|
||||
return {
|
||||
id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`,
|
||||
magnitude: num(raw?.mag),
|
||||
depthKm: num(raw?.depth),
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
timeMs: num(raw?.time), // USGS epoch ms
|
||||
place: text(raw?.place),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
|
||||
const layer = {
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes (24h)',
|
||||
icon: '🌋',
|
||||
source: 'USGS',
|
||||
updateInterval: 60000,
|
||||
|
||||
init(viewer) {
|
||||
_dataSource = new Cesium.CustomDataSource('earthquakes');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_enabled = false;
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
console.log('[Data:Earthquakes] Initialized');
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
_enabled = true;
|
||||
// No continuous-render hold: the discs are static geometry now, so the
|
||||
// layer has no per-frame animator to keep the render loop alive for.
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
async update(viewer) {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
_lastError = `USGS HTTP ${response.status}`;
|
||||
console.warn(`[Data:Earthquakes] API returned ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const geojson = await response.json();
|
||||
if (!geojson || !Array.isArray(geojson.features)) {
|
||||
_lastError = 'Malformed USGS response';
|
||||
return false;
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
let count = 0;
|
||||
const overlayEntries = [];
|
||||
|
||||
for (const feature of geojson.features) {
|
||||
const [lon, lat, depthKm] = feature.geometry.coordinates;
|
||||
const mag = feature.properties.mag;
|
||||
const place = feature.properties.place;
|
||||
const time = feature.properties.time;
|
||||
|
||||
if (mag < 2.5) continue; // Skip micro-quakes
|
||||
|
||||
count++;
|
||||
const baseRadius = Math.pow(2, mag) * 1000;
|
||||
const color = depthColor(depthKm || 0);
|
||||
const isSignificant = mag >= 5.0;
|
||||
const fillAlpha = isSignificant ? 0.4 : 0.3;
|
||||
const outlineAlpha = isSignificant ? 1.0 : 0.8;
|
||||
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
|
||||
const stableId = feature.id || `event-${count}`;
|
||||
_dataSource.entities.add({
|
||||
id: `earthquake:${stableId}`,
|
||||
position,
|
||||
ellipse: {
|
||||
// Static axes — see the module header. A CallbackProperty here
|
||||
// re-tessellates the clamped ground geometry every frame.
|
||||
semiMajorAxis: baseRadius,
|
||||
semiMinorAxis: baseRadius,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(fillAlpha)
|
||||
),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(outlineAlpha),
|
||||
outlineWidth: isSignificant ? 3 : 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
properties: {
|
||||
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
|
||||
usgsId: feature.id ?? null,
|
||||
mag,
|
||||
place,
|
||||
time,
|
||||
depth: depthKm,
|
||||
},
|
||||
});
|
||||
overlayEntries.push(createEarthquakeOverlayEntry({
|
||||
id: String(stableId),
|
||||
position,
|
||||
magnitude: mag,
|
||||
accent: color.toCssColorString(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (_enabled) {
|
||||
overlayHost.setEntries(
|
||||
EARTHQUAKE_OVERLAY_SOURCE_ID,
|
||||
selectEarthquakeOverlayCohort(overlayEntries),
|
||||
{
|
||||
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_count = count;
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`);
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[Data:Earthquakes] Fetch error:', e);
|
||||
_lastError = 'USGS network error';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
_enabled = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot the layer's in-memory earthquake records as plain JSON-safe
|
||||
* objects for the analyst query engine. On-demand only (called at most
|
||||
* once per spoken query) — zero per-frame cost, no listeners, no caching.
|
||||
* Returns [] while the layer is disabled or empty.
|
||||
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const entities = _dataSource.entities.values;
|
||||
if (!entities.length) return [];
|
||||
const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000;
|
||||
const now = Cesium.JulianDate.now();
|
||||
const result = [];
|
||||
for (const entity of entities) {
|
||||
if (result.length >= limit) break;
|
||||
const cartesian = entity.position ? entity.position.getValue(now) : null;
|
||||
const carto = cartesian ? Cesium.Cartographic.fromCartesian(cartesian) : null;
|
||||
const p = entity.properties;
|
||||
result.push(mapAnalystRecord({
|
||||
id: p?.usgsId?.getValue(now) ?? null,
|
||||
mag: p?.mag?.getValue(now),
|
||||
place: p?.place?.getValue(now),
|
||||
time: p?.time?.getValue(now),
|
||||
depth: p?.depth?.getValue(now),
|
||||
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
|
||||
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
|
||||
}, result.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
const earthquakesLayer = createEarthquakesLayer();
|
||||
|
||||
export default earthquakesLayer;
|
||||
@@ -0,0 +1,340 @@
|
||||
// src/data/earthquakes.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
|
||||
createEarthquakeOverlayEntry,
|
||||
createEarthquakesLayer,
|
||||
mapAnalystRecord,
|
||||
selectEarthquakeOverlayCohort,
|
||||
} from './earthquakes.js';
|
||||
import { DataLayerManager } from './manager.js';
|
||||
import {
|
||||
getRenderGovernorDiagnostics,
|
||||
installRenderGovernor,
|
||||
_resetRenderGovernorForTest,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
const FULL_RAW = {
|
||||
id: 'us7000abcd',
|
||||
mag: 5.2,
|
||||
place: '42 km SW of Anchorage, Alaska',
|
||||
time: 1_753_600_000_000,
|
||||
depth: 41.7,
|
||||
lat: 61.02,
|
||||
lon: -150.41,
|
||||
};
|
||||
|
||||
test('earthquake analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RAW, 3);
|
||||
assert.deepEqual(r, {
|
||||
id: 'us7000abcd',
|
||||
magnitude: 5.2,
|
||||
depthKm: 41.7,
|
||||
lat: 61.02,
|
||||
lon: -150.41,
|
||||
timeMs: 1_753_600_000_000,
|
||||
place: '42 km SW of Anchorage, Alaska',
|
||||
});
|
||||
});
|
||||
|
||||
test('earthquake analyst record: missing USGS id falls back to index-based id', () => {
|
||||
assert.equal(mapAnalystRecord({ ...FULL_RAW, id: null }, 3).id, 'QUAKE-0003');
|
||||
assert.equal(mapAnalystRecord({ ...FULL_RAW, id: ' ' }, 41).id, 'QUAKE-0041');
|
||||
assert.equal(mapAnalystRecord(undefined).id, 'QUAKE-0000');
|
||||
});
|
||||
|
||||
test('earthquake analyst record: missing fields become null, never NaN/undefined', () => {
|
||||
const r = mapAnalystRecord({ id: 'us1', mag: NaN, depth: undefined, place: '' }, 0);
|
||||
assert.equal(r.magnitude, null);
|
||||
assert.equal(r.depthKm, null);
|
||||
assert.equal(r.place, null);
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
test('earthquake analyst record: output is JSON-safe (no Cesium types)', () => {
|
||||
const r = mapAnalystRecord(FULL_RAW, 0);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
|
||||
});
|
||||
|
||||
test('earthquake overlay copy keeps source-side magnitude formatting and bounded priority', () => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(-150.41, 61.02);
|
||||
const entry = createEarthquakeOverlayEntry({
|
||||
id: 'us7000abcd',
|
||||
position,
|
||||
magnitude: 5.24,
|
||||
accent: '#ff0000',
|
||||
});
|
||||
assert.equal(entry.title, 'M5.2');
|
||||
assert.equal(entry.position, position);
|
||||
assert.equal(entry.variant, 'label');
|
||||
assert.equal(entry.paintLane, 'ambient-label');
|
||||
assert.equal(entry.collisionGroup, 'ambient-label');
|
||||
assert.equal(entry.protected, undefined);
|
||||
assert.equal(entry.edgeFade, 'keyhole');
|
||||
assert.equal(entry.horizonCull, true);
|
||||
|
||||
const entries = Array.from({ length: EARTHQUAKE_OVERLAY_COHORT_LIMIT + 20 }, (_, index) => ({
|
||||
id: `quake-${String(index).padStart(3, '0')}`,
|
||||
priority: index,
|
||||
}));
|
||||
const cohort = selectEarthquakeOverlayCohort(entries);
|
||||
assert.equal(cohort.length, EARTHQUAKE_OVERLAY_COHORT_LIMIT);
|
||||
assert.equal(cohort[0].id, `quake-${EARTHQUAKE_OVERLAY_COHORT_LIMIT + 19}`);
|
||||
assert.equal(cohort.at(-1).id, 'quake-020');
|
||||
});
|
||||
|
||||
test('real earthquake lifecycle publishes host labels while runtime entities carry no label graphic', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const hostCalls = [];
|
||||
const dataSources = [];
|
||||
const overlayHost = {
|
||||
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
||||
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
||||
clearSource: (...args) => hostCalls.push(['clear', ...args]),
|
||||
};
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [
|
||||
{
|
||||
id: 'us-runtime-1',
|
||||
geometry: { coordinates: [-150.41, 61.02, 41.7] },
|
||||
properties: { mag: 5.24, place: 'Runtime One', time: 1_753_600_000_000 },
|
||||
},
|
||||
{
|
||||
id: 'us-runtime-2',
|
||||
geometry: { coordinates: [139.7, 35.6, 310] },
|
||||
properties: { mag: 3.01, place: 'Runtime Two', time: 1_753_600_100_000 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const layer = createEarthquakesLayer({ overlayHost });
|
||||
try {
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
await layer.update(viewer);
|
||||
|
||||
const entities = dataSources[0].entities.values;
|
||||
assert.equal(entities.length, 2, 'runtime guard requires populated real source entities');
|
||||
assert.ok(entities.every((entity) => entity.label === undefined));
|
||||
const publication = hostCalls.find(([type]) => type === 'entries');
|
||||
assert.ok(publication, 'real update path must publish the overlay source');
|
||||
assert.deepEqual(publication[2].map(({ title }) => title), ['M5.2', 'M3.0']);
|
||||
assert.deepEqual(publication[3], {
|
||||
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
layer.disable(viewer);
|
||||
assert.equal(dataSources[0].show, false);
|
||||
assert.deepEqual(hostCalls.slice(-2), [
|
||||
['clear', 'earthquakes'],
|
||||
['visible', 'earthquakes', false],
|
||||
]);
|
||||
layer.destroy(viewer);
|
||||
assert.equal(dataSources.length, 0);
|
||||
assert.deepEqual(hostCalls.slice(-2), [
|
||||
['clear', 'earthquakes'],
|
||||
['visible', 'earthquakes', false],
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Perf pin: the 2026-08-20 earthquakes frame-rate cliff ────────────────────
|
||||
// Every disc is a CLAMP_TO_GROUND ellipse. When its axes were a
|
||||
// `CallbackProperty`, Cesium re-tessellated all 58 ground primitives on EVERY
|
||||
// frame: 32.4 ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with
|
||||
// the layer off. Static axes restored 60 fps. Both halves of the fix are
|
||||
// pinned — the runtime property shape, and the source-level guards.
|
||||
test('quake disc axes are STATIC — a per-frame callback re-tessellates ground geometry', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove() { return true; },
|
||||
},
|
||||
};
|
||||
const layer = createEarthquakesLayer({
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
});
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [{
|
||||
id: 'us-static-1',
|
||||
geometry: { coordinates: [-122.4, 37.79, 8.2] },
|
||||
properties: { mag: 5.5, place: 'Static One', time: 1_753_600_000_000 },
|
||||
}],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
await layer.update(viewer);
|
||||
|
||||
const [entity] = dataSources[0].entities.values;
|
||||
assert.ok(entity.ellipse, 'the disc is an ellipse graphic');
|
||||
for (const axis of ['semiMajorAxis', 'semiMinorAxis']) {
|
||||
const property = entity.ellipse[axis];
|
||||
assert.equal(
|
||||
property instanceof Cesium.CallbackProperty,
|
||||
false,
|
||||
`${axis} must not be a CallbackProperty — it rebuilds the ground primitive every frame`,
|
||||
);
|
||||
assert.equal(property.isConstant, true, `${axis} must be a constant property`);
|
||||
// Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse.
|
||||
assert.equal(property.getValue(Cesium.JulianDate.now()), Math.pow(2, 5.5) * 1000);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
layer.destroy(viewer);
|
||||
}
|
||||
});
|
||||
|
||||
// Dropping the continuous-render hold is only safe if new poll data still reaches
|
||||
// the screen. In idle mode nothing repaints on its own, so the manager's one-shot
|
||||
// request after each tick is now load-bearing for this layer. Wires the REAL layer
|
||||
// into the REAL manager with the governor installed, rather than trusting the
|
||||
// arrangement by inspection.
|
||||
test('a quake poll still reaches the screen with the render loop idle', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const renderRequests = [];
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) },
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove() { return true; },
|
||||
},
|
||||
};
|
||||
const layer = createEarthquakesLayer({
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
});
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [{
|
||||
id: 'us-idle-1',
|
||||
geometry: { coordinates: [-122.4, 37.79, 8.2] },
|
||||
properties: { mag: 4.2, place: 'Idle One', time: 1_753_600_000_000 },
|
||||
}],
|
||||
}),
|
||||
});
|
||||
|
||||
_resetRenderGovernorForTest();
|
||||
installRenderGovernor(viewer);
|
||||
// Installing the governor enters idle mode, which itself paints one settling
|
||||
// frame. Drop that from the baseline or the enable assertion below can pass on
|
||||
// the install alone, without the manager ever requesting anything.
|
||||
renderRequests.length = 0;
|
||||
const manager = new DataLayerManager(viewer);
|
||||
// updateInterval -1 keeps the poll loop unarmed; we drive one tick by hand.
|
||||
manager.register({ ...layer, updateInterval: -1 });
|
||||
try {
|
||||
await manager.setEnabled('earthquakes', true, { origin: 'test' });
|
||||
|
||||
// The whole point of the change: enabling quakes must not pin the loop on.
|
||||
assert.equal(getRenderGovernorDiagnostics().mode, 'idle', 'quakes must not force continuous render');
|
||||
assert.deepEqual(getRenderGovernorDiagnostics().holds, []);
|
||||
assert.equal(viewer.scene.requestRenderMode, true, 'the governor really is in idle mode');
|
||||
|
||||
// ...and the poll that populated the discs must still have asked for a frame,
|
||||
// because in idle mode nothing repaints on its own.
|
||||
assert.ok(dataSources[0].entities.values.length > 0, 'the enable poll produced discs');
|
||||
assert.ok(renderRequests.length > 0, 'enabling the layer must request a render in idle mode');
|
||||
// Named, not merely counted: the frame has to come from the manager's
|
||||
// visibility request, not from some incidental repaint.
|
||||
assert.ok(
|
||||
getRenderGovernorDiagnostics().recentRequests.some(({ reason }) => reason === 'layer-visibility'),
|
||||
'the enable frame must be the manager\'s layer-visibility request',
|
||||
);
|
||||
|
||||
// A LATER poll must request its own frame too — the enable-time
|
||||
// 'layer-visibility' request cannot cover refreshes that arrive minutes later.
|
||||
const beforeRefresh = renderRequests.length;
|
||||
await manager._runPeriodicUpdate('earthquakes', manager.layers.get('earthquakes'));
|
||||
assert.ok(
|
||||
renderRequests.length > beforeRefresh,
|
||||
'each refresh tick must request its own render while the loop is idle',
|
||||
);
|
||||
const reasons = getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason);
|
||||
assert.ok(
|
||||
reasons.includes('layer-tick:earthquakes'),
|
||||
`the tick request must be attributed to the layer, got ${JSON.stringify(reasons)}`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {});
|
||||
_resetRenderGovernorForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
|
||||
const source = readFileSync(new URL('./earthquakes.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/new Cesium\.CallbackProperty/,
|
||||
'reverting the discs to a per-frame axis callback must fail this pin',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/holdContinuousRender/,
|
||||
'static discs have no per-frame animator, so the layer must not pin the render loop on',
|
||||
);
|
||||
});
|
||||
|
||||
test('earthquake refresh reports failure and clears it only after a successful response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove() { return true; },
|
||||
},
|
||||
};
|
||||
const layer = createEarthquakesLayer({
|
||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||
});
|
||||
try {
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
globalThis.fetch = async () => ({ ok: false, status: 503 });
|
||||
assert.equal(await layer.update(viewer), false);
|
||||
assert.equal(layer.getStats().error, 'USGS HTTP 503');
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ features: [] }),
|
||||
});
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.ok(Number.isFinite(layer.getStats().lastUpdate));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
layer.destroy(viewer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// src/data/fireAnchors.js — DEM ground anchors for rendered FIRMS detections
|
||||
// (field finding 2026-07-21: at close/oblique zoom over high country,
|
||||
// fire dots anchored at ellipsoid height 0 read as buried inside the terrain
|
||||
// ~1-2 km below the visible surface).
|
||||
//
|
||||
// The fix rides the height-datum ship's machinery end to end: anchors read
|
||||
// the SAME shared ground floor every other ground-adjacent consumer uses
|
||||
// (groundFloor.cachedGroundFloor — rendered-mesh cell ?? real Re:Earth DEM
|
||||
// cell), and cold cells warm through the same batched, chunked, cached
|
||||
// resolver. No 3D-Tiles sampling happens here (zero steady-state raycasts);
|
||||
// fires are static, and floor cells latch warm for the session, so each
|
||||
// rendered detection costs at most one DEM lookup EVER — and detections
|
||||
// sharing a ~111 m coarse cell share that lookup.
|
||||
import { cachedGroundFloor, resolveGroundFloorCells } from './groundFloor.js';
|
||||
|
||||
/** @constant {number} Metres above the resolved floor for a fire anchor.
|
||||
* The DEM is bare earth while the fire glow represents a 375 m VIIRS pixel;
|
||||
* a few metres of lift biases "slightly above the visible surface" (owner
|
||||
* principle) without a visible hover on the screen-sized sprite. */
|
||||
export const FIRE_ANCHOR_LIFT_M = 5;
|
||||
|
||||
/**
|
||||
* Synchronous anchor height for one detection: shared ground floor + lift
|
||||
* when the floor is warm, else 0 (the pre-fix ellipsoid anchor — dots render
|
||||
* exactly as before until their floor lands). Warm-cache read only; never
|
||||
* triggers network or sampling.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number} Ellipsoidal anchor height in metres.
|
||||
*/
|
||||
export function fireAnchorHeight(lat, lon) {
|
||||
const floor = cachedGroundFloor(lat, lon);
|
||||
return floor != null ? floor + FIRE_ANCHOR_LIFT_M : 0;
|
||||
}
|
||||
|
||||
/** @type {?Promise<boolean>} Tail of the batch chain — batches run strictly
|
||||
* sequentially so overlapping renders can't stack concurrent requests on
|
||||
* the single dev-server proxy (same courtesy as terrainHeights' sequential
|
||||
* chunking). Each queued batch re-filters against the warm cache when it
|
||||
* actually runs, so cells resolved by an earlier batch are never refetched. */
|
||||
let _chain = null;
|
||||
|
||||
/**
|
||||
* Batched warm of the ground-floor cells behind a rendered detection set.
|
||||
* Fire-and-forget safe (never rejects). Resolves `true` only when at least
|
||||
* one requested point actually gained a warm floor — a failed resolve (proxy
|
||||
* down caches only geoid fallbacks, which do NOT count as a floor) resolves
|
||||
* `false`, so a render → warm → re-render chain terminates instead of
|
||||
* looping; the next natural rebuild retries and the cache self-heals.
|
||||
* @param {Array<{lat: number, lon: number}>} points - Rendered detections.
|
||||
* @returns {Promise<boolean>} Whether any requested point warmed.
|
||||
*/
|
||||
export function warmFireAnchorFloors(points) {
|
||||
const cold = collectCold(points);
|
||||
if (!cold.length) return Promise.resolve(false);
|
||||
const prev = _chain;
|
||||
const run = prev ? prev.then(() => resolveBatch(cold)) : resolveBatch(cold);
|
||||
_chain = run.then(() => true, () => false);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** @returns {Array<{lat: number, lon: number}>} Points with no warm floor. */
|
||||
function collectCold(points) {
|
||||
if (!Array.isArray(points)) return [];
|
||||
const cold = [];
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
if (cachedGroundFloor(p.lat, p.lon) != null) continue;
|
||||
cold.push({ lat: p.lat, lon: p.lon });
|
||||
}
|
||||
return cold;
|
||||
}
|
||||
|
||||
/** Resolves one batch (re-filtered at run time) and reports whether it warmed anything. */
|
||||
async function resolveBatch(points) {
|
||||
const cold = points.filter((p) => cachedGroundFloor(p.lat, p.lon) == null);
|
||||
if (!cold.length) return false;
|
||||
try {
|
||||
await resolveGroundFloorCells(cold);
|
||||
} catch { /* resolver is best-effort and never throws; belt and braces */ }
|
||||
return cold.some((p) => cachedGroundFloor(p.lat, p.lon) != null);
|
||||
}
|
||||
|
||||
/** Test hook: drops the batch chain (module caches live in groundFloor/terrainHeights). */
|
||||
export function _resetFireAnchorsForTest() {
|
||||
_chain = null;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/data/fireAnchors.test.mjs — DEM ground anchors for rendered FIRMS
|
||||
// detections (field finding 2026-07-21: close-zoom fire dots read as
|
||||
// buried under high terrain because anchors sat at ellipsoid height 0).
|
||||
//
|
||||
// Locks the module's two jobs:
|
||||
// fireAnchorHeight(lat, lon) — synchronous warm-cache read: shared
|
||||
// ground floor + lift, or 0 when the floor isn't warm (the pre-fix anchor).
|
||||
// warmFireAnchorFloors(points) — batched, sequential (never concurrent)
|
||||
// resolve of the cold floor cells behind a rendered detection set.
|
||||
// Resolves true only when at least one requested point actually gained a
|
||||
// warm floor — a failed resolve reports false so render → warm → re-render
|
||||
// chains terminate instead of looping against a down proxy.
|
||||
//
|
||||
// `fetch` is injected via `globalThis.fetch` (no real network), same pattern
|
||||
// as terrainHeights.test.mjs. The terrainHeights/groundFloor module caches
|
||||
// persist across tests in this file, so each test uses distinct coordinates.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
FIRE_ANCHOR_LIFT_M,
|
||||
fireAnchorHeight,
|
||||
warmFireAnchorFloors,
|
||||
_resetFireAnchorsForTest,
|
||||
} from './fireAnchors.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
|
||||
/** Installs a fake fetch for the duration of `fn`, restoring the original after. */
|
||||
async function withFakeFetch(fakeFetch, fn) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = fakeFetch;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the `points=lon,lat;…` query of a /api/terrain/heights request URL. */
|
||||
function parsePoints(url) {
|
||||
const raw = decodeURIComponent(String(url).split('points=')[1] || '');
|
||||
return raw.split(';').filter(Boolean).map((pair) => {
|
||||
const [lon, lat] = pair.split(',').map(Number);
|
||||
return { lon, lat };
|
||||
});
|
||||
}
|
||||
|
||||
/** Fake proxy answering ellipsoid = lon + lat per requested point. */
|
||||
function echoFetch(log) {
|
||||
return async (url) => {
|
||||
const points = parsePoints(url);
|
||||
log.push(points);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
results: points.map(({ lon, lat }) => ({ lon, lat, elevation: 0, geoid: 0, ellipsoid: lon + lat })),
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
test('fireAnchorHeight: unknown floor anchors at 0 (pre-fix behavior preserved)', () => {
|
||||
_resetFireAnchorsForTest();
|
||||
assert.equal(fireAnchorHeight(10.001, 10.001), 0);
|
||||
});
|
||||
|
||||
test('fireAnchorHeight: warm shared ground floor anchors at floor + lift', () => {
|
||||
_resetFireAnchorsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
// Seed via the mesh-cell path — proves fires read the SAME choke point
|
||||
// (cachedGroundFloor) every other ground-adjacent consumer uses.
|
||||
reportMeshFloorCell(51.301, -122.401, 1400);
|
||||
assert.equal(fireAnchorHeight(51.3012, -122.4008), 1400 + FIRE_ANCHOR_LIFT_M);
|
||||
});
|
||||
|
||||
test('warmFireAnchorFloors: one batched request, deduped to coarse cells', async () => {
|
||||
_resetFireAnchorsForTest();
|
||||
const log = [];
|
||||
await withFakeFetch(echoFetch(log), async () => {
|
||||
const warmed = await warmFireAnchorFloors([
|
||||
{ lat: 20.0001, lon: 30.0001 }, // same ~111 m cell as the next point
|
||||
{ lat: 20.0004, lon: 30.0004 },
|
||||
{ lat: 20.101, lon: 30.101 }, // distinct cell
|
||||
]);
|
||||
assert.equal(warmed, true);
|
||||
assert.equal(log.length, 1, 'one network request for the whole batch');
|
||||
assert.equal(log[0].length, 2, 'two unique coarse cells, not three points');
|
||||
});
|
||||
// The warm floor is immediately readable at full precision…
|
||||
assert.equal(fireAnchorHeight(20.0001, 30.0001), 50 + FIRE_ANCHOR_LIFT_M);
|
||||
// …and for every other detection sharing the cell.
|
||||
assert.equal(fireAnchorHeight(20.0004, 30.0004), 50 + FIRE_ANCHOR_LIFT_M);
|
||||
});
|
||||
|
||||
test('warmFireAnchorFloors: warm cells never refetch (one lookup per fire ever)', async () => {
|
||||
_resetFireAnchorsForTest();
|
||||
const log = [];
|
||||
await withFakeFetch(echoFetch(log), async () => {
|
||||
await warmFireAnchorFloors([{ lat: 21.001, lon: 31.001 }]);
|
||||
assert.equal(log.length, 1);
|
||||
const warmedAgain = await warmFireAnchorFloors([
|
||||
{ lat: 21.001, lon: 31.001 },
|
||||
{ lat: 21.0012, lon: 31.0011 }, // same cell, different detection
|
||||
]);
|
||||
assert.equal(warmedAgain, false, 'nothing was cold, so nothing warmed');
|
||||
assert.equal(log.length, 1, 'no second network request');
|
||||
});
|
||||
});
|
||||
|
||||
test('warmFireAnchorFloors: overlapping calls run sequentially, never concurrently', async () => {
|
||||
_resetFireAnchorsForTest();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
const log = [];
|
||||
await withFakeFetch(async (url) => {
|
||||
concurrent += 1;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
const points = parsePoints(url);
|
||||
log.push(points);
|
||||
await gate;
|
||||
concurrent -= 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
results: points.map(({ lon, lat }) => ({ lon, lat, elevation: 0, geoid: 0, ellipsoid: lon + lat })),
|
||||
}),
|
||||
};
|
||||
}, async () => {
|
||||
const first = warmFireAnchorFloors([{ lat: 40.001, lon: 50.001 }]);
|
||||
// Second call arrives while the first is in flight: overlaps the first
|
||||
// cell and adds one new one.
|
||||
const second = warmFireAnchorFloors([
|
||||
{ lat: 40.001, lon: 50.001 },
|
||||
{ lat: 41.201, lon: 51.201 },
|
||||
]);
|
||||
release();
|
||||
const [warmedFirst, warmedSecond] = await Promise.all([first, second]);
|
||||
assert.equal(warmedFirst, true);
|
||||
assert.equal(warmedSecond, true, 'the new cell warmed in the follow-up batch');
|
||||
assert.equal(maxConcurrent, 1, 'batches never overlap on the wire');
|
||||
assert.equal(log.length, 2);
|
||||
assert.equal(log[1].length, 1, 'follow-up batch re-filters: only the still-cold cell goes out');
|
||||
});
|
||||
});
|
||||
|
||||
test('warmFireAnchorFloors: proxy failure reports false (re-render chain terminates)', async () => {
|
||||
_resetFireAnchorsForTest();
|
||||
let calls = 0;
|
||||
await withFakeFetch(async () => {
|
||||
calls += 1;
|
||||
throw new Error('proxy down');
|
||||
}, async () => {
|
||||
const warmed = await warmFireAnchorFloors([{ lat: 22.501, lon: 32.501 }]);
|
||||
assert.equal(warmed, false, 'geoid fallback is NOT a warm floor for anchoring');
|
||||
assert.ok(calls >= 1);
|
||||
});
|
||||
assert.equal(fireAnchorHeight(22.501, 32.501), 0, 'anchor stays at 0 until a real floor lands');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Adapter from /api/firms proxy records (src/data/firmsCsv.js shape) to the
|
||||
* internal fire-record shape rendered by firmsHeatmap.js. Pure — no Cesium,
|
||||
* so the mapping is unit-testable. The normalization helpers previously
|
||||
* lived module-private in firmsHeatmap.js and moved here with the live-data
|
||||
* swap (2026-07-16).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map proxy fire records into internal fire records. Records with
|
||||
* non-finite coordinates are skipped; `index` is the post-skip position
|
||||
* (it keys pick ids and context-store ids, so it must stay sequential).
|
||||
* `contextEntity`/`position` start null and are lazily filled by the layer.
|
||||
* @param {?Array<Object>} records - /api/firms `fires` array.
|
||||
* @returns {Array<Object>} Internal fire records.
|
||||
*/
|
||||
export function adaptFirmsRecords(records) {
|
||||
const fires = [];
|
||||
if (!Array.isArray(records)) return fires;
|
||||
const acqCache = new Map();
|
||||
for (const record of records) {
|
||||
const lat = Number(record?.lat);
|
||||
const lon = Number(record?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
fires.push({
|
||||
index: fires.length,
|
||||
lat,
|
||||
lon,
|
||||
frp: finiteNumber(record.frp),
|
||||
confidence: normalizeConfidence(record.confidence),
|
||||
brightness: finiteNumber(record.brightness),
|
||||
night: record.daynight === 'N',
|
||||
acqMs: parseAcquisitionMs(record.acqDate, record.acqTime, acqCache),
|
||||
sensor: normalizeSensor(record.instrument),
|
||||
satellite: typeof record.satellite === 'string' ? record.satellite : '',
|
||||
contextEntity: null,
|
||||
position: null,
|
||||
});
|
||||
}
|
||||
return fires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize FIRMS confidence to 0..1. VIIRS reports the categorical strings
|
||||
* low/nominal/high (`l`/`n`/`h`); MODIS reports 0..100 numeric.
|
||||
* @param {*} value - Raw confidence property.
|
||||
* @returns {number} Confidence in 0..1.
|
||||
*/
|
||||
export function normalizeConfidence(value) {
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim().toLowerCase();
|
||||
if (text === 'low' || text === 'l') return 0.3;
|
||||
if (text === 'nominal' || text === 'n') return 0.6;
|
||||
if (text === 'high' || text === 'h') return 0.9;
|
||||
const numeric = Number(text);
|
||||
return Number.isFinite(numeric) ? clamp01(numeric / 100) : 0;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? clamp01(numeric / 100) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse acq_date ("YYYY-MM-DD") + acq_time (unpadded "HHMM", UTC) into epoch
|
||||
* ms. Memoized per unique date:time pair — granule timestamps repeat heavily.
|
||||
* @param {*} date - acq_date property.
|
||||
* @param {*} time - acq_time property.
|
||||
* @param {Map<string, number>} [cache] - Per-batch memo cache.
|
||||
* @returns {number} Epoch milliseconds, or 0 when unparseable.
|
||||
*/
|
||||
export function parseAcquisitionMs(date, time, cache = new Map()) {
|
||||
if (typeof date !== 'string' || date.length < 10) return 0;
|
||||
const key = `${date}:${time ?? ''}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const year = Number(date.slice(0, 4));
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
|
||||
&& Number.isFinite(hours) && Number.isFinite(minutes);
|
||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
||||
cache.set(key, ms);
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map sensor/instrument strings to a short display sensor name.
|
||||
* @param {*} value - Raw instrument property.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeSensor(value) {
|
||||
const text = String(value || '').toUpperCase();
|
||||
if (text.includes('VIIRS')) return 'VIIRS';
|
||||
if (text.includes('MODIS')) return 'MODIS';
|
||||
return text ? text.slice(0, 12) : '';
|
||||
}
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function finiteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// src/data/firmsAdapt.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
adaptFirmsRecords,
|
||||
normalizeConfidence,
|
||||
normalizeSensor,
|
||||
parseAcquisitionMs,
|
||||
} from './firmsAdapt.js';
|
||||
|
||||
/** A proxy record as served by /api/firms (firmsCsv.js parser shape). */
|
||||
const proxyRecord = (overrides = {}) => ({
|
||||
lat: 38.99488,
|
||||
lon: -121.67046,
|
||||
frp: 0.53,
|
||||
confidence: 'n',
|
||||
brightness: 303.6,
|
||||
brightnessTi5: 290.73,
|
||||
daynight: 'N',
|
||||
acqDate: '2026-07-16',
|
||||
acqTime: '1006',
|
||||
satellite: 'N20',
|
||||
instrument: 'VIIRS',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('adapter maps a proxy record into the internal fire-record shape', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord()]);
|
||||
assert.equal(fire.index, 0);
|
||||
assert.equal(fire.lat, 38.99488);
|
||||
assert.equal(fire.lon, -121.67046);
|
||||
assert.equal(fire.frp, 0.53);
|
||||
assert.equal(fire.confidence, 0.6); // 'n' (nominal) → 0.6
|
||||
assert.equal(fire.brightness, 303.6);
|
||||
assert.equal(fire.night, true); // daynight 'N'
|
||||
assert.equal(fire.acqMs, Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(fire.sensor, 'VIIRS');
|
||||
assert.equal(fire.satellite, 'N20');
|
||||
assert.equal(fire.contextEntity, null);
|
||||
assert.equal(fire.position, null);
|
||||
});
|
||||
|
||||
test('categorical confidence → 0..1 (l/n/h and word forms)', () => {
|
||||
assert.equal(normalizeConfidence('l'), 0.3);
|
||||
assert.equal(normalizeConfidence('n'), 0.6);
|
||||
assert.equal(normalizeConfidence('h'), 0.9);
|
||||
assert.equal(normalizeConfidence('low'), 0.3);
|
||||
assert.equal(normalizeConfidence('nominal'), 0.6);
|
||||
assert.equal(normalizeConfidence('high'), 0.9);
|
||||
});
|
||||
|
||||
test('numeric confidence (MODIS-style) → value/100, clamped', () => {
|
||||
assert.equal(normalizeConfidence(85), 0.85);
|
||||
assert.equal(normalizeConfidence('85'), 0.85);
|
||||
assert.equal(normalizeConfidence(120), 1);
|
||||
assert.equal(normalizeConfidence('garbage'), 0);
|
||||
});
|
||||
|
||||
test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ acqTime: '45' })]);
|
||||
assert.equal(fire.acqMs, Date.UTC(2026, 6, 16, 0, 45));
|
||||
});
|
||||
|
||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||
const cache = new Map();
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||
});
|
||||
|
||||
test('daynight "D" → night false', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||
assert.equal(fire.night, false);
|
||||
});
|
||||
|
||||
test('sensor normalization: VIIRS/MODIS detected, junk truncated', () => {
|
||||
assert.equal(normalizeSensor('VIIRS'), 'VIIRS');
|
||||
assert.equal(normalizeSensor('some_modis_file'), 'MODIS');
|
||||
assert.equal(normalizeSensor(''), '');
|
||||
assert.equal(normalizeSensor('ABCDEFGHIJKLMNOP'), 'ABCDEFGHIJKL'); // 12-char cap
|
||||
});
|
||||
|
||||
test('records with non-finite lat/lon are skipped; index stays sequential', () => {
|
||||
const fires = adaptFirmsRecords([
|
||||
proxyRecord(),
|
||||
proxyRecord({ lat: 'nope' }),
|
||||
proxyRecord({ lon: Infinity }),
|
||||
proxyRecord({ lat: 40.1 }),
|
||||
]);
|
||||
assert.equal(fires.length, 2);
|
||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
||||
});
|
||||
|
||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
||||
assert.equal(fire.frp, 0);
|
||||
assert.equal(fire.brightness, 0);
|
||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||
assert.deepEqual(adaptFirmsRecords(null), []);
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
applyFirmsOverlayPolicy,
|
||||
buildFireCard,
|
||||
buildCellCard,
|
||||
buildSelectedFireCard,
|
||||
createFirmsHeatmapLayer,
|
||||
} from './firmsHeatmap.js';
|
||||
import {
|
||||
accentForSeverity,
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
} from './firmsLabels.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||
const H = 3600000;
|
||||
|
||||
/** Minimal fire record in the layer's internal shape (post-adapt). */
|
||||
function fire(overrides = {}) {
|
||||
return {
|
||||
index: 7,
|
||||
lat: 61.91435,
|
||||
lon: -122.94429,
|
||||
frp: 1520.27,
|
||||
confidence: 0.9,
|
||||
brightness: 340,
|
||||
night: false,
|
||||
acqMs: NOW - 2 * H,
|
||||
sensor: 'VIIRS',
|
||||
satellite: 'N20',
|
||||
contextEntity: null,
|
||||
position: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () => {
|
||||
const card = buildFireCard({ fire: fire(), position: { x: 1, y: 2, z: 3 } }, NOW);
|
||||
assert.equal(card.title, '▲ 1520 MW');
|
||||
assert.equal(card.details.length, 1);
|
||||
assert.equal(card.details[0], 'high · 2h · N20');
|
||||
assert.equal(card.selected, false);
|
||||
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
|
||||
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
|
||||
});
|
||||
|
||||
test('buildFireCard: missing acquisition time omits the age segment', () => {
|
||||
const card = buildFireCard({ fire: fire({ acqMs: 0 }), position: {} }, NOW);
|
||||
assert.equal(card.details[0], 'high · N20');
|
||||
});
|
||||
|
||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
|
||||
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
|
||||
assert.match(card.details[0], /SNPP$/);
|
||||
assert.equal(card.title, '▲ 0.8 MW');
|
||||
assert.notEqual(card.accent, accentForSeverity('red'));
|
||||
});
|
||||
|
||||
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
|
||||
const card = buildSelectedFireCard(fire(), NOW);
|
||||
assert.equal(card.title, 'FIRE · 1520 MW');
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.details[0], 'high conf · 2h ago · VIIRS N20');
|
||||
assert.equal(card.details[1], '61.914°N 122.944°W');
|
||||
});
|
||||
|
||||
test('buildSelectedFireCard: night detections are tagged', () => {
|
||||
const card = buildSelectedFireCard(fire({ night: true }), NOW);
|
||||
assert.match(card.details[1], / · NIGHT$/);
|
||||
});
|
||||
|
||||
test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', () => {
|
||||
const candidate = {
|
||||
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
accent: accentForSeverity('orange'),
|
||||
};
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '14 FIRES');
|
||||
assert.equal(card.details[0], 'max 210 MW · new 3h');
|
||||
assert.equal(card.accent, accentForSeverity('orange'));
|
||||
});
|
||||
|
||||
// Field finding 2026-07-21: anchors must sit on the DEM once the shared
|
||||
// ground floor is warm — and the cached per-fire position must re-anchor when
|
||||
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
|
||||
// from every other test in this file (module caches persist across tests).
|
||||
test('fire anchor: cold floor renders at 0, then re-grounds when the floor warms', () => {
|
||||
const f = fire({ lat: 55.501, lon: -120.501 });
|
||||
const before = buildSelectedFireCard(f, NOW);
|
||||
const cartoBefore = Cesium.Cartographic.fromCartesian(before.position);
|
||||
assert.ok(Math.abs(cartoBefore.height) < 0.5, `cold floor anchors at ellipsoid 0, got ${cartoBefore.height}`);
|
||||
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(55.501, -120.501, 900);
|
||||
const after = buildSelectedFireCard(f, NOW);
|
||||
const cartoAfter = Cesium.Cartographic.fromCartesian(after.position);
|
||||
assert.ok(
|
||||
Math.abs(cartoAfter.height - (900 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||
`warm floor re-anchors at floor + lift, got ${cartoAfter.height}`
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCellCard: singular noun and missing-age omission', () => {
|
||||
const candidate = { cell: { count: 1, maxFrp: 9.9, newestAcqMs: 0 }, position: {}, accent: undefined };
|
||||
const card = buildCellCard(candidate, NOW);
|
||||
assert.equal(card.title, '1 FIRE');
|
||||
assert.equal(card.details[0], 'max 9.9 MW');
|
||||
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
|
||||
});
|
||||
|
||||
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
|
||||
const ambient = applyFirmsOverlayPolicy(buildFireCard({
|
||||
fire: fire(),
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
}, NOW), 4_500_000);
|
||||
assert.equal(ambient.variant, 'card');
|
||||
assert.equal(ambient.protected, false);
|
||||
assert.equal(ambient.collisionGroup, 'ambient-card');
|
||||
assert.equal(ambient.maxDistance, 4_500_000);
|
||||
assert.equal(ambient.distanceFadeStartRatio, 0.7);
|
||||
assert.equal(ambient.edgeFade, 'keyhole');
|
||||
assert.equal(ambient.cardStyle, 'tactical');
|
||||
assert.equal(ambient.verticalOnly, true);
|
||||
assert.equal(ambient.gapPx, Math.max(12, buildFireCard({ fire: fire(), position: {} }, NOW).gapPx + 8));
|
||||
assert.equal(ambient.leaderOffsetPx, ambient.gapPx - 6);
|
||||
|
||||
const selected = applyFirmsOverlayPolicy(buildSelectedFireCard(fire(), NOW), 4_500_000);
|
||||
assert.equal(selected.variant, 'selected');
|
||||
assert.equal(selected.protected, true);
|
||||
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
class MockEvent {
|
||||
constructor() { this.listeners = new Set(); }
|
||||
|
||||
addEventListener(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
test('real FIRMS lifecycle clears host entries on disable and destroy', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
const calls = [];
|
||||
const preRender = new MockEvent();
|
||||
const moveEnd = new MockEvent();
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'no_key' }),
|
||||
});
|
||||
globalThis.window = new EventTarget();
|
||||
const dataSources = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(value) { dataSources.push(value); return value; },
|
||||
remove(value) {
|
||||
const index = dataSources.indexOf(value);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
moveEnd,
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
preRender,
|
||||
primitives: { contains() { return false; } },
|
||||
},
|
||||
};
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'firms',
|
||||
name: 'FIRMS',
|
||||
overlayHost: {
|
||||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
|
||||
});
|
||||
try {
|
||||
layer.init(viewer);
|
||||
await layer.enable(viewer);
|
||||
assert.deepEqual(calls.at(-1), ['visible', FIRMS_OVERLAY_SOURCE_ID, true]);
|
||||
layer.disable();
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
||||
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
await layer.enable(viewer);
|
||||
layer.destroy(viewer);
|
||||
assert.ok(calls.some(([type]) => type === 'clear'));
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
||||
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
const callCount = calls.length;
|
||||
await layer.enable(viewer);
|
||||
assert.equal(calls.length, callCount, 'destroyed FIRMS layers reject late enable work');
|
||||
assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* NASA FIRMS area-CSV parsing — pure functions, no Cesium/DOM/network.
|
||||
*
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/{days}
|
||||
* VIIRS header (confirmed live 2026-07-16):
|
||||
* latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,
|
||||
* instrument,confidence,version,bright_ti5,frp,daynight
|
||||
*
|
||||
* Quirks this module owns:
|
||||
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw.
|
||||
* - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records;
|
||||
* {@link acquisitionMsUtc} does the padding.
|
||||
* - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"),
|
||||
* never CSV — {@link isLikelyCsv} gates parsing.
|
||||
* - `days=1` means "current UTC day" (nearly empty just after 00:00 UTC), so
|
||||
* callers fetch days=2 and clamp with {@link filterTrailing24h}.
|
||||
*/
|
||||
|
||||
/** Header fields that must all be present for a payload to count as FIRMS CSV. */
|
||||
const REQUIRED_HEADER_FIELDS = ['latitude', 'longitude', 'acq_date', 'acq_time', 'confidence', 'frp'];
|
||||
|
||||
const HOUR_MS = 3600_000;
|
||||
/** Trailing window size for {@link filterTrailing24h}. */
|
||||
const WINDOW_MS = 24 * HOUR_MS;
|
||||
/** Forward slack for {@link filterTrailing24h} (upstream/local clock skew). */
|
||||
const FORWARD_SLACK_MS = 2 * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Cheap "is this actually FIRMS CSV?" check. HTML error pages (first
|
||||
* non-whitespace char `<`) and text errors like "Invalid MAP_KEY" fail it;
|
||||
* any payload whose first line carries the expected header fields passes.
|
||||
* @param {string} text - Raw upstream response body.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLikelyCsv(text) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed || trimmed[0] === '<') return false;
|
||||
const headerLine = trimmed.slice(0, trimmed.indexOf('\n') === -1 ? undefined : trimmed.indexOf('\n'))
|
||||
.trim().toLowerCase();
|
||||
const fields = headerLine.split(',').map((f) => f.trim());
|
||||
return REQUIRED_HEADER_FIELDS.every((required) => fields.includes(required));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a FIRMS area CSV payload into flat detection records.
|
||||
*
|
||||
* Tolerates CRLF, trailing newlines, and malformed rows (skipped). A
|
||||
* header-only payload parses to `[]`. Non-CSV input (HTML/error text — see
|
||||
* {@link isLikelyCsv}) returns `null` so callers can distinguish "no fires"
|
||||
* from "upstream failure".
|
||||
*
|
||||
* @param {string} text - Raw CSV payload.
|
||||
* @returns {?Array<{lat: number, lon: number, frp: number, confidence: string|number,
|
||||
* brightness: number, brightnessTi5: number, daynight: string, acqDate: string,
|
||||
* acqTime: string, satellite: string, instrument: string}>} Records, or null for non-CSV.
|
||||
*/
|
||||
export function parseFirmsCsv(text) {
|
||||
if (!isLikelyCsv(text)) return null;
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Locate the header (first non-empty line) and build a column index so the
|
||||
// parser survives column reordering across FIRMS product versions.
|
||||
let headerIndex = 0;
|
||||
while (headerIndex < lines.length && !lines[headerIndex].trim()) headerIndex += 1;
|
||||
const header = lines[headerIndex].trim().toLowerCase().split(',').map((f) => f.trim());
|
||||
const col = new Map(header.map((name, i) => [name, i]));
|
||||
const iLat = col.get('latitude');
|
||||
const iLon = col.get('longitude');
|
||||
const iFrp = col.get('frp');
|
||||
const iConfidence = col.get('confidence');
|
||||
const iBrightness = col.get('bright_ti4') ?? col.get('brightness');
|
||||
const iBrightnessTi5 = col.get('bright_ti5') ?? col.get('bright_t31');
|
||||
const iDaynight = col.get('daynight');
|
||||
const iAcqDate = col.get('acq_date');
|
||||
const iAcqTime = col.get('acq_time');
|
||||
const iSatellite = col.get('satellite');
|
||||
const iInstrument = col.get('instrument');
|
||||
|
||||
const records = [];
|
||||
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(',');
|
||||
if (parts.length < header.length) continue; // malformed row — skip
|
||||
const lat = Number(parts[iLat]);
|
||||
const lon = Number(parts[iLon]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
records.push({
|
||||
lat,
|
||||
lon,
|
||||
frp: finiteOrZero(parts[iFrp]),
|
||||
// Categorical (l/n/h) or numeric — passed through raw; display
|
||||
// normalization happens client-side (normalizeConfidence).
|
||||
confidence: cell(parts, iConfidence),
|
||||
brightness: finiteOrZero(parts[iBrightness]),
|
||||
brightnessTi5: finiteOrZero(parts[iBrightnessTi5]),
|
||||
daynight: cell(parts, iDaynight),
|
||||
acqDate: cell(parts, iAcqDate),
|
||||
acqTime: cell(parts, iAcqTime), // NOT zero-padded — kept verbatim
|
||||
satellite: cell(parts, iSatellite),
|
||||
instrument: cell(parts, iInstrument),
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert FIRMS acq_date ("YYYY-MM-DD") + acq_time (unpadded "HHMM", UTC)
|
||||
* into epoch milliseconds. "1006" → 10:06Z; "45" → 00:45Z; "0" → 00:00Z.
|
||||
* @param {string} acqDate - Acquisition date.
|
||||
* @param {string|number} acqTime - Acquisition time, unpadded HHMM.
|
||||
* @returns {number} Epoch ms, or NaN when unparseable.
|
||||
*/
|
||||
export function acquisitionMsUtc(acqDate, acqTime) {
|
||||
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) return NaN;
|
||||
const timeText = String(acqTime ?? '').trim();
|
||||
if (!/^\d{1,4}$/.test(timeText)) return NaN;
|
||||
const hhmm = timeText.padStart(4, '0');
|
||||
const year = Number(acqDate.slice(0, 4));
|
||||
const month = Number(acqDate.slice(5, 7));
|
||||
const day = Number(acqDate.slice(8, 10));
|
||||
const hours = Number(hhmm.slice(0, 2));
|
||||
const minutes = Number(hhmm.slice(2, 4));
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || hours > 23 || minutes > 59) return NaN;
|
||||
return Date.UTC(year, month - 1, day, hours, minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only records acquired within `[nowMs − 24 h, nowMs + 2 h]` (inclusive).
|
||||
* The 2 h forward slack absorbs upstream/local clock skew; records with an
|
||||
* unparseable acquisition time are dropped. Timestamps are memoized per
|
||||
* unique date:time pair — granule timestamps repeat heavily.
|
||||
* @param {Array<{acqDate: string, acqTime: string|number}>} records - Parser records.
|
||||
* @param {number} nowMs - Reference epoch milliseconds.
|
||||
* @returns {Array<Object>} Filtered records (original objects, order preserved).
|
||||
*/
|
||||
export function filterTrailing24h(records, nowMs) {
|
||||
if (!Array.isArray(records) || !Number.isFinite(nowMs)) return [];
|
||||
const oldest = nowMs - WINDOW_MS;
|
||||
const newest = nowMs + FORWARD_SLACK_MS;
|
||||
const memo = new Map();
|
||||
return records.filter((record) => {
|
||||
const key = `${record.acqDate}:${record.acqTime}`;
|
||||
let ms = memo.get(key);
|
||||
if (ms === undefined) {
|
||||
ms = acquisitionMsUtc(record.acqDate, record.acqTime);
|
||||
memo.set(key, ms);
|
||||
}
|
||||
return Number.isFinite(ms) && ms >= oldest && ms <= newest;
|
||||
});
|
||||
}
|
||||
|
||||
/** Trimmed string cell at index, '' for missing columns. */
|
||||
function cell(parts, index) {
|
||||
if (index === undefined || parts[index] === undefined) return '';
|
||||
return parts[index].trim();
|
||||
}
|
||||
|
||||
/** Numeric cell → finite number, else 0. */
|
||||
function finiteOrZero(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// src/data/firmsCsv.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
acquisitionMsUtc,
|
||||
filterTrailing24h,
|
||||
isLikelyCsv,
|
||||
parseFirmsCsv,
|
||||
} from './firmsCsv.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = fs.readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'firms-viirs-noaa20-sample.csv'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const HEADER = 'latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_ti5,frp,daynight';
|
||||
|
||||
test('fixture parses to 45 records with finite lat/lon/frp', () => {
|
||||
const records = parseFirmsCsv(FIXTURE);
|
||||
assert.ok(Array.isArray(records));
|
||||
assert.equal(records.length, 45);
|
||||
for (const record of records) {
|
||||
assert.ok(Number.isFinite(record.lat), `lat finite: ${record.lat}`);
|
||||
assert.ok(Number.isFinite(record.lon), `lon finite: ${record.lon}`);
|
||||
assert.ok(Number.isFinite(record.frp), `frp finite: ${record.frp}`);
|
||||
assert.ok(record.lat >= -90 && record.lat <= 90);
|
||||
assert.ok(record.lon >= -180 && record.lon <= 180);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture first row maps every field, categorical confidence preserved raw', () => {
|
||||
const first = parseFirmsCsv(FIXTURE)[0];
|
||||
assert.equal(first.lat, 38.99488);
|
||||
assert.equal(first.lon, -121.67046);
|
||||
assert.equal(first.brightness, 303.6); // bright_ti4
|
||||
assert.equal(first.brightnessTi5, 290.73); // bright_ti5
|
||||
assert.equal(first.frp, 0.53);
|
||||
assert.equal(first.confidence, 'n'); // categorical, passed through untouched
|
||||
assert.equal(first.daynight, 'N');
|
||||
assert.equal(first.acqDate, '2026-07-16');
|
||||
assert.equal(first.acqTime, '1006'); // string, as-is
|
||||
assert.equal(first.satellite, 'N20');
|
||||
assert.equal(first.instrument, 'VIIRS');
|
||||
});
|
||||
|
||||
test('acquisitionMsUtc: "1006" = 10:06 UTC', () => {
|
||||
assert.equal(acquisitionMsUtc('2026-07-16', '1006'), Date.UTC(2026, 6, 16, 10, 6));
|
||||
});
|
||||
|
||||
test('acquisitionMsUtc: non-zero-padded "45" = 00:45 UTC', () => {
|
||||
assert.equal(acquisitionMsUtc('2026-07-16', '45'), Date.UTC(2026, 6, 16, 0, 45));
|
||||
});
|
||||
|
||||
test('acquisitionMsUtc: "0" = midnight UTC; garbage → NaN', () => {
|
||||
assert.equal(acquisitionMsUtc('2026-07-16', '0'), Date.UTC(2026, 6, 16, 0, 0));
|
||||
assert.ok(Number.isNaN(acquisitionMsUtc('not-a-date', '1006')));
|
||||
assert.ok(Number.isNaN(acquisitionMsUtc('2026-07-16', 'xx')));
|
||||
});
|
||||
|
||||
test('header-only input → []', () => {
|
||||
assert.deepEqual(parseFirmsCsv(`${HEADER}\n`), []);
|
||||
assert.deepEqual(parseFirmsCsv(HEADER), []);
|
||||
});
|
||||
|
||||
test('CRLF line endings and trailing newline tolerated', () => {
|
||||
const text = `${HEADER}\r\n38.99488,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N\r\n\r\n`;
|
||||
const records = parseFirmsCsv(text);
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].lat, 38.99488);
|
||||
assert.equal(records[0].daynight, 'N'); // no trailing \r leaking into the last field
|
||||
});
|
||||
|
||||
test('malformed rows are skipped, valid rows kept', () => {
|
||||
const text = [
|
||||
HEADER,
|
||||
'not,enough,fields',
|
||||
'garbage-line-with-no-commas',
|
||||
'NaN,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N',
|
||||
'38.99488,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N',
|
||||
].join('\n');
|
||||
const records = parseFirmsCsv(text);
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].lat, 38.99488);
|
||||
});
|
||||
|
||||
test('HTML error page detected as not-CSV', () => {
|
||||
const html = '\n <html><body><h1>Service unavailable</h1></body></html>';
|
||||
assert.equal(isLikelyCsv(html), false);
|
||||
assert.equal(parseFirmsCsv(html), null);
|
||||
});
|
||||
|
||||
test('plain-text upstream error detected as not-CSV', () => {
|
||||
const text = 'Invalid MAP_KEY.';
|
||||
assert.equal(isLikelyCsv(text), false);
|
||||
assert.equal(parseFirmsCsv(text), null);
|
||||
});
|
||||
|
||||
test('isLikelyCsv accepts the real fixture', () => {
|
||||
assert.equal(isLikelyCsv(FIXTURE), true);
|
||||
});
|
||||
|
||||
test('filterTrailing24h: window is [now − 24 h, now + 2 h] inclusive', () => {
|
||||
const now = Date.UTC(2026, 6, 16, 12, 0); // 2026-07-16 12:00Z
|
||||
const rec = (acqDate, acqTime) => ({ acqDate, acqTime });
|
||||
const records = [
|
||||
rec('2026-07-15', '1159'), // 24h + 1min old → out
|
||||
rec('2026-07-15', '1200'), // exactly 24h old → in (inclusive)
|
||||
rec('2026-07-15', '1201'), // 23h59m old → in
|
||||
rec('2026-07-16', '1130'), // recent → in
|
||||
rec('2026-07-16', '1400'), // now + 2h (slack boundary) → in
|
||||
rec('2026-07-16', '1401'), // beyond forward slack → out
|
||||
rec('bad-date', '1006'), // unparseable → out
|
||||
];
|
||||
const kept = filterTrailing24h(records, now);
|
||||
assert.deepEqual(
|
||||
kept.map((r) => `${r.acqDate} ${r.acqTime}`),
|
||||
['2026-07-15 1200', '2026-07-15 1201', '2026-07-16 1130', '2026-07-16 1400']
|
||||
);
|
||||
});
|
||||
|
||||
test('filterTrailing24h on the fixture keeps everything for a same-night now', () => {
|
||||
const records = parseFirmsCsv(FIXTURE);
|
||||
// Fixture is a days=2 pull; all rows are 2026-07-16 (10:06Z–21:27Z), so at
|
||||
// 2026-07-17 02:00Z every detection is 4.5–16 h old — all inside 24 h.
|
||||
const kept = filterTrailing24h(records, Date.UTC(2026, 6, 17, 2, 0));
|
||||
assert.equal(kept.length, records.length);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
||||
// src/data/firmsHeatmap.test.mjs
|
||||
// Focused tests for the pure analyst-record mapper (analyst query engine seam).
|
||||
// Pure function — no viewer/DOM needed; imported directly.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './firmsHeatmap.js';
|
||||
|
||||
const FULL_FIRE = {
|
||||
index: 7,
|
||||
lat: 30.51,
|
||||
lon: -98.21,
|
||||
frp: 1520.4,
|
||||
confidence: 0.9,
|
||||
satellite: 'N21',
|
||||
sensor: 'VIIRS',
|
||||
acqMs: 1_753_600_000_000,
|
||||
};
|
||||
|
||||
test('firms analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_FIRE);
|
||||
assert.deepEqual(r, {
|
||||
id: 'FIRE-00007',
|
||||
lat: 30.51,
|
||||
lon: -98.21,
|
||||
frp: 1520.4,
|
||||
confidence: 0.9,
|
||||
satellite: 'N21',
|
||||
acqTime: 1_753_600_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('firms analyst record: id matches the layer pick-id convention (5-digit pad)', () => {
|
||||
assert.equal(mapAnalystRecord({ ...FULL_FIRE, index: 0 }).id, 'FIRE-00000');
|
||||
assert.equal(mapAnalystRecord({ ...FULL_FIRE, index: 12345 }).id, 'FIRE-12345');
|
||||
});
|
||||
|
||||
test('firms analyst record: blank satellite falls back to sensor, then null', () => {
|
||||
assert.equal(mapAnalystRecord({ ...FULL_FIRE, satellite: '' }).satellite, 'VIIRS');
|
||||
assert.equal(mapAnalystRecord({ ...FULL_FIRE, satellite: '', sensor: '' }).satellite, null);
|
||||
});
|
||||
|
||||
test('firms analyst record: unparseable acq time (0 sentinel) becomes null', () => {
|
||||
assert.equal(mapAnalystRecord({ ...FULL_FIRE, acqMs: 0 }).acqTime, null);
|
||||
});
|
||||
|
||||
test('firms analyst record: empty record yields nulls, never NaN/undefined', () => {
|
||||
const r = mapAnalystRecord(undefined);
|
||||
assert.equal(r.id, 'FIRE-00000');
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
test('firms analyst record: output is JSON-safe (no Cesium types leak)', () => {
|
||||
const r = mapAnalystRecord({ ...FULL_FIRE, contextEntity: {}, position: { x: 1 } });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
|
||||
assert.equal('position' in r, false);
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
applyHorizonCull,
|
||||
createFirmsHeatmapLayer,
|
||||
fireCullPosition,
|
||||
} from './firmsHeatmap.js';
|
||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
|
||||
import {
|
||||
_clearMeshFloorCellsForTest,
|
||||
reportMeshFloorCell,
|
||||
setMeshFloorPreferred,
|
||||
} from './groundFloor.js';
|
||||
import { unregisterSpriteCollection } from './spriteOrder.js';
|
||||
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
|
||||
|
||||
const AUSTIN = { lon: -97.7, lat: 30.2 };
|
||||
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
|
||||
const ANTIPODE = { lon: 82.3, lat: -30.2 };
|
||||
const TOKYO = { lon: 139.7, lat: 35.7 };
|
||||
/** Reported repro altitude for the through-the-globe defect. */
|
||||
const REPRO_HEIGHT_M = 1_500_000;
|
||||
/**
|
||||
* Longitude offset east of Austin where the horizon test DIVERGES between a
|
||||
* sub-ellipsoid anchor and a lifted one: at 1.5 Mm a point here reads hidden
|
||||
* at −22 m but visible at +5/+12 m. Measured against Cesium's own occluder.
|
||||
*/
|
||||
const LIMB_DLON = 41.87;
|
||||
|
||||
/**
|
||||
* Billboard stub that records every write to `show` so the tests can assert
|
||||
* the pass does not dirty the collection's vertex buffer needlessly.
|
||||
*/
|
||||
function billboard(position, show = true) {
|
||||
return {
|
||||
position,
|
||||
_show: show,
|
||||
writes: 0,
|
||||
get show() { return this._show; },
|
||||
set show(value) { this._show = value; this.writes += 1; },
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium BillboardCollection-shaped stub. */
|
||||
function collection(items) {
|
||||
return { length: items.length, get: (i) => items[i] };
|
||||
}
|
||||
|
||||
/** Horizon occluder for a camera directly above Austin at `heightM`. */
|
||||
function occluderOverAustin(heightM) {
|
||||
return new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, heightM),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pure cull predicate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
||||
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0));
|
||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0));
|
||||
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0));
|
||||
|
||||
const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder);
|
||||
|
||||
assert.equal(near.show, true, 'the fire under the camera renders');
|
||||
assert.equal(nearby.show, true, 'a fire well inside the horizon renders');
|
||||
assert.equal(far.show, false, 'the antipodal fire must not shine through the planet');
|
||||
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
||||
assert.equal(visible, 2, 'returns the surviving count');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: raising the camera reveals fires the low camera hid', () => {
|
||||
const paris = () => billboard(Cesium.Cartesian3.fromDegrees(2.35, 48.86, 0));
|
||||
|
||||
const low = paris();
|
||||
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M));
|
||||
assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera');
|
||||
|
||||
const high = paris();
|
||||
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
|
||||
assert.equal(high.show, true, 'from geostationary height the same fire is in view');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => {
|
||||
const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false);
|
||||
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
|
||||
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
|
||||
assert.equal(revealed.writes, 1);
|
||||
});
|
||||
|
||||
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true);
|
||||
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true);
|
||||
|
||||
applyHorizonCull(collection([stays, flips]), occluder);
|
||||
applyHorizonCull(collection([stays, flips]), occluder);
|
||||
|
||||
assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned');
|
||||
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => {
|
||||
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
|
||||
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
|
||||
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
||||
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
|
||||
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
|
||||
assert.equal(item.writes, 0, 'a skipped pass must not touch show');
|
||||
assert.equal(
|
||||
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
|
||||
1,
|
||||
'sparse slots are skipped, surviving neighbours still counted',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-ellipsoid anchors: datum-correct render point, lifted CULL point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
||||
|
||||
applyHorizonCull(collection([sunken]), occluder);
|
||||
assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb');
|
||||
|
||||
applyHorizonCull(collection([sunken]), occluder, [lifted]);
|
||||
assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen');
|
||||
});
|
||||
|
||||
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => {
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
|
||||
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12);
|
||||
applyHorizonCull(collection([far]), occluder, [lifted]);
|
||||
assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull');
|
||||
});
|
||||
|
||||
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
|
||||
const lat = 30.2;
|
||||
const lon = AUSTIN.lon + LIMB_DLON;
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(lat, lon, -27);
|
||||
try {
|
||||
const fire = { index: 1, lat, lon, frp: 10, position: null };
|
||||
const cull = fireCullPosition(fire);
|
||||
const render = fire.position;
|
||||
const renderCarto = Cesium.Cartographic.fromCartesian(render);
|
||||
assert.ok(
|
||||
Math.abs(renderCarto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||
`render anchor stays datum-correct at ${-27 + FIRE_ANCHOR_LIFT_M} m, got ${renderCarto.height}`,
|
||||
);
|
||||
|
||||
assert.notEqual(cull, render, 'a sub-ellipsoid anchor gets a separate cull point');
|
||||
const cullCarto = Cesium.Cartographic.fromCartesian(cull);
|
||||
assert.ok(Math.abs(cullCarto.height - 12) < 0.5, `cull point lifted to 12 m, got ${cullCarto.height}`);
|
||||
assert.equal(fireCullPosition(fire), cull, 'the lifted point is cached, not re-allocated per call');
|
||||
|
||||
// The whole point: the datum-correct point would be culled here, the lifted one is not.
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
assert.equal(occluder.isPointVisible(render), false, 'baseline defect: −22 m reads beyond the horizon');
|
||||
assert.equal(occluder.isPointVisible(cull), true, 'lifted cull point reads correctly as in view');
|
||||
} finally {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point', () => {
|
||||
const lat = 45.5;
|
||||
const lon = -122.6;
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(lat, lon, 900);
|
||||
try {
|
||||
const fire = { index: 2, lat, lon, frp: 10, position: null };
|
||||
const cull = fireCullPosition(fire);
|
||||
assert.equal(cull, fire.position, 'no second Cartesian is allocated for the common case');
|
||||
} finally {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World-overlay host wiring (cards)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('isOverlayPointVisible: horizon test prefers entry.cullPosition when present', () => {
|
||||
const viewport = { width: 800, height: 600 };
|
||||
const screen = { x: 400, y: 300 };
|
||||
const render = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22);
|
||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||
|
||||
assert.equal(
|
||||
isOverlayPointVisible({ horizonCull: true }, render, screen, viewport, occluder),
|
||||
false,
|
||||
'baseline: the render anchor alone false-hides the card',
|
||||
);
|
||||
assert.equal(
|
||||
isOverlayPointVisible({ horizonCull: true, cullPosition: lifted }, render, screen, viewport, occluder),
|
||||
true,
|
||||
'the supplied cull position is what the horizon test uses',
|
||||
);
|
||||
assert.equal(
|
||||
isOverlayPointVisible(
|
||||
{ horizonCull: true, cullPosition: Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12) },
|
||||
render,
|
||||
screen,
|
||||
viewport,
|
||||
occluder,
|
||||
),
|
||||
false,
|
||||
'a far-side cull position still culls',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => {
|
||||
const position = { x: 1, y: 2, z: 3 };
|
||||
const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: { x: 4, y: 5, z: 6 } });
|
||||
assert.ok(kept.cullPosition instanceof Cesium.Cartesian3, 'stored as a host-owned Cartesian3');
|
||||
assert.deepEqual(
|
||||
[kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z],
|
||||
[4, 5, 6],
|
||||
);
|
||||
const dropped = normalizeOverlayEntry('firms', { id: 'b', position, cullPosition: { x: NaN, y: 5, z: 6 } });
|
||||
assert.equal(dropped.cullPosition, null, 'a malformed cull point falls back to the render position');
|
||||
const notAnObject = normalizeOverlayEntry('firms', { id: 'b2', position, cullPosition: 42 });
|
||||
assert.equal(notAnObject.cullPosition, null, 'a non-object cull point is rejected');
|
||||
// Non-adopting sources keep their existing BEHAVIOUR and steady-frame
|
||||
// allocation profile; the normalized record does gain an enumerable
|
||||
// `cullPosition: null` field, which is why this asserts null rather than
|
||||
// absence.
|
||||
const absent = normalizeOverlayEntry('firms', { id: 'c', position });
|
||||
assert.equal(absent.cullPosition, null, 'sources that do not opt in resolve to no cull anchor');
|
||||
});
|
||||
|
||||
test('normalizeOverlayEntry: the stored cull anchor is a snapshot, not the caller object', () => {
|
||||
const position = { x: 1, y: 2, z: 3 };
|
||||
const caller = new Cesium.Cartesian3(4, 5, 6);
|
||||
const normalized = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: caller });
|
||||
assert.notEqual(normalized.cullPosition, caller, 'the host must not retain the caller reference');
|
||||
|
||||
// Sources legitimately recycle scratch vectors between publishes; a mutation
|
||||
// after normalization must never reach the per-frame occluder.
|
||||
caller.x = NaN;
|
||||
caller.y = 999;
|
||||
assert.deepEqual(
|
||||
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
|
||||
[4, 5, 6],
|
||||
'post-normalize mutation of the source vector has no effect',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeOverlayEntry: an accessor-backed cullPosition is read exactly once', () => {
|
||||
const position = { x: 1, y: 2, z: 3 };
|
||||
let reads = 0;
|
||||
const entry = {
|
||||
id: 'a',
|
||||
position,
|
||||
get cullPosition() {
|
||||
reads += 1;
|
||||
return new Cesium.Cartesian3(4, 5, 6);
|
||||
},
|
||||
};
|
||||
const normalized = normalizeOverlayEntry('firms', entry);
|
||||
assert.equal(reads, 1, 'one property read — not one per validated component');
|
||||
assert.deepEqual(
|
||||
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
|
||||
[4, 5, 6],
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort normalization', () => {
|
||||
const position = { x: 1, y: 2, z: 3 };
|
||||
const entry = {
|
||||
id: 'a',
|
||||
position,
|
||||
title: 'still normalized',
|
||||
get cullPosition() { throw new Error('hostile accessor'); },
|
||||
};
|
||||
const normalized = normalizeOverlayEntry('firms', entry);
|
||||
assert.equal(normalized.cullPosition, null, 'the bad anchor degrades to null');
|
||||
assert.equal(normalized.title, 'still normalized', 'the rest of the entry survives');
|
||||
assert.equal(normalized.id, 'a');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer integration: the three production hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class MockEvent {
|
||||
constructor() { this.listeners = new Set(); }
|
||||
addEventListener(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
||||
emit() { for (const listener of [...this.listeners]) listener(); }
|
||||
}
|
||||
|
||||
/** Canvas stub good enough for the pre-baked glow sprites. */
|
||||
function canvasStub() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({
|
||||
createRadialGradient: () => ({ addColorStop() {} }),
|
||||
fillStyle: null,
|
||||
fillRect() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,iVBORw0KGgo=',
|
||||
};
|
||||
}
|
||||
|
||||
/** Proxy-shaped FIRMS record (firmsCsv shape) for the fetch stub. */
|
||||
function rawFire(lat, lon, frp) {
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
frp,
|
||||
confidence: 'h',
|
||||
brightness: 340,
|
||||
daynight: 'D',
|
||||
acqDate: '2026-08-03',
|
||||
acqTime: '0412',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'N20',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the REAL layer against a headless viewer stub with a REAL
|
||||
* Cesium.BillboardCollection, so the production hooks (not just the pure
|
||||
* predicate) are what the assertions exercise.
|
||||
*/
|
||||
function createHarness(rawFires) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalProject = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ fires: rawFires, fetchedAt: Date.now() }),
|
||||
});
|
||||
globalThis.window = globalThis.window || {};
|
||||
globalThis.document = { createElement: () => canvasStub() };
|
||||
|
||||
// Deterministic projection: every distinct world position gets its own
|
||||
// 200 px grid slot (well clear of LABEL_MIN_SEP_PX) inside a 2000×2000
|
||||
// canvas, so declutter geometry never decides these assertions.
|
||||
const slots = new Map();
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (scene, position, result) => {
|
||||
if (!slots.has(position)) slots.set(position, slots.size);
|
||||
const slot = slots.get(position);
|
||||
const out = result || new Cesium.Cartesian2();
|
||||
out.x = 100 + (slot % 10) * 200;
|
||||
out.y = 100 + Math.floor(slot / 10) * 200;
|
||||
return out;
|
||||
};
|
||||
|
||||
const preRender = new MockEvent();
|
||||
const moveEnd = new MockEvent();
|
||||
const primitives = [];
|
||||
const entryCalls = [];
|
||||
const viewer = {
|
||||
dataSources: { add: (value) => value, remove: () => {} },
|
||||
camera: {
|
||||
moveEnd,
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, REPRO_HEIGHT_M),
|
||||
directionWC: new Cesium.Cartesian3(0, 0, -1),
|
||||
positionCartographic: { height: REPRO_HEIGHT_M },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 2000, clientHeight: 2000 },
|
||||
preRender,
|
||||
primitives: {
|
||||
add: (value) => { primitives.push(value); return value; },
|
||||
remove: () => {},
|
||||
contains: () => false,
|
||||
},
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
},
|
||||
};
|
||||
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'firms',
|
||||
name: 'FIRMS',
|
||||
overlayHost: {
|
||||
setEntries: (sourceId, entries) => entryCalls.push(entries),
|
||||
setVisible: () => {},
|
||||
clearSource: () => {},
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
|
||||
});
|
||||
|
||||
return {
|
||||
layer,
|
||||
viewer,
|
||||
preRender,
|
||||
moveEnd,
|
||||
/** The real BillboardCollection the layer created. */
|
||||
billboards: () => primitives[0],
|
||||
/** Latest entry list published to the overlay host. */
|
||||
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
||||
/** `show` flags keyed by billboard pick id. */
|
||||
showById() {
|
||||
const bb = primitives[0];
|
||||
const out = new Map();
|
||||
for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show);
|
||||
return out;
|
||||
},
|
||||
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
|
||||
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
viewer.camera.positionCartographic = { height };
|
||||
},
|
||||
cleanup() {
|
||||
layer.destroy(viewer);
|
||||
unregisterSpriteCollection('firms');
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProject;
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow;
|
||||
if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('layer: a detection rebuild culls its own freshly-added sprites', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(ANTIPODE.lat, ANTIPODE.lon, 900),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
// No camera event has fired — only renderDetections ran.
|
||||
const show = harness.showById();
|
||||
assert.equal(show.size, 3, 'all three detections were rendered');
|
||||
assert.equal(show.get('firms-0'), true, 'the Austin fire under the camera is visible');
|
||||
assert.equal(show.get('firms-1'), false, 'the antipodal fire is culled at build time');
|
||||
assert.equal(show.get('firms-2'), false, 'the Tokyo fire is culled at build time');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
|
||||
|
||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||
harness.moveEnd.emit();
|
||||
|
||||
const show = harness.showById();
|
||||
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
|
||||
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: the throttled preRender watcher re-culls on a camera move', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
|
||||
// performance.now(), whose value at this point depends on process
|
||||
// start-up — so emit, wait out the full throttle window, and emit again.
|
||||
// Whichever tick passes the gate, one of them must run the pass.
|
||||
harness.preRender.emit();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 700); });
|
||||
harness.preRender.emit();
|
||||
|
||||
const show = harness.showById();
|
||||
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass');
|
||||
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: re-enabling after the camera moved does not expose stale far-side sprites', async () => {
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||
]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
|
||||
|
||||
harness.layer.disable();
|
||||
// The camera moves while the layer is OFF: moveEnd is not being listened
|
||||
// to, and the preRender watcher is inert. Nothing re-culls on its own.
|
||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
|
||||
// Assert with NO camera event fired and NO refetch (fires are already
|
||||
// loaded, so enable() does not reload): the enable path itself must have
|
||||
// re-culled before the collection went visible.
|
||||
const show = harness.showById();
|
||||
assert.equal(harness.billboards().show, true, 'the collection is visible again');
|
||||
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
|
||||
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its datum', async () => {
|
||||
const lat = AUSTIN.lat;
|
||||
const lon = AUSTIN.lon + LIMB_DLON;
|
||||
// Negative-geoid coastal cell: ground floor −27 m → anchor −22 m, which the
|
||||
// occluder reads as beyond the horizon even though the fire is in view.
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(lat, lon, -27);
|
||||
const harness = createHarness([
|
||||
rawFire(AUSTIN.lat, AUSTIN.lon, 120),
|
||||
rawFire(lat, lon, 90),
|
||||
]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
|
||||
const bb = harness.billboards();
|
||||
const sunken = bb.get(1);
|
||||
const carto = Cesium.Cartographic.fromCartesian(sunken.position);
|
||||
assert.ok(
|
||||
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
|
||||
);
|
||||
assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)');
|
||||
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('layer: far-side candidates never displace near-side ambient cards', async () => {
|
||||
const near = [];
|
||||
for (let i = 0; i < 6; i += 1) near.push(rawFire(AUSTIN.lat + i * 0.4, AUSTIN.lon + i * 0.4, 100 - i));
|
||||
const far = [];
|
||||
for (let i = 0; i < 20; i += 1) far.push(rawFire(ANTIPODE.lat + i * 0.4, ANTIPODE.lon + i * 0.4, 900 - i));
|
||||
// Far-side fires are the STRONGEST, so they lead the FRP-ranked candidate
|
||||
// walk and would eat all 18 ambient slots without the horizon skip.
|
||||
const harness = createHarness([...near, ...far]);
|
||||
try {
|
||||
harness.layer.init(harness.viewer);
|
||||
await harness.layer.enable(harness.viewer);
|
||||
|
||||
const ids = harness.latestEntries().map((entry) => entry.id);
|
||||
assert.ok(ids.length > 0, 'cards were published');
|
||||
assert.ok(ids.length <= FIRMS_AMBIENT_COHORT_LIMIT, 'the ambient cap still holds');
|
||||
// Card ids are `fire:firms:<lat>:<lon>:<acqMs>:<sat>` (the refetch-stable
|
||||
// detection key), so a card is matched back to its seed by coordinate.
|
||||
const nearCoords = new Set(near.map((fire) => `${fire.lat.toFixed(4)}:${fire.lon.toFixed(4)}`));
|
||||
const nearIds = ids.filter((id) => nearCoords.has(id.split(':').slice(2, 4).join(':')));
|
||||
assert.equal(nearIds.length, 6, 'every in-view fire gets a card');
|
||||
assert.equal(new Set(nearIds).size, 6, 'six distinct near-side detections, not one repeated');
|
||||
assert.equal(ids.length, 6, 'no slot is spent on a fire behind the planet');
|
||||
assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms');
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
// FIRMS click routes: sprite AND card must both select for detail and hand the
|
||||
// camera over (pre-launch defect #4 + P1-3). Driven through the production
|
||||
// click handler and the production card-render path; only the viewer, the
|
||||
// event handler, the overlay host and the world→window projection are stubbed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
|
||||
import { fireDetectionKey } from './firmsLabels.js';
|
||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LAYER_SOURCE = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function makeFire(overrides = {}) {
|
||||
return {
|
||||
index: 7,
|
||||
lat: 30.51,
|
||||
lon: -98.21,
|
||||
frp: 1520.4,
|
||||
confidence: 0.9,
|
||||
satellite: 'N21',
|
||||
sensor: 'VIIRS',
|
||||
acqMs: 1_753_600_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand the layer up far enough to drive one click. `cardHit` is what the
|
||||
* overlay host reports under the click position — the real host only publishes
|
||||
* rects for `interactive` entries, so a stub hit is the contract a painted,
|
||||
* actionable card presents.
|
||||
*/
|
||||
function harness({
|
||||
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
|
||||
withDataSource = false,
|
||||
} = {}) {
|
||||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||||
const priorWindow = globalThis.window;
|
||||
const priorProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
const windowTarget = new EventTarget();
|
||||
globalThis.window = windowTarget;
|
||||
// Spread projections so the greedy screen-separation declutter accepts every
|
||||
// seeded detection; co-located fires would otherwise collide by design.
|
||||
let projected = 0;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => {
|
||||
projected += 1;
|
||||
return { x: 200 * projected, y: 300 };
|
||||
};
|
||||
|
||||
const published = [];
|
||||
let handler = null;
|
||||
const layer = createFirmsHeatmapLayer({
|
||||
id: 'firms-test',
|
||||
name: 'Fires',
|
||||
overlayHost: {
|
||||
setEntries: (sourceId, entries) => published.push(...entries),
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => {
|
||||
handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} };
|
||||
return handler;
|
||||
},
|
||||
});
|
||||
|
||||
const viewer = {
|
||||
scene: {
|
||||
pick: () => picked,
|
||||
canvas: { clientWidth: 1280, clientHeight: 800 },
|
||||
camera: cameraPosition ? { positionWC: cameraPosition } : undefined,
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
// The LOD rebuild (and with it the context-registration sweep) is inert
|
||||
// without a data source, so tests that drive a real refresh need one.
|
||||
if (withDataSource) layer.init(viewer);
|
||||
layer._bindInteractionForTest(viewer, fires);
|
||||
|
||||
const requests = [];
|
||||
windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) => requests.push(e.detail));
|
||||
|
||||
return {
|
||||
layer,
|
||||
viewer,
|
||||
windowTarget,
|
||||
requests,
|
||||
published,
|
||||
fires,
|
||||
click: () => handler.click({ position: { x: 400, y: 300 } }),
|
||||
cleanup() {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
|
||||
if (hadWindow) globalThis.window = priorWindow;
|
||||
else delete globalThis.window;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fire detection key survives a refetch that renumbers the index', () => {
|
||||
const fire = makeFire();
|
||||
assert.equal(fireDetectionKey(fire), fireDetectionKey({ ...fire, index: 999 }));
|
||||
// Different pass over the same pixel is a different detection.
|
||||
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }));
|
||||
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, lat: 31.0 }));
|
||||
assert.match(fireDetectionKey({}), /^firms:x:x:0:x$/);
|
||||
});
|
||||
|
||||
test('two satellites over the same pixel at the same time stay distinct', () => {
|
||||
// The proxy merges SNPP + NOAA-20 + NOAA-21 without dedup and their orbits
|
||||
// overlap, so position and time alone are NOT unique. Collapsing them let a
|
||||
// restored selection land on the other satellite's record.
|
||||
const base = makeFire({ satellite: 'N20' });
|
||||
const twin = makeFire({ satellite: 'N21' });
|
||||
const suomi = makeFire({ satellite: 'N' });
|
||||
assert.notEqual(fireDetectionKey(base), fireDetectionKey(twin));
|
||||
assert.notEqual(fireDetectionKey(base), fireDetectionKey(suomi));
|
||||
assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi));
|
||||
// Satellite naming variants normalize to one identity, not three.
|
||||
assert.equal(fireDetectionKey(base), fireDetectionKey(makeFire({ satellite: 'NOAA-20' })));
|
||||
assert.equal(fireDetectionKey(suomi), fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })));
|
||||
// A record with no satellite falls back to its sensor, not to a shared blank.
|
||||
const sensorOnly = makeFire({ satellite: '', sensor: 'MODIS' });
|
||||
assert.notEqual(fireDetectionKey(sensorOnly), fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' })));
|
||||
});
|
||||
|
||||
test('duplicate-coordinate detections get their own cards and focus targets', () => {
|
||||
const n20 = makeFire({ index: 0, satellite: 'N20' });
|
||||
const n21 = makeFire({ index: 1, satellite: 'N21' });
|
||||
const h = harness({
|
||||
fires: [n20, n21],
|
||||
picked: { primitive: {}, content: {}, featureId: 0 },
|
||||
cardHit: `fire:${fireDetectionKey(n21)}`,
|
||||
});
|
||||
try {
|
||||
const cardIds = h.published.filter((e) => e.id.startsWith('fire:')).map((e) => e.id);
|
||||
assert.equal(new Set(cardIds).size, 2, 'co-located detections must not collapse to one card');
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].id, fireDetectionKey(n21), 'the clicked card wins, not its twin');
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('selection restoration uses the same key the cards and focus use', () => {
|
||||
// Restoration ran on raw lat/lon/acqMs, which collapses co-located
|
||||
// detections from different satellites — the selection could come back on
|
||||
// the wrong record. Reachable only through the 30-minute refetch, so this is
|
||||
// a source contract; the key's own behavior is proven above.
|
||||
const match = LAYER_SOURCE.match(/function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/);
|
||||
assert.ok(match, 'findMatchingFire is missing');
|
||||
assert.match(match[1], /fireDetectionKey\(previous\)/);
|
||||
assert.match(match[1], /fireDetectionKey\(fire\) === key/);
|
||||
assert.doesNotMatch(match[1], /fire\.acqMs === previous\.acqMs/);
|
||||
});
|
||||
|
||||
test('painted detection cards are keyed by the stable detection key', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const card = h.published.find((entry) => entry.id.startsWith('fire:'));
|
||||
assert.ok(card, 'a detection card was painted');
|
||||
assert.equal(card.id, `fire:${fireDetectionKey(h.fires[0])}`);
|
||||
assert.equal(card.interactive, true, 'a detection card must publish a hit rect');
|
||||
assert.match(card.accessibilityLabel, /^Focus fire detection /);
|
||||
assert.equal(card.activate(), true);
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].id, fireDetectionKey(h.fires[0]));
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('an aggregated cell card is never actionable — it is not one fire', () => {
|
||||
const cell = buildCellCard(
|
||||
{ cell: { count: 14, maxFrp: 210, newestAcqMs: 0, latCell: 30, lonCell: -98 }, position: {} },
|
||||
Date.now(),
|
||||
);
|
||||
assert.equal(applyFirmsOverlayPolicy(cell, 1e6).interactive, false);
|
||||
});
|
||||
|
||||
test('far-side detections cannot consume the bounded ambient-card cohort', () => {
|
||||
const front = makeFire({ index: 1, lat: 30.51, lon: -98.21, frp: 100 });
|
||||
const rear = makeFire({ index: 2, lat: -30.51, lon: 81.79, frp: 1000 });
|
||||
const cameraPosition = Cesium.Cartesian3.fromDegrees(front.lon, front.lat, 10_000_000);
|
||||
const h = harness({ fires: [rear, front], cameraPosition });
|
||||
try {
|
||||
const cardIds = h.published.filter((entry) => entry.id.startsWith('fire:')).map((entry) => entry.id);
|
||||
assert.deepEqual(cardIds, [`fire:${fireDetectionKey(front)}`]);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a fire sprite selects it and requests the camera transfer', () => {
|
||||
const fire = makeFire();
|
||||
const h = harness({ fires: [fire], picked: { id: 'firms-7' } });
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].kind, 'fire');
|
||||
assert.equal(h.requests[0].id, fireDetectionKey(fire));
|
||||
assert.ok(h.requests[0].position, 'the transfer needs a world position');
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a fire CARD behaves exactly like clicking its sprite', () => {
|
||||
const fire = makeFire();
|
||||
// The click lands on the card, but the scene pick returns the 3D-Tiles
|
||||
// surface behind it — the case that used to clear the selection.
|
||||
const h = harness({
|
||||
fires: [fire],
|
||||
picked: { primitive: {}, content: {}, featureId: 0 },
|
||||
cardHit: `fire:${fireDetectionKey(fire)}`,
|
||||
});
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.equal(h.requests[0].kind, 'fire');
|
||||
assert.equal(h.requests[0].id, fireDetectionKey(fire));
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on neither sprite nor card clears without moving the camera', () => {
|
||||
const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } });
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 0);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Sibling layers surface heterogeneous pick shapes. Reading `picked.id`
|
||||
// directly recognized only bare strings, so an AIS sprite (or satellite, or
|
||||
// entity) under a fire card let BOTH layers issue a camera command.
|
||||
const SIBLING_PICKS = [
|
||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
||||
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||
['satellites', '25544', { id: 25544 }],
|
||||
];
|
||||
|
||||
for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
|
||||
test(`a ${layerId} pick shaped ${JSON.stringify(picked)} wins over a card hit`, () => {
|
||||
const fire = makeFire();
|
||||
registerPickOwner(layerId, (pickedId) => pickedId === ownedId);
|
||||
const h = harness({
|
||||
fires: [fire],
|
||||
picked,
|
||||
cardHit: `fire:${fireDetectionKey(fire)}`,
|
||||
});
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 0, `the ${layerId} layer is already acting on this click`);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
unregisterPickOwner(layerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a stale card id resolves to nothing instead of an arbitrary fire', () => {
|
||||
const h = harness({
|
||||
picked: { primitive: {}, content: {}, featureId: 0 },
|
||||
cardHit: 'fire:firms:1.0000:1.0000:1:N20',
|
||||
});
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 0);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refresh that drops the selected fire emits an eviction the readout can act on', async () => {
|
||||
// Behavioral, not a source pin: the tag was present and correct while the
|
||||
// event never fired, because the LOD rebuild deletes the selected context
|
||||
// record first and the clear then fails its ownership guard silently.
|
||||
const kept = makeFire({ index: 0 });
|
||||
const dropped = makeFire({
|
||||
index: 1, lat: 31.77, lon: -99.02, acqMs: 1_753_600_100_000, satellite: 'N20',
|
||||
});
|
||||
const h = harness({
|
||||
fires: [dropped, kept],
|
||||
cardHit: `fire:${fireDetectionKey(dropped)}`,
|
||||
withDataSource: true,
|
||||
});
|
||||
const cleared = [];
|
||||
h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => cleared.push(e.detail));
|
||||
const priorFetch = globalThis.fetch;
|
||||
|
||||
try {
|
||||
h.click();
|
||||
assert.equal(h.requests.length, 1, 'the click must select the fire we are about to drop');
|
||||
assert.equal(h.requests[0].id, fireDetectionKey(dropped));
|
||||
assert.deepEqual(cleared, [], 'selecting must not clear anything yet');
|
||||
|
||||
// A normal, NON-EMPTY refresh that simply no longer carries that detection.
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
fetchedAt: Date.now(),
|
||||
stale: false,
|
||||
fires: [{
|
||||
lat: kept.lat,
|
||||
lon: kept.lon,
|
||||
frp: kept.frp,
|
||||
confidence: 'h',
|
||||
brightness: 330,
|
||||
daynight: 'D',
|
||||
acqDate: '2025-07-27',
|
||||
acqTime: '0412',
|
||||
instrument: 'VIIRS',
|
||||
satellite: kept.satellite,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
await h.layer.update();
|
||||
|
||||
assert.equal(
|
||||
cleared.length,
|
||||
1,
|
||||
'a vanished selection must reach consumers as an event, not as silence',
|
||||
);
|
||||
assert.equal(cleared[0].layerId, 'firms-test');
|
||||
assert.equal(
|
||||
cleared[0].reason,
|
||||
'evicted',
|
||||
'the detection left the feed — it was not deselected',
|
||||
);
|
||||
} finally {
|
||||
if (priorFetch === undefined) delete globalThis.fetch;
|
||||
else globalThis.fetch = priorFetch;
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @module firmsLabels
|
||||
* @description FIRMS-specific presentation formatting retained after the
|
||||
* dedicated card canvas moved into the shared world-overlay host.
|
||||
*/
|
||||
|
||||
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
|
||||
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
|
||||
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
|
||||
|
||||
/** Refetch-stable identity for one FIRMS detection, including its source feed. */
|
||||
export function fireDetectionKey(fire) {
|
||||
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
|
||||
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
|
||||
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
|
||||
const source = satelliteShortName(fire?.satellite)
|
||||
|| String(fire?.sensor || '').trim().toUpperCase()
|
||||
|| 'x';
|
||||
return `firms:${lat}:${lon}:${acq}:${source}`;
|
||||
}
|
||||
|
||||
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
|
||||
const ACCENT_RGB = Object.freeze({
|
||||
red: '224, 82, 82',
|
||||
orange: '240, 178, 62',
|
||||
yellow: '244, 227, 108',
|
||||
});
|
||||
|
||||
/**
|
||||
* Severity stop name → "r, g, b" accent string (defaults to yellow).
|
||||
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function accentForSeverity(stopName) {
|
||||
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
|
||||
}
|
||||
|
||||
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
|
||||
export function satelliteShortName(satellite) {
|
||||
const s = String(satellite || '').trim().toUpperCase();
|
||||
if (s === 'N20' || s === 'NOAA-20') return 'N20';
|
||||
if (s === 'N21' || s === 'NOAA-21') return 'N21';
|
||||
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
|
||||
return s ? s.slice(0, 6) : '';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
accentForSeverity,
|
||||
FIRMS_AMBIENT_COHORT_LIMIT,
|
||||
FIRMS_OVERLAY_SOURCE_ID,
|
||||
satelliteShortName,
|
||||
} from './firmsLabels.js';
|
||||
|
||||
test('FIRMS formatting helpers retain the shipped severity palette', () => {
|
||||
assert.equal(accentForSeverity('red'), '224, 82, 82');
|
||||
assert.equal(accentForSeverity('orange'), '240, 178, 62');
|
||||
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
|
||||
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
|
||||
});
|
||||
|
||||
test('FIRMS satellite names retain the three VIIRS abbreviations', () => {
|
||||
assert.equal(satelliteShortName('N20'), 'N20');
|
||||
assert.equal(satelliteShortName('N21'), 'N21');
|
||||
assert.equal(satelliteShortName('N'), 'SNPP');
|
||||
assert.equal(satelliteShortName('TERRA-X9'), 'TERRA-');
|
||||
assert.equal(satelliteShortName(''), '');
|
||||
});
|
||||
|
||||
test('FIRMS host registration constants pin the shipped source budget', () => {
|
||||
assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms');
|
||||
assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18);
|
||||
});
|
||||
|
||||
test('FIRMS helper module cannot resurrect a dedicated canvas renderer', () => {
|
||||
const source = readFileSync(new URL('./firmsLabels.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /createElement\(['"]canvas['"]\)/);
|
||||
assert.doesNotMatch(source, /postRender/);
|
||||
assert.doesNotMatch(source, /worldToWindowCoordinates/);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test fixtures
|
||||
|
||||
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
|
||||
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
|
||||
z12 x935 y1686, captured 2026-07-16 from
|
||||
`api.tomtom.com/traffic/map/4/tile/flow/relative/12/935/1686.pbf`
|
||||
(22,980 bytes). Used ONLY by `src/data/flowTiles.test.mjs` to pin MVT
|
||||
decoding offline — it is a point-in-time congestion snapshot, not a bundled
|
||||
data layer, and is never served to the app. © TomTom.
|
||||
@@ -0,0 +1,46 @@
|
||||
latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_ti5,frp,daynight
|
||||
38.99488,-121.67046,303.6,0.39,0.36,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.73,0.53,N
|
||||
39.49126,-119.61848,303.73,0.41,0.37,2026-07-16,1006,N20,VIIRS,n,2.0NRT,290.12,0.64,N
|
||||
32.44483,-116.96709,301.45,0.46,0.47,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.55,0.4,N
|
||||
32.44652,-116.96341,300.77,0.46,0.47,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.52,0.35,N
|
||||
32.45192,-116.81448,303.1,0.47,0.48,2026-07-16,1009,N20,VIIRS,n,2.0NRT,291.25,0.63,N
|
||||
32.63403,-115.2234,305.87,0.57,0.52,2026-07-16,1009,N20,VIIRS,n,2.0NRT,281.35,3.6,N
|
||||
32.85691,-117.14871,305.1,0.44,0.46,2026-07-16,1009,N20,VIIRS,n,2.0NRT,290.02,0.41,N
|
||||
32.85783,-117.02994,301.7,0.45,0.47,2026-07-16,1009,N20,VIIRS,n,2.0NRT,290.95,0.61,N
|
||||
33.49317,-117.61848,301.55,0.41,0.45,2026-07-16,1009,N20,VIIRS,n,2.0NRT,290.17,0.74,N
|
||||
33.61246,-117.82456,304.67,0.4,0.44,2026-07-16,1009,N20,VIIRS,n,2.0NRT,290.48,1.13,N
|
||||
33.71718,-117.71191,315.92,0.4,0.44,2026-07-16,1009,N20,VIIRS,n,2.0NRT,292.19,0.78,N
|
||||
33.79416,-117.47431,312.66,0.41,0.45,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.24,1.11,N
|
||||
34.0404,-117.82031,304.28,0.39,0.44,2026-07-16,1009,N20,VIIRS,n,2.0NRT,292.88,0.51,N
|
||||
34.14165,-117.42964,305.36,0.4,0.45,2026-07-16,1009,N20,VIIRS,n,2.0NRT,292.94,0.58,N
|
||||
34.15444,-118.19521,314.58,0.56,0.43,2026-07-16,1009,N20,VIIRS,n,2.0NRT,292.86,1.73,N
|
||||
34.33553,-118.51984,317.65,0.53,0.42,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.02,1.94,N
|
||||
34.35427,-116.8526,307.4,0.43,0.46,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.67,1.23,N
|
||||
34.43204,-118.64416,309.01,0.52,0.42,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.03,1.73,N
|
||||
34.60664,-117.33733,315.23,0.4,0.44,2026-07-16,1009,N20,VIIRS,n,2.0NRT,295.8,2.08,N
|
||||
34.62088,-117.1028,312.39,0.41,0.45,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.19,1.45,N
|
||||
35.97387,-119.88654,304.59,0.44,0.39,2026-07-16,1009,N20,VIIRS,n,2.0NRT,294.37,1.08,N
|
||||
36.53365,-121.40484,298.05,0.4,0.37,2026-07-16,1009,N20,VIIRS,n,2.0NRT,286.96,0.3,N
|
||||
36.54124,-119.37177,313.73,0.45,0.39,2026-07-16,1009,N20,VIIRS,n,2.0NRT,294.22,1.61,N
|
||||
37.18347,-121.68037,301.12,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,287.73,0.55,N
|
||||
37.45419,-121.92992,305.71,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.59,0.66,N
|
||||
37.7546,-121.65945,300.09,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,287.81,0.39,N
|
||||
37.88349,-121.18257,300.47,0.39,0.37,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.35,0.49,N
|
||||
37.88424,-121.18698,304.76,0.39,0.37,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.17,0.49,N
|
||||
37.95056,-122.39809,304.05,0.38,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,293.11,1.28,N
|
||||
38.00561,-121.9355,304.25,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,291.58,0.32,N
|
||||
38.01419,-122.11389,303.69,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,292.7,1.83,N
|
||||
38.12214,-121.3797,305,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,288.9,0.41,N
|
||||
38.12698,-121.16856,300.13,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,289.16,0.42,N
|
||||
38.21516,-121.98375,300.37,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,288.49,0.43,N
|
||||
38.30271,-122.74693,297.18,0.38,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,285.19,0.6,N
|
||||
38.71033,-121.2137,335.37,0.39,0.36,2026-07-16,1009,N20,VIIRS,n,2.0NRT,297.84,19.84,N
|
||||
39.7261,-120.12763,344.39,0.75,0.77,2026-07-16,1947,N20,VIIRS,n,2.0NRT,311.6,16.63,D
|
||||
39.72685,-120.12675,343.54,0.75,0.77,2026-07-16,1947,N20,VIIRS,n,2.0NRT,312.62,8.96,D
|
||||
33.41492,-117.50858,335.2,0.49,0.49,2026-07-16,2127,N20,VIIRS,l,2.0NRT,305.28,8.6,D
|
||||
33.4199,-117.50378,345.25,0.49,0.49,2026-07-16,2127,N20,VIIRS,n,2.0NRT,306.36,8.43,D
|
||||
33.94703,-117.66942,351.42,0.49,0.49,2026-07-16,2127,N20,VIIRS,n,2.0NRT,309.77,25.62,D
|
||||
33.94752,-117.66402,342.25,0.49,0.49,2026-07-16,2127,N20,VIIRS,n,2.0NRT,303.87,25.62,D
|
||||
34.131,-118.22691,343.89,0.46,0.47,2026-07-16,2127,N20,VIIRS,n,2.0NRT,311.37,8.96,D
|
||||
39.47552,-121.94763,334.26,0.52,0.41,2026-07-16,2127,N20,VIIRS,l,2.0NRT,309.04,4.26,D
|
||||
39.72568,-120.12376,348.65,0.42,0.45,2026-07-16,2127,N20,VIIRS,l,2.0NRT,320.76,27.73,D
|
||||
|
Binary file not shown.
+5344
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @file Flow→road matching: assign TomTom congestion levels to Overpass roads.
|
||||
*
|
||||
* The traffic layer renders dots along OSM road polylines (Overpass), while
|
||||
* TomTom flow tiles carry their own (differently segmented) polylines with
|
||||
* `traffic_level`. This module snaps flow onto roads geometrically:
|
||||
*
|
||||
* 1. Every flow polyline is exploded into consecutive coord-pair SEGMENTS
|
||||
* (pairs longer than one cell are subdivided so midpoint hashing can't
|
||||
* miss their extremities) and hashed by midpoint into ~100 m grid cells,
|
||||
* projected to local meters (degree cell size cos(lat)-adjusted).
|
||||
* 2. Each road is sampled at up to 7 evenly-spaced points along its length;
|
||||
* each sample looks for the nearest flow segment within 35 m whose
|
||||
* bearing agrees within 30° (folded mod 180° so two-way roads match
|
||||
* opposite-direction flow lines).
|
||||
* 3. A road matches when at least half its samples (minimum 2) matched; its
|
||||
* level is the MEDIAN of the matched samples' trafficLevels, and closure
|
||||
* is true if ANY matched segment is closed.
|
||||
*
|
||||
* Pure and Cesium-free: inputs are plain [[lon,lat],…] polylines, so the
|
||||
* whole pipeline is unit-testable with synthetic geometry.
|
||||
*
|
||||
* @module data/flowMatch
|
||||
*/
|
||||
|
||||
/** @const {number} Meters per degree of latitude (spherical approximation). */
|
||||
const M_PER_DEG_LAT = 111320;
|
||||
/** @const {number} Spatial-hash cell size in meters (~100 m per spec). */
|
||||
const CELL_SIZE_M = 100;
|
||||
/** @const {number} Max snap distance from a road sample to a flow segment. */
|
||||
const MATCH_RADIUS_M = 35;
|
||||
/** @const {number} Max bearing disagreement (degrees, folded mod 180). */
|
||||
const BEARING_TOLERANCE_DEG = 30;
|
||||
/** @const {number} Evenly-spaced samples per road. */
|
||||
const ROAD_SAMPLES = 7;
|
||||
/** @const {number} Minimum matched samples for a road to count as matched. */
|
||||
const MIN_MATCHED_SAMPLES = 2;
|
||||
|
||||
/**
|
||||
* Median of a numeric array (even count averages the two middles).
|
||||
* @param {number[]} values
|
||||
* @returns {number|null} Median, or null for an empty array.
|
||||
*/
|
||||
export function median(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
|
||||
function bearingDeg(dx, dy) {
|
||||
return (Math.atan2(dx, dy) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearing disagreement folded mod 180° — a flow line drawn in the opposite
|
||||
* direction of travel still describes the same two-way road.
|
||||
* @param {number} a - Bearing (degrees). @param {number} b - Bearing (degrees).
|
||||
* @returns {number} min(|Δ|, 180 − |Δ|) in [0, 90].
|
||||
*/
|
||||
function bearingDiffDeg(a, b) {
|
||||
let d = Math.abs(a - b) % 360;
|
||||
if (d > 180) d = 360 - d;
|
||||
return Math.min(d, 180 - d);
|
||||
}
|
||||
|
||||
/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by), meters². */
|
||||
function pointSegDist2(px, py, ax, ay, bx, by) {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let t = len2 > 0 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const qx = ax + t * dx;
|
||||
const qy = ay + t * dy;
|
||||
return (px - qx) * (px - qx) + (py - qy) * (py - qy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match flow segments onto roads.
|
||||
*
|
||||
* @param {Array<{coords:number[][], type:string}>} roads
|
||||
* Parsed Overpass road objects ([[lon,lat],…] polylines).
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>} flowSegments
|
||||
* Decoded flow polylines from `flowTiles.js`.
|
||||
* @returns {{
|
||||
* matches: Array<{level:number, closure:boolean}|null>,
|
||||
* matchedCount: number,
|
||||
* candidateCount: number,
|
||||
* }}
|
||||
* `matches` is PARALLEL to `roads` (index i describes roads[i]; null = no
|
||||
* flow data for that road, render it exactly as today). `candidateCount` is
|
||||
* the number of roads with at least one flow segment inside the 35 m search
|
||||
* radius regardless of bearing — the denominator for coverage stats.
|
||||
*/
|
||||
export function matchFlowToRoads(roads, flowSegments) {
|
||||
const roadCount = Array.isArray(roads) ? roads.length : 0;
|
||||
const matches = new Array(roadCount).fill(null);
|
||||
const empty = { matches, matchedCount: 0, candidateCount: 0 };
|
||||
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
// Local equirectangular projection anchored at the first flow coordinate —
|
||||
// over a ≤0.05° fetch box the distortion is negligible.
|
||||
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
|
||||
if (!anchor) return empty;
|
||||
const [refLon, refLat] = anchor.coords[0];
|
||||
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
|
||||
const projX = (lon) => (lon - refLon) * mPerDegLon;
|
||||
const projY = (lat) => (lat - refLat) * M_PER_DEG_LAT;
|
||||
|
||||
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
|
||||
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
|
||||
const grid = new Map();
|
||||
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
|
||||
|
||||
for (const flow of flowSegments) {
|
||||
const coords = flow?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
const level = flow.trafficLevel;
|
||||
const closure = flow.closure === true;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const ax = projX(coords[i][0]);
|
||||
const ay = projY(coords[i][1]);
|
||||
const bx = projX(coords[i + 1][0]);
|
||||
const by = projY(coords[i + 1][1]);
|
||||
const segLen = Math.hypot(bx - ax, by - ay);
|
||||
if (!(segLen > 0)) continue;
|
||||
const bearing = bearingDeg(bx - ax, by - ay);
|
||||
// Subdivide long pairs so every piece is ≤ one cell — midpoint hashing
|
||||
// then guarantees a 3×3 cell probe sees everything within 35 m.
|
||||
const pieces = Math.max(1, Math.ceil(segLen / CELL_SIZE_M));
|
||||
for (let p = 0; p < pieces; p++) {
|
||||
const t0 = p / pieces;
|
||||
const t1 = (p + 1) / pieces;
|
||||
const seg = {
|
||||
ax: ax + (bx - ax) * t0,
|
||||
ay: ay + (by - ay) * t0,
|
||||
bx: ax + (bx - ax) * t1,
|
||||
by: ay + (by - ay) * t1,
|
||||
bearing,
|
||||
level,
|
||||
closure,
|
||||
};
|
||||
const key = cellOf((seg.ax + seg.bx) / 2, (seg.ay + seg.by) / 2);
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
grid.set(key, bucket);
|
||||
}
|
||||
bucket.push(seg);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (grid.size === 0) return empty;
|
||||
|
||||
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
|
||||
let matchedCount = 0;
|
||||
let candidateCount = 0;
|
||||
|
||||
// ── 2+3. Sample each road and vote ──
|
||||
for (let r = 0; r < roadCount; r++) {
|
||||
const coords = roads[r]?.coords;
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
|
||||
// Project the road once; accumulate arc length for even sampling.
|
||||
const xs = new Array(coords.length);
|
||||
const ys = new Array(coords.length);
|
||||
const cum = new Array(coords.length);
|
||||
cum[0] = 0;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
xs[i] = projX(coords[i][0]);
|
||||
ys[i] = projY(coords[i][1]);
|
||||
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
|
||||
}
|
||||
const totalLen = cum[coords.length - 1];
|
||||
if (!(totalLen > 0)) continue;
|
||||
|
||||
let hadCandidate = false;
|
||||
const matchedLevels = [];
|
||||
let matchedClosure = false;
|
||||
|
||||
let cursor = 1; // cum[] is monotonic; samples advance monotonically too
|
||||
for (let s = 0; s < ROAD_SAMPLES; s++) {
|
||||
// Midpoint-biased fractions keep samples off road endpoints, where
|
||||
// cross-street flow lines meet at intersections.
|
||||
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
|
||||
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
|
||||
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
|
||||
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
|
||||
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
|
||||
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
|
||||
|
||||
// 3×3 cell probe around the sample.
|
||||
const cx = Math.floor(px / CELL_SIZE_M);
|
||||
const cy = Math.floor(py / CELL_SIZE_M);
|
||||
let best = null;
|
||||
let bestDist2 = radius2;
|
||||
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
||||
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
||||
const bucket = grid.get(`${gx},${gy}`);
|
||||
if (!bucket) continue;
|
||||
for (const seg of bucket) {
|
||||
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
|
||||
if (d2 > radius2) continue;
|
||||
hadCandidate = true; // within radius, bearing not yet checked
|
||||
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
|
||||
if (d2 <= bestDist2) {
|
||||
bestDist2 = d2;
|
||||
best = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
matchedLevels.push(best.level);
|
||||
if (best.closure) matchedClosure = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hadCandidate) candidateCount++;
|
||||
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
|
||||
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
|
||||
matchedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, matchedCount, candidateCount };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// src/data/flowMatch.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { matchFlowToRoads, median } from './flowMatch.js';
|
||||
|
||||
// Synthetic geometry around downtown Austin (cos-lat correction matters here).
|
||||
const LAT0 = 30.26;
|
||||
const LON0 = -97.7431;
|
||||
const M_PER_DEG_LAT = 111320;
|
||||
const M_PER_DEG_LON = M_PER_DEG_LAT * Math.cos((LAT0 * Math.PI) / 180);
|
||||
|
||||
/** Build a straight polyline from a start point, heading, length, and vertex spacing (meters). */
|
||||
function line({ lat = LAT0, lon = LON0, northM = 0, eastM = 0, lengthM, stepM = 25, bearingDeg = 0 }) {
|
||||
const startLat = lat + northM / M_PER_DEG_LAT;
|
||||
const startLon = lon + eastM / M_PER_DEG_LON;
|
||||
const steps = Math.max(1, Math.round(lengthM / stepM));
|
||||
const coords = [];
|
||||
const rad = (bearingDeg * Math.PI) / 180;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const d = (lengthM * i) / steps;
|
||||
coords.push([
|
||||
startLon + (Math.sin(rad) * d) / M_PER_DEG_LON,
|
||||
startLat + (Math.cos(rad) * d) / M_PER_DEG_LAT,
|
||||
]);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
const ROAD_NS = { coords: line({ lengthM: 500, bearingDeg: 0 }), type: 'primary' };
|
||||
|
||||
function flow(coords, trafficLevel, { closure = false, roadType = 'Major road' } = {}) {
|
||||
return { coords, trafficLevel, roadType, closure };
|
||||
}
|
||||
|
||||
// ── matching core ───────────────────────────────────────────
|
||||
|
||||
test('coincident parallel road matches with the flow level', () => {
|
||||
const flows = [flow(line({ lengthM: 500, bearingDeg: 0, eastM: 4 }), 0.42)];
|
||||
const { matches, matchedCount, candidateCount } = matchFlowToRoads([ROAD_NS], flows);
|
||||
assert.equal(matchedCount, 1);
|
||||
assert.equal(candidateCount, 1);
|
||||
assert.ok(matches[0], 'road should match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.42) < 1e-9);
|
||||
assert.equal(matches[0].closure, false);
|
||||
});
|
||||
|
||||
test('opposite-direction flow still matches (two-way bearing fold)', () => {
|
||||
// Same line drawn south->north vs road north->south equivalence.
|
||||
const reversed = line({ lengthM: 500, bearingDeg: 0, eastM: 4 }).reverse();
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [flow(reversed, 0.3)]);
|
||||
assert.ok(matches[0], 'reversed flow should still match');
|
||||
assert.ok(Math.abs(matches[0].level - 0.3) < 1e-9);
|
||||
});
|
||||
|
||||
test('perpendicular decoy at the same location does NOT match', () => {
|
||||
// East-west flow crossing the road's midpoint.
|
||||
const decoy = flow(line({ northM: 250, eastM: -250, lengthM: 500, bearingDeg: 90 }), 0.1);
|
||||
const { matches, matchedCount, candidateCount } = matchFlowToRoads([ROAD_NS], [decoy]);
|
||||
assert.equal(matches[0], null);
|
||||
assert.equal(matchedCount, 0);
|
||||
// The decoy IS within 35 m of at least one sample — it's a candidate, just
|
||||
// bearing-rejected. Coverage denominator must reflect that.
|
||||
assert.equal(candidateCount, 1);
|
||||
});
|
||||
|
||||
test('parallel flow offset by more than 35 m does not match', () => {
|
||||
const farFlow = flow(line({ lengthM: 500, bearingDeg: 0, eastM: 50 }), 0.2);
|
||||
const { matches, matchedCount, candidateCount } = matchFlowToRoads([ROAD_NS], [farFlow]);
|
||||
assert.equal(matches[0], null);
|
||||
assert.equal(matchedCount, 0);
|
||||
assert.equal(candidateCount, 0);
|
||||
});
|
||||
|
||||
test('closure propagates to the matched road', () => {
|
||||
const closed = flow(line({ lengthM: 500, bearingDeg: 0, eastM: 2 }), 0, { closure: true });
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [closed]);
|
||||
assert.ok(matches[0], 'closed road should still match');
|
||||
assert.equal(matches[0].closure, true);
|
||||
});
|
||||
|
||||
test('closure propagates when only PART of the road is closed', () => {
|
||||
const openHalf = flow(line({ lengthM: 250, bearingDeg: 0, eastM: 2 }), 0.9);
|
||||
const closedHalf = flow(line({ northM: 250, lengthM: 250, bearingDeg: 0, eastM: 2 }), 0, { closure: true });
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [openHalf, closedHalf]);
|
||||
assert.ok(matches[0]);
|
||||
assert.equal(matches[0].closure, true);
|
||||
});
|
||||
|
||||
test('level is the median across matched samples (thirds at 0.2/0.4/0.9 -> 0.4)', () => {
|
||||
const thirds = [
|
||||
flow(line({ lengthM: 168, bearingDeg: 0, eastM: 2 }), 0.2),
|
||||
flow(line({ northM: 168, lengthM: 166, bearingDeg: 0, eastM: 2 }), 0.4),
|
||||
flow(line({ northM: 334, lengthM: 166, bearingDeg: 0, eastM: 2 }), 0.9),
|
||||
];
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], thirds);
|
||||
assert.ok(matches[0]);
|
||||
assert.ok(Math.abs(matches[0].level - 0.4) < 1e-9, `median was ${matches[0].level}`);
|
||||
});
|
||||
|
||||
test('a road with flow on only a short stretch (< half its samples) stays null', () => {
|
||||
// Flow covers only the first ~15% of the road — 1 of 7 samples at best.
|
||||
const stub = flow(line({ lengthM: 75, bearingDeg: 0, eastM: 2 }), 0.5);
|
||||
const { matches, matchedCount, candidateCount } = matchFlowToRoads([ROAD_NS], [stub]);
|
||||
assert.equal(matches[0], null);
|
||||
assert.equal(matchedCount, 0);
|
||||
assert.equal(candidateCount, 1); // it had candidates, they were just too few
|
||||
});
|
||||
|
||||
test('multiple roads: results stay parallel to the input array', () => {
|
||||
const roadFar = { coords: line({ eastM: 5000, lengthM: 500, bearingDeg: 0 }), type: 'residential' };
|
||||
const flows = [flow(line({ lengthM: 500, bearingDeg: 0, eastM: 3 }), 0.6)];
|
||||
const { matches, matchedCount } = matchFlowToRoads([roadFar, ROAD_NS], flows);
|
||||
assert.equal(matches.length, 2);
|
||||
assert.equal(matches[0], null);
|
||||
assert.ok(matches[1]);
|
||||
assert.equal(matchedCount, 1);
|
||||
});
|
||||
|
||||
test('sparse-vertex flow polylines (long coord pairs) still match everywhere', () => {
|
||||
// One 500 m segment as a single coord pair — midpoint hashing alone would
|
||||
// miss samples near the ends; subdivision must cover them.
|
||||
const sparse = flow([
|
||||
ROAD_NS.coords[0],
|
||||
ROAD_NS.coords[ROAD_NS.coords.length - 1],
|
||||
], 0.5);
|
||||
const { matches } = matchFlowToRoads([ROAD_NS], [sparse]);
|
||||
assert.ok(matches[0], 'sparse flow polyline should match the full road');
|
||||
});
|
||||
|
||||
// ── degenerate inputs ───────────────────────────────────────
|
||||
|
||||
test('empty inputs are safe', () => {
|
||||
assert.deepEqual(matchFlowToRoads([], []), { matches: [], matchedCount: 0, candidateCount: 0 });
|
||||
const noFlow = matchFlowToRoads([ROAD_NS], []);
|
||||
assert.deepEqual(noFlow.matches, [null]);
|
||||
assert.equal(noFlow.candidateCount, 0);
|
||||
assert.deepEqual(matchFlowToRoads([], [flow(line({ lengthM: 100 }), 0.5)]).matches, []);
|
||||
});
|
||||
|
||||
test('degenerate road (single/zero-length coords) stays null without throwing', () => {
|
||||
const dot = { coords: [[LON0, LAT0]], type: 'residential' };
|
||||
const zero = { coords: [[LON0, LAT0], [LON0, LAT0]], type: 'residential' };
|
||||
const flows = [flow(line({ lengthM: 100 }), 0.5)];
|
||||
const { matches } = matchFlowToRoads([dot, zero], flows);
|
||||
assert.deepEqual(matches, [null, null]);
|
||||
});
|
||||
|
||||
// ── median helper ───────────────────────────────────────────
|
||||
|
||||
test('median: odd count picks the middle', () => {
|
||||
assert.equal(median([0.9, 0.2, 0.4]), 0.4);
|
||||
});
|
||||
|
||||
test('median: even count averages the two middles', () => {
|
||||
assert.equal(median([0.2, 0.4, 0.6, 1.0]), 0.5);
|
||||
});
|
||||
|
||||
test('median: single value and empty', () => {
|
||||
assert.equal(median([0.7]), 0.7);
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tilesForBounds } from './tomtomTiles.js';
|
||||
|
||||
/**
|
||||
* @file TomTom traffic-flow vector-tile client: fetch + MVT decode.
|
||||
*
|
||||
* Fetches flow tiles from the local `/api/tomtom/flow/{z}/{x}/{y}.pbf` proxy
|
||||
* (the TomTom key never reaches the browser) and decodes the Mapbox Vector
|
||||
* Tile layer "Traffic flow" into plain lon/lat polylines with congestion
|
||||
* attributes. Consumed by the traffic layer's live mode
|
||||
* (`src/data/traffic.js` → `src/data/flowMatch.js`).
|
||||
*
|
||||
* Segment shape: `{coords: [[lon,lat],…], trafficLevel: 0..1, roadType: string,
|
||||
* closure: boolean}` — `trafficLevel` is TomTom's current/free-flow speed
|
||||
* ratio (1 = free flow). Features with a missing/non-finite `traffic_level`
|
||||
* are skipped unless `road_closure` is true (closures decode with level 0).
|
||||
*
|
||||
* Deps: `pbf@5` (PbfReader) + `@mapbox/vector-tile@3` — both tiny and
|
||||
* tree-shakeable; decoding happens client-side so the proxy stays a dumb
|
||||
* binary cache.
|
||||
*
|
||||
* @module data/flowTiles
|
||||
*/
|
||||
|
||||
export { tilesForBounds };
|
||||
|
||||
/** @const {string} MVT layer name in TomTom flow tiles (verified live 2026-07-16). */
|
||||
const FLOW_LAYER_NAME = 'Traffic flow';
|
||||
/** @const {number} Ms — per-tile decode cache TTL (matches the proxy's 120 s tile TTL). */
|
||||
const DECODE_CACHE_TTL_MS = 120_000;
|
||||
/** @const {number} Max decoded tiles kept in memory before oldest-entry eviction. */
|
||||
const DECODE_CACHE_MAX_ENTRIES = 64;
|
||||
|
||||
/**
|
||||
* Decoded-tile cache keyed by "z/x/y".
|
||||
* @type {Map<string, {at:number, segments:Array}>}
|
||||
*/
|
||||
const _decodeCache = new Map();
|
||||
/** @type {number} Session count of tile requests issued to the proxy (decode-cache misses). */
|
||||
let _tilesFetched = 0;
|
||||
|
||||
/**
|
||||
* Decode one TomTom flow tile (MVT protobuf) into flow segments.
|
||||
*
|
||||
* @param {Uint8Array|Buffer|ArrayBuffer} data - Raw .pbf tile bytes.
|
||||
* @param {number} z - Tile zoom (for tile-local → lon/lat projection).
|
||||
* @param {number} x - Tile column.
|
||||
* @param {number} y - Tile row.
|
||||
* @returns {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>}
|
||||
* Flow polylines in [lon, lat] degrees. Returns [] for undecodable buffers
|
||||
* or tiles without a "Traffic flow" layer (defensive — a corrupt tile must
|
||||
* not kill the traffic layer).
|
||||
*/
|
||||
export function decodeFlowTile(data, z, x, y) {
|
||||
let layer;
|
||||
try {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
layer = tile.layers[FLOW_LAYER_NAME];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!layer) return [];
|
||||
|
||||
const segments = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
let feature;
|
||||
let geometry;
|
||||
try {
|
||||
feature = layer.feature(i);
|
||||
geometry = feature.toGeoJSON(x, y, z).geometry;
|
||||
} catch {
|
||||
continue; // one malformed feature must not drop the tile
|
||||
}
|
||||
const props = feature.properties || {};
|
||||
const closure = props.road_closure === true || props.road_closure === 'true';
|
||||
const rawLevel = props.traffic_level;
|
||||
const hasLevel = typeof rawLevel === 'number' && Number.isFinite(rawLevel);
|
||||
// Skip features we can't color — unless closed (closures render dot-free
|
||||
// regardless of level, so they stay useful without one).
|
||||
if (!hasLevel && !closure) continue;
|
||||
const trafficLevel = hasLevel ? Math.min(1, Math.max(0, rawLevel)) : 0;
|
||||
const roadType = typeof props.road_type === 'string' ? props.road_type : '';
|
||||
|
||||
const lines = geometry.type === 'LineString'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'MultiLineString'
|
||||
? geometry.coordinates
|
||||
: [];
|
||||
for (const coords of lines) {
|
||||
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||
segments.push({ coords, trafficLevel, roadType, closure });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Insert into the decode cache with oldest-entry eviction. */
|
||||
function cacheSet(key, entry) {
|
||||
if (!_decodeCache.has(key) && _decodeCache.size >= DECODE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = _decodeCache.keys().next().value;
|
||||
_decodeCache.delete(oldest);
|
||||
}
|
||||
_decodeCache.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + decode all flow tiles covering the given bounds.
|
||||
*
|
||||
* Tiles are fetched from the local proxy in parallel; each decoded tile is
|
||||
* cached in memory for 120 s (keyed z/x/y), so repeat calls for the same
|
||||
* viewport are free. Partial tile failures return the segments that DID
|
||||
* decode (last-good philosophy); the promise rejects only when every tile
|
||||
* failed (e.g. keyless 503, aborted signal, proxy down).
|
||||
*
|
||||
* @param {{south:number, west:number, north:number, east:number}} bounds - Degrees.
|
||||
* @param {Object} [opts]
|
||||
* @param {AbortSignal} [opts.signal] - Abort signal (camera moved / layer disabled).
|
||||
* @param {number} [opts.zoom=12] - Flow tile zoom level.
|
||||
* @returns {Promise<Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean}>>}
|
||||
* Flat array of flow segments across all covering tiles.
|
||||
*/
|
||||
export async function fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
|
||||
const tiles = tilesForBounds(bounds, zoom);
|
||||
if (tiles.length === 0) return [];
|
||||
const now = Date.now();
|
||||
|
||||
const results = await Promise.allSettled(tiles.map(async ({ z, x, y }) => {
|
||||
const key = `${z}/${x}/${y}`;
|
||||
const cached = _decodeCache.get(key);
|
||||
if (cached && now - cached.at < DECODE_CACHE_TTL_MS) return cached.segments;
|
||||
|
||||
_tilesFetched += 1;
|
||||
const res = await fetch(`/api/tomtom/flow/${z}/${x}/${y}.pbf`, { signal });
|
||||
if (!res.ok) throw new Error(`flow tile ${key}: HTTP ${res.status}`);
|
||||
const segments = decodeFlowTile(await res.arrayBuffer(), z, x, y);
|
||||
cacheSet(key, { at: Date.now(), segments });
|
||||
return segments;
|
||||
}));
|
||||
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
if (fulfilled.length === 0) {
|
||||
throw results[0].reason instanceof Error ? results[0].reason : new Error('flow fetch failed');
|
||||
}
|
||||
return fulfilled.flatMap((r) => r.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session diagnostics for `getStats()` surfaces.
|
||||
* @returns {{tilesFetched:number}} Count of tile requests issued to the proxy
|
||||
* this session (decode-cache hits excluded).
|
||||
*/
|
||||
export function getFlowSessionStats() {
|
||||
return { tilesFetched: _tilesFetched };
|
||||
}
|
||||
|
||||
/** Clear the decode cache (tests + layer teardown). Session stats persist. */
|
||||
export function resetFlowTileCache() {
|
||||
_decodeCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// src/data/flowTiles.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
decodeFlowTile,
|
||||
fetchFlowForBounds,
|
||||
tilesForBounds,
|
||||
getFlowSessionStats,
|
||||
resetFlowTileCache,
|
||||
} from './flowTiles.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16).
|
||||
const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf');
|
||||
const FIXTURE_TILE = { z: 12, x: 935, y: 1686 };
|
||||
|
||||
function loadFixture() {
|
||||
return fs.readFileSync(FIXTURE);
|
||||
}
|
||||
|
||||
// ── decodeFlowTile against the real fixture ─────────────────
|
||||
|
||||
test('fixture decode: more than 50 flow segments', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.length > 50, `got ${segments.length}`);
|
||||
});
|
||||
|
||||
test('fixture decode: every trafficLevel is within [0, 1]', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`);
|
||||
assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: all coordinates land in downtown Austin', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short');
|
||||
for (const [lon, lat] of s.coords) {
|
||||
assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`);
|
||||
assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found');
|
||||
});
|
||||
|
||||
test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => {
|
||||
const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y);
|
||||
for (const s of segments) {
|
||||
assert.equal(typeof s.roadType, 'string');
|
||||
assert.equal(typeof s.closure, 'boolean');
|
||||
}
|
||||
// The fixture carries real closures — closure decoding is exercised, not vacuous.
|
||||
assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture');
|
||||
});
|
||||
|
||||
test('decode of a non-MVT buffer returns [] (defensive)', () => {
|
||||
assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []);
|
||||
});
|
||||
|
||||
// ── tilesForBounds (re-exported slippy math) ────────────────
|
||||
|
||||
test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => {
|
||||
const tiles = tilesForBounds({
|
||||
south: 30.2672 - 0.001, north: 30.2672 + 0.001,
|
||||
west: -97.7431 - 0.001, east: -97.7431 + 0.001,
|
||||
}, 12);
|
||||
assert.ok(
|
||||
tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686),
|
||||
`fixture tile missing: ${JSON.stringify(tiles)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── fetchFlowForBounds (stubbed fetch: cache + abort) ───────
|
||||
|
||||
/** Bounds fully inside the fixture tile. */
|
||||
const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 };
|
||||
|
||||
function stubFetch(impl) {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = impl;
|
||||
return () => { globalThis.fetch = original; };
|
||||
}
|
||||
|
||||
test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => {
|
||||
resetFlowTileCache();
|
||||
const calls = [];
|
||||
const restore = stubFetch(async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(loadFixture(), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-protobuf' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
const segments = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/);
|
||||
assert.ok(segments.length > 50);
|
||||
assert.ok(getFlowSessionStats().tilesFetched >= 1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => {
|
||||
resetFlowTileCache();
|
||||
let calls = 0;
|
||||
const restore = stubFetch(async () => {
|
||||
calls += 1;
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const first = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
const second = await fetchFlowForBounds(FIXTURE_BOUNDS);
|
||||
assert.equal(calls, 1, 'second call must be served from the decode cache');
|
||||
assert.equal(second.length, first.length);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async (url, opts) => {
|
||||
// Mimic real fetch abort semantics.
|
||||
if (opts?.signal?.aborted) {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
throw err;
|
||||
}
|
||||
return new Response(loadFixture(), { status: 200 });
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }),
|
||||
(err) => err.name === 'AbortError'
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => {
|
||||
resetFlowTileCache();
|
||||
const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 }));
|
||||
try {
|
||||
await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs';
|
||||
|
||||
/**
|
||||
* Allocation budgets cover two intentionally different call shapes:
|
||||
* - Prebuilt-option pure paths: <=16 B/call for each treatment helper.
|
||||
* - Production sprite tick: <=212 B/tick when its caller builds both fresh
|
||||
* options literals. The latter preserves roughly 25% headroom over the
|
||||
* locally measured steady-state median instead of pretending those caller
|
||||
* allocations belong to the pure helper bound.
|
||||
*/
|
||||
test('converged focus treatment stays within the GC-bracketed allocation budget', (t) => {
|
||||
if (!isCalibratedAllocationRuntime()) {
|
||||
return t.skip(`allocation budgets are calibrated for Node 24; running ${process.versions.node}`);
|
||||
}
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--expose-gc', 'scripts/focus-allocation-check.mjs'],
|
||||
{ cwd: process.cwd(), encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const report = JSON.parse(result.stdout.trim());
|
||||
assert.ok(report.advanceSpriteFocus.roundedMedian <= 16, result.stdout);
|
||||
assert.ok(report.applyAircraftBillboardTreatment.roundedMedian <= 16, result.stdout);
|
||||
assert.ok(report.productionSpriteTick.roundedMedian <= 212, result.stdout);
|
||||
});
|
||||
@@ -0,0 +1,512 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* Focus-aware sprite treatment shared by the live contact layers.
|
||||
*
|
||||
* The selected target is protected by changing ambient sprite alpha, never by
|
||||
* collection ordering: Cesium's translucent distance sort is not stable for
|
||||
* collections with similar bounding volumes. This is a narrow amendment to
|
||||
* the normal always-visible contact rule. Ambient contacts remain present and
|
||||
* never fall below `dimFloor`; only sprites competing with the tracked target
|
||||
* are smoothly de-emphasized.
|
||||
*
|
||||
* The production consumers sample this service every 80 ms. The default
|
||||
* 300 ms attack and 600 ms release deliberately span several consumer
|
||||
* quanta, so a contact receives at least four distinct rendered emphasis
|
||||
* values during attack instead of crossing the range in one or two ticks.
|
||||
*/
|
||||
|
||||
export const DEFAULT_FOCUS_DEEMPHASIS_PARAMS = Object.freeze({
|
||||
paddingPx: 18,
|
||||
dimFloor: 0.25,
|
||||
nearerBehavior: 'allow',
|
||||
hysteresisPx: 6,
|
||||
distanceHysteresisRatio: 0.08,
|
||||
attackMs: 300,
|
||||
releaseMs: 600,
|
||||
writeEpsilon: 0.005,
|
||||
});
|
||||
|
||||
let _params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
||||
let _focusTarget = null;
|
||||
const _spriteStates = new WeakMap();
|
||||
const _scratchScreen = new Cesium.Cartesian2();
|
||||
const _advanceResult = {
|
||||
factor: 1,
|
||||
changed: false,
|
||||
transitioning: false,
|
||||
active: false,
|
||||
desired: 1,
|
||||
};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function resolvedParams(overrides) {
|
||||
if (!overrides || overrides === _params) return _params;
|
||||
return { ..._params, ...overrides };
|
||||
}
|
||||
|
||||
function focusTargetEmphasisResolved(
|
||||
spriteScreenPosition,
|
||||
spriteCameraDistance,
|
||||
target,
|
||||
tuning,
|
||||
previouslyCompeting,
|
||||
spriteHalfWidthPx,
|
||||
spriteHalfHeightPx,
|
||||
previouslyFarther,
|
||||
) {
|
||||
if (!target?.screenRect || !spriteScreenPosition) return 1;
|
||||
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
|
||||
|
||||
// Published rects already contain their publication-time padding. The
|
||||
// delta keeps direct pure-function calls and runtime A/B overrides honest.
|
||||
// The ambient extent makes overlap mean visual overlap, not center-point
|
||||
// containment: a class-sized icon whose edge intrudes into the focus rect
|
||||
// yields even when its center remains just outside it.
|
||||
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
|
||||
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
|
||||
const expansionX = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
|
||||
const expansionY = paddingDelta + hysteresis
|
||||
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
|
||||
const rect = target.screenRect;
|
||||
const inside = spriteScreenPosition.x >= rect.left - expansionX
|
||||
&& spriteScreenPosition.x <= rect.right + expansionX
|
||||
&& spriteScreenPosition.y >= rect.top - expansionY
|
||||
&& spriteScreenPosition.y <= rect.bottom + expansionY;
|
||||
if (!inside) return 1;
|
||||
|
||||
const floor = clamp(tuning.dimFloor, 0.01, 1);
|
||||
if (tuning.nearerBehavior === 'dim') return floor;
|
||||
|
||||
const distanceBand = Number.isFinite(target.cameraDistance)
|
||||
? Math.max(0, target.cameraDistance * tuning.distanceHysteresisRatio)
|
||||
: 0;
|
||||
const fartherThreshold = Number.isFinite(target.cameraDistance)
|
||||
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
|
||||
: Number.NaN;
|
||||
const farther = !Number.isFinite(spriteCameraDistance)
|
||||
|| !Number.isFinite(fartherThreshold)
|
||||
|| spriteCameraDistance >= fartherThreshold;
|
||||
if (farther) return floor;
|
||||
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
|
||||
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
|
||||
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
|
||||
if (Math.abs(to - from) < Number.EPSILON) return to;
|
||||
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
|
||||
if (duration <= 0) return to;
|
||||
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
|
||||
// Cubic ease-out reaches the exact endpoint at the configured duration and
|
||||
// stays continuous when a moving contact reverses direction mid-transition.
|
||||
const eased = 1 - ((1 - t) ** 3);
|
||||
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the runtime tuning values used by every focus consumer.
|
||||
* Unknown keys are ignored so evidence-harness overrides cannot accidentally
|
||||
* grow an undocumented rendering contract.
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [patch]
|
||||
* @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current normalized values.
|
||||
*/
|
||||
export function setFocusDeemphasisParams(patch = {}) {
|
||||
const next = { ..._params };
|
||||
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
|
||||
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
|
||||
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
|
||||
next.nearerBehavior = patch.nearerBehavior;
|
||||
}
|
||||
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
|
||||
if (Number.isFinite(patch.distanceHysteresisRatio)) {
|
||||
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
|
||||
}
|
||||
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
|
||||
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
|
||||
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
|
||||
_params = next;
|
||||
return { ..._params };
|
||||
}
|
||||
|
||||
/** @returns {typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS} Current runtime tuning. */
|
||||
export function getFocusDeemphasisParams() {
|
||||
return _params;
|
||||
}
|
||||
|
||||
/** Restore production defaults. Primarily useful for deterministic QA. */
|
||||
export function resetFocusDeemphasisParams() {
|
||||
_params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a focus target from the exact world position already cached by its
|
||||
* tracking/render path. Callers must never re-derive a position here: a second
|
||||
* propagation/dead-reckoning sample at a different frame phase recreates the
|
||||
* tracked-target jitter class this service is designed to avoid.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.ownerLayer
|
||||
* @param {string|number} input.id
|
||||
* @param {Cesium.Scene} input.scene
|
||||
* @param {Cesium.Camera} input.camera
|
||||
* @param {Cesium.Cartesian3} input.displayPosition Cached display position.
|
||||
* @param {number} [input.widthPx=24] Projected visual width before padding.
|
||||
* @param {number} [input.heightPx=24] Projected visual height before padding.
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [input.params]
|
||||
* @returns {object|null} Published target, or null when it cannot be projected.
|
||||
*/
|
||||
export function publishFocusTargetFromCachedPosition({
|
||||
ownerLayer,
|
||||
id,
|
||||
scene,
|
||||
camera,
|
||||
displayPosition,
|
||||
widthPx = 24,
|
||||
heightPx = 24,
|
||||
params,
|
||||
}) {
|
||||
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
}
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
|
||||
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
|
||||
clearFocusTarget(ownerLayer, id);
|
||||
return null;
|
||||
}
|
||||
const tuning = resolvedParams(params);
|
||||
const padding = tuning.paddingPx;
|
||||
const halfWidth = Math.max(1, widthPx) / 2;
|
||||
const halfHeight = Math.max(1, heightPx) / 2;
|
||||
const appeared = !_focusTarget;
|
||||
_focusTarget = {
|
||||
ownerLayer,
|
||||
id,
|
||||
screenRect: {
|
||||
left: screen.x - halfWidth - padding,
|
||||
top: screen.y - halfHeight - padding,
|
||||
right: screen.x + halfWidth + padding,
|
||||
bottom: screen.y + halfHeight + padding,
|
||||
},
|
||||
paddingPx: padding,
|
||||
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
|
||||
frameNumber: scene.frameState?.frameNumber ?? -1,
|
||||
};
|
||||
if (appeared) {
|
||||
for (const listener of _focusAppearListeners) {
|
||||
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
|
||||
}
|
||||
}
|
||||
return _focusTarget;
|
||||
}
|
||||
|
||||
const _focusAppearListeners = new Set();
|
||||
|
||||
/**
|
||||
* Notify on the null→set edge of the shared focus target. Consumers that
|
||||
* self-suspend their per-frame focus work (e.g. CCTV's projection loop) use
|
||||
* this single edge to re-arm; the set→null edge needs no event because the
|
||||
* consumer's own loop is still running through the relax tail and
|
||||
* self-stops via focusPassIsNeeded. (perf wave 1)
|
||||
* @param {() => void} listener Called when a focus target is published after none existed.
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
export function onFocusTargetAppear(listener) {
|
||||
_focusAppearListeners.add(listener);
|
||||
return () => _focusAppearListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the focus only when the caller still owns it. This prevents idle
|
||||
* tracked-layer ticks from erasing a target just published by another layer.
|
||||
* @param {string} [ownerLayer]
|
||||
* @param {string|number|null} [id]
|
||||
*/
|
||||
export function clearFocusTarget(ownerLayer, id = null) {
|
||||
if (!_focusTarget) return;
|
||||
if (ownerLayer && _focusTarget.ownerLayer !== ownerLayer) return;
|
||||
if (id !== null && id !== undefined && _focusTarget.id !== id) return;
|
||||
_focusTarget = null;
|
||||
}
|
||||
|
||||
/** @returns {object|null} The current shared focus target. */
|
||||
export function getFocusTarget() {
|
||||
return _focusTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure focus decision for one ambient sprite.
|
||||
*
|
||||
* `previouslyCompeting` is state, not tuning: it expands the exit edge by
|
||||
* `hysteresisPx` without moving the entry edge. Farther sprites dim fully;
|
||||
* nearer treatment deliberately remains a runtime tuning choice.
|
||||
*
|
||||
* @param {{x:number,y:number}|null} spriteScreenPosition
|
||||
* @param {number} spriteCameraDistance
|
||||
* @param {object|null} target
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
||||
* @param {boolean} [previouslyCompeting=false]
|
||||
* @param {number} [spriteHalfWidthPx=0] Ambient visual half-width.
|
||||
* @param {number} [spriteHalfHeightPx=spriteHalfWidthPx] Ambient visual half-height.
|
||||
* @param {boolean} [previouslyFarther=previouslyCompeting] Prior distance-side state.
|
||||
* @returns {number} Target emphasis in [dimFloor, 1].
|
||||
*/
|
||||
export function focusTargetEmphasis(
|
||||
spriteScreenPosition,
|
||||
spriteCameraDistance,
|
||||
target,
|
||||
params,
|
||||
previouslyCompeting = false,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
previouslyFarther = previouslyCompeting,
|
||||
) {
|
||||
const tuning = resolvedParams(params);
|
||||
return focusTargetEmphasisResolved(
|
||||
spriteScreenPosition,
|
||||
spriteCameraDistance,
|
||||
target,
|
||||
tuning,
|
||||
previouslyCompeting,
|
||||
spriteHalfWidthPx,
|
||||
spriteHalfHeightPx,
|
||||
previouslyFarther,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure time-based easing step. Attack means motion toward de-emphasis;
|
||||
* release means restoration toward full emphasis.
|
||||
* @param {number} current
|
||||
* @param {number} target
|
||||
* @param {number} elapsedMs
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function smoothFocusEmphasis(current, target, elapsedMs, params) {
|
||||
const tuning = resolvedParams(params);
|
||||
return smoothFocusEmphasisResolved(current, target, elapsedMs, tuning);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one sprite's persistent focus state. The caller owns the eventual
|
||||
* color write so focus can compose with freshness/horizon alpha at one site.
|
||||
* @param {object} sprite Stable billboard/point primitive identity.
|
||||
* @param {object} input
|
||||
* @param {{x:number,y:number}|null} input.screenPosition
|
||||
* @param {number} input.cameraDistance
|
||||
* @param {number} input.nowMs
|
||||
* @param {object|null} [input.target]
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [input.params]
|
||||
* @param {number} [input.spriteHalfWidthPx=0]
|
||||
* @param {number} [input.spriteHalfHeightPx=input.spriteHalfWidthPx]
|
||||
*
|
||||
* IMPORTANT: the returned object is a module-owned mutable singleton. Consume
|
||||
* every needed property before the next call; never retain it as a snapshot.
|
||||
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
||||
*/
|
||||
export function advanceSpriteFocus(sprite, {
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
}) {
|
||||
const tuning = resolvedParams(params);
|
||||
let state = _spriteStates.get(sprite);
|
||||
if (!state) {
|
||||
if (!target) {
|
||||
_advanceResult.factor = 1;
|
||||
_advanceResult.changed = false;
|
||||
_advanceResult.transitioning = false;
|
||||
_advanceResult.active = false;
|
||||
_advanceResult.desired = 1;
|
||||
return _advanceResult;
|
||||
}
|
||||
state = {
|
||||
factor: 1,
|
||||
desired: 1,
|
||||
transitionFrom: 1,
|
||||
transitionStartMs: nowMs,
|
||||
wasFarther: false,
|
||||
};
|
||||
_spriteStates.set(sprite, state);
|
||||
}
|
||||
|
||||
// First sample the OLD transition at this timestamp. If the desired state
|
||||
// changes, the new transition must begin at the value already on screen,
|
||||
// not the prior tick's stale anchor. This preserves progress during a
|
||||
// retarget and, paired with range hysteresis, prevents boundary chatter
|
||||
// from continually restarting at 1.0.
|
||||
const oldElapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
||||
const current = smoothFocusEmphasisResolved(
|
||||
state.transitionFrom,
|
||||
state.desired,
|
||||
oldElapsedMs,
|
||||
tuning,
|
||||
);
|
||||
const desired = focusTargetEmphasisResolved(
|
||||
screenPosition,
|
||||
cameraDistance,
|
||||
target,
|
||||
tuning,
|
||||
state.desired < 1,
|
||||
spriteHalfWidthPx,
|
||||
spriteHalfHeightPx,
|
||||
state.wasFarther,
|
||||
);
|
||||
if (desired !== state.desired) {
|
||||
state.transitionFrom = current;
|
||||
state.transitionStartMs = nowMs;
|
||||
state.desired = desired;
|
||||
}
|
||||
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
|
||||
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
|
||||
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
|
||||
state.factor = next;
|
||||
state.desired = desired;
|
||||
state.wasFarther = tuning.nearerBehavior !== 'dim'
|
||||
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
|
||||
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
|
||||
const active = Math.abs(next - 1) > tuning.writeEpsilon;
|
||||
|
||||
if (!target && !transitioning && next === 1) _spriteStates.delete(sprite);
|
||||
_advanceResult.factor = next;
|
||||
_advanceResult.changed = changed;
|
||||
_advanceResult.transitioning = transitioning;
|
||||
_advanceResult.active = active;
|
||||
_advanceResult.desired = desired;
|
||||
return _advanceResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project and advance one world-anchored sprite using an owning layer's
|
||||
* existing render pass.
|
||||
* @param {object} sprite Stable primitive identity.
|
||||
* @param {Cesium.Cartesian3} position Current cached/rendered world position.
|
||||
* @param {Cesium.Scene} scene
|
||||
* @param {Cesium.Camera} camera
|
||||
* @param {number} nowMs
|
||||
* @param {object|null} [target]
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
||||
* @param {number} [spriteHalfWidthPx=0]
|
||||
* @param {number} [spriteHalfHeightPx=spriteHalfWidthPx]
|
||||
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
|
||||
*/
|
||||
export function advanceProjectedSpriteFocus(
|
||||
sprite,
|
||||
position,
|
||||
scene,
|
||||
camera,
|
||||
nowMs,
|
||||
target = _focusTarget,
|
||||
params,
|
||||
spriteHalfWidthPx = 0,
|
||||
spriteHalfHeightPx = spriteHalfWidthPx,
|
||||
) {
|
||||
// Common case: nothing tracked and this sprite holds no dimming state —
|
||||
// advanceSpriteFocus would return identity without reading the projection,
|
||||
// so skip the worldToWindowCoordinates round trip entirely (it was N
|
||||
// wasted projections per fleet tick with nothing tracked). Sprites still
|
||||
// relaxing keep their state entry until fully settled (see the delete at
|
||||
// the transition tail), so they keep projecting until done. (perf item 8)
|
||||
if (!target && !_spriteStates.has(sprite)) {
|
||||
_advanceResult.factor = 1;
|
||||
_advanceResult.changed = false;
|
||||
_advanceResult.transitioning = false;
|
||||
_advanceResult.active = false;
|
||||
_advanceResult.desired = 1;
|
||||
return _advanceResult;
|
||||
}
|
||||
const screen = position && scene
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
|
||||
: null;
|
||||
const cameraDistance = position && camera?.positionWC
|
||||
? Cesium.Cartesian3.distance(camera.positionWC, position)
|
||||
: Number.NaN;
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: screen,
|
||||
cameraDistance,
|
||||
nowMs,
|
||||
target,
|
||||
params,
|
||||
spriteHalfWidthPx,
|
||||
spriteHalfHeightPx,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A gated consumer must keep ticking until every previously dimmed sprite has
|
||||
* returned to identity, even when no target remains.
|
||||
* @param {object|null} target
|
||||
* @param {number} activeCount Count of emphasis factors outside the 1.0 deadband.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusPassIsNeeded(target, activeCount) {
|
||||
return target !== null && target !== undefined
|
||||
|| (Number.isFinite(activeCount) && activeCount > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match Cesium's shader-side NearFarScalar interpolation for rendered extents.
|
||||
* Cesium interpolates against squared camera distance with a 0.2 exponent.
|
||||
* @param {{near:number,nearValue:number,far:number,farValue:number}|null} scalar
|
||||
* @param {number} cameraDistanceM
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
|
||||
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
|
||||
const nearSq = scalar.near * scalar.near;
|
||||
const farSq = scalar.far * scalar.far;
|
||||
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
|
||||
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
|
||||
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
|
||||
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
|
||||
}
|
||||
|
||||
/** Shared virtual time used only by the DEV evidence seam. */
|
||||
let _focusEvidenceNowMs = null;
|
||||
|
||||
/** Resolve production time unless deterministic evidence capture owns it. */
|
||||
export function focusNowMs(productionNowMs) {
|
||||
return _focusEvidenceNowMs ?? productionNowMs;
|
||||
}
|
||||
|
||||
/** Set deterministic evidence time; called only through development seams. */
|
||||
export function setFocusEvidenceNowMs(nowMs) {
|
||||
_focusEvidenceNowMs = Number.isFinite(nowMs) ? nowMs : null;
|
||||
return _focusEvidenceNowMs;
|
||||
}
|
||||
|
||||
/** Advance deterministic evidence time without consulting a wall clock. */
|
||||
export function advanceFocusEvidenceNowMs(deltaMs) {
|
||||
if (_focusEvidenceNowMs === null) return null;
|
||||
_focusEvidenceNowMs += Math.max(0, Number.isFinite(deltaMs) ? deltaMs : 0);
|
||||
return _focusEvidenceNowMs;
|
||||
}
|
||||
|
||||
/** Forget state when a primitive is removed from its owning collection. */
|
||||
export function forgetSpriteFocus(sprite) {
|
||||
if (sprite) _spriteStates.delete(sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deadband predicate shared by consumers' final alpha write sites.
|
||||
* @param {number} currentAlpha
|
||||
* @param {number} nextAlpha
|
||||
* @param {Partial<typeof DEFAULT_FOCUS_DEEMPHASIS_PARAMS>} [params]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
|
||||
const tuning = resolvedParams(params);
|
||||
return !Number.isFinite(currentAlpha)
|
||||
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_FOCUS_DEEMPHASIS_PARAMS,
|
||||
advanceFocusEvidenceNowMs,
|
||||
advanceSpriteFocus,
|
||||
focusNowMs,
|
||||
focusTargetEmphasis,
|
||||
nearFarScalarValueAtDistance,
|
||||
setFocusEvidenceNowMs,
|
||||
smoothFocusEmphasis,
|
||||
} from './focusDeemphasis.js';
|
||||
|
||||
const params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS, paddingPx: 0 };
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
|
||||
test('focus decision dims a farther sprite inside and preserves one outside', () => {
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 1200, target, params), 0.25);
|
||||
assert.equal(focusTargetEmphasis({ x: 61, y: 50 }, 1200, target, params), 1);
|
||||
});
|
||||
|
||||
test('focus decision keeps nearer behavior tunable across allow, dim, and partial', () => {
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 800, target, { ...params, nearerBehavior: 'allow' }), 1);
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 800, target, { ...params, nearerBehavior: 'dim' }), 0.25);
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 800, target, { ...params, nearerBehavior: 'partial' }), 0.625);
|
||||
});
|
||||
|
||||
test('focus decision applies padding and hysteresis at the boundary', () => {
|
||||
assert.equal(focusTargetEmphasis({ x: 64, y: 50 }, 1200, target, { ...params, paddingPx: 3 }), 1);
|
||||
assert.equal(focusTargetEmphasis({ x: 63, y: 50 }, 1200, target, { ...params, paddingPx: 3 }), 0.25);
|
||||
assert.equal(focusTargetEmphasis({ x: 65, y: 50 }, 1200, target, { ...params, hysteresisPx: 6 }, false), 1);
|
||||
assert.equal(focusTargetEmphasis({ x: 65, y: 50 }, 1200, target, { ...params, hysteresisPx: 6 }, true), 0.25);
|
||||
});
|
||||
|
||||
test('focus overlap includes the ambient sprite own rendered extent', () => {
|
||||
assert.equal(focusTargetEmphasis({ x: 68, y: 50 }, 1200, target, params, false, 8, 8), 0.25);
|
||||
assert.equal(focusTargetEmphasis({ x: 68, y: 50 }, 1200, target, params, false, 2, 2), 1);
|
||||
});
|
||||
|
||||
test('focus decision never falls below the configured floor', () => {
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 1200, target, { ...params, dimFloor: 0.41 }), 0.41);
|
||||
assert.equal(focusTargetEmphasis({ x: 50, y: 50 }, 1200, target, { ...params, dimFloor: 0 }), 0.01);
|
||||
});
|
||||
|
||||
test('smoothing converges over configured attack and release instead of snapping', () => {
|
||||
const attackMid = smoothFocusEmphasis(1, 0.25, 150, params);
|
||||
assert.ok(attackMid < 1 && attackMid > 0.25, `attack midpoint=${attackMid}`);
|
||||
assert.equal(smoothFocusEmphasis(1, 0.25, 300, params), 0.25);
|
||||
const releaseMid = smoothFocusEmphasis(0.25, 1, 300, params);
|
||||
assert.ok(releaseMid > 0.25 && releaseMid < 1, `release midpoint=${releaseMid}`);
|
||||
assert.equal(smoothFocusEmphasis(0.25, 1, 600, params), 1);
|
||||
});
|
||||
|
||||
test('default attack yields at least four distinct 80 ms sampled values', () => {
|
||||
const samples = [0, 80, 160, 240, 320]
|
||||
.map((elapsedMs) => smoothFocusEmphasis(1, 0.25, elapsedMs, params));
|
||||
assert.ok(new Set(samples.slice(1)).size >= 4, `samples=${samples.join(',')}`);
|
||||
assert.equal(samples.at(-1), 0.25);
|
||||
});
|
||||
|
||||
test('per-sprite state attacks, restores after focus clears, and settles at one', () => {
|
||||
const sprite = {};
|
||||
const input = { screenPosition: { x: 50, y: 50 }, cameraDistance: 1200, target, params };
|
||||
assert.equal(advanceSpriteFocus(sprite, { ...input, nowMs: 0 }).factor, 1);
|
||||
const attack = advanceSpriteFocus(sprite, { ...input, nowMs: 150 });
|
||||
assert.ok(attack.factor < 1 && attack.factor > 0.25);
|
||||
assert.equal(advanceSpriteFocus(sprite, { ...input, nowMs: 300 }).factor, 0.25);
|
||||
assert.equal(advanceSpriteFocus(sprite, { ...input, target: null, nowMs: 300 }).factor, 0.25);
|
||||
const release = advanceSpriteFocus(sprite, { ...input, target: null, nowMs: 600 });
|
||||
assert.ok(release.factor > 0.25 && release.factor < 1);
|
||||
assert.equal(advanceSpriteFocus(sprite, { ...input, target: null, nowMs: 900 }).factor, 1);
|
||||
});
|
||||
|
||||
test('distance chatter reanchors from current progress and converges toward the majority state', () => {
|
||||
const sprite = {};
|
||||
const chatterParams = { ...params, nearerBehavior: 'allow', distanceHysteresisRatio: 0.08 };
|
||||
const distances = [1200, 990, 1010, 995, 1005, 990, 1200, 990, 1010];
|
||||
const factors = distances.map((cameraDistance, index) => advanceSpriteFocus(sprite, {
|
||||
screenPosition: { x: 50, y: 50 },
|
||||
cameraDistance,
|
||||
target,
|
||||
params: chatterParams,
|
||||
nowMs: index * 80,
|
||||
}).factor);
|
||||
for (let index = 1; index < factors.length; index += 1) {
|
||||
assert.ok(factors[index] <= factors[index - 1] + 1e-12, `factors=${factors.join(',')}`);
|
||||
}
|
||||
assert.ok(factors.at(-1) < 0.5, `factors=${factors.join(',')}`);
|
||||
|
||||
const dimSprite = {};
|
||||
const dimFactors = [0, 80, 160, 240].map((nowMs, index) => advanceSpriteFocus(dimSprite, {
|
||||
screenPosition: { x: 50, y: 50 },
|
||||
cameraDistance: index % 2 ? 990 : 1010,
|
||||
target,
|
||||
params: { ...params, nearerBehavior: 'dim' },
|
||||
nowMs,
|
||||
}).factor);
|
||||
assert.ok(dimFactors.at(-1) < 1, `dimFactors=${dimFactors.join(',')}`);
|
||||
});
|
||||
|
||||
test('alternating desired state reanchors from sampled progress and converges to a stable cycle', () => {
|
||||
const sprite = {};
|
||||
const chatterParams = { ...params, nearerBehavior: 'allow', distanceHysteresisRatio: 0.08 };
|
||||
const factors = Array.from({ length: 20 }, (_, index) => advanceSpriteFocus(sprite, {
|
||||
screenPosition: { x: 50, y: 50 },
|
||||
// Cross both sides of the range hysteresis band every tick so `desired`
|
||||
// truly alternates instead of latching in the prior state.
|
||||
cameraDistance: index % 2 === 0 ? 1200 : 800,
|
||||
target,
|
||||
params: chatterParams,
|
||||
nowMs: index * 80,
|
||||
}).factor);
|
||||
|
||||
assert.ok(new Set(factors).size >= 16, `factors=${factors.join(',')}`);
|
||||
const earlyEvenDelta = Math.abs(factors[4] - factors[2]);
|
||||
const lateEvenDelta = Math.abs(factors[18] - factors[16]);
|
||||
const earlyOddDelta = Math.abs(factors[5] - factors[3]);
|
||||
const lateOddDelta = Math.abs(factors[19] - factors[17]);
|
||||
assert.ok(lateEvenDelta < earlyEvenDelta, `even factors=${factors.join(',')}`);
|
||||
assert.ok(lateOddDelta < earlyOddDelta, `odd factors=${factors.join(',')}`);
|
||||
});
|
||||
|
||||
test('advanceSpriteFocus returns its documented module singleton', () => {
|
||||
const sprite = {};
|
||||
const input = { screenPosition: { x: 50, y: 50 }, cameraDistance: 1200, target, params };
|
||||
const first = advanceSpriteFocus(sprite, { ...input, nowMs: 0 });
|
||||
const second = advanceSpriteFocus(sprite, { ...input, nowMs: params.attackMs });
|
||||
assert.strictEqual(first, second);
|
||||
assert.equal(first.factor, params.dimFloor, 'the prior reference reflects the next call');
|
||||
});
|
||||
|
||||
test('evidence clock produces identical alpha sequences across repeated captures', () => {
|
||||
const sequenceOffsets = [0, 80, 160, 240, 320, 400, 560, 720, 880, 1040];
|
||||
const capture = (productionClockBase, productionClockStep) => {
|
||||
setFocusEvidenceNowMs(10_000);
|
||||
const sprite = {};
|
||||
const sequence = sequenceOffsets.map((offset, index) => {
|
||||
if (index > 0) advanceFocusEvidenceNowMs(offset - sequenceOffsets[index - 1]);
|
||||
const nowMs = focusNowMs(productionClockBase + index * productionClockStep);
|
||||
return advanceSpriteFocus(sprite, {
|
||||
screenPosition: { x: 50, y: 50 },
|
||||
cameraDistance: 1200,
|
||||
target: offset <= 320 ? target : null,
|
||||
params,
|
||||
nowMs,
|
||||
}).factor;
|
||||
});
|
||||
setFocusEvidenceNowMs(null);
|
||||
return sequence;
|
||||
};
|
||||
assert.deepEqual(capture(1_000, 1), capture(1_000_000, 9_999));
|
||||
});
|
||||
|
||||
test('NearFarScalar rendered-size interpolation matches Cesium clamp endpoints', () => {
|
||||
const scalar = { near: 1000, nearValue: 3, far: 8_000_000, farValue: 0.5 };
|
||||
assert.equal(nearFarScalarValueAtDistance(scalar, 500), 3);
|
||||
assert.equal(nearFarScalarValueAtDistance(scalar, 9_000_000), 0.5);
|
||||
const middle = nearFarScalarValueAtDistance(scalar, 1_000_000);
|
||||
assert.ok(middle > 0.5 && middle < 3);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyVesselFocusDeemphasis } from './aisLiveVessels.js';
|
||||
import { applyCctvFocusDeemphasis } from './cctv.js';
|
||||
import { applySatellitePointFocusDeemphasis } from './satellites.js';
|
||||
import { DEFAULT_FOCUS_DEEMPHASIS_PARAMS } from './focusDeemphasis.js';
|
||||
|
||||
const params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS, paddingPx: 0 };
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
|
||||
function color(alpha = 1, rgb = 'base') {
|
||||
return {
|
||||
alpha,
|
||||
rgb,
|
||||
withAlpha(nextAlpha) {
|
||||
return color(nextAlpha, rgb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertResumeGate(name, makePass) {
|
||||
test(`${name} gate restores a settled dim contact after untrack`, () => {
|
||||
const { pass, readAlpha } = makePass();
|
||||
let activeCount = 0;
|
||||
const tick = (nowMs, focusTarget) => {
|
||||
const result = pass({ nowMs, target: focusTarget, previousActiveCount: activeCount });
|
||||
activeCount = result.activeCount;
|
||||
return result;
|
||||
};
|
||||
|
||||
assert.equal(tick(0, target).ran, true);
|
||||
const settled = tick(params.attackMs + 1, target);
|
||||
assert.equal(settled.activeCount, 1);
|
||||
assert.equal(readAlpha(), params.dimFloor);
|
||||
|
||||
const releaseStart = tick(params.attackMs + 1, null);
|
||||
assert.equal(releaseStart.ran, true, 'active count keeps the no-target release pass alive');
|
||||
assert.equal(releaseStart.activeCount, 1);
|
||||
|
||||
const restored = tick(params.attackMs + params.releaseMs + 2, null);
|
||||
assert.equal(restored.ran, true);
|
||||
assert.equal(restored.activeCount, 0);
|
||||
assert.equal(readAlpha(), 1);
|
||||
|
||||
assert.equal(tick(params.attackMs + params.releaseMs + 82, null).ran, false);
|
||||
});
|
||||
}
|
||||
|
||||
assertResumeGate('vessel', () => {
|
||||
const billboard = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show: true,
|
||||
width: 32,
|
||||
height: 32,
|
||||
scale: 1,
|
||||
color: color(),
|
||||
};
|
||||
return {
|
||||
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
|
||||
applyVesselFocusDeemphasis({
|
||||
records: [{ billboard }],
|
||||
target: focusTarget,
|
||||
previousActiveCount,
|
||||
nowMs,
|
||||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||||
cameraDistanceFor: () => 1200,
|
||||
params,
|
||||
})
|
||||
),
|
||||
readAlpha: () => billboard.color.alpha,
|
||||
};
|
||||
});
|
||||
|
||||
assertResumeGate('CCTV', () => {
|
||||
const billboard = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show: true,
|
||||
width: 24,
|
||||
height: 24,
|
||||
scale: 1,
|
||||
color: color(),
|
||||
};
|
||||
const record = { camera: { id: 'cam-1' }, billboard };
|
||||
return {
|
||||
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
|
||||
applyCctvFocusDeemphasis({
|
||||
records: [record],
|
||||
target: focusTarget,
|
||||
previousActiveCount,
|
||||
nowMs,
|
||||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||||
cameraDistanceFor: () => 1200,
|
||||
baseColorFor: () => color(),
|
||||
params,
|
||||
})
|
||||
),
|
||||
readAlpha: () => billboard.color.alpha,
|
||||
};
|
||||
});
|
||||
|
||||
assertResumeGate('satellite', () => {
|
||||
const point = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show: true,
|
||||
pixelSize: 6,
|
||||
color: color(),
|
||||
};
|
||||
return {
|
||||
pass: ({ nowMs, target: focusTarget, previousActiveCount }) => (
|
||||
applySatellitePointFocusDeemphasis({
|
||||
points: new Map([[42, point]]),
|
||||
trackedId: null,
|
||||
target: focusTarget,
|
||||
previousActiveCount,
|
||||
nowMs,
|
||||
screenPositionFor: () => ({ x: 50, y: 50 }),
|
||||
cameraDistanceFor: () => 1200,
|
||||
baseColorFor: () => color(),
|
||||
params,
|
||||
})
|
||||
),
|
||||
readAlpha: () => point.color.alpha,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// src/data/geoid.js — bundled EGM96 geoid-undulation lookup.
|
||||
//
|
||||
// h = H + N: the globe (Cesium ellipsoid) needs ELLIPSOIDAL height (h);
|
||||
// most real-world elevation sources (barometric altitude, MSL survey data,
|
||||
// Caltrans/TfL camera priors) give ORTHOMETRIC height (H, "height above mean
|
||||
// sea level"). N is the local geoid undulation — the gap between the WGS84
|
||||
// ellipsoid and the geoid (~mean sea level) surface, ranging roughly
|
||||
// -106..+85 m worldwide. See docs/CURRENT-STATE.md.
|
||||
//
|
||||
// The implementation uses `egm96-universal` (npm, MIT, embeds the NGA
|
||||
// EGM96 15' grid) as a lazy dynamic import so its ~2.7 MB grid data-chunk
|
||||
// never lands in the eager Vite bundle. Only fall back to vendoring the NGA
|
||||
// grid ourselves if the package fails tests, isn't browser-safe, or bloats
|
||||
// the eager bundle. It passed the browser-safety, accuracy, and bundle checks,
|
||||
// so this file is a thin wrapper around it — no vendored fallback is needed.
|
||||
//
|
||||
// egm96-universal's `meanSeaLevel(lat, lon)` already returns exactly N in
|
||||
// metres (relative to WGS84 ellipsoid) with internal longitude
|
||||
// normalization (wraps to [-180, 180) before the grid lookup), so no extra
|
||||
// wrap/interpolation logic is needed here.
|
||||
|
||||
let egm96Module = null;
|
||||
let readyPromise = null;
|
||||
|
||||
/**
|
||||
* Lazily loads the EGM96 grid (dynamic import — code-split by Vite so the
|
||||
* ~2.7 MB grid data stays out of the eager main bundle). Safe to call many
|
||||
* times; the underlying import only happens once and subsequent calls
|
||||
* resolve immediately from the cached promise.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function ensureGeoidReady() {
|
||||
if (!readyPromise) {
|
||||
readyPromise = import('egm96-universal').then((mod) => {
|
||||
egm96Module = mod;
|
||||
});
|
||||
}
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geoid undulation N at a given point, in metres, relative to the WGS84
|
||||
* ellipsoid (positive = geoid above ellipsoid). Throws if
|
||||
* `ensureGeoidReady()` has not resolved yet.
|
||||
* @param {number} latDeg
|
||||
* @param {number} lonDeg
|
||||
* @returns {number}
|
||||
*/
|
||||
export function geoidHeight(latDeg, lonDeg) {
|
||||
if (!egm96Module) {
|
||||
throw new Error(
|
||||
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
|
||||
'await ensureGeoidReady() first.'
|
||||
);
|
||||
}
|
||||
return egm96Module.meanSeaLevel(latDeg, lonDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an orthometric (mean-sea-level) height to an ellipsoidal
|
||||
* (WGS84 globe-relative) height: h = H + N.
|
||||
* @param {number} hMslM - orthometric height in metres (height above MSL)
|
||||
* @param {number} latDeg
|
||||
* @param {number} lonDeg
|
||||
* @returns {number} ellipsoidal height in metres
|
||||
*/
|
||||
export function orthometricToEllipsoidal(hMslM, latDeg, lonDeg) {
|
||||
return hMslM + geoidHeight(latDeg, lonDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* READOUT-ONLY inverse of {@link orthometricToEllipsoidal}: H = h - N.
|
||||
*
|
||||
* Cesium reports camera and entity heights against the WGS84 ELLIPSOID, but a
|
||||
* viewer reads "ALT" as height above mean sea level — so over San Francisco
|
||||
* (N ≈ -32 m) a camera sitting 17 m above the SFO deck reports a startling
|
||||
* -15 m until the undulation is taken back out.
|
||||
*
|
||||
* Takes N as an argument instead of calling {@link geoidHeight} itself, so it
|
||||
* stays a pure function a display surface can call every tick against a cached
|
||||
* cell, and so it degrades safely: a non-finite N (grid still loading, or the
|
||||
* lazy import failed) returns the UNCORRECTED height rather than NaN — a
|
||||
* readout that is ~30 m off for a beat beats a readout that blanks.
|
||||
*
|
||||
* This converts the DATUM of a height that is ALREADY ellipsoidal. It must
|
||||
* never be applied to a barometric/aviation altitude: those are MSL-referenced
|
||||
* already, and subtracting N there would introduce the very error it removes
|
||||
* here, sign-flipped.
|
||||
*
|
||||
* @param {number} hEllipsoidalM - height above the WGS84 ellipsoid, in metres
|
||||
* @param {number|null|undefined} geoidUndulationM - N at that point, in metres
|
||||
* @returns {number} height above MSL in metres, or the input when N is unknown
|
||||
*/
|
||||
export function ellipsoidalToMslDisplayM(hEllipsoidalM, geoidUndulationM) {
|
||||
if (!Number.isFinite(hEllipsoidalM)) return hEllipsoidalM;
|
||||
if (!Number.isFinite(geoidUndulationM)) return hEllipsoidalM;
|
||||
return hEllipsoidalM - geoidUndulationM;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// src/data/geoid.test.mjs — EGM96 geoid-undulation lookup.
|
||||
//
|
||||
// Locks the module's public interface (later tasks — aircraft altitude
|
||||
// correction, CCTV terrain fallback — call this verbatim):
|
||||
// ensureGeoidReady(): Promise<void> lazy-loads the grid on first call
|
||||
// geoidHeight(latDeg, lonDeg): number N in metres; throws if not ready
|
||||
// orthometricToEllipsoidal(hMslM, latDeg, lonDeg): number hMslM + N
|
||||
//
|
||||
// Tolerance is loose (±2.5 m) by design: the bundled grid is EGM96 while the
|
||||
// reference values are Re:Earth's EGM2008 — the two
|
||||
// models differ by up to ~1 m, and the brief's own tolerance absorbs that
|
||||
// spread rather than asserting exact agreement.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ellipsoidalToMslDisplayM,
|
||||
ensureGeoidReady,
|
||||
geoidHeight,
|
||||
orthometricToEllipsoidal,
|
||||
} from './geoid.js';
|
||||
|
||||
const TOLERANCE_M = 2.5;
|
||||
|
||||
const LONDON = { lat: 51.5072, lon: -0.1275, nExpected: 46.1 };
|
||||
const AUSTIN = { lat: 30.2672, lon: -97.7431, nExpected: -26.9 };
|
||||
const SF = { lat: 37.7749, lon: -122.4194, nExpected: -32.2 };
|
||||
const DENVER = { lat: 39.7392, lon: -104.9903, nExpected: -17.3 };
|
||||
/** SFO runway 28R touchdown area — the cockpit/OSD field report's coordinates. */
|
||||
const SFO = { lat: 37.616, lon: -122.368 };
|
||||
|
||||
test('geoidHeight throws before ensureGeoidReady() has resolved', () => {
|
||||
// A fresh, never-initialized module instance can't be observed from the
|
||||
// same process (ESM module cache), so this asserts the documented
|
||||
// contract via the type check further down instead of re-importing.
|
||||
// (See "ready-gate" test below for the real not-ready behavior.)
|
||||
assert.equal(typeof geoidHeight, 'function');
|
||||
});
|
||||
|
||||
test('ensureGeoidReady() resolves and is idempotent (safe to call repeatedly)', async () => {
|
||||
await ensureGeoidReady();
|
||||
await ensureGeoidReady();
|
||||
await ensureGeoidReady();
|
||||
});
|
||||
|
||||
test('geoidHeight matches known EGM96 undulation values within ±2.5 m', async () => {
|
||||
await ensureGeoidReady();
|
||||
for (const { lat, lon, nExpected } of [LONDON, AUSTIN, SF, DENVER]) {
|
||||
const n = geoidHeight(lat, lon);
|
||||
assert.ok(
|
||||
Math.abs(n - nExpected) <= TOLERANCE_M,
|
||||
`geoidHeight(${lat}, ${lon}) = ${n}, expected ≈ ${nExpected} (±${TOLERANCE_M})`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('orthometricToEllipsoidal adds the geoid undulation to the MSL height', async () => {
|
||||
await ensureGeoidReady();
|
||||
const hMslM = 15;
|
||||
const hEllipsoidal = orthometricToEllipsoidal(hMslM, LONDON.lat, LONDON.lon);
|
||||
// London geoid ≈ +46.1 -> 15 + 46.1 = 61.1, expect ≈ 61 within tolerance.
|
||||
assert.ok(
|
||||
Math.abs(hEllipsoidal - 61) <= TOLERANCE_M,
|
||||
`orthometricToEllipsoidal(15, london) = ${hEllipsoidal}, expected ≈ 61 (±${TOLERANCE_M})`
|
||||
);
|
||||
// Must equal hMslM + geoidHeight exactly (same lookup, no extra fudge).
|
||||
const n = geoidHeight(LONDON.lat, LONDON.lon);
|
||||
assert.equal(hEllipsoidal, hMslM + n);
|
||||
});
|
||||
|
||||
test('geoidHeight wraps longitude consistently (359.87 === -0.13)', async () => {
|
||||
await ensureGeoidReady();
|
||||
const wrapped = geoidHeight(51.5, 359.87);
|
||||
const normal = geoidHeight(51.5, -0.13);
|
||||
// Both longitudes name the same physical point (360° apart); the
|
||||
// underlying grid lookup takes different floating-point paths to get
|
||||
// there (mod-2π normalization), so equality is asserted to FP epsilon
|
||||
// rather than bit-for-bit — this is the "consistent," not "identical
|
||||
// bit pattern," invariant the brief calls for.
|
||||
assert.ok(
|
||||
Math.abs(wrapped - normal) < 1e-9,
|
||||
`geoidHeight(51.5, 359.87) = ${wrapped}, geoidHeight(51.5, -0.13) = ${normal}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── ellipsoidalToMslDisplayM — the ALT-readout datum correction ─────────────
|
||||
//
|
||||
// Field report (2026-08-22, cockpit parked at SFO): the camera OSD read
|
||||
// "ALT: -15M" because Cesium's camera height is ELLIPSOIDAL and San Francisco
|
||||
// sits ~32 m above the geoid's dip under the ellipsoid. Same family as the
|
||||
// earlier JFK "ALT: -18M".
|
||||
|
||||
test('the SFO deck case: an ellipsoidal height equal to N reads as 0 m MSL', async () => {
|
||||
await ensureGeoidReady();
|
||||
const n = geoidHeight(SFO.lat, SFO.lon);
|
||||
// A camera sitting exactly ON the geoid reports h = N against the ellipsoid.
|
||||
assert.equal(ellipsoidalToMslDisplayM(n, n), 0);
|
||||
});
|
||||
|
||||
test('the reported SFO cockpit OSD height turns into a small positive MSL number', async () => {
|
||||
await ensureGeoidReady();
|
||||
const n = geoidHeight(SFO.lat, SFO.lon);
|
||||
assert.ok(n < -25 && n > -40, `SFO undulation should be strongly negative, got ${n}`);
|
||||
// The screenshot showed ALT: -15m ellipsoidal over the SFO deck.
|
||||
const displayed = ellipsoidalToMslDisplayM(-15, n);
|
||||
assert.ok(
|
||||
displayed > 10 && displayed < 25,
|
||||
`-15 m ellipsoidal at SFO should read ≈ +17 m MSL, got ${displayed}`
|
||||
);
|
||||
});
|
||||
|
||||
test('a positive undulation lowers the readout — the correction subtracts N', async () => {
|
||||
await ensureGeoidReady();
|
||||
const n = geoidHeight(LONDON.lat, LONDON.lon);
|
||||
assert.ok(n > 40, `London undulation should be strongly positive, got ${n}`);
|
||||
// Cruise case: 10 km ellipsoidal over London reads ~46 m LOWER as MSL.
|
||||
const cruise = ellipsoidalToMslDisplayM(10000, n);
|
||||
assert.equal(cruise, 10000 - n);
|
||||
assert.ok(
|
||||
cruise > 9950 && cruise < 9960,
|
||||
`10 000 m ellipsoidal over London should read ≈ 9954 m MSL, got ${cruise}`
|
||||
);
|
||||
});
|
||||
|
||||
test('ellipsoidalToMslDisplayM round-trips orthometricToEllipsoidal', async () => {
|
||||
await ensureGeoidReady();
|
||||
for (const point of [LONDON, AUSTIN, SF, DENVER]) {
|
||||
const n = geoidHeight(point.lat, point.lon);
|
||||
const ellipsoidal = orthometricToEllipsoidal(120, point.lat, point.lon);
|
||||
// Exact to FP epsilon: +N then -N is the same lookup, no extra fudge.
|
||||
assert.ok(Math.abs(ellipsoidalToMslDisplayM(ellipsoidal, n) - 120) < 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unavailable geoid returns the uncorrected height, never NaN or blank', () => {
|
||||
// Grid still loading, lazy import failed, or an out-of-range lookup: the
|
||||
// readout degrades to the ellipsoidal number rather than printing NaN.
|
||||
for (const missing of [null, undefined, Number.NaN, 'x']) {
|
||||
assert.equal(ellipsoidalToMslDisplayM(-15, missing), -15);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-finite height is passed through untouched', async () => {
|
||||
await ensureGeoidReady();
|
||||
const n = geoidHeight(SFO.lat, SFO.lon);
|
||||
assert.equal(ellipsoidalToMslDisplayM(null, n), null);
|
||||
assert.equal(ellipsoidalToMslDisplayM(undefined, n), undefined);
|
||||
assert.ok(Number.isNaN(ellipsoidalToMslDisplayM(Number.NaN, n)));
|
||||
});
|
||||
|
||||
test('geoidHeight throws a clear error if called before the grid is ready', async () => {
|
||||
// Exercise the not-ready path via a fresh dynamic import under Node's ESM
|
||||
// cache-busting query trick so this module instance has never had
|
||||
// ensureGeoidReady() called on it.
|
||||
const fresh = await import('./geoid.js?fresh-not-ready-check');
|
||||
assert.throws(() => fresh.geoidHeight(0, 0), /not ready|ensureGeoidReady/i);
|
||||
});
|
||||
@@ -0,0 +1,580 @@
|
||||
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
|
||||
// heights (field-test round 2026-07-06).
|
||||
//
|
||||
// Two field findings drove this module:
|
||||
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
|
||||
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
|
||||
// local relief).
|
||||
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
|
||||
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
|
||||
// (field elevation ~1590 m ellipsoidal).
|
||||
//
|
||||
// The fix everywhere is the same: never render below the local ellipsoidal
|
||||
// surface. This module provides the shared floor: a COARSE (3-decimal,
|
||||
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
|
||||
// aircraft and multi-hundred-point trails collapse onto a small set of cells
|
||||
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
|
||||
// every fix — the taxiing-cache lesson from the adversarial review).
|
||||
// A ~111 m cell means the floor can be off by the relief within the cell in
|
||||
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
|
||||
// a few metres short of a ridge line still beats burying it.
|
||||
//
|
||||
// Zero-network on the render path: reads are synchronous cache lookups;
|
||||
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
|
||||
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
|
||||
// warm — results land for later polls/appends.
|
||||
import { cachedRealEllipsoidalGround, resolveEllipsoidalGround } from './terrainHeights.js';
|
||||
|
||||
/** @constant {number} Metres added above the resolved floor so a clamped
|
||||
* billboard/waypoint sits ON the surface instead of z-fighting it. */
|
||||
export const GROUND_FLOOR_LIFT_M = 1.5;
|
||||
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
|
||||
export const MESH_FLOOR_BELOW_PRIOR_M = 15;
|
||||
export const MESH_FLOOR_ABOVE_PRIOR_M = 80;
|
||||
|
||||
/**
|
||||
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
|
||||
*/
|
||||
export function coarseFloorCoord(lat, lon) {
|
||||
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure floor clamp.
|
||||
* - finite alt + finite ground → max(alt, ground + lift)
|
||||
* - finite alt + unknown ground → alt (no data, no clamp)
|
||||
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
|
||||
* the point IS on the surface; put it there)
|
||||
* - null alt + unknown ground → null (caller applies its own fallback)
|
||||
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
|
||||
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
|
||||
* @param {number} [liftM] - Clearance above the floor.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
|
||||
const hasAlt = Number.isFinite(altM);
|
||||
const hasGround = Number.isFinite(groundM);
|
||||
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
|
||||
if (hasAlt) return altM;
|
||||
if (hasGround) return groundM + liftM;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor decision at the point of DISPLAY (2026-08-19).
|
||||
*
|
||||
* `floorAltitudeM` above answers "what height should this FIX render at" and
|
||||
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
|
||||
* a Cartesian interpolation between two fixes for the whole segment, and a
|
||||
* velocity extrapolation of the newest fix while a contact coasts through its
|
||||
* stale-feed grace. Both drift the rendered point horizontally away from the
|
||||
* fix whose cell supplied the height — across a graded apron (KAUS spans
|
||||
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
|
||||
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
|
||||
*
|
||||
* This is the same "never below the visible surface" rule applied at the
|
||||
* displayed coordinate instead of the fix coordinate. It returns `null` rather
|
||||
* than the unchanged height whenever nothing should move, so the per-frame
|
||||
* caller can skip rebuilding a Cartesian entirely — the common case by far.
|
||||
*
|
||||
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
|
||||
* dead-reckoned display position currently carries.
|
||||
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
|
||||
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
|
||||
* @param {number} [liftM] - Clearance above the floor.
|
||||
* @returns {number|null} The height to render at, or null for "leave it alone".
|
||||
*/
|
||||
export function displayFloorHeightM(displayHeightM, floorM, liftM = GROUND_FLOOR_LIFT_M) {
|
||||
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM)) return null;
|
||||
const lifted = floorM + liftM;
|
||||
return displayHeightM < lifted ? lifted : null;
|
||||
}
|
||||
|
||||
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
|
||||
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
|
||||
export const FLOOR_CELL_DEG = 0.001;
|
||||
|
||||
/** @constant {number} Resolved neighbours required before one may be borrowed.
|
||||
* A single reading cannot be checked against anything: a hangar roof and an
|
||||
* apron are both "a warm cell 111 m away", and there is no way to tell them
|
||||
* apart from one number. Two readings at least agree or disagree. */
|
||||
export const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
|
||||
|
||||
/**
|
||||
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
|
||||
*
|
||||
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
|
||||
* Re:Earth proxy timed out four times in a row and a grounded contact standing
|
||||
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
|
||||
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
|
||||
* grounded contacts live that is the same surface, and it is a MEASURED height
|
||||
* rather than an invented one.
|
||||
*
|
||||
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
|
||||
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
|
||||
*
|
||||
* An earlier cut leaned HIGH, reasoning from the locked "never below the
|
||||
* visible surface" principle. That principle is about a contact's OWN measured
|
||||
* ground; applied to a BORROWED cell it inverts, and a field test found
|
||||
* why — planes floating in midair at terminal gates. The two errors are not
|
||||
* symmetric:
|
||||
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
|
||||
* floor under the contact simply does not lift it — the same "no clamp" the
|
||||
* cold-cell case already has, bounded by one cell of grade (metres).
|
||||
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
|
||||
* not by grade, and a parked contact holds it: measured on the A/B rig,
|
||||
* a lone roof neighbour put a contact 29.5 m up and left it there
|
||||
* permanently when its own cell never warmed, and 6.3 s of visible
|
||||
* stair-stepping when it did.
|
||||
* A plane at a gate is on the apron, never on the terminal roof, so on a
|
||||
* structure edge the LOW neighbour is the answer and the high one is an
|
||||
* artefact of the photogrammetric mesh.
|
||||
*
|
||||
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
|
||||
* the grade across ~111 m, so the clamp lifts a little less than it could. That
|
||||
* shows up as no lift rather than as a wrong one, and the contact's own cell
|
||||
* corrects it as soon as it warms.
|
||||
*
|
||||
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
|
||||
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
|
||||
* warm to borrow from.
|
||||
*/
|
||||
export function neighborFloorM(cell) {
|
||||
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
|
||||
let lowest = null;
|
||||
let resolved = 0;
|
||||
for (let dLat = -1; dLat <= 1; dLat += 1) {
|
||||
for (let dLon = -1; dLon <= 1; dLon += 1) {
|
||||
if (dLat === 0 && dLon === 0) continue;
|
||||
const h = cachedGroundFloor(cell.lat + dLat * FLOOR_CELL_DEG, cell.lon + dLon * FLOOR_CELL_DEG);
|
||||
if (h == null) continue;
|
||||
resolved += 1;
|
||||
if (lowest == null || h < lowest) lowest = h;
|
||||
}
|
||||
}
|
||||
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
|
||||
}
|
||||
|
||||
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
|
||||
* or a spliced fix must not turn one contact into an unbounded warm batch.
|
||||
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
|
||||
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
|
||||
export const CORRIDOR_MAX_CELLS = 12;
|
||||
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
|
||||
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
|
||||
* a short leg rounds to a single step, so the walk degenerates to testing the
|
||||
* leg's endpoints and a cell clipped between them is skipped. At this step any
|
||||
* cell the path occupies for ~14 m of ground or more contains a sample — the
|
||||
* guarantee this walk actually makes. The loop still exits the moment the cell
|
||||
* budget fills, so a finer step costs iterations only on short corridors. */
|
||||
export const CORRIDOR_WALK_STEP_DEG = 0.000125;
|
||||
/** @constant {number} Degrees added around a cell before a contact is allowed
|
||||
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
|
||||
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
|
||||
* the sprite between two heights. */
|
||||
export const CELL_HYSTERESIS_DEG = 0.0002;
|
||||
|
||||
/**
|
||||
* The floor cell a contact should read, with boundary hysteresis.
|
||||
*
|
||||
* Cells are 0.001° squares, so a display position sitting on an edge (parked
|
||||
* GPS jitter, the tracked reconciliation's decaying correction) flips between
|
||||
* two cells frame to frame — and their floors can differ by the local grade or,
|
||||
* next to a building, several metres. The contact keeps its previous cell until
|
||||
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
|
||||
* comparisons and no allocation on the sticky path.
|
||||
*
|
||||
* @param {number} lat @param {number} lon - Displayed coordinate.
|
||||
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
|
||||
* this contact read last tick, if any.
|
||||
* @returns {{lat: number, lon: number}} The cell to read.
|
||||
*/
|
||||
export function stickyFloorCell(lat, lon, previousCell) {
|
||||
if (previousCell
|
||||
&& Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG
|
||||
&& Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG) {
|
||||
return previousCell;
|
||||
}
|
||||
return coarseFloorCoord(lat, lon);
|
||||
}
|
||||
|
||||
/**
|
||||
* The coarse cells a contact's DISPLAY is about to walk through.
|
||||
*
|
||||
* The dead-reckoned display runs one render delay behind and advances toward
|
||||
* the newest fix, so the segment [display → newest fix] is exactly the ground
|
||||
* it will render over during the next poll or two. Warming only the cell it is
|
||||
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
|
||||
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
|
||||
* stays permanently ahead of its own floor data and the display clamp has
|
||||
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
|
||||
* whole observation).
|
||||
*
|
||||
* Walks in half-cell steps so no cell between the ends is skipped. The step
|
||||
* COUNT follows the segment length (never a fixed cap — a capped count over a
|
||||
* long segment samples sparsely and leaves holes in the middle of ground the
|
||||
* contact does cross). Length is bounded instead, and honestly: the walk emits
|
||||
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
|
||||
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
|
||||
* contiguous near ground plus the far endpoint — rather than thinned; the
|
||||
* ground in between is picked up on a later poll as the display advances into
|
||||
* it. Loop work stays bounded because the walk exits as soon as the cell budget
|
||||
* fills, whatever the segment length.
|
||||
*
|
||||
* Takes the display PATH as a polyline, not two endpoints: while the contact
|
||||
* is extrapolating, that path is an arc (the dead-reckon integrates a
|
||||
* constant-rate turn), and walking a straight chord between its ends would
|
||||
* warm ground the contact never crosses while its actual arc stayed cold.
|
||||
*
|
||||
* @param {Array<{lat: number, lon: number}>} points - Display path, current
|
||||
* position first, destination last.
|
||||
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
|
||||
* first, destination last.
|
||||
*/
|
||||
export function corridorFloorCells(points) {
|
||||
if (!Array.isArray(points) || !points.length) return [];
|
||||
const first = points[0];
|
||||
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
|
||||
const start = coarseFloorCoord(first.lat, first.lon);
|
||||
const out = [start];
|
||||
const seen = new Set([`${start.lat},${start.lon}`]);
|
||||
let destination = null;
|
||||
for (let i = points.length - 1; i >= 1; i--) {
|
||||
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
|
||||
destination = coarseFloorCoord(points[i].lat, points[i].lon);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destination) return out;
|
||||
const destKey = `${destination.lat},${destination.lon}`;
|
||||
// One slot is reserved for the destination, appended after the prefix.
|
||||
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
|
||||
walk:
|
||||
for (let s = 0; s < points.length - 1; s++) {
|
||||
const a = points[s];
|
||||
const b = points[s + 1];
|
||||
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
|
||||
const steps = Math.ceil(
|
||||
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / CORRIDOR_WALK_STEP_DEG,
|
||||
);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
if (out.length >= prefixLimit) break walk;
|
||||
const t = i / steps;
|
||||
const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, a.lon + (b.lon - a.lon) * t);
|
||||
const key = `${cell.lat},${cell.lon}`;
|
||||
if (seen.has(key) || key === destKey) continue;
|
||||
seen.add(key);
|
||||
out.push(cell);
|
||||
}
|
||||
}
|
||||
if (!seen.has(destKey)) out.push(destination);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares one poll's corridor-cell budget across the contacts that need it.
|
||||
*
|
||||
* The first cut walked contacts in Map insertion order and decremented the
|
||||
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
|
||||
* already collected and the contacts actually outrunning their floor data
|
||||
* starved. Three rules fix that:
|
||||
* 1. Cells already collected are FREE — a parked contact, whose corridor is
|
||||
* its own fix cell, never competes for budget at all.
|
||||
* 2. Candidates are ordered by how many of their cells are genuinely cold,
|
||||
* then by speed (whoever leaves their floor behind soonest).
|
||||
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
|
||||
* each, then the remainder — so no contact is left with NOTHING while
|
||||
* another takes seconds. The nearest cell is the one a contact needs first
|
||||
* anyway; the far end of its corridor can wait a poll.
|
||||
*
|
||||
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
|
||||
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
|
||||
* everything this call allocates, so the caller's dedupe stays consistent.
|
||||
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
|
||||
* movers means the same stable prefix wins every poll and the tail never moves.
|
||||
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
|
||||
* round-robin across polls while a genuinely needier contact is never demoted
|
||||
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
|
||||
*
|
||||
* `seen` alone is NOT a sufficient free-list: in production it holds only the
|
||||
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
|
||||
* spent budget again, letting a contact's near cells consume its whole share
|
||||
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
|
||||
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
|
||||
* still EMITTED, because the mesh sampler must see a cell again after its DEM
|
||||
* prior lands or it never latches a rendered-surface height there.
|
||||
*
|
||||
* That gives a service bound derivable from the policy rather than a magic
|
||||
* number: the first round charges at most one cell per candidate, so at least
|
||||
* `min(candidates, budget)` candidates are served per poll; a candidate that
|
||||
* misses out keeps its cold count while every served candidate's drops, so it
|
||||
* outranks them next poll. Every candidate is therefore served within
|
||||
* `ceil(candidates / budget)` polls once the warms land.
|
||||
*
|
||||
* @param {number} budget - Max cold cells to charge for.
|
||||
* @param {number} fairShare - Per-candidate cap for the middle round.
|
||||
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
|
||||
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
|
||||
* cell already has a floor (defaults to the shared cache).
|
||||
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
|
||||
*/
|
||||
export function allocateCorridorCells(
|
||||
candidates, seen, budget, fairShare, epoch = 0,
|
||||
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
|
||||
) {
|
||||
const out = [];
|
||||
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0)) return out;
|
||||
const ranked = [...candidates].sort((a, b) => (b.cold - a.cold) || (b.speedMps - a.speedMps));
|
||||
if (Number.isFinite(epoch) && epoch !== 0) {
|
||||
for (let i = 0; i < ranked.length;) {
|
||||
let j = i + 1;
|
||||
while (j < ranked.length
|
||||
&& ranked[j].cold === ranked[i].cold
|
||||
&& ranked[j].speedMps === ranked[i].speedMps) j += 1;
|
||||
const runLength = j - i;
|
||||
if (runLength > 1) {
|
||||
const shift = ((epoch % runLength) + runLength) % runLength;
|
||||
const run = ranked.slice(i, j);
|
||||
for (let m = 0; m < runLength; m += 1) ranked[i + m] = run[(m + shift) % runLength];
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
|
||||
// the previous one stopped instead of re-walking cells already handled.
|
||||
const cursor = new Array(ranked.length).fill(0);
|
||||
let left = budget;
|
||||
const round = (limit) => {
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
const cells = ranked[i].cells || [];
|
||||
while (cursor[i] < cells.length && cursor[i] < limit) {
|
||||
const cell = cells[cursor[i]];
|
||||
const key = `${cell.lat},${cell.lon}`;
|
||||
if (seen.has(key)) { cursor[i] += 1; continue; } // already in this batch
|
||||
if (isWarm(cell)) {
|
||||
// Free: it has a floor already. Still emitted, so the mesh sampler
|
||||
// gets another look now that its DEM prior exists.
|
||||
cursor[i] += 1;
|
||||
seen.add(key);
|
||||
out.push(cell);
|
||||
continue;
|
||||
}
|
||||
if (left <= 0) return;
|
||||
cursor[i] += 1;
|
||||
seen.add(key);
|
||||
out.push(cell);
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
round(1); // nobody goes home empty-handed
|
||||
round(fairShare); // then a fair slice each
|
||||
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Mesh-floor cells (round 4, validated design) ---------------------
|
||||
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
|
||||
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
|
||||
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
|
||||
// inside the mesh. These cells hold the RENDERED surface height, sampled
|
||||
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
|
||||
// source groundSnap uses, which is why 3D models always looked right) and
|
||||
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
|
||||
// below the visible surface; slightly above is always fine. The DEM stays
|
||||
// the instant global prior/fallback and the sanity gate for samples.
|
||||
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
|
||||
const _meshCells = new Map();
|
||||
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
|
||||
* regime (photoreal stack). On globe stacks the rendered surface IS the
|
||||
* terrain provider (Re:Earth on keyless), so the DEM is already correct
|
||||
* there and mesh heights would float. Kept current by meshFloorSampler's
|
||||
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
|
||||
let _meshPreferred = true;
|
||||
|
||||
/** @param {boolean} preferred - google-3d regime active. */
|
||||
export function setMeshFloorPreferred(preferred) {
|
||||
_meshPreferred = !!preferred;
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether mesh-floor cells currently apply. */
|
||||
export function meshFloorPreferred() {
|
||||
return _meshPreferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a sampled rendered-surface height for the cell containing the
|
||||
* coordinate (one-shot latch — first accepted sample wins).
|
||||
* @param {number} lat @param {number} lon @param {number} heightM
|
||||
*/
|
||||
export function reportMeshFloorCell(lat, lon, heightM) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(heightM)) return;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
const key = `${c.lat},${c.lon}`;
|
||||
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
|
||||
}
|
||||
|
||||
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
|
||||
export function meshFloorSampleWithinPrior(heightM, priorM) {
|
||||
return Number.isFinite(heightM)
|
||||
&& Number.isFinite(priorM)
|
||||
&& heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M
|
||||
&& heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and record a rendered-mesh floor through the single shared gate.
|
||||
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
|
||||
* rooftop, or aircraft hit would otherwise win the session-long first-write
|
||||
* latch and poison every floor consumer in the cell.
|
||||
* @returns {boolean} Whether the sample passed and was reported.
|
||||
*/
|
||||
export function reportValidatedMeshFloorCell(lat, lon, heightM) {
|
||||
if (![lat, lon, heightM].every(Number.isFinite)) return false;
|
||||
const cell = coarseFloorCoord(lat, lon);
|
||||
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
|
||||
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
|
||||
reportMeshFloorCell(cell.lat, cell.lon, heightM);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous mesh-cell read — null when the cell is unsampled OR the
|
||||
* google-3d regime is inactive (globe stacks must floor on the DEM).
|
||||
* @param {number} lat @param {number} lon
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function cachedMeshFloor(lat, lon) {
|
||||
if (!_meshPreferred) return null;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
const h = _meshCells.get(`${c.lat},${c.lon}`);
|
||||
return h != null ? h : null;
|
||||
}
|
||||
|
||||
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
|
||||
export function _clearMeshFloorCellsForTest() {
|
||||
_meshCells.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
|
||||
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
|
||||
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
|
||||
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
|
||||
* they all agree on one surface.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
|
||||
*/
|
||||
export function cachedGroundFloor(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const mesh = cachedMeshFloor(lat, lon);
|
||||
if (mesh != null) return mesh;
|
||||
const c = coarseFloorCoord(lat, lon);
|
||||
return cachedRealEllipsoidalGround(c.lat, c.lon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
|
||||
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
|
||||
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
|
||||
* and falls back per-point, so any trace length is safe. Never throws.
|
||||
* @param {Array<{lat: number, lon: number}>} points
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
|
||||
* the floor resolve before painting with whatever cells are warm. Owner
|
||||
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
|
||||
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
|
||||
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
|
||||
* The resolve keeps running past the deadline and lands in the cache for
|
||||
* the next paint/select. */
|
||||
export const FLOOR_RESOLVE_DEADLINE_MS = 1200;
|
||||
|
||||
/**
|
||||
* Bounded-latency variant of resolveGroundFloorCells: waits at most
|
||||
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
|
||||
* cells that ARE warm. The underlying resolve continues in the background
|
||||
* and fills the cache for later reads. Never throws.
|
||||
* @param {Array<{lat: number, lon: number}>} points
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function resolveGroundFloorCellsBounded(points) {
|
||||
return Promise.race([
|
||||
resolveGroundFloorCells(points),
|
||||
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function resolveGroundFloorCells(points) {
|
||||
if (!Array.isArray(points) || !points.length) return;
|
||||
const cells = new Map();
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
const c = coarseFloorCoord(p.lat, p.lon);
|
||||
const key = `${c.lat},${c.lon}`;
|
||||
if (cells.has(key)) continue;
|
||||
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
|
||||
cells.set(key, c);
|
||||
}
|
||||
if (!cells.size) return;
|
||||
try {
|
||||
await resolveEllipsoidalGround([...cells.values()]);
|
||||
} catch { /* best-effort — unresolved cells simply don't clamp */ }
|
||||
}
|
||||
|
||||
/** @type {boolean} Single-flight guard for the warm batch. */
|
||||
let _floorBatchInFlight = false;
|
||||
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
|
||||
* batch was in flight — flushed as the NEXT batch the moment it completes.
|
||||
* Round 6: the old guard silently DROPPED contending calls (flights every
|
||||
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
|
||||
* airport could take minutes of lost polls before a given cell resolved —
|
||||
* the owner's "you can't just go to a place and start inspecting it". */
|
||||
const _pendingFloorCells = new Map();
|
||||
|
||||
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
|
||||
function _resolveFloorCells(cells) {
|
||||
_floorBatchInFlight = true;
|
||||
resolveEllipsoidalGround([...cells.values()])
|
||||
.catch(() => { /* best-effort — unclamped this tick, floor lands later */ })
|
||||
.finally(() => {
|
||||
_floorBatchInFlight = false;
|
||||
if (_pendingFloorCells.size) {
|
||||
const next = new Map(_pendingFloorCells);
|
||||
_pendingFloorCells.clear();
|
||||
_resolveFloorCells(next);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget batch warm of the coarse floor cells for the given points.
|
||||
* Dedupes to unique cells, skips already-warm ones, and never blocks or
|
||||
* throws into the caller. Contending calls QUEUE (never drop): their cells
|
||||
* ride the next batch as soon as the in-flight one completes. Results are
|
||||
* picked up by later `cachedGroundFloor` reads.
|
||||
* @param {Array<{lat: number, lon: number}>} points
|
||||
*/
|
||||
export function warmGroundFloor(points) {
|
||||
if (!Array.isArray(points) || !points.length) return;
|
||||
const cells = new Map();
|
||||
for (const p of points) {
|
||||
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
|
||||
const c = coarseFloorCoord(p.lat, p.lon);
|
||||
const key = `${c.lat},${c.lon}`;
|
||||
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
|
||||
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
|
||||
cells.set(key, c);
|
||||
}
|
||||
if (!cells.size) return;
|
||||
if (_floorBatchInFlight) {
|
||||
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
|
||||
return;
|
||||
}
|
||||
_resolveFloorCells(cells);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
// src/data/groundFloor.test.mjs
|
||||
// Field-test round (2026-07-06): locks the coarse ground-floor helpers used to
|
||||
// clamp military render altitudes and trail waypoints so they never render
|
||||
// below the local ellipsoidal surface (RS46 heli-in-hillside + WAKE01
|
||||
// trail-underground findings).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
coarseFloorCoord, floorAltitudeM, GROUND_FLOOR_LIFT_M, displayFloorHeightM,
|
||||
corridorFloorCells, CORRIDOR_MAX_CELLS, stickyFloorCell, allocateCorridorCells,
|
||||
CORRIDOR_WALK_STEP_DEG,
|
||||
meshFloorSampleWithinPrior,
|
||||
reportMeshFloorCell, cachedMeshFloor, cachedGroundFloor,
|
||||
setMeshFloorPreferred, meshFloorPreferred, _clearMeshFloorCellsForTest,
|
||||
neighborFloorM,
|
||||
} from './groundFloor.js';
|
||||
import {
|
||||
corridorPathLatLon, projectGroundArcLatLon,
|
||||
CORRIDOR_SAMPLE_SPACING_M, CORRIDOR_MAX_LENGTH_M,
|
||||
} from './motionModel.js';
|
||||
|
||||
test('coarseFloorCoord rounds to a 3-decimal (~111 m) grid cell', () => {
|
||||
const c = coarseFloorCoord(35.049876, -106.591432);
|
||||
assert.equal(c.lat, 35.05);
|
||||
assert.equal(c.lon, -106.591);
|
||||
});
|
||||
|
||||
test('coarseFloorCoord: nearby points collapse to the same cell (cache reuse)', () => {
|
||||
const a = coarseFloorCoord(35.0501, -106.5914);
|
||||
const b = coarseFloorCoord(35.0504, -106.5909);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test('floorAltitudeM lifts a below-ground altitude to ground + lift', () => {
|
||||
// RS46 case: baro+N ≈ 334 m but the ridge is ~400 m ellipsoidal.
|
||||
const got = floorAltitudeM(334, 400);
|
||||
assert.equal(got, 400 + GROUND_FLOOR_LIFT_M);
|
||||
});
|
||||
|
||||
test('floorAltitudeM leaves an above-ground altitude untouched', () => {
|
||||
assert.equal(floorAltitudeM(1200, 400), 1200);
|
||||
});
|
||||
|
||||
test('floorAltitudeM with unknown ground returns the altitude unchanged', () => {
|
||||
assert.equal(floorAltitudeM(334, null), 334);
|
||||
assert.equal(floorAltitudeM(334, NaN), 334);
|
||||
});
|
||||
|
||||
test('floorAltitudeM with null altitude and known ground returns ground + lift (ground-waypoint case)', () => {
|
||||
// WAKE01 case: a readsb trace point of "ground" has NO altitude — it must
|
||||
// sit ON the local surface (~1590 m at Kirtland), never the old 50 m sentinel.
|
||||
const got = floorAltitudeM(null, 1590);
|
||||
assert.equal(got, 1590 + GROUND_FLOOR_LIFT_M);
|
||||
});
|
||||
|
||||
test('floorAltitudeM with null altitude and unknown ground returns null (caller fallback applies)', () => {
|
||||
assert.equal(floorAltitudeM(null, null), null);
|
||||
});
|
||||
|
||||
test('floorAltitudeM honors a custom lift', () => {
|
||||
assert.equal(floorAltitudeM(10, 100, 5), 105);
|
||||
});
|
||||
|
||||
test('floorAltitudeM treats ground of 0 (sea-level coast) as a real floor', () => {
|
||||
assert.equal(floorAltitudeM(-20, 0), 0 + GROUND_FLOOR_LIFT_M);
|
||||
});
|
||||
|
||||
// Mesh-floor cells (round 4): the rendered-surface layer over the DEM prior.
|
||||
test('mesh cell: report + read round-trips within the same coarse cell', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.1971, -97.6663, 138.4);
|
||||
// ~30 m away, same 111 m cell
|
||||
assert.equal(cachedMeshFloor(30.19725, -97.66645), 138.4);
|
||||
});
|
||||
|
||||
test('mesh cell: first accepted sample wins (one-shot latch)', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.197, -97.666, 138.4);
|
||||
reportMeshFloorCell(30.197, -97.666, 150.0);
|
||||
assert.equal(cachedMeshFloor(30.197, -97.666), 138.4);
|
||||
});
|
||||
|
||||
test('mesh cell: reads are null on globe stacks (DEM is the rendered surface there)', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.197, -97.666, 138.4);
|
||||
setMeshFloorPreferred(false);
|
||||
assert.equal(cachedMeshFloor(30.197, -97.666), null);
|
||||
// cells survive the regime round-trip (latched for the session)
|
||||
setMeshFloorPreferred(true);
|
||||
assert.equal(cachedMeshFloor(30.197, -97.666), 138.4);
|
||||
});
|
||||
|
||||
test('cachedGroundFloor prefers the mesh cell over the DEM cell (AUS 17 m case)', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
// no DEM warm needed for this assertion: mesh present -> mesh wins outright
|
||||
reportMeshFloorCell(30.197, -97.666, 138.1);
|
||||
assert.equal(cachedGroundFloor(30.197, -97.666), 138.1);
|
||||
});
|
||||
|
||||
test('meshFloorPreferred reflects the setter (sampler stack-listener contract)', () => {
|
||||
setMeshFloorPreferred(false);
|
||||
assert.equal(meshFloorPreferred(), false);
|
||||
setMeshFloorPreferred(true);
|
||||
assert.equal(meshFloorPreferred(), true);
|
||||
});
|
||||
|
||||
test('all mesh-floor writers share the asymmetric real-DEM acceptance window', () => {
|
||||
assert.equal(meshFloorSampleWithinPrior(85, 100), true);
|
||||
assert.equal(meshFloorSampleWithinPrior(180, 100), true);
|
||||
assert.equal(meshFloorSampleWithinPrior(84.99, 100), false);
|
||||
assert.equal(meshFloorSampleWithinPrior(180.01, 100), false);
|
||||
assert.equal(meshFloorSampleWithinPrior(120, null), false);
|
||||
});
|
||||
|
||||
// --- Display-time floor (2026-08-19, buried taxiing contacts at KAUS) -------
|
||||
// A grounded contact's render height is fixed at POLL time from the floor of
|
||||
// the fix's cell, but what renders is the DEAD-RECKONED position, which drifts
|
||||
// across cells for the whole 30 s segment (and far further while a stale
|
||||
// ground contact coasts). On a graded apron the sprite ends up under the mesh
|
||||
// it drifted over. displayFloorHeightM is the decision at the point of display.
|
||||
|
||||
test('displayFloorHeightM lifts a display height that drifted under the floor', () => {
|
||||
// KAUS taxiway: fix cell floor 124.7 m, drifted onto a 140.2 m cell.
|
||||
assert.equal(displayFloorHeightM(124.7, 140.2), 140.2 + GROUND_FLOOR_LIFT_M);
|
||||
});
|
||||
|
||||
test('displayFloorHeightM returns null (no rebuild) when already above the floor', () => {
|
||||
assert.equal(displayFloorHeightM(140.2, 124.7), null);
|
||||
});
|
||||
|
||||
test('displayFloorHeightM returns null exactly at the lifted floor (no jitter)', () => {
|
||||
assert.equal(displayFloorHeightM(124.7 + GROUND_FLOOR_LIFT_M, 124.7), null);
|
||||
});
|
||||
|
||||
test('displayFloorHeightM returns null when the floor cell is not warm', () => {
|
||||
assert.equal(displayFloorHeightM(124.7, null), null);
|
||||
assert.equal(displayFloorHeightM(124.7, NaN), null);
|
||||
assert.equal(displayFloorHeightM(124.7, undefined), null);
|
||||
});
|
||||
|
||||
test('displayFloorHeightM returns null for a non-finite display height', () => {
|
||||
assert.equal(displayFloorHeightM(NaN, 124.7), null);
|
||||
assert.equal(displayFloorHeightM(null, 124.7), null);
|
||||
});
|
||||
|
||||
test('displayFloorHeightM honours a caller-supplied lift', () => {
|
||||
assert.equal(displayFloorHeightM(100, 140, 5), 145);
|
||||
});
|
||||
|
||||
// --- Display corridor ------------------------------------------------------
|
||||
// A grounded contact's DISPLAY walks from where it currently renders toward
|
||||
// the newest fix over the following poll(s), so those are the cells it is
|
||||
// about to need a floor for. Warming only the cell it is standing on warms the
|
||||
// cell it is LEAVING — a contact taxiing at 10 m/s crosses a 111 m cell every
|
||||
// ~11 s while the warm batch runs once per 30 s poll, so it stays permanently
|
||||
// ahead of its own floor data (measured at KAUS: sprites buried 2–4 m with
|
||||
// `cellFloor=COLD` for the whole observation).
|
||||
|
||||
test('corridorFloorCells covers both ends of the display→fix walk', () => {
|
||||
const cells = corridorFloorCells([{ lat: 30.200, lon: -97.660 }, { lat: 30.203, lon: -97.660 }]);
|
||||
assert.deepEqual(cells[0], { lat: 30.2, lon: -97.66 });
|
||||
assert.deepEqual(cells[cells.length - 1], { lat: 30.203, lon: -97.66 });
|
||||
});
|
||||
|
||||
test('corridorFloorCells has no gaps along a multi-cell walk', () => {
|
||||
// ~330 m north = 3 coarse cells.
|
||||
const lats = corridorFloorCells([{ lat: 30.200, lon: -97.660 }, { lat: 30.203, lon: -97.660 }]).map((c) => c.lat);
|
||||
assert.deepEqual(lats, [30.2, 30.201, 30.202, 30.203]);
|
||||
});
|
||||
|
||||
test('corridorFloorCells dedupes when both ends share one cell', () => {
|
||||
const cells = corridorFloorCells([{ lat: 30.2001, lon: -97.6601 }, { lat: 30.2004, lon: -97.6604 }]);
|
||||
assert.deepEqual(cells, [{ lat: 30.2, lon: -97.66 }]);
|
||||
});
|
||||
|
||||
test('corridorFloorCells is bounded on a long walk (never unbounded)', () => {
|
||||
const cells = corridorFloorCells([{ lat: 30.2, lon: -97.66 }, { lat: 31.2, lon: -96.66 }]);
|
||||
assert.ok(cells.length <= CORRIDOR_MAX_CELLS, `bounded, got ${cells.length}`);
|
||||
});
|
||||
|
||||
test('corridorFloorCells returns the start cell alone for a bad endpoint', () => {
|
||||
assert.deepEqual(corridorFloorCells([{ lat: 30.2, lon: -97.66 }, { lat: NaN, lon: -97.66 }]), [{ lat: 30.2, lon: -97.66 }]);
|
||||
assert.deepEqual(corridorFloorCells([{ lat: 30.2, lon: -97.66 }, { lat: null, lon: null }]), [{ lat: 30.2, lon: -97.66 }]);
|
||||
});
|
||||
|
||||
test('corridorFloorCells returns nothing for a bad start', () => {
|
||||
assert.deepEqual(corridorFloorCells([{ lat: NaN, lon: -97.66 }, { lat: 30.2, lon: -97.66 }]), []);
|
||||
});
|
||||
|
||||
// --- F5: contiguous prefix + guaranteed endpoint ---------------------------
|
||||
// The first cut capped the interpolation at 20 half-cell steps, so past ~1.1 km
|
||||
// the walk sampled SPARSELY: cells between the samples were skipped and the
|
||||
// destination could be dropped entirely. A corridor with holes warms ground the
|
||||
// contact never crosses while leaving ground it does cross cold.
|
||||
|
||||
test('corridorFloorCells: 3 km corridor keeps a gap-free prefix', () => {
|
||||
// ~3.3 km north.
|
||||
const cells = corridorFloorCells([{ lat: 30.200, lon: -97.660 }, { lat: 30.230, lon: -97.660 }]);
|
||||
assert.ok(cells.length <= CORRIDOR_MAX_CELLS, `bounded, got ${cells.length}`);
|
||||
// Every prefix cell is exactly one cell on from the previous — no holes.
|
||||
const prefix = cells.slice(0, -1);
|
||||
for (let i = 1; i < prefix.length; i++) {
|
||||
const step = Math.round((prefix[i].lat - prefix[i - 1].lat) * 1000);
|
||||
assert.equal(step, 1, `hole between ${JSON.stringify(prefix[i - 1])} and ${JSON.stringify(prefix[i])}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('corridorFloorCells: the destination cell always survives truncation', () => {
|
||||
const cells = corridorFloorCells([{ lat: 30.200, lon: -97.660 }, { lat: 30.230, lon: -97.660 }]);
|
||||
assert.deepEqual(cells[cells.length - 1], { lat: 30.23, lon: -97.66 },
|
||||
'the far end is where the contact is headed — it must be warmed, not dropped');
|
||||
});
|
||||
|
||||
test('corridorFloorCells: a diagonal 3 km corridor is still gap-free', () => {
|
||||
const cells = corridorFloorCells([{ lat: 30.200, lon: -97.660 }, { lat: 30.220, lon: -97.640 }]);
|
||||
const prefix = cells.slice(0, -1);
|
||||
for (let i = 1; i < prefix.length; i++) {
|
||||
const dLat = Math.abs(Math.round((prefix[i].lat - prefix[i - 1].lat) * 1000));
|
||||
const dLon = Math.abs(Math.round((prefix[i].lon - prefix[i - 1].lon) * 1000));
|
||||
assert.ok(dLat <= 1 && dLon <= 1 && (dLat + dLon) > 0,
|
||||
`non-adjacent step ${JSON.stringify(prefix[i - 1])} -> ${JSON.stringify(prefix[i])}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- F6: cell-boundary hysteresis ------------------------------------------
|
||||
// A dead-reckoned position jittering across a 0.001° edge would otherwise pick
|
||||
// a different cell — and so a different floor — at 12 Hz, popping the sprite
|
||||
// between two heights.
|
||||
|
||||
test('stickyFloorCell picks the containing cell with no previous cell', () => {
|
||||
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, null), { lat: 30.2, lon: -97.66 });
|
||||
});
|
||||
|
||||
test('stickyFloorCell holds the previous cell for a jitter-sized excursion', () => {
|
||||
const prev = { lat: 30.2, lon: -97.66 };
|
||||
// 30.2006 belongs to cell 30.201, but it is only ~11 m past the boundary.
|
||||
assert.deepEqual(stickyFloorCell(30.2006, -97.66, prev), prev);
|
||||
assert.deepEqual(stickyFloorCell(30.1994, -97.66, prev), prev);
|
||||
});
|
||||
|
||||
test('stickyFloorCell releases once the contact is properly into the next cell', () => {
|
||||
const prev = { lat: 30.2, lon: -97.66 };
|
||||
assert.deepEqual(stickyFloorCell(30.2011, -97.66, prev), { lat: 30.201, lon: -97.66 });
|
||||
});
|
||||
|
||||
test('stickyFloorCell holds across a diagonal corner excursion', () => {
|
||||
const prev = { lat: 30.2, lon: -97.66 };
|
||||
assert.deepEqual(stickyFloorCell(30.2006, -97.6606, prev), prev);
|
||||
});
|
||||
|
||||
test('stickyFloorCell does not stick to a far-away stale cell', () => {
|
||||
const prev = { lat: 30.1, lon: -97.66 };
|
||||
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
|
||||
});
|
||||
|
||||
// --- F2: corridor budget fairness ------------------------------------------
|
||||
// The first cut spent the budget in Map insertion order and decremented it
|
||||
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
|
||||
// already collected) burned slots and the contacts actually outrunning their
|
||||
// floor data got nothing.
|
||||
|
||||
/** Warmth oracle for the budget-policy tests: nothing is warm, so every cell
|
||||
* is charged and the allocation order under test is the only variable. */
|
||||
const NONE_WARM = () => false;
|
||||
|
||||
const cellsFrom = (lat, n) => Array.from({ length: n }, (_, i) => ({ lat: +(lat + i * 0.001).toFixed(3), lon: -97.66 }));
|
||||
|
||||
test('allocateCorridorCells: already-collected cells cost nothing', () => {
|
||||
const seen = new Set(['30.2,-97.66']);
|
||||
const got = allocateCorridorCells(
|
||||
[{ cells: [{ lat: 30.2, lon: -97.66 }], cold: 0, speedMps: 0 }], seen, 4, 4, 0, NONE_WARM,
|
||||
);
|
||||
assert.deepEqual(got, [], 'a parked contact on a collected cell allocates nothing');
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: a parked contact never crowds out a mover', () => {
|
||||
const seen = new Set(['30.2,-97.66']);
|
||||
const parked = { cells: [{ lat: 30.2, lon: -97.66 }], cold: 0, speedMps: 0 };
|
||||
const mover = { cells: cellsFrom(30.3, 3), cold: 3, speedMps: 12 };
|
||||
// Parked FIRST in insertion order — the old bug's shape.
|
||||
const got = allocateCorridorCells([parked, mover], seen, 3, 4, 0, NONE_WARM);
|
||||
assert.equal(got.length, 3);
|
||||
assert.deepEqual(got.map((c) => c.lat), [30.3, 30.301, 30.302]);
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: 85 grounded contacts — the needy are served first', () => {
|
||||
// 64 parked contacts whose cells the poll already collected, then 21 movers
|
||||
// with cold ground ahead. Insertion-order budgeting starved every mover.
|
||||
const seen = new Set();
|
||||
const candidates = [];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const cell = { lat: +(31 + i * 0.001).toFixed(3), lon: -97.66 };
|
||||
seen.add(`${cell.lat},${cell.lon}`);
|
||||
candidates.push({ cells: [cell], cold: 0, speedMps: 0 });
|
||||
}
|
||||
const movers = [];
|
||||
for (let i = 0; i < 21; i++) {
|
||||
const cells = cellsFrom(32 + i, 4);
|
||||
movers.push(cells);
|
||||
candidates.push({ cells, cold: 4, speedMps: 10 });
|
||||
}
|
||||
const got = allocateCorridorCells(candidates, seen, 64, 4, 0, NONE_WARM);
|
||||
const gotKeys = new Set(got.map((c) => `${c.lat},${c.lon}`));
|
||||
// Demand (21 x 4 = 84) exceeds the 64-cell budget, so not every mover can be
|
||||
// filled — but none may be left with NOTHING, and no slot may go to a parked
|
||||
// contact whose cell the poll already had.
|
||||
for (const cells of movers) {
|
||||
assert.ok(gotKeys.has(`${cells[0].lat},${cells[0].lon}`),
|
||||
`mover at ${cells[0].lat} was starved outright`);
|
||||
}
|
||||
assert.equal(got.length, 64, 'budget is spent, and spent entirely on movers');
|
||||
for (const cell of got) {
|
||||
assert.ok(cell.lat >= 32, `slot wasted on an already-collected parked cell (${cell.lat})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: fair share caps a long corridor in the first pass', () => {
|
||||
const seen = new Set();
|
||||
const hog = { cells: cellsFrom(30, 12), cold: 12, speedMps: 30 };
|
||||
const small = { cells: cellsFrom(40, 2), cold: 2, speedMps: 5 };
|
||||
const got = allocateCorridorCells([hog, small], seen, 6, 4, 0, NONE_WARM);
|
||||
const lats = got.map((c) => c.lat);
|
||||
assert.ok(lats.includes(40) && lats.includes(40.001),
|
||||
'the small corridor is served before the hog takes seconds');
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: leftover budget goes to the neediest in pass two', () => {
|
||||
const seen = new Set();
|
||||
const hog = { cells: cellsFrom(30, 8), cold: 8, speedMps: 30 };
|
||||
const small = { cells: cellsFrom(40, 1), cold: 1, speedMps: 5 };
|
||||
const got = allocateCorridorCells([hog, small], seen, 8, 4, 0, NONE_WARM);
|
||||
assert.equal(got.length, 8);
|
||||
assert.ok(got.filter((c) => c.lat >= 30 && c.lat < 31).length === 7, 'hog gets the remainder');
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: mutates `seen` so the caller stays deduped', () => {
|
||||
const seen = new Set();
|
||||
allocateCorridorCells([{ cells: cellsFrom(30, 2), cold: 2, speedMps: 1 }], seen, 4, 4, 0, NONE_WARM);
|
||||
assert.ok(seen.has('30,-97.66') && seen.has('30.001,-97.66'));
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: no budget, no allocation', () => {
|
||||
assert.deepEqual(allocateCorridorCells([{ cells: cellsFrom(30, 2), cold: 2, speedMps: 1 }], new Set(), 0, 4, 0, NONE_WARM), []);
|
||||
assert.deepEqual(allocateCorridorCells([], new Set(), 10, 4, 0, NONE_WARM), []);
|
||||
});
|
||||
|
||||
// --- F1: the corridor covers the ARC, not the chord -------------------------
|
||||
|
||||
test('corridorFloorCells walks a turning path\'s arc, not the chord across it', () => {
|
||||
const path = corridorPathLatLon({
|
||||
extrapolating: true,
|
||||
displayLat: 30.200, displayLon: -97.660, courseDeg: 0, speedMps: 12, turnRateDps: 3,
|
||||
fixLat: 30.199, fixLon: -97.660, lookaheadSec: 30,
|
||||
});
|
||||
const arcCells = corridorFloorCells(path);
|
||||
const chordCells = corridorFloorCells([path[0], path[path.length - 1]]);
|
||||
const chordKeys = new Set(chordCells.map((c) => `${c.lat},${c.lon}`));
|
||||
const offChord = arcCells.filter((c) => !chordKeys.has(`${c.lat},${c.lon}`));
|
||||
assert.ok(offChord.length > 0,
|
||||
'the turn leaves the chord — those cells are the ground a straight corridor left cold');
|
||||
});
|
||||
|
||||
test('corridorFloorCells keeps a multi-leg path gap-free', () => {
|
||||
const path = corridorPathLatLon({
|
||||
extrapolating: true,
|
||||
displayLat: 30.200, displayLon: -97.660, courseDeg: 0, speedMps: 12, turnRateDps: 2,
|
||||
fixLat: 30.199, fixLon: -97.660, lookaheadSec: 40,
|
||||
});
|
||||
const cells = corridorFloorCells(path);
|
||||
const prefix = cells.slice(0, -1);
|
||||
for (let i = 1; i < prefix.length; i++) {
|
||||
const dLat = Math.abs(Math.round((prefix[i].lat - prefix[i - 1].lat) * 1000));
|
||||
const dLon = Math.abs(Math.round((prefix[i].lon - prefix[i - 1].lon) * 1000));
|
||||
assert.ok(dLat <= 1 && dLon <= 1 && (dLat + dLon) > 0,
|
||||
`non-adjacent step ${JSON.stringify(prefix[i - 1])} -> ${JSON.stringify(prefix[i])}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- F2-shifted: a tie larger than the budget must not starve its tail ------
|
||||
// Need-ranking alone left the stable ranked tail with nothing, poll after poll:
|
||||
// starvation moved rather than being removed. The epoch rotates equal-ranked
|
||||
// runs so the tail cycles in.
|
||||
|
||||
test('allocateCorridorCells: the epoch rotates a tie, so a different prefix leads each poll', () => {
|
||||
const cellsFor = (i) => [{ lat: +(50 + i).toFixed(3), lon: -97.66 }];
|
||||
const candidates = Array.from({ length: 5 }, (_, i) => ({ cells: cellsFor(i), cold: 1, speedMps: 10 }));
|
||||
const first = (epoch) => allocateCorridorCells(candidates, new Set(), 1, 4, epoch, NONE_WARM)[0].lat;
|
||||
const leaders = new Set([first(0), first(1), first(2)]);
|
||||
assert.equal(leaders.size, 3, 'the same contact led every poll — the tie never cycles');
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: rotation never demotes a needier contact below a less needy one', () => {
|
||||
const needy = { cells: [{ lat: 60, lon: -97.66 }], cold: 4, speedMps: 1 };
|
||||
const idle = { cells: [{ lat: 61, lon: -97.66 }], cold: 1, speedMps: 99 };
|
||||
for (let epoch = 0; epoch < 6; epoch++) {
|
||||
const got = allocateCorridorCells([idle, needy], new Set(), 1, 4, epoch, NONE_WARM);
|
||||
assert.equal(got[0].lat, 60, `epoch ${epoch} served the less needy contact first`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- F1: the arc walk must be CELL-COMPLETE ---------------------------------
|
||||
// Six FIXED samples were fine at taxi speed and coarse at speed, and a short
|
||||
// leg rounds to a single walk step — so the collector degenerated to testing
|
||||
// the sample points themselves and skipped cells the arc genuinely crosses.
|
||||
// Concrete miss: 5 m/s, 0.4 deg/s, 60 s from (30.200, -97.660) passes through
|
||||
// 30.202,-97.659 for ~4.4 s (~22 m of ground) and never sampled it.
|
||||
|
||||
const TURNING_COAST = {
|
||||
extrapolating: true,
|
||||
displayLat: 30.200, displayLon: -97.660,
|
||||
courseDeg: 0, speedMps: 5, turnRateDps: 0.4,
|
||||
fixLat: 30.199, fixLon: -97.660,
|
||||
lookaheadSec: 60,
|
||||
};
|
||||
|
||||
test('corridorFloorCells covers the cell a slow sustained turn actually occupies', () => {
|
||||
const cells = corridorFloorCells(corridorPathLatLon(TURNING_COAST));
|
||||
const keys = new Set(cells.map((c) => `${c.lat},${c.lon}`));
|
||||
assert.ok(keys.has('30.202,-97.659'),
|
||||
`the arc sits in 30.202,-97.659 for ~22 m; collected ${JSON.stringify(cells)}`);
|
||||
});
|
||||
|
||||
test('corridorFloorCells: every cell the sampled arc passes through is collected', () => {
|
||||
// Independent oracle: re-walk the arc at 2 m granularity and demand that any
|
||||
// cell it occupies for a meaningful stretch is in the corridor.
|
||||
const cells = corridorFloorCells(corridorPathLatLon(TURNING_COAST));
|
||||
const keys = new Set(cells.map((c) => `${c.lat},${c.lon}`));
|
||||
const dwell = new Map();
|
||||
const stepSec = 60 / 500;
|
||||
for (let i = 0; i <= 500; i++) {
|
||||
const p = projectGroundArcLatLon(
|
||||
TURNING_COAST.displayLat, TURNING_COAST.displayLon, TURNING_COAST.courseDeg,
|
||||
TURNING_COAST.speedMps, TURNING_COAST.turnRateDps, (60 * i) / 500,
|
||||
);
|
||||
const c = coarseFloorCoord(p.lat, p.lon);
|
||||
const key = `${c.lat},${c.lon}`;
|
||||
dwell.set(key, (dwell.get(key) || 0) + stepSec * TURNING_COAST.speedMps);
|
||||
}
|
||||
// The walk guarantees cells occupied for at least a step's worth of ground.
|
||||
const guaranteedM = CORRIDOR_WALK_STEP_DEG * 111320;
|
||||
const missed = [...dwell.entries()]
|
||||
.filter(([key, metres]) => metres >= guaranteedM * 1.5 && !keys.has(key))
|
||||
.map(([key]) => key);
|
||||
assert.deepEqual(missed, [], `cells the arc occupies but the corridor missed: ${missed.join(' ')}`);
|
||||
});
|
||||
|
||||
test('corridorPathLatLon spacing stays cell-sized as speed rises', () => {
|
||||
const fast = corridorPathLatLon({ ...TURNING_COAST, speedMps: 25, turnRateDps: 0 });
|
||||
for (let i = 1; i < fast.length; i++) {
|
||||
const dM = Math.hypot(
|
||||
(fast[i].lat - fast[i - 1].lat) * 111320,
|
||||
(fast[i].lon - fast[i - 1].lon) * 111320 * Math.cos(30.2 * Math.PI / 180),
|
||||
);
|
||||
assert.ok(dM <= CORRIDOR_SAMPLE_SPACING_M + 1, `sample gap ${dM.toFixed(1)} m`);
|
||||
}
|
||||
});
|
||||
|
||||
test('corridorPathLatLon truncates a very long arc instead of thinning it', () => {
|
||||
const path = corridorPathLatLon({ ...TURNING_COAST, speedMps: 120, lookaheadSec: 60 });
|
||||
const end = path[path.length - 1];
|
||||
const lenM = Math.hypot(
|
||||
(end.lat - TURNING_COAST.displayLat) * 111320,
|
||||
(end.lon - TURNING_COAST.displayLon) * 111320 * Math.cos(30.2 * Math.PI / 180),
|
||||
);
|
||||
assert.ok(lenM <= CORRIDOR_MAX_LENGTH_M + 50, `projected ${lenM.toFixed(0)} m`);
|
||||
});
|
||||
|
||||
// --- F2: fairness under PRODUCTION `seen` semantics -------------------------
|
||||
// Production's `seen` holds only the CURRENT poll's batch, so a cell warmed on
|
||||
// an earlier poll looked new and was charged again — a contact's near cells
|
||||
// could eat its whole share every poll while the ground ahead stayed cold, and
|
||||
// the ranked tail waited far longer than the policy implies.
|
||||
|
||||
test('allocateCorridorCells: warm cells are emitted but never charged', () => {
|
||||
const warm = new Set(['30,-97.66']);
|
||||
const cells = [{ lat: 30, lon: -97.66 }, { lat: 30.001, lon: -97.66 }];
|
||||
const got = allocateCorridorCells(
|
||||
[{ cells, cold: 1, speedMps: 5 }], new Set(), 1, 4, 0,
|
||||
(c) => warm.has(`${c.lat},${c.lon}`),
|
||||
);
|
||||
assert.equal(got.length, 2, 'the warm cell still reaches the mesh sampler');
|
||||
assert.deepEqual(got.map((c) => c.lat), [30, 30.001]);
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: a warm near-cell cannot eat the budget every poll', () => {
|
||||
const warm = new Set(['30,-97.66']);
|
||||
const hogWarmPrefix = { cells: [{ lat: 30, lon: -97.66 }, { lat: 30.001, lon: -97.66 }], cold: 1, speedMps: 5 };
|
||||
const other = { cells: [{ lat: 45, lon: -97.66 }], cold: 1, speedMps: 5 };
|
||||
const got = allocateCorridorCells(
|
||||
[hogWarmPrefix, other], new Set(), 2, 1, 0,
|
||||
(c) => warm.has(`${c.lat},${c.lon}`),
|
||||
);
|
||||
const lats = got.map((c) => c.lat);
|
||||
assert.ok(lats.includes(30.001), 'the cold cell BEHIND the warm one is still reached');
|
||||
assert.ok(lats.includes(45), 'and the other contact is still served');
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: 80 movers are served within the bound the policy implies', () => {
|
||||
const CELLS_EACH = 4;
|
||||
const BUDGET = 64;
|
||||
const COUNT = 80;
|
||||
// Round one charges at most one cell per candidate, so >= min(COUNT, BUDGET)
|
||||
// candidates are served per poll and an unserved candidate outranks every
|
||||
// served one next poll. Bound falls straight out of that.
|
||||
const boundPolls = Math.ceil(COUNT / BUDGET);
|
||||
const movers = [];
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
movers.push(Array.from(
|
||||
{ length: CELLS_EACH },
|
||||
(_, k) => ({ lat: +(40 + i + k * 0.001).toFixed(3), lon: -97.66 }),
|
||||
));
|
||||
}
|
||||
const warmed = new Set();
|
||||
const servedPoll = new Map();
|
||||
for (let poll = 0; poll < boundPolls; poll++) {
|
||||
const candidates = movers.map((cells) => ({
|
||||
cells,
|
||||
cold: cells.filter((c) => !warmed.has(`${c.lat},${c.lon}`)).length,
|
||||
speedMps: 10,
|
||||
}));
|
||||
// PRODUCTION SEMANTICS: `seen` starts empty every poll — it is this poll's
|
||||
// batch, not a memory of what has already warmed.
|
||||
const got = allocateCorridorCells(
|
||||
candidates, new Set(), BUDGET, 4, poll,
|
||||
(c) => warmed.has(`${c.lat},${c.lon}`),
|
||||
);
|
||||
for (const c of got) warmed.add(`${c.lat},${c.lon}`);
|
||||
movers.forEach((cells, i) => {
|
||||
if (servedPoll.has(i)) return;
|
||||
if (cells.some((c) => warmed.has(`${c.lat},${c.lon}`))) servedPoll.set(i, poll);
|
||||
});
|
||||
}
|
||||
const starved = movers.map((_, i) => i).filter((i) => !servedPoll.has(i));
|
||||
assert.deepEqual(starved, [],
|
||||
`unserved after the ceil(${COUNT}/${BUDGET}) = ${boundPolls}-poll bound: ${starved.join(',')}`);
|
||||
});
|
||||
|
||||
test('allocateCorridorCells: every mover reaches its FULL corridor in bounded polls', () => {
|
||||
const CELLS_EACH = 4;
|
||||
const BUDGET = 64;
|
||||
const COUNT = 80;
|
||||
// Total cold demand / budget, rounded up — the work simply cannot finish
|
||||
// sooner, and the policy must not take longer.
|
||||
const boundPolls = Math.ceil((COUNT * CELLS_EACH) / BUDGET);
|
||||
const movers = [];
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
movers.push(Array.from(
|
||||
{ length: CELLS_EACH },
|
||||
(_, k) => ({ lat: +(40 + i + k * 0.001).toFixed(3), lon: -97.66 }),
|
||||
));
|
||||
}
|
||||
const warmed = new Set();
|
||||
for (let poll = 0; poll < boundPolls; poll++) {
|
||||
const candidates = movers.map((cells) => ({
|
||||
cells,
|
||||
cold: cells.filter((c) => !warmed.has(`${c.lat},${c.lon}`)).length,
|
||||
speedMps: 10,
|
||||
}));
|
||||
const got = allocateCorridorCells(
|
||||
candidates, new Set(), BUDGET, 4, poll,
|
||||
(c) => warmed.has(`${c.lat},${c.lon}`),
|
||||
);
|
||||
for (const c of got) warmed.add(`${c.lat},${c.lon}`);
|
||||
}
|
||||
const incomplete = movers
|
||||
.map((cells, i) => [i, cells.filter((c) => !warmed.has(`${c.lat},${c.lon}`)).length])
|
||||
.filter(([, cold]) => cold > 0);
|
||||
assert.deepEqual(incomplete, [],
|
||||
`corridors still cold after the ceil(${COUNT * CELLS_EACH}/${BUDGET}) = ${boundPolls}-poll bound`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// neighborFloorM (2026-08-21 terrain-outage round). The Re:Earth proxy timed
|
||||
// out four times in a row and a grounded contact rendered at the geoid, under
|
||||
// the mesh it was parked on. An adjacent resolved cell is the measured
|
||||
// stand-in the flights display-floor hold borrows when its own cell is cold.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('neighborFloorM leans to the LOWEST resolved neighbour', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.201, -97.66, 141);
|
||||
reportMeshFloorCell(30.199, -97.66, 152);
|
||||
reportMeshFloorCell(30.2, -97.659, 145);
|
||||
// Flat-ish ground: the neighbours are within metres and the choice barely
|
||||
// matters. It matters at a structure edge, which the next case covers.
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), 141);
|
||||
});
|
||||
|
||||
test('neighborFloorM takes the apron, not the roof, at a structure edge', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.201, -97.66, 120); // the apron
|
||||
reportMeshFloorCell(30.199, -97.66, 205); // the terminal roof next door
|
||||
// A parked contact is on the apron; it is never on the roof. Leaning high
|
||||
// here is what put planes in midair at gates during the field test.
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), 120);
|
||||
});
|
||||
|
||||
test('neighborFloorM refuses a LONE neighbour — one reading cannot be checked', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.199, -97.66, 205); // could be a roof; nothing to compare it to
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), null,
|
||||
'refusing leaves the contact unclamped, which is inert — borrowing floats it permanently');
|
||||
// A second reading makes the pair comparable, and the low one is the ground.
|
||||
reportMeshFloorCell(30.201, -97.66, 120);
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), 120);
|
||||
});
|
||||
|
||||
test('neighborFloorM is null when nothing around the cell has resolved', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), null);
|
||||
assert.equal(neighborFloorM(null), null);
|
||||
assert.equal(neighborFloorM({ lat: Number.NaN, lon: -97.66 }), null);
|
||||
});
|
||||
|
||||
test('neighborFloorM never counts the cell itself — it exists because that one is cold', () => {
|
||||
_clearMeshFloorCellsForTest();
|
||||
setMeshFloorPreferred(true);
|
||||
reportMeshFloorCell(30.2, -97.66, 500);
|
||||
assert.equal(neighborFloorM({ lat: 30.2, lon: -97.66 }), null);
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
|
||||
*
|
||||
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
|
||||
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
|
||||
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
|
||||
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
|
||||
* renders inside the tiles or hovering above them. Billboards dodge this with
|
||||
* disableDepthTestDistance; a depth-tested glTF model cannot.
|
||||
*
|
||||
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
|
||||
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
|
||||
* ONE successful sample, cached per icao; the cache stops answering directly when
|
||||
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
|
||||
* still streaming, OSM fallback with no tileset skin, sample outside loaded
|
||||
* geometry) are a retry-later signal with per-icao exponential backoff. A
|
||||
* per-window budget stops a freshly modeled airport cluster from firing dozens of
|
||||
* pick renders in one fleet tick.
|
||||
*
|
||||
* What a caller gets while a sample is outstanding depends on whether this
|
||||
* contact has ever HAD one, and the difference is the difference between two
|
||||
* kinds of wrong:
|
||||
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
|
||||
* ground is, and the only honest answer for a depth-tested model is not to
|
||||
* place it at all; the caller keeps the depth-test-free billboard.
|
||||
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
|
||||
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
|
||||
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
|
||||
* retry backoff, and the ground it was measured on a few dozen metres back is
|
||||
* real evidence, not a guess. Same shape as the billboard chain's
|
||||
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
|
||||
* bounded by how far the contact can have travelled from it.
|
||||
*
|
||||
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
|
||||
* fresh evidence that contradicts it. Distance travelled is only a proxy for
|
||||
* "does this still describe the ground here", and it is a loose one — a contact
|
||||
* can taxi 200 m onto a different surface well inside the bound, and because a
|
||||
* miss preserves the hold, nothing in the retry path would ever correct that.
|
||||
* So where the mesh-floor cells have actually MEASURED the surface under the
|
||||
* contact's current position, and that surface is ABOVE the held value by more
|
||||
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
|
||||
* the contact is COLD again. The fallback is the floored 2D billboard, which is
|
||||
* the honest picture — a model buried in the apron is not a better one.
|
||||
*
|
||||
* Shared by flights.js and militaryFlights.js (one instance each — the caches
|
||||
* are per-layer, keyed by that layer's icao space).
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } from './groundFloor.js';
|
||||
|
||||
/** Taxi threshold: a cached snap answers directly for moves up to this far from
|
||||
* the sampled spot (m). Past it the value is demoted to a held last-known and a
|
||||
* resample runs. */
|
||||
const MOVE_INVALIDATE_M = 50;
|
||||
/** @constant {number} How far a contact may travel from the spot its snap was
|
||||
* measured at before that measurement stops describing the ground under it, and
|
||||
* the hold is dropped rather than stretched.
|
||||
*
|
||||
* Sized to two things at once. It has to outlast a resample outage, or it buys
|
||||
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
|
||||
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
|
||||
* prevent. And it has to stay inside the grade error an airfield can hide: the
|
||||
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
|
||||
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
|
||||
* already being added, and two orders under the ~100+ m burial the snap exists
|
||||
* to prevent.
|
||||
*
|
||||
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
|
||||
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
|
||||
* under-read there is inert; here the held value IS the placement, so the error
|
||||
* shows in both directions and the bound has to be tighter. */
|
||||
const HELD_SNAP_MAX_DRIFT_M = 250;
|
||||
/** @constant {number} How far a held measurement may disagree with FRESH
|
||||
* measured evidence at the contact's current position before it is dropped
|
||||
* rather than trusted.
|
||||
*
|
||||
* The drift bound above asks "how far has the contact travelled?", which is a
|
||||
* proxy for "does this value still describe the ground?" — and a proxy is all
|
||||
* it is. 202 m of taxi onto a different surface is inside the bound and still
|
||||
* wrong, and because a miss preserves the hold, repeated misses preserve the
|
||||
* error for as long as they last. So the bound is not the only guard: where
|
||||
* something has actually MEASURED the surface the contact is standing on now,
|
||||
* that measurement outranks the memory, and a memory that contradicts it is
|
||||
* discarded rather than stretched to cover the gap.
|
||||
*
|
||||
* 5 m sits between the two scales this has to separate:
|
||||
* - ABOVE the honest disagreement between two measurements of the SAME
|
||||
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
|
||||
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
|
||||
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
|
||||
* those should cost a taxiing aircraft its model.
|
||||
* - BELOW the scale at which a placement reads as wrong. The belly offsets
|
||||
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
|
||||
* model displaced more than ~5 m is visibly off its gear rather than
|
||||
* imprecisely on it — and it is two orders under the ~100+ m burial the
|
||||
* snap exists to prevent.
|
||||
*
|
||||
* Applied in ONE direction: the hold is released only when fresh evidence sits
|
||||
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
|
||||
* is the entire reason this gate exists. The other sign is deliberately left
|
||||
* alone, and the asymmetry is not tidiness:
|
||||
*
|
||||
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
|
||||
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
|
||||
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
|
||||
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
|
||||
* built to under-read rather than to invent. Releasing on that sign would
|
||||
* discard good measurements wherever the two disagree downward. Measured on
|
||||
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
|
||||
* the SAME spot, and a two-sided rule dropped a hold that was correct.
|
||||
* - The residual is stated rather than hidden, and stated WITH its condition.
|
||||
* A contact taxiing DOWNHILL keeps a held value above its new surface and
|
||||
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
|
||||
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
|
||||
* so the retries never land and the float persists for as long as the
|
||||
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
|
||||
* and no vertical cap of its own — only whatever the ground drops within
|
||||
* that radius. The other releases (a ground flip via forget(), travelling
|
||||
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
|
||||
* downhill taxi is guaranteed to hit either. It is accepted over the
|
||||
* alternative because it errs UPWARD and stays visible, where a buried model
|
||||
* errs into the terrain and cannot be seen at all. */
|
||||
const HELD_SNAP_CONTRADICTION_M = 5;
|
||||
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
|
||||
const RETRY_BASE_MS = 2000;
|
||||
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
|
||||
const RETRY_MAX_MS = 30000;
|
||||
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
|
||||
const SAMPLE_BUDGET_PER_WINDOW = 4;
|
||||
/** Budget window length (ms). */
|
||||
const SAMPLE_WINDOW_MS = 250;
|
||||
|
||||
/**
|
||||
* True when the active photoreal tileset has finished streaming the tiles in
|
||||
* view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in
|
||||
* scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before
|
||||
* that both blows the frame budget on forced synchronous loads and produces
|
||||
* misses that would burn retry backoff for nothing. With NO tileset present
|
||||
* (OSM fallback) this returns true so the sample path is not permanently
|
||||
* blocked — the sample just misses and backs off.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _tilesReady(viewer) {
|
||||
const prims = viewer?.scene?.primitives;
|
||||
if (!prims) return false;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
let p = null;
|
||||
try { p = prims.get(i); } catch { continue; }
|
||||
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
|
||||
return p.tilesLoaded === true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-layer ground-snap cache.
|
||||
* @returns {{
|
||||
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
|
||||
* getExclusions?: () => Array<unknown>) => number|null,
|
||||
* forget: (icao: string) => void,
|
||||
* clear: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function createGroundSnap() {
|
||||
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
|
||||
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
|
||||
* the sampled height and the surface point it was measured at, whether that
|
||||
* pair is still the fresh answer or has been demoted to a bounded last-known
|
||||
* (`held`), and the retry backoff a miss earned. */
|
||||
const entries = new Map();
|
||||
let windowStartMs = 0;
|
||||
let windowCount = 0;
|
||||
const scratchCarto = new Cesium.Cartographic();
|
||||
const scratchSurfacePos = new Cesium.Cartesian3();
|
||||
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
|
||||
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
|
||||
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
|
||||
* back after the sample lands. */
|
||||
const scratchHeldCarto = new Cesium.Cartographic();
|
||||
|
||||
/**
|
||||
* The last measurement for a contact whose fresh snap has been demoted — it
|
||||
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
|
||||
*
|
||||
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
|
||||
* spot the value was measured at; past that the memory is DROPPED rather than
|
||||
* stretched, and the contact is back to no evidence (null, model hidden). The
|
||||
* bound is purely spatial on purpose: the ground under a contact that has not
|
||||
* moved does not change with time, so what invalidates the value is the
|
||||
* contact MOVING, which is exactly what this measures.
|
||||
*
|
||||
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
|
||||
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function heldSnapM(entry, surfacePos) {
|
||||
if (!entry || !entry.held || entry.h == null || !entry.samplePos) return null;
|
||||
if (Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos)
|
||||
> HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M) {
|
||||
return dropHold(entry);
|
||||
}
|
||||
// Held memory never outranks fresh contradicting evidence. Inside the drift
|
||||
// bound the contact can still have taxied onto a different surface — an
|
||||
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
|
||||
// the hold, nothing else would ever correct it: the model would stay buried
|
||||
// (or floating) for as long as the resample kept missing.
|
||||
const fresh = freshMeasuredFloorAt(surfacePos);
|
||||
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
|
||||
return dropHold(entry);
|
||||
}
|
||||
return entry.h;
|
||||
}
|
||||
|
||||
/** Forget a demoted measurement: it no longer describes anywhere this contact
|
||||
* is, so the contact is COLD again (model hidden, floored billboard). */
|
||||
function dropHold(entry) {
|
||||
entry.h = null;
|
||||
entry.samplePos = null;
|
||||
entry.held = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Independently measured surface height at the contact's CURRENT position, or
|
||||
* null where nothing has measured there yet.
|
||||
*
|
||||
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
|
||||
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
|
||||
* measurement through the same validation gate (written by this module's own
|
||||
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
|
||||
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
|
||||
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
|
||||
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
|
||||
* holds over a disagreement that says nothing about where the model sits.
|
||||
*
|
||||
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
|
||||
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
|
||||
* to answer from a single sample — and borrowing one to DISCARD a real
|
||||
* measurement would let a roof cell hide a correctly placed model.
|
||||
*
|
||||
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function freshMeasuredFloorAt(surfacePos) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
surfacePos, Cesium.Ellipsoid.WGS84, scratchHeldCarto,
|
||||
);
|
||||
if (!carto) return null;
|
||||
return cachedMeshFloor(
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
|
||||
* when this contact has NO ground evidence at all (caller keeps the model
|
||||
* hidden and calls again on a later tick — cheap map hit until a retry is due).
|
||||
* A contact that HAS resolved once keeps that answer through a resample outage;
|
||||
* see `heldSnapM`.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
|
||||
* @param {string} icao - Cache key (per-layer icao space).
|
||||
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
|
||||
* is measured after projecting this input to the WGS84 ellipsoid, so a
|
||||
* poll-time altitude/datum change cannot invalidate a stationary snap.
|
||||
* @param {(() => Array<unknown>)|undefined} getExclusions - Lazily builds the
|
||||
* scene.sampleHeight objectsToExclude list (own billboards/models/entities —
|
||||
* the vertical pick ray at a plane's OWN lat/lon lands on its icon/model
|
||||
* otherwise). Only invoked when a sample actually fires.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function heightFor(viewer, icao, pos, getExclusions) {
|
||||
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(pos, scratchSurfacePos);
|
||||
if (!surfacePos) return null;
|
||||
const cached = entries.get(icao);
|
||||
if (cached && cached.h != null && cached.samplePos && !cached.held) {
|
||||
if (Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <= MOVE_INVALIDATE_M * MOVE_INVALIDATE_M) {
|
||||
return cached.h;
|
||||
}
|
||||
// Taxied away from the sampled spot. The measurement is DEMOTED to a
|
||||
// bounded last-known rather than deleted, and the resample runs below: a
|
||||
// miss now holds the aircraft on the ground it just left instead of
|
||||
// hiding a model that was already up.
|
||||
cached.held = true;
|
||||
cached.nextRetryMs = 0;
|
||||
cached.misses = 0;
|
||||
}
|
||||
const now = Date.now();
|
||||
const entry = entries.get(icao);
|
||||
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
|
||||
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
|
||||
// tries again next tick instead of waiting out a backoff it didn't earn.
|
||||
if (now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; }
|
||||
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW) return heldSnapM(entry, surfacePos);
|
||||
const misses = entry ? entry.misses : 0;
|
||||
const miss = () => {
|
||||
// A held measurement survives the miss — only the retry schedule moves.
|
||||
const held = !!(entry && entry.held && entry.h != null && entry.samplePos);
|
||||
const next = {
|
||||
h: held ? entry.h : null,
|
||||
samplePos: held ? entry.samplePos : null,
|
||||
held,
|
||||
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
|
||||
misses: misses + 1,
|
||||
};
|
||||
entries.set(icao, next);
|
||||
return heldSnapM(next, surfacePos);
|
||||
};
|
||||
if (!_tilesReady(viewer)) return miss();
|
||||
windowCount += 1;
|
||||
let sampled;
|
||||
try {
|
||||
const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, scratchCarto);
|
||||
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
|
||||
sampled = viewer.scene.sampleHeight(carto, getExclusions ? getExclusions() : undefined);
|
||||
} catch {
|
||||
sampled = undefined;
|
||||
}
|
||||
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
|
||||
// ellipsoid is pick garbage, not ground.
|
||||
if (!Number.isFinite(sampled) || sampled < -150) return miss();
|
||||
if (meshFloorPreferred()) {
|
||||
reportValidatedMeshFloorCell(
|
||||
Cesium.Math.toDegrees(scratchCarto.latitude),
|
||||
Cesium.Math.toDegrees(scratchCarto.longitude),
|
||||
sampled,
|
||||
);
|
||||
}
|
||||
// A fresh sample RELEASES any hold: this is a measurement of where the
|
||||
// contact is now, and it outranks a memory of where it was.
|
||||
entries.set(icao, {
|
||||
h: sampled,
|
||||
samplePos: Cesium.Cartesian3.clone(surfacePos),
|
||||
held: false,
|
||||
nextRetryMs: 0,
|
||||
misses: 0,
|
||||
});
|
||||
return sampled;
|
||||
}
|
||||
|
||||
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
|
||||
function forget(icao) { entries.delete(icao); }
|
||||
|
||||
/** Drop everything (layer destroy). */
|
||||
function clear() { entries.clear(); }
|
||||
|
||||
return { heightFor, forget, clear };
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Shared icon-orientation + horizon helpers. Orientation and culling serve the
|
||||
* moving-entity layers (commercial flights, military flights, AIS vessels);
|
||||
* `skyBackdropFactor` serves the detection overlay, which needs the same
|
||||
* silhouette geometry to know what a label is being read against.
|
||||
*
|
||||
* THE ORIENTATION PROBLEM (2026-06-10 playtest): billboards are camera-facing
|
||||
* quads, so "point along the real-world course" must be computed in SCREEN
|
||||
* space. The previous approach mixed two regimes (surface-normal alignedAxis
|
||||
* at oblique pitch, camera-heading compensation at nadir) and broke in
|
||||
* tracked-entity orbit mode, where camera.heading is expressed in the
|
||||
* entity's reference frame — the tracked icon stayed glued to the viewport.
|
||||
*
|
||||
* THE FIX: transform the local course vector into world space, then project it
|
||||
* directly onto the camera's right/up basis. This stays valid when a forward
|
||||
* probe point would be off-screen or behind the camera during a >180° tracked
|
||||
* orbit. The camera-basis projection is exact at the viewport center and an
|
||||
* intentional orthographic approximation elsewhere: an off-center contact at
|
||||
* oblique pitch can diverge from a pinhole/window-space projection because the
|
||||
* latter also includes the course vector's depth component. The approximation
|
||||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||||
* unverified math swap, decides whether exact projection becomes preferred.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Meters used to give the course vector a stable projection magnitude. */
|
||||
const FORWARD_PROBE_M = 2000;
|
||||
/**
|
||||
* Minimum camera-plane component before we trust the angle — below this the
|
||||
* course points almost exactly into/out of the screen and the angle is noise.
|
||||
*/
|
||||
const MIN_SCREEN_COMPONENT_M = 0.5;
|
||||
/** Ignore sub-degree projection noise while retaining deliberate camera-orbit rotation. */
|
||||
const ROTATION_DEADBAND_RAD = Cesium.Math.toRadians(0.5);
|
||||
|
||||
const _scratchEnu = new Cesium.Matrix4();
|
||||
const _scratchForward = new Cesium.Cartesian3();
|
||||
const _scratchWorldForward = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Computes the billboard rotation (radians, CCW-positive, for
|
||||
* alignedAxis = Cartesian3.ZERO) that points an icon along its real-world
|
||||
* course in the stable camera-basis approximation described above.
|
||||
*
|
||||
* @param {Cesium.Scene} scene - The scene (projection source).
|
||||
* @param {Cesium.Cartesian3} position - Entity world position.
|
||||
* @param {number} courseDeg - Course/track in degrees clockwise from north.
|
||||
* @param {number|null} previous - Rotation to keep when projection is
|
||||
* unavailable (off-screen/behind camera/degenerate).
|
||||
* @returns {number|null} Rotation in radians, or `previous` when unknown.
|
||||
*/
|
||||
export function screenProjectedRotation(scene, position, courseDeg, previous = null) {
|
||||
const camera = scene?.camera;
|
||||
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
|
||||
|
||||
const courseRad = Cesium.Math.toRadians(courseDeg || 0);
|
||||
Cesium.Cartesian3.fromElements(
|
||||
Math.sin(courseRad) * FORWARD_PROBE_M,
|
||||
Math.cos(courseRad) * FORWARD_PROBE_M,
|
||||
0,
|
||||
_scratchForward
|
||||
);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position, Cesium.Ellipsoid.WGS84, _scratchEnu);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(enu, _scratchForward, _scratchWorldForward);
|
||||
|
||||
// Screen x follows camera-right. Window y grows downward, the opposite of
|
||||
// camera-up. Projecting the vector itself avoids clipping/behind-camera
|
||||
// failure modes from the old forward-point worldToWindowCoordinates probe.
|
||||
const dx = Cesium.Cartesian3.dot(_scratchWorldForward, camera.rightWC);
|
||||
const dy = -Cesium.Cartesian3.dot(_scratchWorldForward, camera.upWC);
|
||||
if (
|
||||
(dx * dx + dy * dy)
|
||||
< MIN_SCREEN_COMPONENT_M * MIN_SCREEN_COMPONENT_M
|
||||
) return previous;
|
||||
|
||||
// Window y grows downward; rotation 0 = icon pointing screen-up.
|
||||
// Icon direction in window coords after CCW rotation r is (-sin r, -cos r),
|
||||
// so matching the projected course (dx, dy) gives r = atan2(-dx, -dy).
|
||||
return Math.atan2(-dx, -dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a billboard rotation when the newly projected angle differs only by
|
||||
* sub-degree render noise. The comparison uses the shortest wrapped arc so
|
||||
* values around ±π do not jump.
|
||||
*
|
||||
* @param {number|null} previous Last displayed rotation.
|
||||
* @param {number|null} next Newly projected rotation.
|
||||
* @param {number} [deadbandRad=ROTATION_DEADBAND_RAD] Angular hold threshold.
|
||||
* @returns {number|null} Stable rotation.
|
||||
*/
|
||||
export function stabilizeScreenRotation(
|
||||
previous,
|
||||
next,
|
||||
deadbandRad = ROTATION_DEADBAND_RAD,
|
||||
) {
|
||||
if (!Number.isFinite(next)) return Number.isFinite(previous) ? previous : null;
|
||||
if (!Number.isFinite(previous)) return next;
|
||||
const delta = Math.atan2(Math.sin(next - previous), Math.cos(next - previous));
|
||||
return Math.abs(delta) < Math.max(0, deadbandRad) ? previous : next;
|
||||
}
|
||||
|
||||
const _occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, new Cesium.Cartesian3());
|
||||
|
||||
/**
|
||||
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
|
||||
* radians. ~1.1° per side: at the product's default 60° frustum on a 1280×800
|
||||
* viewport that is roughly 30 px of screen crossfade — wide enough that a
|
||||
* contact drifting across the horizon never pops, narrow enough that a label
|
||||
* plainly over ground still gets its full treatment.
|
||||
*/
|
||||
export const HORIZON_FEATHER_RAD = 0.019;
|
||||
|
||||
const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
|
||||
/**
|
||||
* How much SKY sits behind a world point from this camera: 0 (planet behind)
|
||||
* … 1 (sky behind), smoothly blended across a band centred on the horizon.
|
||||
*
|
||||
* THERE ARE TWO REGIMES AND THEY ANSWER DIFFERENT QUESTIONS. Read this before
|
||||
* trusting the number for anything other than a plate alpha.
|
||||
*
|
||||
* ABOVE the ellipsoid the horizon is the tangent silhouette and the test is a
|
||||
* genuine ray/ellipsoid hit test: 1 means the view ray through the point misses
|
||||
* the planet entirely. Here the function is the exact complement of
|
||||
* {@link horizonOccluder} — the occluder asks whether the planet is in FRONT of
|
||||
* a point (cull it), this asks whether it is BEHIND (what a screen-space label
|
||||
* anchored there is read against). For any point the occluder keeps, a ray that
|
||||
* hits the ellipsoid must hit it beyond the point, so the hit/miss test alone
|
||||
* settles the backdrop.
|
||||
*
|
||||
* AT OR BELOW the ellipsoid there is no tangent cone, and the test is NOT a ray
|
||||
* hit test. It is an EYE-PLANE convention: 1 means the point sits above the
|
||||
* camera's local geodetic horizontal. The two genuinely differ — from a camera
|
||||
* 18 m under the surface, the ray to a contact 900 m up and a few km out
|
||||
* CROSSES the ellipsoid on the way there, and the answer is still sky. That is
|
||||
* deliberate. A sub-ellipsoid camera is an artifact of the geoid/ellipsoid
|
||||
* split, not of being underground: the geoid runs ~34 m below the ellipsoid at
|
||||
* JFK, so a cockpit parked on the ramp there sits at −18 m of ellipsoid height
|
||||
* with the whole rendered world still above it. The ellipsoid that ray crosses
|
||||
* is not a surface anyone can see, so intersecting it would answer a question
|
||||
* nobody asked; what a label is read against is decided instead by whether it
|
||||
* sits above or below the viewer's eye level. Cesium's `EllipsoidalOccluder`
|
||||
* takes the same convention below the surface, so the two stay sign-consistent
|
||||
* — the complementarity above survives as agreement here.
|
||||
*
|
||||
* The regimes MEET rather than being switched between: the horizon dip
|
||||
* acos(R/(R+h)) falls to zero as the camera settles onto the surface, so the
|
||||
* tangent cone opens continuously to the horizontal plane, and clamping the
|
||||
* cone's sine at 1 (half-angle 90°) IS that limit — not a special case.
|
||||
*
|
||||
* Computed in scaled space, where WGS84 becomes a unit sphere. That map is
|
||||
* affine, so both regimes stay exact: above the surface ray-surface
|
||||
* intersection is preserved and the zero crossing is the true silhouette rather
|
||||
* than a spherical stand-in; below it the eye plane is the true GEODETIC
|
||||
* tangent plane, because the scaled radial maps back to (x/a², y/a², z/b²), the
|
||||
* real surface normal, which at 40°N sits 0.19° off the geocentric radial. Only
|
||||
* the magnitude of the margin picks up the flattening, and a feather band does
|
||||
* not care about a third of a percent.
|
||||
*
|
||||
* Deliberately geometric, not photometric, in BOTH regimes: sampling rendered
|
||||
* pixels behind every label would be correct about mountains and towers that
|
||||
* rise above the horizon, and would also cost a readback per label per frame.
|
||||
* The band absorbs that approximation — a contact just above the horizon lands
|
||||
* mid-blend, which is the honest answer when the backdrop is part sky.
|
||||
*
|
||||
* @param {Cesium.Cartesian3} cameraPosition World camera position.
|
||||
* @param {Cesium.Cartesian3} position World position of the labelled point.
|
||||
* @param {number} [featherRad=HORIZON_FEATHER_RAD] Half-width of the blend band.
|
||||
* @returns {number} 0 (ground behind) … 1 (sky behind).
|
||||
*/
|
||||
export function skyBackdropFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
if (!cameraPosition || !position) return 0;
|
||||
|
||||
const s = _wgs84OneOverRadii;
|
||||
const cx = cameraPosition.x * s.x;
|
||||
const cy = cameraPosition.y * s.y;
|
||||
const cz = cameraPosition.z * s.z;
|
||||
const cameraMag = Math.sqrt(cx * cx + cy * cy + cz * cz);
|
||||
// Only the planet's exact centre (or a non-finite camera) has no local
|
||||
// vertical to measure against. Everything else — including the sub-ellipsoid
|
||||
// cameras that coastal ground level actually produces — gets a real answer.
|
||||
// `> 0` already rejects NaN and negatives; the finite check also rejects an
|
||||
// infinite coordinate, which would otherwise normalize to NaN below and slip
|
||||
// a NaN plate alpha into the paint.
|
||||
if (!(cameraMag > 0) || !Number.isFinite(cameraMag)) return 0;
|
||||
|
||||
let dx = position.x * s.x - cx;
|
||||
let dy = position.y * s.y - cy;
|
||||
let dz = position.z * s.z - cz;
|
||||
const rayMag = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
// Same two rejections on the ray: a zero-length one has no direction, and an
|
||||
// infinite one normalizes to NaN.
|
||||
if (!(rayMag > 0) || !Number.isFinite(rayMag)) return 0;
|
||||
dx /= rayMag;
|
||||
dy /= rayMag;
|
||||
dz /= rayMag;
|
||||
|
||||
// Angle between the view ray and the direction to the planet's centre,
|
||||
// against the half-angle of the tangent cone. Inside the cone the ray strikes
|
||||
// the planet; outside it, the ray escapes to sky.
|
||||
const cosToCentre = -(cx * dx + cy * dy + cz * dz) / cameraMag;
|
||||
const rayAngle = Math.acos(Math.min(1, Math.max(-1, cosToCentre)));
|
||||
// The clamp is the eye-level case described above, reached as a limit rather
|
||||
// than as a special case: at and under the surface the cone has opened to the
|
||||
// full 90° half-angle and the test reads "above or below local horizontal".
|
||||
const horizonHalfAngle = Math.asin(Math.min(1, 1 / cameraMag));
|
||||
const margin = rayAngle - horizonHalfAngle;
|
||||
|
||||
if (!(featherRad > 0)) return margin > 0 ? 1 : 0;
|
||||
const t = Math.min(1, Math.max(0, margin / (2 * featherRad) + 0.5));
|
||||
// Smoothstep: C1-continuous, so a contact crossing the horizon fades rather
|
||||
// than steps, and the fade has no visible corner at either edge of the band.
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared horizon occluder, updated to the camera's position.
|
||||
* With the Cesium globe hidden (Google 3D tiles provide the planet) nothing
|
||||
* writes far-side depth, so billboards must be horizon-culled manually.
|
||||
* Call once per tick, then test points with occluder.isPointVisible(pos).
|
||||
*
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
export function horizonOccluder(camera) {
|
||||
_occluder.cameraPosition = camera.positionWC;
|
||||
return _occluder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||||
* passes (position quantized to ~10m, angles to ~0.06 deg).
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cameraPoseSignature(camera) {
|
||||
const p = camera.positionWC;
|
||||
return `${Math.round(p.x / 10)}:${Math.round(p.y / 10)}:${Math.round(p.z / 10)}:` +
|
||||
`${camera.heading.toFixed(3)}:${camera.pitch.toFixed(3)}:${camera.roll.toFixed(3)}`;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
HORIZON_FEATHER_RAD,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
skyBackdropFactor,
|
||||
stabilizeScreenRotation,
|
||||
} from './iconOrientation.js';
|
||||
|
||||
const equatorPosition = Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||
|
||||
function sceneForScreenRotation(degrees) {
|
||||
const angle = Cesium.Math.toRadians(degrees);
|
||||
return {
|
||||
camera: {
|
||||
rightWC: new Cesium.Cartesian3(Math.cos(angle), 0, -Math.sin(angle)),
|
||||
upWC: new Cesium.Cartesian3(Math.sin(angle), 0, Math.cos(angle)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrappedDelta(actual, expected) {
|
||||
return Math.atan2(Math.sin(actual - expected), Math.cos(actual - expected));
|
||||
}
|
||||
|
||||
test('camera-basis course projection stays correct through a full orbit', () => {
|
||||
for (const degrees of [0, 90, 179, 181, 220, 270, 359]) {
|
||||
const actual = screenProjectedRotation(
|
||||
sceneForScreenRotation(degrees),
|
||||
equatorPosition,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(wrappedDelta(actual, Cesium.Math.toRadians(degrees))) < 1e-12,
|
||||
`expected ${degrees}° course projection, received ${Cesium.Math.toDegrees(actual)}°`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('off-center oblique contacts pin the documented perspective divergence', () => {
|
||||
// Camera looks obliquely toward the equator. Right is world-east; up and
|
||||
// forward are pitched 30° from the local tangent/normal pair.
|
||||
const pitch = Cesium.Math.toRadians(30);
|
||||
const right = new Cesium.Cartesian3(0, 1, 0);
|
||||
const up = new Cesium.Cartesian3(Math.sin(pitch), 0, Math.cos(pitch));
|
||||
const forward = new Cesium.Cartesian3(-Math.cos(pitch), 0, Math.sin(pitch));
|
||||
const basisRotation = screenProjectedRotation({ camera: { rightWC: right, upWC: up } },
|
||||
equatorPosition, 0, null);
|
||||
|
||||
// Exact pinhole derivative for a contact 2 km right of center and 5 km in
|
||||
// front. North has a depth component under this oblique camera, so exact
|
||||
// projection turns slightly while the stable camera-basis result stays up.
|
||||
const contactX = 2000;
|
||||
const contactY = 0;
|
||||
const contactZ = 5000;
|
||||
const north = new Cesium.Cartesian3(0, 0, 1);
|
||||
const directionX = Cesium.Cartesian3.dot(north, right);
|
||||
const directionY = Cesium.Cartesian3.dot(north, up);
|
||||
const directionZ = Cesium.Cartesian3.dot(north, forward);
|
||||
const exactScreenX = directionX * contactZ - contactX * directionZ;
|
||||
const exactScreenUp = directionY * contactZ - contactY * directionZ;
|
||||
const exactRotation = Math.atan2(-exactScreenX, exactScreenUp);
|
||||
|
||||
assert.ok(Math.abs(basisRotation) < 1e-12);
|
||||
assert.ok(Math.abs(wrappedDelta(exactRotation, basisRotation)) > Cesium.Math.toRadians(5));
|
||||
});
|
||||
|
||||
test('screen rotation holds sub-degree projection noise', () => {
|
||||
const previous = 1;
|
||||
assert.equal(
|
||||
stabilizeScreenRotation(previous, previous + (0.25 * Math.PI / 180)),
|
||||
previous,
|
||||
);
|
||||
});
|
||||
|
||||
test('screen rotation accepts deliberate camera-orbit movement', () => {
|
||||
const previous = 1;
|
||||
const next = previous + (2 * Math.PI / 180);
|
||||
assert.equal(stabilizeScreenRotation(previous, next), next);
|
||||
});
|
||||
|
||||
test('screen rotation compares across the wrapped angle boundary', () => {
|
||||
const previous = Math.PI - (0.1 * Math.PI / 180);
|
||||
const next = -Math.PI + (0.1 * Math.PI / 180);
|
||||
assert.equal(stabilizeScreenRotation(previous, next), previous);
|
||||
});
|
||||
|
||||
// --- Backdrop discriminator -------------------------------------------------
|
||||
// A label's plate should hold text off busy ground and get out of the way
|
||||
// against empty sky. These pin the geometry that decides which case it is.
|
||||
|
||||
/** Camera at cruise altitude over the equator at lon 0, looking outward. */
|
||||
const CRUISE_M = 10_000;
|
||||
const cruiseCamera = Cesium.Cartesian3.fromDegrees(0, 0, CRUISE_M);
|
||||
|
||||
/**
|
||||
* Longitude of the ellipsoid silhouette from `cruiseCamera`. Along the equator
|
||||
* the WGS84 cross-section is a true circle of radius `maximumRadius`, so the
|
||||
* tangent condition is exact here and the expected answer is derived, not
|
||||
* guessed.
|
||||
*/
|
||||
const tangentLonDeg = Cesium.Math.toDegrees(Math.acos(
|
||||
Cesium.Ellipsoid.WGS84.maximumRadius
|
||||
/ (Cesium.Ellipsoid.WGS84.maximumRadius + CRUISE_M),
|
||||
));
|
||||
|
||||
test('a contact above the silhouette is read against sky', () => {
|
||||
// Another aircraft at the same flight level 200 km ahead. The chord between
|
||||
// two points at equal altitude bends below them, so it exits above the
|
||||
// horizon rather than into the planet — which is why a cruising contact
|
||||
// appears against sky and not against ground.
|
||||
const ahead = Cesium.Cartesian3.fromDegrees(200_000 / 111_320, 0, CRUISE_M);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, ahead), 1);
|
||||
// Straight overhead is the unambiguous case.
|
||||
const overhead = Cesium.Cartesian3.fromDegrees(0, 0, 400_000);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, overhead), 1);
|
||||
});
|
||||
|
||||
test('a contact below the silhouette keeps ground behind it', () => {
|
||||
// Low and close: the view ray through it strikes the planet well short of
|
||||
// the horizon, so the plate has real imagery to separate the text from.
|
||||
const below = Cesium.Cartesian3.fromDegrees(30_000 / 111_320, 0, 2_000);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, below), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)), 0);
|
||||
});
|
||||
|
||||
test('the silhouette itself lands exactly mid-band', () => {
|
||||
// The band is centred on the true horizon, so the tangent point is the 50%
|
||||
// crossing. This is the assertion that fails if the discriminator is ever
|
||||
// swapped for a spherical-earth or screen-space approximation.
|
||||
const tangent = Cesium.Cartesian3.fromDegrees(tangentLonDeg, 0, 0);
|
||||
assert.ok(Math.abs(skyBackdropFactor(cruiseCamera, tangent) - 0.5) < 1e-6);
|
||||
});
|
||||
|
||||
/**
|
||||
* A contact 100 km ahead, climbing from the deck to above the camera. Only
|
||||
* altitude can carry a contact across the silhouette — a point at height zero
|
||||
* is on the planet and can never be in front of sky — so this, not a ground
|
||||
* track, is the sweep that exercises the band.
|
||||
*/
|
||||
function climbSweep(step = 500) {
|
||||
const samples = [];
|
||||
for (let height = 0; height <= 12_000; height += step) {
|
||||
samples.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
test('the band lerps monotonically and saturates on both sides', () => {
|
||||
// Smoothstep across the band means a contact drifting over the horizon
|
||||
// fades rather than popping.
|
||||
const samples = climbSweep();
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
assert.ok(
|
||||
samples[i] >= samples[i - 1] - 1e-12,
|
||||
`factor must not fall as a contact rises past the horizon (${samples[i - 1]} → ${samples[i]})`,
|
||||
);
|
||||
}
|
||||
assert.equal(samples[0], 0, 'well below the horizon is fully grounded');
|
||||
assert.equal(samples[samples.length - 1], 1, 'well above the horizon is fully sky');
|
||||
const partial = samples.filter((value) => value > 0 && value < 1);
|
||||
assert.ok(partial.length >= 3, `expected a real blend band, saw ${partial.length} partial samples`);
|
||||
});
|
||||
|
||||
test('the band, not the geometry, is what smooths the crossing', () => {
|
||||
// With the feather removed the same sweep must collapse to a hard step.
|
||||
// This separates "the discriminator is right" from "the blend is wired up":
|
||||
// if the two ever get conflated, only one of these two tests goes red.
|
||||
const hard = [];
|
||||
for (let height = 0; height <= 12_000; height += 500) {
|
||||
hard.push(skyBackdropFactor(
|
||||
cruiseCamera,
|
||||
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
|
||||
0,
|
||||
));
|
||||
}
|
||||
assert.deepEqual([...new Set(hard)], [0, 1], 'a zero band must produce only the two end states');
|
||||
});
|
||||
|
||||
/**
|
||||
* JFK, where the geoid runs about 34 m BELOW the ellipsoid: the ramp sits near
|
||||
* −30 m of ellipsoid height and a widebody cockpit reads ALT −18 m. Everyday
|
||||
* ground level at a coastal airport is therefore a camera INSIDE the ellipsoid,
|
||||
* which is why this is the scenario the horizon test has to get right.
|
||||
*/
|
||||
const JFK_LON = -73.7781;
|
||||
const JFK_LAT = 40.6413;
|
||||
const JFK_RAMP_H = -30;
|
||||
const jfkCockpit = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const DEG_PER_M_LAT = 1 / 110_574;
|
||||
|
||||
test('below the ellipsoid, a contact against open sky still reads as sky', () => {
|
||||
// The owner's cockpit view: traffic on approach, 8 km out and 900 m up, is
|
||||
// ~6.5° above eye level — the whole band is 1.09° per side, so this is not a
|
||||
// near call. Before the fix a sub-ellipsoid camera fell into the degenerate
|
||||
// guard and every one of these came back 0: a row of dark boxes on empty sky.
|
||||
const approach = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 8_000 * DEG_PER_M_LAT, 900);
|
||||
assert.equal(skyBackdropFactor(jfkCockpit, approach), 1);
|
||||
// Straight overhead is the unambiguous case, from under the surface as much
|
||||
// as from cruise altitude.
|
||||
assert.equal(
|
||||
skyBackdropFactor(jfkCockpit, Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000)),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('below the ellipsoid, a contact on the ramp still keeps its plate', () => {
|
||||
// Sky selectivity must not become "no plates at ground level". An aircraft
|
||||
// 150 m ahead on the ramp sits 12 m below the cockpit — 4.6° under eye level,
|
||||
// four band-widths down — so it is read against apron and keeps the full
|
||||
// plate that holds mono text off bright concrete.
|
||||
const ramp = Cesium.Cartesian3.fromDegrees(
|
||||
JFK_LON,
|
||||
JFK_LAT + 150 * DEG_PER_M_LAT,
|
||||
JFK_RAMP_H,
|
||||
);
|
||||
assert.equal(skyBackdropFactor(jfkCockpit, ramp), 0);
|
||||
// And the taxiway further out, still below the cockpit floor.
|
||||
assert.equal(
|
||||
skyBackdropFactor(
|
||||
jfkCockpit,
|
||||
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 400 * DEG_PER_M_LAT, JFK_RAMP_H - 5),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('at the surface the horizon is exactly eye level, geodetic not geocentric', () => {
|
||||
// With no altitude there is no horizon dip, so the honest silhouette is the
|
||||
// local horizontal plane through the camera and a contact ON that plane is
|
||||
// the 50% crossing. The plane that matters is the GEODETIC tangent plane, and
|
||||
// at 40°N the geodetic normal misses the geocentric radial by ~0.19° — about
|
||||
// a sixth of the 1.09° half-band, so a spherical stand-in would land visibly
|
||||
// off centre rather than at 0.5, which is what the second half of this test
|
||||
// measures.
|
||||
const onSurface = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 0);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(onSurface);
|
||||
const east = Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(20_000, 0, 0),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const alongHorizontal = Cesium.Cartesian3.add(onSurface, east, new Cesium.Cartesian3());
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongHorizontal) - 0.5) < 1e-5,
|
||||
`eye level must be the band centre, got ${skyBackdropFactor(onSurface, alongHorizontal)}`,
|
||||
);
|
||||
// The geocentric radial is NOT the answer: a contact perpendicular to it
|
||||
// lands measurably off centre, so this test cannot pass by accident.
|
||||
const radialUp = Cesium.Cartesian3.normalize(onSurface, new Cesium.Cartesian3());
|
||||
const perpToRadial = Cesium.Cartesian3.cross(
|
||||
radialUp,
|
||||
Cesium.Cartesian3.UNIT_Z,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
Cesium.Cartesian3.cross(perpToRadial, radialUp, perpToRadial);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
Cesium.Cartesian3.normalize(perpToRadial, perpToRadial),
|
||||
20_000,
|
||||
perpToRadial,
|
||||
);
|
||||
const alongRadialPlane = Cesium.Cartesian3.add(onSurface, perpToRadial, new Cesium.Cartesian3());
|
||||
assert.ok(
|
||||
Math.abs(skyBackdropFactor(onSurface, alongRadialPlane) - 0.5) > 0.05,
|
||||
'a geocentric horizontal would be a different plane; the test must be able to tell',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A fixed contact on the deck 30 km east along the equator — where the WGS84
|
||||
* cross-section is a true circle, so the sweep's geometry is exact — read from
|
||||
* a camera climbing from 50 m under the surface to 50 m over it. The contact
|
||||
* sits just under the horizon the whole way, so the factor is mid-band and
|
||||
* genuinely varying: this sweep measures the crossover itself, not a saturated
|
||||
* end state.
|
||||
*/
|
||||
function surfaceCrossingSweep(from = -50, to = 50, step = 1) {
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const samples = [];
|
||||
for (let height = from; height <= to; height += step) {
|
||||
samples.push(skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
test('the answer stays continuous as the camera rises through the surface', () => {
|
||||
// The tangent cone and eye level are the same limit: the horizon dip
|
||||
// acos(R/(R+h)) goes to zero as the camera settles onto the ellipsoid, so the
|
||||
// cone opens continuously to the horizontal plane and the two regimes meet
|
||||
// with no seam. A hard fail-closed under the surface put a full step into the
|
||||
// middle of that climb — plates snapping on as the camera touched down.
|
||||
const samples = surfaceCrossingSweep();
|
||||
let worst = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
worst = Math.max(worst, Math.abs(samples[i] - samples[i - 1]));
|
||||
}
|
||||
assert.ok(worst < 0.05, `no step across the surface may pop; worst adjacent jump ${worst}`);
|
||||
});
|
||||
|
||||
test('the crossover has no seam at the surface itself', () => {
|
||||
// A centimetre either side of the ellipsoid must read the same. The dip at
|
||||
// 1 cm is 5.6e-5 rad, a thousandth of the band, so any visible difference
|
||||
// here is a discontinuity in the code rather than in the geometry.
|
||||
const contact = Cesium.Cartesian3.fromDegrees(30_000 / 111_319.49, 0, 0);
|
||||
const under = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, -0.01), contact);
|
||||
const over = skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, 0.01), contact);
|
||||
assert.ok(Math.abs(under - over) < 5e-3, `seam at the surface: ${under} vs ${over}`);
|
||||
});
|
||||
|
||||
test('a sub-surface camera carries real information, not one frozen answer', () => {
|
||||
// The failure mode this replaces returned a constant. Under the surface the
|
||||
// factor must still track the contact's elevation, so a sweep of DIFFERENT
|
||||
// sub-surface camera heights must produce different answers.
|
||||
const belowOnly = surfaceCrossingSweep(-200, -1, 1);
|
||||
const spread = Math.max(...belowOnly) - Math.min(...belowOnly);
|
||||
assert.ok(spread > 0.05, `a sub-surface camera must still discriminate; spread ${spread}`);
|
||||
assert.ok(
|
||||
belowOnly.every((value) => value > 0 && value < 1),
|
||||
'this sweep is deliberately mid-band the whole way',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The formula this fix replaced, verbatim, kept only as the differential
|
||||
* reference below. The clamp is supposed to be the IDENTITY above the surface —
|
||||
* `1 / cameraMag` is already under 1 there, so `Math.min` cannot round it — and
|
||||
* that claim is worth a permanent pin rather than a one-off measurement, because
|
||||
* a future edit to the shared lines could drift the cruise answer while every
|
||||
* sub-surface test stayed green.
|
||||
*/
|
||||
function legacyAboveSurfaceFactor(cameraPosition, position, featherRad = HORIZON_FEATHER_RAD) {
|
||||
const s = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
const cx = cameraPosition.x * s.x;
|
||||
const cy = cameraPosition.y * s.y;
|
||||
const cz = cameraPosition.z * s.z;
|
||||
const cameraMag = Math.sqrt(cx * cx + cy * cy + cz * cz);
|
||||
if (!(cameraMag > 1)) return 0;
|
||||
let dx = position.x * s.x - cx;
|
||||
let dy = position.y * s.y - cy;
|
||||
let dz = position.z * s.z - cz;
|
||||
const rayMag = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (!(rayMag > 0)) return 0;
|
||||
dx /= rayMag;
|
||||
dy /= rayMag;
|
||||
dz /= rayMag;
|
||||
const cosToCentre = -(cx * dx + cy * dy + cz * dz) / cameraMag;
|
||||
const rayAngle = Math.acos(Math.min(1, Math.max(-1, cosToCentre)));
|
||||
const margin = rayAngle - Math.asin(1 / cameraMag);
|
||||
if (!(featherRad > 0)) return margin > 0 ? 1 : 0;
|
||||
const t = Math.min(1, Math.max(0, margin / (2 * featherRad) + 0.5));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** Deterministic LCG, so a failure is reproducible rather than a flake. */
|
||||
function seededRandom(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test('above the surface the answer is bit-identical to the formula it replaced', () => {
|
||||
// Every camera the product has ever rendered from is above the ellipsoid, so
|
||||
// this fix must be provably invisible there — not "close", identical. Object.is
|
||||
// rather than a tolerance: a tolerance would let a real drift hide under it.
|
||||
const random = seededRandom(0x5eed_1234);
|
||||
let compared = 0;
|
||||
let insideBand = 0;
|
||||
for (let i = 0; i < 4000; i++) {
|
||||
const lon = random() * 360 - 180;
|
||||
const lat = random() * 170 - 85;
|
||||
// 1 m to ~geostationary: the whole range the camera actually occupies.
|
||||
const cameraHeight = 1 + random() ** 4 * 36_000_000;
|
||||
const camera = Cesium.Cartesian3.fromDegrees(lon, lat, cameraHeight);
|
||||
const contact = Cesium.Cartesian3.fromDegrees(
|
||||
lon + (random() * 60 - 30),
|
||||
Math.max(-89, Math.min(89, lat + (random() * 60 - 30))),
|
||||
random() ** 3 * 1_000_000 - 500,
|
||||
);
|
||||
const shipped = skyBackdropFactor(camera, contact);
|
||||
const legacy = legacyAboveSurfaceFactor(camera, contact);
|
||||
assert.ok(
|
||||
Object.is(shipped, legacy),
|
||||
`above-surface drift at sample ${i} (h=${cameraHeight}): ${shipped} !== ${legacy}`,
|
||||
);
|
||||
if (shipped > 0 && shipped < 1) insideBand++;
|
||||
compared++;
|
||||
}
|
||||
assert.equal(compared, 4000, 'the sweep must actually run');
|
||||
// Saturated ends agree trivially; only the smoothstep interior can show a
|
||||
// float-level drift. If a future edit to the sampling pushed every sample to
|
||||
// 0 or 1 this pin would still be green while proving nothing, so guard it.
|
||||
assert.ok(
|
||||
insideBand >= 100,
|
||||
`the sweep must exercise the blend band, not just its saturated ends (${insideBand} inside)`,
|
||||
);
|
||||
});
|
||||
|
||||
test('below the surface the backdrop test agrees with the occluder beside it', () => {
|
||||
// These two are halves of one geometry: the occluder asks what is in FRONT of
|
||||
// a point, this asks what is BEHIND it, and the docstring's invariant is that
|
||||
// for any point the occluder KEEPS, a ray that hits the ellipsoid hits it
|
||||
// beyond the point. Cesium resolves a sub-ellipsoid camera by culling
|
||||
// everything under the local horizontal plane — eye level, the same limit
|
||||
// adopted here — so the invariant survives under the surface only if both
|
||||
// halves use that plane. The old fail-closed broke exactly this: the occluder
|
||||
// kept a contact against sky and the backdrop test called it ground.
|
||||
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const occluder = horizonOccluder({ positionWC: camera });
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
|
||||
const offsetBy = (up) => Cesium.Cartesian3.add(
|
||||
camera,
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
new Cesium.Cartesian3(0, 20_000, up),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
// 20 km out and 600 m clear of eye level is well outside the 1.09° band.
|
||||
const aboveEye = offsetBy(600);
|
||||
const belowEye = offsetBy(-600);
|
||||
assert.equal(occluder.isPointVisible(aboveEye), true, 'the occluder keeps what is above eye level');
|
||||
assert.equal(skyBackdropFactor(camera, aboveEye), 1, 'and the backdrop test must call it sky');
|
||||
assert.equal(occluder.isPointVisible(belowEye), false, 'the occluder culls what is below it');
|
||||
assert.equal(skyBackdropFactor(camera, belowEye), 0, 'and the backdrop test must call it ground');
|
||||
});
|
||||
|
||||
test('a degenerate camera or contact reports ground rather than throwing', () => {
|
||||
// A plate that stays is a cosmetic miss; a plate that vanishes on a frame
|
||||
// where the inputs go bad is a flicker. Fail toward the plate — but only for
|
||||
// inputs that are genuinely unanswerable, never for an ordinary low camera.
|
||||
assert.equal(skyBackdropFactor(null, cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, null), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, cruiseCamera), 0);
|
||||
// The planet's centre has no local vertical to measure against.
|
||||
assert.equal(skyBackdropFactor(Cesium.Cartesian3.ZERO, cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(NaN, NaN, NaN), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(NaN, NaN, NaN)), 0);
|
||||
// An infinite coordinate normalizes to NaN a few lines later, and a NaN plate
|
||||
// alpha reaches the canvas as an invisible label rather than a caught error.
|
||||
assert.equal(skyBackdropFactor(new Cesium.Cartesian3(Infinity, 0, 0), cruiseCamera), 0);
|
||||
assert.equal(skyBackdropFactor(cruiseCamera, new Cesium.Cartesian3(Infinity, 0, 0)), 0);
|
||||
});
|
||||
|
||||
test('the feather band is a named constant in a sane screen-space range', () => {
|
||||
// ~1° per side: at the product's default frustum that is tens of pixels of
|
||||
// crossfade. A band of degrees would smear plates across half the sky; a
|
||||
// band of arc-seconds would pop.
|
||||
assert.ok(HORIZON_FEATHER_RAD > 0.002 && HORIZON_FEATHER_RAD < 0.09);
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// Mapped-installation proxy persistence (field test 2026-08-18: "search
|
||||
// nearby sites" was slow because every look around paid a live Overpass round
|
||||
// trip, and the 5-minute memory tier died with the dev server).
|
||||
//
|
||||
// The bbox/TTL cases below are pure; the cache-tier cases drive the REAL
|
||||
// read/write code against a REAL temp directory — miss, write, restart, disk
|
||||
// hit, expiry, serve-stale, and a torn write — so the persistence claims are
|
||||
// exercised rather than asserted.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
militaryInstallationCacheKey,
|
||||
militaryInstallationDiskFresh,
|
||||
militaryInstallationDiskPath,
|
||||
migrateMilitaryInstallationEntry,
|
||||
quantizeMilitaryInstallationBox,
|
||||
readMilitaryInstallationDisk,
|
||||
resolveMilitaryInstallationTier,
|
||||
validMilitaryInstallationBox,
|
||||
writeMilitaryInstallationDisk,
|
||||
} from '../../vite.config.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const TTL_MS = 30 * DAY_MS;
|
||||
|
||||
/** An entry shaped exactly like what the proxy writes. */
|
||||
function entry(cachedAt, elements = [{ type: 'node', id: 1 }]) {
|
||||
return {
|
||||
payload: { elements, saturated: false, retrievedAt: new Date(cachedAt).toISOString(), status: 'ready' },
|
||||
cachedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function tempCacheDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gev-installations-'));
|
||||
}
|
||||
|
||||
test('the cache bbox snaps OUTWARD so a cached answer always covers the request', () => {
|
||||
const requested = { south: 30.2612, west: -97.7431, north: 30.2884, east: -97.7099 };
|
||||
const snapped = quantizeMilitaryInstallationBox(requested);
|
||||
assert.deepEqual(snapped, { south: 30.25, west: -97.75, north: 30.3, east: -97.7 });
|
||||
assert.ok(snapped.south <= requested.south, 'south only ever grows outward');
|
||||
assert.ok(snapped.west <= requested.west, 'west only ever grows outward');
|
||||
assert.ok(snapped.north >= requested.north, 'north only ever grows outward');
|
||||
assert.ok(snapped.east >= requested.east, 'east only ever grows outward');
|
||||
});
|
||||
|
||||
test('a bbox already on the grid is left alone rather than pushed a whole cell', () => {
|
||||
const onGrid = { south: 30.25, west: -97.75, north: 30.3, east: -97.7 };
|
||||
assert.deepEqual(quantizeMilitaryInstallationBox(onGrid), onGrid);
|
||||
// 29.9999/0.05 lands a hair under an exact grid line in binary floating point.
|
||||
assert.deepEqual(
|
||||
quantizeMilitaryInstallationBox({ south: 29.9999, west: 0, north: 30.05, east: 0.1 }),
|
||||
{ south: 29.95, west: 0, north: 30.05, east: 0.1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('nearby viewports share one cache key while a distant one does not', () => {
|
||||
const key = (box) => militaryInstallationCacheKey(quantizeMilitaryInstallationBox(box));
|
||||
const base = key({ south: 30.2612, west: -97.7431, north: 30.2884, east: -97.7099 });
|
||||
const nudged = key({ south: 30.2634, west: -97.7402, north: 30.2871, east: -97.7104 });
|
||||
const panned = key({ south: 31.2612, west: -96.7431, north: 31.2884, east: -96.7099 });
|
||||
assert.equal(base, nudged, 'a sub-cell pan reuses the persisted answer');
|
||||
assert.notEqual(base, panned, 'a real move still fetches its own area');
|
||||
assert.equal(base, '30.250,-97.750,30.300,-97.700');
|
||||
});
|
||||
|
||||
test('snapping never pushes a bbox off the globe', () => {
|
||||
const polar = quantizeMilitaryInstallationBox({ south: -89.99, west: -179.99, north: 89.99, east: 179.99 });
|
||||
assert.ok(polar.south >= -90 && polar.north <= 90);
|
||||
assert.ok(polar.west >= -180 && polar.east <= 180);
|
||||
});
|
||||
|
||||
test('the snapped bbox stays acceptable to the request validator', () => {
|
||||
const params = new URLSearchParams({ south: '30.26120', west: '-97.74310', north: '30.28840', east: '-97.70990' });
|
||||
const requested = validMilitaryInstallationBox(params);
|
||||
const snapped = quantizeMilitaryInstallationBox(requested);
|
||||
assert.ok(snapped.south < snapped.north && snapped.west < snapped.east);
|
||||
});
|
||||
|
||||
test('exact-viewport answers are keyed apart from snapped ones', () => {
|
||||
const box = { south: 30.26, west: -97.74, north: 30.28, east: -97.71 };
|
||||
const snapped = militaryInstallationCacheKey(quantizeMilitaryInstallationBox(box));
|
||||
const exact = `exact:${militaryInstallationCacheKey(box, 5)}`;
|
||||
assert.notEqual(snapped, exact, 'a saturated retry must never overwrite the shared tile');
|
||||
assert.notEqual(militaryInstallationDiskPath(snapped), militaryInstallationDiskPath(exact));
|
||||
});
|
||||
|
||||
test('exact-viewport keys carry the same precision as the query bounds', () => {
|
||||
// The exact path queries the RAW viewport at 5 decimals. Keying at 3 would
|
||||
// collide two different queries, and the second view would be served an
|
||||
// answer missing the edge strip it just exposed.
|
||||
const a = { south: 30.26120, west: -97.74310, north: 30.28840, east: -97.70990 };
|
||||
const b = { south: 30.26140, west: -97.74290, north: 30.28860, east: -97.70970 };
|
||||
assert.notEqual(
|
||||
militaryInstallationCacheKey(a, 5),
|
||||
militaryInstallationCacheKey(b, 5),
|
||||
'two nearby exact viewports must not share an entry',
|
||||
);
|
||||
assert.equal(militaryInstallationCacheKey(a, 5), '30.26120,-97.74310,30.28840,-97.70990');
|
||||
// The snapped path lives on a 0.05 deg grid, where 3 decimals is exact.
|
||||
assert.equal(
|
||||
militaryInstallationCacheKey(quantizeMilitaryInstallationBox(a)),
|
||||
militaryInstallationCacheKey(quantizeMilitaryInstallationBox(b)),
|
||||
'the shared snapped tile still collapses neighbours on purpose',
|
||||
);
|
||||
});
|
||||
|
||||
test('a pre-fix cache entry derives its missing saturation flag instead of lying', async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const atCap = { payload: { elements: new Array(700).fill({ type: 'node' }), retrievedAt: '', status: 'ready' }, cachedAt: now };
|
||||
const underCap = { payload: { elements: new Array(699).fill({ type: 'node' }), retrievedAt: '', status: 'ready' }, cachedAt: now };
|
||||
assert.equal(migrateMilitaryInstallationEntry(atCap).payload.saturated, true);
|
||||
assert.equal(migrateMilitaryInstallationEntry(underCap).payload.saturated, false);
|
||||
// An entry that already states it is authoritative.
|
||||
const explicit = { payload: { elements: new Array(700).fill({}), saturated: false }, cachedAt: now };
|
||||
assert.equal(migrateMilitaryInstallationEntry(explicit).payload.saturated, false);
|
||||
assert.equal(migrateMilitaryInstallationEntry(null), null);
|
||||
|
||||
// End to end through a real file written in the OLD shape: the 30-day TTL
|
||||
// means these entries would otherwise skip the exact retry for a month.
|
||||
const dir = tempCacheDir();
|
||||
try {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
// Written recently, in the OLD shape — the case the 30-day TTL keeps alive.
|
||||
const legacyOnDisk = { ...atCap, cachedAt: Date.now() };
|
||||
await fsp.writeFile(militaryInstallationDiskPath('legacy', dir), JSON.stringify(legacyOnDisk));
|
||||
const read = await readMilitaryInstallationDisk('legacy', TTL_MS, dir);
|
||||
assert.equal(read.payload.saturated, true, 'the client must be told to re-ask for its exact viewport');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('persisted installations stay fresh for 30 days and no longer', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(militaryInstallationDiskFresh(entry(now - DAY_MS), undefined, now), true);
|
||||
assert.equal(militaryInstallationDiskFresh(entry(now - 29 * DAY_MS), undefined, now), true);
|
||||
assert.equal(militaryInstallationDiskFresh(entry(now - 31 * DAY_MS), undefined, now), false);
|
||||
// The serve-stale path accepts any age when Overpass is down.
|
||||
assert.equal(militaryInstallationDiskFresh(entry(now - 400 * DAY_MS), Infinity, now), true);
|
||||
});
|
||||
|
||||
test('a malformed or truncated disk entry is never served', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
assert.equal(militaryInstallationDiskFresh(null, undefined, now), false);
|
||||
assert.equal(militaryInstallationDiskFresh({ payload: { elements: [] } }, undefined, now), false);
|
||||
assert.equal(militaryInstallationDiskFresh({ cachedAt: now }, undefined, now), false);
|
||||
assert.equal(militaryInstallationDiskFresh({ payload: {}, cachedAt: now }, undefined, now), false);
|
||||
assert.equal(militaryInstallationDiskFresh({ payload: { elements: 'nope' }, cachedAt: now }, undefined, now), false);
|
||||
});
|
||||
|
||||
test('a real miss, write, and restart round trip serves from disk', async () => {
|
||||
const dir = tempCacheDir();
|
||||
const key = '30.250,-97.750,30.300,-97.700';
|
||||
const inFlight = new Map();
|
||||
let memory = new Map();
|
||||
const readDisk = (maxAgeMs = TTL_MS) => readMilitaryInstallationDisk(key, maxAgeMs, dir);
|
||||
|
||||
try {
|
||||
// Cold everything: nothing on disk, nothing in memory.
|
||||
const miss = await resolveMilitaryInstallationTier({ cacheKey: key, memoryCache: memory, inFlight, readDisk });
|
||||
assert.equal(miss.source, 'UPSTREAM');
|
||||
|
||||
// The proxy answers and persists.
|
||||
const fresh = entry(Date.now());
|
||||
memory.set(key, fresh);
|
||||
assert.equal(await writeMilitaryInstallationDisk(key, fresh, dir), true);
|
||||
assert.equal(fs.existsSync(militaryInstallationDiskPath(key, dir)), true);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(dir).filter((name) => name.endsWith('.tmp')),
|
||||
[],
|
||||
'no temp file is left behind',
|
||||
);
|
||||
|
||||
const hit = await resolveMilitaryInstallationTier({ cacheKey: key, memoryCache: memory, inFlight, readDisk });
|
||||
assert.equal(hit.source, 'HIT');
|
||||
|
||||
// Dev-server restart: memory is gone, disk is not.
|
||||
memory = new Map();
|
||||
const afterRestart = await resolveMilitaryInstallationTier({ cacheKey: key, memoryCache: memory, inFlight, readDisk });
|
||||
assert.equal(afterRestart.source, 'DISK');
|
||||
assert.deepEqual(afterRestart.entry.payload.elements, fresh.payload.elements);
|
||||
|
||||
// A memory entry past the 5-minute window falls through to disk too.
|
||||
memory.set(key, entry(Date.now() - 6 * 60_000));
|
||||
const staleMemory = await resolveMilitaryInstallationTier({ cacheKey: key, memoryCache: memory, inFlight, readDisk });
|
||||
assert.equal(staleMemory.source, 'DISK');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an in-flight request is joined instead of paying for a disk read', async () => {
|
||||
const dir = tempCacheDir();
|
||||
const key = 'inflight-key';
|
||||
let diskReads = 0;
|
||||
try {
|
||||
await writeMilitaryInstallationDisk(key, entry(Date.now()), dir);
|
||||
const result = await resolveMilitaryInstallationTier({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
inFlight: new Map([[key, Promise.resolve({})]]),
|
||||
readDisk: () => { diskReads += 1; return readMilitaryInstallationDisk(key, TTL_MS, dir); },
|
||||
});
|
||||
assert.equal(result.source, 'UPSTREAM', 'the caller coalesces onto the live request');
|
||||
assert.equal(diskReads, 0);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an expired disk entry stops answering, but still serves stale when upstream is down', async () => {
|
||||
const dir = tempCacheDir();
|
||||
const key = 'expiry-key';
|
||||
try {
|
||||
await writeMilitaryInstallationDisk(key, entry(Date.now() - 31 * DAY_MS), dir);
|
||||
assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null, 'past its TTL for a normal read');
|
||||
const stale = await readMilitaryInstallationDisk(key, Infinity, dir);
|
||||
assert.ok(stale, 'last-good context at ANY age beats an empty layer');
|
||||
assert.equal(stale.payload.status, 'ready');
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a torn write leaves the previous entry readable', async () => {
|
||||
const dir = tempCacheDir();
|
||||
const key = 'atomic-key';
|
||||
const target = militaryInstallationDiskPath(key, dir);
|
||||
try {
|
||||
const good = entry(Date.now(), [{ type: 'node', id: 'original' }]);
|
||||
await writeMilitaryInstallationDisk(key, good, dir);
|
||||
|
||||
// Simulate a crash partway through a REPLACEMENT write: the temp sibling is
|
||||
// half-written and never renamed. An in-place overwrite would have shredded
|
||||
// the target here.
|
||||
await fsp.writeFile(`${target}.crashed.tmp`, '{"payload":{"elements":[{"type":"nod');
|
||||
|
||||
const survivor = await readMilitaryInstallationDisk(key, TTL_MS, dir);
|
||||
assert.ok(survivor, 'the previous entry is still readable after a torn write');
|
||||
assert.deepEqual(survivor.payload.elements, [{ type: 'node', id: 'original' }]);
|
||||
|
||||
// And a genuinely corrupt TARGET is rejected rather than served.
|
||||
await fsp.writeFile(target, '{"payload":{"elements":[');
|
||||
assert.equal(await readMilitaryInstallationDisk(key, TTL_MS, dir), null);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwritable cache directory degrades to a miss instead of throwing', async () => {
|
||||
const dir = tempCacheDir();
|
||||
const blocked = path.join(dir, 'blocked');
|
||||
try {
|
||||
// A FILE where the cache directory should be: mkdir must fail.
|
||||
await fsp.writeFile(blocked, 'not a directory');
|
||||
assert.equal(await writeMilitaryInstallationDisk('key', entry(Date.now()), blocked), false);
|
||||
assert.equal(await readMilitaryInstallationDisk('key', TTL_MS, blocked), null);
|
||||
} finally {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// src/data/issPass.js
|
||||
/**
|
||||
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
|
||||
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
|
||||
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
|
||||
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
|
||||
* nextISSPass, extended with set-time + peak tracking.
|
||||
*/
|
||||
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
|
||||
|
||||
const R2D = 180 / Math.PI;
|
||||
const D2R = Math.PI / 180;
|
||||
|
||||
/** Observer look angles at an instant, or null when propagation fails. */
|
||||
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
|
||||
const date = new Date(dateMs);
|
||||
const pv = propagate(satrec, date);
|
||||
const pos = pv && pv.position;
|
||||
if (!pos || typeof pos === 'boolean') return null;
|
||||
const ecf = eciToEcf(pos, gstime(date));
|
||||
const look = ecfToLookAngles(
|
||||
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
|
||||
ecf,
|
||||
);
|
||||
return {
|
||||
elevDeg: look.elevation * R2D,
|
||||
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
export function findNextIssPass({
|
||||
satrec, latDeg, lonDeg, fromMs,
|
||||
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
|
||||
}) {
|
||||
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
|
||||
const horizonMs = fromMs + horizonHours * 3600_000;
|
||||
const coarse = coarseStepSec * 1000;
|
||||
const fine = fineStepSec * 1000;
|
||||
|
||||
// Coarse scan for the first sample above threshold. If we START inside a
|
||||
// pass, that's still "the next pass" for a voice answer — accept it.
|
||||
let hit = null;
|
||||
for (let t = fromMs; t <= horizonMs; t += coarse) {
|
||||
if (elev(t) >= minElevDeg) { hit = t; break; }
|
||||
}
|
||||
if (hit == null) return null;
|
||||
|
||||
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
|
||||
let riseMs = hit;
|
||||
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
|
||||
|
||||
// Walk forward through the pass tracking the peak until we drop below.
|
||||
let maxElevDeg = -90;
|
||||
let maxElevMs = riseMs;
|
||||
let t = riseMs;
|
||||
while (t <= horizonMs) {
|
||||
const e = elev(t);
|
||||
if (e < minElevDeg && t > riseMs) break;
|
||||
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
|
||||
t += fine;
|
||||
}
|
||||
const setMs = t;
|
||||
|
||||
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
|
||||
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// src/data/issPass.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { findNextIssPass, lookAnglesAt } from './issPass.js';
|
||||
|
||||
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
|
||||
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
|
||||
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch
|
||||
|
||||
test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 });
|
||||
assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit');
|
||||
assert.ok(pass.riseMs > FROM_MS);
|
||||
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
|
||||
assert.ok(pass.maxElevDeg >= 10);
|
||||
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
|
||||
|
||||
// Independent re-check: elevation at the reported peak really is the max-ish
|
||||
// and ≥ threshold; one minute before rise it is below threshold.
|
||||
const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5);
|
||||
const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg);
|
||||
assert.ok(before && before.elevDeg < 10);
|
||||
});
|
||||
|
||||
test('returns null when no pass clears an absurd threshold', () => {
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
|
||||
assert.equal(pass, null);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,506 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ALLOCATION_ELASTIC,
|
||||
ALLOCATION_WEIGHTED,
|
||||
LabelArbiter,
|
||||
allocateLayerQuotas,
|
||||
} from './labelArbiter.js';
|
||||
|
||||
function objectQuotas(map) {
|
||||
return Object.fromEntries(Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function candidate(layerId, index, x = index * 30) {
|
||||
return {
|
||||
key: `${layerId}:${index}`,
|
||||
layerId,
|
||||
sourceId: index,
|
||||
priority: 0,
|
||||
centerDistance: Math.abs(x),
|
||||
keyholeAlpha: 1,
|
||||
placements: [{ corner: 'NE', rect: { x, y: layerId === 'flights' ? 0 : 80, w: 20, h: 12 } }],
|
||||
};
|
||||
}
|
||||
|
||||
function spatialCandidate(index, x, y) {
|
||||
return {
|
||||
key: `satellites:${index}`,
|
||||
layerId: 'satellites',
|
||||
priority: 25,
|
||||
centerDistance: Math.hypot(x, y),
|
||||
keyholeAlpha: 1,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
placements: [{ corner: 'NE', rect: { x: x * 2, y: y * 2, w: 10, h: 8 } }],
|
||||
};
|
||||
}
|
||||
|
||||
test('Elastic gives one layer full capacity and two layers an equal entitlement', () => {
|
||||
assert.deepEqual(objectQuotas(allocateLayerQuotas({ cctv: 200 }, 90, ALLOCATION_ELASTIC)), { cctv: 90 });
|
||||
assert.deepEqual(
|
||||
objectQuotas(allocateLayerQuotas({ cctv: 200, traffic: 200 }, 90, ALLOCATION_ELASTIC)),
|
||||
{ cctv: 45, traffic: 45 },
|
||||
);
|
||||
});
|
||||
|
||||
test('Elastic redistributes unused capacity', () => {
|
||||
assert.deepEqual(
|
||||
objectQuotas(allocateLayerQuotas({ cctv: 200, traffic: 20 }, 90, ALLOCATION_ELASTIC)),
|
||||
{ cctv: 70, traffic: 20 },
|
||||
);
|
||||
});
|
||||
|
||||
test('Weighted is deterministic, representative, and work-conserving', () => {
|
||||
const first = allocateLayerQuotas({ flights: 1000, satellites: 100 }, 28, ALLOCATION_WEIGHTED);
|
||||
const reverse = allocateLayerQuotas(new Map([['satellites', 100], ['flights', 1000]]), 28, ALLOCATION_WEIGHTED);
|
||||
assert.deepEqual(objectQuotas(first), objectQuotas(reverse));
|
||||
assert.equal(Array.from(first.values()).reduce((sum, value) => sum + value, 0), 28);
|
||||
assert.ok(first.get('flights') > 0);
|
||||
assert.ok(first.get('satellites') > 0);
|
||||
assert.ok(first.get('flights') > first.get('satellites'));
|
||||
});
|
||||
|
||||
test('arbiter is registration-order independent and represents both layers', () => {
|
||||
const objects = [
|
||||
...Array.from({ length: 20 }, (_, index) => candidate('flights', index)),
|
||||
...Array.from({ length: 20 }, (_, index) => candidate('satellites', index)),
|
||||
];
|
||||
const a = new LabelArbiter();
|
||||
const b = new LabelArbiter();
|
||||
a.solve(objects, { capacity: 10, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
b.solve(objects.slice().reverse(), { capacity: 10, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
assert.deepEqual(Array.from(a.selectedKeys).sort(), Array.from(b.selectedKeys).sort());
|
||||
assert.equal(a.diagnostics().labelsByLayer.flights, 5);
|
||||
assert.equal(a.diagnostics().labelsByLayer.satellites, 5);
|
||||
});
|
||||
|
||||
test('unchanged solves and policy toggles preserve winning identities', () => {
|
||||
const objects = [
|
||||
...Array.from({ length: 12 }, (_, index) => candidate('flights', index)),
|
||||
...Array.from({ length: 6 }, (_, index) => candidate('satellites', index)),
|
||||
];
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
const first = new Set(arbiter.selectedKeys);
|
||||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_ELASTIC, now: 1125 });
|
||||
assert.deepEqual(arbiter.selectedKeys, first);
|
||||
arbiter.solve(objects, { capacity: 8, strategy: ALLOCATION_WEIGHTED, now: 1250 });
|
||||
const retained = Array.from(first).filter((key) => arbiter.selectedKeys.has(key));
|
||||
assert.ok(retained.length >= 6);
|
||||
});
|
||||
|
||||
test('incumbents satisfying the layer target skip spatial queue construction', () => {
|
||||
const objects = Array.from({ length: 12 }, (_, index) => candidate('flights', index));
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve(objects, { capacity: 6, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
assert.ok(arbiter.diagnostics().spatialQueueBuildCount > 0);
|
||||
|
||||
arbiter.solve(objects, {
|
||||
capacity: 6,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now: 1125,
|
||||
preserveIncumbents: true,
|
||||
});
|
||||
assert.equal(arbiter.diagnostics().spatialQueueBuildCount, 0);
|
||||
});
|
||||
|
||||
test('candidate anchors are cached once as scalar coordinates for queue scans', () => {
|
||||
const objects = [candidate('flights', 1, 40), candidate('flights', 2, 100)];
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve(objects, { capacity: 1, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
assert.deepEqual(
|
||||
objects.map((item) => [item._anchorX, item._anchorY]),
|
||||
[[50, 6], [110, 6]],
|
||||
);
|
||||
});
|
||||
|
||||
test('increasing capacity retains the entire eligible cohort', () => {
|
||||
const objects = [
|
||||
...Array.from({ length: 30 }, (_, index) => candidate('flights', index)),
|
||||
...Array.from({ length: 30 }, (_, index) => candidate('satellites', index)),
|
||||
];
|
||||
const arbiter = new LabelArbiter();
|
||||
let previous = new Set();
|
||||
for (const [capacity, now] of [[14, 1000], [21, 1125], [28, 1250]]) {
|
||||
arbiter.solve(objects, {
|
||||
capacity,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now,
|
||||
preserveIncumbents: true,
|
||||
});
|
||||
for (const key of previous) assert.ok(arbiter.selectedKeys.has(key));
|
||||
previous = new Set(arbiter.selectedKeys);
|
||||
}
|
||||
});
|
||||
|
||||
test('collision placement never accepts overlapping cards', () => {
|
||||
const objects = Array.from({ length: 5 }, (_, index) => ({
|
||||
...candidate('cctv', index, 0),
|
||||
placements: [{ corner: 'NE', rect: { x: 0, y: 0, w: 50, h: 20 } }],
|
||||
}));
|
||||
const arbiter = new LabelArbiter();
|
||||
const result = arbiter.solve(objects, { capacity: 5, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
assert.equal(result.selectedCount, 1);
|
||||
});
|
||||
|
||||
test('saturated vertical-only candidates are dismissed without repeated spread-queue revisits', () => {
|
||||
const objects = Array.from({ length: 18 }, (_, index) => ({
|
||||
key: `firms:${index}`,
|
||||
layerId: 'firms',
|
||||
sourceId: index,
|
||||
priority: 18 - index,
|
||||
centerDistance: index,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 100,
|
||||
screenY: 100,
|
||||
placements: [
|
||||
{ corner: 'above', rect: { x: 60, y: 50, w: 80, h: 30 } },
|
||||
{ corner: 'below', rect: { x: 60, y: 120, w: 80, h: 30 } },
|
||||
],
|
||||
}));
|
||||
const arbiter = new LabelArbiter();
|
||||
const result = arbiter.solve(objects, {
|
||||
capacity: objects.length,
|
||||
demandByLayer: { firms: objects.length },
|
||||
now: 1000,
|
||||
preserveIncumbents: false,
|
||||
});
|
||||
assert.equal(result.selectedCount, 2);
|
||||
assert.ok(result.spatialQueueNextCount <= 4, `next() calls: ${result.spatialQueueNextCount}`);
|
||||
});
|
||||
|
||||
test('first equal-priority solve distributes labels across the candidate field', () => {
|
||||
const objects = [
|
||||
spatialCandidate('center', 0, 0),
|
||||
spatialCandidate('nw', -100, -100),
|
||||
spatialCandidate('ne', 100, -100),
|
||||
spatialCandidate('sw', -100, 100),
|
||||
spatialCandidate('se', 100, 100),
|
||||
spatialCandidate('near-1', -10, -10),
|
||||
spatialCandidate('near-2', 10, -10),
|
||||
spatialCandidate('near-3', -10, 10),
|
||||
spatialCandidate('near-4', 10, 10),
|
||||
];
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve(objects, { capacity: 4, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
const selected = objects.filter((item) => arbiter.selectedKeys.has(item.key));
|
||||
const outerCount = selected.filter((item) => item.centerDistance > 100).length;
|
||||
const xValues = selected.map((item) => item.screenX);
|
||||
const yValues = selected.map((item) => item.screenY);
|
||||
assert.ok(outerCount >= 3);
|
||||
assert.ok(Math.max(...xValues) - Math.min(...xValues) >= 200);
|
||||
assert.ok(Math.max(...yValues) - Math.min(...yValues) >= 200);
|
||||
});
|
||||
|
||||
test('semantic priority outranks spatial separation for new labels', () => {
|
||||
const highPriority = { ...spatialCandidate('priority', 0, 0), priority: 120 };
|
||||
const farCandidate = spatialCandidate('far', 500, 500);
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([farCandidate, highPriority], { capacity: 1, strategy: ALLOCATION_ELASTIC, now: 1000 });
|
||||
assert.deepEqual(arbiter.selectedKeys, new Set([highPriority.key]));
|
||||
});
|
||||
|
||||
test('fading entries remain renderable for the transition window', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const one = [candidate('flights', 1)];
|
||||
arbiter.solve(one, { capacity: 1, now: 1000 });
|
||||
const pooled = [{}];
|
||||
const rendered = arbiter.renderEntries(one, 1150, pooled);
|
||||
assert.equal(rendered, pooled);
|
||||
assert.equal(rendered[0], pooled[0]);
|
||||
assert.equal(rendered[0].temporalAlpha, 1);
|
||||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||||
assert.ok(arbiter.renderEntries([], 1300)[0].temporalAlpha > 0);
|
||||
assert.equal(arbiter.renderEntries([], 1501).length, 0);
|
||||
});
|
||||
|
||||
test('renderEntries reuses output without filter, slice, or sort allocation helpers', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const objects = [candidate('flights', 1), candidate('flights', 2, 80)];
|
||||
arbiter.solve(objects, { capacity: 2, now: 1000 });
|
||||
const current = new Map(objects.map((item) => [item.key, item]));
|
||||
const pooled = [{}, {}];
|
||||
const originals = {
|
||||
filter: Array.prototype.filter,
|
||||
slice: Array.prototype.slice,
|
||||
sort: Array.prototype.sort,
|
||||
};
|
||||
const calls = { filter: 0, slice: 0, sort: 0 };
|
||||
for (const name of Object.keys(originals)) {
|
||||
Array.prototype[name] = function countRenderAllocationHelper(...args) {
|
||||
calls[name]++;
|
||||
return originals[name].apply(this, args);
|
||||
};
|
||||
}
|
||||
let rendered;
|
||||
try {
|
||||
rendered = arbiter.renderEntries(current, 1200, pooled);
|
||||
} finally {
|
||||
Object.assign(Array.prototype, originals);
|
||||
}
|
||||
assert.equal(rendered, pooled);
|
||||
assert.equal(rendered[0], pooled[0]);
|
||||
assert.equal(rendered[1], pooled[1]);
|
||||
assert.deepEqual(calls, { filter: 0, slice: 0, sort: 0 });
|
||||
});
|
||||
|
||||
test('moving cohorts re-solve with cohort-independent allocation helpers', () => {
|
||||
const movingCohort = (size, frame) => Array.from({ length: size }, (_, index) => {
|
||||
const x = ((index * 37 + frame * 3) % 700) + 10;
|
||||
const y = ((index * 53 + frame * 5) % 500) + 10;
|
||||
return {
|
||||
key: `flights:${index}`,
|
||||
layerId: 'flights',
|
||||
sourceId: index,
|
||||
priority: index % 4,
|
||||
centerDistance: Math.hypot(x - 350, y - 250),
|
||||
keyholeAlpha: 1,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
placements: [
|
||||
{ corner: 'above', rect: { x, y, w: 48, h: 16 } },
|
||||
{ corner: 'below', rect: { x, y: y + 24, w: 48, h: 16 } },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const originals = {
|
||||
filter: Array.prototype.filter,
|
||||
slice: Array.prototype.slice,
|
||||
sort: Array.prototype.sort,
|
||||
push: Array.prototype.push,
|
||||
};
|
||||
const measure = (size) => {
|
||||
const arbiter = new LabelArbiter();
|
||||
for (let frame = 0; frame < 6; frame++) {
|
||||
arbiter.solve(movingCohort(size, frame), { capacity: size, now: 1000 + frame * 130 });
|
||||
}
|
||||
const calls = { filter: 0, slice: 0, sort: 0, push: 0 };
|
||||
for (const name of Object.keys(originals)) {
|
||||
Array.prototype[name] = function countSolveAllocationHelper(...args) {
|
||||
calls[name]++;
|
||||
return originals[name].apply(this, args);
|
||||
};
|
||||
}
|
||||
try {
|
||||
arbiter.solve(movingCohort(size, 6), { capacity: size, now: 1780 });
|
||||
} finally {
|
||||
Object.assign(Array.prototype, originals);
|
||||
}
|
||||
return calls;
|
||||
};
|
||||
|
||||
const small = measure(12);
|
||||
const large = measure(60);
|
||||
// The remaining helpers are layer-scale (quota allocation), so a five-fold
|
||||
// larger cohort must not add a single per-candidate array operation.
|
||||
assert.deepEqual(large, small);
|
||||
assert.ok(small.slice + small.push === 0);
|
||||
});
|
||||
|
||||
test('selected-key membership is republished in place across solves', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const objects = Array.from({ length: 6 }, (_, index) => candidate('flights', index));
|
||||
arbiter.solve(objects, { capacity: 4, now: 1000 });
|
||||
const keys = arbiter.selectedKeys;
|
||||
const first = new Set(keys);
|
||||
arbiter.solve(objects.slice(0, 3), { capacity: 2, now: 1400 });
|
||||
assert.equal(arbiter.selectedKeys, keys);
|
||||
assert.equal(keys.size, 2);
|
||||
for (const key of keys) assert.ok(objects.slice(0, 3).some((item) => item.key === key));
|
||||
assert.ok(first.size > keys.size);
|
||||
});
|
||||
|
||||
test('a stateless candidate keeps no corner, no cooldown, and no fades', () => {
|
||||
// Sources that shipped as a per-frame rebuild (CCTV thumbnails) opt out of the
|
||||
// arbiter's statefulness. Everything the default path remembers, this path must
|
||||
// forget: the sticky corner that pinned cards below their icon mid-screen, the
|
||||
// 1.5 s re-entry cooldown that delayed their return after a sweep, and the
|
||||
// enter/exit ramps that a hard-cliff source never had.
|
||||
const make = (key, overrides = {}) => ({
|
||||
key,
|
||||
layerId: 'cctv',
|
||||
sourceId: key,
|
||||
priority: 1,
|
||||
centerDistance: 0,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 130,
|
||||
screenY: 110,
|
||||
stateless: true,
|
||||
placements: [
|
||||
{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } },
|
||||
{ corner: 'below', rect: { x: 100, y: 400, w: 60, h: 20 } },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
const blocker = make('cctv:blocker', {
|
||||
priority: 10,
|
||||
stateless: false,
|
||||
placements: [{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } }],
|
||||
});
|
||||
const mover = make('cctv:mover');
|
||||
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'below',
|
||||
'it took the free corner while the preferred one was blocked');
|
||||
|
||||
// Corner is re-decided from geometry, NOT remembered: with the blocker gone the
|
||||
// card returns to its preferred placement immediately.
|
||||
arbiter.solve([mover], { capacity: 1, now: 1100 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').corner, 'above',
|
||||
'a stateless candidate re-decides its corner instead of sticking');
|
||||
|
||||
// Evicted, then re-offered on the very next solve: no cooldown may block it.
|
||||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||||
assert.equal(arbiter.states.get('cctv:mover').cooldownUntil, 0,
|
||||
'eviction stamps no re-entry cooldown on a stateless candidate');
|
||||
arbiter.solve([mover], { capacity: 1, now: 1220 });
|
||||
const rendered = arbiter.renderEntries(new Map([[mover.key, mover]]), 1220, []);
|
||||
const entry = rendered.find((item) => item.candidate.key === 'cctv:mover');
|
||||
assert.ok(entry, 'the card returns on the next solve rather than waiting out a cooldown');
|
||||
assert.equal(entry.temporalAlpha, 1,
|
||||
'it paints at full alpha on arrival — a hard cliff, not a 150 ms ramp');
|
||||
|
||||
// And it leaves on a cliff too, with no fade-out tail.
|
||||
arbiter.solve([], { capacity: 0, now: 1300 });
|
||||
const afterExit = arbiter.renderEntries(new Map(), 1310, []);
|
||||
assert.equal(afterExit.find((item) => item.candidate.key === 'cctv:mover'), undefined,
|
||||
'a stateless card disappears on eviction instead of fading out');
|
||||
});
|
||||
|
||||
test('a re-solve retries the remembered corner before the first listed placement', () => {
|
||||
const blocker = {
|
||||
key: 'flights:blocker',
|
||||
layerId: 'flights',
|
||||
sourceId: 'blocker',
|
||||
priority: 10,
|
||||
centerDistance: 0,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 130,
|
||||
screenY: 110,
|
||||
placements: [{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } }],
|
||||
};
|
||||
const mover = {
|
||||
key: 'flights:mover',
|
||||
layerId: 'flights',
|
||||
sourceId: 'mover',
|
||||
priority: 1,
|
||||
centerDistance: 10,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 130,
|
||||
screenY: 110,
|
||||
placements: [
|
||||
{ corner: 'above', rect: { x: 100, y: 100, w: 60, h: 20 } },
|
||||
{ corner: 'below', rect: { x: 100, y: 400, w: 60, h: 20 } },
|
||||
],
|
||||
};
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([blocker, mover], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.states.get('flights:mover').corner, 'below');
|
||||
|
||||
// The blocker is gone, so the first listed placement is free again. Sticky
|
||||
// corners are tried FIRST regardless of list order, otherwise a label hops
|
||||
// back and forth every time its neighbour comes and goes.
|
||||
arbiter.solve([mover], { capacity: 1, now: 4000 });
|
||||
assert.equal(arbiter.states.get('flights:mover').corner, 'below');
|
||||
});
|
||||
|
||||
test('candidate ordering is stable when the comparator reports a true tie', () => {
|
||||
// Canonically equivalent but distinct strings collate to 0, which is the only
|
||||
// way candidateCompare ties two different identities all the way down.
|
||||
const tied = (key, x) => ({
|
||||
key,
|
||||
layerId: 'flights',
|
||||
sourceId: key,
|
||||
priority: 3,
|
||||
centerDistance: 42,
|
||||
keyholeAlpha: 1,
|
||||
screenX: x,
|
||||
screenY: 0,
|
||||
placements: [{ corner: 'above', rect: { x, y: 0, w: 20, h: 12 } }],
|
||||
});
|
||||
const first = tied('flights:é', 0);
|
||||
const second = tied('flights:é', 400);
|
||||
assert.equal(first.key.localeCompare(second.key), 0);
|
||||
assert.notEqual(first.key, second.key);
|
||||
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([first, second], { capacity: 1, now: 1000 });
|
||||
assert.deepEqual(Array.from(arbiter.selectedKeys), [first.key]);
|
||||
|
||||
const reversed = new LabelArbiter();
|
||||
reversed.solve([second, first], { capacity: 1, now: 1000 });
|
||||
assert.deepEqual(Array.from(reversed.selectedKeys), [second.key]);
|
||||
});
|
||||
|
||||
test('each solve starts from an empty collision field', () => {
|
||||
const cohort = Array.from({ length: 5 }, (_, index) => candidate('flights', index, index * 200));
|
||||
const arbiter = new LabelArbiter();
|
||||
const first = arbiter.solve(cohort, { capacity: 5, now: 1000, preserveIncumbents: false });
|
||||
assert.equal(first.selectedCount, 5);
|
||||
// Identical geometry one solve later: the reused spatial hash must not still
|
||||
// be holding the previous solve's rectangles.
|
||||
const second = arbiter.solve(cohort, { capacity: 5, now: 5000, preserveIncumbents: false });
|
||||
assert.equal(second.selectedCount, 5);
|
||||
});
|
||||
|
||||
test('the pooled state list is never handed out to callers', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([candidate('flights', 1), candidate('flights', 2, 200)], { capacity: 2, now: 1000 });
|
||||
assert.equal(arbiter.activeStateCount(), 2);
|
||||
assert.equal(typeof arbiter.activeStates, 'undefined');
|
||||
const state = arbiter.activeStateAt(0);
|
||||
assert.ok(state?.key);
|
||||
assert.equal(arbiter.activeStateAt(99), undefined);
|
||||
// Reading twice must not disturb the arbiter's own view of its states.
|
||||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||||
});
|
||||
|
||||
test('the pooled state list is invalidated when states are dropped', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve([candidate('flights', 1)], { capacity: 1, now: 1000 });
|
||||
assert.equal(arbiter.activeStateCount(), 1);
|
||||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||||
|
||||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||||
assert.equal(arbiter.activeStateCount(), arbiter.states.size);
|
||||
|
||||
arbiter.solve([], { capacity: 0, now: 5000 });
|
||||
assert.equal(arbiter.states.size, 0);
|
||||
assert.equal(arbiter.activeStateCount(), 0);
|
||||
});
|
||||
|
||||
test('uncapped demand drives quotas while the solve input remains bounded', () => {
|
||||
const objects = [
|
||||
...Array.from({ length: 12 }, (_, index) => candidate('flights', index)),
|
||||
...Array.from({ length: 12 }, (_, index) => candidate('satellites', index, 500 + index * 30)),
|
||||
];
|
||||
const arbiter = new LabelArbiter();
|
||||
arbiter.solve(objects, {
|
||||
capacity: 20,
|
||||
strategy: ALLOCATION_WEIGHTED,
|
||||
demandByLayer: { flights: 12000, satellites: 120 },
|
||||
now: 1000,
|
||||
});
|
||||
const diagnostics = arbiter.diagnostics();
|
||||
assert.deepEqual(diagnostics.demand, { flights: 12000, satellites: 120 });
|
||||
assert.ok(diagnostics.quotas.flights > diagnostics.quotas.satellites);
|
||||
assert.equal(diagnostics.capacity, 20);
|
||||
});
|
||||
|
||||
test('live identities expose selected and temporal fading membership by layer/source', () => {
|
||||
const arbiter = new LabelArbiter();
|
||||
const one = [candidate('flights', 42)];
|
||||
arbiter.solve(one, { capacity: 1, now: 1000 });
|
||||
assert.deepEqual(arbiter.liveIdentities({ includeFading: false, now: 1100 }), new Map([
|
||||
['flights', new Set([42])],
|
||||
]));
|
||||
arbiter.solve([], { capacity: 0, now: 1200 });
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: false, now: 1250 }).size, 0);
|
||||
assert.deepEqual(arbiter.liveIdentities({ includeFading: true, now: 1250 }), new Map([
|
||||
['flights', new Set([42])],
|
||||
]));
|
||||
assert.equal(arbiter.liveIdentities({ includeFading: true, now: 1501 }).size, 0);
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ALLOCATION_ELASTIC,
|
||||
ALLOCATION_WEIGHTED,
|
||||
LabelArbiter,
|
||||
} from './labelArbiter.js';
|
||||
|
||||
// Compact rows retain the full identity/corner/rect contract:
|
||||
// key@corner@x@y@w@h (fade rows append @alpha@selected).
|
||||
const GOLDEN = {
|
||||
elasticTie12: {
|
||||
selected: 'firms:000,firms:008,firms:010,vessels:003,vessels:007,vessels:009',
|
||||
placements: 'firms:000@NE@-229@-190@38@16|firms:008@NE@451@-65@38@16|firms:010@NE@153@-304@38@16|vessels:003@NE@94@-74@38@16|vessels:007@NE@-418@-73@38@16|vessels:009@NE@-210@180@38@16',
|
||||
fades: [],
|
||||
selectedCount: 6,
|
||||
quotas: 'firms:3,vessels:3',
|
||||
labelsByLayer: 'firms:3,vessels:3',
|
||||
queue: [2, 6],
|
||||
spatialCellCount: 27,
|
||||
},
|
||||
weightedFew24: {
|
||||
selected: 'cctv:003,cctv:009,cctv:015,cctv:018,firms:010,firms:016,vessels:008,vessels:011,vessels:020',
|
||||
placements: 'cctv:003@NE@-163@-259@38@16|cctv:009@NE@188@-51@38@16|cctv:015@NE@-230@100@38@16|cctv:018@NE@-431@-109@38@16|firms:010@NE@-199@-290@38@16|firms:016@NE@232@232@38@16|vessels:008@NE@52@-209@38@16|vessels:011@NE@-68@-54@38@16|vessels:020@NE@-31@199@38@16',
|
||||
fades: [],
|
||||
selectedCount: 9,
|
||||
quotas: 'cctv:4,firms:2,vessels:3',
|
||||
labelsByLayer: 'cctv:4,firms:2,vessels:3',
|
||||
queue: [3, 9],
|
||||
spatialCellCount: 28,
|
||||
},
|
||||
duplicateKeys48: {
|
||||
selected: 'a-blocker:fixed,z-duplicates:shared',
|
||||
placements: 'a-blocker:fixed@only@0@0@20@20|z-duplicates:shared@free@300@300@20@20',
|
||||
fades: [],
|
||||
selectedCount: 2,
|
||||
quotas: 'a-blocker:1,z-duplicates:1',
|
||||
labelsByLayer: 'a-blocker:1,z-duplicates:1',
|
||||
queue: [2, 2],
|
||||
spatialCellCount: 5,
|
||||
},
|
||||
stable96: [
|
||||
{
|
||||
selected: 'flights:003,flights:009,flights:033,flights:057,flights:087,satellites:055,satellites:067,satellites:073,satellites:079,satellites:085,vessels:017,vessels:029,vessels:035,vessels:083,vessels:095',
|
||||
placements: 'flights:003@NE@-214@22@38@16|flights:009@NE@190@-267@38@16|flights:033@NE@-437@237@38@16|flights:057@NE@43@241@38@16|flights:087@NE@384@3@38@16|satellites:055@NE@-343@-236@38@16|satellites:067@NE@420@187@38@16|satellites:073@NE@-380@40@38@16|satellites:079@NE@256@32@38@16|satellites:085@NE@-80@207@38@16|vessels:017@NE@-212@-132@38@16|vessels:029@NE@12@-14@38@16|vessels:035@NE@130@-106@38@16|vessels:083@NE@-43@-289@38@16|vessels:095@NE@439@-262@38@16',
|
||||
fades: [],
|
||||
selectedCount: 15,
|
||||
quotas: 'flights:5,satellites:5,vessels:5',
|
||||
labelsByLayer: 'flights:5,satellites:5,vessels:5',
|
||||
queue: [3, 15],
|
||||
spatialCellCount: 52,
|
||||
},
|
||||
{
|
||||
selected: 'flights:003,flights:009,flights:033,flights:057,flights:087,satellites:055,satellites:067,satellites:073,satellites:079,satellites:085,vessels:017,vessels:029,vessels:035,vessels:083,vessels:095',
|
||||
placements: 'flights:003@NE@-214@22@38@16|flights:009@NE@190@-267@38@16|flights:033@NE@-437@237@38@16|flights:057@NE@43@241@38@16|flights:087@NE@384@3@38@16|satellites:055@NE@-343@-236@38@16|satellites:067@NE@420@187@38@16|satellites:073@NE@-380@40@38@16|satellites:079@NE@256@32@38@16|satellites:085@NE@-80@207@38@16|vessels:017@NE@-212@-132@38@16|vessels:029@NE@12@-14@38@16|vessels:035@NE@130@-106@38@16|vessels:083@NE@-43@-289@38@16|vessels:095@NE@439@-262@38@16',
|
||||
fades: [],
|
||||
selectedCount: 15,
|
||||
quotas: 'flights:5,satellites:5,vessels:5',
|
||||
labelsByLayer: 'flights:5,satellites:5,vessels:5',
|
||||
queue: [0, 0],
|
||||
spatialCellCount: 52,
|
||||
},
|
||||
],
|
||||
clustered200: {
|
||||
selected: 'firms:000,firms:001',
|
||||
placements: 'firms:000@above@70@66@60@20|firms:001@below@70@114@60@20',
|
||||
fades: [],
|
||||
selectedCount: 2,
|
||||
quotas: 'firms:200',
|
||||
labelsByLayer: 'firms:2',
|
||||
queue: [2, 4],
|
||||
spatialCellCount: 9,
|
||||
},
|
||||
packedCells400: {
|
||||
selected: 'air:000,air:003,air:051,air:066,air:171,air:210,air:330,air:351,ground:013,ground:124,ground:157,ground:379,sea:146,sea:230,sea:281,sea:347,sea:383,sea:386',
|
||||
placements: 'air:000@packed@-639@-127@10@10|air:003@packed@-607@-639@10@10|air:051@NE@-4288@546@38@16|air:066@NE@-245@-2390@38@16|air:171@NE@5493@2134@38@16|air:210@NE@2943@2389@38@16|air:330@NE@3289@-1454@38@16|air:351@NE@5423@-3072@38@16|ground:013@NE@2371@-2822@38@16|ground:124@NE@-20@3579@38@16|ground:157@NE@-1642@-3390@38@16|ground:379@NE@5196@-1036@38@16|sea:146@NE@-3559@-3258@38@16|sea:230@NE@-1938@-1868@38@16|sea:281@NE@5212@-1783@38@16|sea:347@NE@-2185@2564@38@16|sea:383@NE@2177@-1527@38@16|sea:386@NE@4250@1117@38@16',
|
||||
fades: [],
|
||||
selectedCount: 18,
|
||||
quotas: 'air:8,ground:4,sea:6',
|
||||
labelsByLayer: 'air:8,ground:4,sea:6',
|
||||
queue: [3, 18],
|
||||
spatialCellCount: 55,
|
||||
},
|
||||
fadeSequence12: [
|
||||
{
|
||||
selected: 'tracks:000,tracks:005,tracks:007,tracks:009',
|
||||
placements: 'tracks:000@NE@-51@-134@38@16|tracks:005@NE@-179@-256@38@16|tracks:007@NE@347@200@38@16|tracks:009@NE@446@22@38@16',
|
||||
fades: [
|
||||
'7000:',
|
||||
'7075:tracks:000@NE@-51@-134@38@[email protected]@true|tracks:005@NE@-179@-256@38@[email protected]@true|tracks:007@NE@347@200@38@[email protected]@true|tracks:009@NE@446@22@38@[email protected]@true',
|
||||
'7150:tracks:000@NE@-51@-134@38@16@1@true|tracks:005@NE@-179@-256@38@16@1@true|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
|
||||
],
|
||||
selectedCount: 4,
|
||||
quotas: 'tracks:4',
|
||||
labelsByLayer: 'tracks:4',
|
||||
queue: [1, 4],
|
||||
spatialCellCount: 15,
|
||||
},
|
||||
{
|
||||
selected: 'tracks:007,tracks:009',
|
||||
placements: 'tracks:007@NE@347@200@38@16|tracks:009@NE@446@22@38@16',
|
||||
fades: [
|
||||
'7200:tracks:000@NE@-51@-134@38@16@1@false|tracks:005@NE@-179@-256@38@16@1@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
|
||||
'7275:tracks:000@NE@-51@-134@38@[email protected]@false|tracks:005@NE@-179@-256@38@[email protected]@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
|
||||
'7350:tracks:000@NE@-51@-134@38@[email protected]@false|tracks:005@NE@-179@-256@38@[email protected]@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
|
||||
'7501:tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
|
||||
],
|
||||
selectedCount: 2,
|
||||
quotas: 'tracks:2',
|
||||
labelsByLayer: 'tracks:2',
|
||||
queue: [1, 2],
|
||||
spatialCellCount: 15,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function seededRandom(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function rect(x, y, w = 38, h = 16) {
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
function placementsFor(x, y, shape = 'full') {
|
||||
if (shape === 'verticalOnly') {
|
||||
return [
|
||||
{ corner: 'above', rect: rect(x - 30, y - 34, 60, 20) },
|
||||
{ corner: 'below', rect: rect(x - 30, y + 14, 60, 20) },
|
||||
];
|
||||
}
|
||||
if (shape === 'few') {
|
||||
return [
|
||||
{ corner: 'NE', rect: rect(x + 8, y - 24) },
|
||||
{ corner: 'SW', rect: rect(x - 46, y + 8) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ corner: 'NE', rect: rect(x + 8, y - 24) },
|
||||
{ corner: 'NW', rect: rect(x - 46, y - 24) },
|
||||
{ corner: 'SE', rect: rect(x + 8, y + 8) },
|
||||
{ corner: 'SW', rect: rect(x - 46, y + 8) },
|
||||
];
|
||||
}
|
||||
|
||||
function makeCohort(count, {
|
||||
seed,
|
||||
layers,
|
||||
shape = 'full',
|
||||
tieHeavy = false,
|
||||
spreadX = 900,
|
||||
spreadY = 560,
|
||||
} = {}) {
|
||||
const random = seededRandom(seed);
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const x = Math.floor(random() * spreadX) - Math.floor(spreadX / 2);
|
||||
const y = Math.floor(random() * spreadY) - Math.floor(spreadY / 2);
|
||||
const layerId = layers[index % layers.length];
|
||||
return {
|
||||
key: `${layerId}:${String(index).padStart(3, '0')}`,
|
||||
layerId,
|
||||
sourceId: index,
|
||||
priority: tieHeavy ? index % 2 : Math.floor(random() * 7),
|
||||
centerDistance: tieHeavy ? 100 : Math.hypot(x, y),
|
||||
keyholeAlpha: tieHeavy ? 1 : 0.55 + Math.floor(random() * 4) * 0.1,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
placements: placementsFor(x, y, shape),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function roundedAlpha(value) {
|
||||
return Number(value.toFixed(6));
|
||||
}
|
||||
|
||||
function snapshotSolve(arbiter, candidates, options, fadeTimes = []) {
|
||||
const diagnostics = arbiter.solve(candidates, options);
|
||||
const selected = Array.from(arbiter.selectedKeys).sort();
|
||||
const placements = selected.map((key) => {
|
||||
const state = arbiter.states.get(key);
|
||||
const { x, y, w, h } = state.lastPlacement.rect;
|
||||
return [key, state.corner, x, y, w, h];
|
||||
});
|
||||
const current = new Map(candidates.map((candidate) => [candidate.key, candidate]));
|
||||
const fades = fadeTimes.map((now) => [
|
||||
now,
|
||||
...arbiter.renderEntries(current, now)
|
||||
.map((entry) => {
|
||||
const { x, y, w, h } = entry.placement.rect;
|
||||
return [
|
||||
entry.candidate.key,
|
||||
entry.placement.corner,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
roundedAlpha(entry.temporalAlpha),
|
||||
entry.selected,
|
||||
];
|
||||
})
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
]);
|
||||
return {
|
||||
selected: selected.join(','),
|
||||
placements: placements.map((row) => row.join('@')).join('|'),
|
||||
fades: fades.map(([now, ...entries]) => `${now}:${entries.map((row) => row.join('@')).join('|')}`),
|
||||
selectedCount: diagnostics.selectedCount,
|
||||
quotas: Object.entries(diagnostics.quotas).map((row) => row.join(':')).join(','),
|
||||
labelsByLayer: Object.entries(diagnostics.labelsByLayer).map((row) => row.join(':')).join(','),
|
||||
queue: [diagnostics.spatialQueueBuildCount, diagnostics.spatialQueueNextCount],
|
||||
spatialCellCount: arbiter._spatial.cells.size,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGoldenBattery() {
|
||||
const elasticTie12 = makeCohort(12, {
|
||||
seed: 0x12e1a57,
|
||||
layers: ['firms', 'vessels'],
|
||||
tieHeavy: true,
|
||||
});
|
||||
const elasticArbiter = new LabelArbiter();
|
||||
|
||||
const weightedFew24 = makeCohort(24, {
|
||||
seed: 0x24fe771,
|
||||
layers: ['cctv', 'firms', 'vessels'],
|
||||
shape: 'few',
|
||||
});
|
||||
const weightedArbiter = new LabelArbiter();
|
||||
|
||||
const duplicateTail = makeCohort(45, {
|
||||
seed: 0x48d0b1e,
|
||||
layers: ['z-duplicates'],
|
||||
shape: 'verticalOnly',
|
||||
}).map((candidate, index) => ({
|
||||
...candidate,
|
||||
key: `z-duplicates:tail-${String(index).padStart(2, '0')}`,
|
||||
priority: 0,
|
||||
}));
|
||||
const duplicateCandidates = [
|
||||
{
|
||||
key: 'a-blocker:fixed',
|
||||
layerId: 'a-blocker',
|
||||
sourceId: 'blocker',
|
||||
priority: 100,
|
||||
centerDistance: 0,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
placements: [{ corner: 'only', rect: rect(0, 0, 20, 20) }],
|
||||
},
|
||||
{
|
||||
key: 'z-duplicates:shared',
|
||||
layerId: 'z-duplicates',
|
||||
sourceId: 'blocked-copy',
|
||||
priority: 99,
|
||||
centerDistance: 1,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 4,
|
||||
screenY: 4,
|
||||
placements: [{ corner: 'blocked', rect: rect(0, 0, 20, 20) }],
|
||||
},
|
||||
{
|
||||
key: 'z-duplicates:shared',
|
||||
layerId: 'z-duplicates',
|
||||
sourceId: 'free-copy',
|
||||
priority: 99,
|
||||
centerDistance: 1,
|
||||
keyholeAlpha: 1,
|
||||
screenX: 300,
|
||||
screenY: 300,
|
||||
placements: [{ corner: 'free', rect: rect(300, 300, 20, 20) }],
|
||||
},
|
||||
...duplicateTail,
|
||||
];
|
||||
const duplicateArbiter = new LabelArbiter();
|
||||
|
||||
const stable96 = makeCohort(96, {
|
||||
seed: 0x96c0ffee,
|
||||
layers: ['flights', 'satellites', 'vessels'],
|
||||
shape: 'few',
|
||||
tieHeavy: true,
|
||||
});
|
||||
const stableArbiter = new LabelArbiter();
|
||||
|
||||
const clustered200 = makeCohort(200, {
|
||||
seed: 0x200f1e1d,
|
||||
layers: ['firms'],
|
||||
shape: 'verticalOnly',
|
||||
}).map((candidate, index) => ({
|
||||
...candidate,
|
||||
priority: 200 - index,
|
||||
centerDistance: index,
|
||||
screenX: 100,
|
||||
screenY: 100,
|
||||
placements: placementsFor(100, 100, 'verticalOnly'),
|
||||
}));
|
||||
const clusteredArbiter = new LabelArbiter();
|
||||
|
||||
const packed400 = makeCohort(400, {
|
||||
seed: 0x400b17c5,
|
||||
layers: ['air', 'ground', 'sea'],
|
||||
shape: 'few',
|
||||
spreadX: 12000,
|
||||
spreadY: 8000,
|
||||
});
|
||||
const packedCell = (candidate, cellX, cellY, priority) => ({
|
||||
...candidate,
|
||||
priority,
|
||||
keyholeAlpha: 1,
|
||||
centerDistance: 0,
|
||||
screenX: cellX * 32 + 1,
|
||||
screenY: cellY * 32 + 1,
|
||||
placements: [{
|
||||
corner: 'packed',
|
||||
rect: rect(cellX * 32 + 1, cellY * 32 + 1, 10, 10),
|
||||
}],
|
||||
});
|
||||
packed400[0] = packedCell(packed400[0], -20, -4, 1000);
|
||||
packed400[3] = packedCell(packed400[3], -19, -20, 999);
|
||||
const packedArbiter = new LabelArbiter();
|
||||
|
||||
const fade12 = makeCohort(12, {
|
||||
seed: 0xfade0012,
|
||||
layers: ['tracks'],
|
||||
shape: 'few',
|
||||
});
|
||||
const fadeArbiter = new LabelArbiter();
|
||||
|
||||
return {
|
||||
elasticTie12: snapshotSolve(elasticArbiter, elasticTie12, {
|
||||
capacity: 6,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now: 1000,
|
||||
preserveIncumbents: false,
|
||||
}),
|
||||
weightedFew24: snapshotSolve(weightedArbiter, weightedFew24, {
|
||||
capacity: 9,
|
||||
strategy: ALLOCATION_WEIGHTED,
|
||||
layerWeights: { cctv: 2, firms: 0.75, vessels: 1.25 },
|
||||
now: 2000,
|
||||
preserveIncumbents: false,
|
||||
}),
|
||||
duplicateKeys48: snapshotSolve(duplicateArbiter, duplicateCandidates, {
|
||||
capacity: 2,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
demandByLayer: { 'a-blocker': 1, 'z-duplicates': 47 },
|
||||
now: 3000,
|
||||
preserveIncumbents: false,
|
||||
}),
|
||||
stable96: [
|
||||
snapshotSolve(stableArbiter, stable96, {
|
||||
capacity: 15,
|
||||
strategy: ALLOCATION_WEIGHTED,
|
||||
now: 4000,
|
||||
preserveIncumbents: true,
|
||||
}),
|
||||
snapshotSolve(stableArbiter, stable96.slice().reverse(), {
|
||||
capacity: 15,
|
||||
strategy: ALLOCATION_WEIGHTED,
|
||||
now: 4300,
|
||||
preserveIncumbents: true,
|
||||
}),
|
||||
],
|
||||
clustered200: snapshotSolve(clusteredArbiter, clustered200, {
|
||||
capacity: 200,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now: 5000,
|
||||
preserveIncumbents: false,
|
||||
}),
|
||||
packedCells400: snapshotSolve(packedArbiter, packed400, {
|
||||
capacity: 18,
|
||||
strategy: ALLOCATION_WEIGHTED,
|
||||
layerWeights: { air: 1.5, ground: 0.75, sea: 1 },
|
||||
now: 6000,
|
||||
preserveIncumbents: false,
|
||||
}),
|
||||
fadeSequence12: [
|
||||
snapshotSolve(fadeArbiter, fade12, {
|
||||
capacity: 4,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now: 7000,
|
||||
preserveIncumbents: false,
|
||||
}, [7000, 7075, 7150]),
|
||||
snapshotSolve(fadeArbiter, fade12.slice(4), {
|
||||
capacity: 2,
|
||||
strategy: ALLOCATION_ELASTIC,
|
||||
now: 7200,
|
||||
preserveIncumbents: false,
|
||||
}, [7200, 7275, 7350, 7501]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.GEV_PRINT_ARBITER_GOLDEN === '1') {
|
||||
process.stdout.write(`${JSON.stringify(buildGoldenBattery(), null, 2)}\n`);
|
||||
} else {
|
||||
test('seeded arbiter scenario battery matches the current golden behavior', () => {
|
||||
assert.deepEqual(buildGoldenBattery(), GOLDEN);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 433 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user