Merge origin/main into fix/antimeridian-traffic-bounds

This commit is contained in:
Sameh Khamis committed 2026-09-20 17:09:53 -07:00
commit 2f16e34cad
724 files changed
+193369 -25564

No files matched your search

+414 -67
View File
@@ -8,90 +8,415 @@
*/
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',
'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',
'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
'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',
'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',
'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',
'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',
'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',
'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',
'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)
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',
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();
const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) {
if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
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 (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,
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,
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',
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, owner picks; CC-BY 4.0 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* 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 },
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 },
};
+7 -3
View File
@@ -35,9 +35,11 @@ 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"';
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"';
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"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
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
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false,
includeHidden = false,
} = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
}
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5,
scaleFloor: 0.45,
alphaFloor: 0.35,
combinedAlphaFloor: 0.20,
combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides };
}
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|| cameraHeightM >= tuning.globeViewBlendEndM) {
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;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result;
}
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
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 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;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
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.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);
}
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
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);
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 };
}
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved(
cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
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) {
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @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 }) {
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;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha);
return 1;
}
+4 -25
View File
@@ -1,17 +1,8 @@
import { createVesselLayer } from '../layers/vessels/index.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import * as context from './contextStore.js';
import * as trails from './trailRenderer.js';
import * as labels from './detectionDraw.js';
import * as picking from './pickRegistry.js';
import * as overlay from '../overlays/worldOverlay.js';
import * as geoid from './geoid.js';
import * as sprites from './spriteOrder.js';
import * as focus from './focusDeemphasis.js';
import * as worldFocus from '../worldFocus.js';
import * as render from '../renderGovernor.js';
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
const aisLiveVesselsLayer = createVesselLayer({
import { createAisStreamSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
}),
@@ -19,18 +10,6 @@ const aisLiveVesselsLayer = createVesselLayer({
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS,
},
services: {
context,
trails,
labels,
picking,
overlay,
geoid,
sprites,
focus,
worldFocus,
render,
},
});
export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js';
export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError;
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aisStreamRows,
ingestAisStreamEnvelope,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
+15
View File
@@ -0,0 +1,15 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createOverpassAlprSource } from '../layers/alpr/index.js';
export * from '../layers/alpr/index.js';
const slot = createSourceSlot(
createOverpassAlprSource(),
['fetch'],
'ALPR source',
);
export const configureAlprSource = slot.configure;
export default createApplicationAlpr({
surface: defaultSurface,
source: slot.source,
});
File diff suppressed because it is too large. Load diff
+110 -40
View File
@@ -28,11 +28,39 @@ 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: [] },
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;
@@ -42,8 +70,9 @@ 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;
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)));
}
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
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 '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);
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;
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
}
});
}
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
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));
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.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
}
return records;
}
@@ -108,9 +153,12 @@ 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']);
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;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// 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 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 };
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
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') {
const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view';
} else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
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;
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));
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);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length,
items: top,
truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel,
coverage: {
layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
lastResult = { items, coverage: result.coverage };
return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return {
query,
reset() { lastResult = null; },
hasMemory() { return Boolean(lastResult); },
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}
File diff suppressed because it is too large. Load diff
+396
View File
@@ -0,0 +1,396 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
const media = createBhoteKoshiEmbeddedMedia({
viewer: {},
documentRef: { createElement: unexpected },
globalRef: {
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
setTimeout: unexpected,
requestAnimationFrame: unexpected,
},
facebookLoader: unexpected,
youtubeLoader: unexpected,
xLoader: unexpected,
});
for (const sourceUrl of [
'https://www.youtube.com/watch?v=abcdefghijk',
'https://www.facebook.com/watch/?v=123456789',
'https://x.com/example/status/123456789',
]) {
const options = { observation: { media: { sourceUrl } }, autoplay: true };
assert.equal(media.warm(options), false, 'no hidden preload');
assert.equal(media.show(options), false, 'retain the existing fallback card');
assert.equal(media.play(), false);
assert.equal(media.pause(), false);
media.hide();
}
media.destroy();
media.hide();
await Promise.resolve();
});
test('ordinary browsers retain real preloads on the same launcher URL', () => {
for (const userAgent of [
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
'Mozilla/5.0 Electron/38.0.0',
'',
]) {
const { media, root } = preloadFixture({ globalRef: {
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
} });
assert.equal(media.warm({ observation: { media: {
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
} } }), true);
assert.equal(root.children.length, 1);
media.pause();
assert.equal(root.children.length, 0);
media.destroy();
}
});
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
const timers = new Map();
const globalRef = {
setTimeout(fn, delay) { const id = ++nextId; timers.set(id, { at: now + delay, fn }); return id; },
clearTimeout(id) { timers.delete(id); },
};
const advance = (ms) => {
const until = now + ms;
while (true) {
const entry = [...timers].filter(([, item]) => item.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
const [id, item] = entry; timers.delete(id); now = item.at; item.fn();
}
now = until;
};
let events;
const calls = [];
let time = 0;
let state = -1;
const player = {
mute: () => calls.push('mute'), playVideo: () => calls.push('play'),
pauseVideo: () => calls.push('pause'), destroy: () => calls.push('destroy'),
getCurrentTime: () => time,
getPlayerState: () => state,
};
const api = { Player: class { constructor(_iframe, options) { events = options.events; return player; } } };
const statuses = [];
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7, globalRef,
loadApi: () => api, onStatus: (phase) => statuses.push(phase) });
return { session, advance, calls, timers, statuses, player, get events() { return events; }, setTime(value) { time = value; }, setState(value) { state = value; } };
}
test('trimmed YouTube reconciles already-playing READY and missed PLAYING callbacks', async () => {
for (const alreadyPlaying of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (alreadyPlaying) { f.setState(1); f.setTime(1); }
f.events.onReady({ target: f.player });
if (!alreadyPlaying) { f.setState(1); f.setTime(1); f.advance(100); }
f.setTime(6); f.advance(5000);
assert.deepEqual(f.session.getState(), { phase: 'playing', currentTime: 6 });
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube accepts ENDED before PLAYING and never replays a completed READY', async () => {
for (const endedCallback of [true, false]) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.setState(0); f.setTime(7);
if (endedCallback) f.events.onStateChange({ data: 0, target: f.player });
f.events.onReady({ target: f.player });
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.calls, ['pause']);
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
f.session.destroy();
}
});
test('trimmed YouTube waits through delayed startup, stops at source 0:07, and releases its timers', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.advance(3000);
assert.equal(f.session.getState().phase, 'starting');
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.setTime(4); f.advance(4000);
assert.equal(f.session.getState().phase, 'playing', 'seven wall-clock seconds do not truncate a delayed clip');
f.setTime(7); f.advance(100);
assert.deepEqual(f.session.getState(), { phase: 'completed', currentTime: 7 });
assert.deepEqual(f.statuses, ['completed']);
assert.equal(f.timers.size, 0);
assert.equal(f.calls.at(-1), 'pause');
f.session.destroy();
});
test('trimmed YouTube bounds blocked, failed, stalled and never-started playback', async () => {
for (const outcome of ['blocked', 'unavailable', 'timeout', 'stalled']) {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
if (outcome === 'blocked') f.events.onAutoplayBlocked();
else if (outcome === 'unavailable') f.events.onError();
else if (outcome === 'stalled') { f.events.onStateChange({ data: 1 }); f.advance(12000); }
else f.advance(5000);
assert.equal(f.session.getState().phase, outcome === 'stalled' ? 'timeout' : outcome);
assert.equal(f.timers.size, 0);
const calls = [...f.calls];
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
assert.deepEqual(f.calls, calls, 'late provider callbacks cannot restart a terminal attempt');
f.session.destroy();
}
});
test('trimmed YouTube teardown revokes pending SDK and player callbacks', async () => {
const f = trimmedPlaybackFixture();
await Promise.resolve(); await Promise.resolve();
f.session.destroy();
f.events.onReady({ target: f.player });
f.events.onStateChange({ data: 1 });
f.events.onError(); f.advance(20000);
assert.deepEqual(f.calls, ['destroy']);
assert.deepEqual(f.statuses, []);
assert.equal(f.timers.size, 0);
let constructCount = 0;
let resolveApi;
const session = createTrimmedYouTubePlayback({ iframe: {}, endAtSec: 7,
loadApi: () => new Promise((resolve) => { resolveApi = resolve; }) });
await Promise.resolve();
session.destroy();
resolveApi({ Player: class { constructor() { constructCount++; } } });
await Promise.resolve(); await Promise.resolve();
assert.equal(constructCount, 0);
});
test('pausing YouTube removes its frame and rejects a late load from the stopped generation', () => {
const nodes = [];
class Element extends EventTarget {
constructor(tag) { super(); this.tag = tag; this.children = []; this.dataset = {}; this.classList = { add() {}, remove() {} }; nodes.push(this); }
setAttribute() {}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); this.parent = null; }
}
const documentRef = {
body: new Element('body'),
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {} });
const options = { observation: { title: 'Clip', media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } }, anchor: {}, autoplay: true };
assert.equal(media.show(options), true);
const frame = nodes.find((node) => node.tag === 'iframe');
const card = nodes.find((node) => node.tag === 'section');
assert.ok(card.parent);
assert.equal(media.pause(), true);
assert.equal(card.parent, null);
frame.dispatchEvent(new Event('load'));
assert.equal(card.parent, null);
assert.equal(media.show(options), true, 'an explicit replay creates a fresh player');
media.destroy();
assert.equal(documentRef.body.children.length, 0);
});
test('resolves supported embedded callout providers from canonical public URLs', () => {
assert.deepEqual(
resolveEmbeddedMediaSource('https://www.youtube.com/watch?v=DbqRexFxv3k'),
{
provider: 'youtube',
id: 'DbqRexFxv3k',
url: 'https://www.youtube.com/watch?v=DbqRexFxv3k',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://www.facebook.com/reel/1571491657757829').provider,
'facebook',
);
assert.deepEqual(
resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958'),
{
provider: 'x',
id: '2092640554568007958',
url: 'https://x.com/Indowatchosint/status/2092640554568007958',
},
);
assert.equal(
resolveEmbeddedMediaSource('https://twitter.com/Indowatchosint/status/2092640554568007958').provider,
'x',
);
assert.equal(
resolveEmbeddedMediaSource('https://www.youtube.com/shorts/DbqRexFxv3k').id,
'DbqRexFxv3k',
);
});
test('rejects deceptive hosts, non-post X links, private targets, and iframe markup', () => {
for (const value of [
'https://x.com.evil.example/user/status/2092640554568007958',
'https://x.com/Indowatchosint',
'https://youtube.com.evil.example/watch?v=DbqRexFxv3k',
'http://www.youtube.com/watch?v=DbqRexFxv3k',
'https://localhost/video.mp4',
'<iframe src="https://x.com/user/status/2092640554568007958"></iframe>',
]) {
assert.equal(resolveEmbeddedMediaSource(value), null, value);
}
assert.equal(publicEmbeddedMediaUrl('https://user:[email protected]/name/status/123'), null);
});
test('builds privacy-aware provider frames and keeps X on its widget runtime', () => {
const youtube = resolveEmbeddedMediaSource('https://youtu.be/DbqRexFxv3k');
const youtubeFrame = new URL(embeddedMediaFrameUrl(youtube, { autoplay: true }));
assert.equal(youtubeFrame.hostname, 'www.youtube-nocookie.com');
assert.equal(youtubeFrame.pathname, '/embed/DbqRexFxv3k');
assert.equal(youtubeFrame.searchParams.get('autoplay'), '1');
assert.equal(youtubeFrame.searchParams.get('mute'), '1');
const timestampedFrame = new URL(embeddedMediaFrameUrl(youtube, {
autoplay: true,
startAtSec: 26,
}));
assert.equal(timestampedFrame.searchParams.get('start'), '26');
const facebook = resolveEmbeddedMediaSource('https://www.facebook.com/watch/?v=456');
const facebookFrame = new URL(embeddedMediaFrameUrl(facebook, { autoplay: false }));
assert.equal(facebookFrame.hostname, 'www.facebook.com');
assert.equal(facebookFrame.pathname, '/plugins/video.php');
assert.equal(facebookFrame.searchParams.get('href'), facebook.url);
assert.equal(facebookFrame.searchParams.get('autoplay'), 'false');
const x = resolveEmbeddedMediaSource('https://x.com/Indowatchosint/status/2092640554568007958');
assert.equal(embeddedMediaFrameUrl(x), null);
});
test('Mailung playback stops at source timestamp 0:07 without changing other clips', async () => {
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const bounded = event.evidenceSpine.filter(point => point.media?.embedEndAtSec != null);
assert.deepEqual(bounded.map(point => point.id), ['mailung-bazzar']);
const media = bounded[0].media;
assert.equal(media.embedEndAtSec, 7);
const source = resolveEmbeddedMediaSource(media.sourceUrl);
for (const autoplay of [false, true]) {
const url = new URL(embeddedMediaFrameUrl(source, {
autoplay, startAtSec: media.clipInSec, endAtSec: media.embedEndAtSec,
}));
assert.equal(url.searchParams.get('end'), '7');
}
for (const endAtSec of [undefined, null, NaN, Infinity, -1, 0, 3]) {
const url = new URL(embeddedMediaFrameUrl(source, {startAtSec: 3, endAtSec}));
assert.equal(url.searchParams.has('end'), false);
}
});
function preloadFixture(options = {}) {
class Element extends EventTarget {
constructor(tag) {
super(); this.tag = tag; this.children = []; this.dataset = {};
this.classList = { add() {}, remove() {}, contains() { return false; } };
}
setAttribute() {}
removeAttribute() {}
append(...children) {
for (const child of children) { child.remove(); child.parent = this; this.children.push(child); }
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this);
this.parent = null;
}
}
const documentRef = {
body: new Element('body'),
createElement: tag => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
};
const media = createBhoteKoshiEmbeddedMedia({ viewer: {}, documentRef, globalRef: {}, ...options });
return { media, root: documentRef.body.children[0] };
}
for (const operation of ['pause', 'hide', 'destroy']) {
test(`${operation} removes a hidden YouTube preload`, () => {
const { media, root } = preloadFixture();
media.warm({ sourceUrl: 'https://youtu.be/DbqRexFxv3k' });
assert.equal(root.children.length, 1);
media[operation]();
assert.equal(root.children.length, 0);
media.destroy();
});
}
test('camera updates retain a preload until the visible player loads', () => {
const { media, root } = preloadFixture();
const observation = { media: { sourceUrl: 'https://youtu.be/DbqRexFxv3k' } };
media.warm({ observation });
const frame = root.children[0];
media.hide({ preserveWarm: true });
media.warm({ observation });
assert.equal(root.children[0], frame);
assert.equal(media.show({ observation, anchor: {} }), true);
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
assert.equal(root.children.length, 0);
});
test('hiding revokes a pending Facebook SDK before it can mount or subscribe', async () => {
let resolveApi;
const calls = [];
const { media, root } = preloadFixture({
facebookLoader: () => new Promise(resolve => { resolveApi = resolve; }),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
assert.equal(root.children.length, 1);
media.hide();
assert.equal(root.children.length, 0);
resolveApi({ Event: { subscribe() { calls.push('subscribe'); } }, XFBML: { parse() { calls.push('parse'); } } });
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(calls, []);
media.destroy();
});
test('pausing cancels a mounted Facebook preload and rejects its late ready callback', async () => {
let ready;
let unsubscribed = 0;
const timers = new Set();
const playerCalls = [];
const { media, root } = preloadFixture({
globalRef: { setTimeout(fn) { timers.add(fn); return fn; }, clearTimeout(fn) { timers.delete(fn); } },
facebookLoader: async () => ({
Event: { subscribe(_name, callback) { ready = callback; }, unsubscribe() { unsubscribed++; } },
XFBML: { parse() {} },
}),
});
media.warm({ sourceUrl: 'https://www.facebook.com/reel/1571491657757829' });
await Promise.resolve(); await Promise.resolve();
assert.equal(timers.size, 1);
media.pause();
assert.equal(root.children.length, 0);
assert.equal(timers.size, 0);
assert.equal(unsubscribed, 1);
ready({ type: 'video', instance: { play() { playerCalls.push('play'); }, pause() { playerCalls.push('pause'); } } });
assert.deepEqual(playerCalls, []);
media.destroy();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+499
View File
@@ -0,0 +1,499 @@
/**
* Simplified GeoPera river centerline from the point nearest the owner-provided
* Debris-Dammed Lake observation through Trishuli Bazaar. Every fourth published
* vertex is retained, plus the exact endpoint nearest the verified bazaar pin.
*
* Source: https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson
* License: CC BY-NC 4.0 (derived data; Planet Labs PBC / Vantor open data)
*/
export const BHOTE_KOSHI_FLOOD_PATH = Object.freeze([
Object.freeze([85.48476, 28.33255]),
Object.freeze([85.48353, 28.33308]),
Object.freeze([85.48231, 28.33279]),
Object.freeze([85.48108, 28.33278]),
Object.freeze([85.47985, 28.33304]),
Object.freeze([85.47863, 28.33275]),
Object.freeze([85.47741, 28.33301]),
Object.freeze([85.47618, 28.33299]),
Object.freeze([85.47496, 28.33325]),
Object.freeze([85.47373, 28.33351]),
Object.freeze([85.4725, 28.33377]),
Object.freeze([85.47128, 28.33375]),
Object.freeze([85.47005, 28.33401]),
Object.freeze([85.46881, 28.33481]),
Object.freeze([85.46759, 28.33506]),
Object.freeze([85.46636, 28.33532]),
Object.freeze([85.46513, 28.33585]),
Object.freeze([85.4639, 28.33584]),
Object.freeze([85.46267, 28.33609]),
Object.freeze([85.46145, 28.33635]),
Object.freeze([85.46022, 28.33661]),
Object.freeze([85.45899, 28.33659]),
Object.freeze([85.45777, 28.33685]),
Object.freeze([85.45655, 28.33629]),
Object.freeze([85.45533, 28.33628]),
Object.freeze([85.45411, 28.336]),
Object.freeze([85.45289, 28.33544]),
Object.freeze([85.45167, 28.33543]),
Object.freeze([85.45045, 28.33514]),
Object.freeze([85.44922, 28.33513]),
Object.freeze([85.448, 28.33511]),
Object.freeze([85.44677, 28.33537]),
Object.freeze([85.44554, 28.33563]),
Object.freeze([85.44432, 28.33534]),
Object.freeze([85.4431, 28.33533]),
Object.freeze([85.44188, 28.33504]),
Object.freeze([85.44065, 28.3353]),
Object.freeze([85.43943, 28.33529]),
Object.freeze([85.43821, 28.335]),
Object.freeze([85.43699, 28.33472]),
Object.freeze([85.43577, 28.33416]),
Object.freeze([85.43456, 28.33334]),
Object.freeze([85.43335, 28.33251]),
Object.freeze([85.43214, 28.33141]),
Object.freeze([85.43093, 28.33059]),
Object.freeze([85.42971, 28.33057]),
Object.freeze([85.42848, 28.33056]),
Object.freeze([85.42726, 28.33027]),
Object.freeze([85.42604, 28.33026]),
Object.freeze([85.42482, 28.33025]),
Object.freeze([85.4236, 28.32996]),
Object.freeze([85.42238, 28.32968]),
Object.freeze([85.42116, 28.32939]),
Object.freeze([85.41995, 28.32829]),
Object.freeze([85.41873, 28.32774]),
Object.freeze([85.41752, 28.32718]),
Object.freeze([85.41629, 28.32744]),
Object.freeze([85.41508, 28.32634]),
Object.freeze([85.41387, 28.32524]),
Object.freeze([85.41297, 28.32415]),
Object.freeze([85.41268, 28.32306]),
Object.freeze([85.41239, 28.32198]),
Object.freeze([85.41241, 28.3209]),
Object.freeze([85.41273, 28.31982]),
Object.freeze([85.41275, 28.31873]),
Object.freeze([85.41276, 28.31765]),
Object.freeze([85.41308, 28.31657]),
Object.freeze([85.41249, 28.31548]),
Object.freeze([85.4122, 28.3144]),
Object.freeze([85.41252, 28.31332]),
Object.freeze([85.41254, 28.31223]),
Object.freeze([85.41194, 28.31114]),
Object.freeze([85.41196, 28.31006]),
Object.freeze([85.41167, 28.30897]),
Object.freeze([85.41077, 28.30788]),
Object.freeze([85.40955, 28.30705]),
Object.freeze([85.40865, 28.30596]),
Object.freeze([85.40744, 28.30513]),
Object.freeze([85.40623, 28.30404]),
Object.freeze([85.40503, 28.30294]),
Object.freeze([85.40381, 28.30238]),
Object.freeze([85.4026, 28.30156]),
Object.freeze([85.40139, 28.30046]),
Object.freeze([85.40019, 28.29936]),
Object.freeze([85.39898, 28.29827]),
Object.freeze([85.39777, 28.29717]),
Object.freeze([85.39748, 28.29608]),
Object.freeze([85.39658, 28.29499]),
Object.freeze([85.39629, 28.2939]),
Object.freeze([85.3957, 28.29281]),
Object.freeze([85.39449, 28.29172]),
Object.freeze([85.39328, 28.29062]),
Object.freeze([85.39238, 28.28953]),
Object.freeze([85.39117, 28.28897]),
Object.freeze([85.38996, 28.28787]),
Object.freeze([85.38875, 28.28678]),
Object.freeze([85.38754, 28.28622]),
Object.freeze([85.38632, 28.28593]),
Object.freeze([85.38511, 28.28511]),
Object.freeze([85.3839, 28.28401]),
Object.freeze([85.383, 28.28292]),
Object.freeze([85.38179, 28.28182]),
Object.freeze([85.38089, 28.28073]),
Object.freeze([85.37968, 28.28017]),
Object.freeze([85.37846, 28.27934]),
Object.freeze([85.37725, 28.27852]),
Object.freeze([85.37666, 28.27743]),
Object.freeze([85.37668, 28.27634]),
Object.freeze([85.37669, 28.27526]),
Object.freeze([85.37701, 28.27418]),
Object.freeze([85.37642, 28.27309]),
Object.freeze([85.37644, 28.27201]),
Object.freeze([85.37615, 28.27092]),
Object.freeze([85.37616, 28.26984]),
Object.freeze([85.37648, 28.26876]),
Object.freeze([85.37558, 28.26767]),
Object.freeze([85.37499, 28.26658]),
Object.freeze([85.37378, 28.26548]),
Object.freeze([85.37288, 28.26439]),
Object.freeze([85.37198, 28.26329]),
Object.freeze([85.37169, 28.26221]),
Object.freeze([85.37048, 28.26111]),
Object.freeze([85.36928, 28.26001]),
Object.freeze([85.36806, 28.25973]),
Object.freeze([85.36685, 28.25863]),
Object.freeze([85.36656, 28.25754]),
Object.freeze([85.36566, 28.25645]),
Object.freeze([85.36507, 28.25536]),
Object.freeze([85.36478, 28.25427]),
Object.freeze([85.36449, 28.25318]),
Object.freeze([85.36512, 28.25211]),
Object.freeze([85.36452, 28.25102]),
Object.freeze([85.36423, 28.24993]),
Object.freeze([85.36425, 28.24885]),
Object.freeze([85.36335, 28.24776]),
Object.freeze([85.36245, 28.24666]),
Object.freeze([85.36124, 28.24556]),
Object.freeze([85.36004, 28.24447]),
Object.freeze([85.35882, 28.24391]),
Object.freeze([85.35823, 28.24282]),
Object.freeze([85.35733, 28.24173]),
Object.freeze([85.35734, 28.24064]),
Object.freeze([85.35797, 28.23957]),
Object.freeze([85.35799, 28.23849]),
Object.freeze([85.3577, 28.2374]),
Object.freeze([85.35802, 28.23632]),
Object.freeze([85.35834, 28.23524]),
Object.freeze([85.35928, 28.23417]),
Object.freeze([85.3596, 28.23309]),
Object.freeze([85.36023, 28.23202]),
Object.freeze([85.36024, 28.23093]),
Object.freeze([85.36057, 28.22985]),
Object.freeze([85.36058, 28.22877]),
Object.freeze([85.3609, 28.22769]),
Object.freeze([85.36092, 28.22661]),
Object.freeze([85.36094, 28.22553]),
Object.freeze([85.36126, 28.22445]),
Object.freeze([85.36067, 28.22336]),
Object.freeze([85.36007, 28.22227]),
Object.freeze([85.35886, 28.22117]),
Object.freeze([85.35796, 28.22008]),
Object.freeze([85.35706, 28.21898]),
Object.freeze([85.35616, 28.21789]),
Object.freeze([85.35526, 28.21679]),
Object.freeze([85.35467, 28.2157]),
Object.freeze([85.35469, 28.21462]),
Object.freeze([85.3547, 28.21354]),
Object.freeze([85.35472, 28.21246]),
Object.freeze([85.35443, 28.21137]),
Object.freeze([85.35414, 28.21028]),
Object.freeze([85.35416, 28.2092]),
Object.freeze([85.35448, 28.20812]),
Object.freeze([85.3548, 28.20704]),
Object.freeze([85.35482, 28.20596]),
Object.freeze([85.35392, 28.20486]),
Object.freeze([85.35332, 28.20377]),
Object.freeze([85.35303, 28.20269]),
Object.freeze([85.35275, 28.2016]),
Object.freeze([85.35246, 28.20052]),
Object.freeze([85.35247, 28.19943]),
Object.freeze([85.35188, 28.19834]),
Object.freeze([85.35128, 28.19725]),
Object.freeze([85.35038, 28.19616]),
Object.freeze([85.3501, 28.19507]),
Object.freeze([85.3495, 28.19398]),
Object.freeze([85.34891, 28.19289]),
Object.freeze([85.34862, 28.1918]),
Object.freeze([85.34802, 28.19071]),
Object.freeze([85.34743, 28.18962]),
Object.freeze([85.34622, 28.18853]),
Object.freeze([85.34593, 28.18744]),
Object.freeze([85.34565, 28.18635]),
Object.freeze([85.34475, 28.18526]),
Object.freeze([85.34385, 28.18417]),
Object.freeze([85.34325, 28.18308]),
Object.freeze([85.34266, 28.18199]),
Object.freeze([85.34237, 28.1809]),
Object.freeze([85.34239, 28.17982]),
Object.freeze([85.34271, 28.17874]),
Object.freeze([85.34272, 28.17765]),
Object.freeze([85.34274, 28.17657]),
Object.freeze([85.34215, 28.17548]),
Object.freeze([85.34216, 28.1744]),
Object.freeze([85.34249, 28.17332]),
Object.freeze([85.34189, 28.17223]),
Object.freeze([85.34191, 28.17115]),
Object.freeze([85.34192, 28.17006]),
Object.freeze([85.34255, 28.16899]),
Object.freeze([85.34226, 28.1679]),
Object.freeze([85.34289, 28.16683]),
Object.freeze([85.3423, 28.16574]),
Object.freeze([85.3417, 28.16465]),
Object.freeze([85.34048, 28.16436]),
Object.freeze([85.33928, 28.16353]),
Object.freeze([85.33807, 28.16271]),
Object.freeze([85.33747, 28.16162]),
Object.freeze([85.33718, 28.16053]),
Object.freeze([85.33628, 28.15971]),
Object.freeze([85.33507, 28.15888]),
Object.freeze([85.33385, 28.15859]),
Object.freeze([85.33295, 28.15777]),
Object.freeze([85.33174, 28.15694]),
Object.freeze([85.33053, 28.15639]),
Object.freeze([85.32932, 28.15529]),
Object.freeze([85.32842, 28.1542]),
Object.freeze([85.32721, 28.15337]),
Object.freeze([85.32631, 28.15228]),
Object.freeze([85.3251, 28.15145]),
Object.freeze([85.32389, 28.15089]),
Object.freeze([85.32267, 28.15061]),
Object.freeze([85.32147, 28.14978]),
Object.freeze([85.32026, 28.14895]),
Object.freeze([85.31904, 28.1484]),
Object.freeze([85.31783, 28.14784]),
Object.freeze([85.31663, 28.14674]),
Object.freeze([85.31541, 28.14619]),
Object.freeze([85.3142, 28.14536]),
Object.freeze([85.313, 28.14426]),
Object.freeze([85.3124, 28.14317]),
Object.freeze([85.31181, 28.14208]),
Object.freeze([85.31058, 28.14261]),
Object.freeze([85.30936, 28.14232]),
Object.freeze([85.30816, 28.14122]),
Object.freeze([85.30695, 28.14012]),
Object.freeze([85.30575, 28.13903]),
Object.freeze([85.30485, 28.13793]),
Object.freeze([85.30456, 28.13685]),
Object.freeze([85.30366, 28.13575]),
Object.freeze([85.30307, 28.13466]),
Object.freeze([85.30248, 28.13357]),
Object.freeze([85.30127, 28.13274]),
Object.freeze([85.30067, 28.13165]),
Object.freeze([85.30039, 28.13057]),
Object.freeze([85.3001, 28.12948]),
Object.freeze([85.29889, 28.12838]),
Object.freeze([85.29799, 28.12729]),
Object.freeze([85.29709, 28.12647]),
Object.freeze([85.29588, 28.12591]),
Object.freeze([85.29467, 28.12508]),
Object.freeze([85.29347, 28.12398]),
Object.freeze([85.29226, 28.12316]),
Object.freeze([85.29105, 28.12206]),
Object.freeze([85.28985, 28.12096]),
Object.freeze([85.28864, 28.11986]),
Object.freeze([85.28775, 28.11877]),
Object.freeze([85.28653, 28.11821]),
Object.freeze([85.28532, 28.11765]),
Object.freeze([85.28411, 28.11683]),
Object.freeze([85.2829, 28.116]),
Object.freeze([85.28231, 28.11491]),
Object.freeze([85.28172, 28.11382]),
Object.freeze([85.28082, 28.11272]),
Object.freeze([85.27961, 28.11217]),
Object.freeze([85.2787, 28.11134]),
Object.freeze([85.27749, 28.11079]),
Object.freeze([85.27629, 28.10969]),
Object.freeze([85.27508, 28.10859]),
Object.freeze([85.27388, 28.10749]),
Object.freeze([85.27267, 28.10667]),
Object.freeze([85.27177, 28.10584]),
Object.freeze([85.27056, 28.10474]),
Object.freeze([85.26936, 28.10392]),
Object.freeze([85.26815, 28.10309]),
Object.freeze([85.26695, 28.10199]),
Object.freeze([85.26574, 28.10089]),
Object.freeze([85.26454, 28.09979]),
Object.freeze([85.26364, 28.0987]),
Object.freeze([85.26274, 28.09761]),
Object.freeze([85.26153, 28.09678]),
Object.freeze([85.26033, 28.09568]),
Object.freeze([85.25911, 28.09539]),
Object.freeze([85.2579, 28.09511]),
Object.freeze([85.25668, 28.09509]),
Object.freeze([85.25546, 28.09481]),
Object.freeze([85.25425, 28.09398]),
Object.freeze([85.25304, 28.09342]),
Object.freeze([85.25183, 28.09286]),
Object.freeze([85.25061, 28.09285]),
Object.freeze([85.24939, 28.09256]),
Object.freeze([85.24818, 28.09201]),
Object.freeze([85.24696, 28.09199]),
Object.freeze([85.24573, 28.09252]),
Object.freeze([85.24451, 28.0925]),
Object.freeze([85.24328, 28.09248]),
Object.freeze([85.24207, 28.0922]),
Object.freeze([85.24085, 28.09191]),
Object.freeze([85.23963, 28.0919]),
Object.freeze([85.23841, 28.09161]),
Object.freeze([85.2372, 28.09132]),
Object.freeze([85.23598, 28.09104]),
Object.freeze([85.23476, 28.09102]),
Object.freeze([85.23355, 28.09019]),
Object.freeze([85.23234, 28.08964]),
Object.freeze([85.23113, 28.08881]),
Object.freeze([85.22993, 28.08771]),
Object.freeze([85.22872, 28.08688]),
Object.freeze([85.22782, 28.08579]),
Object.freeze([85.22692, 28.08496]),
Object.freeze([85.22571, 28.08414]),
Object.freeze([85.2245, 28.08358]),
Object.freeze([85.22329, 28.08302]),
Object.freeze([85.22208, 28.08219]),
Object.freeze([85.22087, 28.08164]),
Object.freeze([85.21966, 28.08108]),
Object.freeze([85.21876, 28.08026]),
Object.freeze([85.21755, 28.07943]),
Object.freeze([85.21635, 28.07833]),
Object.freeze([85.21514, 28.07723]),
Object.freeze([85.21394, 28.0764]),
Object.freeze([85.21272, 28.07585]),
Object.freeze([85.21151, 28.07529]),
Object.freeze([85.2103, 28.075]),
Object.freeze([85.2094, 28.07391]),
Object.freeze([85.20881, 28.07282]),
Object.freeze([85.20791, 28.07172]),
Object.freeze([85.20762, 28.07063]),
Object.freeze([85.20672, 28.06954]),
Object.freeze([85.20552, 28.06844]),
Object.freeze([85.20523, 28.06735]),
Object.freeze([85.20495, 28.06627]),
Object.freeze([85.20588, 28.0652]),
Object.freeze([85.2062, 28.06412]),
Object.freeze([85.20683, 28.06304]),
Object.freeze([85.20716, 28.06196]),
Object.freeze([85.20717, 28.06088]),
Object.freeze([85.2078, 28.05981]),
Object.freeze([85.20751, 28.05872]),
Object.freeze([85.20814, 28.05765]),
Object.freeze([85.20847, 28.05657]),
Object.freeze([85.20787, 28.05548]),
Object.freeze([85.20759, 28.05439]),
Object.freeze([85.20699, 28.0533]),
Object.freeze([85.2061, 28.0522]),
Object.freeze([85.20489, 28.05138]),
Object.freeze([85.20369, 28.05028]),
Object.freeze([85.2031, 28.04919]),
Object.freeze([85.2022, 28.04809]),
Object.freeze([85.20191, 28.04701]),
Object.freeze([85.20101, 28.04591]),
Object.freeze([85.19981, 28.04508]),
Object.freeze([85.1986, 28.04398]),
Object.freeze([85.19739, 28.04343]),
Object.freeze([85.1965, 28.04233]),
Object.freeze([85.19529, 28.0415]),
Object.freeze([85.19439, 28.04041]),
Object.freeze([85.1938, 28.03932]),
Object.freeze([85.19382, 28.03824]),
Object.freeze([85.19384, 28.03715]),
Object.freeze([85.19355, 28.03607]),
Object.freeze([85.19357, 28.03498]),
Object.freeze([85.19328, 28.0339]),
Object.freeze([85.1936, 28.03282]),
Object.freeze([85.19362, 28.03174]),
Object.freeze([85.19364, 28.03065]),
Object.freeze([85.19305, 28.02956]),
Object.freeze([85.19215, 28.02847]),
Object.freeze([85.19156, 28.02738]),
Object.freeze([85.19097, 28.02629]),
Object.freeze([85.18976, 28.02573]),
Object.freeze([85.18854, 28.02571]),
Object.freeze([85.18732, 28.02515]),
Object.freeze([85.18611, 28.0246]),
Object.freeze([85.18522, 28.0235]),
Object.freeze([85.18493, 28.02242]),
Object.freeze([85.18556, 28.02134]),
Object.freeze([85.1868, 28.02027]),
Object.freeze([85.18742, 28.0192]),
Object.freeze([85.18714, 28.01811]),
Object.freeze([85.18746, 28.01703]),
Object.freeze([85.18656, 28.01594]),
Object.freeze([85.18536, 28.01484]),
Object.freeze([85.18446, 28.01375]),
Object.freeze([85.18418, 28.01266]),
Object.freeze([85.18389, 28.01157]),
Object.freeze([85.1836, 28.01049]),
Object.freeze([85.18362, 28.0094]),
Object.freeze([85.18364, 28.00832]),
Object.freeze([85.18366, 28.00724]),
Object.freeze([85.18368, 28.00615]),
Object.freeze([85.1843, 28.00508]),
Object.freeze([85.18432, 28.004]),
Object.freeze([85.18404, 28.00291]),
Object.freeze([85.18375, 28.00182]),
Object.freeze([85.18438, 28.00075]),
Object.freeze([85.18501, 27.99967]),
Object.freeze([85.18533, 27.9986]),
Object.freeze([85.18596, 27.99752]),
Object.freeze([85.18567, 27.99643]),
Object.freeze([85.18477, 27.99534]),
Object.freeze([85.18357, 27.99424]),
Object.freeze([85.18237, 27.99314]),
Object.freeze([85.18239, 27.99206]),
Object.freeze([85.18302, 27.99098]),
Object.freeze([85.18273, 27.9899]),
Object.freeze([85.18152, 27.98907]),
Object.freeze([85.18062, 27.98824]),
Object.freeze([85.18034, 27.98716]),
Object.freeze([85.18066, 27.98608]),
Object.freeze([85.18037, 27.98499]),
Object.freeze([85.18039, 27.98391]),
Object.freeze([85.18041, 27.98283]),
Object.freeze([85.18043, 27.98174]),
Object.freeze([85.18075, 27.98067]),
Object.freeze([85.18138, 27.97959]),
Object.freeze([85.1814, 27.97851]),
Object.freeze([85.18172, 27.97743]),
Object.freeze([85.18204, 27.97635]),
Object.freeze([85.18236, 27.97527]),
Object.freeze([85.18299, 27.9742]),
Object.freeze([85.18362, 27.97312]),
Object.freeze([85.18425, 27.97205]),
Object.freeze([85.18396, 27.97096]),
Object.freeze([85.18275, 27.9704]),
Object.freeze([85.18154, 27.97012]),
Object.freeze([85.18033, 27.96956]),
Object.freeze([85.17911, 27.96927]),
Object.freeze([85.17821, 27.96818]),
Object.freeze([85.17762, 27.96709]),
Object.freeze([85.17703, 27.966]),
Object.freeze([85.17613, 27.9649]),
Object.freeze([85.17492, 27.96434]),
Object.freeze([85.17371, 27.96406]),
Object.freeze([85.1725, 27.9635]),
Object.freeze([85.17128, 27.96321]),
Object.freeze([85.17008, 27.96238]),
Object.freeze([85.16888, 27.96129]),
Object.freeze([85.16859, 27.9602]),
Object.freeze([85.168, 27.95911]),
Object.freeze([85.16771, 27.95802]),
Object.freeze([85.16682, 27.95693]),
Object.freeze([85.16561, 27.95637]),
Object.freeze([85.1644, 27.95581]),
Object.freeze([85.16318, 27.95552]),
Object.freeze([85.16198, 27.95442]),
Object.freeze([85.1623, 27.95335]),
Object.freeze([85.16232, 27.95226]),
Object.freeze([85.16234, 27.95118]),
Object.freeze([85.16236, 27.9501]),
Object.freeze([85.16238, 27.94902]),
Object.freeze([85.1627, 27.94794]),
Object.freeze([85.16241, 27.94685]),
Object.freeze([85.16152, 27.94576]),
Object.freeze([85.16093, 27.94466]),
Object.freeze([85.16034, 27.94357]),
Object.freeze([85.15974, 27.94248]),
Object.freeze([85.15915, 27.94139]),
Object.freeze([85.15887, 27.9403]),
Object.freeze([85.15797, 27.93921]),
Object.freeze([85.15738, 27.93812]),
Object.freeze([85.15679, 27.93703]),
Object.freeze([85.1562, 27.93594]),
Object.freeze([85.15561, 27.93485]),
Object.freeze([85.15532, 27.93376]),
Object.freeze([85.15473, 27.93267]),
Object.freeze([85.15352, 27.93184]),
Object.freeze([85.15229, 27.93291]),
Object.freeze([85.15105, 27.9337]),
Object.freeze([85.15015, 27.93288]),
Object.freeze([85.14987, 27.93179]),
Object.freeze([85.14988, 27.93071]),
Object.freeze([85.14929, 27.92962]),
Object.freeze([85.14901, 27.92853]),
Object.freeze([85.14994, 27.92746]),
Object.freeze([85.15057, 27.92639]),
Object.freeze([85.15089, 27.92531]),
Object.freeze([85.15121, 27.92423]),
Object.freeze([85.15123, 27.92315]),
Object.freeze([85.15034, 27.92205]),
Object.freeze([85.14943, 27.92177]),
]);
+117
View File
@@ -0,0 +1,117 @@
// Overview pins follow the event pack through Devighat.
// Charaudi remains a separate evidence shot, and none of these observation
// locations extends the mapped flood path.
export const INCIDENT_OVERVIEW_PLACES = Object.freeze(
[
[
'immediate-collapse-viewpoint',
'COLLAPSE VIEWPOINT',
28.3632333,
85.4423639,
2850,
'left',
],
[
'debris-dammed-lake',
'DAMMED LAKE',
28.3346111,
85.4861389,
2750,
'right',
],
[
'second-landslide',
'SECOND LANDSLIDE',
28.3379706,
85.4688823,
2750,
'left',
],
[
'gyirong-border-gate',
'BORDER GATE',
28.2808338,
85.3780248,
1818,
'right',
],
['timure-cluster', 'TIMURE', 28.2519361, 85.3665833, 1689, 'left'],
[
'syabru-besi',
'SYABRU BESI',
28.1602583333333,
85.3325666666667,
1417,
'right',
],
['dhunche', 'DHUNCHE', 28.1131861111111, 85.2953305555556, 1300, 'left'],
[
'mailung-upper-trishuli',
'UPPER TRISHULI',
28.090925,
85.2322916666667,
950,
'right',
],
[
'mailung-bazzar',
'MAILUNG',
28.0761666666667,
85.2125277777778,
800,
'left',
],
[
'dandagaun',
'DANDAGAUN',
28.0751944444444,
85.2076944444444,
780,
'right-lower',
],
[
'dandagaun-viewpoint',
'DANDAGAUN VIEWPOINT',
28.0354,
85.1894361111111,
700,
'right',
],
[
'betrawati-bazaar',
'BETRAWATI',
27.9758992572246,
85.1835331689033,
600,
'left',
],
[
'bhainse',
'BHAINSE',
27.9623111111111,
85.1739777777778,
500,
'right-lower',
],
[
'bidur-trishuli-bridge',
'BIDUR BRIDGE',
27.920858,
85.148029,
510,
'left',
],
[
'devighat-taadi-khola-bridge',
'DEVIGHAT BRIDGE',
27.8585278,
85.1090318,
350,
'right',
],
].map(([id, title, lat, lon, heightM, placement]) =>
Object.freeze({ id, title, lat, lon, heightM, placement }),
),
);
export const INCIDENT_OVERVIEW_HOLD_SEC = 11;
File diff suppressed because it is too large. Load diff
+676
View File
@@ -0,0 +1,676 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
BHOTE_KOSHI_REGIONAL_PRESENTATION,
BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
closeRegionRing,
createBhoteKoshiLocatorLayer,
largestGeoJsonRing,
NEPAL_CONTEXT_PRESENTATION,
revealPolylinePositions,
} from './bhoteKoshiLocator.js';
function viewerFixture() {
const added = [];
const removed = [];
return {
creditDisplay: { addStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
},
_test: { added, removed },
};
}
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
cancelFrame() {},
};
}
function overlayHostFixture() {
const publications = [];
const clears = [];
return {
setEntries(sourceId, entries, options) {
publications.push({ sourceId, entries, options });
},
clearSource(sourceId) {
clears.push(sourceId);
},
_test: { publications, clears },
};
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
});
test('Bhote Koshi locator reveals a border progressively', () => {
const positions = [
new Cesium.Cartesian3(0, 0, 0),
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
]);
assert.equal(revealPolylinePositions(positions, 1), positions);
});
test('Bhote Koshi locator stages its anchored callout from dot to leader to text', () => {
assert.deepEqual(anchoredCalloutPresentation(0), {
alpha: 0,
leaderProgress: 0,
contentAlpha: 0,
scale: 0.96,
});
assert.deepEqual(anchoredCalloutPresentation(1200), {
alpha: 1,
leaderProgress: 1,
contentAlpha: 1,
scale: 1,
});
});
test('Bhote Koshi regional locator sequences Nepal, border, then the incident', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const renders = [];
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
assert.equal(await layer.init(viewer), true);
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
assert.equal(viewer._test.added.length, 1);
const source = viewer._test.added[0];
const ids = source.entities.values.map((entity) => entity.id);
for (const id of [
'nepal-context-halo',
'nepal-border-fill',
'nepal-border-highlight',
'bhote-koshi-incident-halo',
]) {
assert.ok(ids.includes(id), `${id} belongs to the regional sequence`);
}
const incidentHalo = source.entities.getById('bhote-koshi-incident-halo');
const border = source.entities.getById('nepal-border-highlight');
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
assert.equal(incident.variant, 'card');
assert.equal(incident.paintLane, 'thumbnail');
assert.equal(incident.title, 'BHOTE KOSHI FLOOD INCIDENT');
assert.deepEqual(incident.details, ['NEPAL · 26 AUG 2026']);
assert.equal(incident.anchorDot, true);
assert.equal(incident.leaderStyle, 'elbow');
assert.equal('image' in incident, false);
assert.equal('requireImage' in incident, false);
assert.equal(incident.sourceAlpha(), 0);
assert.equal(incident.leaderProgress(), 0);
assert.equal(incident.contentAlpha(), 0);
assert.equal(incident.presentationScale(), 0.96);
assert.equal(nepal.sourceAlpha(), 0);
assert.ok(border.polyline.positions.getValue().length < 5);
assert.equal(incidentHalo.show.getValue(), false);
assert.equal(incidentHalo.point.pixelSize.getValue(), 16);
nowMs = 3100;
assert.equal(nepal.sourceAlpha(), 1);
assert.equal(border.polyline.positions.getValue().length, 5);
assert.equal(incidentHalo.show.getValue(), false);
nowMs = 3300;
assert.equal(incidentHalo.show.getValue(), true);
assert.equal(incident.sourceAlpha(), 0);
nowMs = 4500;
assert.equal(incident.sourceAlpha(), 1);
assert.equal(incident.leaderProgress(), 1);
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(layer.getStats().status, 'idle');
});
test('Bhote Koshi locator switches between Nepal context and the regional incident treatment', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
await layer.destroy();
});
test('Incident Corridor reveals all fifteen overview pins and keeps layout active through the sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(labels.length, 15);
assert.equal(overlayHost._test.publications.at(-1).options.moving, true);
for (const [index, point] of INCIDENT_OVERVIEW_PLACES.entries()) {
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
await layer.destroy();
});
test('Bhote Koshi flood path draws a solid cyan sourced route after the place shot', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
assert.equal(bidur.title, '14 · BIDUR BRIDGE');
for (const place of INCIDENT_OVERVIEW_PLACES) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.sourceAlpha(), 1, place.id);
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
nowMs = 250;
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, 2);
nowMs = 2250;
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi path-overview presentation keeps the route settled without Nepal border', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi trigger-record presentation reveals sourced incident details', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
});
await layer.enable(viewer);
const source = viewer._test.added[0];
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
]);
assert.equal(record.interactive, true);
assert.equal(typeof record.activate, 'function');
assert.equal(record.sourceAlpha(), 0);
nowMs = 1200;
assert.equal(record.sourceAlpha(), 1);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Trigger record');
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.destroy();
});
test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
const flights = [];
let armedCallback = null;
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
scheduleDelay(callback) {
armedCallback = callback;
return 1;
},
cancelDelay() {},
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
assert.equal(flights.length, 0);
assert.equal(typeof armedCallback, 'function');
moveStart.raiseEvent();
assert.equal(flights.length, 0);
moveEnd.raiseEvent();
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
assert.equal(flights[1].duration, 2.8);
const orbit = Cesium.Cartographic.fromCartesian(flights[1].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.latitude) - 28.3769) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(orbit.longitude) - 85.4923) < 1e-6);
assert.ok(Math.abs(orbit.height - 10526) < 1e-4);
await layer.destroy();
});
for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
test(`Scene Stop revokes locator ${phase} and stale callbacks cannot own a successor`, async () => {
const viewer = viewerFixture();
const timers = new Map();
const callbacks = [];
const flights = [];
let nextTimer = 0;
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
overlayHost: overlayHostFixture(),
requestRender() {},
scheduleDelay(callback) {
callbacks.push(callback);
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
if (phase === 'orbit') flights[0].complete();
const oldFlights = [...flights];
SceneDirector.prototype.stopScene.call(director);
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
assert.equal(moveStart.numberOfListeners, 0);
assert.equal(moveEnd.numberOfListeners, 0);
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
});
}
test('Bhote Koshi locator keeps the regional context settled while nearby cities stagger in', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let nowMs = 0;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
}),
requestRender() {},
overlayHost,
now: () => nowMs,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
nowMs = 350;
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
nowMs = 850;
assert.equal(kathmandu.sourceAlpha(), 1);
assert.ok(bhaktapur.sourceAlpha() > 0);
assert.ok(dhulikhel.sourceAlpha() > 0);
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
await layer.destroy();
});
test('Bhote Koshi locator degrades to labels when the regional boundary is unavailable', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const originalWarn = console.warn;
console.warn = () => {};
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
await layer.destroy();
}
});
test('Bhote Koshi regional locator waits for its boundary before starting the full sequence', async () => {
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
overlayHost,
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
await layer.destroy();
});
test('Bhote Koshi locator cancels a stale boundary resolution and removes its surface', async () => {
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
...nonRunningAnimation(),
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+97
View File
@@ -0,0 +1,97 @@
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
export const SOURCE_PATH_BEAT_IDS = Object.freeze([
'debris-dammed-lake',
'second-landslide',
'dhunche',
'mailung-upper-trishuli',
'mailung-bazzar',
'dandagaun',
'dandagaun-viewpoint',
'betrawati-bazaar',
'bhainse',
'bidur-trishuli-bridge',
]);
function nearestVertex(point, route) {
if (!point || !Number.isFinite(point.lon) || !Number.isFinite(point.lat))
return -1;
const longitudeScale = Math.cos((point.lat * Math.PI) / 180);
let nearest = -1;
let distance = 1000; // Witness viewpoints can sit beside the river, not on it.
route.forEach(([lon, lat], index) => {
const metres =
Math.hypot((lon - point.lon) * longitudeScale, lat - point.lat) * 111195;
if (metres < distance) {
distance = metres;
nearest = index;
}
});
return nearest;
}
/** Split the already-bundled GeoPera centreline; never connect witness pins. */
export function buildSourceShotPaths(event, route = BHOTE_KOSHI_FLOOD_PATH) {
const observations = new Map(
(event.evidenceSpine || []).map((point) => [point.id, point]),
);
const result = new Map();
const add = (id, from, to, { joinComparison = false } = {}) => {
let start = nearestVertex(from, route);
const end = nearestVertex(to, route);
if (start < 0 || end <= start) return;
const startHeight = from.fallbackElevationM ?? from.elevationM;
const endHeight = to.fallbackElevationM ?? to.elevationM;
if (!Number.isFinite(startHeight) || !Number.isFinite(endHeight)) return;
if (joinComparison) {
// The raster corridor ends between simplified centreline vertices.
// Snapping to its nearest vertex can move the head upstream on entry.
// Keep that already-sourced endpoint and continue to the next vertex
// downstream of it, rather than replaying the upstream half-segment.
const [lon, lat] = route[start];
const [nextLon, nextLat] = route[start + 1];
const scale = Math.cos((from.lat * Math.PI) / 180);
const along =
(from.lon - lon) * (nextLon - lon) * scale * scale +
(from.lat - lat) * (nextLat - lat);
if (along > 0) start++;
}
const coordinates = route.slice(start, end + 1);
if (
joinComparison &&
(coordinates[0][0] !== from.lon || coordinates[0][1] !== from.lat)
) {
coordinates.unshift([from.lon, from.lat]);
}
result.set(
id,
coordinates.map(([lon, lat], index) => ({
lon,
lat,
// Stable source profile for explicit source-elevation shots; otherwise a
// fallback when neither rendered mesh nor elevation terrain can be sampled.
fallbackElevationM:
startHeight +
((endHeight - startHeight) * index) / (coordinates.length - 1),
})),
);
};
add(
'debris-dammed-lake',
observations.get('debris-dammed-lake'),
observations.get('second-landslide'),
);
add(
'second-landslide',
observations.get('second-landslide'),
observations.get('gyirong-border-gate'),
);
let previous = event.reconstruction?.corridor?.at(-1);
for (const id of SOURCE_PATH_BEAT_IDS.slice(2)) {
const point = observations.get(id);
add(id, previous, point, { joinComparison: id === 'dhunche' });
// Missing coverage breaks continuity rather than inventing a long connector.
previous = point;
}
return result;
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { buildSourceShotPaths, SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url)));
test('overview and evidence paths start at the lake reach, never the earlier trigger reach', () => {
const lakeStart = [85.48476, 28.33255];
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], lakeStart);
assert.equal(BHOTE_KOSHI_FLOOD_PATH.some(([lon, lat]) => lon === 85.5306 && lat === 28.2916), false);
const paths = buildSourceShotPaths(event);
const first = paths.get('debris-dammed-lake')[0];
assert.deepEqual([first.lon, first.lat], lakeStart);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.slice(0, paths.get('debris-dammed-lake').length),
paths.get('debris-dammed-lake').map(({lon, lat}) => [lon, lat]));
});
test('ten shot paths use only the existing centreline and join at shared endpoints', () => {
const paths = buildSourceShotPaths(event);
assert.deepEqual([...paths.keys()], SOURCE_PATH_BEAT_IDS);
const vertices = new Set(BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => `${lon},${lat}`));
const boundary = event.reconstruction.corridor.at(-1);
vertices.add(`${boundary.lon},${boundary.lat}`); // Exact sourced comparison handoff.
for (const points of paths.values()) {
assert.ok(points.length >= 2);
assert.ok(points.every(({ lon, lat, fallbackElevationM }) => (
vertices.has(`${lon},${lat}`) && Number.isFinite(fallbackElevationM)
)));
}
const coordinate = (point) => [point.lon, point.lat];
for (const ids of [SOURCE_PATH_BEAT_IDS.slice(0, 2), SOURCE_PATH_BEAT_IDS.slice(2)]) {
for (let index = 1; index < ids.length; index++) {
assert.deepEqual(coordinate(paths.get(ids[index - 1]).at(-1)), coordinate(paths.get(ids[index])[0]));
}
}
assert.deepEqual(coordinate(paths.get('bidur-trishuli-bridge').at(-1)), BHOTE_KOSHI_FLOOD_PATH.at(-1));
for (const id of ['immediate-collapse-viewpoint', 'devighat-taadi-khola-bridge', 'charaudi']) {
assert.equal(paths.has(id), false, 'out-of-coverage media must not gain a fabricated river route');
}
});
test('missing and distant observations fail closed without connecting witness coordinates', () => {
const broken = structuredClone(event);
broken.evidenceSpine.find(({ id }) => id === 'dhunche').lon = 0;
const paths = buildSourceShotPaths(broken);
assert.equal(paths.has('dhunche'), false);
assert.equal(paths.has('mailung-upper-trishuli'), false);
assert.equal(buildSourceShotPaths({}).size, 0);
});
test('Dhunche starts at the exact comparison endpoint without stepping upstream first', () => {
const boundary = event.reconstruction.corridor.at(-1);
const path = buildSourceShotPaths(event).get('dhunche');
assert.deepEqual([path[0].lon, path[0].lat], [boundary.lon, boundary.lat]);
assert.ok(path[1].lat < boundary.lat, 'the first new vertex is downstream of the comparison endpoint');
});
+10 -8
View File
@@ -1,13 +1,15 @@
import { createApplicationBikeshare } from '../app/layers/bikeshare.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js';
import { createBikeshareLayer } from '../layers/bikeshare/index.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as overlays from '../overlays/worldOverlay.js';
const layer = createBikeshareLayer({
source: createBikeshareSource(),
services: { render, sprites, picking, overlays },
const sourceSlot = createSourceSlot(
createBikeshareSource(),
['getStations'],
'Bikeshare source',
);
export const configureBikeshareSource = sourceSlot.configure;
const layer = createApplicationBikeshare({
source: sourceSlot.source,
});
export const createBikeshareSelectedOverlayEntry =
layer.createBikeshareSelectedOverlayEntry;
+93
View File
@@ -0,0 +1,93 @@
// src/data/cameraSensitivity.js
/**
* Shared claims on `camera.percentageChanged`.
*
* `camera.changed` fires once the view has moved by this fraction, and it is a
* single number on the camera that every listener in the app shares. Several
* layers want it finer than the default — traffic, bikeshare and transit all
* ask for 0.05 so they notice they have entered a coverage area — and each of
* them used to write the number directly and hand back whatever it remembered.
*
* That cannot work, because a number is not an owner. Two layers asking for the
* same 0.05 write the same value, so when the first one leaves it sees its own
* number still in place, concludes nothing else has claimed it, and restores
* the coarse default out from under a layer that is still running. The symptom
* is a camera-driven layer that quietly stops noticing the camera: Transit is
* turned off and Bikeshare stops loading new cities until something else nudges
* it.
*
* So ownership is tracked explicitly. Claims are named, the camera gets the
* finest value anyone has asked for, and the original value returns only when
* the last claim is gone. Claiming twice under one name replaces that claim.
*
* This is bookkeeping, not policy: it never decides what a layer should ask
* for, only that nobody's answer is taken away while they still need it.
*/
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
const _cameras = new WeakMap();
/** The value Cesium starts a camera at, used when the camera reports nothing. */
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
function ledgerFor(camera) {
let ledger = _cameras.get(camera);
if (!ledger) {
const base = Number.isFinite(camera.percentageChanged)
? camera.percentageChanged
: DEFAULT_PERCENTAGE_CHANGED;
ledger = { base, claims: new Map() };
_cameras.set(camera, ledger);
}
return ledger;
}
function applyLedger(camera, ledger) {
let value = ledger.base;
for (const claimed of ledger.claims.values()) {
if (claimed < value) value = claimed;
}
camera.percentageChanged = value;
return value;
}
/**
* Ask for a camera-change sensitivity at least as fine as `value`.
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`).
* @param {string} owner Stable claim name, usually the layer id.
* @param {number} value Requested `percentageChanged`.
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function claimCameraSensitivity(camera, owner, value) {
if (!camera || !owner || !Number.isFinite(value)) return null;
const ledger = ledgerFor(camera);
ledger.claims.set(owner, value);
return applyLedger(camera, ledger);
}
/**
* Give up one claim. The camera keeps the finest value still claimed by
* somebody else, and returns to its original only when none are left.
* @param {object|null|undefined} camera
* @param {string} owner
* @returns {number|null} The value now in force, or null when there is no camera.
*/
export function releaseCameraSensitivity(camera, owner) {
if (!camera || !owner) return null;
const ledger = _cameras.get(camera);
if (!ledger) return null;
if (!ledger.claims.delete(owner)) return camera.percentageChanged;
const applied = applyLedger(camera, ledger);
if (ledger.claims.size === 0) _cameras.delete(camera);
return applied;
}
/**
* Who currently holds a claim. For tests and diagnostics only.
* @param {object|null|undefined} camera
* @returns {Array<string>}
*/
export function cameraSensitivityClaims(camera) {
const ledger = camera ? _cameras.get(camera) : null;
return ledger ? [...ledger.claims.keys()] : [];
}
+13 -25
View File
@@ -1,32 +1,20 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationCctv } from '../app/layers/cctv.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js';
import { createCctvLayer } from '../layers/cctv/index.js';
import * as sprites from './spriteOrder.js';
import * as activation from '../cctvFocusRequest.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as locations from '../locations.js';
import * as picking from './pickRegistry.js';
import * as terrain from './terrainHeights.js';
import * as ground from './groundFloor.js';
import * as mesh from './meshFloorSampler.js';
import * as focus from './focusDeemphasis.js';
import * as render from '../renderGovernor.js';
const layer = createCctvLayer({
source: createCctvSource(),
services: {
sprites,
activation,
overlays,
locations,
picking,
terrain,
ground,
mesh,
focus,
render,
},
const sourceSlot = createSourceSlot(
createCctvSource(),
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
'Cctv source',
);
export const configureCctvSource = sourceSlot.configure;
const layer = createApplicationCctv({
surface: defaultSurface,
source: sourceSlot.source,
});
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
export const migrateRangeScaleForFloor = layer.migrateRangeScaleForFloor;
export const _pushAmbientCardEntriesForTest =
layer._pushAmbientCardEntriesForTest;
export const _setCctvOverlayHostForTest = layer._setCctvOverlayHostForTest;
+697 -185
View File
File diff suppressed because it is too large. Load diff
+389
View File
@@ -0,0 +1,389 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
calgaryCameraId,
calgaryCameraName,
calgaryCameraToSource,
loadCalgarySourcesFromOpenData,
normalizeCalgaryImageUrl,
} from '../../server/providers/cctv/sources.js';
import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { directionToHeading } from './directionText.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Open Calgary row, shaped like the live `k7p9-kppz` payload. */
const row = (overrides = {}) => ({
camera_url: {
url: 'http://trafficcam.calgary.ca/loc142.jpg',
description: 'Camera 143',
},
quadrant: 'SW',
camera_location: 'Bow Trail / 37 Street SW',
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
...overrides,
});
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
const source = calgaryCameraToSource(row());
assert.equal(source.id, 'calgary-142');
assert.equal(source.name, 'Bow Trail / 37 Street SW');
assert.equal(source.cityId, 'calgary');
assert.equal(source.provider, 'The City of Calgary');
assert.equal(source.lat, 51.0453097);
assert.equal(source.lon, -114.1413616);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'calgary-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – City of Calgary',
);
// The catalog publishes http://; the registered URL is the upgraded one.
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
});
test('no heading is derived from the quadrant or the address suffix', () => {
// Both fields parse as a confident compass bearing, and both would be wrong
// for every camera in the city: they are Calgary's address grid, not a
// camera facing. Nobody may "fix" this by wiring either one up.
assert.equal(directionToHeading('SW', true), 225);
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
const source = calgaryCameraToSource(row({ quadrant }));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the quadrant says.
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
}
});
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
// Toronto: a plausible lat/lon, but not a Calgary camera.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
),
null,
);
// Null island, and the string coordinates that would become it.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [0, 0] } }),
),
null,
);
assert.equal(calgaryCameraToSource(row({ point: null })), null);
assert.equal(
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
null,
);
// A catalog edit cannot steer the frame proxy off the city host.
assert.equal(
calgaryCameraToSource(
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
),
null,
);
assert.equal(
calgaryCameraToSource(
row({
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
}),
),
null,
);
assert.equal(
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
null,
);
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
assert.equal(calgaryCameraToSource(null), null);
});
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
assert.equal(
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(normalizeCalgaryImageUrl(''), null);
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
'calgary-86',
);
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
'calgary-cams-deerfoot-16av',
);
assert.equal(calgaryCameraId(''), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
calgaryCameraName(
{ camera_location: ' 9 Avenue / 3 Street SE ' },
'calgary-1',
),
'9 Avenue / 3 Street SE',
);
assert.equal(
calgaryCameraName(
{ camera_url: { description: 'Camera 12' } },
'calgary-11',
),
'Camera 12',
);
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
row(),
// Same frame file twice: one camera, not two.
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
]);
});
const cameras = await loadCalgarySourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
['calgary-142', 'calgary-86'],
);
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
const long = calgaryCameraToSource(
row({
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/rows.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['nsw', 10],
['calgary', 10],
],
);
// Calgary contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
);
});
/**
* Serve the Calgary catalog to the Calgary endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith('https://data.calgary.ca/')) {
return Response.json([
row(),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
]);
}
return Response.json([]);
});
// A source root with no curated catalogs or ground-height sidecar, so the
// file-based packs contribute nothing and only the live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Calgary lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_CALGARY_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
'the catalog refresh invokes the Calgary loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
['calgary-142', 'calgary-86'],
'Calgary cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_CALGARY_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/
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);
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;
}
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
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));
.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 = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
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 (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
@@ -165,15 +185,26 @@ 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 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 };
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);
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @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 } = {}) {
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,
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,
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
@@ -230,7 +267,12 @@ export function createFrameSlot() {
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: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
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));
.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);
}
+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
allocateSourceCap,
resolveCatalogCap,
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({
id: `${prefix}-${i}`,
rank: i,
})),
});
test('under the cap every pack is served whole, in merge order', () => {
const { sources, packs } = allocateSourceCap(
[pack('a', 3), pack('b', 2)],
10,
);
assert.deepEqual(
sources.map((s) => s.id),
['a-0', 'a-1', 'a-2', 'b-0', 'b-1'],
);
assert.deepEqual(packs, [
{ name: 'a', offered: 3, kept: 3 },
{ name: 'b', offered: 2, kept: 2 },
]);
});
test('over the cap the packs share it round-robin, and the last pack is never starved', () => {
// The old positional slice kept the first N of [a..., b..., c...], which
// deleted pack c entirely; the fair split thins every pack instead.
const { sources, packs } = allocateSourceCap(
[pack('a', 100), pack('b', 100), pack('c', 100)],
9,
);
assert.equal(sources.length, 9);
assert.deepEqual(
packs.map((p) => p.kept),
[3, 3, 3],
);
// Each pack contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('c-')).map((s) => s.rank),
[0, 1, 2],
);
});
test('a duplicate inside one pack takes one slot, not two', () => {
const dup = {
name: 'file',
sources: [{ id: 'x', v: 'old' }, { id: 'x', v: 'new' }, { id: 'y' }],
};
const { sources } = allocateSourceCap([dup], 2);
assert.deepEqual(
sources.map((s) => [s.id, s.v]),
[
['x', 'new'],
['y', undefined],
],
);
});
test('a short pack releases its turns to the others', () => {
const { packs } = allocateSourceCap([pack('a', 2), pack('b', 50)], 10);
assert.deepEqual(
packs.map((p) => p.kept),
[2, 8],
);
});
test('duplicate ids resolve last-pack-wins and count against the winning pack', () => {
const live = {
name: 'live',
sources: [{ id: 'cam-1', label: 'live' }, { id: 'cam-2' }],
};
const override = {
name: 'env',
sources: [{ id: 'cam-1', label: 'override' }],
};
const { sources, packs } = allocateSourceCap([live, override], 10);
assert.deepEqual(
sources.map((s) => [s.id, s.label]),
[
['cam-2', undefined],
['cam-1', 'override'],
],
);
assert.deepEqual(packs, [
{ name: 'live', offered: 1, kept: 1 },
{ name: 'env', offered: 1, kept: 1 },
]);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('1'), 8);
assert.equal(resolveCatalogCap('999999'), CCTV_MAX_SOURCES_CEILING);
assert.equal(resolveCatalogCap('120.9'), 120);
});
test('the catalog applies the fair cap to configured packs end to end', async () => {
const saved = { ...process.env };
try {
process.env.CCTV_PREFER_AUSTIN = '0';
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
process.env.CCTV_SOURCES_JSON = JSON.stringify(
Array.from({ length: 12 }, (_, i) => ({
id: `env-${i}`,
name: `Env ${i}`,
lat: 30 + i * 0.01,
lon: -97,
url: `https://example.test/${i}.jpg`,
})),
);
process.env.CCTV_MAX_SOURCES = '9';
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
const sources = await getSources();
assert.equal(sources.length, 9);
assert.deepEqual(
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CAMERA_CODE_MAX_CHARS,
cameraDisplayCode,
normalizeSourceItem,
} from '../../server/providers/cctv/normalize.js';
test('cameraDisplayCode trims long names with an ellipsis and collapses whitespace', () => {
assert.equal(
cameraDisplayCode(' IH 35 at Cesar Chavez '),
'IH 35 at Cesar Chavez',
);
const long = 'City of Windsor – Goyeau Street and Park Street';
const code = cameraDisplayCode(long);
assert.equal(code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(code.endsWith('…'));
assert.equal(cameraDisplayCode(''), '');
});
test('every pack labels by its feed name; explicit codes win; the id is the last resort', () => {
const base = { lat: 1, lon: 1, url: 'https://x.test/a.jpg' };
assert.equal(
normalizeSourceItem({
...base,
id: '354',
name: '5TH ST / CONGRESS AVE',
sourceKind: 'austin-open-data',
}).code,
'5TH ST / CONGRESS AVE',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'on-860',
name: 'Goyeau Street and Park Street',
sourceKind: 'ontario-511-open-data',
}).code,
'GOYEAU STREET AND PARK STRE…',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'nsw-46fa631a',
name: 'Erskine Street looking west',
sourceKind: 'nsw-livetraffic',
code: 'ERSKINE ST (SYDNEY)',
}).code,
'ERSKINE ST (SYDNEY)',
);
assert.equal(
normalizeSourceItem({
...base,
id: 'fi-c1455101',
name: '',
sourceKind: 'fintraffic-open-data',
}).code,
'fi-c1455101',
'a missing name falls back to the id',
);
});
+272
View File
@@ -0,0 +1,272 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_AUSTIN_MAX_SOURCES,
DEFAULT_CALTRANS_MAX_SOURCES,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_MAX_SOURCES_CEILING,
DEFAULT_DRIVEBC_MAX_SOURCES,
DEFAULT_TFL_MAX_SOURCES,
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
DRIVEBC_WEBCAMS_URL,
} from '../../server/providers/cctv/constants.js';
import {
driveBcImageCredit,
loadDriveBcSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** A DriveBC `/api/webcams/` row carrying the fields the loader reads. */
function driveBcRow(id, lat, lon, overrides = {}) {
return {
id,
name: `Camera ${id}`,
is_on: true,
should_appear: true,
region_name: 'Lower Mainland',
orientation: 'N',
elevation: 12,
location: { type: 'Point', coordinates: [lon, lat] },
links: { imageDisplay: `/images/${id}.jpg?t=1` },
...overrides,
};
}
test('DriveBC loader keeps published cameras and builds frame URLs from the camera id', async (t) => {
quiet(t);
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
driveBcRow(13, 49.001268, -122.756658, {
name: 'Douglas (Peace Arch) Border Crossing',
region_name: 'Border Cams',
elevation: 10,
// The payload's own link is ignored; frames come from the pinned host.
links: { imageDisplay: 'https://example.com/13.jpg' },
}),
driveBcRow(682, 49.0579, -123.0569, {
orientation: 'se',
elevation: null,
credit:
'Images courtesy of <a href="https://www.translink.ca/" target="_blank">TransLink</a>',
}),
driveBcRow(1, 49.2, -123.1, { is_on: false }),
driveBcRow(2, 49.2, -123.1, { should_appear: false }),
driveBcRow('3', 49.2, -123.1),
driveBcRow(4, null, -123.1),
driveBcRow(5, 49.2, -123.1, { location: null }),
driveBcRow(6, 1000, -123.1),
driveBcRow(7, 34.05, -118.24),
]);
});
const cameras = await loadDriveBcSourcesFromOpenData();
assert.deepEqual(requested, [DRIVEBC_WEBCAMS_URL]);
// Nearest to the Vancouver anchor first: the pack is always distance-sorted
// so the catalog-wide cap thins it from the far end.
assert.deepEqual(
cameras.map((camera) => camera.id),
['drivebc-682', 'drivebc-13'],
);
const border = cameras.find((camera) => camera.id === 'drivebc-13');
const deltaport = cameras.find((camera) => camera.id === 'drivebc-682');
assert.equal(border.url, 'https://www.drivebc.ca/images/13.jpg');
assert.equal(border.snapshotUrl, border.url);
assert.equal(border.name, 'Douglas (Peace Arch) Border Crossing');
assert.equal(border.city, 'BC Border');
assert.equal(border.provider, 'DriveBC');
assert.equal(border.feedType, 'image');
assert.equal(border.headingDeg, 0);
assert.equal(border.headingConfidence, 'high');
assert.equal(border.groundElevationM, 10);
assert.match(border.license, /Open Government Licence – British Columbia/);
assert.equal(
border.credit,
'',
'a provincial camera carries no partner credit',
);
assert.equal(
deltaport.credit,
'Images courtesy of TransLink',
'a partner-supplied camera names its owner, HTML stripped',
);
assert.equal(deltaport.city, 'Lower Mainland');
assert.equal(
deltaport.headingDeg,
135,
'orientation codes are case-insensitive',
);
assert.equal(
deltaport.groundElevationM,
0,
'a missing elevation falls back to sea level',
);
});
test('DriveBC loader keeps the cameras nearest Vancouver and Victoria under its cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_DRIVEBC_MAX_SOURCES: '8' });
const rows = [
// Fort Nelson rows come first in payload order, so only distance can drop them.
...[58.8, 58.81, 58.82, 58.83].map((lat, i) =>
driveBcRow(100 + i, lat, -122.7),
),
...[49.28, 49.27, 49.26, 49.25].map((lat, i) =>
driveBcRow(200 + i, lat, -123.12),
),
...[48.43, 48.44, 48.45, 48.46].map((lat, i) =>
driveBcRow(300 + i, lat, -123.37),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadDriveBcSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras
.map((camera) => camera.id)
.filter((id) => /^drivebc-10\d$/.test(id)),
[],
'the northern cameras are the ones dropped',
);
});
test('DriveBC loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ webcams: [] }),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadDriveBcSourcesFromOpenData(), []);
}
});
test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_DRIVEBC_ENABLED: undefined,
CCTV_DRIVEBC_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only DriveBC answers.
return String(url) === DRIVEBC_WEBCAMS_URL
? Response.json([driveBcRow(13, 49.001268, -122.756658)])
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['drivebc-13'],
);
assert.equal(sources[0].url, 'https://www.drivebc.ca/images/13.jpg');
process.env.CCTV_DRIVEBC_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(DRIVEBC_WEBCAMS_URL),
false,
'a disabled pack makes no request',
);
});
test('default per-pack camera caps fit inside the default catalog cap', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
DEFAULT_CALTRANS_MAX_SOURCES +
DEFAULT_TFL_MAX_SOURCES +
DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES +
1;
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
);
assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
'the default cap must sit inside the CCTV_MAX_SOURCES ceiling',
);
});
test('DriveBC credit keeps partner attribution and drops operational notes', () => {
assert.equal(
driveBcImageCredit(
'Images courtesy of <a href="https://x.test/">TransLink</a>',
),
'Images courtesy of TransLink',
);
assert.equal(
driveBcImageCredit('Camera image provided by City of Vancouver'),
'Camera image provided by City of Vancouver',
);
assert.equal(
driveBcImageCredit(
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
),
'BC HighwayCam presented in cooperation with AtkinsRéalis.',
);
assert.equal(
driveBcImageCredit(
'This camera is located in a remote area and relies on solar power. As a result, transmission delays may occur.',
),
'',
);
assert.equal(driveBcImageCredit(null), '');
});
+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTallinnSourcesFromCatalog,
parseTarkteeDatexImages,
parseTarkteeDatexLocations,
} from '../../server/providers/cctv/sources.js';
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
<predefinedLocation id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0">
<predefinedLocationName>
<values><value lang="et">Test Site</value></values>
</predefinedLocationName>
<location>
<pointByCoordinates>
<pointCoordinates>
<latitude>59.4370</latitude>
<longitude>24.7530</longitude>
</pointCoordinates>
</pointByCoordinates>
</location>
</predefinedLocation>
</predefinedLocationContainer>
</payloadPublication>
</d2LogicalModel>`;
const SAMPLE_IMAGES_XML = `<?xml version="1.0" encoding="UTF-8"?>
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
<payloadPublication>
<trafficView id="1">
<linearTrafficView id="1">
<linearPredefinedLocationReference targetClass="PredefinedLocation" id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0"/>
<trafficViewRecord id="1-1">
<urlLink>
<urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg</urlLinkAddress>
<urlLinkType>image</urlLinkType>
</urlLink>
</trafficViewRecord>
</linearTrafficView>
</trafficView>
</payloadPublication>
</d2LogicalModel>`;
test('Tallinn catalog loads curated ristmikud stills only', () => {
const cameras = loadTallinnSourcesFromCatalog();
assert.ok(
cameras.length >= 200,
`expected a full Tallinn pack, got ${cameras.length}`,
);
for (const camera of cameras.slice(0, 25)) {
assert.match(camera.id, /^tln-/);
assert.equal(camera.cityId, 'tallinn');
assert.equal(camera.feedType, 'image');
assert.equal(camera.sourceKind, 'tallinn-ristmikud');
assert.ok(camera.url.startsWith('https://ristmikud.tallinn.ee/'));
assert.equal(camera.url, camera.snapshotUrl);
assert.ok(Number.isFinite(camera.lat) && Number.isFinite(camera.lon));
}
});
test('Tarktee DATEX parsers join location UUIDs to image URLs', () => {
const locations = parseTarkteeDatexLocations(SAMPLE_LOCATIONS_XML);
const images = parseTarkteeDatexImages(SAMPLE_IMAGES_XML);
assert.equal(locations.size, 1);
assert.equal(images.size, 1);
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
assert.equal(locations.get(id)?.name, 'Test Site');
assert.equal(locations.get(id)?.lat, 59.437);
assert.equal(locations.get(id)?.lon, 24.753);
assert.equal(
images.get(id),
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
);
});
test('Tarktee DATEX image parser rejects non-official hosts', () => {
const xml = SAMPLE_IMAGES_XML.replace(
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
'https://evil.example/images/42/42.jpg',
);
const images = parseTarkteeDatexImages(xml);
assert.equal(images.size, 0);
});
+256
View File
@@ -0,0 +1,256 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadFintrafficSourcesFromOpenData } from '../../server/providers/cctv/sources.js';
import {
fintrafficCameraName,
isLikelyFinlandCoordinate,
} from '../../server/providers/cctv/normalize.js';
import {
FINTRAFFIC_GROUND_ELEVATION_M,
FINTRAFFIC_IMAGE_ORIGIN,
FINTRAFFIC_STATIONS_URL,
} from '../../server/providers/cctv/constants.js';
/** One station feature in the shape tie.digitraffic.fi actually returns. */
function station(
id,
{
name = `vt4_${id}`,
status = 'GATHERING',
lon = 24.9384,
lat = 60.1699,
altitude = 0,
presets = [],
} = {},
) {
return {
type: 'Feature',
id,
geometry: { type: 'Point', coordinates: [lon, lat, altitude] },
properties: {
id,
name,
collectionStatus: status,
dataUpdatedTime: '2026-09-13T03:25:36Z',
presets,
state: null,
},
};
}
/** Run the loader against a canned station list, restoring global fetch after. */
async function loadWith(features, { env = {}, capture = {} } = {}) {
const originalFetch = globalThis.fetch;
const originalEnv = {};
for (const [key, value] of Object.entries(env)) {
originalEnv[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
globalThis.fetch = async (url, options) => {
capture.url = String(url);
capture.options = options;
return new Response(
JSON.stringify({
type: 'FeatureCollection',
dataUpdatedTime: '2026-09-13T05:31:58Z',
features,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
};
try {
return await loadFintrafficSourcesFromOpenData();
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
test('Fintraffic loader turns every in-collection preset into one camera', async () => {
const cameras = await loadWith([
station('C01503', {
name: 'kt51_Inkoo',
lon: 23.99616,
lat: 60.05374,
presets: [
{ id: 'C0150301', inCollection: true },
{ id: 'C0150302', inCollection: true },
],
}),
]);
assert.equal(cameras.length, 2);
const [first, second] = cameras;
assert.equal(first.id, 'fi-c0150301');
assert.equal(first.name, 'kt51 Inkoo (view 01)');
assert.equal(second.name, 'kt51 Inkoo (view 02)');
assert.equal(first.provider, 'Fintraffic');
assert.equal(first.city, 'Finland');
assert.equal(first.cityId, 'finland');
assert.equal(first.sourceKind, 'fintraffic-open-data');
assert.equal(first.license, 'Fintraffic / digitraffic.fi (CC BY 4.0)');
assert.equal(first.feedType, 'image');
// Presets of one station share the station position...
assert.equal(first.lat, 60.05374);
assert.equal(second.lat, first.lat);
assert.equal(second.lon, first.lon);
// ...so the id-hash fallback heading is what keeps their gizmos apart.
assert.notEqual(first.headingDeg, second.headingDeg);
assert.equal(first.headingConfidence, 'low');
});
test('Fintraffic frame URLs are built on the official image origin', async () => {
const cameras = await loadWith([
station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] }),
]);
assert.equal(cameras[0].url, `${FINTRAFFIC_IMAGE_ORIGIN}C0150301.jpg`);
assert.equal(cameras[0].snapshotUrl, cameras[0].url);
assert.ok(cameras[0].url.startsWith('https://weathercam.digitraffic.fi/'));
});
test('Fintraffic loader drops dead stations, uncollected presets and bad ids', async () => {
const cameras = await loadWith([
// Station off collection: every preset goes, live or not.
station('C01601', {
status: 'REMOVED_TEMPORARILY',
presets: [{ id: 'C0160101', inCollection: true }],
}),
station('C01503', {
presets: [
{ id: 'C0150301', inCollection: true },
{ id: 'C0150302', inCollection: false },
{ id: 'C0150303' },
// Ids that would escape the synthesized frame path, or belong to
// another station, never become a camera.
{ id: '../../etc/passwd', inCollection: true },
{ id: 'C9999901', inCollection: true },
],
}),
// Coordinates nowhere near Finland (swapped lat/lon).
station('C01504', {
lon: 60.1699,
lat: 24.9384,
presets: [{ id: 'C0150401', inCollection: true }],
}),
]);
assert.deepEqual(
cameras.map((camera) => camera.id),
['fi-c0150301'],
);
});
test('Fintraffic ground elevation uses a reported altitude and falls back otherwise', async () => {
const cameras = await loadWith([
station('C08508', {
altitude: 136,
presets: [{ id: 'C0850801', inCollection: true }],
}),
station('C01503', {
altitude: 0,
presets: [{ id: 'C0150301', inCollection: true }],
}),
station('C03506', {
altitude: 99_000,
presets: [{ id: 'C0350601', inCollection: true }],
}),
]);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
assert.equal(byId.get('fi-c0850801').groundElevationM, 136);
// 0 is "not reported", not sea level.
assert.equal(
byId.get('fi-c0150301').groundElevationM,
FINTRAFFIC_GROUND_ELEVATION_M,
);
// A garbage upstream value cannot fling a camera kilometres up.
assert.equal(byId.get('fi-c0350601').groundElevationM, 1400);
});
test('Fintraffic loader identifies itself to Digitraffic and refuses redirects', async () => {
const capture = {};
await loadWith(
[station('C01503', { presets: [{ id: 'C0150301', inCollection: true }] })],
{ capture },
);
assert.equal(capture.url, FINTRAFFIC_STATIONS_URL);
assert.equal(capture.options.headers['Digitraffic-User'], 'gods-eye-view');
assert.equal(capture.options.headers['Accept-Encoding'], 'gzip');
assert.equal(capture.options.redirect, 'manual');
assert.ok(capture.options.signal instanceof AbortSignal);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(null, {
status: 302,
headers: { Location: 'https://example.com/stations' },
});
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('Fintraffic pack caps itself and fails to an empty list, never a throw', async () => {
const presets = Array.from({ length: 40 }, (_, index) => ({
id: `C01503${String(index).padStart(2, '0')}`,
inCollection: true,
}));
const capped = await loadWith([station('C01503', { presets })], {
env: { CCTV_FINTRAFFIC_MAX_SOURCES: '8' },
});
assert.equal(capped.length, 8);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error('network down');
};
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
globalThis.fetch = async () => new Response('nope', { status: 503 });
try {
assert.deepEqual(await loadFintrafficSourcesFromOpenData(), []);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fintrafficCameraName unpacks the machine station name and the view number', () => {
assert.equal(
fintrafficCameraName('vt3_Hyvinkää_Noppo', 'C01234', 'C0123402'),
'vt3 Hyvinkää Noppo (view 02)',
);
// Nameless station still gets a usable label.
assert.equal(
fintrafficCameraName('', 'C01234', 'C0123409'),
'Fintraffic C01234 (view 09)',
);
// A preset id that is just the station id has no view suffix to add.
assert.equal(
fintrafficCameraName('vt4_Mäntsälä', 'C01234', 'C01234'),
'vt4 Mäntsälä',
);
});
test('isLikelyFinlandCoordinate spans the catalog extent and rejects the rest', () => {
assert.equal(isLikelyFinlandCoordinate(60.1699, 24.9384), true); // Helsinki
assert.equal(isLikelyFinlandCoordinate(70.088512, 27.0), true); // Utsjoki, the northern extreme
assert.equal(isLikelyFinlandCoordinate(59.856454, 19.618722), true); // the southwestern extreme
assert.equal(isLikelyFinlandCoordinate(51.5074, -0.1278), false); // London
assert.equal(isLikelyFinlandCoordinate(0, 0), false); // null island
assert.equal(isLikelyFinlandCoordinate(NaN, 24.9384), false);
});
+198
View File
@@ -0,0 +1,198 @@
/**
* Pure monitor-plane footprint math shared by the client geometry
* (src/layers/cctv/geometry.js), the server sidecar join
* (server/providers/cctv/groundHeights.js) and the offline precompute
* (scripts/precompute-cctv-heights.mjs). Lives outside every ownership
* package on purpose. No Cesium, no DOM: the same function
* decides where a plane's support points are on the ground for both.
*
* The plane is the pitched far cap of the camera frustum: center at range R
* along the heading (horizontal R·cos(pitch)), half-width R·tan(hFov/2),
* half-height R·tan(vFov/2) with vFov from a 16:9 aspect, tilted by the
* pitch. Support points are a 3×3 grid over that rectangle: rows bottom /
* middle / top, columns left / center / right, named `bl bm br ml mc mr tl tm
* tr`. Their horizontal positions depend on the pose only, never on ground
* height, so an offline job can sample the ground under them for the nominal
* pose and the client can reuse those samples as long as the pose is the same.
*/
const EARTH_RADIUS_M = 6371000;
export const PLANE_VERT_ASPECT = 16 / 9;
export const SUPPORT_KEYS = Object.freeze([
'bl',
'bm',
'br',
'ml',
'mc',
'mr',
'tl',
'tm',
'tr',
]);
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Offset a lat/lon by a distance along a compass heading (spherical earth;
* sub-centimetre at the ≤ 5 km ranges cameras use).
* @param {number} lat @param {number} lon @param {number} headingDeg @param {number} distM
* @returns {{lat:number, lon:number}}
*/
export function projectPoint(lat, lon, headingDeg, distM) {
const b = toRad(headingDeg);
const la = toRad(lat);
const lo = toRad(lon);
const ad = distM / EARTH_RADIUS_M;
const la2 = Math.asin(
Math.sin(la) * Math.cos(ad) + Math.cos(la) * Math.sin(ad) * Math.cos(b),
);
const lo2 =
lo +
Math.atan2(
Math.sin(b) * Math.sin(ad) * Math.cos(la),
Math.cos(ad) - Math.sin(la) * Math.sin(la2),
);
return { lat: toDeg(la2), lon: toDeg(lo2) };
}
/**
* Plane dimensions and offsets for a pose, before any ground is known.
* @param {{pitchDeg:number, fovDeg:number, rangeM:number}} pose
* @returns {{R:number, pitch:number, halfW:number, halfH:number, horiz:number,
* vert:number, upVert:number, upHoriz:number, vFovDeg:number}}
*/
export function planeDimensions(pose) {
const R = Math.max(1, Number(pose.rangeM) || 1);
const pitch = toRad(Math.max(-89, Math.min(89, Number(pose.pitchDeg) || 0)));
const hFov = toRad(Math.max(8, Math.min(160, Number(pose.fovDeg) || 74)));
const halfW = R * Math.tan(hFov / 2);
const vFovRad = 2 * Math.atan(Math.tan(hFov / 2) / PLANE_VERT_ASPECT);
const halfH = R * Math.tan(vFovRad / 2);
return {
R,
pitch,
halfW,
halfH,
horiz: R * Math.cos(pitch),
vert: R * Math.sin(pitch),
// In-plane "up", decomposed into a vertical part and a horizontal part
// along the heading (a downward pitch tilts the plane's top forward).
upVert: Math.cos(pitch) * halfH,
upHoriz: -Math.sin(pitch) * halfH,
vFovDeg: toDeg(vFovRad),
};
}
/**
* Horizontal (lat/lon) positions of the mount and the nine plane support
* points for a pose. Altitudes are not part of this: the caller adds the
* ground it measured under each point.
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
* fovDeg:number, rangeM:number}} pose
* @returns {{mount:{lat:number,lon:number}, capCenter:{lat:number,lon:number},
* supports: Record<string,{lat:number,lon:number, row:-1|0|1, col:-1|0|1}>}}
*/
export function planeSupportPoints(pose) {
const dims = planeDimensions(pose);
const heading = Number(pose.headingDeg) || 0;
const cap = projectPoint(pose.lat, pose.lon, heading, dims.horiz);
const supports = {};
const rows = { b: -1, m: 0, t: 1 };
// Column letter: l / r, with the centre spelled `m` in the bottom and top
// rows (bm, tm) and `c` in the middle row (mc).
const cols = { l: -1, m: 0, c: 0, r: 1 };
for (const key of SUPPORT_KEYS) {
const row = rows[key[0]];
const col = cols[key[1]];
const across = projectPoint(
cap.lat,
cap.lon,
heading + 90,
col * dims.halfW,
);
const along = projectPoint(
across.lat,
across.lon,
heading,
row * dims.upHoriz,
);
supports[key] = { lat: along.lat, lon: along.lon, row, col };
}
return { mount: { lat: pose.lat, lon: pose.lon }, capCenter: cap, supports };
}
/**
* Altitude of the plane at a support point, given the plane center altitude:
* the rigid rectangle's rows sit ± upVert around the center.
* @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims
* @returns {number}
*/
export function supportAltitude(capAltM, row, dims) {
return capAltM + row * dims.upVert;
}
/**
* The single rigid lift (metres, ≥ 0) that puts every support point at least
* `clearanceM` above the ground measured under it. Support points without a
* measured ground fall back to `fallbackGroundM` (normally the ground at the
* mount), which is what makes the pre-precompute path deterministic instead
* of buried.
* @param {number} capAltM - Unlifted plane center altitude.
* @param {{upVert:number}} dims
* @param {Record<string, number>|null|undefined} groundUnder - support key → ground altitude.
* @param {number} fallbackGroundM
* @param {number} clearanceM
* @returns {{liftM:number, limitingKey:string|null}}
*/
export function requiredPlaneLift(
capAltM,
dims,
groundUnder,
fallbackGroundM,
clearanceM,
) {
let liftM = 0;
let limitingKey = null;
const rows = { b: -1, m: 0, t: 1 };
for (const key of SUPPORT_KEYS) {
const raw = groundUnder ? groundUnder[key] : undefined;
// Strict: a null/absent support must fall back, not read as 0 m.
const ground =
typeof raw === 'number' && Number.isFinite(raw) ? raw : fallbackGroundM;
if (!Number.isFinite(ground)) continue;
const alt = supportAltitude(capAltM, rows[key[0]], dims);
const deficit = ground + clearanceM - alt;
if (deficit > liftM) {
liftM = deficit;
limitingKey = key;
}
}
return { liftM, limitingKey };
}
/**
* Stable hash of the nominal pose a footprint was sampled for, so shipped
* samples are only reused while the pose they describe is unchanged.
* @param {{lat:number, lon:number, headingDeg:number, pitchDeg:number,
* fovDeg:number, rangeM:number, mountHeightM:number}} pose
* @returns {string}
*/
export function poseHash(pose) {
const text = [
Number(pose.lat).toFixed(6),
Number(pose.lon).toFixed(6),
Number(pose.headingDeg).toFixed(1),
Number(pose.pitchDeg).toFixed(1),
Number(pose.fovDeg).toFixed(1),
Number(pose.rangeM).toFixed(1),
Number(pose.mountHeightM).toFixed(1),
].join('|');
// FNV-1a 32-bit, base36: short, stable, dependency-free.
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return `p1-${h.toString(36)}`;
}
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
SUPPORT_KEYS,
planeDimensions,
planeSupportPoints,
poseHash,
projectPoint,
requiredPlaneLift,
} from './cctvFootprint.js';
const pose = {
lat: 30.2672,
lon: -97.7431,
headingDeg: 90,
pitchDeg: -24,
fovDeg: 56,
rangeM: 210,
mountHeightM: 12,
};
test('support points form a 3×3 grid across the pitched far cap', () => {
const { mount, capCenter, supports } = planeSupportPoints(pose);
assert.deepEqual(mount, { lat: pose.lat, lon: pose.lon });
assert.deepEqual(Object.keys(supports).sort(), [...SUPPORT_KEYS].sort());
const dims = planeDimensions(pose);
// Cap center sits R·cos(pitch) east of the mount (heading 90).
const east = projectPoint(pose.lat, pose.lon, 90, dims.horiz);
assert.ok(
Math.abs(capCenter.lat - east.lat) < 1e-9 &&
Math.abs(capCenter.lon - east.lon) < 1e-9,
);
// Middle-center is the cap center; left/right columns straddle it across the heading.
assert.ok(Math.abs(supports.mc.lat - capCenter.lat) < 1e-9);
assert.ok(
supports.ml.lat > capCenter.lat && supports.mr.lat < capCenter.lat,
'left is north of centre when looking east',
);
// A downward pitch tilts the top row forward (further along the heading).
assert.ok(
supports.tm.lon > supports.mc.lon && supports.bm.lon < supports.mc.lon,
);
});
test('the rigid lift is the largest clearance deficit over the supports', () => {
const dims = planeDimensions(pose);
const ground = 149;
const capAlt = ground + pose.mountHeightM + dims.vert; // far below ground for -24°
const flat = requiredPlaneLift(capAlt, dims, null, ground, 2);
assert.ok(
Math.abs(flat.liftM - (ground + 2 - (capAlt - dims.upVert))) < 1e-9,
);
assert.equal(flat.limitingKey[0], 'b');
const bump = requiredPlaneLift(capAlt, dims, { tr: ground + 200 }, ground, 2);
assert.equal(bump.limitingKey, 'tr');
assert.ok(bump.liftM > flat.liftM);
// Already-clear planes need no lift at all.
assert.deepEqual(requiredPlaneLift(ground + 500, dims, null, ground, 2), {
liftM: 0,
limitingKey: null,
});
// Unusable measurements fall back to the mount ground rather than zero.
assert.equal(
requiredPlaneLift(capAlt, dims, { bl: null, br: 'x' }, ground, 2).liftM,
flat.liftM,
);
});
test('poseHash is stable for the same pose and changes with any pose field', () => {
const h = poseHash(pose);
assert.equal(poseHash({ ...pose }), h);
assert.match(h, /^p1-[0-9a-z]+$/);
for (const [key, delta] of [
['lat', 0.00001],
['headingDeg', 0.1],
['pitchDeg', 0.1],
['fovDeg', 0.1],
['rangeM', 1],
['mountHeightM', 0.1],
]) {
assert.notEqual(poseHash({ ...pose, [key]: pose[key] + delta }), h, key);
}
});
+176 -44
View File
@@ -24,6 +24,7 @@
* invariant is untouched.
*/
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
// Grazing guard (spec §5): reject plane intersections when the view ray is
// nearly parallel to the constraint plane — the hit point races to infinity
@@ -73,7 +74,11 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
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());
const hit = Cesium.Cartesian3.multiplyByScalar(
rayDir,
s,
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.add(rayOrigin, hit, hit);
}
@@ -88,7 +93,10 @@ export function rayPlaneIntersect(rayOrigin, rayDir, planeOrigin, planeNormal) {
*/
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));
return Math.atan2(
Cesium.Cartesian3.dot(v, basisB),
Cesium.Cartesian3.dot(v, basisA),
);
}
/**
@@ -149,7 +157,11 @@ function enuAxes(position) {
/** a*sa + b*sb (fresh Cartesian3). */
function combine2(a, sa, b, sb) {
const out = Cesium.Cartesian3.multiplyByScalar(a, sa, new Cesium.Cartesian3());
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);
}
@@ -164,7 +176,12 @@ function combine2(a, sa, b, sb) {
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 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 };
@@ -175,7 +192,12 @@ 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);
const offset = combine2(
basisA,
Math.cos(angle) * radius,
basisB,
Math.sin(angle) * radius,
);
positions.push(Cesium.Cartesian3.add(center, offset, offset));
}
return positions;
@@ -201,7 +223,12 @@ function ringPositions(center, radius, basisA, basisB) {
* destroy: function(): void, isDragging: function(): boolean,
* isEnabled: function(): boolean}}
*/
export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, endPatch }) {
export function createCalibrationGizmo({
viewer,
getActiveRecord,
applyPatch,
endPatch,
}) {
let enabled = false;
let drag = null; // { part, startCal, basePose, refs... }
let hoveredId = null;
@@ -224,20 +251,24 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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,
},
} : {}),
...(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),
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).
@@ -285,16 +316,42 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
const mount = positions.mount;
const axes = enuAxes(mount);
const { forwardHoriz, right, view } = viewAxesFor(camera.headingDeg, camera.pitchDeg, axes);
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 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()
)];
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);
@@ -305,8 +362,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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());
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;
}
@@ -342,8 +407,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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))));
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);
@@ -378,7 +452,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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);
entity.point.pixelSize =
(name === 'handle-range' ? 13 : 10) + (hot ? 4 : 0);
}
setCursor(hoveredId ? 'grab' : '');
}
@@ -396,7 +471,11 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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 { forwardHoriz, right, view } = viewAxesFor(
camera.headingDeg,
camera.pitchDeg,
axes,
);
const startCal = { ...camera.calibration };
const basePose = { ...camera.basePose };
const state = {
@@ -404,7 +483,15 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
// 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,
part,
startCal,
basePose,
mount,
capCenter,
axes,
forwardHoriz,
right,
view,
effectiveRangeM: geometry.rangeM,
startAngle: null,
startParam: null,
@@ -418,8 +505,17 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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;
} 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;
@@ -447,26 +543,49 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
function dragPatch(windowPosition) {
const ray = pickRay(windowPosition);
if (!ray || !drag) return null;
const { part, startCal, basePose, mount, capCenter, axes, forwardHoriz, right, view } = drag;
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)) };
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)) };
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);
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 };
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') {
@@ -477,11 +596,16 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
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 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)));
const fovDeg = toDeg(
2 * Math.atan(halfW / Math.max(1, drag.effectiveRangeM)),
);
return { fovDeg: fovDeg - basePose.fovDeg };
}
return null;
@@ -506,6 +630,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
handler.setInputAction((event) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
if (!isPointerFree()) return;
if (!enabled) return;
const part = pickGizmoPart(event.position);
debugLog('LEFT_DOWN', event.position, 'part:', part);
@@ -528,7 +654,13 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
return;
}
const patch = dragPatch(event.endPosition);
debugLog('dragPatch', drag.part, event.endPosition, '→', patch ? JSON.stringify(patch) : null);
debugLog(
'dragPatch',
drag.part,
event.endPosition,
'→',
patch ? JSON.stringify(patch) : null,
);
if (patch) applyPatch(patch, drag.record);
return;
}
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
joinGroundHeights,
loadGroundHeights,
} from '../../server/providers/cctv/groundHeights.js';
import { poseHash } from './cctvFootprint.js';
const source = (over = {}) => ({
id: 'cam-1',
lat: 30.2672,
lon: -97.7431,
headingDeg: 90,
pitchDeg: -24,
fovDeg: 56,
rangeM: 210,
mountHeightM: 12,
...over,
});
test('a shipped entry attaches only while the nominal pose still matches', () => {
const s = source();
const entry = {
status: 'ok',
poseHash: poseHash(s),
mountGroundM: 151.2,
supports: { bl: 150.1, bm: 149.9, br: 155.5, tl: null, tm: 'x' },
};
const [joined] = joinGroundHeights([s], { 'cam-1': entry });
assert.deepEqual(joined.groundHeights, {
poseHash: entry.poseHash,
mountGroundM: 151.2,
supports: { bl: 150.1, bm: 149.9, br: 155.5 },
});
const moved = source({ lat: 30.2673 });
const [notJoined] = joinGroundHeights([moved], { 'cam-1': entry });
assert.equal(
notJoined.groundHeights,
undefined,
'a moved camera loses the shipped value',
);
const [missed] = joinGroundHeights([source()], {
'cam-1': { ...entry, status: 'miss', mountGroundM: null },
});
assert.equal(missed.groundHeights, undefined, 'a miss ships nothing');
assert.deepEqual(
joinGroundHeights([source()], null)[0].groundHeights,
undefined,
);
});
test('the sidecar loads from the source root and tolerates a missing or malformed file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-heights-'));
fs.mkdirSync(
path.join(dir, 'src', 'data', 'local_data', 'cctv_ground_heights'),
{
recursive: true,
},
);
assert.deepEqual(loadGroundHeights(dir), {}, 'missing file');
const file = path.join(
dir,
'src',
'data',
'local_data',
'cctv_ground_heights',
'cctv_ground_heights.json',
);
fs.writeFileSync(file, '{not json');
assert.deepEqual(loadGroundHeights(dir), {}, 'malformed file');
fs.writeFileSync(
file,
JSON.stringify({ schemaVersion: 1, cameras: { a: { status: 'ok' } } }),
);
assert.deepEqual(loadGroundHeights(dir), { a: { status: 'ok' } });
});
+122 -47
View File
@@ -31,6 +31,13 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'austin transportation & public works': 5 * 60 * 1000,
'transport for london': 3 * 60 * 1000,
caltrans: 3 * 60 * 1000,
'ontario 511': 3 * 60 * 1000,
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
txdot: 3 * 60 * 1000,
// Digitraffic publishes a new weathercam frame on each station's
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
fintraffic: 10 * 60 * 1000,
});
/**
@@ -42,7 +49,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
* @returns {{cardLimit:number}}
*/
export function cctvLodBudgets(cameraHeightM) {
const height = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : CITY_HEIGHT_M;
const height = Number.isFinite(cameraHeightM)
? Math.max(0, cameraHeightM)
: CITY_HEIGHT_M;
if (height <= STREET_HEIGHT_M) {
return { cardLimit: CCTV_AMBIENT_CARD_MIN };
}
@@ -68,7 +77,11 @@ export const CCTV_CARD_INCUMBENT_FACTOR = 0.8;
* @param {number} [factor]
* @returns {number}
*/
export function incumbentRankKm(distanceKm, isIncumbent, factor = CCTV_CARD_INCUMBENT_FACTOR) {
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;
}
@@ -81,8 +94,9 @@ 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;
return (
Number.isFinite(viewW) && viewW > 0 && Number.isFinite(viewH) && viewH > 0
);
}
/**
@@ -117,7 +131,10 @@ export function cctvCandidateSpreadKm(
.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));
const p = Math.min(
1,
Math.max(0, Number.isFinite(percentile) ? percentile : 0),
);
return finite[Math.round(p * (finite.length - 1))];
}
@@ -176,32 +193,47 @@ export const CCTV_CARD_GRID_ROWS = 4;
* @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,
} = {}) {
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));
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);
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 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);
@@ -249,29 +281,42 @@ export function distributeCctvCards(candidates, {
* @param {number} [options.viewH] - Viewport height (CSS px).
* @returns {{cardIds:string[],budgets:{cardLimit:number}}}
*/
export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW, viewH } = {}) {
export function selectCctvLod(
candidates,
{ cameraHeightM, incumbentIds, viewW, viewH } = {},
) {
const budgets = cctvLodBudgets(cameraHeightM);
const incumbents = incumbentIds instanceof Set ? incumbentIds : new Set(incumbentIds || []);
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)
.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)),
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)
));
.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) {
@@ -284,9 +329,15 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
}
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;
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),
@@ -295,7 +346,14 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
sy: Number.isFinite(candidate.sy) ? candidate.sy : NaN,
};
const current = eligibleById.get(normalized.id);
if (!current || compareCctvRepresentative(normalized, current, { screened, viewW, viewH }) < 0) {
if (
!current ||
compareCctvRepresentative(normalized, current, {
screened,
viewW,
viewH,
}) < 0
) {
eligibleById.set(normalized.id, normalized);
}
}
@@ -304,7 +362,9 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
// robust distance scale or consume a bounded ambient-card slot.
const stills = [...eligibleById.values()];
const spreadKm = cctvCandidateSpreadKm(stills.map((candidate) => candidate.distanceKm));
const spreadKm = cctvCandidateSpreadKm(
stills.map((candidate) => candidate.distanceKm),
);
for (const candidate of stills) {
const blendedKm = blendCenterRankKm(
candidate.distanceKm,
@@ -313,17 +373,22 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
);
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)
));
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 });
cardIds = distributeCctvCards(stills, {
budget: budgets.cardLimit,
viewW,
viewH,
});
if (cardIds.length < budgets.cardLimit) {
const chosen = new Set(cardIds);
for (const candidate of stills) {
@@ -336,10 +401,12 @@ export function selectCctvLod(candidates, { cameraHeightM, incumbentIds, viewW,
}
function compareCctvRepresentative(a, b, { screened, viewW, viewH }) {
if (a.distanceKm !== b.distanceKm) return a.distanceKm < b.distanceKm ? -1 : 1;
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);
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;
@@ -414,7 +481,10 @@ export function applyEvictionGrace({
// 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));
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++) {
@@ -423,7 +493,10 @@ export function applyEvictionGrace({
continue;
}
keepIds.push(graced[i].id);
nextGrace.set(graced[i].id, { misses: graced[i].misses, since: graced[i].since });
nextGrace.set(graced[i].id, {
misses: graced[i].misses,
since: graced[i].since,
});
}
return { keepIds, evictIds, graceState: nextGrace };
@@ -442,6 +515,8 @@ export function staticFrameRefreshMs(camera) {
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();
const provider = String(camera?.provider || '')
.trim()
.toLowerCase();
return PROVIDER_STATIC_REFRESH_MS[provider] || DEFAULT_STATIC_REFRESH_MS;
}
+215 -74
View File
@@ -41,7 +41,9 @@ test('cctvLodBudgets scales the card budget from 20 to 40 with view height', ()
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 });
assert.deepEqual(cctvLodBudgets(30_000), {
cardLimit: CCTV_AMBIENT_CARD_MAX,
});
});
test('selectCctvLod caps ambient cards at the 40 hard cap regardless of catalog size', () => {
@@ -54,19 +56,30 @@ 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']);
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 });
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 }
[
null,
{},
{ id: '', inView: true },
{ id: 'cam-ok', inView: true, distanceKm: NaN },
],
{ cameraHeightM: 500 },
);
assert.deepEqual(selected.cardIds, ['cam-ok']);
});
@@ -84,7 +97,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
// 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,
id: `anchor-${String(i).padStart(2, '0')}`,
distanceKm: i / 100,
inView: true,
}));
const contested = [
{ id: 'incumbent', distanceKm: 5.0, inView: true },
@@ -101,7 +116,9 @@ test('selectCctvLod: a carded camera outranks a slightly-nearer non-carded one',
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,
id: `anchor-${String(i).padStart(2, '0')}`,
distanceKm: i / 100,
inView: true,
}));
const contested = [
{ id: 'incumbent', distanceKm: 5.0, inView: true },
@@ -111,13 +128,19 @@ test('selectCctvLod: a meaningfully (>20%) closer camera still displaces an incu
cameraHeightM: 500,
incumbentIds: new Set(['incumbent']),
});
assert.ok(selected.cardIds.includes('newcomer'), 'meaningfully closer camera wins');
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() });
const withEmpty = selectCctvLod(candidates(20), {
cameraHeightM: 500,
incumbentIds: new Set(),
});
assert.deepEqual(plain.cardIds, withEmpty.cardIds);
});
@@ -129,25 +152,39 @@ test('distributeCctvCards spreads clustered candidates across cells', () => {
// 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,
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']
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,
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']
distributeCctvCards([...clustered, lonely], {
budget: 4,
viewW: 1000,
viewH: 800,
}),
['center-0', 'periphery', 'center-1', 'center-2'],
);
});
@@ -155,64 +192,101 @@ 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,
id: `c-${i}`,
sx: 20 + i * 5,
sy: 20,
rankKm: i,
}));
const ids = distributeCctvCards(oneCell, { budget: 3, viewW: 1000, viewH: 800 });
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 });
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 });
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 });
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 }), []);
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,
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: 400 + (i % 5) * 8,
sy: 300 + Math.floor(i / 5) * 8,
}));
const periphery = { id: 'periphery', distanceKm: 9, inView: true, sx: 950, sy: 750 };
sx: 950,
sy: 750,
};
const selected = selectCctvLod([...clustered, periphery], {
cameraHeightM: 500, viewW: 1000, viewH: 800,
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');
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 }
{ cameraHeightM: 500 },
);
assert.equal(plain.cardIds.includes('periphery'), false);
});
@@ -253,11 +327,13 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
const legacy = selectCctvLod(input, { cameraHeightM: 500 });
assert.deepEqual(
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 }).cardIds,
selectCctvLod(input, { cameraHeightM: 500, viewW: Infinity, viewH: 800 })
.cardIds,
legacy.cardIds,
);
assert.deepEqual(
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity }).cardIds,
selectCctvLod(input, { cameraHeightM: 500, viewW: 1000, viewH: Infinity })
.cardIds,
legacy.cardIds,
);
@@ -266,7 +342,8 @@ test('non-finite positive viewport dimensions retain exact legacy nearest-first
{ id: 'normal', distanceKm: 2, inView: true },
];
assert.deepEqual(
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 }).cardIds,
selectCctvLod(legacyEdgeIds, { cameraHeightM: 500, viewW: 0, viewH: 800 })
.cardIds,
[' ', 'normal'],
'invalid viewport preserves every non-empty string ID accepted by the legacy path',
);
@@ -294,18 +371,34 @@ test('center weighting deterministically favors center without starving peripher
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);
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');
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);
assert.equal(
legacy.cardIds.filter((id) => id.startsWith('center-')).length,
0,
);
});
test('ineligible video, hidden, and malformed-distance rows cannot alter eligible winners', () => {
@@ -322,13 +415,26 @@ test('ineligible video, hidden, and malformed-distance rows cannot alter eligibl
sx: 500,
sy: 400,
})),
{ id: 'hidden-outlier', distanceKm: 9_000, inView: false, sx: 900, sy: 700 },
{
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);
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', () => {
@@ -339,7 +445,9 @@ test('malformed-distance rows remain slot-neutral when valid candidates leave sp
];
assert.deepEqual(selectCctvLod(candidates, view).cardIds, ['valid']);
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, ['valid']);
assert.deepEqual(selectCctvLod([...candidates].reverse(), view).cardIds, [
'valid',
]);
});
test('duplicate IDs are slot-neutral and choose a deterministic representative', () => {
@@ -353,7 +461,10 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
{ ...center[0], distanceKm: NaN, sx: 500, sy: 400 },
];
assert.deepEqual(selectCctvLod([...eligible, ...duplicates], view).cardIds, baseline);
assert.deepEqual(
selectCctvLod([...eligible, ...duplicates], view).cardIds,
baseline,
);
assert.deepEqual(
selectCctvLod([...duplicates, ...eligible].reverse(), view).cardIds,
baseline,
@@ -362,13 +473,16 @@ test('duplicate IDs are slot-neutral and choose a deterministic representative',
});
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 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 },
@@ -380,13 +494,26 @@ test('center weighting has deterministic ID ties and preserves invalid-anchor el
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');
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');
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', () => {
@@ -480,7 +607,11 @@ test('applyEvictionGrace never exceeds the hard cap; graced cards go first, olde
// 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');
assert.deepEqual(
result.evictIds,
['cam-old'],
'oldest grace entry evicted for the cap',
);
});
test('applyEvictionGrace with a full selection keeps no graced cards', () => {
@@ -497,9 +628,19 @@ test('applyEvictionGrace with a full selection keeps no graced cards', () => {
});
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: '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({ provider: 'Ontario 511' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_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);
+588
View File
@@ -0,0 +1,588 @@
// Client `Range` handling for the CCTV media proxy. The route used to relay
// req.headers.range verbatim to a third-party upstream; these pin what is now
// allowed through, both as string policy and through the mounted route with an
// injected upstream.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { Readable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
test('single well-formed byte ranges pass through canonicalized', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023');
// A single range that names the same byte twice is legal.
assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7');
// The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal.
assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99');
});
test('multi-range is dropped so the upstream never answers multipart/byteranges', () => {
// The relay forwards Content-Length/Content-Range and caps on a single
// declared body; a multipart response defeats that accounting.
assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), '');
assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), '');
});
test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => {
for (const bad of [
'bytes=abc-def',
'bytes=',
'bytes=-',
'bytes=1-2-3',
'0-100', // no unit
'items=0-100', // a unit the proxy does not understand
'bytes=1.5-2',
'bytes=-0', // zero-length suffix is unsatisfiable
'bytes=0x10-0x20',
'bytes=+5-10',
'bytes=-5-10',
// An outbound request refuses to carry these, and the failure used to be
// written into the camera's health message.
'bytes=0-10\r\nX-Injected: 1',
'bytes=0-10\nHost: evil.example',
]) {
assert.equal(
sanitizeCctvRangeHeader(bad),
'',
`expected drop: ${JSON.stringify(bad)}`,
);
}
});
test('non-string and empty inputs yield no Range header', () => {
// req.headers.range is undefined on a normal GET, and Node hands back an
// array when a client sends the header twice.
assert.equal(sanitizeCctvRangeHeader(undefined), '');
assert.equal(sanitizeCctvRangeHeader(null), '');
assert.equal(sanitizeCctvRangeHeader(''), '');
assert.equal(sanitizeCctvRangeHeader(' '), '');
assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), '');
assert.equal(sanitizeCctvRangeHeader(42), '');
});
test('every accepted form is bounded to the relay body cap', () => {
// Explicit span.
assert.equal(
sanitizeCctvRangeHeader('bytes=0-999999999999'),
`bytes=0-${CAP - 1}`,
);
assert.equal(
sanitizeCctvRangeHeader('bytes=1000-999999999999'),
`bytes=1000-${1000 + CAP - 1}`,
);
// Open-ended: bounded too, rather than asking an upstream for a whole file
// of unknown size.
assert.equal(
sanitizeCctvRangeHeader('bytes=500-'),
`bytes=500-${500 + CAP - 1}`,
);
// Suffix: bounded to one span of the tail.
assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500');
assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`);
// Exactly at the cap, and comfortably under it, are untouched.
assert.equal(
sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`),
`bytes=0-${CAP - 1}`,
);
assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575');
});
test('inverted and unsafe-integer positions are dropped', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), '');
// Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision.
assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), '');
// Absurd digit counts are refused before any Number() conversion.
assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), '');
});
// ---------------------------------------------------------------------------
// The mounted route, with the upstream injected through globalThis.fetch.
// ---------------------------------------------------------------------------
const CAMERA = {
id: 'range-fixture',
name: 'Range fixture',
lat: 30.27,
lon: -97.74,
feedType: 'video',
url: 'https://upstream.invalid/live.mp4',
};
/** A minimal upstream Response with a streaming body. */
function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) {
const store = new Map(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
body:
body === null
? null
: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
arrayBuffer: async () => Buffer.from(String(body)),
};
}
/** Collects what a handler wrote, the way a Node ServerResponse would. */
function recordingResponse() {
const chunks = [];
let finished;
const done = new Promise((resolve) => {
finished = resolve;
});
const res = {
statusCode: 0,
headers: {},
writableEnded: false,
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
},
write(chunk) {
chunks.push(Buffer.from(chunk));
return true;
},
end(chunk) {
if (chunk) chunks.push(Buffer.from(chunk));
res.writableEnded = true;
res.body = Buffer.concat(chunks).toString('utf8');
finished();
},
on() {},
once() {},
emit() {},
destroy() {},
};
return { res, done };
}
/**
* Mount the real provider, serve one camera, and answer its upstream from the
* supplied function.
*/
function mount(t, respond) {
const before = {
json: process.env.CCTV_SOURCES_JSON,
file: process.env.CCTV_SOURCES_FILE,
austin: process.env.CCTV_FORCE_AUSTIN,
};
process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
process.env.CCTV_FORCE_AUSTIN = '0';
const nativeFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({
url: String(url),
headers: init.headers || {},
signal: init.signal || null,
});
return respond({ url: String(url), init });
};
t.after(() => {
globalThis.fetch = nativeFetch;
for (const [name, value] of [
['CCTV_SOURCES_JSON', before.json],
['CCTV_SOURCES_FILE', before.file],
['CCTV_FORCE_AUSTIN', before.austin],
]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let handler = null;
const plugin = cctvProxy();
plugin.configureServer({
middlewares: {
use: (_route, fn) => {
handler = fn;
},
},
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
/** Drive one request through the mounted handler. */
const call = async (path, headers = {}) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers, method: 'GET', on() {} }, res);
await done;
return res;
};
/** Read the camera's current health entry. */
const health = async () => {
const res = await call('/health');
return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id);
};
return { call, health, requests, handler: (req, res) => handler(req, res) };
}
test('a canonical range reaches the upstream and its 206 is passed through', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': 'bytes 0-1023/4096',
'content-length': '1024',
'accept-ranges': 'bytes',
},
body: 'partial',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 206);
assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096');
assert.equal(res.headers['Accept-Ranges'], 'bytes');
assert.equal(res.body, 'partial');
});
test('an unbounded seek is bounded before it is forwarded', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: { 'content-type': 'video/mp4' },
body: 'tail',
}),
);
await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' });
assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`);
});
test('a rejected range is dropped and the request proceeds without one', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
// Documented choice: drop the header rather than answer 400, so a client
// that sends something this proxy will not carry still gets its media.
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('a rejected range leaves the camera health report untouched', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
const entry = await app.health();
assert.equal(entry.status, 'ok');
// The client's own string must never appear in a report other callers read.
assert.doesNotMatch(JSON.stringify(entry), /X-Injected/);
});
test('a multi-range request is served whole rather than as multipart', async (t) => {
const app = mount(t, ({ init }) =>
upstreamResponse({
status: init?.headers?.Range ? 206 : 200,
headers: {
'content-type': init?.headers?.Range
? 'multipart/byteranges; boundary=x'
: 'video/mp4',
},
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-1,2-3',
});
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'video/mp4');
});
test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 416,
headers: { 'content-range': 'bytes */1024' },
body: null,
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: `bytes=${2 ** 40}-${2 ** 40 + 10}`,
});
assert.equal(res.statusCode, 416);
assert.match(res.body, /416/);
const entry = await app.health();
assert.equal(entry.status, 'degraded');
assert.equal(entry.message, 'Upstream HTTP 416');
});
test('an upstream that ignores the range still serves its whole body', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4', 'content-length': '10' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('an oversized declared body is refused even when a bounded range asked for it', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-length': String(CAP + 1),
},
body: 'too big',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(res.statusCode, 502);
assert.match(res.body, /exceeds size cap/);
});
test('a client that disconnects mid-stream stops the upstream read', async (t) => {
// A live feed with no end of its own, counting what it is asked to produce.
let produced = 0;
let stopped = false;
const endlessBody = () =>
Readable.toWeb(
Readable.from(
(async function* () {
try {
for (;;) {
produced += 1;
yield Buffer.from('frame-chunk-');
await new Promise((resolve) => setTimeout(resolve, 10));
}
} finally {
stopped = true;
}
})(),
),
);
const app = mount(t, () => ({
ok: true,
status: 206,
headers: {
get: (name) =>
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
},
body: endlessBody(),
arrayBuffer: async () => Buffer.alloc(0),
}));
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
await new Promise((resolve, reject) => {
const request = http.get(
{
host: '127.0.0.1',
port,
path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'),
headers: { Range: 'bytes=0-1023' },
},
(response) => {
response.once('data', () => {
// Walk away mid-body, the way a browser does when the operator
// switches camera.
request.destroy();
resolve();
});
response.on('error', () => resolve());
},
);
request.on('error', reject);
});
await new Promise((resolve) => setTimeout(resolve, 150));
const afterDisconnect = produced;
await new Promise((resolve) => setTimeout(resolve, 400));
// The point: the camera host is no longer being read from. Without the
// release this counter keeps climbing for as long as the upstream will feed.
assert.equal(
produced,
afterDisconnect,
`the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`,
);
assert.equal(stopped, true, 'the upstream body was never released');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client walking away must not fault the server',
);
const entry = await app.health();
// The camera was serving fine; the client left. Nothing about that is a
// camera fault other viewers should see.
assert.equal(entry.status, 'ok');
});
test('a client that leaves before the headers arrive cancels the upstream request', async (t) => {
// The camera is slow, or dead: nothing comes back until the request is
// cancelled. Everything worth testing here happens before any header.
let aborted = false;
const app = mount(t, ({ init }) => {
return new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
aborted = true;
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
});
});
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
const request = http.get({
host: '127.0.0.1',
port,
path: `/api/cctv/media/${CAMERA.id}`,
headers: { Range: 'bytes=0-1023' },
});
request.on('error', () => {});
// Let the route reach its upstream request, then walk away before it answers.
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(app.requests.length, 1, 'the upstream request went out');
assert.equal(aborted, false, 'nothing was cancelled while the client waited');
request.destroy();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(aborted, true, 'the upstream request outlived the client');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client leaving early must not fault the server',
);
// The camera did nothing wrong; the viewer left.
const entry = await app.health();
assert.notEqual(entry?.status, 'degraded');
});
test('a folded CR/LF range never reaches the upstream through a real client', async (t) => {
// Node's own parser, not a hand-built headers object, so the reachability of
// this shape through an ordinary HTTP request is shown rather than assumed.
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
// A client can fold a value across a continuation line; Node's parser
// rejoins it, so what the route sees contains whitespace a header cannot
// carry back out.
const raw = await new Promise((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1', () => {
socket.write(
`GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` +
'Host: 127.0.0.1\r\n' +
'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' +
'Connection: close\r\n\r\n',
);
});
let text = '';
socket.on('data', (chunk) => {
text += chunk.toString('utf8');
});
socket.on('end', () => resolve(text));
socket.on('error', reject);
});
// Either Node's parser refuses the request outright, or it reaches the route
// and the route drops the value — but it must never be forwarded.
const reachedRoute = app.requests.length > 0;
assert.match(raw, /^HTTP\/1\.1 (200|400)/);
if (reachedRoute) {
assert.equal(
app.requests.at(-1).headers.Range,
undefined,
'nothing was forwarded',
);
assert.match(raw, /^HTTP\/1\.1 200/);
} else {
assert.match(raw, /^HTTP\/1\.1 400/);
}
console.log(
` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`,
);
const entry = await app.health();
assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/);
});
test('two sequential seeks are served as two partial responses', async (t) => {
const app = mount(t, ({ init }) => {
const range = init?.headers?.Range || '';
const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || [];
return upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': `bytes ${first}-${last}/4096`,
'content-length': String(Number(last) - Number(first) + 1),
'accept-ranges': 'bytes',
},
body: `chunk-${first}`,
});
});
const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' });
const second = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=100-199',
});
assert.equal(app.requests[0].headers.Range, 'bytes=0-99');
assert.equal(app.requests[1].headers.Range, 'bytes=100-199');
assert.equal(first.statusCode, 206);
assert.equal(second.statusCode, 206);
assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096');
assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096');
assert.equal(first.body, 'chunk-0');
assert.equal(second.body, 'chunk-100');
});
+196
View File
@@ -0,0 +1,196 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadNswSourcesFromOpenData,
nswCameraLabel,
nswCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
cctvUpstreamUserAgent,
fetchCctvImageFromUpstream,
} from '../../server/providers/cctv/media.js';
import {
NSW_CAMERAS_URL,
NSW_IMAGE_USER_AGENT,
} from '../../server/providers/cctv/constants.js';
const feature = (overrides = {}, props = {}) => ({
type: 'Feature',
id: '5_ways_miranda',
geometry: { type: 'Point', coordinates: [151.1036, -34.0337] },
properties: {
region: 'SYD_SOUTH',
title: '5 Ways (Miranda)',
view: '5 Ways at The Boulevarde looking west towards Sutherland.',
direction: 'W',
href: 'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/5_ways_miranda.jpeg',
...props,
},
...overrides,
});
test('NSW label prefers the view sentence and falls back to the title for works notices', () => {
assert.equal(
nswCameraLabel({ title: '5 Ways (Miranda)', view: '5 Ways looking west.' }),
'5 Ways looking west.',
);
assert.equal(
nswCameraLabel({ title: 'Victoria Pass', view: 'x'.repeat(200) }),
'Victoria Pass',
);
assert.equal(nswCameraLabel({ title: 'T', view: 'line one\nline two' }), 'T');
assert.equal(nswCameraLabel({}), '');
});
test('NSW feature maps to a source with a compass heading and a pinned image host', () => {
const source = nswCameraToSource(feature({}, { direction: 'N-E' }));
assert.equal(source.id, 'nsw-5_ways_miranda');
assert.equal(source.city, 'SYD SOUTH');
assert.equal(source.provider, 'Live Traffic NSW');
assert.equal(source.lat, -34.0337);
assert.equal(source.lon, 151.1036);
assert.equal(source.headingDeg, 45);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.sourceKind, 'nsw-livetraffic');
assert.match(source.license, /CC BY 4.0/);
assert.equal(source.url, source.snapshotUrl);
});
test('NSW mapping rejects off-host images and unusable geometry', () => {
assert.equal(
nswCameraToSource(feature({}, { href: 'https://evil.example/x.jpeg' })),
null,
);
assert.equal(nswCameraToSource(feature({ geometry: null })), null);
assert.equal(nswCameraToSource(feature({ id: '' })), null);
const noBearing = nswCameraToSource(feature({}, { direction: '' }));
assert.equal(noBearing.headingConfidence, 'low');
assert.ok(Number.isFinite(noBearing.headingDeg));
});
test('NSW loader reads the keyless feed and caps around Sydney', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
type: 'FeatureCollection',
features: [
feature(),
feature({ id: 'other' }, { href: 'https://evil.example/x.jpeg' }),
],
});
});
const cameras = await loadNswSourcesFromOpenData();
assert.deepEqual(requested, [NSW_CAMERAS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
['nsw-5_ways_miranda'],
);
});
test('the browser User-Agent applies to the NSW image host only', async () => {
assert.equal(
cctvUpstreamUserAgent(
'https://webcams.transport.nsw.gov.au/livetraffic-webcams/cameras/x.jpeg',
),
NSW_IMAGE_USER_AGENT,
);
assert.equal(
cctvUpstreamUserAgent(
'https://webcams.transport.nsw.gov.au.evil.test/x.jpeg',
),
'gods-eye-view-cctv-proxy/1.0',
);
assert.equal(
cctvUpstreamUserAgent(
'https://example.test/webcams.transport.nsw.gov.au/x.jpeg',
),
'gods-eye-view-cctv-proxy/1.0',
);
assert.equal(
cctvUpstreamUserAgent('not a url'),
'gods-eye-view-cctv-proxy/1.0',
);
const seen = [];
await fetchCctvImageFromUpstream(
'https://cctv.austinmobility.io/image/1.jpg',
{
timeoutMs: 100,
fetchImpl: async (url, init) => {
seen.push(init.headers['User-Agent']);
return new Response(Buffer.from([0xff, 0xd8, 0xff]), {
headers: { 'Content-Type': 'image/jpeg' },
});
},
},
);
assert.deepEqual(seen, ['gods-eye-view-cctv-proxy/1.0']);
});
test('the frame path follows redirects within the host only', async () => {
const jpeg = () =>
new Response(Buffer.from([0xff, 0xd8, 0xff]), {
headers: { 'Content-Type': 'image/jpeg' },
});
const redirect = (location) =>
new Response(null, { status: 302, headers: { location } });
const start = 'https://cctv.austinmobility.io/image/1.jpg';
const sameHost = [];
const followed = await fetchCctvImageFromUpstream(start, {
timeoutMs: 100,
fetchImpl: async (url, init) => {
assert.equal(init.redirect, 'manual', 'the fetch itself never follows');
sameHost.push(url);
return sameHost.length === 1 ? redirect('/image/moved/1.jpg') : jpeg();
},
});
assert.equal(followed?.ok, true);
assert.deepEqual(sameHost, [
start,
'https://cctv.austinmobility.io/image/moved/1.jpg',
]);
const offHost = [];
const refused = await fetchCctvImageFromUpstream(start, {
timeoutMs: 100,
fetchImpl: async (url) => {
offHost.push(url);
return redirect('https://evil.example/1.jpg');
},
});
assert.equal(refused, null);
assert.deepEqual(offHost, [start], 'the off-host target is never requested');
// Same host on another port, a plaintext downgrade, and a protocol-relative
// other host are all a different origin and are refused.
for (const target of [
'https://cctv.austinmobility.io:8443/image/1.jpg',
'http://cctv.austinmobility.io/image/1.jpg',
'//evil.example/1.jpg',
]) {
const seen = [];
const result = await fetchCctvImageFromUpstream(start, {
timeoutMs: 100,
fetchImpl: async (url) => {
seen.push(url);
return redirect(target);
},
});
assert.equal(result, null, target);
assert.deepEqual(seen, [start], target);
}
let hops = 0;
const looped = await fetchCctvImageFromUpstream(start, {
timeoutMs: 100,
fetchImpl: async () => {
hops += 1;
return redirect('/image/again.jpg');
},
});
assert.equal(looped, null);
assert.equal(hops, 3, 'at most two redirect hops are followed');
});
+328
View File
@@ -0,0 +1,328 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadTxdotSourcesFromOpenData,
normalizeTxdotDistrictPayload,
} from '../../server/providers/cctv/sources.js';
import { fetchTxdotSnapshot } from '../../server/providers/cctv/media.js';
import { TXDOT_CCTV_STATUS_URL } from '../../server/providers/cctv/constants.js';
/** Minimal valid JPEG head: SOI marker plus a byte of payload. */
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
const JPEG_B64 = JPEG_BYTES.toString('base64');
/** One "Device Online" record in the shape GetCctvStatusListByDistrict returns. */
function cameraRow(overrides = {}) {
return {
icd_Id: 'FM-734 @ US-290 EB',
name: 'FM-734 @ US-290 EB',
latitude: 30.34396,
longitude: -97.57988,
statusDescription: 'Device Online',
hasSnapshot: true,
dirDescription: 'North',
equipLoc: { roadway: 'FM-734', direction: 'North' },
...overrides,
};
}
const districtPayload = (rows) => ({
roadwayCctvStatuses: { 'FM-734': rows },
});
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
test('TxDOT catalog keeps only online cameras with finite coordinates', () => {
const cameras = normalizeTxdotDistrictPayload(
districtPayload([
cameraRow({ icd_Id: 'a', name: 'a' }),
cameraRow({
icd_Id: 'b',
name: 'b',
statusDescription: 'Device Offline',
}),
cameraRow({ icd_Id: 'c', name: 'c', statusDescription: 'Device Error' }),
cameraRow({ icd_Id: 'd', name: 'd', latitude: null }),
cameraRow({ icd_Id: 'e', name: 'e', longitude: 'not-a-number' }),
cameraRow({ icd_Id: 'f', name: 'f', latitude: 0, longitude: 0 }),
cameraRow({
icd_Id: 'g',
name: 'g',
latitude: '30.1',
longitude: '-97.7',
}),
cameraRow({ icd_Id: 'h', name: 'h', hasSnapshot: false }),
]),
'AUS',
);
assert.equal(cameras.length, 1);
assert.equal(cameras[0].name, 'a');
assert.equal(cameras[0].provider, 'TxDOT');
assert.equal(cameras[0].feedType, 'image');
assert.equal(cameras[0].sourceKind, 'txdot-its');
assert.equal(cameras[0].license, 'Public TxDOT traffic camera data');
assert.equal(
cameras[0].url,
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=a&districtCode=AUS',
);
assert.equal(
cameras[0].id,
`txdot-aus-${Buffer.from('a').toString('base64url')}`,
);
});
test('TxDOT ids stay distinct for distinct device keys, whatever they hash or slug to', () => {
const ids = normalizeTxdotDistrictPayload(
districtPayload([
// These two share a 32-bit FNV hash.
cameraRow({ icd_Id: 'costarring', name: 'costarring' }),
cameraRow({ icd_Id: 'liquid', name: 'liquid' }),
// These two share a lowercase slug and a hash.
cameraRow({ icd_Id: 'abcDEfaBCDEFAbcdEFABCdEfAbcdeFaB', name: 'x' }),
cameraRow({ icd_Id: 'ABcDeFABCDefaBCDeFAbCdeFabcdefAB', name: 'y' }),
]),
'AUS',
).map((camera) => camera.id);
assert.equal(new Set(ids).size, 4);
assert.equal(
Buffer.from(ids[0].replace(/^txdot-aus-/, ''), 'base64url').toString(
'utf8',
),
'costarring',
'the device key is recoverable from the id',
);
});
test('TxDOT catalog dedupes a camera listed under two roadways', () => {
const cameras = normalizeTxdotDistrictPayload(
{
roadwayCctvStatuses: {
'IH-35': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
'SH-71': [cameraRow({ icd_Id: 'shared', name: 'IH-35 @ SH-71' })],
},
},
'AUS',
);
assert.equal(cameras.length, 1);
});
test('TxDOT heading comes from an explicit travel token, never the roadway direction', () => {
const [eastbound, westbound, plain] = normalizeTxdotDistrictPayload(
districtPayload([
cameraRow({ icd_Id: 'eb', name: 'FM-734 @ US-290 EB' }),
cameraRow({ icd_Id: 'wb', name: 'FM-734 @ US-290 WB' }),
cameraRow({ icd_Id: 'plain', name: 'FM-734 @ Bellingham Dr' }),
]),
'AUS',
);
assert.equal(eastbound.headingDeg, 90);
assert.equal(eastbound.headingConfidence, 'high');
assert.equal(westbound.headingDeg, 270);
assert.equal(westbound.headingConfidence, 'high');
assert.equal(plain.headingConfidence, 'low');
assert.ok(Number.isFinite(plain.headingDeg));
});
test('TxDOT heading refuses bare cardinals in Texas route names', () => {
const cameras = normalizeTxdotDistrictPayload(
districtPayload([
cameraRow({ icd_Id: 'lamar', name: 'N Lamar Blvd @ Rundberg Ln' }),
cameraRow({ icd_Id: 'west', name: 'West Ave @ 6th St' }),
]),
'AUS',
);
for (const camera of cameras) {
assert.equal(
camera.headingConfidence,
'low',
`${camera.name} must not claim a heading`,
);
}
});
test('TxDOT camera ids are district-scoped and stable across runs', () => {
const build = (district) =>
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
.id;
assert.equal(build('AUS'), build('AUS'));
assert.ok(build('AUS').startsWith('txdot-aus-'));
assert.ok(build('HOU').startsWith('txdot-hou-'));
assert.notEqual(build('AUS'), build('HOU'));
});
test('TxDOT ground elevation uses a per-district prior', () => {
const at = (district) =>
normalizeTxdotDistrictPayload(districtPayload([cameraRow()]), district)[0]
.groundElevationM;
assert.equal(at('HOU'), 15);
assert.equal(at('ELP'), 1140);
assert.equal(at('AUS'), 149);
assert.equal(at('SAT'), 198);
});
test('TxDOT catalog tolerates a malformed payload', () => {
assert.deepEqual(normalizeTxdotDistrictPayload(null, 'AUS'), []);
assert.deepEqual(normalizeTxdotDistrictPayload({}, 'AUS'), []);
assert.deepEqual(
normalizeTxdotDistrictPayload({ roadwayCctvStatuses: [] }, 'AUS'),
[],
);
assert.deepEqual(
normalizeTxdotDistrictPayload(
{ roadwayCctvStatuses: { 'IH-35': null } },
'AUS',
),
[],
);
});
test('TxDOT loader fetches each configured district and ignores unknown codes', async (t) => {
quiet(t);
const saved = process.env.CCTV_TXDOT_DISTRICTS;
process.env.CCTV_TXDOT_DISTRICTS = 'aus, SAT, NOPE, aus';
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
const district = new URL(String(url)).searchParams.get('districtCode');
return Response.json(
districtPayload([
cameraRow({ icd_Id: `${district}-1`, name: `${district}-1` }),
]),
);
});
try {
const cameras = await loadTxdotSourcesFromOpenData();
assert.deepEqual(requested, [
TXDOT_CCTV_STATUS_URL('AUS'),
TXDOT_CCTV_STATUS_URL('SAT'),
]);
assert.deepEqual(cameras.map((camera) => camera.cityId).sort(), [
'tx-aus',
'tx-sat',
]);
} finally {
if (saved === undefined) delete process.env.CCTV_TXDOT_DISTRICTS;
else process.env.CCTV_TXDOT_DISTRICTS = saved;
}
});
test('TxDOT snapshot decodes JSON only from the official origin and only as JPEG', async () => {
const jsonResponse = (body) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
const official =
'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x&districtCode=AUS';
const frame = await fetchTxdotSnapshot(official, {
timeoutMs: 100,
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
});
assert.equal(frame?.ok, true);
assert.equal(frame?.contentType, 'image/jpeg');
assert.deepEqual(frame?.body, JPEG_BYTES);
// Off-origin with the right path, and right origin with the wrong path,
// are each refused on their own.
for (const bad of [
'https://evil.example/its/DistrictIts/GetCctvSnapshotByIcdId?icdId=x',
'https://its.txdot.gov/its/DistrictIts/Other?icdId=x',
]) {
assert.equal(
await fetchTxdotSnapshot(bad, {
timeoutMs: 100,
fetchImpl: async () => jsonResponse({ snippet: JPEG_B64 }),
}),
null,
bad,
);
}
// A redirect is never followed, so the pin holds for the request made.
let calls = 0;
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
fetchImpl: async (_url, init) => {
calls += 1;
assert.equal(init.redirect, 'manual');
return new Response(null, {
status: 302,
headers: { location: 'https://evil.example/x.json' },
});
},
}),
null,
);
assert.equal(calls, 1);
// Junk base64 and oversize bodies are refused before decoding.
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
fetchImpl: async () => jsonResponse({ snippet: '/9!@#j/4AA=' }),
}),
null,
);
// Malformed padding is refused even though Buffer.from() would decode it.
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
fetchImpl: async () => jsonResponse({ snippet: '/9j/4AA==' }),
}),
null,
);
// A real JPEG larger than the frame cap is refused after decoding...
const bigJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(59, 0x11)]);
assert.equal(bigJpeg.length, 64);
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
maxBytes: 8,
fetchImpl: async () =>
jsonResponse({ snippet: bigJpeg.toString('base64') }),
}),
null,
);
assert.equal(
(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
maxBytes: 64,
fetchImpl: async () =>
jsonResponse({ snippet: bigJpeg.toString('base64') }),
})
)?.body.length,
64,
'the same frame passes when it fits the cap',
);
// ...and an envelope that overflows the streaming cap never gets parsed.
const hugeJpeg = Buffer.concat([JPEG_BYTES, Buffer.alloc(8192, 0x11)]);
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
maxBytes: 8,
fetchImpl: async () =>
jsonResponse({ snippet: hugeJpeg.toString('base64') }),
}),
null,
);
const notJpeg = Buffer.from('<html>nope</html>').toString('base64');
for (const body of [
null,
{ snippet: null },
{ snippet: '' },
{ snippet: notJpeg },
]) {
assert.equal(
await fetchTxdotSnapshot(official, {
timeoutMs: 100,
fetchImpl: async () => jsonResponse(body),
}),
null,
);
}
});
+9 -8
View File
@@ -46,7 +46,7 @@ export function cameraHue(index) {
* @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;
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),
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
* @returns {{positions: Float64Array, indices: Uint16Array}}
*/
export function frustumVolumeGeometryData(positions) {
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
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;
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
});
// 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,
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
]);
return { positions: flat, indices };
}
+81
View File
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadWarendorfSourcesFromCatalog } from '../../server/providers/cctv/sources.js';
test('Warendorf catalog registers the Marktplatz webcam on official hosts only', (t) => {
t.mock.method(console, 'log', () => {});
const cameras = loadWarendorfSourcesFromCatalog();
assert.deepEqual(
cameras.map((camera) => camera.id),
['warendorf-marktplatz-rathaus'],
);
for (const camera of cameras) {
assert.ok(
/^https?:\/\/(webcam\.warendorf\.de|www\.kreis-warendorf\.de)\//.test(
camera.url,
),
camera.url,
);
assert.equal(camera.snapshotUrl, camera.url);
assert.equal(camera.sourceKind, 'municipal-webcam');
assert.equal(camera.poseSource, 'curated');
assert.match(camera.license, /^Public municipal webcam data/);
}
});
test('Warendorf loader tolerates a missing catalog file', (t) => {
t.mock.method(console, 'warn', () => {});
assert.deepEqual(
loadWarendorfSourcesFromCatalog({ sourceRoot: '/nonexistent' }),
[],
);
});
test('Warendorf loader skips malformed rows without throwing', (t) => {
t.mock.method(console, 'log', () => {});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-warendorf-'));
fs.mkdirSync(path.join(dir, 'config'));
fs.writeFileSync(
path.join(dir, 'config', 'cctv_sources.warendorf.json'),
JSON.stringify([
{
id: { toString: null },
url: 'https://www.kreis-warendorf.de/a.jpg',
lat: 51.9,
lon: 7.9,
},
{
id: 'ok',
url: 'https://www.kreis-warendorf.de/a.jpg',
lat: 51.9,
lon: 7.9,
},
{
id: 'text-coords',
url: 'https://www.kreis-warendorf.de/a.jpg',
lat: '51.9',
lon: '7.9',
},
{
id: 'null-island',
url: 'https://www.kreis-warendorf.de/a.jpg',
lat: 0,
lon: 0,
},
{
id: 'off-host',
url: 'https://evil.example/a.jpg',
lat: 51.9,
lon: 7.9,
},
]),
);
const cameras = loadWarendorfSourcesFromCatalog({ sourceRoot: dir });
assert.deepEqual(
cameras.map((camera) => camera.id),
['ok'],
);
});
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) {
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 keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next;
}
+27 -8
View File
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
* @returns {string} Canonical form, or '' when there is nothing to compare.
*/
export function canonicalizeContactId(value) {
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
}
/**
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
* @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();
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;
if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '').trim().toLowerCase();
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;
if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
}
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
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 (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
}
+691
View File
@@ -0,0 +1,691 @@
/** Portable retained report-time playback. No scene or scheduling ownership. */
export const FIX_BYTES = 48;
export const FIX_FLAGS = Object.freeze({
VEHICLE_TIME: 1,
FEED_TIME: 2,
RECEIPT_TIME: 4,
FLOOR: 8,
BREAK: 16,
LIVE: 32,
});
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
export function createLagReservoir(capacity = 256) {
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
}
function addLag(reservoir, value) {
if (!reservoir || !Number.isFinite(value) || value < 0) return;
reservoir.values[reservoir.cursor] = value;
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
}
function percentile(reservoir, rank) {
if (!reservoir?.count) return 0;
const sorted = reservoir.values.slice(0, reservoir.count).sort();
return sorted[Math.ceil(sorted.length * rank) - 1];
}
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
return { maxBytes, allocatedBytes: 0 };
}
/** Numeric fields are reserved up front, including unknown mesh heights. */
export function createFixSample() {
return {
t: NaN,
lat: NaN,
lon: NaN,
heightM: NaN,
h: NaN,
receivedAt: NaN,
bearingDeg: NaN,
flags: 0,
epoch: 0,
seq: -1,
};
}
export function createTrack({
capacity = 128,
retentionMs = 900_000,
policy = {},
budget = null,
} = {}) {
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
return {
capacity,
retentionMs,
policy,
budget,
metricsScratchA: createFixSample(),
metricsScratchB: createFixSample(),
storage: new DataView(new ArrayBuffer(0)),
slots: 0,
head: 0,
count: 0,
baseSeq: 0,
revision: 0,
truncated: false,
epochs: new Map(),
lag: createLagReservoir(32),
targetDelayMs: 25_000,
displayT: NaN,
wallAnchor: NaN,
monoAnchor: NaN,
lastMono: NaN,
offset: 0,
rate: 1,
phase: 'buffering',
resets: 0,
resetReason: null,
acceptedAt: NaN,
acceptedMono: NaN,
resumePending: false,
bracket: 0,
metricsRevision: -1,
metricsIndex: -1,
from: createFixSample(),
to: createFixSample(),
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
latest: createFixSample(),
pending: null,
pendingCount: 0,
};
}
function address(track, index) {
return ((track.head + index) % track.slots) * FIX_BYTES;
}
export function readFix(track, index, out = {}) {
if (index < 0 || index >= track.count) return null;
const v = track.storage,
o = address(track, index);
out.t = v.getFloat64(o);
out.lat = v.getFloat64(o + 8);
out.lon = v.getFloat64(o + 16);
out.heightM = v.getFloat32(o + 24);
out.h = out.heightM;
out.receivedAt = v.getFloat64(o + 28);
out.bearingDeg = v.getFloat32(o + 36);
out.flags = v.getUint32(o + 40);
out.epoch = v.getUint32(o + 44);
out.seq = track.baseSeq + index;
return out;
}
function writeFix(track, index, fix) {
const v = track.storage,
o = address(track, index);
v.setFloat64(o, fix.t);
v.setFloat64(o + 8, fix.lat);
v.setFloat64(o + 16, fix.lon);
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
v.setUint32(o + 40, fix.flags || 0);
v.setUint32(o + 44, fix.epoch || 0);
}
function resize(track, slots) {
const bytes = slots * FIX_BYTES,
delta = bytes - track.storage.byteLength;
if (
delta > 0 &&
track.budget &&
track.budget.allocatedBytes + delta > track.budget.maxBytes
)
return false;
const old = track.storage,
head = track.head,
oldSlots = track.slots;
const next = new Uint8Array(bytes),
previous = new Uint8Array(old.buffer);
for (let i = 0; i < track.count; i++) {
const o = ((head + i) % oldSlots) * FIX_BYTES;
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
}
track.storage = new DataView(next.buffer);
track.slots = slots;
track.head = 0;
if (track.budget) track.budget.allocatedBytes += delta;
return true;
}
export function destroyTrack(track) {
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
track.storage = new DataView(new ArrayBuffer(0));
track.count = 0;
track.slots = 0;
track.epochs.clear();
}
function timeAt(track, i) {
return track.storage.getFloat64(address(track, i));
}
function dropOldest(track) {
track.head = (track.head + 1) % track.slots;
track.count--;
track.baseSeq++;
track.revision++;
track.truncated = true;
}
/** Retention never evicts the bracket currently on screen. */
export function trimTrack(track, nowMs, keep = track.capacity) {
while (
track.count > 2 &&
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
) {
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
break;
dropOldest(track);
}
if (keep < track.slots && track.count <= keep)
resize(track, Math.max(2, track.count));
const used = new Set();
for (let i = 0; i < track.count; i++)
used.add(track.storage.getUint32(address(track, i) + 44));
for (const id of track.epochs.keys())
if (!used.has(id)) track.epochs.delete(id);
}
export function validFix(fix) {
return (
Number.isFinite(fix?.t) &&
fix.t >= 0 &&
Number.isFinite(fix.lat) &&
Math.abs(fix.lat) <= 90 &&
Number.isFinite(fix.lon) &&
Math.abs(fix.lon) <= 180
);
}
function distance(a, b) {
const lon = ((b.lon - a.lon + 540) % 360) - 180;
return Math.hypot(
(b.lat - a.lat) * 111320,
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
);
}
function append(track, fix) {
if (track.count === track.slots && track.slots < track.capacity)
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
if (track.count === track.slots) {
if (track.count < 2) return false;
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
// Pin both active ends; drop the next future observation and mark the gap.
if (track.count < 4) return false;
const scratch = {};
for (let i = 2; i < track.count - 1; i++) {
readFix(track, i + 1, scratch);
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
writeFix(track, i, scratch);
}
track.count--;
track.truncated = true;
} else dropOldest(track);
}
writeFix(track, track.count++, fix);
track.revision++;
return true;
}
export function insertFix(track, fix, observation = {}) {
if (
!validFix(fix) ||
(Number.isFinite(observation.wallNowMs) &&
fix.t > observation.wallNowMs + 10_000)
)
return { accepted: false, reason: 'malformed' };
const last = readFix(track, track.count - 1, track.latest);
if (last && fix.t === last.t)
return {
accepted: false,
reason:
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
};
let reason = last ? 'moved' : 'first';
if (
last &&
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
) {
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
const prior = track.pending;
if (
!prior ||
prior.reason !== reason ||
fix.t <= prior.t ||
(track.policy.accept && !track.policy.accept(prior, fix))
) {
if (!prior || fix.t !== prior.t) {
track.pending = { ...fix, reason };
track.pendingCount = 1;
}
return { accepted: false, reason };
}
track.pendingCount++;
const required = reason === 'out-of-order' ? 3 : 2;
if (track.pendingCount < required) {
track.pending = { ...fix, reason };
return { accepted: false, reason };
}
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
if (reason === 'out-of-order') {
track.count = 0;
track.head = 0;
track.baseSeq += 128;
}
const epoch = (last.epoch + 1) >>> 0;
append(track, {
...prior,
epoch,
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
});
fix = { ...fix, epoch };
if (reason === 'out-of-order') track.displayT = NaN;
track.resetReason = reason;
track.resets++;
}
const receivedAt =
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
trimTrack(track, receivedAt);
const accepted = append(track, {
...fix,
receivedAt,
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
});
if (accepted) {
if (!observation.replayed) {
if (last && fix.t > last.t && reason === 'moved') {
addLag(track.lag, receivedAt - last.t);
addLag(track.policy.feedLag, receivedAt - last.t);
track.targetDelayMs = clampDelay(
Math.max(
percentile(track.lag, 0.95),
percentile(track.policy.feedLag, 0.9),
) + 5000,
);
} else if (!last)
track.targetDelayMs = clampDelay(
Math.max(0, receivedAt - fix.t) + 20_000,
);
}
track.acceptedAt = receivedAt;
track.acceptedMono = observation.monoNowMs ?? NaN;
track.resumePending = true;
track.pending = null;
track.pendingCount = 0;
}
return { accepted, reason: accepted ? reason : 'capacity' };
}
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
export function mergeHistory(track, fixes) {
const merged = new Map();
for (const fix of fixes.slice(-128))
if (
validFix(fix) &&
(!Number.isFinite(track.acceptedAt) ||
fix.t >= track.acceptedAt - track.retentionMs)
)
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
for (let i = 0; i < track.count; i++) {
const fix = readFix(track, i);
merged.set(fix.t, fix);
}
const retained = Array.from({ length: track.count }, (_, i) =>
readFix(track, i),
);
const active = Number.isFinite(track.displayT)
? retained.findLastIndex((f) => f.t <= track.displayT)
: -1;
const pinnedFrom = retained[active],
pinnedTo = retained[active + 1];
const trusted = new Set(retained.map((f) => f.t));
const candidates = [...merged.values()]
.sort((a, b) => a.t - b.t)
.filter(
(f) =>
trusted.has(f.t) ||
!pinnedFrom ||
!pinnedTo ||
f.t < pinnedFrom.t ||
f.t > pinnedTo.t,
);
const ordered = [];
// Start from an admitted live position, including when walking backwards
// into retained history. A lone outlier is quarantined in either direction.
// Never borrow the live pending observation as a second vote for itself.
function admit(sequence, anchor, backwards = false) {
let last = anchor,
pending = null;
const plausible = (a, b) =>
!track.policy.accept ||
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
for (const fix of sequence) {
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
ordered.push(fix);
last = fix;
pending = null;
} else if (pending && plausible(pending, fix)) {
// Two distinct coherent observations establish a replacement segment.
// Keep a visible gap between it and the authoritative live path.
if (backwards) last.flags |= FIX_FLAGS.BREAK;
else pending.flags |= FIX_FLAGS.BREAK;
ordered.push(pending, fix);
last = fix;
pending = null;
} else pending = fix;
}
}
const first = retained[0];
if (first) {
admit(
candidates.filter((f) => f.t < first.t).reverse(),
merged.get(first.t),
true,
);
admit(
candidates.filter((f) => f.t >= first.t),
null,
);
} else admit(candidates, null);
ordered.sort((a, b) => a.t - b.t);
while (ordered.length > track.capacity) {
const i = ordered.findIndex(
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
);
if (i > 0 && i + 1 < ordered.length)
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
ordered.splice(Math.max(0, i), 1);
track.truncated = true;
}
for (let i = 1; i < ordered.length; i++) {
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
ordered[i].flags |= FIX_FLAGS.BREAK;
}
track.count = 0;
track.head = 0;
track.baseSeq += 128;
for (const fix of ordered) append(track, fix);
track.revision++;
track.bracket = 0;
}
export function correctHeight(track, seq, heightM) {
const i = seq - track.baseSeq;
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
const o = address(track, i),
value = Math.fround(heightM);
if (track.storage.getFloat32(o + 24) === value) return false;
track.storage.setFloat32(o + 24, value);
track.storage.setUint32(
o + 40,
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
);
track.revision++;
return true;
}
function anchoredWall(track, clocks) {
if (!Number.isFinite(track.wallAnchor)) {
track.wallAnchor = clocks.wallNowMs;
track.monoAnchor = clocks.monoNowMs;
}
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
}
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
export function createSample() {
return {
displayT: NaN,
revision: 0,
nextWakeMonoMs: Infinity,
phase: 'buffering',
motion: 'unknown',
fromSeq: -1,
toSeq: -1,
lat: NaN,
lon: NaN,
heightM: NaN,
segmentCourseDeg: NaN,
segmentSpeedMps: NaN,
fraction: NaN,
fromT: NaN,
toT: NaN,
nextMotionT: Infinity,
latestReportAgeMs: NaN,
actualDelayMs: NaN,
};
}
/** Sample an explicit report time into caller-owned storage. */
export function sampleAt(track, displayTimeMs, out) {
out.displayT = displayTimeMs;
return sampleCurrent(track, out);
}
/** The frame path keeps its changing double in storage across this call. */
function sampleCurrent(track, out) {
const sampleTime = out.displayT;
out.revision = track.revision;
out.nextWakeMonoMs = Infinity;
out.phase = track.phase;
out.motion = 'unknown';
out.fromSeq = -1;
out.toSeq = -1;
out.lat = NaN;
out.lon = NaN;
out.heightM = NaN;
out.segmentCourseDeg = NaN;
out.segmentSpeedMps = 0;
if (!track.count) return out;
let i = Math.min(track.bracket, track.count - 1);
if (timeAt(track, i) > sampleTime) i = 0;
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
track.bracket = i;
const a = readFix(track, i, track.from);
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
const b = readFix(track, j, track.to);
const continuous =
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
out.fromSeq = a.seq;
out.toSeq = continuous ? b.seq : a.seq;
out.fraction = continuous
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
: 1;
out.fromT = a.t;
out.toT = continuous ? b.t : a.t;
if (
track.metricsRevision !== track.revision ||
track.metricsIndex !== i ||
track.metricsTo !== j
) {
const metres = continuous ? distance(a, b) : 0;
const metrics = track.metrics;
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
metrics.course = NaN;
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
metrics.course =
((Math.atan2(
(((b.lon - a.lon + 540) % 360) - 180) *
Math.cos((a.lat * Math.PI) / 180),
b.lat - a.lat,
) *
180) /
Math.PI +
360) %
360;
} else {
// Tiny displacement holds the last reliable course in this epoch.
// With no reliable displacement yet, use the earliest known bearing.
const p = track.metricsScratchA,
q = track.metricsScratchB;
for (let k = i; k >= 0; k--) {
readFix(track, k, q);
if (q.epoch !== a.epoch) break;
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
readFix(track, k - 1, p);
if (
p.epoch === q.epoch &&
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
) {
metrics.course =
((Math.atan2(
(((q.lon - p.lon + 540) % 360) - 180) *
Math.cos((p.lat * Math.PI) / 180),
q.lat - p.lat,
) *
180) /
Math.PI +
360) %
360;
break;
}
}
}
metrics.nextMotionT = Infinity;
const p = track.metricsScratchA,
q = track.metricsScratchB;
for (let k = i; k + 1 < track.count; k++) {
readFix(track, k, p);
readFix(track, k + 1, q);
if (
p.lat !== q.lat ||
p.lon !== q.lon ||
p.epoch !== q.epoch ||
q.flags & FIX_FLAGS.BREAK
) {
metrics.nextMotionT =
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
break;
}
}
track.metricsRevision = track.revision;
track.metricsIndex = i;
track.metricsTo = j;
}
const f = out.fraction;
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
out.lon = continuous
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
: a.lon;
if (out.lon > 180) out.lon -= 360;
if (out.lon < -180) out.lon += 360;
out.heightM = continuous
? a.heightM + (b.heightM - a.heightM) * f
: a.heightM;
out.segmentCourseDeg = track.metrics.course;
out.segmentSpeedMps = track.metrics.speed;
out.motion = continuous
? track.metrics.speed > 0
? 'moving'
: 'stopped'
: 'unknown';
out.nextMotionT = track.metrics.nextMotionT;
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
? track.wallAnchor +
track.lastMono -
track.monoAnchor -
timeAt(track, track.count - 1)
: NaN;
out.actualDelayMs = Number.isFinite(track.wallAnchor)
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
: NaN;
return out;
}
export function advance(track, clocks, out) {
const wall = anchoredWall(track, clocks),
previousMono = track.lastMono;
const dt = Number.isFinite(previousMono)
? Math.max(0, clocks.monoNowMs - previousMono)
: 0;
track.lastMono = clocks.monoNowMs;
if (!track.count) return sampleAt(track, NaN, out);
const newest = timeAt(track, track.count - 1),
oldest = timeAt(track, 0);
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
const delayBlocked =
track.offset === 0 &&
track.phase !== 'playing' &&
ceiling < track.displayT &&
track.displayT < newest;
if (!Number.isFinite(track.displayT))
track.displayT = Math.min(
newest,
wall - track.targetDelayMs + track.offset,
);
else if (
track.offset === 0 &&
track.rate !== 0 &&
(ceiling - track.displayT > 90_000 ||
(track.truncated && track.displayT < oldest))
) {
track.resetReason =
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
track.displayT = ceiling;
track.resets++;
} else if (track.rate !== 0) {
// While playing, finish the active segment at 1x even if lag grows.
const activeCeiling =
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
let next = track.displayT + dt * track.rate;
if (track.phase === 'held' && track.resumePending) {
const sinceReceipt = Number.isFinite(track.acceptedMono)
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
: 0;
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
}
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
}
track.phase =
track.rate === 0
? 'paused'
: track.displayT < oldest || delayBlocked
? 'buffering'
: track.displayT >= newest
? 'held'
: 'playing';
track.resumePending = false;
out.displayT = track.displayT;
sampleCurrent(track, out);
if (out.motion !== 'moving' || track.phase !== 'playing') {
const boundary = Math.max(oldest, out.nextMotionT);
if (
Number.isFinite(boundary) &&
boundary > track.displayT &&
track.rate > 0
)
out.nextWakeMonoMs =
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
}
if (delayBlocked && track.rate > 0)
out.nextWakeMonoMs =
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
out.motion = 'stale';
return out;
}
export function seek(track, displayTimeMs, clocks) {
if (!Number.isFinite(displayTimeMs))
throw new TypeError('A seek needs a finite time');
anchoredWall(track, clocks);
track.displayT = displayTimeMs;
track.offset = -1;
track.rate = 0;
track.phase = 'paused';
track.lastMono = clocks.monoNowMs;
track.bracket = 0;
track.resets++;
track.resetReason = 'seek';
}
export function setTimeOffsetMs(track, offset, clocks) {
if (!Number.isFinite(offset) || offset > 0)
throw new RangeError('Offset must be zero or negative');
if (Number.isFinite(track.displayT)) {
track.resets++;
track.resetReason = 'time-offset';
}
track.offset = offset;
track.rate = 1;
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
track.lastMono = clocks.monoNowMs;
track.phase = 'buffering';
track.bracket = 0;
}
export function setRate(track, rate, clocks) {
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
throw new RangeError('Rate must be in [0, 16]');
if (track.offset === 0 && rate !== 0 && rate !== 1)
throw new RangeError('Live playback uses 1x or hold');
track.rate = rate;
track.lastMono = clocks.monoNowMs;
track.phase = rate === 0 ? 'paused' : 'buffering';
}
+302
View File
@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createTrack,
createLagReservoir,
createHistoryBudget,
insertFix,
readFix,
advance,
sampleAt,
seek,
setRate,
setTimeOffsetMs,
mergeHistory,
correctHeight,
trimTrack,
destroyTrack,
FIX_BYTES,
FIX_FLAGS,
} from './contactPlayback.js';
const fix = (t, metres = t / 100) => ({
t,
lat: 42 + metres / 111320,
lon: -71,
bearingDeg: 0,
});
const clocks = (t) => ({ wallNowMs: t, monoNowMs: t });
const put = (track, t, metres, receipt = t) =>
insertFix(track, fix(t, metres), { receivedAt: receipt, wallNowMs: receipt });
test('10 s reports / 15 s polls and a 6 m crawl move at report speed on ordinary frames', () => {
for (const speed of [10, 0.6]) {
const track = createTrack(),
out = {};
let prior = null,
moving = 0,
pollJumps = 0;
for (let now = 5000; now <= 180000; now += 16) {
if (
!prior ||
Math.floor((now - 5000) / 15000) !== Math.floor((now - 5016) / 15000)
) {
const t = Math.floor((now - 5000) / 10000) * 10000;
put(track, t, (t / 1000) * speed, now);
}
advance(track, clocks(now), out);
if (prior && out.displayT >= 0 && prior.displayT >= 0) {
const d = (out.lat - prior.lat) * 111320;
const dt = out.displayT - prior.displayT;
assert.ok(dt >= 0 && dt <= 16.0001);
if (dt > 0 && d > 0) {
assert.ok(Math.abs(d / (dt / 1000) - speed) < speed * 0.02);
moving++;
}
if (d > speed * 0.0161) pollJumps++;
}
prior = { ...out };
}
assert.ok(
moving > 5000,
`non-vacuous ${speed} m/s trace: ${moving} moving frames`,
);
assert.equal(pollJumps, 0);
}
});
test('stationary observations survive, schedule departure, and distinguish underflow', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 0);
put(track, 30000, 6);
setTimeOffsetMs(track, 0, clocks(35000));
advance(track, clocks(35000), out);
assert.equal(out.motion, 'stopped');
assert.equal(out.nextWakeMonoMs, 40000);
advance(track, clocks(40000), out);
assert.equal(out.motion, 'moving');
advance(track, clocks(55000), out);
assert.equal(out.phase, 'held');
assert.equal(out.segmentSpeedMps, 0);
});
test('same-time conflicts and invalid coordinates never make zero-duration segments', () => {
const track = createTrack();
put(track, 0, 0);
assert.equal(put(track, 0, 0).reason, 'repeat');
assert.equal(put(track, 0, 1).reason, 'conflict');
for (const value of [
{ t: 1, lat: 91, lon: 0 },
{ t: 1, lat: 0, lon: NaN },
{ t: -1, lat: 0, lon: 0 },
])
assert.equal(insertFix(track, value).accepted, false);
assert.equal(track.count, 1);
assert.equal(put(track, 15000, 0).accepted, true);
});
test('ring wrap retains 15 minutes and pins an active bracket under capacity pressure', () => {
const track = createTrack({ capacity: 8 });
for (let i = 0; i < 8; i++) put(track, i * 10000, i);
seek(track, 5000, clocks(80000));
for (let i = 8; i < 30; i++) put(track, i * 10000, i);
const sample = sampleAt(track, 5000, {});
assert.equal(sample.fromT, 0);
assert.equal(sample.toT, 10000);
assert.equal(track.count, 8);
assert.equal(track.storage.byteLength, 8 * FIX_BYTES);
assert.equal(track.truncated, true);
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
seek(track, 250000, clocks(300000));
trimTrack(track, 300000, 2);
assert.ok(track.count <= 5);
});
test('backward seek and history merge leave clock and live health alone', () => {
const track = createTrack(),
out = {};
put(track, 30000, 30);
put(track, 45000, 45);
seek(track, 37500, clocks(80000));
const health = track.acceptedAt;
mergeHistory(track, [fix(0, 0), fix(15000, 15), fix(30000, 999)]);
assert.equal(track.displayT, 37500);
assert.equal(track.acceptedAt, health);
assert.equal(readFix(track, 2).lat, fix(30000, 30).lat);
seek(track, 7500, clocks(80000));
advance(track, clocks(90000), out);
assert.equal(out.displayT, 7500);
assert.equal(out.phase, 'paused');
setRate(track, 1, clocks(90000));
advance(track, clocks(91000), out);
assert.equal(out.displayT, 8500);
});
test('system-clock changes do not alter animation elapsed time', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 60000, 600);
advance(track, clocks(60000), out);
const before = out.displayT;
advance(track, { wallNowMs: 999999999, monoNowMs: 60016 }, out);
assert.equal(out.displayT - before, 16);
setTimeOffsetMs(track, -10000, clocks(60016));
assert.equal(track.resetReason, 'time-offset');
assert.equal(track.resets, 1);
});
test('a 90 s outage holds, then resumes without a sprint; backlog reset adds no second delay', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 15);
for (let t = 25000; t <= 120000; t += 100) advance(track, clocks(t), out);
assert.equal(out.displayT, 15000);
put(track, 105000, 105, 120000);
advance(track, clocks(120100), out);
assert.ok(out.displayT - 15000 <= 100);
track.targetDelayMs = 25000;
put(track, 300000, 300, 300000);
track.targetDelayMs = 25000;
advance(track, clocks(300000), out);
assert.equal(track.resets, 1);
assert.equal(track.resetReason, 'backlog');
assert.equal(out.displayT, 275000);
});
test('lag measures receipt of current minus report of previous and ignores cached deliveries', () => {
const feedLag = createLagReservoir(),
track = createTrack({ policy: { feedLag } });
put(track, 0, 0, 5000);
put(track, 10000, 100, 20000);
assert.equal(track.lag.values[0], 20000);
put(track, 10000, 100, 80000);
assert.equal(track.lag.count, 1);
put(track, 20000, 200, 50000);
assert.equal(track.targetDelayMs, 45000);
});
test('displayed bearing never reads a future fix; height corrections revise typed storage', () => {
const track = createTrack();
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
insertFix(track, { ...fix(15000, 6), bearingDeg: 270 });
const out = sampleAt(track, 7500, {});
assert.equal(out.segmentCourseDeg, 90);
assert.ok(Number.isNaN(out.heightM));
correctHeight(track, out.fromSeq, 11.5);
correctHeight(track, out.toSeq, 21.5);
assert.equal(sampleAt(track, 7500, out).heightM, 16.5);
});
test('allocation budget is hard and destruction returns payload bytes', () => {
const budget = createHistoryBudget(48 * 16),
tracks = Array.from({ length: 3 }, () => createTrack({ budget }));
for (const track of tracks)
for (let t = 0; t < 100; t++) put(track, t * 1000, t);
assert.ok(budget.allocatedBytes <= budget.maxBytes);
for (const track of tracks) destroyTrack(track);
assert.equal(budget.allocatedBytes, 0);
});
test('tiny displacement holds a preceding reliable course instead of a changed bearing', () => {
const track = createTrack();
insertFix(track, { ...fix(0, 0), bearingDeg: 90 });
insertFix(track, { ...fix(15000, 100), bearingDeg: 270 });
insertFix(track, { ...fix(30000, 106), bearingDeg: 180 });
assert.equal(sampleAt(track, 20000, {}).segmentCourseDeg, 0);
});
test('an epoch gap wakes at its far boundary without drawing a connecting segment', () => {
const track = createTrack(),
out = {};
insertFix(track, { ...fix(0, 0), epoch: 1 });
insertFix(track, { ...fix(30000, 100), epoch: 2 });
advance(track, clocks(40000), out);
assert.equal(out.segmentSpeedMps, 0);
assert.equal(out.nextWakeMonoMs, 65000);
});
test('increased lag is adopted while rebuffering, with one monotonic wake and no slow playback', () => {
const track = createTrack(),
out = {};
put(track, 0, 0);
put(track, 15000, 15);
for (let now = 25000; now <= 120000; now += 100)
advance(track, clocks(now), out);
insertFix(track, fix(105000, 105), {
receivedAt: 120000,
wallNowMs: 120000,
monoNowMs: 120000,
});
advance(track, clocks(120100), out);
assert.equal(out.displayT, 15000);
assert.equal(out.phase, 'buffering');
assert.equal(out.nextWakeMonoMs, 125000);
advance(track, { wallNowMs: -9999999, monoNowMs: 125000 }, out);
assert.equal(out.displayT, 15000);
advance(track, { wallNowMs: 9999999, monoNowMs: 125016 }, out);
assert.equal(out.displayT, 15016);
});
test('capacity rejection leaves both lag reservoirs and clock policy unchanged', () => {
const feedLag = createLagReservoir();
const track = createTrack({ capacity: 2, policy: { feedLag } });
put(track, 0, 0);
put(track, 10000, 10);
seek(track, 5000, clocks(10000));
const policyState = () =>
structuredClone({
lag: track.lag,
feedLag,
targetDelayMs: track.targetDelayMs,
acceptedAt: track.acceptedAt,
acceptedMono: track.acceptedMono,
resumePending: track.resumePending,
displayT: track.displayT,
wallAnchor: track.wallAnchor,
monoAnchor: track.monoAnchor,
resetReason: track.resetReason,
resets: track.resets,
});
const before = policyState();
assert.deepEqual(put(track, 20000, 20, 300000), {
accepted: false,
reason: 'capacity',
});
assert.deepEqual(policyState(), before);
});
test('sampleAt writes the supplied output without replacing nested scratch', () => {
const track = createTrack();
put(track, 0, 0);
put(track, 15000, 150);
const out = {},
from = track.from,
to = track.to,
metrics = track.metrics;
for (let i = 0; i < 10000; i++) {
assert.equal(sampleAt(track, i, out), out);
assert.equal(track.from, from);
assert.equal(track.to, to);
assert.equal(track.metrics, metrics);
}
});
test('backfill pins the live bracket and quarantines outliers on either side', () => {
const track = createTrack({
policy: {
accept: (a, b) =>
Math.abs(b.lat - a.lat) * 111320 <= ((b.t - a.t) / 1000) * 36,
},
});
put(track, 10000, 0);
put(track, 25000, 100);
seek(track, 17500, clocks(40000));
const before = { ...sampleAt(track, 17500, {}) };
mergeHistory(track, [fix(0, -111320), fix(17500, 25), fix(40000, 111320)]);
const after = sampleAt(track, 17500, {});
assert.equal(track.count, 2);
assert.equal(after.fromT, before.fromT);
assert.equal(after.toT, before.toT);
assert.equal(after.lat, before.lat);
mergeHistory(track, [fix(40000, 111320), fix(55000, 111420)]);
assert.equal(
track.count,
4,
'two coherent replacement fixes can be retained',
);
assert.ok(readFix(track, 2).flags & FIX_FLAGS.BREAK);
assert.equal(sampleAt(track, 30000, {}).lat, fix(25000, 100).lat);
});
+309
View File
@@ -0,0 +1,309 @@
import * as Cesium from 'cesium';
const ALPHA_STOPS = [
[0, 0.8],
[120000, 0.45],
[600000, 0.2],
[900000, 0.08],
];
export function trailAlpha(ageMs) {
const stops = ALPHA_STOPS;
for (let i = 1; i < stops.length; i++) {
if (ageMs === stops[i][0]) return stops[i][1];
if (ageMs < stops[i][0]) {
const [a, x] = stops[i - 1],
[b, y] = stops[i];
return x + ((y - x) * Math.max(0, ageMs - a)) / (b - a);
}
}
return 0.08;
}
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
visible = true,
style = '#5EF08A';
let lastSecond = -1,
completed = -1,
active = null;
const styleColor = Cesium.Color.fromCssColorString(style);
const attributeIds = [];
const color = new Cesium.Color(),
endpoint = new Cesium.Cartesian3();
const headPositions = [new Cesium.Cartesian3(), endpoint];
let rebuilds = 0;
function tint(alpha, dark = false) {
if (dark) {
color.red = 0.02;
color.green = 0.03;
color.blue = 0.05;
} else Cesium.Color.clone(styleColor, color);
color.alpha = alpha;
return color;
}
function replaceHistory(next) {
if (next.revision === revision) return;
revision = next.revision;
segments = next.segments;
completed = -1;
lastSecond = -1;
active = null;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
attributeIds.push([`${i}:0`, `${i}:1`]);
if (segment.positions.length < 2) continue;
for (let lane = 0; lane < 2; lane++) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
}),
);
}
}
if (instances.length) {
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
}
function setDisplaySample(sample, renderedPosition) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
let lo = 0,
hi = segments.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (segments[mid].toT <= sample.displayT) lo = mid + 1;
else hi = mid;
}
const done = lo - 1;
const candidate = segments[lo];
const segment =
candidate &&
candidate.fromT <= sample.displayT &&
candidate.fromSeq === sample.fromSeq &&
candidate.toSeq === sample.toSeq
? candidate
: null;
if (body?.ready && done !== completed) {
for (
let i = Math.min(done, completed) + 1;
i <= Math.max(done, completed);
i++
) {
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
}
if (body?.ready && second !== lastSecond) {
for (let i = 0; i < segments.length; i++) {
const alpha = trailAlpha(sample.displayT - segments[i].toT);
for (let lane = 0; lane < 2; lane++) {
const attributes = body.getGeometryInstanceAttributes(
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
rgba,
);
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
}
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
setDisplaySample,
setStyle(next) {
if (next === style) return;
style = next;
Cesium.Color.fromCssColorString(style, styleColor);
lastSecond = -1;
},
setVisible(next) {
visible = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
diagnostics() {
return {
revision,
rebuilds,
segments: segments.length,
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
};
}
+357
View File
@@ -0,0 +1,357 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { readFileSync } from 'node:fs';
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t, ground = false) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
'ImageBitmap',
'OffscreenCanvas',
]) {
const prior = globalThis[key];
globalThis[key] = class {};
t.after(() => {
if (prior === undefined) delete globalThis[key];
else globalThis[key] = prior;
});
}
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
const groundPrimitives = new Cesium.PrimitiveCollection();
t.after(() => groundPrimitives.destroy());
return {
primitives,
groundPrimitives,
frameState: { context: { depthTexture: ground } },
globe: { show: false },
};
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
renderer = createContactTrailRenderer(scene);
const positions = [0, 1, 2].map((i) =>
Cesium.Cartesian3.fromDegrees(-71, 42 + i * 0.001, 11.5),
);
renderer.replaceHistory({
revision: 1,
segments: [
{
fromSeq: 0,
toSeq: 1,
fromT: 0,
toT: 10000,
positions: positions.slice(0, 2),
},
{
fromSeq: 0,
toSeq: 1,
fromT: 10000,
toT: 20000,
positions: positions.slice(1, 3),
},
],
});
const { body } = renderer.diagnostics();
assert.equal(body.allowPicking, false);
// The constructor defers this check until shader preparation. Run the real
// Cesium validator without WebGL, including a missing-attribute control.
const source = body.depthFailAppearance.vertexShaderSource;
body._batchTableAttributeIndices = { color: 0 };
assert.throws(
() => Cesium.Primitive._updateColorAttribute(body, source, true),
/depthFailColor/,
);
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
assert.doesNotThrow(() =>
Cesium.Primitive._updateColorAttribute(body, source, true),
);
const attributes = new Map(
body.geometryInstances.map((instance) => [
instance.id,
{
show: new Uint8Array([0]),
color: new Uint8Array(4),
depthFailColor: new Uint8Array(4),
},
]),
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => {
const stored = attributes.get(id),
accessor = {};
for (const name of ['show', 'color', 'depthFailColor'])
Object.defineProperty(accessor, name, {
get: () => stored[name].slice(),
set: (value) => {
stored[name] = value.slice();
},
});
return accessor;
};
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
0.5,
new Cesium.Cartesian3(),
);
const sample = { displayT: 15000, fromSeq: 0, toSeq: 1, fraction: 0.75 };
for (let i = 0; i < 120; i++) renderer.setDisplaySample(sample, marker);
assert.deepEqual(
[...attributes.values()].map((a) => a.show[0]),
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
assert.equal(result.head.material.uniforms.fraction, 0.5);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
{ ...sample, displayT: 5000, fraction: 0.25 },
positions[0],
);
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'rewind hides future geometry again',
);
renderer.destroy();
assert.equal(scene.primitives.length, 0);
});
test('trail age curve uses the specified four alpha anchors', () => {
assert.equal(trailAlpha(0), 0.8);
assert.equal(trailAlpha(120000), 0.45);
assert.equal(trailAlpha(600000), 0.2);
assert.equal(trailAlpha(900000), 0.08);
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
readFileSync(
new URL(
import.meta
.resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'),
),
),
);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
const start = performance.now();
const instances = async (count) =>
Promise.all(
Array.from(
{ length: count },
async (_, i) =>
new Cesium.GeometryInstance({
id: i,
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
new Cesium.GroundPolylineGeometry({
positions,
width: i % 2 ? 3 : 5,
granularity: 0,
}),
),
}),
),
);
const pipelineBytes = async (count) => {
// Match GroundPolylinePrimitive's internal Primitive options and the
// application's morph-capable scene, including batch IDs and encoding.
const result = Cesium.PrimitivePipeline.combineGeometry({
instances: await instances(count),
projection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
elementIndexUintSupported: true,
scene3DOnly: false,
vertexCacheOptimize: false,
compressVertices: false,
createPickOffsets: false,
});
return result.geometries.reduce(
(sum, geometry) =>
sum +
Object.values(geometry.attributes).reduce(
(n, a) => n + (a?.values?.byteLength || 0),
0,
) +
geometry.indices.byteLength,
0,
);
};
const bytes = await pipelineBytes(1);
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
const bodyBytes = await pipelineBytes(bodyInstances);
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
assert.equal(bodyBytes, 2014128);
const instanceReserve = 64 * bodyInstances;
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
assert.ok(
bytes <= 2048,
'subdivision crossings stay below 2 KiB of head geometry',
);
console.log(
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
);
});
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
const scene = fixture(t, true),
renderer = createContactTrailRenderer(scene);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
renderer.replaceHistory({
revision: 1,
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
});
const first = renderer.diagnostics();
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
assert.ok(scene.groundPrimitives.contains(first.body));
for (const instance of first.body.geometryInstances)
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
for (let i = 1; i <= 120; i++)
renderer.setDisplaySample(
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
positions[1],
);
const result = renderer.diagnostics();
assert.equal(result.body, first.body);
assert.equal(result.headRebuilds, 1);
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
assert.equal(result.head.material.uniforms.fraction, 0.6);
assert.match(result.head.material.shaderSource, /discard/);
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
scene.globe.show = stack !== 'Google';
renderer.setDisplaySample(
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
positions[1],
);
const retained = renderer.diagnostics();
assert.equal(retained.body, result.body, `${stack}: retained history`);
assert.equal(retained.headPrimitive, result.headPrimitive);
for (const primitive of [retained.body, retained.headPrimitive]) {
assert.equal(
primitive.classificationType,
Cesium.ClassificationType.BOTH,
stack,
);
assert.equal(primitive.show, true, stack);
}
}
renderer.setVisible(false);
assert.equal(result.body.show, false);
assert.equal(result.headPrimitive.show, false);
renderer.destroy();
assert.equal(scene.groundPrimitives.length, 0);
assert.equal(scene.primitives.length, 0);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
const renderer = createContactTrailRenderer(fixture(t));
const positions = [
Cesium.Cartesian3.fromDegrees(-71, 42),
Cesium.Cartesian3.fromDegrees(-71, 42.001),
];
renderer.replaceHistory({
revision: 1,
segments: Array.from({ length: 100 }, (_, i) => ({
fromSeq: 0,
toSeq: 1,
fromT: i * 500,
toT: (i + 1) * 500,
positions,
})),
});
const { body } = renderer.diagnostics();
const counts = { show: 0, color: 0, depthFailColor: 0 };
const attributes = new Map(
body.geometryInstances.map((instance) => {
const result = {};
for (const name of Object.keys(counts)) {
let value = instance.attributes[name].value;
Object.defineProperty(result, name, {
get: () => value,
set(next) {
value = next;
counts[name]++;
},
});
}
return [instance.id, result];
}),
);
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
const display = (displayT) =>
renderer.setDisplaySample(
{ displayT, fromSeq: 0, toSeq: 1, fraction: 0.1 },
positions[0],
);
const reset = () => {
for (const name of Object.keys(counts)) counts[name] = 0;
};
display(100);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
reset();
display(600);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
reset();
display(100);
assert.deepEqual(counts, { show: 2, color: 0, depthFailColor: 0 });
assert.ok(
[...attributes.values()].every((a) => a.show[0] === 0),
'backward seek hides the completed range',
);
reset();
display(1100);
assert.deepEqual(counts, { show: 4, color: 200, depthFailColor: 200 });
reset();
display(1200);
assert.deepEqual(counts, { show: 0, color: 0, depthFailColor: 0 });
renderer.setStyle('#FF4538');
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
),
);
});
+28 -13
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId;
store.selectedAt = Date.now();
const record = store.entities.get(contextId);
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
window.dispatchEvent(
new CustomEvent('gev:entity-selected', { detail: record }),
);
return record;
}
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
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);
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 };
@@ -146,33 +149,44 @@ export function getSelectedEntityContext({ dataManager = null } = {}) {
* 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 } = {}) {
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' },
}));
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) {
/**
* Remove obsolete context records when a viewport-scoped layer refreshes.
* Optional retainIds keeps surviving records and their current selection intact.
*/
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
const store = getContextStore();
for (const [id, record] of store.entities) {
if (record?.layerId === layerId) store.entities.delete(id);
if (record?.layerId === layerId && !retainIds?.has(id))
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' },
}));
window.dispatchEvent(
new CustomEvent('gev:entity-selection-cleared', {
detail: { layerId, reason: 'evicted' },
}),
);
}
}
@@ -180,6 +194,7 @@ 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;
if (dataManager && record.layerId && !dataManager.isEnabled(record.layerId))
return false;
return true;
}
+140 -4
View File
@@ -7,7 +7,7 @@ import * as Cesium from 'cesium';
* docs/pre-ship-audit-2026-07-01.md): 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.
* cables), NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic (CC BY 4.0),
* The MIT code license does NOT cover this data.
*
* These credits are registered ONCE at init as STATIC credits with
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
{
key: 'adsbdb',
html:
'Aircraft type, registration &amp; flight routes: ' +
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
'Guillaume Michel · route data is the work of ' +
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'aisstream',
html:
@@ -81,6 +92,14 @@ export const DATA_CREDITS = [
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
},
{
key: 'alpr-osm',
html:
'ALPR camera locations (automatic license plate readers): ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(<a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener">ODbL 1.0</a>); ' +
'community mapping includes <a href="https://deflock.org" target="_blank" rel="noopener">DeFlock</a>',
},
{
key: 'military-installations-osm',
html:
@@ -91,7 +110,7 @@ export const DATA_CREDITS = [
{
key: 'cockpit-place-osm',
html:
'Cockpit place context: ' +
'Cockpit place context and last-resort place search: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'via Nominatim (ODbL 1.0)',
},
@@ -122,6 +141,12 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames: City of Austin, TX — ' +
'<a href="https://data.austintexas.gov" target="_blank" rel="noopener">data.austintexas.gov</a>',
},
{
key: 'txdot-cctv',
html:
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'caltrans-cctv',
html:
@@ -135,10 +160,44 @@ export const DATA_CREDITS = [
'<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: 'ontario-511-cctv',
html:
'CCTV cameras &amp; frames (Ontario): ' +
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
},
{
key: 'fintraffic-cctv',
html:
'CCTV cameras &amp; frames (Finland): Fintraffic / ' +
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
},
{
key: 'osrm-routing',
// The service asks for its attribution to carry a "fix the map" link, so
// a reader who spots a wrong turn can go and correct the data it came from.
html:
'Routing (voice routes and Directions): OSRM on the FOSSGIS servers — ' +
'<a href="https://routing.openstreetmap.de/about.html" target="_blank" rel="noopener">routing.openstreetmap.de</a> · ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> (ODbL) · ' +
'<a href="https://www.openstreetmap.org/fixthemap" target="_blank" rel="noopener">fix the map</a>',
},
{
key: 'gtfs-rt',
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
},
{
key: 'radio-browser',
html:
@@ -176,6 +235,40 @@ export const DATA_CREDITS = [
'(<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: 'drivebc-cctv',
html:
'CCTV cameras &amp; frames (British Columbia): ' +
'<a href="https://www.drivebc.ca/" target="_blank" rel="noopener">DriveBC</a>. ' +
'Contains information licensed under the ' +
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
},
{
key: 'tallinn-cctv',
html:
'CCTV cameras &amp; frames (Tallinn): City of Tallinn — ' +
'<a href="https://ristmikud.tallinn.ee/" target="_blank" rel="noopener">ristmikud.tallinn.ee</a> (courtesy)',
},
{
key: 'tarktee-cctv',
html:
'CCTV cameras &amp; frames (Estonia road weather): Transpordiamet / Tarktee — ' +
'<a href="https://tarktee.transpordiamet.ee/" target="_blank" rel="noopener">tarktee.transpordiamet.ee</a> (courtesy)',
},
{
key: 'warendorf-cctv',
html:
'Webcam (Warendorf): <a href="https://www.warendorf.de/" target="_blank" rel="noopener">Stadt Warendorf</a> (courtesy); ' +
'camera poses derived from OpenStreetMap geometry, © OpenStreetMap contributors (ODbL)',
},
{
key: 'nsw-cctv',
html:
'CCTV cameras &amp; frames (New South Wales): ' +
'<a href="https://www.livetraffic.com/" target="_blank" rel="noopener">Live Traffic NSW</a> — Transport for NSW ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>)',
},
{
key: 'telegeography',
html:
@@ -209,6 +302,49 @@ export const NATURAL_EARTH_CREDIT = {
'<a href="https://www.naturalearthdata.com" target="_blank" rel="noopener">Natural Earth</a> (public domain)',
};
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return {
key: `transit-${feed.id}`,
html:
`Transit (${escape(feed.attribution)}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
/** Registered when the Bhote Koshi event reconstruction activates. */
export const BHOTE_KOSHI_CREDIT = {
key: 'bhote-koshi-2026',
html:
'Bhote Koshi 2026 event imagery and derived reconstruction: ' +
'<a href="https://vantor.com/company/open-data-program" target="_blank" rel="noopener">Vantor Open Data</a> ' +
'and <a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction" target="_blank" rel="noopener">GeoPera</a> ' +
'(CC BY-NC 4.0); terrain © Re:Earth / Mapterhorn (CC BY 4.0)',
};
/** Registered when the scene-friendly Nepal incident locator activates. */
export const BHOTE_KOSHI_LOCATOR_CREDIT = {
key: 'bhote-koshi-locator',
html:
'Nepal administrative boundary and nearby-city locations: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0); incident-place anchors use an owner-curated, source-verified geolocation union; ' +
'flood corridor derived from the ' +
'<a href="https://github.com/geo-pera/bhotekoshi-2026-reconstruction/blob/main/vectors/river_centerline.geojson" target="_blank" rel="noopener">GeoPera river centerline</a> ' +
'(CC BY-NC 4.0)',
};
/** @type {Set<string>} Keys of dynamic credits already registered this session. */
const _dynamicCreditKeys = new Set();
@@ -238,12 +374,12 @@ export function registerDynamicCredit(viewer, credit) {
* present in the "Data attribution" popover.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer
*/
export function registerDataCredits(viewer) {
export function registerDataCredits(viewer, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
return;
}
for (const { html } of DATA_CREDITS) {
for (const { html } of credits) {
// showOnScreen=false → lives in the expandable "Data attribution" popover,
// not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
assert.equal(
new Set(keys).size,
keys.length,
'a duplicate key would silently shadow one provider’s credit',
);
for (const entry of DATA_CREDITS) {
assert.ok(entry.key, 'a credit without a key cannot be registered');
assert.ok(
entry.html && entry.html.trim().length > 0,
`credit ${entry.key} has nothing to show`,
);
}
});
test('adsbdb is credited and carries its published route-data restriction', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
assert.ok(
credit,
'adsbdb supplies aircraft type and routes and must be credited',
);
// adsbdb publishes this restriction for its route data. Pin the provider's
// credits and restriction here so a later edit cannot silently remove them.
assert.match(credit.html, /David Taylor, Edinburgh/);
assert.match(credit.html, /Jim Mason, Glasgow/);
assert.match(
credit.html,
/may not be\s+copied, published, or incorporated into other databases/,
);
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
assert.match(credit.html, /PlaneBase/);
assert.match(credit.html, /Guillaume Michel/);
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
+240 -70
View File
@@ -1,3 +1,4 @@
import { paintTransitBracket } from './detectionDraw.js';
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
import {
@@ -10,6 +11,7 @@ import {
import {
acquireAlpha,
appendCornerBracket,
appendTransitBracket,
resolveTier,
measureTrackLabel,
nearFarScale,
@@ -217,6 +219,8 @@ let _hostLane = null;
* @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null}
*/
let _calloutLane = null;
let _transitBracketPaths = new Map();
let _transitBracketAlpha = 1;
/**
* Callouts solved by the sensor lane this frame, replayed by the callout lane.
* The array and its rows are POOLED — `_calloutCount` is the live length — so a
@@ -275,7 +279,10 @@ let _cockpitModeListener = null;
*/
export function initDetection(viewer, layers, onModeChange) {
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
}
_hostLane?.unregister?.();
_calloutLane?.unregister?.();
@@ -319,7 +326,10 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */
export function destroyDetection() {
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener('gev:cockpit-mode-changed', _cockpitModeListener);
window.removeEventListener(
'gev:cockpit-mode-changed',
_cockpitModeListener,
);
}
_cockpitModeListener = null;
_cockpitActive = false;
@@ -329,6 +339,7 @@ export function destroyDetection() {
_hostLane = null;
_calloutLane?.unregister?.();
_calloutLane = null;
_transitBracketPaths.clear();
_calloutCount = 0;
_viewer = null;
_layers = [];
@@ -392,7 +403,8 @@ export function setMode(modeLabel) {
_mode = MODE_OFF;
} else {
const requestedDensity = defaultDensityForProfile(target);
if (profileForDensity(_densityPct) !== target) _densityPct = requestedDensity;
if (profileForDensity(_densityPct) !== target)
_densityPct = requestedDensity;
_mode = _modeForDensity();
_lastNonOffMode = _mode;
}
@@ -457,7 +469,10 @@ export function isDetectionSuspended() {
*/
export function setDetectionTuning(options = {}) {
let modeChanged = false;
if (typeof options.densityPct === 'number' && Number.isFinite(options.densityPct)) {
if (
typeof options.densityPct === 'number' &&
Number.isFinite(options.densityPct)
) {
const nextDensity = canonicalizeDensity(options.densityPct);
if (nextDensity !== _densityPct) {
_densityPct = nextDensity;
@@ -472,7 +487,10 @@ export function setDetectionTuning(options = {}) {
}
}
if (options.allocationStrategy != null) {
const nextStrategy = normalizeAllocationStrategy(options.allocationStrategy, _allocationStrategy);
const nextStrategy = normalizeAllocationStrategy(
options.allocationStrategy,
_allocationStrategy,
);
if (nextStrategy !== _allocationStrategy) {
_allocationStrategy = nextStrategy;
_labelSolveDirty = true;
@@ -517,7 +535,14 @@ export function markDetectionSourcesChanged(reason = 'sources-changed') {
/** Read-only diagnostics for unit/browser QA. */
export function getDetectionDiagnostics() {
return _lastDiagnostics ? JSON.parse(JSON.stringify(_lastDiagnostics)) : null;
if (!_lastDiagnostics) return null;
const result = JSON.parse(JSON.stringify(_lastDiagnostics));
// Readback only: expose painted plates so pixel QA can exclude occluded
// sprites/strokes. Nothing is copied on the production paint path.
result.calloutRects = _calloutPool
.slice(0, _calloutCount)
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
return result;
}
function _publishDiagnostics() {
@@ -525,19 +550,30 @@ function _publishDiagnostics() {
const dataset = _hostCanvas.dataset;
dataset.profile = _lastDiagnostics.profile || 'OFF';
dataset.densityPct = String(_lastDiagnostics.densityPct ?? _densityPct);
dataset.allocationStrategy = _lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.allocationStrategy =
_lastDiagnostics.allocationStrategy || _allocationStrategy;
dataset.solveRevision = String(_lastDiagnostics.solveRevision ?? 0);
dataset.collectiveLabelBudget = String(_lastDiagnostics.collectiveLabelBudget ?? 0);
dataset.collectiveLabelBudget = String(
_lastDiagnostics.collectiveLabelBudget ?? 0,
);
dataset.labelsByLayer = JSON.stringify(_lastDiagnostics.labelsByLayer || {});
dataset.entitlementByLayer = JSON.stringify(_lastDiagnostics.entitlementByLayer || {});
dataset.entitlementByLayer = JSON.stringify(
_lastDiagnostics.entitlementByLayer || {},
);
dataset.labeledKeys = JSON.stringify(_lastDiagnostics.labeledKeys || []);
dataset.labelAlphaByKey = JSON.stringify(_lastDiagnostics.labelAlphaByKey || {});
dataset.bracketOpacityCounts = JSON.stringify(_lastDiagnostics.bracketOpacityCounts || {});
dataset.labelAlphaByKey = JSON.stringify(
_lastDiagnostics.labelAlphaByKey || {},
);
dataset.bracketOpacityCounts = JSON.stringify(
_lastDiagnostics.bracketOpacityCounts || {},
);
dataset.keyholeRadius = String(_lastDiagnostics.keyholeRadius ?? 0);
dataset.keyholeFeatherPx = String(_lastDiagnostics.keyholeFeatherPx ?? 0);
dataset.demandByLayer = JSON.stringify(_lastDiagnostics.demandByLayer || {});
dataset.cohortByLayer = JSON.stringify(_lastDiagnostics.cohortByLayer || {});
dataset.placementBuildCount = String(_lastDiagnostics.placementBuildCount ?? 0);
dataset.placementBuildCount = String(
_lastDiagnostics.placementBuildCount ?? 0,
);
dataset.observationCount = String(_lastDiagnostics.observationCount ?? 0);
dataset.selectedCount = String(_lastDiagnostics.selectedCount ?? 0);
dataset.fadingCount = String(_lastDiagnostics.fadingCount ?? 0);
@@ -622,7 +658,11 @@ function _applyModeState() {
_labelArbiter.clear();
_lastLabelSolveAt = 0;
_labelSolveDirty = true;
_lastSolveSnapshot = { demandByLayer: {}, cohortByLayer: {}, cohortCount: 0 };
_lastSolveSnapshot = {
demandByLayer: {},
cohortByLayer: {},
cohortCount: 0,
};
} else {
_enableTime = _nowMs(); // restart the subtle fade-in on (re)activation
_syncSurfaceVisibility();
@@ -655,8 +695,10 @@ function _shouldPaintDetectionLane(frame) {
});
if (decision.skip) {
_throttleSkipCount++;
if (_lastDiagnostics) _lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp) governorRequestRender('detection-paint-skipped');
if (_lastDiagnostics)
_lastDiagnostics.throttleSkipCount = _throttleSkipCount;
if (decision.requestFollowUp)
governorRequestRender('detection-paint-skipped');
return false;
}
return true;
@@ -677,8 +719,13 @@ function _paintDetectionLane(frame) {
_labelSolveDirty = true;
}
const start = performance.now();
const result = _drawOverlay(frame)
|| { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
const result = _drawOverlay(frame) || {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
_lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
@@ -695,17 +742,19 @@ function _paintDetectionLane(frame) {
// exactly what makes it safe where the old blanket hold was not. On a parked
// scene with nothing animating it asks for nothing, and the governor stays
// idle. See `detectionRenderDemand.js`.
if (detectionNeedsFollowUpFrame({
active: _mode !== MODE_OFF && !_suspended,
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
solvePending: result.solvePending === true,
})) {
if (
detectionNeedsFollowUpFrame({
active: _mode !== MODE_OFF && !_suspended,
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
solvePending: result.solvePending === true,
})
) {
governorRequestRender('detection-animation');
}
}
@@ -794,7 +843,8 @@ function _drawModeBanner(visibleCount, sampledCount) {
if (!_debugBanner) return;
const mode = MODE_LABELS[_mode];
const paused = _suspended ? ` PAUSED:${_suspendReason || 'transition'}` : '';
const allocation = _allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
const allocation =
_allocationStrategy === ALLOCATION_ELASTIC ? 'ELASTIC' : 'WEIGHTED';
const text = `${mode} VIS:${visibleCount} SRC:${sampledCount} DENS:${_densityPct}% ${allocation} ${_lastRenderMs.toFixed(1)}ms${paused}`;
_ctx.fillStyle = _theme.labelBg;
_ctx.fillRect(12, 12, Math.max(180, _ctx.measureText(text).width + 12), 16);
@@ -890,16 +940,27 @@ function _buildLabelPlacements(
const placements = [];
for (const placement of raw) {
const { cardX, cardY } = placement;
if (cardX < margin || cardY < margin || cardX + card.w > width - margin || cardY + card.h > height - margin) {
if (
cardX < margin ||
cardY < margin ||
cardX + card.w > width - margin ||
cardY + card.h > height - margin
) {
continue;
}
const cardRect = { x: cardX, y: cardY, w: card.w, h: card.h };
if (rectIntersectsAny(cardRect, occlusionRects)) continue;
const leadToX = placement.leadToSide.endsWith('E') ? cardX + card.w : cardX;
const leadToY = placement.leadToSide.startsWith('S') ? cardY + card.h : cardY;
const leadToY = placement.leadToSide.startsWith('S')
? cardY + card.h
: cardY;
const centerX = cardX + card.w * 0.5;
const centerY = cardY + card.h * 0.5;
const radialAlpha = keyholeLabelAlphaFromGeometry(centerX, centerY, keyhole);
const radialAlpha = keyholeLabelAlphaFromGeometry(
centerX,
centerY,
keyhole,
);
if (radialAlpha <= 0) continue;
placements.push({
...placement,
@@ -930,18 +991,37 @@ function _buildLabelPlacements(
*/
function _stashCallout(entry, acquireFade, keyhole) {
const { candidate, placement, temporalAlpha } = entry;
const radialAlpha = keyholeLabelAlphaFromGeometry(placement.centerX, placement.centerY, keyhole);
const radialAlpha = keyholeLabelAlphaFromGeometry(
placement.centerX,
placement.centerY,
keyhole,
);
const alpha = acquireFade * temporalAlpha * radialAlpha;
if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount];
if (!row) {
row = {
x: 0, y: 0, w: 0, h: 0,
primaryX: 0, microX: 0, baseline: 0,
leadFromX: 0, leadFromY: 0, leadToX: 0, leadToY: 0,
plate: '', plateScale: 1, accent: '', label: '', primary: '', micro: '',
font: FONT, microFont: MICRO_FONT, alpha: 1,
x: 0,
y: 0,
w: 0,
h: 0,
primaryX: 0,
microX: 0,
baseline: 0,
leadFromX: 0,
leadFromY: 0,
leadToX: 0,
leadToY: 0,
plate: '',
plateScale: 1,
accent: '',
label: '',
primary: '',
micro: '',
font: FONT,
microFont: MICRO_FONT,
alpha: 1,
};
_calloutPool[_calloutCount] = row;
}
@@ -981,9 +1061,20 @@ function _stashCallout(entry, acquireFade, keyhole) {
* @param {Object} frame Host paint frame.
*/
function _paintCalloutLane(frame) {
if (_mode === MODE_OFF || _suspended || _calloutCount === 0) return;
if (_mode === MODE_OFF || _suspended) return;
const ctx = frame.ctx;
if (!ctx) return;
for (const bands of _transitBracketPaths.values()) {
for (const entry of bands) {
if (entry)
paintTransitBracket(
ctx,
entry.path,
entry.color,
entry.alpha * _transitBracketAlpha,
);
}
}
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
for (let i = 0; i < _calloutCount; i++) {
@@ -1006,7 +1097,14 @@ function _plateScaleForBackdrop(skyFactor) {
}
/** Materialize one bounded rich-callout candidate for the existing arbiter. */
function _materializeCandidate(obj, width, height, keyhole, occlusionRects, cameraPosition) {
function _materializeCandidate(
obj,
width,
height,
keyhole,
occlusionRects,
cameraPosition,
) {
const primary = obj._candidatePrimary;
const micro = obj._candidateMicro;
const card = measureTrackLabel(primary, micro, _charWidth);
@@ -1027,7 +1125,10 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
for (const placement of placements) {
centerDistance = Math.min(
centerDistance,
Math.hypot(placement.centerX - keyhole.centerX, placement.centerY - keyhole.centerY),
Math.hypot(
placement.centerX - keyhole.centerX,
placement.centerY - keyhole.centerY,
),
);
keyholeAlpha = Math.max(keyholeAlpha, placement.keyholeAlpha);
}
@@ -1048,7 +1149,9 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
// Costed here rather than in the object sweep: the sweep walks every
// observation in view, while this runs only for callouts that actually
// placed — a budgeted handful per frame.
plateScale: _plateScaleForBackdrop(skyBackdropFactor(cameraPosition, obj.position)),
plateScale: _plateScaleForBackdrop(
skyBackdropFactor(cameraPosition, obj.position),
),
primary,
micro,
hasMicro: card.hasMicro,
@@ -1061,9 +1164,16 @@ function _materializeCandidate(obj, width, height, keyhole, occlusionRects, came
* for a bounded cohort on solve ticks.
*/
function _drawOverlay(frame) {
_transitBracketPaths.clear();
const { width, height } = frame;
if (!_ctx || width <= 0 || height <= 0) {
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
return {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
}
// ONE timestamp per frame, monotonic, shared with the demand policy below and
@@ -1071,12 +1181,16 @@ function _drawOverlay(frame) {
// animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
const shouldSolve = _labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
const shouldSolve =
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
const calloutOcclusionRects = frame.uiRects;
const selectedIdentities = shouldSolve
? _labelArbiter.liveIdentities({ includeFading: false, now })
: null;
const renderIdentities = _labelArbiter.liveIdentities({ includeFading: true, now });
const renderIdentities = _labelArbiter.liveIdentities({
includeFading: true,
now,
});
const objects = _collectDetectableObjects();
const sampledCount = objects.length;
if (objects.length === 0) {
@@ -1100,7 +1214,9 @@ function _drawOverlay(frame) {
profile: MODE_LABELS[_mode],
densityPct: _densityPct,
allocationStrategy: _allocationStrategy,
viewScale: viewScaleForAltitude(_viewer?.camera?.positionCartographic?.height),
viewScale: viewScaleForAltitude(
_viewer?.camera?.positionCartographic?.height,
),
candidateCount: 0,
observationCount: 0,
visibleCount: 0,
@@ -1128,7 +1244,13 @@ function _drawOverlay(frame) {
// whole mechanism exists to remove.
_lastLabelSolveAt = now;
_labelSolveDirty = false;
return { didSolve: false, solveMs: 0, fadingCount: 0, animatingCount: 0, solvePending: false };
return {
didSolve: false,
solveMs: 0,
fadingCount: 0,
animatingCount: 0,
solvePending: false,
};
}
// Horizon culling, keyhole geometry, and camera transforms are shared with
@@ -1163,7 +1285,10 @@ function _drawOverlay(frame) {
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
const pathFor = (map, color, alpha) => {
const band = Math.max(1, Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)));
const band = Math.max(
1,
Math.min(BRACKET_ALPHA_STEPS, Math.ceil(alpha * BRACKET_ALPHA_STEPS)),
);
let bands = map.get(color);
if (!bands) {
bands = new Array(BRACKET_ALPHA_STEPS + 1);
@@ -1201,8 +1326,10 @@ function _drawOverlay(frame) {
const clipW = vp3 * px + vp7 * py + vp11 * pz + vp15;
if (clipW <= 0) continue;
const invW = 1 / clipW;
const sx = ((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
const sy = (0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
const sx =
((vp0 * px + vp4 * py + vp8 * pz + vp12) * invW * 0.5 + 0.5) * width;
const sy =
(0.5 - (vp1 * px + vp5 * py + vp9 * pz + vp13) * invW * 0.5) * height;
// Tracked objects (skipLabel) get larger boxes. AIR reticles scale with the
// plane's on-screen size (same scaleByDistance curve as the billboards) so
@@ -1213,26 +1340,49 @@ function _drawOverlay(frame) {
if (obj.type === 'AIR') {
const bscale = nearFarScale(
Cesium.Cartesian3.distance(camPos, obj.position),
BILL_NEAR, BILL_NEAR_SCALE, BILL_FAR, BILL_FAR_SCALE,
BILL_NEAR,
BILL_NEAR_SCALE,
BILL_FAR,
BILL_FAR_SCALE,
);
halfW = _clamp((isTracked ? 14 : 9) * bscale, 7, 48);
halfH = _clamp((isTracked ? 11 : 7) * bscale, 5, 38);
} else if (obj.tier?.startsWith('transit_') && obj.bracketHalfWidth > 0) {
halfW = obj.bracketHalfWidth;
halfH = obj.bracketHalfHeight;
} else {
halfW = _mode === MODE_DENSE ? (isTracked ? 28 : 11) : 16;
halfH = _mode === MODE_DENSE ? (isTracked ? 22 : 7) : 10;
}
// Viewport bounds check with padding
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH) continue;
if (sx < -halfW || sx > width + halfW || sy < -halfH || sy > height + halfH)
continue;
// Tier color drives the bracket, accent bar, and leader line for this object.
// The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
const bracketAlpha = detectionBracketAlpha(obj.type, keyholeAlpha, keyholeOutsideOpacity);
const bracketAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
);
if (bracketAlpha > 0) {
appendCornerBracket(pathFor(bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(
pathFor(
transit ? _transitBracketPaths : bracketPaths,
color,
bracketAlpha,
),
sx,
sy,
halfW,
halfH,
);
visibleCount++;
if (obj.type === 'AIR') aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
else bracketOpacityCounts.partial++;
} else {
@@ -1266,8 +1416,10 @@ function _drawOverlay(frame) {
if (shouldSolve && keyholeAlpha > 0) {
demandByLayer.set(layerId, (demandByLayer.get(layerId) || 0) + 1);
if (!cohortBuilders.has(layerId)) cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent = selectedIdentities?.get(layerId)?.has(sourceId) || false;
if (!cohortBuilders.has(layerId))
cohortBuilders.set(layerId, new BoundedCohort(256));
const incumbent =
selectedIdentities?.get(layerId)?.has(sourceId) || false;
cohortBuilders.get(layerId).consider(obj, incumbent);
}
@@ -1281,13 +1433,17 @@ function _drawOverlay(frame) {
calloutOcclusionRects,
camPos,
);
if (candidate && !candidateMap.has(candidate.key)) candidateMap.set(candidate.key, candidate);
if (candidate && !candidateMap.has(candidate.key))
candidateMap.set(candidate.key, candidate);
}
}
const altitude = _viewer?.camera?.positionCartographic?.height ?? 1e9;
const collectiveBudget = labelBudgetFor(altitude, _densityPct);
const ambientBudget = Math.max(0, collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount));
const ambientBudget = Math.max(
0,
collectiveBudget - Math.min(collectiveBudget, protectedVisibleCount),
);
let didSolve = false;
let solveMs = 0;
if (shouldSolve) {
@@ -1300,7 +1456,9 @@ function _drawOverlay(frame) {
const solveCandidates = [];
const cohortByLayer = {};
for (const [layerId, builder] of cohortBuilders) {
const cohort = builder.values(cohortCapForQuota(quotas.get(layerId) || 0));
const cohort = builder.values(
cohortCapForQuota(quotas.get(layerId) || 0),
);
for (const obj of cohort) {
const key = _detectionKey(layerId, obj._cohortSourceId);
let candidate = candidateMap.get(key);
@@ -1318,7 +1476,9 @@ function _drawOverlay(frame) {
}
if (candidate) solveCandidates.push(candidate);
}
cohortByLayer[layerId] = solveCandidates.filter((candidate) => candidate.layerId === layerId).length;
cohortByLayer[layerId] = solveCandidates.filter(
(candidate) => candidate.layerId === layerId,
).length;
}
const solveStarted = performance.now();
_labelArbiter.solve(solveCandidates, {
@@ -1346,6 +1506,7 @@ function _drawOverlay(frame) {
const fade = acquireAlpha(_enableTime, now, FADE_MS);
const bracketWidth = _mode === MODE_DENSE ? 1 : 1.25;
_transitBracketAlpha = fade * bracketPresentationOpacity;
// Brackets — batched by tier color and linear radial-opacity band.
_ctx.lineWidth = bracketWidth;
@@ -1398,14 +1559,17 @@ function _drawOverlay(frame) {
labelsByLayer: arbiterDiagnostics.labelsByLayer || {},
entitlementByLayer: arbiterDiagnostics.quotas || {},
labeledKeys: Array.from(_labelArbiter.selectedKeys),
labelAlphaByKey: Object.fromEntries(renderEntries.map((entry) => [
entry.candidate.key,
entry.temporalAlpha * keyholeLabelAlphaFromGeometry(
entry.placement.centerX,
entry.placement.centerY,
keyhole,
),
])),
labelAlphaByKey: Object.fromEntries(
renderEntries.map((entry) => [
entry.candidate.key,
entry.temporalAlpha *
keyholeLabelAlphaFromGeometry(
entry.placement.centerX,
entry.placement.centerY,
keyhole,
),
]),
),
solveRevision: _labelArbiter.solveRevision,
keyholeRadius: keyhole.radius,
keyholeFeatherPx: keyhole.featherPx,
@@ -1430,5 +1594,11 @@ function _drawOverlay(frame) {
_drawModeBanner(visibleCount, sampledCount);
// `_labelSolveDirty` surviving a paint means the solve was owed and did not
// run — the frame that carried the request cannot be the last one.
return { didSolve, solveMs, fadingCount, animatingCount, solvePending: _labelSolveDirty };
return {
didSolve,
solveMs,
fadingCount,
animatingCount,
solvePending: _labelSolveDirty,
};
}
+41 -12
View File
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
/** 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);
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
const bandDelta =
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
if (bandDelta) return bandDelta;
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
const hashDelta =
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
}
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/
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)));
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 = [];
}
@@ -62,11 +71,20 @@ export class BoundedCohort {
}
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 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);
const contenders = this.heap
.slice()
.sort(compareCohortContenders)
.slice(0, remaining);
return incumbents.concat(contenders);
}
@@ -75,7 +93,10 @@ export class BoundedCohort {
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]];
[this.heap[current], this.heap[parent]] = [
this.heap[parent],
this.heap[current],
];
current = parent;
}
}
@@ -86,10 +107,18 @@ export class BoundedCohort {
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 (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]];
[this.heap[current], this.heap[worst]] = [
this.heap[worst],
this.heap[current],
];
current = worst;
}
}
+33 -5
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/
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);
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,
);
}
/**
@@ -237,3 +240,28 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
const t = (distance - near) / (far - near);
return nearValue + t * (farValue - nearValue);
}
/** Transit keeps literal mode colour on the normal-composite surface. */
export function paintTransitBracket(ctx, path, color, alpha = 1) {
ctx.save?.();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#05080C';
ctx.lineWidth = 3.25;
ctx.stroke(path);
ctx.strokeStyle = color;
ctx.lineWidth = 1.25;
ctx.stroke(path);
ctx.restore?.();
}
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
appendCornerBracket(
sink,
Math.floor(sx) + 0.5,
Math.floor(sy) + 0.5,
Math.round(halfW),
Math.round(halfH),
);
}
+31
View File
@@ -161,3 +161,34 @@ test('nearFarScale interpolates by distance and clamps to the near/far values',
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}`);
});
test('transit brackets paint a 1.25 px mode stroke over 3.25 px dark backing', async () => {
const { paintTransitBracket } = await import('./detectionDraw.js');
const strokes = [];
const ctx = { stroke(path) { strokes.push([path, this.lineWidth, this.strokeStyle, this.globalAlpha]); } };
const path = {};
paintTransitBracket(ctx, path, '#FF4538', 0.75);
assert.deepEqual(strokes, [[path, 3.25, '#05080C', 0.75], [path, 1.25, '#FF4538', 0.75]]);
});
test('transit bracket backing and colour use source-over under every inherited sensor theme', async () => {
const {paintTransitBracket}=await import('./detectionDraw.js');
for(const theme of ['normal','thermal','surveillance','noir','retro']) {
const strokes=[];
const ctx={globalCompositeOperation:'screen',globalAlpha:0.3,stroke(p){strokes.push([this.globalCompositeOperation,this.lineWidth,this.strokeStyle]);},save(){this.saved=[this.globalCompositeOperation,this.globalAlpha];},restore(){[this.globalCompositeOperation,this.globalAlpha]=this.saved;}};
paintTransitBracket(ctx,{},'#FF4538',1);
assert.deepEqual(strokes,[['source-over',3.25,'#05080C'],['source-over',1.25,'#FF4538']],theme);
assert.equal(ctx.globalCompositeOperation,'screen');
}
});
test('transit bracket stroke centres have full pixel coverage at the failing fractional positions', async () => {
const {appendTransitBracket}=await import('./detectionDraw.js');
for(const sy of [305.076,490.54,279.56]) {
const points=[],sink={moveTo:(x,y)=>points.push([x,y]),lineTo:(x,y)=>points.push([x,y])};
appendTransitBracket(sink,759.016,sy,11,7);
assert.equal(points.length,12);
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
}
});
+89 -2
View File
@@ -1,3 +1,4 @@
import { readShellSource } from '../testSupport/readShellSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -23,6 +24,7 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
assert.equal(countFadingRenderEntries([
@@ -96,7 +98,7 @@ function mockContext(target, trace) {
// 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); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -377,6 +379,12 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
env.postRender.raise();
const first = getDetectionDiagnostics();
assert.equal(first.profile, 'DENSE');
assert.ok(Array.isArray(first.calloutRects));
if (first.calloutRects.length) {
const x=first.calloutRects[0].x;
first.calloutRects[0].x=NaN;
assert.equal(getDetectionDiagnostics().calloutRects[0].x,x,'QA owns a copy, never a live plate');
}
assert.equal(first.observationCount, 2);
assert.equal(first.didSolve, true);
assert.ok(first.solveRevision > 0);
@@ -671,7 +679,7 @@ test('pathological detection paint holds alternate frames without freezing share
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/applicationShell.js', import.meta.url), 'utf8')
const uiSource = readShellSource()
+ '\n' + readFileSync(new URL('../ui/visualPresets.js', import.meta.url), 'utf8');
assert.doesNotMatch(source, /createElement\(\s*['"]canvas['"]\s*\)/);
assert.doesNotMatch(source, /postRender\.addEventListener/);
@@ -873,3 +881,82 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
env.cleanup();
}
});
test('a transit vehicle is a contact: box, route, mode and operator on the shared canvas', () => {
// The owner's finding was that buses were "green dots moving around
// clumsily" — the layer rendered them and the detection overlay ignored
// them, because transit published no detectable objects at all. This drives
// the real host with the shape the layer now returns.
const env = installEnvironment();
const objects = [
{
position: new Cesium.Cartesian3(0, 0, 6_356_752),
sourceId: 'mbta:y1276',
tier: 'transit_bus',
id: '111',
type: 'VEH',
klass: 'MBTA',
metric: 'BUS 34 KM/H',
},
{
position: new Cesium.Cartesian3(0, 900, 6_356_752),
sourceId: 'mbta:R-548BBB14',
id: 'RED',
type: 'VEH',
klass: 'MBTA',
metric: 'METRO STOPPED',
skipLabel: true,
},
];
try {
initWorldOverlay(env.viewer);
initDetection(
env.viewer,
[{ id: 'transit', getDetectableObjects: () => objects }],
() => {},
);
setMode('DENSE');
env.advance(250);
env.postRender.raise();
env.advance(250);
env.postRender.raise();
const painted = env.ctx.calls
.filter(([name]) => name === 'fillText')
.map(([, text]) => text);
assert.ok(
painted.includes('111'),
`the route is the bright line; painted ${JSON.stringify(painted)}`,
);
// The host paints a single-line track label — bright id, one dim micro
// field — so the mode and what the vehicle is doing share that field.
assert.ok(
painted.some((text) => String(text).includes('BUS')),
`the mode rides along on the micro line; painted ${JSON.stringify(painted)}`,
);
assert.ok(
painted.some((text) => String(text).includes('34 KM/H')),
'and so does what the vehicle is doing',
);
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#5EF08A' && width === 1.25));
assert.ok(env.ctx.calls.some(([name, , color, width]) => name === 'stroke' && color === '#05080C' && width === 3.25));
assert.ok(!env.detectionCtx.calls.some(([name, , color]) => name === 'stroke' && color === '#5EF08A'));
// The operator travels on the object for the two-tier card form, which is
// where `klass` is read; the host's track label does not paint it.
assert.equal(composeLabel(objects[0]).secondary, 'MBTA · BUS 34 KM/H');
// The selected vehicle carries its own fuller card, so detection must not
// label it a second time.
assert.equal(
painted.includes('RED'),
false,
'skipLabel keeps the selected vehicle from being labelled twice',
);
} finally {
env.cleanup();
}
});
test('transit contacts take the vehicle tier, not the civil-air one', () => {
assert.equal(resolveTier({ type: 'VEH' }), 'vehicle');
assert.equal(resolveTier({ type: 'AIR' }), 'civil');
assert.equal(resolveTier({ type: 'SEA' }), 'sea');
});
+66 -20
View File
@@ -8,7 +8,10 @@
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]);
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
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: 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);
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;
const progress =
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
);
}
/**
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* 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) {
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);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, 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';
if (x > (width * 2) / 3) return 'right';
return 'front';
}
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase();
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';
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();
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();
const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback
: ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** 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;
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';
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
* 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 rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, fallbackDensity);
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
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) };
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 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);
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);
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -18,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
const indexHtml = expandApplicationHtml(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);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
}
+1 -1
View File
@@ -17,7 +17,7 @@ test('Cockpit reduces only the bracket presentation multiplier', () => {
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, /removeEventListener\(\s*'gev:cockpit-mode-changed',\s*_cockpitModeListener,?\s*\)/);
assert.match(
source,
/fade \* entry\.alpha \* bracketPresentationOpacity/,
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) {
const skip = !layoutChanged
&& Number(lastPaintMs) > thresholdMs
&& Number(frameCount) % 2 !== 0;
const skip =
!layoutChanged &&
Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip };
}
+36 -12
View File
@@ -277,7 +277,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
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'\);/,
assert.match(source, /if \(\s*detectionNeedsFollowUpFrame\(\{[\s\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.
@@ -306,7 +306,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
// 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];
const emptyExit = /\/\/ Drop the replay buffer with it[\s\S]*?return \{\s*didSolve: false[\s\S]*?\};/.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');
@@ -318,7 +318,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
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(drawOverlay, /const now =\s*Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\);/);
assert.match(source, /_enableTime = _nowMs\(\);/,
'the enable stamp shares the monotonic clock the fade is measured against');
@@ -331,7 +331,7 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
'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'\)/,
assert.match(shouldPaint, /if \(decision\.requestFollowUp\)\s*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
@@ -359,14 +359,38 @@ test('a layer whose detectable set changed dirties the solve', async () => {
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');
const { LayerLifecycle } = await import('./lifecycle.js');
const { LayerPresentation } = await import('../app/layerPresentation.js');
const manager = new LayerLifecycle({});
const reactions = [];
const presentation = new LayerPresentation(manager, {
requestRender: (reason) => reactions.push(['render', reason]),
invalidateDetection: (reason) => reactions.push(['detection', reason]),
});
let result = true;
manager.register({ id: 'fixture', name: 'Fixture', updateInterval: 0,
init() {}, enable() {}, disable() {}, destroy() {},
update() { if (result instanceof Error) throw result; return result; },
getStats() { return { count: 1 }; },
});
await manager.setEnabled('fixture', true);
assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]);
reactions.length = 0;
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']]);
reactions.length = 0;
result = false;
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [['render', 'layer-tick:fixture'], ['detection', 'layer-tick:fixture']], 'partial/rejected updates still invalidate detection');
reactions.length = 0;
result = new Error('fixture update failure');
await manager.refreshLayer('fixture');
assert.deepEqual(reactions, [], 'throwing updates do not publish changed data');
result = true;
await manager.setEnabled('fixture', false);
assert.deepEqual(reactions, [['render', 'layer-visibility'], ['detection', 'layer-visibility']]);
presentation.destroy();
await manager.destroyAll();
});
test('the render-governor gate covers the parked case, with teeth on the painter', async () => {
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/
export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase();
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).
+69
View File
@@ -0,0 +1,69 @@
/**
* @module directions
* @description Service bundle for the Directions layer.
*
* The layer itself lives in `src/layers/directions/`, which imports nothing
* but Cesium and the pure step formatter. This module is the one place that
* names the application's shared services — the render governor, sprite order,
* the pick registry, the world overlay, the annotation material, the camera
* verbs, the shared ground floor and the pointer arbiter — and it re-exports
* the layer's pure helpers for the tests and the share-link code.
*/
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as scenePick from './scenePick.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import * as camera from '../cameraVerbs.js';
import * as ground from './groundFloor.js';
import * as input from './inputOwnership.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
/** The shared scene owners every Directions instance runs on. */
export const directionsServices = Object.freeze({
render,
sprites,
picking,
scenePick,
overlays,
annotations,
camera,
ground,
input,
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
}
export {
DEFAULT_DIRECTIONS_MODE,
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
DIRECTIONS_STEP_OVERLAY_SOURCE_ID,
DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS,
FLIGHT_PROGRESS_MS,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
createDirectionsLayer,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
directionsStats,
directionsStepCopy,
directionsStepList,
normalizeDirectionsParams,
normalizeRoutePayload,
pointerBlockedMessage,
stepAnchorDelayMs,
stepIndexAtDistance,
stepMarkerHeightM,
stepMarkerIndices,
} from '../layers/directions/index.js';
+775
View File
@@ -0,0 +1,775 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
DEFAULT_DIRECTIONS_MODE,
DIRECTIONS_MODES,
DIRECTIONS_POINTER_OWNER,
DIRECTIONS_ROUTE_COLOR,
createDirectionsStepOverlayEntry,
directionsRequestUrl,
directionsRowControls,
directionsStats,
directionsStepCopy,
directionsStepList,
normalizeDirectionsParams,
normalizeRoutePayload,
stepAnchorDelayMs,
stepIndexAtDistance,
stepMarkerHeightM,
stepMarkerIndices,
pointerBlockedMessage,
POINTER_TOOL_EXITS,
STEP_ANCHOR_DEADLINE_MS,
STEP_ANCHOR_RETRY_MS,
STEP_ANCHOR_SLOW_RETRY_MS,
STEP_ANCHOR_FAST_ATTEMPTS,
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import {
claimPointer,
isPointerFree,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from './inputOwnership.js';
/** Take the pointer as some other tool, and hand back its release. */
function claimAs(owner) {
const lease = claimPointer(owner);
assert.ok(lease, `${owner} should have taken the pointer`);
return () => releasePointer(lease);
}
import { LAYER_STATE_REGISTRY } from './layerState.js';
import { SPRITE_LAYER_ORDER } from './spriteOrder.js';
/** The instance under test; each ownership fixture builds a fresh one. */
let directionsLayer = createApplicationDirectionsLayer();
const idle = {
enabled: true,
mode: 'car',
armed: null,
a: null,
b: null,
status: 'idle',
error: null,
route: null,
lastUpdate: null,
};
const A = { lat: 60.1699, lon: 24.9384 };
const B = { lat: 60.2055, lon: 24.6559 };
const route = {
distanceM: 21479,
durationS: 1478,
geometry: [
[24.9384, 60.1699],
[24.6559, 60.2055],
],
steps: [
{
index: 0,
instruction: 'Head out on Kaivokatu',
distanceM: 8,
durationS: 1,
lat: 60.1699,
lon: 24.9384,
},
{
index: 1,
instruction: 'Turn right onto Kansakoulukatu',
distanceM: 236,
durationS: 39,
lat: 60.1683,
lon: 24.9349,
},
{
index: 2,
instruction: 'Arrive at your destination',
distanceM: 0,
durationS: 0,
lat: 60.2055,
lon: 24.6559,
},
],
mode: 'car',
};
test('layer module declares the manager contract, params, and row controls', () => {
assert.equal(directionsLayer.id, 'directions');
assert.equal(directionsLayer.updateInterval, 0);
for (const method of [
'init',
'enable',
'disable',
'update',
'getStats',
'destroy',
'setParams',
'getParams',
'getRowControls',
'setRowControlsListener',
'attachDataManager',
]) {
assert.equal(
typeof directionsLayer[method],
'function',
`${method} is implemented`,
);
}
assert.deepEqual(directionsLayer.getParams(), {
mode: DEFAULT_DIRECTIONS_MODE,
});
const entry = LAYER_STATE_REGISTRY.find((row) => row.id === 'directions');
assert.ok(entry, 'directions has a share-link token');
assert.equal(
LAYER_STATE_REGISTRY.filter((row) => row.token === entry.token).length,
1,
);
assert.ok(SPRITE_LAYER_ORDER.includes('directions'));
});
test('params: unknown modes are rejected, commands and arm state are normalized', () => {
assert.deepEqual(normalizeDirectionsParams({ mode: 'FOOT' }), {
mode: 'foot',
});
assert.equal(normalizeDirectionsParams({ mode: 'rocket' }), null);
assert.deepEqual(normalizeDirectionsParams({ arm: 'a' }), { arm: 'a' });
assert.deepEqual(normalizeDirectionsParams({ arm: 'zzz' }), { arm: null });
assert.deepEqual(
normalizeDirectionsParams({ swap: true, fly: true, clear: true }),
{ swap: true, fly: true, clear: true },
);
assert.deepEqual(normalizeDirectionsParams({ swap: 'yes' }), {});
assert.deepEqual(normalizeDirectionsParams(), {});
// The live module refuses an unknown mode without touching state.
assert.equal(directionsLayer.setParams({ mode: 'rocket' }), false);
assert.deepEqual(directionsLayer.getParams(), {
mode: DEFAULT_DIRECTIONS_MODE,
});
});
test('row chips: modes, arming, and command availability follow the state', () => {
const { chips } = directionsRowControls(idle);
const ids = chips.map((c) => c.id);
assert.deepEqual(ids, [
'mode-car',
'mode-foot',
'mode-bike',
'set-a',
'set-b',
'swap',
'fly',
'clear',
]);
assert.equal(chips.find((c) => c.id === 'mode-car').active, true);
assert.equal(chips.find((c) => c.id === 'set-a').label, 'SET A');
assert.deepEqual(chips.find((c) => c.id === 'set-a').params, { arm: 'a' });
assert.equal(chips.find((c) => c.id === 'fly').disabled, true);
assert.equal(chips.find((c) => c.id === 'swap').disabled, true);
assert.equal(chips.find((c) => c.id === 'clear').disabled, true);
const armed = directionsRowControls({ ...idle, armed: 'a' }).chips;
assert.equal(armed.find((c) => c.id === 'set-a').label, 'CLICK MAP');
assert.deepEqual(
armed.find((c) => c.id === 'set-a').params,
{ arm: null },
'clicking again cancels',
);
const ready = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
}).chips;
assert.equal(ready.find((c) => c.id === 'set-a').label, 'A ✓');
assert.equal(ready.find((c) => c.id === 'fly').disabled, false);
assert.equal(ready.find((c) => c.id === 'swap').disabled, false);
assert.equal(ready.find((c) => c.id === 'clear').disabled, false);
const routing = directionsRowControls({
...idle,
a: A,
b: B,
status: 'routing',
}).chips;
assert.equal(routing.find((c) => c.id === 'fly').busy, true);
for (const mode of Object.keys(DIRECTIONS_MODES))
assert.ok(ids.includes(`mode-${mode}`));
});
test('stats guide the user, show progress, and summarize a route honestly', () => {
assert.equal(directionsStats(idle).coverage, 'SET A, then click the globe');
assert.equal(
directionsStats({ ...idle, armed: 'b' }).coverage,
'Click the globe to place B',
);
assert.equal(
directionsStats({ ...idle, a: A }).coverage,
'SET B, then click the globe',
);
const routing = directionsStats({ ...idle, a: A, b: B, status: 'routing' });
assert.equal(routing.loading, true);
const ready = directionsStats({
...idle,
a: A,
b: B,
status: 'ready',
route,
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
assert.equal(
directionsStats(idle).loadingLabel,
'SET A, then click the globe',
);
const failed = directionsStats({
...idle,
a: A,
b: B,
status: 'error',
error: 'No route found between A and B',
});
assert.equal(failed.error, 'No route found between A and B');
assert.equal(failed.count, 0);
assert.equal(directionsLayer.getStats().count, 0);
});
test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
assert.deepEqual(last.details, ['Step 3 of 3']);
const position = Cesium.Cartesian3.fromDegrees(24.9349, 60.1683, 2);
const card = createDirectionsStepOverlayEntry(1, position, copy);
assert.equal(card.accent, DIRECTIONS_ROUTE_COLOR);
assert.equal(card.id, 'directions-step-1');
assert.equal(createDirectionsStepOverlayEntry(null, position, copy), null);
});
test('the proxy request carries both endpoints, the profile, and asks for steps', () => {
const url = directionsRequestUrl('foot', A, B);
assert.match(
url,
/^\/api\/route\?profile=foot&coords=24\.938400%2C60\.169900%3B24\.655900%2C60\.205500&steps=1$/,
);
});
test('route payloads are validated; a failed route never becomes a straight line', () => {
const ok = normalizeRoutePayload(
{
ok: true,
distanceM: 21479,
durationS: 1478,
geometry: route.geometry,
steps: route.steps,
},
'car',
);
assert.equal(ok.distanceM, 21479);
assert.equal(ok.steps.length, 3);
assert.equal(ok.mode, 'car');
assert.equal(
normalizeRoutePayload({ ok: false, error: 'no route found' }, 'car'),
null,
);
assert.equal(
normalizeRoutePayload({ ok: true, geometry: [[1, 1]] }, 'car'),
null,
);
assert.equal(
normalizeRoutePayload(
{
ok: true,
geometry: [
[999, 1],
[1, 1],
],
},
'car',
),
null,
);
const noSteps = normalizeRoutePayload(
{ ok: true, geometry: route.geometry },
'bike',
);
assert.deepEqual(noSteps.steps, []);
assert.equal(normalizeRoutePayload(null, 'car'), null);
});
test('maneuver dots skip departure and arrival and ride the shared ground floor', () => {
assert.deepEqual(stepMarkerIndices(route.steps), [1]);
assert.deepEqual(stepMarkerIndices([]), []);
assert.deepEqual(stepMarkerIndices(null), []);
assert.deepEqual(
stepMarkerIndices([{}, {}, {}, {}, {}]),
[1, 2, 3],
'every decision between the endpoints gets a dot',
);
// Denver: ground is ~1.6 km ellipsoidal. A dot fixed near zero would sit a
// kilometre and a half under the junction it marks.
const lift = GROUND_FLOOR_LIFT_M;
assert.equal(stepMarkerHeightM(1609.3, lift), 1609.3 + lift);
assert.equal(stepMarkerHeightM(0, lift), lift);
assert.equal(
stepMarkerHeightM(null, lift),
null,
'a cold cell has no honest height',
);
assert.equal(stepMarkerHeightM(undefined, lift), null);
assert.equal(stepMarkerHeightM(Number.NaN, lift), null);
});
test('the turn-by-turn list is ordered, labelled, and highlights the flown step', () => {
assert.equal(directionsStepList({ route: null }), null);
assert.equal(directionsStepList({ route: { steps: [] } }), null);
const list = directionsStepList({
route,
selectedStep: null,
flightStep: null,
});
assert.deepEqual(
list.items.map((item) => item.ordinal),
[1, 2, 3],
'items are in route order',
);
assert.deepEqual(
list.items.map((item) => item.text),
route.steps.map((step) => step.instruction),
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
[{ step: 0 }, { step: 1 }, { step: 2 }],
);
assert.equal(
list.items.some((item) => item.active),
false,
);
const selected = directionsStepList({
route,
selectedStep: 2,
flightStep: null,
});
assert.deepEqual(
selected.items.map((item) => item.active),
[false, false, true],
);
assert.deepEqual(
selected.items.map((item) => item.current),
[false, false, false],
);
// A running FLY owns the highlight; the clicked step yields to it.
const flying = directionsStepList({ route, selectedStep: 2, flightStep: 1 });
assert.deepEqual(
flying.items.map((item) => item.active),
[false, true, false],
);
assert.deepEqual(
flying.items.map((item) => item.current),
[false, true, false],
);
const rowList = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
}).list;
assert.equal(rowList.items.length, 3);
assert.equal(rowList.ariaLabel, 'Turn-by-turn directions');
assert.equal(directionsRowControls(idle).list, null, 'no route, no list');
});
test('the flown step follows distance travelled, never running off either end', () => {
const steps = [{ distanceM: 100 }, { distanceM: 250 }, { distanceM: 0 }];
assert.equal(stepIndexAtDistance(steps, 0), 0);
assert.equal(stepIndexAtDistance(steps, 99), 0);
assert.equal(stepIndexAtDistance(steps, 100), 1);
assert.equal(stepIndexAtDistance(steps, 349), 1);
assert.equal(stepIndexAtDistance(steps, 350), 2, 'the arrival step is last');
assert.equal(stepIndexAtDistance(steps, 99999), 2);
assert.equal(stepIndexAtDistance(steps, -5), 0);
assert.equal(stepIndexAtDistance(steps, Number.NaN), 0);
assert.equal(stepIndexAtDistance([], 10), null);
assert.equal(stepIndexAtDistance(null, 10), null);
});
test('the FLY chip reports a flight in progress', () => {
const flying = directionsRowControls({
...idle,
a: A,
b: B,
status: 'ready',
route,
flightStep: 1,
}).chips.find((chip) => chip.id === 'fly');
assert.equal(flying.label, 'FLYING');
assert.equal(flying.busy, true);
assert.equal(flying.active, true);
});
test('a blocked pointer is reported instead of silently doing nothing', () => {
const blocked = directionsStats({ ...idle, pointerBlocked: true });
assert.match(blocked.error, /Another map tool is using clicks/);
assert.equal(blocked.count, 0);
});
test('a step selection can be requested and cleared through params', () => {
assert.deepEqual(normalizeDirectionsParams({ step: 3 }), { step: 3 });
assert.deepEqual(normalizeDirectionsParams({ step: 0 }), { step: 0 });
assert.deepEqual(normalizeDirectionsParams({ step: null }), { step: null });
assert.deepEqual(normalizeDirectionsParams({ step: -1 }), { step: null });
assert.deepEqual(normalizeDirectionsParams({ step: 'two' }), { step: null });
});
/**
* Drive the live layer module with the overlay host stubbed out. No viewer is
* needed for the arming paths: they are pure state plus the pointer claim.
*/
function ownershipFixture(t) {
directionsLayer = createApplicationDirectionsLayer();
directionsLayer._setOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
resetPointerOwnership();
t.after(() => {
directionsLayer.setParams({ clear: true });
directionsLayer._setOverlayHostForTest(null);
resetPointerOwnership();
});
}
test('placing an endpoint takes the pointer, and every exit path gives it back', (t) => {
ownershipFixture(t);
assert.equal(isPointerFree(), true);
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'arming claims the pointer',
);
assert.equal(
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-a').active,
true,
);
// Re-arming for the other endpoint keeps the same claim; it never stacks.
directionsLayer.setParams({ arm: 'b' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// Cancel (clicking the armed chip again).
directionsLayer.setParams({ arm: null });
assert.equal(isPointerFree(), true, 'cancelling releases');
assert.equal(
directionsLayer.getRowControls().chips.find((c) => c.id === 'set-b').active,
false,
);
// CLEAR while armed.
directionsLayer.setParams({ arm: 'a' });
directionsLayer.setParams({ clear: true });
assert.equal(isPointerFree(), true, 'CLEAR releases');
// disable() while armed.
directionsLayer.setParams({ arm: 'a' });
directionsLayer.disable(null);
assert.equal(isPointerFree(), true, 'disabling the layer releases');
// destroy() while armed, on a layer that was never enabled.
directionsLayer.setParams({ arm: 'b' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
directionsLayer.destroy({ scene: { primitives: { remove() {} } } });
assert.equal(isPointerFree(), true, 'destroy releases');
});
test('arming is refused, and said so, while another tool holds the pointer', (t) => {
ownershipFixture(t);
const releaseDraw = claimAs('draw');
assert.equal(
directionsLayer.setParams({ arm: 'a' }),
true,
'the write is accepted',
);
assert.equal(
pointerOwner(),
'draw',
"the other tool's claim is never stolen",
);
const chips = directionsLayer.getRowControls().chips;
assert.equal(
chips.find((chip) => chip.id === 'set-a').active,
false,
'nothing is armed',
);
assert.match(
directionsLayer.getStats().error,
/Another map tool is using clicks/,
'the row says why the click did nothing',
);
// Once the other tool lets go, arming works and the complaint clears.
assert.equal(releaseDraw(), true);
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(directionsLayer.getStats().error, null);
});
test('a release names this layer, so it cannot free a successor tool claim', (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// A superseding teardown: the layer is disabled, then another tool claims.
directionsLayer.disable(null);
const releaseDraw = claimAs('draw');
// A late second teardown of ours must not free the draw tool's claim.
directionsLayer.disable(null);
directionsLayer.setParams({ clear: true });
assert.equal(pointerOwner(), 'draw');
releaseDraw();
});
test('the pointer claim outlives the click that consumed it', async (t) => {
ownershipFixture(t);
// Every layer binds its own handler to the same canvas and they all run
// inside ONE browser event. Releasing the claim the moment the endpoint is
// placed hands the rest of that same click to every ambient handler
// registered after this one — which is how placing A on top of a bikeshare
// station also deselected the station.
directionsLayer.setParams({ arm: 'a' });
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 }),
true,
);
// Still inside the dispatch: an ambient handler that runs after this one
// asks the same question the real ones ask, and must be told to stand down.
assert.equal(
isPointerFree(),
false,
'a later handler in the same click yields',
);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
// A microtask checkpoint runs BETWEEN two DOM listeners, so the release must
// not be on one.
await Promise.resolve();
await Promise.resolve();
assert.equal(
isPointerFree(),
false,
'a microtask is not the end of the click',
);
// The click is over; the pointer goes back.
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
isPointerFree(),
true,
'the claim is returned once the click ends',
);
// A bad placement request changes nothing.
directionsLayer.setParams({ arm: 'b' });
assert.equal(directionsLayer.placeEndpoint('c', { lat: 1, lon: 1 }), false);
assert.equal(
directionsLayer.placeEndpoint('b', { lat: Number.NaN, lon: 1 }),
false,
);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER, 'still armed');
});
test('a teardown during the pending release still frees the pointer at once', async (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
assert.equal(isPointerFree(), false);
// Disabling mid-click must not wait for a timer that may never be reached.
directionsLayer.disable(null);
assert.equal(isPointerFree(), true);
// And the pending timer cannot then release someone else's later claim.
const releaseDraw = claimAs('draw');
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(pointerOwner(), 'draw');
releaseDraw();
});
test('a cold ground cell is re-read for as long as the terrain proxy can take', () => {
// The proxy behind the shared floor can take tens of seconds on a cold
// cache. Twelve quick retries covered four seconds of that.
assert.ok(
STEP_ANCHOR_DEADLINE_MS >= 60_000,
`the budget must outlast a slow terrain round trip, is ${STEP_ANCHOR_DEADLINE_MS} ms`,
);
assert.equal(stepAnchorDelayMs(0, 0), STEP_ANCHOR_RETRY_MS);
assert.equal(
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS - 1, 1000),
STEP_ANCHOR_RETRY_MS,
);
assert.equal(
stepAnchorDelayMs(STEP_ANCHOR_FAST_ATTEMPTS, 2000),
STEP_ANCHOR_SLOW_RETRY_MS,
'the re-reads back off once the quick ones have not settled it',
);
// The last wait never overshoots the budget, and past it there is no wait.
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS - 100), 100);
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS), null);
assert.equal(stepAnchorDelayMs(50, STEP_ANCHOR_DEADLINE_MS + 1), null);
assert.equal(stepAnchorDelayMs(0, Number.NaN), null);
});
test('a route cut off at the step cap says so instead of ending at a random turn', () => {
const cut = { ...route, stepsTruncated: true };
const list = directionsStepList({ route: cut });
assert.equal(list.items.length, route.steps.length + 1);
const last = list.items.at(-1);
assert.match(last.text, /Only the first 3 turns of this route are shown/);
assert.equal(last.disabled, true, 'the note is not an action');
assert.equal(last.params, undefined, 'and carries no command');
assert.match(
directionsStats({ ...idle, a: A, b: B, status: 'ready', route: cut })
.coverage,
/first 3 turns$/,
);
// A complete route says nothing extra.
assert.equal(directionsStepList({ route }).items.length, route.steps.length);
assert.equal(
normalizeRoutePayload(
{ ok: true, geometry: route.geometry, steps: route.steps },
'car',
).stepsTruncated,
false,
);
assert.equal(
normalizeRoutePayload(
{
ok: true,
geometry: route.geometry,
steps: route.steps,
stepsTruncated: true,
},
'car',
).stepsTruncated,
true,
);
});
test('re-arming reuses the lease this layer already holds', (t) => {
ownershipFixture(t);
// A claim never stacks, not even under the same name — two live instances of
// one tool are two owners. So arming B after arming A must not ask for a
// second claim it cannot get.
assert.equal(directionsLayer.setParams({ arm: 'a' }), true);
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
assert.equal(directionsLayer.setParams({ arm: 'b' }), true);
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'the layer still holds the pointer after re-arming',
);
const chips = directionsLayer.getRowControls().chips;
assert.equal(chips.find((chip) => chip.id === 'set-b').active, true);
assert.equal(
directionsLayer.getStats().error,
null,
'and nothing is refused',
);
});
test('a stale lease cannot free the claim a later arming holds', async (t) => {
ownershipFixture(t);
directionsLayer.setParams({ arm: 'a' });
directionsLayer.placeEndpoint('a', { lat: 37.7955, lon: -122.3937 });
// The release for that click is pending. Arming again before it fires must
// keep the pointer, not lose it to the timer a moment later.
directionsLayer.setParams({ arm: 'b' });
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(
pointerOwner(),
DIRECTIONS_POINTER_OWNER,
'the pending release must not disarm the new arming',
);
});
test('a refused arming names the tool holding the pointer and how to leave it', () => {
// Owner field test: he pressed SET A while Draw was open, nothing happened,
// and he concluded Directions was broken. Silence was the bug.
assert.equal(
pointerBlockedMessage('draw', 'a'),
'Draw is active — press Escape twice to leave Draw, then set A',
);
assert.equal(
pointerBlockedMessage('draw', 'b'),
'Draw is active — press Escape twice to leave Draw, then set B',
);
// A tool nobody has written an exit for still gets a usable sentence.
assert.equal(
pointerBlockedMessage('measure', 'a'),
'measure is active — turn measure off, then set A',
);
// Nothing to say when nothing is in the way, or when we are the holder.
assert.equal(pointerBlockedMessage(null, 'a'), null);
assert.equal(pointerBlockedMessage('', 'a'), null);
assert.equal(pointerBlockedMessage(' ', 'a'), null);
assert.equal(pointerBlockedMessage(DIRECTIONS_POINTER_OWNER, 'a'), null);
// Every exit the table knows names its tool and says how to leave it.
for (const [id, exit] of Object.entries(POINTER_TOOL_EXITS)) {
assert.match(
pointerBlockedMessage(id, 'a'),
new RegExp(`^${exit.name} is active — `),
);
assert.match(pointerBlockedMessage(id, 'a'), /, then set A$/);
}
});
test('the refusal reaches the app toast, and a successful arming says nothing', (t) => {
ownershipFixture(t);
const toasts = [];
directionsLayer.attachShellServices({
showToast: (text) => toasts.push(text),
});
t.after(() => directionsLayer.attachShellServices(null));
const releaseDraw = claimAs('draw');
directionsLayer.setParams({ arm: 'a' });
assert.deepEqual(toasts, [
'Draw is active — press Escape twice to leave Draw, then set A',
]);
// The row status the operator can also read is still set.
assert.match(
directionsLayer.getStats().error,
/Another map tool is using clicks/,
);
// Once Draw lets go, arming works and says nothing at all.
releaseDraw();
directionsLayer.setParams({ arm: 'a' });
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
+4 -15
View File
@@ -1,22 +1,11 @@
import {
createEarthquakesLayer as createLayer,
createUsgsEarthquakeSource,
} from '../layers/earthquakes/index.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { createUsgsEarthquakeSource } from '../layers/earthquakes/index.js';
import { createApplicationEarthquakes } from '../app/layers/earthquakes.js';
export * from '../layers/earthquakes/index.js';
/** Wire the standalone source and application overlay owner. */
export function createEarthquakesLayer({
source = createUsgsEarthquakeSource(),
overlayHost = {
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
},
...options
} = {}) {
return createLayer({ source, overlayHost });
return createApplicationEarthquakes({ source, ...options });
}
export default createEarthquakesLayer();
+55
View File
@@ -0,0 +1,55 @@
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status =
typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError =
state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status))
return 'unavailable';
if (
(presentedError ||
state.unavailable === true ||
state.available === false) &&
!hasPriorData &&
!GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true ||
status === 'fallback' ||
state.mode === 'sim' ||
/\bfallback\b/i.test(source) ||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded ||
presentedError ||
state.unavailable === true ||
state.available === false
)
return 'degraded';
if (state.partial === true) return 'partial';
return 'nominal';
}
+6 -2
View File
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
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 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;
+19 -1
View File
@@ -9,7 +9,7 @@ import {
filterTrailing24h,
isLikelyCsv,
parseFirmsCsv,
} from './firmsCsv.js';
} from 'gods-eye-view/sources/firms-csv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE = fs.readFileSync(
@@ -144,3 +144,21 @@ test('filterTrailing24h on the fixture keeps everything for a same-night now', (
const kept = filterTrailing24h(records, Date.UTC(2026, 6, 17, 2, 0));
assert.equal(kept.length, records.length);
});
const contractCases = JSON.parse(
fs.readFileSync(
path.join(__dirname, 'fixtures', 'firms-csv-cases.json'),
'utf8',
),
);
for (const fixture of contractCases) {
test(`portable FIRMS CSV contract: ${fixture.name}`, () => {
const records = parseFirmsCsv(fixture.csv);
assert.equal(records === null ? null : records.length, fixture.parsedCount);
const recent = filterTrailing24h(records, Date.parse(fixture.now));
assert.deepEqual(
recent.map((record) => record.acqTime),
fixture.recentTimes,
);
});
}
+16 -34
View File
@@ -1,26 +1,19 @@
import * as Cesium from 'cesium';
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
import { createSourceSlot } from '../sources/sourceSlot.js';
import {
createFirmsHeatmapLayer as createLayer,
createFirmsHelpers,
createFirmsSource,
} from '../layers/firms/index.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as context from './contextStore.js';
import * as picking from './pickRegistry.js';
import * as anchors from './fireAnchors.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as focus from '../worldFocus.js';
const services = {
render,
sprites,
context,
picking,
anchors,
overlays,
focus,
};
const helpers = createFirmsHelpers({ services });
const sourceSlot = createSourceSlot(
createFirmsSource(),
['getSnapshot'],
'Fire source',
);
export const configureFirmsSource = sourceSlot.configure;
const helpers = createFirmsHelpers({
services: { ...firmsServices, anchors: defaultSurface.anchors },
});
export const mapAnalystRecord = helpers.mapAnalystRecord;
export const fireCullPosition = helpers.fireCullPosition;
export const applyHorizonCull = helpers.applyHorizonCull;
@@ -29,22 +22,11 @@ export const buildFireCard = helpers.buildFireCard;
export const buildCellCard = helpers.buildCellCard;
export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy;
/** Supply the local source and the application's scene service owners. */
/** Retain the existing default feed and helper exports for direct callers. */
export function createFirmsHeatmapLayer(options) {
return createLayer({
return createApplicationFirms({
surface: defaultSurface,
...options,
icon: options.icon ?? '▲',
source: options.source ?? 'NASA FIRMS',
feed: options.feed ?? createFirmsSource(),
services,
overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory:
options.screenSpaceEventHandlerFactory ??
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
feed: options.feed ?? sourceSlot.source,
});
}
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
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';
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}`;
}
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '').trim().toUpperCase();
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';
+44
View File
@@ -0,0 +1,44 @@
[
{
"name": "categorical confidence and unpadded midnight time",
"csv": "latitude,longitude,acq_date,acq_time,confidence,frp\n30,-97,2026-07-16,45,n,12\n",
"now": "2026-07-16T12:00:00Z",
"parsedCount": 1,
"recentTimes": ["45"]
},
{
"name": "truncated rows are omitted",
"csv": "latitude,longitude,acq_date,acq_time,confidence,frp\n30,-97,2026-07-16,1200\n",
"now": "2026-07-16T12:00:00Z",
"parsedCount": 0,
"recentTimes": []
},
{
"name": "invalid acquisition hour does not roll into tomorrow",
"csv": "latitude,longitude,acq_date,acq_time,confidence,frp\n30,-97,2026-07-15,2400,n,12\n",
"now": "2026-07-16T12:00:00Z",
"parsedCount": 1,
"recentTimes": []
},
{
"name": "inclusive trailing day and two-hour forward slack",
"csv": "latitude,longitude,acq_date,acq_time,confidence,frp\n30,-97,2026-07-15,1200,n,12\n30,-97,2026-07-15,1159,n,12\n30,-97,2026-07-16,1400,h,12\n30,-97,2026-07-16,1401,l,12\n",
"now": "2026-07-16T12:00:00Z",
"parsedCount": 4,
"recentTimes": ["1200", "1400"]
},
{
"name": "upstream error is different from an empty catalog",
"csv": "Invalid MAP_KEY",
"now": "2026-07-16T12:00:00Z",
"parsedCount": null,
"recentTimes": []
},
{
"name": "header-only catalog is successful and empty",
"csv": "latitude,longitude,acq_date,acq_time,confidence,frp\n",
"now": "2026-07-16T12:00:00Z",
"parsedCount": 0,
"recentTimes": []
}
]
+8 -39
View File
@@ -1,44 +1,13 @@
import { createCivilFlightLayer } from '../layers/flights/index.js';
import { createOpenSkySource } from '../sources/live/standalone.js';
import * as picking from './pickRegistry.js';
import * as sprites from './spriteOrder.js';
import * as trails from './trailRenderer.js';
import * as aircraftPresentation from './tr3bRegistry.js';
import * as camera from './trackedCamera.js';
import * as militaryRegistry from './militaryRegistry.js';
import * as labels from './detectionDraw.js';
import * as groundFloor from './groundFloor.js';
import * as meshFloor from './meshFloorSampler.js';
import * as geoid from './geoid.js';
import * as focus from './focusDeemphasis.js';
import * as readout from './trackedReadout.js';
import * as context from './contextStore.js';
import * as render from '../renderGovernor.js';
import * as groundSnap from './groundSnap.js';
import * as recession from './aircraftRecession.js';
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.js';
const flightsLayer = createCivilFlightLayer({
import { createOpenSkySource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({
surface: defaultSurface,
source: createOpenSkySource(),
resolveAsset: (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
services: {
picking,
sprites,
trails,
aircraftPresentation,
camera,
militaryRegistry,
labels,
groundFloor,
meshFloor,
geoid,
focus,
readout,
context,
render,
groundSnap,
recession,
},
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
export const _floorGroundedDisplayPositionForTest =
+29 -9
View File
@@ -45,7 +45,9 @@ 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;
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
@@ -98,13 +100,19 @@ 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) {
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);
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);
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
// ── 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)}`;
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;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
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]);
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;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// 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 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]);
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);
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
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 (
bearingDiffDeg(seg.bearing, sampleBearing) >=
BEARING_TOLERANCE_DEG
)
continue;
if (d2 <= bestDist2) {
bestDist2 = d2;
best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
}
if (hadCandidate) candidateCount++;
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
if (
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
) {
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++;
}
+114 -50
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther,
) {
if (!target?.screenRect || !spriteScreenPosition) return 1;
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) 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 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 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;
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);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance)
|| !Number.isFinite(fartherThreshold)
|| spriteCameraDistance >= fartherThreshold;
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 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);
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);
const eased = 1 - (1 - t) ** 3;
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
}
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/
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 (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.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);
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 };
}
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24,
params,
}) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id);
return null;
}
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
displayPosition,
_scratchScreen,
);
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id);
return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding,
},
paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
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); }
try {
listener();
} catch (e) {
console.warn('[focus] appear listener error:', e);
}
}
}
return _focusTarget;
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
* 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,
}) {
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) {
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
state.desired = desired;
}
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
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;
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;
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
_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;
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,
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean}
*/
export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined
|| (Number.isFinite(activeCount) && activeCount > 0);
return (
(target !== null && target !== undefined) ||
(Number.isFinite(activeCount) && activeCount > 0)
);
}
/**
@@ -466,8 +527,9 @@ 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);
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;
}
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha)
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
return (
!Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) {
throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.'
'await ensureGeoidReady() first.',
);
}
return egm96Module.meanSeaLevel(latDeg, lonDeg);
+32 -580
View File
@@ -1,580 +1,32 @@
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
// heights (field-test round 2026-07-06).
//
// Two owner 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 an owner playtest 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, owner-approved 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);
}
import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain });
export const {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
} = groundFloor;
+4 -350
View File
@@ -1,352 +1,6 @@
/**
* 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,
* }}
*/
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */
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 };
return createSnap({ groundFloor });
}
+274
View File
@@ -0,0 +1,274 @@
/**
* @module gtfsRealtime
* @description Minimal GTFS-Realtime decoder for VehiclePosition feeds.
*
* Decodes only what the Transit layer needs from a `FeedMessage` — the header
* and every entity's `VehiclePosition` — straight from the protobuf wire
* format with `pbf` (already a dependency via the TomTom flow tiles). No
* `gtfs-realtime-bindings`, no protobufjs, no generated code: the GTFS-RT
* schema is stable, and field numbers are the contract.
*
* Field numbers (gtfs-realtime.proto, v2.0):
* FeedMessage 1 header, 2 entity[]
* FeedHeader 1 gtfs_realtime_version, 2 incrementality, 3 timestamp
* FeedEntity 1 id, 2 is_deleted, 3 trip_update, 4 vehicle, 5 alert
* VehiclePosition 1 trip, 2 position, 3 current_stop_sequence,
* 4 current_status, 5 timestamp, 6 congestion_level,
* 7 stop_id, 8 vehicle, 9 occupancy_status
* TripDescriptor 1 trip_id, 2 start_time, 3 start_date,
* 4 schedule_relationship, 5 route_id, 6 direction_id
* Position 1 latitude, 2 longitude, 3 bearing, 4 odometer, 5 speed
* VehicleDescriptor 1 id, 2 label, 3 license_plate
*
* Unknown fields (extensions, newer additions) are skipped by pbf, so a feed
* that carries OVapi/NYCT extensions decodes the same as a plain one.
*
* Decoding is bounded, because a byte cap alone does not bound what a decode
* COSTS: entities past `GTFS_MAX_ENTITIES` are left unread so pbf skips them
* whole (the result says it truncated), and every string field — the feed
* header's version included — is refused past `GTFS_MAX_STRING_CHARS` rather
* than carried into the snapshot.
*
* Pure: safe to import from the browser layer, the Vite proxy, and node:test.
*/
import { PbfReader } from 'pbf';
/** VehiclePosition.VehicleStopStatus enum → label. */
export const VEHICLE_STOP_STATUS = Object.freeze({
0: 'INCOMING_AT',
1: 'STOPPED_AT',
2: 'IN_TRANSIT_TO',
});
/** VehiclePosition.OccupancyStatus enum → label. */
export const OCCUPANCY_STATUS = Object.freeze({
0: 'EMPTY',
1: 'MANY_SEATS_AVAILABLE',
2: 'FEW_SEATS_AVAILABLE',
3: 'STANDING_ROOM_ONLY',
4: 'CRUSHED_STANDING_ROOM_ONLY',
5: 'FULL',
6: 'NOT_ACCEPTING_PASSENGERS',
7: 'NO_DATA_AVAILABLE',
8: 'NOT_BOARDABLE',
});
/**
* Hard ceiling on decoded entities. Every registered feed is a metropolitan or
* national fleet — the largest observed is a few thousand vehicles — so this is
* two orders of magnitude above any honest payload.
*/
export const GTFS_MAX_ENTITIES = 50_000;
/** Longest accepted string field. GTFS ids, labels and stop ids are short. */
export const GTFS_MAX_STRING_CHARS = 256;
/** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */
export const GTFS_INCREMENTALITY_FULL_DATASET = 0;
export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1;
/**
* Read a length-delimited string, refusing one that is implausibly long.
* An over-long field marks its containing message `oversize`, and the whole
* record is then dropped — never truncated. Half an id is a different vehicle,
* not a shorter name for the same one.
* @param {object} pbf
* @param {object} target Message being read, flagged when the field is refused.
* @returns {string|null}
*/
function readBoundedString(pbf, target) {
const value = pbf.readString();
if (value.length <= GTFS_MAX_STRING_CHARS) return value;
target.oversize = true;
return null;
}
function readFeedHeader(tag, header, pbf) {
if (tag === 1) header.version = readBoundedString(pbf, header);
else if (tag === 2) header.incrementality = pbf.readVarint();
else if (tag === 3) header.timestamp = pbf.readVarint();
}
function readTripDescriptor(tag, trip, pbf) {
if (tag === 1) trip.tripId = readBoundedString(pbf, trip);
else if (tag === 2) trip.startTime = readBoundedString(pbf, trip);
else if (tag === 3) trip.startDate = readBoundedString(pbf, trip);
else if (tag === 4) trip.scheduleRelationship = pbf.readVarint();
else if (tag === 5) trip.routeId = readBoundedString(pbf, trip);
else if (tag === 6) trip.directionId = pbf.readVarint();
}
function readPosition(tag, position, pbf) {
if (tag === 1) position.latitude = pbf.readFloat();
else if (tag === 2) position.longitude = pbf.readFloat();
else if (tag === 3) position.bearing = pbf.readFloat();
else if (tag === 4) position.odometer = pbf.readDouble();
else if (tag === 5) position.speed = pbf.readFloat();
}
function readVehicleDescriptor(tag, descriptor, pbf) {
if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor);
else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor);
else if (tag === 3)
descriptor.licensePlate = readBoundedString(pbf, descriptor);
}
function readVehiclePosition(tag, vehicle, pbf) {
if (tag === 1) vehicle.trip = pbf.readMessage(readTripDescriptor, {});
else if (tag === 2) vehicle.position = pbf.readMessage(readPosition, {});
else if (tag === 3) vehicle.currentStopSequence = pbf.readVarint();
else if (tag === 4) vehicle.currentStatus = pbf.readVarint();
else if (tag === 5) vehicle.timestamp = pbf.readVarint();
else if (tag === 6) vehicle.congestionLevel = pbf.readVarint();
else if (tag === 7) vehicle.stopId = readBoundedString(pbf, vehicle);
else if (tag === 8)
vehicle.vehicle = pbf.readMessage(readVehicleDescriptor, {});
else if (tag === 9) vehicle.occupancyStatus = pbf.readVarint();
}
function readFeedEntity(tag, entity, pbf) {
if (tag === 1) entity.id = readBoundedString(pbf, entity);
else if (tag === 2) entity.isDeleted = pbf.readBoolean();
else if (tag === 4) entity.vehicle = pbf.readMessage(readVehiclePosition, {});
// 3 (trip_update) and 5 (alert) are skipped: pbf advances past any tag the
// reader leaves untouched.
}
function readFeedMessage(tag, message, pbf) {
if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {});
else if (tag === 2) {
// Past the cap the field is left UNREAD. pbf skips any field its handler
// did not consume, so the excess costs one length varint each instead of a
// full nested decode that is then thrown away.
if (message.entities.length >= GTFS_MAX_ENTITIES) {
message.truncated = true;
return;
}
message.entities.push(pbf.readMessage(readFeedEntity, {}));
}
}
/**
* Decode a raw GTFS-Realtime FeedMessage.
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
* @returns {{ header: {version?: string, incrementality?: number, timestamp?: number},
* entities: object[], truncated: boolean }}
*/
export function decodeFeedMessage(bytes) {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const pbf = new PbfReader(view);
return pbf.readFields(readFeedMessage, {
header: {},
entities: [],
truncated: false,
});
}
function finiteOrNull(value) {
return Number.isFinite(value) ? value : null;
}
function nonEmptyString(value) {
const text = typeof value === 'string' ? value.trim() : '';
return text ? text : null;
}
/**
* True when a lat/lon pair is a usable surface position. Rejects non-finite
* values, out-of-range degrees, and the (0,0) null island a cold GPS reports.
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
export function isPlausibleVehiclePosition(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
if (Math.abs(lat) < 1e-6 && Math.abs(lon) < 1e-6) return false;
return true;
}
/**
* Flatten one decoded FeedEntity into the record the Transit layer renders,
* or null when the entity carries no usable vehicle position.
* @param {object} entity Decoded FeedEntity.
* @returns {object|null}
*/
export function normalizeVehicleEntity(entity) {
if (!entity || entity.isDeleted === true) return null;
const vehicle = entity.vehicle;
const position = vehicle?.position;
if (!vehicle || !position) return null;
// Any refused (over-long) string anywhere in this entity makes the whole
// record untrustworthy — drop it rather than render a half-identified dot.
if (
entity.oversize ||
vehicle.oversize ||
vehicle.trip?.oversize ||
vehicle.vehicle?.oversize
) {
return null;
}
const lat = finiteOrNull(position.latitude);
const lon = finiteOrNull(position.longitude);
if (!isPlausibleVehiclePosition(lat, lon)) return null;
const id = nonEmptyString(vehicle.vehicle?.id) || nonEmptyString(entity.id);
if (!id) return null;
const bearing = finiteOrNull(position.bearing);
const speed = finiteOrNull(position.speed);
const timestamp =
Number.isFinite(vehicle.timestamp) && vehicle.timestamp > 0
? vehicle.timestamp
: null;
return {
id,
lat: Number(lat.toFixed(6)),
lon: Number(lon.toFixed(6)),
bearing: bearing === null ? null : ((bearing % 360) + 360) % 360,
speedMps: speed === null || speed < 0 ? null : speed,
timestamp,
routeId: nonEmptyString(vehicle.trip?.routeId),
tripId: nonEmptyString(vehicle.trip?.tripId),
directionId: Number.isInteger(vehicle.trip?.directionId)
? vehicle.trip.directionId
: null,
label: nonEmptyString(vehicle.vehicle?.label),
stopId: nonEmptyString(vehicle.stopId),
status: VEHICLE_STOP_STATUS[vehicle.currentStatus] || null,
occupancy: OCCUPANCY_STATUS[vehicle.occupancyStatus] || null,
};
}
/**
* Decode a VehiclePositions feed into the compact snapshot the proxy serves.
* Duplicate vehicle ids keep the newest timestamp (feeds occasionally repeat
* a vehicle across two trip entities during a handover).
* @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes.
* @returns {{ version: string|null, timestamp: number|null, incrementality: number,
* entityCount: number, truncated: boolean, vehicles: object[] }}
*/
export function decodeVehiclePositions(bytes) {
const message = decodeFeedMessage(bytes);
const byId = new Map();
for (const entity of message.entities) {
const record = normalizeVehicleEntity(entity);
if (!record) continue;
const existing = byId.get(record.id);
if (!existing || (record.timestamp ?? 0) >= (existing.timestamp ?? 0)) {
byId.set(record.id, record);
}
}
const headerTimestamp = message.header?.timestamp;
const incrementality = Number.isFinite(message.header?.incrementality)
? message.header.incrementality
: GTFS_INCREMENTALITY_FULL_DATASET;
return {
version: nonEmptyString(message.header?.version),
timestamp:
Number.isFinite(headerTimestamp) && headerTimestamp > 0
? headerTimestamp
: null,
incrementality,
entityCount: message.entities.length,
truncated: message.truncated === true,
vehicles: [...byId.values()],
};
}
+348
View File
@@ -0,0 +1,348 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PbfWriter } from 'pbf';
import {
GTFS_MAX_ENTITIES,
GTFS_MAX_STRING_CHARS,
decodeFeedMessage,
decodeVehiclePositions,
isPlausibleVehiclePosition,
normalizeVehicleEntity,
} from './gtfsRealtime.js';
/** Encode a nested message with a fresh writer and embed it as a bytes field. */
function embed(writer, tag, build) {
const inner = new PbfWriter();
build(inner);
writer.writeBytesField(tag, inner.finish());
}
/**
* Hand-encode one FeedEntity carrying a VehiclePosition, from the proto field
* numbers — the same contract the decoder reads, written independently.
*/
function writeVehicleEntity(
writer,
{
entityId,
vehicleId,
label,
lat,
lon,
bearing,
speed,
timestamp,
routeId,
tripId,
directionId,
stopId,
status,
occupancy,
isDeleted,
extraUnknownFields = false,
},
) {
embed(writer, 2, (entity) => {
if (entityId != null) entity.writeStringField(1, entityId);
if (isDeleted) entity.writeBooleanField(2, true);
embed(entity, 4, (vehicle) => {
if (routeId != null || tripId != null || directionId != null) {
embed(vehicle, 1, (trip) => {
if (tripId != null) trip.writeStringField(1, tripId);
if (routeId != null) trip.writeStringField(5, routeId);
if (directionId != null) trip.writeVarintField(6, directionId);
});
}
if (lat != null) {
embed(vehicle, 2, (position) => {
position.writeFloatField(1, lat);
position.writeFloatField(2, lon);
if (bearing != null) position.writeFloatField(3, bearing);
if (speed != null) position.writeFloatField(5, speed);
if (extraUnknownFields) position.writeDoubleField(4, 123456.5); // odometer
});
}
if (status != null) vehicle.writeVarintField(4, status);
if (timestamp != null) vehicle.writeVarintField(5, timestamp);
if (stopId != null) vehicle.writeStringField(7, stopId);
if (vehicleId != null || label != null) {
embed(vehicle, 8, (descriptor) => {
if (vehicleId != null) descriptor.writeStringField(1, vehicleId);
if (label != null) descriptor.writeStringField(2, label);
});
}
if (occupancy != null) vehicle.writeVarintField(9, occupancy);
if (extraUnknownFields)
vehicle.writeStringField(1001, 'operator-extension');
});
});
}
function encodeFeed(
entities,
{ version = '2.0', timestamp = 1_788_936_000 } = {},
) {
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, version);
header.writeVarintField(2, 0);
header.writeVarintField(3, timestamp);
});
for (const entity of entities) writeVehicleEntity(writer, entity);
return writer.finish();
}
test('decodes header and every VehiclePosition field the layer renders', () => {
const bytes = encodeFeed([
{
entityId: 'e1',
vehicleId: 'bus-17',
label: '1557',
lat: 44.927551,
lon: -93.386711,
bearing: 248,
speed: 11.2,
timestamp: 1_788_936_945,
routeId: '17',
tripId: '1134773',
directionId: 0,
stopId: '57458',
status: 1,
occupancy: 2,
},
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.version, '2.0');
assert.equal(snapshot.timestamp, 1_788_936_000);
assert.equal(snapshot.entityCount, 1);
assert.equal(snapshot.vehicles.length, 1);
const [v] = snapshot.vehicles;
assert.equal(v.id, 'bus-17');
assert.equal(v.label, '1557');
assert.ok(Math.abs(v.lat - 44.927551) < 1e-5);
assert.ok(Math.abs(v.lon + 93.386711) < 1e-5);
assert.equal(v.bearing, 248);
assert.ok(Math.abs(v.speedMps - 11.2) < 1e-5);
assert.equal(v.timestamp, 1_788_936_945);
assert.equal(v.routeId, '17');
assert.equal(v.tripId, '1134773');
assert.equal(v.directionId, 0);
assert.equal(v.stopId, '57458');
assert.equal(v.status, 'STOPPED_AT');
assert.equal(v.occupancy, 'FEW_SEATS_AVAILABLE');
});
test('unknown and extension fields are skipped, not misread', () => {
const bytes = encodeFeed([
{ entityId: 'x', lat: 51.3, lon: 5.33, extraUnknownFields: true },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.vehicles.length, 1);
assert.equal(snapshot.vehicles[0].id, 'x');
assert.ok(Math.abs(snapshot.vehicles[0].lat - 51.3) < 1e-5);
});
test('entities without a usable position are dropped and never throw', () => {
const bytes = encodeFeed([
{ entityId: 'no-position' },
{ entityId: 'deleted', lat: 1, lon: 1, isDeleted: true },
{ entityId: 'null-island', lat: 0, lon: 0 },
{ entityId: 'out-of-range', lat: 91, lon: 10 },
{ entityId: 'nan', lat: Number.NaN, lon: 10 },
{ entityId: 'keeper', lat: 60.17, lon: 24.94 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.entityCount, 6);
assert.deepEqual(
snapshot.vehicles.map((v) => v.id),
['keeper'],
);
});
test('vehicle id falls back to the entity id and the vehicle is dropped when neither exists', () => {
const bytes = encodeFeed([
{ entityId: 'entity-only', lat: 42.3, lon: -71.1 },
{ lat: 42.3, lon: -71.1 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.deepEqual(
snapshot.vehicles.map((v) => v.id),
['entity-only'],
);
});
test('duplicate vehicle ids keep the newest timestamp', () => {
const bytes = encodeFeed([
{ entityId: 'a', vehicleId: 'v1', lat: 42.3, lon: -71.1, timestamp: 100 },
{ entityId: 'b', vehicleId: 'v1', lat: 42.31, lon: -71.11, timestamp: 200 },
{ entityId: 'c', vehicleId: 'v1', lat: 42.32, lon: -71.12, timestamp: 150 },
]);
const snapshot = decodeVehiclePositions(bytes);
assert.equal(snapshot.vehicles.length, 1);
assert.equal(snapshot.vehicles[0].timestamp, 200);
assert.ok(Math.abs(snapshot.vehicles[0].lat - 42.31) < 1e-5);
});
test('bearing is normalized to [0, 360) and negative speed is treated as unknown', () => {
const bytes = encodeFeed([
{ entityId: 'neg', lat: 30.2, lon: -97.7, bearing: -90, speed: -1 },
{ entityId: 'wrap', lat: 30.2, lon: -97.7, bearing: 450 },
]);
const [neg, wrap] = decodeVehiclePositions(bytes).vehicles;
assert.equal(neg.bearing, 270);
assert.equal(neg.speedMps, null);
assert.equal(wrap.bearing, 90);
});
test('missing header yields null version/timestamp rather than garbage', () => {
const writer = new PbfWriter();
writeVehicleEntity(writer, { entityId: 'solo', lat: 10, lon: 10 });
const snapshot = decodeVehiclePositions(writer.finish());
assert.equal(snapshot.version, null);
assert.equal(snapshot.timestamp, null);
assert.equal(snapshot.vehicles.length, 1);
});
test('decodeFeedMessage accepts an ArrayBuffer and an empty feed', () => {
const empty = decodeFeedMessage(new ArrayBuffer(0));
assert.deepEqual(empty, { header: {}, entities: [], truncated: false });
const bytes = encodeFeed([]);
const asBuffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
assert.equal(decodeFeedMessage(asBuffer).header.version, '2.0');
});
test('isPlausibleVehiclePosition and normalizeVehicleEntity guard their inputs', () => {
assert.equal(isPlausibleVehiclePosition(45, 90), true);
assert.equal(isPlausibleVehiclePosition(0, 0), false);
assert.equal(isPlausibleVehiclePosition(-90.0001, 0), false);
assert.equal(isPlausibleVehiclePosition(1, Infinity), false);
assert.equal(normalizeVehicleEntity(null), null);
assert.equal(normalizeVehicleEntity({ id: 'no-vehicle' }), null);
assert.equal(
normalizeVehicleEntity({
id: 'v',
vehicle: { position: { latitude: 1, longitude: 2 }, currentStatus: 99 },
})?.status,
null,
);
});
test('the decode is bounded by entity count as well as by bytes', () => {
const entities = [];
for (let index = 0; index < GTFS_MAX_ENTITIES + 25; index += 1) {
entities.push({
entityId: `e${index}`,
vehicleId: `v${index}`,
lat: 42 + index * 1e-6,
lon: -71,
timestamp: 1_788_936_000,
});
}
const decoded = decodeVehiclePositions(encodeFeed(entities));
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
assert.equal(decoded.truncated, true);
assert.equal(decoded.vehicles.length, GTFS_MAX_ENTITIES);
// A feed inside the cap is not flagged.
assert.equal(
decodeVehiclePositions(encodeFeed(entities.slice(0, 3))).truncated,
false,
);
});
test('an implausibly long string drops the whole record rather than truncating an id', () => {
const long = 'x'.repeat(GTFS_MAX_STRING_CHARS + 1);
const decoded = decodeVehiclePositions(
encodeFeed([
{
entityId: 'ok',
vehicleId: 'good',
lat: 42.1,
lon: -71.1,
timestamp: 1_788_936_000,
},
{
entityId: 'bad',
vehicleId: long,
lat: 42.2,
lon: -71.2,
timestamp: 1_788_936_000,
},
{
entityId: 'bad-route',
vehicleId: 'v3',
routeId: long,
lat: 42.3,
lon: -71.3,
timestamp: 1_788_936_000,
},
]),
);
// The entity is still counted — the decoder had to walk past it — but it is
// never rendered, because half an id is a different vehicle.
assert.equal(decoded.entityCount, 3);
assert.deepEqual(
decoded.vehicles.map((vehicle) => vehicle.id),
['good'],
);
// A string exactly at the cap is fine.
const atCap = 'y'.repeat(GTFS_MAX_STRING_CHARS);
const kept = decodeVehiclePositions(
encodeFeed([
{ entityId: 'e', vehicleId: atCap, lat: 42, lon: -71, timestamp: 1 },
]),
);
assert.deepEqual(
kept.vehicles.map((vehicle) => vehicle.id),
[atCap],
);
});
test('incrementality is reported, not silently discarded', () => {
assert.equal(decodeVehiclePositions(encodeFeed([])).incrementality, 0);
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, '2.0');
header.writeVarintField(2, 1);
header.writeVarintField(3, 1_788_936_000);
});
assert.equal(decodeVehiclePositions(writer.finish()).incrementality, 1);
});
test('the feed header version is bounded like every other string', () => {
const writer = new PbfWriter();
embed(writer, 1, (header) => {
header.writeStringField(1, 'v'.repeat(GTFS_MAX_STRING_CHARS + 1));
header.writeVarintField(2, 0);
header.writeVarintField(3, 1_788_936_000);
});
const decoded = decodeVehiclePositions(writer.finish());
// Refused, not carried: an unbounded header field is the one string a feed
// can grow without the entity cap ever seeing it.
assert.equal(decoded.version, null);
assert.equal(decoded.timestamp, 1_788_936_000);
});
test('entities past the cap are skipped whole rather than decoded and discarded', () => {
const decoded = decodeVehiclePositions(
encodeFeed(
Array.from({ length: GTFS_MAX_ENTITIES + 5 }, (_, index) => ({
entityId: `e${index}`,
vehicleId: `v${index}`,
lat: 42 + index * 1e-6,
lon: -71,
timestamp: 1_788_936_000,
})),
),
);
assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES);
assert.equal(decoded.truncated, true);
// The skipped tail never becomes an object, so the last retained vehicle is
// the one at the cap and nothing beyond it appears.
assert.equal(
decoded.vehicles.some((item) => item.id === `v${GTFS_MAX_ENTITIES}`),
false,
);
});
+150 -14
View File
@@ -49,7 +49,12 @@ const _scratchWorldForward = new Cesium.Cartesian3();
* unavailable (off-screen/behind camera/degenerate).
* @returns {number|null} Rotation in radians, or `previous` when unknown.
*/
export function screenProjectedRotation(scene, position, courseDeg, previous = null) {
export function screenProjectedRotation(
scene,
position,
courseDeg,
previous = null,
) {
const camera = scene?.camera;
if (!camera?.rightWC || !camera?.upWC || !position) return previous;
@@ -58,20 +63,26 @@ export function screenProjectedRotation(scene, position, courseDeg, previous = n
Math.sin(courseRad) * FORWARD_PROBE_M,
Math.cos(courseRad) * FORWARD_PROBE_M,
0,
_scratchForward
_scratchForward,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
position,
Cesium.Ellipsoid.WGS84,
_scratchEnu,
);
Cesium.Matrix4.multiplyByPointAsVector(
enu,
_scratchForward,
_scratchWorldForward,
);
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;
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),
@@ -94,13 +105,20 @@ export function stabilizeScreenRotation(
next,
deadbandRad = ROTATION_DEADBAND_RAD,
) {
if (!Number.isFinite(next)) return Number.isFinite(previous) ? previous : null;
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));
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());
const _occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
new Cesium.Cartesian3(),
);
/**
* Half-width of the crossfade band centred on the ellipsoid silhouette, in
@@ -169,7 +187,11 @@ const _wgs84OneOverRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
* @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) {
export function skyBackdropFactor(
cameraPosition,
position,
featherRad = HORIZON_FEATHER_RAD,
) {
if (!cameraPosition || !position) return 0;
const s = _wgs84OneOverRadii;
@@ -236,6 +258,120 @@ export function horizonOccluder(camera) {
*/
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)}`;
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)}`
);
}
/**
* Meters ahead used by the exact projection below. Short on purpose: the point
* of a perspective projection is that it is LOCAL to the contact, and a two
* kilometre probe on a bus at six hundred metres would sample a part of the
* frustum the vehicle is nowhere near.
*/
const NEAR_PROBE_M = 30;
const _scratchProbe = new Cesium.Cartesian3();
const _scratchWindowAt = new Cesium.Cartesian2();
const _scratchWindowAhead = new Cesium.Cartesian2();
/**
* Billboard rotation for a SURFACE contact, projected exactly — perspective
* division included — instead of through the camera-basis approximation.
*
* `screenProjectedRotation` above deliberately projects the course vector onto
* the camera's right/up basis, which is orthographic: exact at the viewport
* centre and an approximation away from it. That is the right trade for
* aircraft, where the alternative loses continuity through a tracked orbit and
* the contacts are far enough away for the error to be small.
*
* It is the wrong trade for street-level vehicles. A probe with a real Cesium
* camera at 600 m and −45° pitch, on a northbound contact 350 m right of
* centre, put the orthographic answer at 0° where the true screen direction of
* travel was 22.5°. At that pitch and that range the vehicles fill the frame
* away from the centre, and every one of them points visibly wrong.
*
* So this projects two nearby world points through the actual view-projection
* and reads the angle between them on screen. When either point cannot be
* projected — behind the camera, off the frustum, degenerate — it falls back to
* the camera-basis answer rather than inventing one.
*
* @param {Cesium.Scene} scene The scene (projection source).
* @param {Cesium.Cartesian3} position Entity world position.
* @param {number} courseDeg Course in degrees clockwise from north.
* @param {number|null} previous Rotation to keep when projection is unavailable.
* @returns {number|null} Rotation in radians, or the fallback.
*/
export function perspectiveProjectedRotation(
scene,
position,
courseDeg,
previous = null,
) {
if (!scene?.camera || !position || !Number.isFinite(courseDeg)) {
return previous;
}
const courseRad = Cesium.Math.toRadians(courseDeg);
Cesium.Cartesian3.fromElements(
Math.sin(courseRad) * NEAR_PROBE_M,
Math.cos(courseRad) * NEAR_PROBE_M,
0,
_scratchForward,
);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
position,
Cesium.Ellipsoid.WGS84,
_scratchEnu,
);
Cesium.Matrix4.multiplyByPointAsVector(
enu,
_scratchForward,
_scratchWorldForward,
);
Cesium.Cartesian3.add(position, _scratchWorldForward, _scratchProbe);
const at = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchWindowAt,
);
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
_scratchProbe,
_scratchWindowAhead,
);
if (!at || !ahead) {
return screenProjectedRotation(scene, position, courseDeg, previous);
}
const dx = ahead.x - at.x;
const dy = ahead.y - at.y;
const separation = Math.hypot(dx, dy);
// Window y grows downward; rotation 0 = icon pointing screen-up.
const exact = Math.atan2(-dx, -dy);
if (separation >= PERSPECTIVE_BLEND_MAX_PX) return exact;
// Below a pixel or so of separation the exact answer is dominated by
// projection noise, and above it the camera-basis answer is the wrong one
// by up to twenty-odd degrees. A hard cutoff between the two flipped a
// sprite by 23° for a 20 m change of camera altitude; the two answers are
// blended across the band instead, the short way round, so the rotation is
// continuous through it.
const basis = screenProjectedRotation(scene, position, courseDeg, previous);
if (!Number.isFinite(basis)) return exact;
if (separation <= PERSPECTIVE_BLEND_MIN_PX) return basis;
const weight =
(separation - PERSPECTIVE_BLEND_MIN_PX) /
(PERSPECTIVE_BLEND_MAX_PX - PERSPECTIVE_BLEND_MIN_PX);
const delta = Math.atan2(Math.sin(exact - basis), Math.cos(exact - basis));
return basis + delta * weight;
}
/**
* Probe separations (px) between which the exact and camera-basis answers
* are blended. At or below the floor the camera-basis answer is used alone;
* at or above the ceiling the exact one.
*/
export const PERSPECTIVE_BLEND_MIN_PX = 0.5;
export const PERSPECTIVE_BLEND_MAX_PX = 3;
/** Exported for tests: how far ahead the exact projection probes. */
export const PERSPECTIVE_PROBE_M = NEAR_PROBE_M;
+342 -53
View File
@@ -47,8 +47,12 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
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);
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
@@ -65,26 +69,29 @@ test('off-center oblique contacts pin the documented perspective divergence', ()
const exactRotation = Math.atan2(-exactScreenX, exactScreenUp);
assert.ok(Math.abs(basisRotation) < 1e-12);
assert.ok(Math.abs(wrappedDelta(exactRotation, basisRotation)) > Cesium.Math.toRadians(5));
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)),
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);
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);
const previous = Math.PI - (0.1 * Math.PI) / 180;
const next = -Math.PI + (0.1 * Math.PI) / 180;
assert.equal(stabilizeScreenRotation(previous, next), previous);
});
@@ -102,10 +109,12 @@ const cruiseCamera = Cesium.Cartesian3.fromDegrees(0, 0, CRUISE_M);
* 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),
));
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
@@ -124,7 +133,10 @@ test('a contact below the silhouette keeps ground behind it', () => {
// 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);
assert.equal(
skyBackdropFactor(cruiseCamera, Cesium.Cartesian3.fromDegrees(0.05, 0, 0)),
0,
);
});
test('the silhouette itself lands exactly mid-band', () => {
@@ -144,10 +156,12 @@ test('the silhouette itself lands exactly mid-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),
));
samples.push(
skyBackdropFactor(
cruiseCamera,
Cesium.Cartesian3.fromDegrees(100_000 / 111_320, 0, height),
),
);
}
return samples;
}
@@ -163,9 +177,16 @@ test('the band lerps monotonically and saturates on both sides', () => {
);
}
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');
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`);
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', () => {
@@ -174,13 +195,19 @@ test('the band, not the geometry, is what smooths the crossing', () => {
// 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,
));
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');
assert.deepEqual(
[...new Set(hard)],
[0, 1],
'a zero band must produce only the two end states',
);
});
/**
@@ -200,12 +227,19 @@ test('below the ellipsoid, a contact against open sky still reads as sky', () =>
// ~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);
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)),
skyBackdropFactor(
jfkCockpit,
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, 10_000),
),
1,
);
});
@@ -225,7 +259,11 @@ test('below the ellipsoid, a contact on the ramp still keeps its plate', () => {
assert.equal(
skyBackdropFactor(
jfkCockpit,
Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT + 400 * DEG_PER_M_LAT, JFK_RAMP_H - 5),
Cesium.Cartesian3.fromDegrees(
JFK_LON,
JFK_LAT + 400 * DEG_PER_M_LAT,
JFK_RAMP_H - 5,
),
),
0,
);
@@ -246,14 +284,21 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
new Cesium.Cartesian3(20_000, 0, 0),
new Cesium.Cartesian3(),
);
const alongHorizontal = Cesium.Cartesian3.add(onSurface, east, 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 radialUp = Cesium.Cartesian3.normalize(
onSurface,
new Cesium.Cartesian3(),
);
const perpToRadial = Cesium.Cartesian3.cross(
radialUp,
Cesium.Cartesian3.UNIT_Z,
@@ -265,7 +310,11 @@ test('at the surface the horizon is exactly eye level, geodetic not geocentric',
20_000,
perpToRadial,
);
const alongRadialPlane = Cesium.Cartesian3.add(onSurface, perpToRadial, new Cesium.Cartesian3());
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',
@@ -284,7 +333,9 @@ 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));
samples.push(
skyBackdropFactor(Cesium.Cartesian3.fromDegrees(0, 0, height), contact),
);
}
return samples;
}
@@ -300,7 +351,10 @@ test('the answer stays continuous as the camera rises through the surface', () =
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}`);
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', () => {
@@ -308,9 +362,18 @@ test('the crossover has no seam at the surface itself', () => {
// 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}`);
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', () => {
@@ -319,7 +382,10 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
// 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(
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',
@@ -334,7 +400,11 @@ test('a sub-surface camera carries real information, not one frozen answer', ()
* 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) {
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;
@@ -415,22 +485,39 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
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),
const offsetBy = (up) =>
Cesium.Cartesian3.add(
camera,
Cesium.Matrix4.multiplyByPointAsVector(
enu,
new Cesium.Cartesian3(0, 20_000, up),
new Cesium.Cartesian3(),
),
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');
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', () => {
@@ -442,12 +529,24 @@ test('a degenerate camera or contact reports ground rather than throwing', () =>
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);
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);
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', () => {
@@ -456,3 +555,193 @@ test('the feather band is a named constant in a sane screen-space range', () =>
// band of arc-seconds would pop.
assert.ok(HORIZON_FEATHER_RAD > 0.002 && HORIZON_FEATHER_RAD < 0.09);
});
// --- Perspective projection through a real view-projection --------------------
import {
PERSPECTIVE_BLEND_MAX_PX,
PERSPECTIVE_BLEND_MIN_PX,
PERSPECTIVE_PROBE_M,
perspectiveProjectedRotation,
} from './iconOrientation.js';
/**
* A scene the exact projector accepts: 3D mode, a canvas, and a camera with a
* real view matrix and perspective frustum. The camera sits `altitudeM` above
* the origin and looks north, pitched down by `pitchDeg`, with world east as
* screen right — the oblique street-level pose where the camera-basis answer
* is wrong off-centre and the exact one is not.
*/
function obliqueScene(
altitudeM,
pitchDeg,
{ width = 1280, height = 800 } = {},
) {
const position = Cesium.Cartesian3.fromDegrees(0, 0, altitudeM);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
const up = Cesium.Matrix4.getColumn(enu, 2, new Cesium.Cartesian3());
const pitch = Cesium.Math.toRadians(pitchDeg);
const direction = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
north,
Math.cos(pitch),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
up,
-Math.sin(pitch),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const cameraUp = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
north,
Math.sin(pitch),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
up,
Math.cos(pitch),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const viewMatrix = Cesium.Matrix4.computeView(
position,
direction,
cameraUp,
east,
new Cesium.Matrix4(),
);
const frustum = new Cesium.PerspectiveFrustum({
fov: Cesium.Math.toRadians(60),
aspectRatio: width / height,
near: 1,
far: 1e7,
});
return {
frameState: { mode: Cesium.SceneMode.SCENE3D },
canvas: { clientWidth: width, clientHeight: height },
camera: {
viewMatrix,
frustum,
rightWC: east,
upWC: cameraUp,
positionWC: position,
},
};
}
/** Probe separation (px) the helper sees for a contact and course in a scene. */
function probeSeparation(scene, contact, courseDeg) {
const courseRad = Cesium.Math.toRadians(courseDeg);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(contact);
const forward = Cesium.Matrix4.multiplyByPointAsVector(
enu,
new Cesium.Cartesian3(
Math.sin(courseRad) * PERSPECTIVE_PROBE_M,
Math.cos(courseRad) * PERSPECTIVE_PROBE_M,
0,
),
new Cesium.Cartesian3(),
);
const probe = Cesium.Cartesian3.add(
contact,
forward,
new Cesium.Cartesian3(),
);
const at = Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact);
const ahead = Cesium.SceneTransforms.worldToWindowCoordinates(scene, probe);
const dx = ahead.x - at.x;
const dy = ahead.y - at.y;
return { separation: Math.hypot(dx, dy), exact: Math.atan2(-dx, -dy) };
}
test('the perspective helper is continuous through the sub-pixel band, not a 23° flip', () => {
// Codex's reproduction: a contact near the right edge of an oblique view,
// the camera altitude walked so the probe separation crosses half a pixel.
// The rotation jumped 23° → 0° between two adjacent altitudes. The exact
// and camera-basis answers now blend across a band, the short way round.
//
// The contact keeps the SAME screen position across the walk — its ground
// offsets scale with the altitude — so the two answers disagree by the
// same twenty-odd degrees at every altitude while the separation alone
// walks from well above the band to well below it.
const course = 0; // northbound
const contactFor = (altitude) =>
Cesium.Cartesian3.fromDegrees(
(0.7 * altitude) / 111_320,
(1.2 * altitude) / 111_320,
0,
);
const samples = [];
// Geometric steps: separation scales with altitude, so equal ratios give
// equal-sized steps in the quantity the band is defined on.
for (let altitude = 500; altitude <= 600_000; altitude *= 1.02) {
const scene = obliqueScene(altitude, 45);
const contact = contactFor(altitude);
if (!Cesium.SceneTransforms.worldToWindowCoordinates(scene, contact))
continue;
const { separation, exact } = probeSeparation(scene, contact, course);
samples.push({
altitude,
separation,
exact,
rotation: perspectiveProjectedRotation(scene, contact, course, null),
basis: screenProjectedRotation(scene, contact, course, null),
});
}
const inBand = samples.filter(
(s) =>
s.separation > PERSPECTIVE_BLEND_MIN_PX &&
s.separation < PERSPECTIVE_BLEND_MAX_PX,
);
const above = samples.filter((s) => s.separation >= PERSPECTIVE_BLEND_MAX_PX);
const below = samples.filter((s) => s.separation <= PERSPECTIVE_BLEND_MIN_PX);
assert.ok(
inBand.length >= 5 && above.length >= 5 && below.length >= 5,
`the walk crosses the whole band (${below.length}/${inBand.length}/${above.length})`,
);
// Off-centre and oblique, the two answers genuinely disagree — that is the
// divergence the exact projection exists to fix.
const disagreement = Math.abs(
wrappedDelta(above[0].rotation, above[0].basis),
);
assert.ok(
disagreement > Cesium.Math.toRadians(5),
`exact and camera-basis differ by ${Cesium.Math.toDegrees(disagreement).toFixed(1)}° above the band`,
);
let worstStep = 0;
for (let i = 1; i < samples.length; i += 1) {
worstStep = Math.max(
worstStep,
Math.abs(wrappedDelta(samples[i].rotation, samples[i - 1].rotation)),
);
}
assert.ok(
worstStep < Cesium.Math.toRadians(3),
`no step between neighbouring altitudes exceeds 3° (worst ${Cesium.Math.toDegrees(worstStep).toFixed(2)}°)`,
);
// Below the band it is the camera-basis answer; above it the exact one, unblended.
for (const s of below)
assert.ok(
Math.abs(wrappedDelta(s.rotation, s.basis)) < 1e-6,
`basis alone at ${s.separation.toFixed(2)} px`,
);
for (const s of above)
assert.ok(
Math.abs(wrappedDelta(s.rotation, s.exact)) < 1e-6,
`exact alone at ${s.separation.toFixed(2)} px`,
);
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Who owns the pointer over the globe.
*
* Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and
* select whatever is under a left click. That is right when nothing else is
* going on, and wrong the moment a tool needs the pointer for itself: a draw
* session placing a vertex on top of an aircraft must not also track the
* aircraft, and the next such tool must not have to be added to a list inside
* every layer.
*
* So there is one claim, held by at most one owner at a time:
*
* - A TOOL that needs the pointer calls `claimPointer('draw')` when it turns on
* and gets back a LEASE — an opaque token. It releases with
* `releasePointer(lease)` when it turns off. It does not consult anyone;
* holding the claim is what makes it the owner.
* - An AMBIENT SELECTION HANDLER — anything that picks an entity because the
* user clicked — early-returns while `isPointerFree()` is false. It never
* claims.
*
* Claims do not stack and are never stolen: while one owner holds the pointer,
* a second `claimPointer` returns null rather than displacing it — and so does
* a second claim under the SAME name, because two live instances of one tool
* (an old one mid-teardown, its replacement already running) are two owners,
* not one. The lease is what tells them apart: releasing takes the token that
* was issued, so a superseded instance calling `releasePointer` with its own
* stale lease frees nothing and its successor keeps the pointer.
*
* Ownership is page-scoped, like the layers and handlers it arbitrates.
*/
/** @typedef {{owner: string, id: number}} PointerLease */
/** @type {PointerLease|null} */
let currentLease = null;
let nextLeaseId = 1;
/**
* Take the pointer for `owner`.
* @param {string} owner Stable identifier for the claiming tool, e.g. 'draw'.
* @returns {PointerLease|null} The lease to release with, or null if the
* pointer is already held — including by another instance of the same tool.
*/
export function claimPointer(owner) {
const name = normalizeOwner(owner);
if (!name) return null;
if (currentLease !== null) return null;
currentLease = Object.freeze({ owner: name, id: nextLeaseId++ });
return currentLease;
}
/**
* Give the pointer back. Only the exact lease that took it can release it, so a
* superseded instance cannot free the claim its replacement now holds.
* @param {PointerLease|null} lease The token `claimPointer` returned.
* @returns {boolean} Whether this call released the claim.
*/
export function releasePointer(lease) {
if (!currentLease || !lease || lease.id !== currentLease.id) return false;
currentLease = null;
return true;
}
/** @returns {string|null} The current owner's name, or null when free. */
export function pointerOwner() {
return currentLease ? currentLease.owner : null;
}
/** @returns {boolean} True when no tool holds the pointer. */
export function isPointerFree() {
return currentLease === null;
}
/**
* @param {string} owner
* @returns {boolean} True when a tool of that name is holding the pointer.
*/
export function isPointerOwnedBy(owner) {
const name = normalizeOwner(owner);
return Boolean(name) && pointerOwner() === name;
}
/**
* @param {PointerLease|null} lease
* @returns {boolean} True when THIS lease is the live one.
*/
export function isLeaseCurrent(lease) {
return Boolean(currentLease && lease && lease.id === currentLease.id);
}
/**
* Drop any claim. For tests and for application teardown, which cannot rely on
* a half-disposed tool to release its own lease.
* @returns {string|null} The owner that was holding it, if any.
*/
export function resetPointerOwnership() {
const previous = pointerOwner();
currentLease = null;
return previous;
}
function normalizeOwner(owner) {
return typeof owner === 'string' && owner.trim() ? owner.trim() : null;
}
+176
View File
@@ -0,0 +1,176 @@
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 {
claimPointer,
isLeaseCurrent,
isPointerFree,
isPointerOwnedBy,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from './inputOwnership.js';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
test.beforeEach(() => resetPointerOwnership());
test('the pointer starts free and a claim hands back a lease', () => {
assert.equal(isPointerFree(), true);
assert.equal(pointerOwner(), null);
const lease = claimPointer('draw');
assert.ok(lease, 'a successful claim returns a lease, not a boolean');
assert.equal(isPointerFree(), false);
assert.equal(pointerOwner(), 'draw');
assert.equal(isPointerOwnedBy('draw'), true);
assert.equal(isPointerOwnedBy('directions'), false);
assert.equal(isLeaseCurrent(lease), true);
});
test('a claim is never stolen — not even by the same tool name', () => {
const first = claimPointer('draw');
assert.ok(first);
assert.equal(
claimPointer('directions'),
null,
'another tool must not displace it',
);
assert.equal(
claimPointer('draw'),
null,
'a SECOND instance of the same tool is a second owner, not a no-op',
);
assert.equal(pointerOwner(), 'draw');
assert.equal(isLeaseCurrent(first), true);
});
test('a replaced instance cannot free its successor’s claim', () => {
// The bug this rule exists for. An old Draw instance is torn down while a new
// one is already running; the old teardown calls releasePointer. Under
// release-by-name that freed the LIVE claim and every layer started selecting
// through the new instance's vertices.
const stale = claimPointer('draw');
assert.ok(releasePointer(stale), 'the old instance gives the pointer back');
const live = claimPointer('draw');
assert.ok(live, 'the replacement takes it');
assert.notEqual(live.id, stale.id, 'a fresh claim is a fresh lease');
assert.equal(releasePointer(stale), false, 'the stale lease frees nothing');
assert.equal(pointerOwner(), 'draw', 'the replacement still holds it');
assert.equal(isLeaseCurrent(stale), false);
assert.equal(isLeaseCurrent(live), true);
assert.equal(releasePointer(live), true);
assert.equal(isPointerFree(), true);
});
test('release ignores anything that is not the live lease', () => {
const lease = claimPointer('draw');
for (const bad of [
null,
undefined,
'draw',
{},
{ owner: 'draw', id: lease.id + 99 },
]) {
assert.equal(releasePointer(bad), false, JSON.stringify(bad));
assert.equal(pointerOwner(), 'draw');
}
assert.equal(releasePointer(lease), true);
assert.equal(releasePointer(lease), false, 'releasing twice changes nothing');
});
test('a claim needs a real owner id', () => {
for (const bad of ['', ' ', null, undefined, 7, {}]) {
assert.equal(claimPointer(bad), null, JSON.stringify(bad));
assert.equal(isPointerFree(), true);
}
const lease = claimPointer(' draw ');
assert.ok(lease, 'ids are trimmed, not rejected for padding');
assert.equal(pointerOwner(), 'draw');
assert.equal(releasePointer(lease), true);
});
test('reset reports who was holding it, for teardown', () => {
assert.equal(resetPointerOwnership(), null);
const lease = claimPointer('draw');
assert.equal(resetPointerOwnership(), 'draw');
assert.equal(isPointerFree(), true);
assert.equal(isLeaseCurrent(lease), false);
});
test('every scene click handler consults ownership before it picks', () => {
// This is the whole point of a shared contract: a new tool must not have to
// find and edit each of these. If a layer grows a click handler it has to
// appear here too.
const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
[
'src/layers/installations/selection.js',
'viewer.scene.pick(click.position)',
],
['src/layers/launches/lifecycle.js', 'drillPick(movement.position'],
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
[
'src/layers/satellites/interaction.js',
'viewer.scene.pick(click.position)',
],
[
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
assert.match(
source,
/import \{ isPointerFree \} from '[^']*inputOwnership\.js';/,
`${file} must consult the shared pointer claim`,
);
const guardAt = source.indexOf('if (!isPointerFree()) return;');
const pickAt = source.indexOf(firstPick);
assert.ok(guardAt >= 0, `${file} is missing the guard`);
assert.ok(
pickAt >= 0,
`${file}: could not find its pick call (${firstPick})`,
);
assert.ok(
guardAt < pickAt,
`${file} must yield before it picks, not after`,
);
}
});
test('ambient selection handlers never claim the pointer themselves', () => {
// Claiming from a selection handler would deadlock every other layer the
// moment a user clicked anything. Only tools claim.
for (const file of [
'src/data/trackingClickGesture.js',
'src/data/localGeojsonCore.js',
'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js',
'src/layers/satellites/interaction.js',
]) {
assert.doesNotMatch(
read(file),
/claimPointer\(/,
`${file} must not claim the pointer`,
);
}
});
+25 -6
View File
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
}
export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
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;
@@ -41,13 +47,17 @@ export function findNextIssPass({
// 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 (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;
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;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
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 };
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
}
+175 -72
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
}
function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
const entries =
demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
return entries
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
.map(([layerId, demand]) => [
String(layerId),
Math.max(0, Math.floor(Number(demand) || 0)),
])
.filter(([, demand]) => demand > 0)
.sort(([a], [b]) => a.localeCompare(b));
}
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
weighted.length = idCount;
priorityOrder.length = idCount;
remainders.length = idCount;
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
priorityOrder.sort(
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
);
let remaining = capacity;
if (capacity >= idCount) {
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
let apportioned = 0;
for (let i = 0; i < idCount; i++) {
const entry = weighted[i];
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
entry.fraction = exact - Math.floor(exact);
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction
|| b.weight - a.weight
|| a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (let i = 0; i < idCount && remaining > 0; i++) {
const entry = remainders[i];
const current = quotas.get(entry.layerId) || 0;
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
* Allocate a collective capacity across non-empty layers. Both strategies are
* work-conserving and deterministic; unused entitlement is always borrowed.
*/
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
export function allocateLayerQuotas(
demandByLayer,
capacity,
strategy = ALLOCATION_ELASTIC,
layerWeights = {},
) {
const entries = demandEntries(demandByLayer);
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
const cap = clampInt(capacity, 0, totalDemand);
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
if (remaining > 0 && totalWeight > 0) {
let apportioned = 0;
for (const entry of weighted) {
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
apportioned += whole;
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
remainders.push({
layerId: entry.layerId,
fraction: exact - Math.floor(exact),
weight: entry.weight,
});
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (const entry of remainders) {
if (remaining <= 0) break;
const current = quotas.get(entry.layerId) || 0;
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
}
function rectIsFinite(rect) {
return rect
&& Number.isFinite(rect.x)
&& Number.isFinite(rect.y)
&& Number.isFinite(rect.w)
&& Number.isFinite(rect.h)
&& rect.w > 0
&& rect.h > 0;
return (
rect &&
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.w) &&
Number.isFinite(rect.h) &&
rect.w > 0 &&
rect.h > 0
);
}
function overlaps(a, b, padding = 4) {
return a.x < b.x + b.w + padding
&& a.x + a.w + padding > b.x
&& a.y < b.y + b.h + padding
&& a.y + a.h + padding > b.y;
return (
a.x < b.x + b.w + padding &&
a.x + a.w + padding > b.x &&
a.y < b.y + b.h + padding &&
a.y + a.h + padding > b.y
);
}
const CELL_ORIGIN = 8192;
@@ -271,8 +298,10 @@ class SpatialHash {
* never hide a real collision.
*/
_cellKey(x, y) {
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
const cx =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
// Smi; arithmetic multiplication materializes a boxed double at each
// spatial-hash get/add on this hot solve path.
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
// A stateless candidate is never lifetime-pinned: its source shipped as a
// per-frame rebuild, so it must be free to lose its slot the moment something
// better wants it. Plain incumbency ordering below still applies.
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
const aPinned =
!!aState?.selected &&
a.stateless !== true &&
now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned =
!!bState?.selected &&
b.stateless !== true &&
now - bState.selectedAt < MIN_LIFETIME_MS;
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const aIncumbent = !!aState?.selected;
const bIncumbent = !!bState?.selected;
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
function cacheCandidateAnchorScalars(candidate) {
let anchorX = Number.NaN;
let anchorY = Number.NaN;
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
if (
Number.isFinite(candidate?.screenX) &&
Number.isFinite(candidate?.screenY)
) {
anchorX = candidate.screenX;
anchorY = candidate.screenY;
} else {
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
const placement = Array.isArray(candidate?.placements)
? candidate.placements[0]
: null;
if (
Number.isFinite(placement?.leadFromX) &&
Number.isFinite(placement?.leadFromY)
) {
anchorX = placement.leadFromX;
anchorY = placement.leadFromY;
} else if (rectIsFinite(placement?.rect)) {
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
* stay unboxed. Few-placement candidates already blocked by the
* monotonically growing collision field are dismissed before anchor scans.
*/
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
reset(
candidates,
count,
anchors,
anchorCount,
attemptStamps,
stamp,
spatial,
states,
) {
this.candidates = candidates;
this.count = count;
this.anchorCount = anchorCount;
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
const rectY = placementYs[rectIndex];
const rectW = placementWs[rectIndex];
const rectH = placementHs[rectIndex];
if (rectX < anchorRectX + anchorRectW + 4
&& rectX + rectW + 4 > anchorRectX
&& rectY < anchorRectY + anchorRectH + 4
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
if (
rectX < anchorRectX + anchorRectW + 4 &&
rectX + rectW + 4 > anchorRectX &&
rectY < anchorRectY + anchorRectH + 4 &&
rectY + rectH + 4 > anchorRectY
)
freeMask &= ~bit;
}
placementMasks[i] = freeMask;
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
bestIndex = i;
continue;
}
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
const priorityDelta =
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
if (priorityDelta !== 0) {
if (priorityDelta > 0) {
best = candidate;
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
if (stickyCorner) {
for (let i = 0; i < placements.length; i++) {
const placement = placements[i];
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
continue;
if (!spatial.collides(placement.rect)) return placement;
}
}
@@ -790,7 +847,13 @@ export class LabelArbiter {
let candidateCount = 0;
for (let i = 0; i < source.length; i++) {
const candidate = source[i];
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
if (
!candidate ||
!candidate.key ||
!candidate.layerId ||
!(candidate.keyholeAlpha > 0)
)
continue;
cacheCandidateAnchorScalars(candidate);
candidateList[candidateCount++] = candidate;
let bucket = byLayer.get(candidate.layerId);
@@ -815,13 +878,15 @@ export class LabelArbiter {
});
} else if (options.demandByLayer != null) {
const supplied = demandEntries(options.demandByLayer);
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
for (let i = 0; i < supplied.length; i++)
demand.set(supplied[i][0], supplied[i][1]);
}
// A supplied demand map describes the full lightweight field while the
// candidate list may be only a bounded materialized cohort. Keep defensive
// support for candidate layers omitted by an incomplete caller map.
byLayer.forEach((bucket, layerId) => {
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
if (bucket.count > 0 && !demand.has(layerId))
demand.set(layerId, bucket.count);
});
const activeLayers = this._activeLayersScratch;
@@ -857,14 +922,20 @@ export class LabelArbiter {
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
if (activeLayers[i] !== this.lastActiveLayers[i])
sameActiveLayers = false;
}
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
const preserveIncumbents =
options.preserveIncumbents !== false && sameActiveLayers;
let spatialQueueBuildCount = 0;
let spatialQueueNextCount = 0;
const attempt = (candidate, allowOverlapFallback = false) => {
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
if (
selectedCount >= capacity ||
attemptStamps.get(candidate.key) === stamp
)
return false;
attemptStamps.set(candidate.key, stamp);
const previous = this.states.get(candidate.key);
const stateless = candidate.stateless === true;
@@ -872,10 +943,12 @@ export class LabelArbiter {
// very next solve, as their stateless originals did) and carry no sticky
// corner, so above/below is re-decided from geometry every solve instead
// of being pinned by whichever side happened to be free once.
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
return false;
const sticky = stateless ? undefined : previous?.corner;
let placement = firstFreePlacement(candidate, sticky, spatial);
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
if (!placement && allowOverlapFallback)
placement = firstOrderedPlacement(candidate, sticky);
if (!placement) return false;
spatial.add(placement.rect);
selectStamps.set(candidate.key, stamp);
@@ -953,7 +1026,8 @@ export class LabelArbiter {
}
let retainedIncumbents = 0;
for (let i = 0; i < selectedCount; i++) {
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
if (this.selectedKeys.has(selectedCandidates[i].key))
retainedIncumbents++;
}
const exitingStates = this._refreshStateList();
@@ -964,7 +1038,9 @@ export class LabelArbiter {
state.exitStartedAt = now;
// Stateless entries may be re-selected immediately; the cooldown exists
// to damp thrash for sources that shipped with hysteresis.
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
state.cooldownUntil = state.stateless
? 0
: now + FADE_OUT_MS + COOLDOWN_MS;
}
}
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
this._stateListDirty = true;
}
if (labelsByLayer) {
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
labelsByLayer[candidate.layerId] =
(labelsByLayer[candidate.layerId] || 0) + 1;
}
}
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
const livingStates = this._refreshStateList();
for (let i = 0; i < livingStates.length; i++) {
const state = livingStates[i];
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
if (
!state.selected &&
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
) {
this.states.delete(state.key);
this._stateListDirty = true;
}
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
});
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
dropped.length = droppedCount;
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
for (let i = 0; i < selectedCount; i++)
this.selectedKeys.add(selectedCandidates[i].key);
const previousActiveLayers = this.lastActiveLayers;
this.lastActiveLayers = activeLayers;
this._activeLayersScratch = previousActiveLayers;
this.solveRevision++;
this.lastDiagnostics = collectDiagnostics ? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
} : null;
this.lastDiagnostics = collectDiagnostics
? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
}
: null;
selectedCandidates.length = selectedCount;
selectedPlacements.length = selectedCount;
pruneStampMap(attemptStamps, candidateCount);
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
* A caller-owned output array enables allocation-free per-frame rendering.
*/
renderEntries(currentCandidates, now = Date.now(), out = []) {
const current = currentCandidates instanceof Map
? currentCandidates
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
const current =
currentCandidates instanceof Map
? currentCandidates
: new Map(
(currentCandidates || []).map((candidate) => [
candidate.key,
candidate,
]),
);
const states = this._refreshStateList();
let outIndex = 0;
for (let i = 0; i < states.length; i++) {
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
// fully painted this solve or gone. No enter ramp, no exit tail.
temporalAlpha = state.selected ? 1 : 0;
} else if (state.selected) {
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
temporalAlpha = Math.min(
1,
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
);
} else {
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
temporalAlpha =
1 -
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
}
if (temporalAlpha <= 0) continue;
const candidate = current.get(state.key) || state.lastCandidate;
if (!candidate) continue;
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
const placement = renderPlacement(
candidate,
state.stateless ? undefined : state.corner,
state.lastPlacement,
);
if (!placement) continue;
const entry = out[outIndex] || (out[outIndex] = {});
entry.candidate = candidate;
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
for (const state of this.states.values()) {
if (!state.selected) {
if (!includeFading) continue;
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
if (
!Number.isFinite(state.exitStartedAt) ||
now - state.exitStartedAt >= FADE_OUT_MS
)
continue;
}
const candidate = state.lastCandidate;
if (!candidate?.layerId || candidate.sourceId == null) continue;
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
if (!grouped.has(candidate.layerId))
grouped.set(candidate.layerId, new Set());
grouped.get(candidate.layerId).add(candidate.sourceId);
}
return grouped;
+450 -178
View File
File diff suppressed because it is too large. Load diff
+15 -6
View File
@@ -1,3 +1,5 @@
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -156,8 +158,9 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 16);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
assert.equal(REGISTERED_LAYER_IDS.length, 21);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 21);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
@@ -224,8 +227,14 @@ test('v2 codec distinguishes absent from empty and keeps canonical deterministic
});
test('unknown enabled-layer tokens reject the payload instead of becoming an empty set', () => {
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.z')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=unknown')), null);
assert.equal(decodeLayerStateParams(new URLSearchParams('v=2&l=c.unknown')), null);
});
test('Nepal event and locator have distinct enabled-only share tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=h.z'));
assert.deepEqual(decoded.enabledLayerIds, ['bhote-koshi-2026', 'bhote-koshi-locator']);
assert.ok(encode(decoded).includes('l=h.z'));
});
test('unknown and forbidden option fields are ignored while missing options use codec defaults', () => {
@@ -372,12 +381,12 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
assert.match(source, /^\s*(?:let |flightState\.)_models3dMode = 'proximity';/m,
`${name}: and starts in proximity, matching the codec default`);
}
const ui = await readFile(new URL('../ui/applicationShell.js', import.meta.url), 'utf8');
const ui = await readShellSource();
assert.match(ui, /^\s*this\.(?:flightState\.)?_models3dEnabled = true;$/m,
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity');
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8');
const html = expandApplicationHtml(await readFile(new URL('../../index.html', import.meta.url), 'utf8'));
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
File diff suppressed because it is too large. Load diff
+10 -29
View File
@@ -1,15 +1,5 @@
import { governorRequestRender } from '../renderGovernor.js';
import {
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
} from './contextStore.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { localGeoJsonServices } from '../app/localGeojsonServices.js';
export { localGeoJsonServices } from '../app/localGeojsonServices.js';
import {
createLocalGeoJsonLayer as createLayer,
createLocalInfrastructureOverlayPublisher as createPublisher,
@@ -17,26 +7,14 @@ import {
export * from './localGeojsonCore.js';
/** Existing standalone application operations, shared by its local layers. */
export const localGeoJsonServices = Object.freeze({
overlayHost: Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
}),
clearSelectedEntityContextForLayer,
registerEntityContext,
removeEntityContextsForLayer,
selectEntityContext,
governorRequestRender,
});
/** Create a layer using the standalone app's operations; retain overlay overrides. */
export function createLocalGeoJsonLayer(options) {
return createLayer(options, {
...localGeoJsonServices,
overlayHost: options.overlayHost === undefined
? localGeoJsonServices.overlayHost : options.overlayHost,
overlayHost:
options.overlayHost === undefined
? localGeoJsonServices.overlayHost
: options.overlayHost,
});
}
@@ -44,6 +22,9 @@ export function createLocalGeoJsonLayer(options) {
export function createLocalInfrastructureOverlayPublisher(options) {
return createPublisher({
...options,
host: options.host === undefined ? localGeoJsonServices.overlayHost : options.host,
host:
options.host === undefined
? localGeoJsonServices.overlayHost
: options.host,
});
}
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
import {
selectInfraLod,
applyInfraEvictionGrace,
@@ -768,6 +769,8 @@ export function createLocalGeoJsonLayer(
viewer.scene.canvas,
);
_clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js).
if (!isPointerFree()) return;
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
+1 -6
View File
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
source: 'NASA FIRMS · LIVE',
});
export default [
datacenters,
dams,
submarineCablesLayer,
fires,
];
export default [datacenters, dams, submarineCablesLayer, fires];
@@ -0,0 +1,46 @@
# CCTV ground heights
Precomputed ground height under every camera in the served CCTV catalog and
under the nine support points of its monitor plane, aligned to work with
Google Photorealistic 3D Tiles. Heights are WGS84 ellipsoidal metres. The
client places a camera and its plane from this file with no runtime sampling.
Runtime output: `cctv_ground_heights.json`
```
{
"schemaVersion": 1,
"provider": "google-3d-tiles",
"heightReference": "WGS84-ellipsoid",
"generatedAt": "<ISO>",
"cameras": {
"<camera id>": {
"poseHash": "p1-…", // hash of the served pose the entry describes
"status": "ok" | "miss",
"mountGroundM": <number>, // ground under the camera position (absent on a miss)
"supports": { // ground under the plane's 3×3 support grid; null where
"bl","bm","br", // a point has no value (absent on a miss)
"ml","mc","mr",
"tl","tm","tr"
},
"misses": [<keys with no value>],
"sampledAt": "<ISO>",
"attempts": <n>
}
}
}
```
Support point positions are defined by `src/data/cctvFootprint.js`
(`planeSupportPoints`), shared with the server join
(`server/providers/cctv/groundHeights.js`). An entry is used only while the
camera's served pose still hashes to `poseHash`; a camera whose feed moved it,
or whose pose a user edited, falls back to runtime placement from the terrain
proxy.
Regenerate against a running app (resumable; only cameras whose pose changed
or whose entry is incomplete are redone):
```
GEV_BASE=http://localhost:4173 node scripts/precompute-cctv-heights.mjs
```
File diff suppressed because it is too large. Load diff
+34 -1993
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -3302,3 +3302,37 @@ test('panel remount releases old listeners and destruction revokes retained cont
else globalThis.document = originalDocument;
}
});
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const mgr = new DataLayerManager({});
const layer = makeRowControlLayer();
let calls = 0, blocked = false;
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
mgr.register(layer.module);
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
await mgr.setEnabled('satellites', true);
const row = container.querySelector('[data-layer-id="satellites"]');
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
const controls = row.querySelector('.data-toggle-controls');
const chip = collectByClass(controls, 'data-toggle-chip')[0];
controls.listeners.click({ target: chip });
assert.equal(calls, 1);
blocked = true;
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
blocked = false;
await mgr.setEnabled('satellites', false);
controls.listeners.click({ target: chip });
assert.equal(calls, 1, 'disabled layer cannot navigate');
} finally {
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
+7 -165
View File
@@ -1,165 +1,7 @@
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
// (field-test round 4, 2026-07-06).
//
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
// airport apron) — so DEM-floored sprites/trails still buried themselves in
// the mesh while 3D models (which groundSnap against the mesh) looked right.
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
//
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
// tile-load prioritization at the probe point, so sampling is (a) gated to
// cells NEAR the viewer — where tiles are streamed and where an error is
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
// (d) skipped entirely outside the google-3d regime. A returned-undefined
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
//
// Sample acceptance is sanity-gated against the DEM prior when warm (a
// vertical probe can hit a rooftop or another aircraft instead of pavement):
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
import * as Cesium from 'cesium';
import {
coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell,
setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor,
} from './groundFloor.js';
/** @constant {number} Max scene samples per call (one call per layer poll). */
const MAX_SAMPLES_PER_CALL = 40;
/** @constant {number} Only cells within this range of the viewer subpoint are
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
* one-shot latch made those permanent (owner round 5: grounded planes stuck
* at a uniform wrong height). */
const MAX_SAMPLE_DIST_KM = 15;
/** @constant {number} No sampling when the camera is above this height — the
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
// stack renders. main.js re-dispatches MapStackController.onChange as this
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
if (typeof window !== 'undefined') {
window.addEventListener('gev:map-stack-changed', (event) => {
const activeId = event?.detail?.activeId;
if (activeId) setMeshFloorPreferred(activeId === 'photoreal');
});
}
/**
* True when a VISIBLE 3D tileset in the scene reports its streaming queue
* drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady.
* Walks top-level primitives only (a handful; once per poll).
* @param {Cesium.Scene} scene
* @returns {boolean}
*/
function _visibleTilesetLoaded(scene) {
try {
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) {
return !!p.tilesLoaded;
}
}
} catch { /* mid-teardown */ }
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon = (lon2 - lon1) * 111.32 * Math.cos(((lat1 + lat2) / 2) * Math.PI / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - Own billboards/models to
* exclude from the probe (Cesium matches instances/.primitive/.id — pass
* Billboard and Model instances, NOT collections).
* @param {number} [options.viewerLat] @param {number} [options.viewerLon]
* Viewer subpoint (computed once by the caller's poll).
*/
export function sampleMeshFloorCells(scene, points, { excludeObjects = [], viewerLat, viewerLon } = {}) {
if (!meshFloorPreferred()) return;
if (!scene || typeof scene.sampleHeight !== 'function') return;
if (!Array.isArray(points) || !points.length) return;
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
// while tiles are MID-STREAM a probe returns coarse-LOD heights — real
// numbers, wildly wrong — and the one-shot latch made them permanent.
// Only sample when the visible Google tileset reports tilesLoaded AND the
// camera is low enough that the streamed LOD near it is fine-grained.
const camH = scene.camera?.positionCartographic?.height;
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
if (!_visibleTilesetLoaded(scene)) return;
let sampled = 0;
const attempted = new Set();
for (const p of points) {
if (sampled >= MAX_SAMPLES_PER_CALL) break;
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const cell = coarseFloorCoord(p.lat, p.lon);
const key = `${cell.lat},${cell.lon}`;
if (attempted.has(key)) continue;
attempted.add(key);
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
if (Number.isFinite(viewerLat) && Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
let height;
try {
const carto = Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _scratchProbe);
height = scene.sampleHeight(carto, excludeObjects);
sampled += 1;
} catch {
continue; // scene mid-teardown — try again next poll
}
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
// - A geoid-fallback prior (cached while the proxy was failing) is a
// sea-level poison that rejected legitimate samples at elevated
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
// - No prior at all means no way to tell a real surface from a
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
// latched a 37 m "surface" at Austin permanently. The DEM lands
// within a poll (the same warm batch that queued this cell), so
// waiting costs one poll, not correctness.
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
// existed was built here and then removed, because it was measured failing.
// Run twice against the real GPU at the same Austin apron with the proxy
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
// coarse-LOD read of ground that is really ~122 m, and it left the contact
// ~100 m under the mesh. The DEM requirement above is not a formality.
//
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
// NOT a resolution signal. It reports that the streaming queue has drained
// for the CURRENT camera's screen-space-error target, and a distant or high
// camera is entirely satisfied by coarse tiles — which is exactly the state
// that reported "loaded" during the measurement above. Gating harder on it
// buys nothing. A correct gate has to ask about the specific tile covering
// the sample point: walk the tileset tree to the tile containing the
// coordinate and require its `geometricError` to be below a threshold tied
// to the accuracy the caller needs, before trusting the sample. That is
// feasible and deliberately deferred — see the P2 in
// the project roadmap, which bundles it with the fly_route probe so
// one post-launch pass settles rendered-mesh sampling everywhere.
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
}
}
export { cachedGroundFloor }; // re-export for callers that want one import site
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.js';
const meshFloorSampler = createMeshFloorSampler({
groundFloor,
eventTarget: typeof window === 'undefined' ? null : window,
});
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+6 -14
View File
@@ -1,22 +1,14 @@
import { createAwarenessLayer } from '../layers/awareness/index.js';
import { createApplicationAwareness } from '../app/layers/militaryAwareness.js';
import flights from './flights.js';
import military from './militaryFlights.js';
import vessels from './aisLiveVessels.js';
import installations from './militaryInstallations.js';
import * as navigation from '../navigationPolicy.js';
import * as geometry from '../celestialRing.js';
import * as render from '../renderGovernor.js';
const layer = createAwarenessLayer({
services: {
flights,
military,
vessels,
installations,
navigation,
geometry,
render,
},
const layer = createApplicationAwareness({
flights,
military,
vessels,
installations,
});
export const contextTargetFlyToAllowed = layer.contextTargetFlyToAllowed;
export const awarenessClearMatchesSubject = layer.awarenessClearMatchesSubject;
+2 -2
View File
@@ -1358,7 +1358,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
]) {
assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`);
}
@@ -1535,7 +1535,7 @@ test('production eviction sites actually tag their clears', () => {
for (const [name, source] of [['flights', flightsSource], ['militaryFlights', militarySource]]) {
assert.match(
source,
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:parts\.\w+\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
/if\s*\(\s*icao24\s*===\s*(?:flightState\.)?_trackedIcao,?\s*\)\s*\{\s*\n\s*(?:(?:parts\.)?tracking\.)?_clearTracking\(\s*false,\s*\{\s*evicted:\s*true\s*\},?\s*\);/,
`${name} must mark its aged-out cull as an eviction`,
);
assert.match(
+73 -18
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/
export function findByDoublingRadius(search, {
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {}) {
export function findByDoublingRadius(
search,
{
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {},
) {
if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
for (const candidate of [
value.callsign,
value.name,
value.label,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
}
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
*/
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
export function summarizeAwarenessCohort(
items,
{ available = true, stale = false, limit = 3 } = {},
) {
if (!available)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
};
const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
&& (item.distanceM ?? item.distance) >= 0)
.filter(
(item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM);
return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
};
}
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
*/
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
export function getAwarenessNavigationTargets(
cohorts,
subject = null,
visitedKeys = [],
) {
const visited = new Set(visitedKeys);
const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id;
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
if (
![
'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(layerId)
)
continue;
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? cohort.summary.navigationNearest
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
: Array.isArray(cohort?.summary?.nearest)
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
targets.push({
layerId,
id: String(id),
item,
visited: visited.has(key),
});
}
}
targets.sort((a, b) => {
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
// reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder;
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
const subjectOrder =
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
});
+8 -39
View File
@@ -1,44 +1,13 @@
import { createMilitaryFlightLayer } from '../layers/military/index.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import * as picking from './pickRegistry.js';
import * as sprites from './spriteOrder.js';
import * as trails from './trailRenderer.js';
import * as aircraftPresentation from './tr3bRegistry.js';
import * as camera from './trackedCamera.js';
import * as militaryRegistry from './militaryRegistry.js';
import * as labels from './detectionDraw.js';
import * as groundFloor from './groundFloor.js';
import * as meshFloor from './meshFloorSampler.js';
import * as geoid from './geoid.js';
import * as focus from './focusDeemphasis.js';
import * as readout from './trackedReadout.js';
import * as context from './contextStore.js';
import * as render from '../renderGovernor.js';
import * as groundSnap from './groundSnap.js';
import * as recession from './aircraftRecession.js';
import { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
const militaryFlightsLayer = createMilitaryFlightLayer({
import { createAdsbLolSource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface,
source: createAdsbLolSource(),
resolveAsset: (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
services: {
picking,
sprites,
trails,
aircraftPresentation,
camera,
militaryRegistry,
labels,
groundFloor,
meshFloor,
geoid,
focus,
readout,
context,
render,
groundSnap,
recession,
},
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
export const _setTrackedMilitaryRefreshStateForTest =
Loaded 100 of 724 files, more files were not shown because too many files have changed in this diff. Show more