mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 20:34:02 +00:00
Merge origin/main into fix/antimeridian-traffic-bounds
This commit is contained in:
commit
2f16e34cad
724 files changed
+193369
-25564
No files matched your search
+414
-67
@@ -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 },
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
|
||||
modelRendering = false,
|
||||
includeHidden = false,
|
||||
} = {}) {
|
||||
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
|
||||
return Boolean(
|
||||
includeHidden || isTracked || billboardShown || modelRendering,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]),
|
||||
]);
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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';
|
||||
@@ -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
@@ -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();
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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()],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user