mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
style: format remaining owned runtime modules
This commit is contained in:
1 parent
ae99843c14
commit
2aca7aecbb
94 files changed
+8865
-3151
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;
|
||||
}
|
||||
+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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
+173
-44
@@ -74,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);
|
||||
}
|
||||
|
||||
@@ -89,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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,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);
|
||||
}
|
||||
@@ -165,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 };
|
||||
@@ -176,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;
|
||||
@@ -202,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;
|
||||
@@ -225,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).
|
||||
@@ -286,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);
|
||||
@@ -306,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;
|
||||
}
|
||||
|
||||
@@ -343,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);
|
||||
@@ -379,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' : '');
|
||||
}
|
||||
@@ -397,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 = {
|
||||
@@ -405,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,
|
||||
@@ -419,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;
|
||||
@@ -448,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') {
|
||||
@@ -478,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;
|
||||
@@ -531,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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+23
-11
@@ -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,16 +149,21 @@ 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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +174,19 @@ export function clearSelectedEntityContextForLayer(layerId, { evicted = false }
|
||||
export function removeEntityContextsForLayer(layerId, { retainIds } = {}) {
|
||||
const store = getContextStore();
|
||||
for (const [id, record] of store.entities) {
|
||||
if (record?.layerId === layerId && !retainIds?.has(id)) 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' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,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;
|
||||
}
|
||||
@@ -185,8 +185,7 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
{
|
||||
key: 'gtfs-rt',
|
||||
html:
|
||||
'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)',
|
||||
},
|
||||
{
|
||||
key: 'radio-browser',
|
||||
@@ -299,11 +298,12 @@ export const NATURAL_EARTH_CREDIT = {
|
||||
* @returns {{ key: string, html: string }}
|
||||
*/
|
||||
export function transitFeedCredit(feed) {
|
||||
const escape = (text) => String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const escape = (text) =>
|
||||
String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
return {
|
||||
key: `transit-${feed.id}`,
|
||||
html:
|
||||
|
||||
+219
-70
@@ -279,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?.();
|
||||
@@ -323,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;
|
||||
@@ -397,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;
|
||||
}
|
||||
@@ -462,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;
|
||||
@@ -477,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;
|
||||
@@ -526,7 +539,9 @@ export function getDetectionDiagnostics() {
|
||||
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 }));
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -535,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);
|
||||
@@ -632,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();
|
||||
@@ -665,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;
|
||||
@@ -687,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);
|
||||
@@ -705,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');
|
||||
}
|
||||
}
|
||||
@@ -804,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);
|
||||
@@ -900,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,
|
||||
@@ -940,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;
|
||||
}
|
||||
@@ -996,7 +1066,13 @@ function _paintCalloutLane(frame) {
|
||||
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);
|
||||
if (entry)
|
||||
paintTransitBracket(
|
||||
ctx,
|
||||
entry.path,
|
||||
entry.color,
|
||||
entry.alpha * _transitBracketAlpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
@@ -1021,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);
|
||||
@@ -1042,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);
|
||||
}
|
||||
@@ -1063,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,
|
||||
@@ -1079,7 +1167,13 @@ 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
|
||||
@@ -1087,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) {
|
||||
@@ -1116,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,
|
||||
@@ -1144,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
|
||||
@@ -1179,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);
|
||||
@@ -1217,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
|
||||
@@ -1229,7 +1340,10 @@ 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);
|
||||
@@ -1241,18 +1355,34 @@ function _drawOverlay(frame) {
|
||||
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) {
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(pathFor(transit ? _transitBracketPaths : bracketPaths, color, bracketAlpha), sx, sy, halfW, halfH);
|
||||
(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 {
|
||||
@@ -1286,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);
|
||||
}
|
||||
|
||||
@@ -1301,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) {
|
||||
@@ -1320,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);
|
||||
@@ -1338,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, {
|
||||
@@ -1419,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,
|
||||
@@ -1451,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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +241,6 @@ export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||||
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?.();
|
||||
@@ -255,6 +257,11 @@ export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||||
|
||||
/** 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));
|
||||
appendCornerBracket(
|
||||
sink,
|
||||
Math.floor(sx) + 0.5,
|
||||
Math.floor(sy) + 0.5,
|
||||
Math.round(halfW),
|
||||
Math.round(halfH),
|
||||
);
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
|
||||
export function detectionBracketOpacity(cockpitActive) {
|
||||
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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';
|
||||
|
||||
+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);
|
||||
|
||||
+29
-1
@@ -1,4 +1,32 @@
|
||||
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;
|
||||
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;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
/** Compatibility constructor using the default floor cache. */
|
||||
export function createGroundSnap() { return createSnap({ groundFloor }); }
|
||||
export function createGroundSnap() {
|
||||
return createSnap({ groundFloor });
|
||||
}
|
||||
+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;
|
||||
|
||||
+438
-179
File diff suppressed because it is too large.
Load diff
@@ -11,8 +11,10 @@ export * from './localGeojsonCore.js';
|
||||
export function createLocalGeoJsonLayer(options) {
|
||||
return createLayer(options, {
|
||||
...localGeoJsonServices,
|
||||
overlayHost: options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost : options.overlayHost,
|
||||
overlayHost:
|
||||
options.overlayHost === undefined
|
||||
? localGeoJsonServices.overlayHost
|
||||
: options.overlayHost,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,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,
|
||||
});
|
||||
}
|
||||
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
|
||||
source: 'NASA FIRMS · LIVE',
|
||||
});
|
||||
|
||||
export default [
|
||||
datacenters,
|
||||
dams,
|
||||
submarineCablesLayer,
|
||||
fires,
|
||||
];
|
||||
export default [datacenters, dams, submarineCablesLayer, fires];
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
|
||||
import * as groundFloor from './groundFloor.js';
|
||||
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
|
||||
const meshFloorSampler = createMeshFloorSampler({
|
||||
groundFloor,
|
||||
eventTarget: typeof window === 'undefined' ? null : window,
|
||||
});
|
||||
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -37,10 +37,14 @@ const MAX_FOOTPRINT_POINTS = 400;
|
||||
* @returns {string} Display label, never empty.
|
||||
*/
|
||||
export function humanizeInstallationClass(klass) {
|
||||
const key = String(klass || '').trim().toLowerCase();
|
||||
const key = String(klass || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (LABEL_BY_CLASS[key]) return LABEL_BY_CLASS[key];
|
||||
const words = key.replaceAll('_', ' ').trim();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Mapped installation';
|
||||
return words
|
||||
? `${words[0].toUpperCase()}${words.slice(1)}`
|
||||
: 'Mapped installation';
|
||||
}
|
||||
|
||||
function finiteLatitude(value) {
|
||||
@@ -54,7 +58,8 @@ function finiteLongitude(value) {
|
||||
function pointFrom(element) {
|
||||
const lat = Number(element?.lat ?? element?.center?.lat);
|
||||
const longitude = Number(element?.lon ?? element?.center?.lon);
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude)) return { latitude: lat, longitude };
|
||||
if (finiteLatitude(lat) && finiteLongitude(longitude))
|
||||
return { latitude: lat, longitude };
|
||||
|
||||
// Bounds midpoint fallback (field test 2026-08-28, Warendorf: nothing drawn).
|
||||
// The proxy asks for `out center tags geom`, but Overpass takes the LAST
|
||||
@@ -67,8 +72,12 @@ function pointFrom(element) {
|
||||
// recovers all of them without touching the query or the footprint path.
|
||||
const bounds = element?.bounds;
|
||||
// JSON nulls, booleans, and empty strings must not coerce to a false 0,0 site.
|
||||
if (![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon]
|
||||
.every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
||||
if (
|
||||
![bounds?.minlat, bounds?.minlon, bounds?.maxlat, bounds?.maxlon].every(
|
||||
(value) => typeof value === 'number' && Number.isFinite(value),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const south = Number(bounds?.minlat);
|
||||
const west = Number(bounds?.minlon);
|
||||
const north = Number(bounds?.maxlat);
|
||||
@@ -79,17 +88,28 @@ function pointFrom(element) {
|
||||
// A single installation footprint is never wider than the request bbox cap
|
||||
// (10°): an ascending box that spans the antimeridian (minlon -179, maxlon
|
||||
// 179) passes the ordering check yet midpoints to longitude 0.
|
||||
if (finiteLatitude(south) && finiteLatitude(north)
|
||||
&& finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south <= north && west <= east
|
||||
&& north - south <= 10 && east - west <= 10) {
|
||||
if (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south <= north &&
|
||||
west <= east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
) {
|
||||
return { latitude: (south + north) / 2, longitude: (west + east) / 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function footprintFrom(element) {
|
||||
if (!Array.isArray(element?.geometry) || element.geometry.length < 3 || element.geometry.length > MAX_FOOTPRINT_POINTS) return null;
|
||||
if (
|
||||
!Array.isArray(element?.geometry) ||
|
||||
element.geometry.length < 3 ||
|
||||
element.geometry.length > MAX_FOOTPRINT_POINTS
|
||||
)
|
||||
return null;
|
||||
const points = [];
|
||||
for (const point of element.geometry) {
|
||||
const latitude = Number(point?.lat);
|
||||
@@ -106,21 +126,30 @@ function footprintFrom(element) {
|
||||
* @param {string} [retrievedAt]
|
||||
* @returns {{records: Array, droppedCount: number}}
|
||||
*/
|
||||
export function normalizeMilitaryInstallations(payload, retrievedAt = new Date().toISOString()) {
|
||||
export function normalizeMilitaryInstallations(
|
||||
payload,
|
||||
retrievedAt = new Date().toISOString(),
|
||||
) {
|
||||
const records = [];
|
||||
const ids = new Set();
|
||||
let droppedCount = 0;
|
||||
for (const element of Array.isArray(payload?.elements) ? payload.elements : []) {
|
||||
for (const element of Array.isArray(payload?.elements)
|
||||
? payload.elements
|
||||
: []) {
|
||||
const type = String(element?.type || '');
|
||||
const osmId = Number(element?.id);
|
||||
if (!['node', 'way', 'relation'].includes(type) || !Number.isSafeInteger(osmId)) {
|
||||
if (
|
||||
!['node', 'way', 'relation'].includes(type) ||
|
||||
!Number.isSafeInteger(osmId)
|
||||
) {
|
||||
droppedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `osm:${type}:${osmId}`;
|
||||
const tags = element?.tags || {};
|
||||
const klass = CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()]
|
||||
|| (tags.landuse === 'military' ? 'military_land' : null);
|
||||
const klass =
|
||||
CLASS_BY_MILITARY_TAG[String(tags.military || '').toLowerCase()] ||
|
||||
(tags.landuse === 'military' ? 'military_land' : null);
|
||||
const point = pointFrom(element);
|
||||
if (!klass || !point || ids.has(id)) {
|
||||
droppedCount += 1;
|
||||
@@ -134,7 +163,9 @@ export function normalizeMilitaryInstallations(payload, retrievedAt = new Date()
|
||||
// geometry, while a footprint-less way/relation has unknown extent.
|
||||
osmType: type,
|
||||
class: klass,
|
||||
name: String(tags.name || tags['name:en'] || '').trim() || humanizeInstallationClass(klass),
|
||||
name:
|
||||
String(tags.name || tags['name:en'] || '').trim() ||
|
||||
humanizeInstallationClass(klass),
|
||||
...point,
|
||||
footprint: footprintFrom(element),
|
||||
sources: [{ name: 'OpenStreetMap', id: `${type}/${osmId}`, retrievedAt }],
|
||||
@@ -152,6 +183,14 @@ export function isValidInstallationBoundingBox(value) {
|
||||
const west = Number(box.west);
|
||||
const north = Number(box.north);
|
||||
const east = Number(box.east);
|
||||
return finiteLatitude(south) && finiteLatitude(north) && finiteLongitude(west) && finiteLongitude(east)
|
||||
&& south < north && west < east && north - south <= 10 && east - west <= 10;
|
||||
return (
|
||||
finiteLatitude(south) &&
|
||||
finiteLatitude(north) &&
|
||||
finiteLongitude(west) &&
|
||||
finiteLongitude(east) &&
|
||||
south < north &&
|
||||
west < east &&
|
||||
north - south <= 10 &&
|
||||
east - west <= 10
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({
|
||||
* trail anchor has to ride the same chain or it lands on a different axis than
|
||||
* the aircraft it is supposed to be attached to. */
|
||||
const AXIS_CORRECTION = Cesium.Matrix4.multiplyTransformation(
|
||||
Cesium.Axis.Y_UP_TO_Z_UP, Cesium.Axis.Z_UP_TO_X_UP, new Cesium.Matrix4(),
|
||||
Cesium.Axis.Y_UP_TO_Z_UP,
|
||||
Cesium.Axis.Z_UP_TO_X_UP,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const _anchorScratch = new Cesium.Cartesian3();
|
||||
const _chainScratch = new Cesium.Matrix4();
|
||||
@@ -124,7 +126,11 @@ export function modelAnchorWorld(model, nativeAnchor, result) {
|
||||
components || Cesium.Matrix4.IDENTITY,
|
||||
_chainScratch,
|
||||
);
|
||||
Cesium.Matrix4.multiplyTransformation(_chainScratch, AXIS_CORRECTION, _chainScratch);
|
||||
Cesium.Matrix4.multiplyTransformation(
|
||||
_chainScratch,
|
||||
AXIS_CORRECTION,
|
||||
_chainScratch,
|
||||
);
|
||||
return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result);
|
||||
}
|
||||
|
||||
@@ -220,9 +226,17 @@ export function trailHeadStart(start, anchor, center, radiusM, result) {
|
||||
// a real trail.
|
||||
if (!anchor || !center || !result) return start;
|
||||
if (!Number.isFinite(radiusM) || radiusM <= 0) return start;
|
||||
const startD = Math.hypot(start.x - center.x, start.y - center.y, start.z - center.z);
|
||||
const startD = Math.hypot(
|
||||
start.x - center.x,
|
||||
start.y - center.y,
|
||||
start.z - center.z,
|
||||
);
|
||||
if (startD >= radiusM) return start;
|
||||
const anchorD = Math.hypot(anchor.x - center.x, anchor.y - center.y, anchor.z - center.z);
|
||||
const anchorD = Math.hypot(
|
||||
anchor.x - center.x,
|
||||
anchor.y - center.y,
|
||||
anchor.z - center.z,
|
||||
);
|
||||
// A degenerate shell (an anchor at or outside the envelope — no shipped asset
|
||||
// has one, since every anchor is a measured hull point) leaves nothing to
|
||||
// ramp across; fall back to the containment verdict rather than divide by it.
|
||||
@@ -252,8 +266,20 @@ export function modelVisualAnchor(modelMatrix, nativeCenter, scale, result) {
|
||||
const x = (nativeCenter?.[0] || 0) * safeScale;
|
||||
const y = (nativeCenter?.[1] || 0) * safeScale;
|
||||
const z = (nativeCenter?.[2] || 0) * safeScale;
|
||||
result.x = modelMatrix[0] * x + modelMatrix[4] * y + modelMatrix[8] * z + modelMatrix[12];
|
||||
result.y = modelMatrix[1] * x + modelMatrix[5] * y + modelMatrix[9] * z + modelMatrix[13];
|
||||
result.z = modelMatrix[2] * x + modelMatrix[6] * y + modelMatrix[10] * z + modelMatrix[14];
|
||||
result.x =
|
||||
modelMatrix[0] * x +
|
||||
modelMatrix[4] * y +
|
||||
modelMatrix[8] * z +
|
||||
modelMatrix[12];
|
||||
result.y =
|
||||
modelMatrix[1] * x +
|
||||
modelMatrix[5] * y +
|
||||
modelMatrix[9] * z +
|
||||
modelMatrix[13];
|
||||
result.z =
|
||||
modelMatrix[2] * x +
|
||||
modelMatrix[6] * y +
|
||||
modelMatrix[10] * z +
|
||||
modelMatrix[14];
|
||||
return result;
|
||||
}
|
||||
+102
-27
@@ -29,10 +29,14 @@ export function norm360(deg) {
|
||||
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
||||
if (!onGround || !newest?.position || !candidatePosition) return false;
|
||||
const stored = Cesium.Cartographic.fromCartesian(
|
||||
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
|
||||
newest.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
const candidate = Cesium.Cartographic.fromCartesian(
|
||||
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
|
||||
candidatePosition,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCandidateCarto,
|
||||
);
|
||||
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
@@ -62,7 +66,11 @@ export function courseBetweenCartesians(from, to, minChordM = 25) {
|
||||
const dy = to.y - from.y;
|
||||
const dz = to.z - from.z;
|
||||
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
from,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
_mmCarto,
|
||||
);
|
||||
if (!carto) return null;
|
||||
const sLat = Math.sin(carto.latitude);
|
||||
const cLat = Math.cos(carto.latitude);
|
||||
@@ -110,7 +118,10 @@ export function speedRamp(speedMps) {
|
||||
if (!Number.isFinite(speedMps)) return 1;
|
||||
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
||||
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
||||
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
|
||||
return (
|
||||
(speedMps - COURSE_TRACK_ONLY_MPS) /
|
||||
(COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS)
|
||||
);
|
||||
}
|
||||
|
||||
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
||||
@@ -141,10 +152,16 @@ export function displayedKinematics({
|
||||
reportedTrackDeg,
|
||||
} = {}) {
|
||||
return {
|
||||
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
|
||||
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
|
||||
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
|
||||
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
|
||||
speedMps: Number.isFinite(derivedSpeedMps)
|
||||
? Math.max(0, derivedSpeedMps)
|
||||
: Number.isFinite(reportedSpeedMps)
|
||||
? Math.max(0, reportedSpeedMps)
|
||||
: null,
|
||||
trackDeg: Number.isFinite(derivedTrackDeg)
|
||||
? derivedTrackDeg
|
||||
: Number.isFinite(reportedTrackDeg)
|
||||
? reportedTrackDeg
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,10 +184,17 @@ export function staleCoastLimitSeconds({
|
||||
maximumSec = 300,
|
||||
} = {}) {
|
||||
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
||||
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
|
||||
const ceiling = Math.max(
|
||||
floor,
|
||||
Number.isFinite(maximumSec) ? maximumSec : 300,
|
||||
);
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs))
|
||||
return floor;
|
||||
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
||||
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
|
||||
const grace = Math.max(
|
||||
0,
|
||||
Number.isFinite(contactGraceSec) ? contactGraceSec : 60,
|
||||
);
|
||||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||||
}
|
||||
|
||||
@@ -197,18 +221,31 @@ export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
|
||||
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
||||
* behavior: no way to tell, so they still count).
|
||||
*/
|
||||
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
|
||||
export function estimateTurnRateDps(
|
||||
samples,
|
||||
noiseFloorDps = 0.4,
|
||||
maxDps = 4,
|
||||
minSpeedMps = 0,
|
||||
) {
|
||||
if (!samples || samples.length < 2) return 0;
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const dt = samples[i].tSec - samples[i - 1].tSec;
|
||||
if (dt < 2 || dt > 120) continue;
|
||||
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
|
||||
if (
|
||||
!Number.isFinite(samples[i].trackDeg) ||
|
||||
!Number.isFinite(samples[i - 1].trackDeg)
|
||||
)
|
||||
continue;
|
||||
if (minSpeedMps > 0) {
|
||||
const v0 = samples[i - 1].speedMps;
|
||||
const v1 = samples[i].speedMps;
|
||||
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
|
||||
if (
|
||||
(Number.isFinite(v0) && v0 < minSpeedMps) ||
|
||||
(Number.isFinite(v1) && v1 < minSpeedMps)
|
||||
)
|
||||
continue;
|
||||
}
|
||||
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
||||
n += 1;
|
||||
@@ -274,13 +311,31 @@ const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||||
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
||||
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
||||
*/
|
||||
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
|
||||
export function projectGroundArcLatLon(
|
||||
lat,
|
||||
lon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps,
|
||||
dtSec,
|
||||
) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
||||
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|
||||
|| speedMps <= 0 || dtSec <= 0) {
|
||||
if (
|
||||
!Number.isFinite(courseDeg) ||
|
||||
!Number.isFinite(speedMps) ||
|
||||
!Number.isFinite(dtSec) ||
|
||||
speedMps <= 0 ||
|
||||
dtSec <= 0
|
||||
) {
|
||||
return { lat, lon };
|
||||
}
|
||||
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
|
||||
const off = arcOffsetEnu(
|
||||
speedMps,
|
||||
courseDeg,
|
||||
turnRateDps || 0,
|
||||
dtSec,
|
||||
_scratchProjectArc,
|
||||
);
|
||||
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
||||
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
||||
return {
|
||||
@@ -320,8 +375,15 @@ export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDp
|
||||
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
||||
*/
|
||||
export function corridorPathLatLon({
|
||||
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
|
||||
fixLat, fixLon, lookaheadSec,
|
||||
extrapolating,
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
turnRateDps = 0,
|
||||
fixLat,
|
||||
fixLon,
|
||||
lookaheadSec,
|
||||
}) {
|
||||
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
||||
const start = { lat: displayLat, lon: displayLon };
|
||||
@@ -338,9 +400,16 @@ export function corridorPathLatLon({
|
||||
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
||||
const out = [start];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
out.push(projectGroundArcLatLon(
|
||||
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
|
||||
));
|
||||
out.push(
|
||||
projectGroundArcLatLon(
|
||||
displayLat,
|
||||
displayLon,
|
||||
courseDeg,
|
||||
speed,
|
||||
turnRateDps,
|
||||
cappedSec * (i / n),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -385,13 +454,17 @@ const _forwardFixOffset = new Cesium.Cartesian3();
|
||||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||||
*/
|
||||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
|
||||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
|
||||
return null;
|
||||
const startEpochMs = Number.isFinite(newest.epochMs)
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
|
||||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
|
||||
return null;
|
||||
|
||||
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
|
||||
const previousVelocity = Number.isFinite(newest.velocity)
|
||||
? Math.max(0, newest.velocity)
|
||||
: 0;
|
||||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||||
arcOffsetEnu(
|
||||
@@ -421,7 +494,9 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||||
epochMs: next.epochMs,
|
||||
position,
|
||||
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
|
||||
velocity: Number.isFinite(next.velocity)
|
||||
? Math.max(0, next.velocity)
|
||||
: previousVelocity,
|
||||
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,8 @@ export function resolvePickId(picked) {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') return id.mmsi;
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export function resolvePickId(picked) {
|
||||
};
|
||||
let id = unwrap(picked.id);
|
||||
if (id === undefined) id = unwrap(picked.primitive?.id);
|
||||
return (typeof id === 'string' || typeof id === 'number') ? String(id) : null;
|
||||
return typeof id === 'string' || typeof id === 'number' ? String(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-13
@@ -1,6 +1,7 @@
|
||||
const RADIO_COUNTRY_MAX_LENGTH = 80;
|
||||
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(`
|
||||
const ISO_ALPHA_2_CODES = Object.freeze(
|
||||
`
|
||||
AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
|
||||
CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
|
||||
GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO
|
||||
@@ -8,12 +9,16 @@ JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
|
||||
MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO
|
||||
RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV
|
||||
TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
|
||||
`.trim().split(/\s+/));
|
||||
`
|
||||
.trim()
|
||||
.split(/\s+/),
|
||||
);
|
||||
|
||||
const ISO_ALPHA_2_SET = new Set(ISO_ALPHA_2_CODES);
|
||||
const ENGLISH_REGION_NAMES = typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
const ENGLISH_REGION_NAMES =
|
||||
typeof Intl?.DisplayNames === 'function'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
|
||||
function countryKey(value) {
|
||||
return String(value || '')
|
||||
@@ -29,7 +34,8 @@ function countryKey(value) {
|
||||
const COUNTRY_NAME_TO_CODE = new Map();
|
||||
for (const code of ISO_ALPHA_2_CODES) {
|
||||
const displayName = ENGLISH_REGION_NAMES?.of(code);
|
||||
if (displayName && displayName !== code) COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
if (displayName && displayName !== code)
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(displayName), code);
|
||||
}
|
||||
|
||||
for (const [name, code] of Object.entries({
|
||||
@@ -71,7 +77,8 @@ for (const [name, code] of Object.entries({
|
||||
'east timor': 'TL',
|
||||
'cabo verde': 'CV',
|
||||
vatican: 'VA',
|
||||
})) COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
}))
|
||||
COUNTRY_NAME_TO_CODE.set(countryKey(name), code);
|
||||
|
||||
function canonicalCountryName(code) {
|
||||
return ENGLISH_REGION_NAMES?.of(code) || code;
|
||||
@@ -90,17 +97,19 @@ export function normalizeRadioCountryInput(value) {
|
||||
}
|
||||
const trimmed = value.normalize('NFKC').trim().replace(/\s+/g, ' ');
|
||||
if (
|
||||
!trimmed
|
||||
|| trimmed.length > RADIO_COUNTRY_MAX_LENGTH
|
||||
|| /[\u0000-\u001f\u007f]/.test(trimmed)
|
||||
|| !/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
!trimmed ||
|
||||
trimmed.length > RADIO_COUNTRY_MAX_LENGTH ||
|
||||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
|
||||
!/^[\p{L}\p{M}.&'’()\-\s]+$/u.test(trimmed)
|
||||
)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
|
||||
const possibleCode = trimmed.toUpperCase();
|
||||
const code = ISO_ALPHA_2_SET.has(possibleCode)
|
||||
? possibleCode
|
||||
: COUNTRY_NAME_TO_CODE.get(countryKey(trimmed)) || '';
|
||||
if (!code) return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
if (!code)
|
||||
return Object.freeze({ valid: false, empty: false, code: '', name: '' });
|
||||
return Object.freeze({
|
||||
valid: true,
|
||||
empty: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { applicationServices } from '../services/application.js';
|
||||
export * from './regionalModel.js';
|
||||
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
|
||||
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
|
||||
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
|
||||
if (![latitude, longitude].every(Number.isFinite))
|
||||
throw new Error('Valid coordinates are required');
|
||||
return applicationServices.regional.getBrief(latitude, longitude, { signal });
|
||||
}
|
||||
+52
-20
@@ -1,13 +1,18 @@
|
||||
const MAX_ARTICLES = 5;
|
||||
|
||||
function cleanText(value, maxLength = 180) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeHttpUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? parsed.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -17,14 +22,27 @@ function safeHttpUrl(value) {
|
||||
export function normalizeRegionalPlace(payload) {
|
||||
const address = payload?.address || {};
|
||||
const locality = cleanText(
|
||||
address.city || address.town || address.village || address.municipality
|
||||
|| address.hamlet || address.county,
|
||||
address.city ||
|
||||
address.town ||
|
||||
address.village ||
|
||||
address.municipality ||
|
||||
address.hamlet ||
|
||||
address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(
|
||||
address.state || address.region || address.county,
|
||||
90,
|
||||
);
|
||||
const region = cleanText(address.state || address.region || address.county, 90);
|
||||
const country = cleanText(address.country, 90);
|
||||
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
|
||||
.join(', ') || country || cleanText(payload?.display_name, 120);
|
||||
const label =
|
||||
[locality, region]
|
||||
.filter(
|
||||
(value, index, values) => value && values.indexOf(value) === index,
|
||||
)
|
||||
.join(', ') ||
|
||||
country ||
|
||||
cleanText(payload?.display_name, 120);
|
||||
if (!label) return null;
|
||||
return {
|
||||
label,
|
||||
@@ -51,11 +69,16 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
|
||||
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
|
||||
const publishedAt = compactDate
|
||||
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
|
||||
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
|
||||
: Number.isNaN(Date.parse(rawDate))
|
||||
? null
|
||||
: new Date(rawDate).toISOString();
|
||||
articles.push({
|
||||
title,
|
||||
url,
|
||||
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
|
||||
domain: cleanText(
|
||||
row?.domain || new URL(url).hostname.replace(/^www\./, ''),
|
||||
80,
|
||||
),
|
||||
publishedAt,
|
||||
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
|
||||
});
|
||||
@@ -74,11 +97,15 @@ export function normalizeRegionalWeather(payload) {
|
||||
};
|
||||
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
|
||||
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
|
||||
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
const observedRaw =
|
||||
typeof current.time === 'string' &&
|
||||
!/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
|
||||
? `${current.time}Z`
|
||||
: current.time;
|
||||
return {
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
|
||||
observedAt: Number.isNaN(Date.parse(observedRaw))
|
||||
? null
|
||||
: new Date(observedRaw).toISOString(),
|
||||
temperatureC: numberOrNull(current.temperature_2m),
|
||||
apparentTemperatureC: numberOrNull(current.apparent_temperature),
|
||||
precipitationMm: numberOrNull(current.precipitation),
|
||||
@@ -109,14 +136,19 @@ export function weatherCodeLabel(code) {
|
||||
|
||||
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
|
||||
export function regionalDistanceM(from, to) {
|
||||
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
|
||||
if (
|
||||
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
|
||||
Number.isFinite,
|
||||
)
|
||||
) {
|
||||
return Infinity;
|
||||
}
|
||||
const phi1 = from.latitude * Math.PI / 180;
|
||||
const phi2 = to.latitude * Math.PI / 180;
|
||||
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
|
||||
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
|
||||
const a = Math.sin(deltaPhi / 2) ** 2
|
||||
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
const phi1 = (from.latitude * Math.PI) / 180;
|
||||
const phi2 = (to.latitude * Math.PI) / 180;
|
||||
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
|
||||
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) ** 2 +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
||||
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
@@ -26,7 +26,13 @@
|
||||
* none of the above are usable — the caller applies its OWN existing sticky/default
|
||||
* fallback (this helper never invents one, so it can't drift from the caller's policy).
|
||||
*/
|
||||
export function pickRenderAltitudeM({ geoAltM, baroAltM, onGround, surfaceM, geoidN }) {
|
||||
export function pickRenderAltitudeM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
onGround,
|
||||
surfaceM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (onGround && Number.isFinite(surfaceM)) {
|
||||
return surfaceM;
|
||||
}
|
||||
@@ -89,7 +95,12 @@ export function reuseGroundedSurfaceM(currentM, previousM) {
|
||||
* @param {number|null|undefined} params.geoidN - Geoid undulation N here.
|
||||
* @returns {number|null} The geoid guess, or null when it must not be used.
|
||||
*/
|
||||
export function geoidSurfaceLastResortM({ geoAltM, baroAltM, priorRenderM, geoidN }) {
|
||||
export function geoidSurfaceLastResortM({
|
||||
geoAltM,
|
||||
baroAltM,
|
||||
priorRenderM,
|
||||
geoidN,
|
||||
}) {
|
||||
if (Number.isFinite(geoAltM) || Number.isFinite(baroAltM)) return null;
|
||||
if (Number.isFinite(priorRenderM)) return null;
|
||||
return Number.isFinite(geoidN) ? geoidN : null;
|
||||
|
||||
@@ -34,11 +34,14 @@ export const RETRY_COOLDOWN_MAX_MS = 300_000;
|
||||
* @param {() => number} [options.now] - Clock seam for tests.
|
||||
* @returns {() => Promise<T>} Memoized loader; retries once the cooldown elapses.
|
||||
*/
|
||||
export function createRetryableLoader(load, {
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
export function createRetryableLoader(
|
||||
load,
|
||||
{
|
||||
cooldownMs = RETRY_COOLDOWN_MS,
|
||||
maxCooldownMs = RETRY_COOLDOWN_MAX_MS,
|
||||
now = () => Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
/** @type {Promise<T>|null} */
|
||||
let inflight = null;
|
||||
/** @type {*} Last rejection, replayed to callers inside the cooldown. */
|
||||
@@ -51,7 +54,10 @@ export function createRetryableLoader(load, {
|
||||
return function loadOnce() {
|
||||
if (inflight) return inflight;
|
||||
if (hasFailure) {
|
||||
const wait = Math.min(cooldownMs * 2 ** (consecutiveFailures - 1), maxCooldownMs);
|
||||
const wait = Math.min(
|
||||
cooldownMs * 2 ** (consecutiveFailures - 1),
|
||||
maxCooldownMs,
|
||||
);
|
||||
// Replay the recorded reason rather than re-running a loader that is
|
||||
// probably still broken. Callers see identical behavior either way.
|
||||
if (now() - failedAt < wait) return Promise.reject(failure);
|
||||
|
||||
+44
-12
@@ -19,7 +19,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dp = (lat2 - lat1) * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
@@ -28,7 +29,8 @@ function bearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = lat2 * D2R;
|
||||
const dl = (lon2 - lon1) * D2R;
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -42,8 +44,8 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
const VERT_TREND_MPS = 2; // ~400 fpm
|
||||
const LOCAL_AIRPORT_KM = 150;
|
||||
|
||||
/**
|
||||
@@ -55,16 +57,35 @@ const LOCAL_AIRPORT_KM = 150;
|
||||
* @param {{lat:number|null, lon:number|null}|null} [p.destination]
|
||||
* @returns {boolean} false ONLY when the route is confidently wrong.
|
||||
*/
|
||||
export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateMps = null, origin = null, destination = null }) {
|
||||
export function routePlausible({
|
||||
latDeg,
|
||||
lonDeg,
|
||||
altitudeM = null,
|
||||
verticalRateMps = null,
|
||||
origin = null,
|
||||
destination = null,
|
||||
}) {
|
||||
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
|
||||
const haveD = Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const haveD =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
|
||||
|
||||
// (a) Geographic consistency: near an endpoint, or roughly on the path.
|
||||
const near = (pt) => greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
const near = (pt) =>
|
||||
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
geomOk = Math.abs(crossTrackKm(latDeg, lonDeg, origin.lat, origin.lon, destination.lat, destination.lon)) < CROSS_TRACK_KM;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
latDeg,
|
||||
lonDeg,
|
||||
origin.lat,
|
||||
origin.lon,
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
@@ -72,13 +93,24 @@ export function routePlausible({ latDeg, lonDeg, altitudeM = null, verticalRateM
|
||||
|
||||
// (b) Vertical-trend consistency for low traffic (observer-free adaptation).
|
||||
if (
|
||||
Number.isFinite(altitudeM) && altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) && Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
Number.isFinite(altitudeM) &&
|
||||
altitudeM < LOW_ALT_M &&
|
||||
Number.isFinite(verticalRateMps) &&
|
||||
Math.abs(verticalRateMps) > VERT_TREND_MPS
|
||||
) {
|
||||
if (verticalRateMps > 0) {
|
||||
if (haveO && greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local
|
||||
if (
|
||||
haveO &&
|
||||
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // departing — origin should be local
|
||||
} else {
|
||||
if (haveD && greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local
|
||||
if (
|
||||
haveD &&
|
||||
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
|
||||
LOCAL_AIRPORT_KM
|
||||
)
|
||||
return false; // arriving — destination should be local
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -69,7 +69,13 @@ export const SATELLITE_CLASSES = Object.freeze({
|
||||
});
|
||||
|
||||
/** Legend/report order for the classes above. */
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze(['station', 'nav', 'geo', 'visual', 'comms']);
|
||||
export const SATELLITE_CLASS_ORDER = Object.freeze([
|
||||
'station',
|
||||
'nav',
|
||||
'geo',
|
||||
'visual',
|
||||
'comms',
|
||||
]);
|
||||
|
||||
/**
|
||||
* CelesTrak group tag → { class, subtype }. Subtype names the specific
|
||||
@@ -150,8 +156,11 @@ export function satelliteClassLabel(group, { isIss = false } = {}) {
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor = entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, { isIss: descriptor.isIss });
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -171,7 +180,13 @@ export function satelliteClassLegend(counts) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
result.push({ klass, label: spec.label, color: spec.color, blurb: spec.blurb, count });
|
||||
result.push({
|
||||
klass,
|
||||
label: spec.label,
|
||||
color: spec.color,
|
||||
blurb: spec.blurb,
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -42,7 +42,8 @@ const MAX_PICK_MAGNITUDE_M = 1_000_000_000;
|
||||
export function isPickedWorldPosition(position) {
|
||||
if (!position) return false;
|
||||
const { x, y, z } = position;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
|
||||
return false;
|
||||
const magnitude = Math.hypot(x, y, z);
|
||||
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
|
||||
}
|
||||
@@ -47,7 +47,8 @@ export function restoreSpriteOrder(viewer) {
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
const scene = viewer.scene;
|
||||
const primitives = scene?.primitives;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.()) return;
|
||||
if (!primitives || scene.isDestroyed?.() || primitives.isDestroyed?.())
|
||||
return;
|
||||
|
||||
for (const layerId of SPRITE_LAYER_ORDER) {
|
||||
const collection = _collections.get(layerId);
|
||||
@@ -71,6 +72,7 @@ export function restoreSpriteOrderOnEnable(
|
||||
viewer,
|
||||
restore = restoreSpriteOrder,
|
||||
) {
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function') return;
|
||||
if (!SPRITE_LAYER_ORDER.includes(layerId) || typeof restore !== 'function')
|
||||
return;
|
||||
restore(viewer);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createTerrainHeights } from '../services/terrainHeights.js';
|
||||
import { applicationServices } from '../services/application.js';
|
||||
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
|
||||
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
|
||||
const terrainHeights = createTerrainHeights({
|
||||
source: applicationServices.terrain,
|
||||
});
|
||||
export const {
|
||||
cachedEllipsoidalGround,
|
||||
cachedRealEllipsoidalGround,
|
||||
resolveEllipsoidalGround,
|
||||
} = terrainHeights;
|
||||
@@ -38,7 +38,9 @@ export const TR3B_CLASS = 'tr3b';
|
||||
|
||||
/** @param {*} id Raw contact id. @returns {string} Normalized registry key. */
|
||||
function key(id) {
|
||||
return String(id ?? '').trim().toLowerCase();
|
||||
return String(id ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
-19
@@ -26,17 +26,23 @@ export function trackedModelScaleForPixelCap({
|
||||
maximumPixelSize,
|
||||
}) {
|
||||
if (
|
||||
!Number.isFinite(baseScale) || baseScale <= 0
|
||||
|| !Number.isFinite(nativeRadiusM) || nativeRadiusM <= 0
|
||||
|| !Number.isFinite(rangeM) || rangeM <= 0
|
||||
|| !Number.isFinite(viewportHeightPx) || viewportHeightPx <= 0
|
||||
|| !Number.isFinite(fovyRad) || fovyRad <= 0
|
||||
|| !Number.isFinite(maximumPixelSize) || maximumPixelSize <= 0
|
||||
) return baseScale;
|
||||
!Number.isFinite(baseScale) ||
|
||||
baseScale <= 0 ||
|
||||
!Number.isFinite(nativeRadiusM) ||
|
||||
nativeRadiusM <= 0 ||
|
||||
!Number.isFinite(rangeM) ||
|
||||
rangeM <= 0 ||
|
||||
!Number.isFinite(viewportHeightPx) ||
|
||||
viewportHeightPx <= 0 ||
|
||||
!Number.isFinite(fovyRad) ||
|
||||
fovyRad <= 0 ||
|
||||
!Number.isFinite(maximumPixelSize) ||
|
||||
maximumPixelSize <= 0
|
||||
)
|
||||
return baseScale;
|
||||
const focalLengthPx = viewportHeightPx / (2 * Math.tan(fovyRad / 2));
|
||||
const projectedDiameterPx = (
|
||||
2 * nativeRadiusM * baseScale * focalLengthPx
|
||||
) / rangeM;
|
||||
const projectedDiameterPx =
|
||||
(2 * nativeRadiusM * baseScale * focalLengthPx) / rangeM;
|
||||
if (projectedDiameterPx <= maximumPixelSize) return baseScale;
|
||||
return baseScale * (maximumPixelSize / projectedDiameterPx);
|
||||
}
|
||||
@@ -99,17 +105,25 @@ export function clampTrackedCameraPosition(
|
||||
previousPosition,
|
||||
minimumRangeM = MIN_TRACKED_RANGE_M,
|
||||
) {
|
||||
const crossedOrigin = Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(camera.position, camera.direction);
|
||||
const crossedOrigin =
|
||||
Cesium.Cartesian3.dot(camera.position, previousPosition) <= 0;
|
||||
const forwardDistance = -Cesium.Cartesian3.dot(
|
||||
camera.position,
|
||||
camera.direction,
|
||||
);
|
||||
const rangeSquared = Cesium.Cartesian3.magnitudeSquared(camera.position);
|
||||
if (crossedOrigin) {
|
||||
Cesium.Cartesian3.normalize(previousPosition, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(camera.position, minimumRangeM, camera.position);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.position,
|
||||
minimumRangeM,
|
||||
camera.position,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
forwardDistance < minimumRangeM
|
||||
|| rangeSquared < minimumRangeM * minimumRangeM
|
||||
forwardDistance < minimumRangeM ||
|
||||
rangeSquared < minimumRangeM * minimumRangeM
|
||||
) {
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.direction,
|
||||
@@ -192,10 +206,14 @@ export function applyTrackedCameraFrame(viewer, entity, viewFrom) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
trackedTransform,
|
||||
);
|
||||
const offset = typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (!offset || Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12) {
|
||||
const offset =
|
||||
typeof viewFrom.getValue === 'function'
|
||||
? viewFrom.getValue(viewer.clock.currentTime, resolvedViewFrom)
|
||||
: Cesium.Cartesian3.clone(viewFrom, resolvedViewFrom);
|
||||
if (
|
||||
!offset ||
|
||||
Cesium.Cartesian3.magnitudeSquared(offset) < Cesium.Math.EPSILON12
|
||||
) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,5 +76,8 @@ export const TRACKED_MODEL_EXIT_ALT_M =
|
||||
*/
|
||||
export function trackedModelZoomActive(cameraHeightM, wasActive = false) {
|
||||
if (!Number.isFinite(cameraHeightM)) return false;
|
||||
return cameraHeightM < (wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M);
|
||||
return (
|
||||
cameraHeightM <
|
||||
(wasActive ? TRACKED_MODEL_EXIT_ALT_M : TRACKED_MODEL_ENTER_ALT_M)
|
||||
);
|
||||
}
|
||||
+59
-21
@@ -49,17 +49,24 @@ let _overlayHost = DEFAULT_TRACKED_OVERLAY_HOST;
|
||||
* @param {string} accent Source-owned accent color.
|
||||
* @returns {{title:string,details:string[],accent:string}}
|
||||
*/
|
||||
export function trackedLabelModelFromText(text, accent = WORLD_OVERLAY_STYLE.accent) {
|
||||
export function trackedLabelModelFromText(
|
||||
text,
|
||||
accent = WORLD_OVERLAY_STYLE.accent,
|
||||
) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return { title: '', details: [], accent };
|
||||
let lines;
|
||||
if (raw.includes('\n')) {
|
||||
lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const parts = raw.split(' · ');
|
||||
lines = parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
lines =
|
||||
parts.length > 1
|
||||
? [parts[0].trim(), parts.slice(1).join(' · ').trim()]
|
||||
: [raw];
|
||||
}
|
||||
return { title: lines[0] || '', details: lines.slice(1), accent };
|
||||
}
|
||||
@@ -101,7 +108,9 @@ export function cachedTrackedVisualPosition(entity) {
|
||||
try {
|
||||
const visual = entity.gevVisualPosition();
|
||||
if (visual) return visual;
|
||||
} catch { /* fall through to the display position */ }
|
||||
} catch {
|
||||
/* fall through to the display position */
|
||||
}
|
||||
}
|
||||
return cachedTrackedDisplayPosition(entity);
|
||||
}
|
||||
@@ -140,7 +149,9 @@ export function createTrackedOverlayEntry(entity) {
|
||||
collisionGroup: 'ambient-card',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title,
|
||||
details: Array.isArray(model.details) ? model.details.map((line) => String(line)) : [],
|
||||
details: Array.isArray(model.details)
|
||||
? model.details.map((line) => String(line))
|
||||
: [],
|
||||
accent: model.accent || WORLD_OVERLAY_STYLE.accent,
|
||||
cardStyle: model.cardStyle,
|
||||
selected: model.selected === true,
|
||||
@@ -149,9 +160,14 @@ export function createTrackedOverlayEntry(entity) {
|
||||
leaderAnimationStartedAt: model.leaderAnimationStartedAt,
|
||||
leaderDrawRatio: model.leaderDrawRatio,
|
||||
anchorRadiusPx: Number.isFinite(Number(model.anchorRadiusPx))
|
||||
? Math.max(0, Number(model.anchorRadiusPx)) : 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(model, 'anchorRadiusScale')
|
||||
? model.anchorRadiusScale : TRACKED_BILLBOARD_SCALE,
|
||||
? Math.max(0, Number(model.anchorRadiusPx))
|
||||
: 10,
|
||||
anchorRadiusScale: Object.prototype.hasOwnProperty.call(
|
||||
model,
|
||||
'anchorRadiusScale',
|
||||
)
|
||||
? model.anchorRadiusScale
|
||||
: TRACKED_BILLBOARD_SCALE,
|
||||
minAnchorGapPx: 16,
|
||||
anchorGapPaddingPx: 10,
|
||||
verticalOnly: true,
|
||||
@@ -203,7 +219,10 @@ export function getActiveTrackedReadoutId() {
|
||||
}
|
||||
|
||||
/** Static-context layers whose click selection publishes the readout card. */
|
||||
const READOUT_CONTEXT_LAYERS = new Set(['military-installations', 'alpr-cameras']);
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Initialize the model bridge and selection listeners. No render listener is
|
||||
@@ -215,10 +234,11 @@ export function initTrackedReadout(viewer) {
|
||||
if (_viewer) destroyTrackedReadout();
|
||||
_viewer = viewer;
|
||||
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
|
||||
_trackedEntityChangedRemove = viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_trackedEntityChangedRemove =
|
||||
viewer.trackedEntityChanged?.addEventListener?.(() => {
|
||||
if (viewer.trackedEntity) _selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}) || null;
|
||||
_contextSelectedHandler = (event) => {
|
||||
const record = event.detail;
|
||||
if (READOUT_CONTEXT_LAYERS.has(record?.layerId)) {
|
||||
@@ -232,7 +252,10 @@ export function initTrackedReadout(viewer) {
|
||||
}
|
||||
};
|
||||
_contextClearedHandler = (event) => {
|
||||
if (!_selectedContext || event.detail?.layerId === _selectedContext.layerId) {
|
||||
if (
|
||||
!_selectedContext ||
|
||||
event.detail?.layerId === _selectedContext.layerId
|
||||
) {
|
||||
_selectedContext = null;
|
||||
syncActiveEntity();
|
||||
}
|
||||
@@ -242,8 +265,14 @@ export function initTrackedReadout(viewer) {
|
||||
if (!_viewer?.trackedEntity) clearTrackedSource();
|
||||
};
|
||||
window.addEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
window.addEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
window.addEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
window.addEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
syncActiveEntity();
|
||||
// Boot-verification contract: the track regression harness asserts this
|
||||
// exact line at init (dropped by the host migration; restored).
|
||||
@@ -254,9 +283,18 @@ export function initTrackedReadout(viewer) {
|
||||
export function destroyTrackedReadout() {
|
||||
_trackedEntityChangedRemove?.();
|
||||
_trackedEntityChangedRemove = null;
|
||||
if (_contextSelectedHandler) window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler) window.removeEventListener('gev:entity-selection-cleared', _contextClearedHandler);
|
||||
if (_aircraftSelectedHandler) window.removeEventListener('gev:awareness-subject-selected', _aircraftSelectedHandler);
|
||||
if (_contextSelectedHandler)
|
||||
window.removeEventListener('gev:entity-selected', _contextSelectedHandler);
|
||||
if (_contextClearedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:entity-selection-cleared',
|
||||
_contextClearedHandler,
|
||||
);
|
||||
if (_aircraftSelectedHandler)
|
||||
window.removeEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
_aircraftSelectedHandler,
|
||||
);
|
||||
_contextSelectedHandler = null;
|
||||
_contextClearedHandler = null;
|
||||
_aircraftSelectedHandler = null;
|
||||
|
||||
@@ -28,8 +28,10 @@ export function isTrackingClickGesture(gesture = {}) {
|
||||
const durationMs = Number.isFinite(gesture.durationMs)
|
||||
? Math.max(0, gesture.durationMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return isTrackingSelectionGesture(gesture)
|
||||
&& durationMs <= MAX_TRACKING_CLICK_DURATION_MS;
|
||||
return (
|
||||
isTrackingSelectionGesture(gesture) &&
|
||||
durationMs <= MAX_TRACKING_CLICK_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +55,12 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
|
||||
let completedGesture = null;
|
||||
|
||||
const appendTravel = (position) => {
|
||||
if (!pressActive || !Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return;
|
||||
if (
|
||||
!pressActive ||
|
||||
!Number.isFinite(position?.x) ||
|
||||
!Number.isFinite(position?.y)
|
||||
)
|
||||
return;
|
||||
if (previousPosition) {
|
||||
travelPx += Math.hypot(
|
||||
position.x - previousPosition.x,
|
||||
|
||||
@@ -33,8 +33,8 @@ export function greatCircleKm(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dp = toRad(lat2 - lat1);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dp / 2) ** 2
|
||||
+ Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
const a =
|
||||
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ function initialBearingRad(lat1, lon1, lat2, lon2) {
|
||||
const p2 = toRad(lat2);
|
||||
const dl = toRad(lon2 - lon1);
|
||||
const y = Math.sin(dl) * Math.cos(p2);
|
||||
const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
const x =
|
||||
Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
|
||||
return Math.atan2(y, x);
|
||||
}
|
||||
|
||||
@@ -68,12 +69,15 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
const p1 = toRad(lat);
|
||||
const l1 = toRad(lon);
|
||||
const p2 = Math.asin(
|
||||
Math.sin(p1) * Math.cos(delta) + Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad)
|
||||
);
|
||||
const l2 = l1 + Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2)
|
||||
Math.sin(p1) * Math.cos(delta) +
|
||||
Math.cos(p1) * Math.sin(delta) * Math.cos(bearingRad),
|
||||
);
|
||||
const l2 =
|
||||
l1 +
|
||||
Math.atan2(
|
||||
Math.sin(bearingRad) * Math.sin(delta) * Math.cos(p1),
|
||||
Math.cos(delta) - Math.sin(p1) * Math.sin(p2),
|
||||
);
|
||||
// Normalize longitude to [-180, 180)
|
||||
const lonDeg = ((toDeg(l2) + 540) % 360) - 180;
|
||||
return { lat: toDeg(p2), lon: lonDeg };
|
||||
@@ -100,7 +104,13 @@ function destinationPoint(lat, lon, bearingRad, distKm) {
|
||||
* @returns {{lat:number, lon:number, source:'hit'|'nadir'|'pulled'}}
|
||||
* The fetch center and which rule produced it.
|
||||
*/
|
||||
export function deriveFetchCenter({ nadirLat, nadirLon, hitLat, hitLon, maxPullKm = 12 }) {
|
||||
export function deriveFetchCenter({
|
||||
nadirLat,
|
||||
nadirLon,
|
||||
hitLat,
|
||||
hitLon,
|
||||
maxPullKm = 12,
|
||||
}) {
|
||||
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
|
||||
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
/** @const {Object<string,'mono'|'crt'>} Style name → non-normal profile. */
|
||||
const PROFILE_BY_STYLE = {
|
||||
surveillance: 'mono', // NVG — P43 phosphor × luma
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
thermal: 'mono', // FLIR — grayscale/ironbow × luma
|
||||
noir: 'mono', // full desaturation
|
||||
retro: 'crt', // CRT — hue survives, small dots don't
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -49,13 +49,33 @@ const PROFILE_BY_STYLE = {
|
||||
*/
|
||||
const DOT_STYLE = {
|
||||
mono: {
|
||||
jam: { rgba: [255, 255, 255, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 255, 255, 0.9], sizeDelta: 1, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
free: { rgba: [255, 255, 255, 0.85], sizeDelta: 0, outline: { rgba: [0, 0, 0, 0.8], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 255, 255, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 255, 255, 0.9],
|
||||
sizeDelta: 1,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: {
|
||||
rgba: [255, 255, 255, 0.85],
|
||||
sizeDelta: 0,
|
||||
outline: { rgba: [0, 0, 0, 0.8], width: 1 },
|
||||
},
|
||||
},
|
||||
crt: {
|
||||
jam: { rgba: [255, 59, 48, 0.95], sizeDelta: 3, outline: { rgba: [0, 0, 0, 0.9], width: 2 } },
|
||||
slow: { rgba: [255, 179, 0, 0.92], sizeDelta: 2, outline: { rgba: [0, 0, 0, 0.85], width: 1 } },
|
||||
jam: {
|
||||
rgba: [255, 59, 48, 0.95],
|
||||
sizeDelta: 3,
|
||||
outline: { rgba: [0, 0, 0, 0.9], width: 2 },
|
||||
},
|
||||
slow: {
|
||||
rgba: [255, 179, 0, 0.92],
|
||||
sizeDelta: 2,
|
||||
outline: { rgba: [0, 0, 0, 0.85], width: 1 },
|
||||
},
|
||||
free: { rgba: [0, 255, 102, 0.9], sizeDelta: 1, outline: null },
|
||||
},
|
||||
};
|
||||
@@ -123,7 +143,8 @@ export function presetDotOutline(styleName, bucket) {
|
||||
* @returns {string|null} Tier key, or null (no override → stock 'vehicle').
|
||||
*/
|
||||
export function trafficBucketTier(bucket) {
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam') return `veh_${bucket}`;
|
||||
if (bucket === 'free' || bucket === 'slow' || bucket === 'jam')
|
||||
return `veh_${bucket}`;
|
||||
if (bucket === 'sim') return 'veh_nodata';
|
||||
return null;
|
||||
}
|
||||
@@ -33,13 +33,14 @@ const GAP_SPREAD_M = 6;
|
||||
*/
|
||||
export function queuePlatoons(totalLen, count, rng = Math.random) {
|
||||
const platoons = [];
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0)) return platoons;
|
||||
if (!Number.isFinite(totalLen) || totalLen <= 0 || !(count > 0))
|
||||
return platoons;
|
||||
|
||||
let placed = 0;
|
||||
while (placed < count) {
|
||||
const platoonSize = Math.min(
|
||||
count - placed,
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD)
|
||||
PLATOON_MIN + Math.floor(rng() * PLATOON_SPREAD),
|
||||
);
|
||||
const anchor = rng() * totalLen;
|
||||
const platoon = [];
|
||||
|
||||
@@ -28,7 +28,9 @@ import { registerPickOwner } from './pickRegistry.js';
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) => String(pickedId).startsWith('gev-trail:'));
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
@@ -99,7 +101,12 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (last && Cesium.Cartesian3.distanceSquared(last, position) < MIN_SEGMENT_DISTANCE_SQ) continue;
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
@@ -122,7 +129,11 @@ export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try { viewer.entities.remove(entity); } catch { /* torn down */ }
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
|
||||
+51
-12
@@ -20,21 +20,48 @@ export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
|
||||
*/
|
||||
const TYPE_STYLES = [
|
||||
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
|
||||
{ pattern: /cargo|container|bulk|carrier/i, css: '#39d5ff', accent: '57, 213, 255' },
|
||||
{ pattern: /passenger|ferry|cruise/i, css: '#ff7adf', accent: '255, 122, 223' },
|
||||
{
|
||||
pattern: /cargo|container|bulk|carrier/i,
|
||||
css: '#39d5ff',
|
||||
accent: '57, 213, 255',
|
||||
},
|
||||
{
|
||||
pattern: /passenger|ferry|cruise/i,
|
||||
css: '#ff7adf',
|
||||
accent: '255, 122, 223',
|
||||
},
|
||||
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
|
||||
{ pattern: /tug|tow|pilot|supply|service/i, css: '#f7f0a3', accent: '247, 240, 163' },
|
||||
{
|
||||
pattern: /tug|tow|pilot|supply|service/i,
|
||||
css: '#f7f0a3',
|
||||
accent: '247, 240, 163',
|
||||
},
|
||||
];
|
||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
||||
|
||||
const NUMERIC_TYPE_SPECIALS = {
|
||||
30: 'FISHING', 31: 'TOWING', 32: 'TOWING', 33: 'DREDGER', 34: 'DIVE OPS',
|
||||
35: 'MILITARY', 36: 'SAILING', 37: 'PLEASURE',
|
||||
50: 'PILOT', 51: 'SAR', 52: 'TUG', 53: 'PORT TENDER', 54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE', 58: 'MEDICAL',
|
||||
30: 'FISHING',
|
||||
31: 'TOWING',
|
||||
32: 'TOWING',
|
||||
33: 'DREDGER',
|
||||
34: 'DIVE OPS',
|
||||
35: 'MILITARY',
|
||||
36: 'SAILING',
|
||||
37: 'PLEASURE',
|
||||
50: 'PILOT',
|
||||
51: 'SAR',
|
||||
52: 'TUG',
|
||||
53: 'PORT TENDER',
|
||||
54: 'ANTI-POLLUTION',
|
||||
55: 'LAW ENFORCE',
|
||||
58: 'MEDICAL',
|
||||
};
|
||||
const NUMERIC_TYPE_FAMILIES = {
|
||||
4: 'HIGH-SPEED', 6: 'PASSENGER', 7: 'CARGO', 8: 'TANKER', 9: 'OTHER',
|
||||
4: 'HIGH-SPEED',
|
||||
6: 'PASSENGER',
|
||||
7: 'CARGO',
|
||||
8: 'TANKER',
|
||||
9: 'OTHER',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,13 +103,22 @@ function styleForType(type) {
|
||||
* @param {number} [rowLimit=900] Configured source row ceiling.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAULT_LABEL_LIMIT) {
|
||||
export function vesselOverlayCohortLimit(
|
||||
width,
|
||||
height,
|
||||
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
|
||||
) {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
const requested = Number(rowLimit);
|
||||
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
|
||||
const gridCapacity = Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(VESSEL_OVERLAY_MAX_COHORT, Math.floor(requested), gridCapacity);
|
||||
const gridCapacity =
|
||||
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
|
||||
return Math.min(
|
||||
VESSEL_OVERLAY_MAX_COHORT,
|
||||
Math.floor(requested),
|
||||
gridCapacity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +129,10 @@ export function vesselOverlayCohortLimit(width, height, rowLimit = VESSEL_DEFAUL
|
||||
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function applyVesselOverlayPolicy(card, fadeDistance = VESSEL_CARD_FADE_DISTANCE_M) {
|
||||
export function applyVesselOverlayPolicy(
|
||||
card,
|
||||
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
|
||||
) {
|
||||
const selected = card?.selected === true;
|
||||
const rawGap = Number(card?.gapPx) || 10;
|
||||
const gapPx = Math.max(12, rawGap + 8);
|
||||
|
||||
Reference in new issue
Block a user