style: format remaining owned runtime modules

This commit is contained in:
Sameh Khamis committed 2026-09-15 07:26:32 -07:00
1 parent ae99843c14
commit 2aca7aecbb
94 files changed
+8865 -3151

No files matched your search

+414 -67
View File
@@ -8,90 +8,415 @@
*/
const HELI = new Set([
'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'AS50', 'AS55', 'AS65', 'AS32',
'A109', 'A119', 'A139', 'A169', 'A189', 'B06', 'B06T', 'B407', 'B412', 'B427',
'B429', 'B430', 'B505', 'S76', 'S92', 'S61', 'S64', 'H60', 'H500', 'MD52',
'MD60', 'R22', 'R44', 'R66', 'EXEC', 'EXPL', 'GAZL', 'LYNX', 'NH90', 'PUMA',
'SCAV', 'UH1', 'B105', 'B212', 'B214', 'B222', 'AC', 'H47', 'H64',
'EC20',
'EC25',
'EC30',
'EC35',
'EC45',
'EC55',
'AS50',
'AS55',
'AS65',
'AS32',
'A109',
'A119',
'A139',
'A169',
'A189',
'B06',
'B06T',
'B407',
'B412',
'B427',
'B429',
'B430',
'B505',
'S76',
'S92',
'S61',
'S64',
'H60',
'H500',
'MD52',
'MD60',
'R22',
'R44',
'R66',
'EXEC',
'EXPL',
'GAZL',
'LYNX',
'NH90',
'PUMA',
'SCAV',
'UH1',
'B105',
'B212',
'B214',
'B222',
'AC',
'H47',
'H64',
]);
const QUAD = new Set([
'B741', 'B742', 'B743', 'B744', 'B748', 'B74S', 'B74R', 'B74D', 'A388', 'A342',
'A343', 'A345', 'A346', 'A124', 'C5M', 'A225', 'IL96', 'B52', 'A140',
'B741',
'B742',
'B743',
'B744',
'B748',
'B74S',
'B74R',
'B74D',
'A388',
'A342',
'A343',
'A345',
'A346',
'A124',
'C5M',
'A225',
'IL96',
'B52',
'A140',
]);
const WIDE = new Set([
'A306', 'A30B', 'A310', 'A332', 'A333', 'A338', 'A339', 'A359', 'A35K', 'B762',
'B763', 'B764', 'B772', 'B77L', 'B773', 'B77W', 'B778', 'B779', 'B788', 'B789',
'B78X', 'MD11', 'IL86', 'DC10', 'L101', 'A337', 'B767', 'B777', 'B787',
'C17', 'K35R', // military heavies that read as widebodies
'A306',
'A30B',
'A310',
'A332',
'A333',
'A338',
'A339',
'A359',
'A35K',
'B762',
'B763',
'B764',
'B772',
'B77L',
'B773',
'B77W',
'B778',
'B779',
'B788',
'B789',
'B78X',
'MD11',
'IL86',
'DC10',
'L101',
'A337',
'B767',
'B777',
'B787',
'C17',
'K35R', // military heavies that read as widebodies
]);
const TPROP = new Set([
'DH8A', 'DH8B', 'DH8C', 'DH8D', 'AT43', 'AT44', 'AT45', 'AT46', 'AT72', 'AT73',
'AT75', 'AT76', 'SF34', 'SB20', 'SW3', 'SW4', 'E110', 'E120', 'C208', 'C212',
'C408', 'PC12', 'B190', 'BE20', 'B350', 'B300', 'JS31', 'JS32', 'JS41', 'D228',
'D328', 'F50', 'F27', 'ATP', 'TBM7', 'TBM8', 'TBM9', 'TBM0', 'PC6', 'C441',
'C425', 'DHC6', 'DHC7', 'C130', 'AN12', 'AN26', 'AN32', 'SH36', 'CVLT', 'SAAB',
'DH8A',
'DH8B',
'DH8C',
'DH8D',
'AT43',
'AT44',
'AT45',
'AT46',
'AT72',
'AT73',
'AT75',
'AT76',
'SF34',
'SB20',
'SW3',
'SW4',
'E110',
'E120',
'C208',
'C212',
'C408',
'PC12',
'B190',
'BE20',
'B350',
'B300',
'JS31',
'JS32',
'JS41',
'D228',
'D328',
'F50',
'F27',
'ATP',
'TBM7',
'TBM8',
'TBM9',
'TBM0',
'PC6',
'C441',
'C425',
'DHC6',
'DHC7',
'C130',
'AN12',
'AN26',
'AN32',
'SH36',
'CVLT',
'SAAB',
'A400',
]);
const GLIDER = new Set([
'DISC', 'DUOD', 'VENT', 'NIMB', 'NIM3', 'NIM4', 'JANS', 'ARCE', 'DG40', 'DG80',
'DG1T', 'DG30', 'DG50', 'LS3', 'LS4', 'LS6', 'LS7', 'LS8', 'STD3', 'G103',
'G102', 'G104', 'PW5', 'PW6', 'L13', 'L23', 'L33', 'PIK', 'PEGA', 'KEST',
'TWIN', 'AS33', 'ASW', 'ASG', 'ASK', 'VENS', 'GLID', 'MOSQ', 'DIMO',
'DISC',
'DUOD',
'VENT',
'NIMB',
'NIM3',
'NIM4',
'JANS',
'ARCE',
'DG40',
'DG80',
'DG1T',
'DG30',
'DG50',
'LS3',
'LS4',
'LS6',
'LS7',
'LS8',
'STD3',
'G103',
'G102',
'G104',
'PW5',
'PW6',
'L13',
'L23',
'L33',
'PIK',
'PEGA',
'KEST',
'TWIN',
'AS33',
'ASW',
'ASG',
'ASK',
'VENS',
'GLID',
'MOSQ',
'DIMO',
]);
const LIGHT = new Set([
'C150', 'C152', 'C162', 'C172', 'C72R', 'C175', 'C177', 'C180', 'C182', 'C185',
'C188', 'C206', 'C207', 'C210', 'C310', 'C337', 'SR20', 'SR22', 'S22T', 'PA18',
'PA24', 'PA28', 'P28A', 'P28B', 'P28R', 'PA32', 'P32R', 'PA34', 'PA38', 'PA44',
'PA46', 'DA20', 'DA40', 'DA42', 'DA62', 'BE33', 'BE35', 'BE36', 'BE58', 'BE76',
'BE19', 'BE23', 'BE24', 'M20P', 'M20T', 'AA1', 'AA5', 'GLAS', 'COL4', 'RV4',
'RV6', 'RV7', 'RV8', 'RV9', 'RV10', 'RV14', 'GA8', 'G115', 'BL8', 'CH7',
'C150',
'C152',
'C162',
'C172',
'C72R',
'C175',
'C177',
'C180',
'C182',
'C185',
'C188',
'C206',
'C207',
'C210',
'C310',
'C337',
'SR20',
'SR22',
'S22T',
'PA18',
'PA24',
'PA28',
'P28A',
'P28B',
'P28R',
'PA32',
'P32R',
'PA34',
'PA38',
'PA44',
'PA46',
'DA20',
'DA40',
'DA42',
'DA62',
'BE33',
'BE35',
'BE36',
'BE58',
'BE76',
'BE19',
'BE23',
'BE24',
'M20P',
'M20T',
'AA1',
'AA5',
'GLAS',
'COL4',
'RV4',
'RV6',
'RV7',
'RV8',
'RV9',
'RV10',
'RV14',
'GA8',
'G115',
'BL8',
'CH7',
]);
// GEV addition (2026-08-15 Hangar fleet): business jets — distinct class so the
// Citation II GLB + a slimmer glyph read apart from airliners.
const BIZJET = new Set([
'C500', 'C501', 'C510', 'C525', 'C25A', 'C25B', 'C25C', 'C25M', 'C550',
'C551', 'C560', 'C56X', 'C650', 'C680', 'C68A', 'C700', 'C750',
'CL30', 'CL35', 'CL60', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'GA5C',
'GA6C', 'G150', 'G280', 'GL5T', 'GL7T', 'GLEX',
'LJ23', 'LJ24', 'LJ25', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60',
'LJ70', 'LJ75', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'F900', 'F2TH',
'H25A', 'H25B', 'H25C', 'HDJT', 'E50P', 'E55P', 'E545', 'E550', 'PC24',
'PRM1', 'BE40', 'ASTR', 'WW24',
'C500',
'C501',
'C510',
'C525',
'C25A',
'C25B',
'C25C',
'C25M',
'C550',
'C551',
'C560',
'C56X',
'C650',
'C680',
'C68A',
'C700',
'C750',
'CL30',
'CL35',
'CL60',
'GLF2',
'GLF3',
'GLF4',
'GLF5',
'GLF6',
'GA5C',
'GA6C',
'G150',
'G280',
'GL5T',
'GL7T',
'GLEX',
'LJ23',
'LJ24',
'LJ25',
'LJ31',
'LJ35',
'LJ40',
'LJ45',
'LJ55',
'LJ60',
'LJ70',
'LJ75',
'FA10',
'FA20',
'FA50',
'FA7X',
'FA8X',
'F900',
'F2TH',
'H25A',
'H25B',
'H25C',
'HDJT',
'E50P',
'E55P',
'E545',
'E550',
'PC24',
'PRM1',
'BE40',
'ASTR',
'WW24',
'SF50', // Cirrus Vision Jet — a JET (was mis-set in LIGHT before the bizjet class existed)
]);
// GEV addition (2026-08-15 Hangar fleet): large UAVs — ICAO designators seen on
// ADS-B/adsb.lol for Predator/Reaper/Global Hawk-class airframes.
const UAV = new Set([
'Q1', 'Q4', 'Q9', 'MQ1', 'MQ4', 'MQ9', 'RQ4', 'TB2', 'SHDW', 'HERN',
'Q1',
'Q4',
'Q9',
'MQ1',
'MQ4',
'MQ9',
'RQ4',
'TB2',
'SHDW',
'HERN',
]);
// GEV addition: fast jets, for the military layer's adsb.lol `t` codes.
const FASTJET = new Set([
'F16', 'F15', 'F18', 'FA18', 'F14', 'F22', 'F35', 'F4', 'F5', 'A10', 'AV8B',
'TYPH', 'EUFI', 'RFAL', 'RAFL', 'GRIP', 'JAS39', 'TOR', 'MIR2', 'M2000',
'SU27', 'SU30', 'SU33', 'SU34', 'SU35', 'SU57', 'MG29', 'MIG29', 'MG31', 'J20',
'T38', 'HAWK', 'L39', 'M346', 'T7A',
'F16',
'F15',
'F18',
'FA18',
'F14',
'F22',
'F35',
'F4',
'F5',
'A10',
'AV8B',
'TYPH',
'EUFI',
'RFAL',
'RAFL',
'GRIP',
'JAS39',
'TOR',
'MIR2',
'M2000',
'SU27',
'SU30',
'SU33',
'SU34',
'SU35',
'SU57',
'MG29',
'MIG29',
'MG31',
'J20',
'T38',
'HAWK',
'L39',
'M346',
'T7A',
]);
/** OpenSky extended-states integer category → class. */
const OPENSKY_CATEGORY = {
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
2: 'light', // Light (< 15 500 lbs)
3: 'airliner', // Small
4: 'airliner', // Large
5: 'airliner', // High-vortex large (B757)
6: 'widebody', // Heavy
7: 'fastjet', // High performance (>5g, >400 kt)
8: 'helicopter', // Rotorcraft
9: 'glider',
};
/** ADS-B emitter-category string → class. */
const EMITTER_CATEGORY = {
A1: 'light', A2: 'light', A3: 'airliner', A4: 'airliner',
A5: 'widebody', A6: 'fastjet', A7: 'helicopter', B1: 'glider',
A1: 'light',
A2: 'light',
A3: 'airliner',
A4: 'airliner',
A5: 'widebody',
A6: 'fastjet',
A7: 'helicopter',
B1: 'glider',
};
export function classifyAircraft({ typeCode, category } = {}) {
const code = String(typeCode || '').trim().toUpperCase();
const code = String(typeCode || '')
.trim()
.toUpperCase();
if (code) {
if (FASTJET.has(code)) return 'fastjet';
if (UAV.has(code)) return 'uav';
@@ -104,37 +429,59 @@ export function classifyAircraft({ typeCode, category } = {}) {
if (LIGHT.has(code)) return 'light';
return 'airliner'; // known type code, not in a special set → default jet
}
if (Number.isFinite(category) && OPENSKY_CATEGORY[category]) return OPENSKY_CATEGORY[category];
const cat = String(category || '').trim().toUpperCase();
if (Number.isFinite(category) && OPENSKY_CATEGORY[category])
return OPENSKY_CATEGORY[category];
const cat = String(category || '')
.trim()
.toUpperCase();
if (EMITTER_CATEGORY[cat]) return EMITTER_CATEGORY[cat];
return 'airliner';
}
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62, glider: 0.58, turboprop: 0.86, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.82, fastjet: 0.8,
bizjet: 0.72, uav: 0.75,
light: 0.62,
glider: 0.58,
turboprop: 0.86,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.82,
fastjet: 0.8,
bizjet: 0.72,
uav: 0.75,
};
/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares
* the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when
* real per-class models land in CLASS_MODEL_URL. */
export const CLASS_SCALE_3D = {
light: 0.75, glider: 0.75, turboprop: 0.85, airliner: 1.0,
widebody: 1.3, quadjet: 1.45, helicopter: 0.8, fastjet: 0.8,
bizjet: 0.8, uav: 0.8,
light: 0.75,
glider: 0.75,
turboprop: 0.85,
airliner: 1.0,
widebody: 1.3,
quadjet: 1.45,
helicopter: 0.8,
fastjet: 0.8,
bizjet: 0.8,
uav: 0.8,
};
/** Per-class glTF — all the shared airplane today; drop real assets in here.
* NOTE for future models: each asset may need its own heading offset (the
* shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */
export const CLASS_MODEL_URL = {
light: '/models/airplane.glb', glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb', airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb', quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb', fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb', uav: '/models/airplane.glb',
light: '/models/airplane.glb',
glider: '/models/airplane.glb',
turboprop: '/models/airplane.glb',
airliner: '/models/airplane.glb',
widebody: '/models/airplane.glb',
quadjet: '/models/airplane.glb',
helicopter: '/models/airplane.glb',
fastjet: '/models/airplane.glb',
bizjet: '/models/airplane.glb',
uav: '/models/airplane.glb',
};
/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 —
@@ -148,10 +495,10 @@ export const CLASS_MODEL_URL = {
* pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet,
* glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */
export const CLASS_MODEL_REAL = {
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 },
light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 },
bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 },
uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 },
widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 },
turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 },
};
+7 -3
View File
@@ -35,9 +35,11 @@ const VIEW = 96;
const C = VIEW / 2; // 48 — glyph centre
// Hairline dark edge (scaled for the 96 box: ~1.5u ≈ the old 0.5u at 32).
const STROKE = 'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
const STROKE =
'stroke="rgba(0,0,0,0.32)" stroke-width="1.4" stroke-linejoin="round"';
// Heavier edge for the chart-symbol ("bold") glyphs adopted 2026-08-15.
const STROKE_BOLD = 'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
const STROKE_BOLD =
'stroke="rgba(0,0,0,0.38)" stroke-width="2" stroke-linejoin="round"';
// Softer white for translucent detail (prop discs, rotor disc) — still white so
// the tint multiplies cleanly; only the alpha differs.
const DISC = 'fill="white" fill-opacity="0.5"';
@@ -239,7 +241,9 @@ const BODIES = {
const _iconCache = new Map();
const _b64 = (s) =>
typeof btoa === 'function' ? btoa(s) : Buffer.from(s, 'utf8').toString('base64');
typeof btoa === 'function'
? btoa(s)
: Buffer.from(s, 'utf8').toString('base64');
/** Fleet raster: billboards render at ~40–58 DEVICE px (width 20–24 CSS ×
* Retina × class scale). Cesium's billboard atlas has no mipmaps, so a big
+3 -1
View File
@@ -11,5 +11,7 @@ export function aircraftIncludedInNearby({
modelRendering = false,
includeHidden = false,
} = {}) {
return Boolean(includeHidden || isTracked || billboardShown || modelRendering);
return Boolean(
includeHidden || isTracked || billboardShown || modelRendering,
);
}
+50 -19
View File
@@ -10,7 +10,7 @@ export const DEFAULT_AIRCRAFT_RECESSION_PARAMS = Object.freeze({
startLimbRatio: 0.5,
scaleFloor: 0.45,
alphaFloor: 0.35,
combinedAlphaFloor: 0.20,
combinedAlphaFloor: 0.2,
globeViewBlendStartM: 3_500_000,
globeViewBlendEndM: 4_500_000,
earthRadiusM: 6_378_137,
@@ -36,10 +36,19 @@ function resolvedParams(overrides) {
return { ..._params, ...overrides };
}
function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning, result) {
if (!Number.isFinite(cameraDistanceM) || cameraDistanceM < 0
|| !Number.isFinite(cameraHeightM) || cameraHeightM <= 0
|| cameraHeightM >= tuning.globeViewBlendEndM) {
function aircraftRecessionFactorsResolved(
cameraDistanceM,
cameraHeightM,
tuning,
result,
) {
if (
!Number.isFinite(cameraDistanceM) ||
cameraDistanceM < 0 ||
!Number.isFinite(cameraHeightM) ||
cameraHeightM <= 0 ||
cameraHeightM >= tuning.globeViewBlendEndM
) {
result.scale = 1;
result.alpha = 1;
result.limbRatio = null;
@@ -60,11 +69,13 @@ function aircraftRecessionFactorsResolved(cameraDistanceM, cameraHeightM, tuning
return result;
}
const rawT = (limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const rawT =
(limbRatio - tuning.startLimbRatio) / (1 - tuning.startLimbRatio);
const t = clamp(rawT, 0, 1);
const limbEase = t * t * (3 - 2 * t); // smoothstep: zero slope at both band edges
const globeRawT = (cameraHeightM - tuning.globeViewBlendStartM)
/ (tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeRawT =
(cameraHeightM - tuning.globeViewBlendStartM) /
(tuning.globeViewBlendEndM - tuning.globeViewBlendStartM);
const globeT = clamp(globeRawT, 0, 1);
const globeEase = globeT * globeT * (3 - 2 * globeT);
const strength = 1 - globeEase;
@@ -84,8 +95,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (Number.isFinite(patch.startLimbRatio)) {
next.startLimbRatio = clamp(patch.startLimbRatio, 0, 0.99);
}
if (Number.isFinite(patch.scaleFloor)) next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor)) next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.scaleFloor))
next.scaleFloor = clamp(patch.scaleFloor, 0.05, 1);
if (Number.isFinite(patch.alphaFloor))
next.alphaFloor = clamp(patch.alphaFloor, 0.05, 1);
if (Number.isFinite(patch.combinedAlphaFloor)) {
next.combinedAlphaFloor = clamp(patch.combinedAlphaFloor, 0.05, 1);
}
@@ -109,8 +122,10 @@ export function setAircraftRecessionParams(patch = {}) {
if (next.globeViewBlendStartM >= next.globeViewBlendEndM) {
next.globeViewBlendStartM = Math.max(1, next.globeViewBlendEndM - 1);
}
if (Number.isFinite(patch.earthRadiusM)) next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.earthRadiusM))
next.earthRadiusM = Math.max(1, patch.earthRadiusM);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -149,7 +164,10 @@ export function cameraLimbDistanceM(
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [params]
* @returns {{scale:number,alpha:number,limbRatio:number|null}}
*/
export function aircraftRecessionFactors({ cameraDistanceM, cameraHeightM }, params) {
export function aircraftRecessionFactors(
{ cameraDistanceM, cameraHeightM },
params,
) {
const tuning = resolvedParams(params);
return aircraftRecessionFactorsResolved(
cameraDistanceM,
@@ -202,12 +220,17 @@ export function applyAircraftBillboardTreatment({
const alpha = baseAlpha * composedTreatment;
let scaleWrites = 0;
let alphaWrites = 0;
if (!Number.isFinite(billboard.scale) || Math.abs(billboard.scale - scale) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.scale) ||
Math.abs(billboard.scale - scale) > tuning.writeEpsilon
) {
billboard.scale = scale;
scaleWrites = 1;
}
if (!Number.isFinite(billboard.color?.alpha)
|| Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon) {
if (
!Number.isFinite(billboard.color?.alpha) ||
Math.abs(billboard.color.alpha - alpha) > tuning.writeEpsilon
) {
billboard.color = baseColor.withAlpha(alpha);
alphaWrites = 1;
}
@@ -228,11 +251,19 @@ export function applyAircraftBillboardTreatment({
* @param {Partial<typeof DEFAULT_AIRCRAFT_RECESSION_PARAMS>} [input.params]
* @returns {number} One when a color write occurred, otherwise zero.
*/
export function applyAircraftModelTreatment({ model, baseColor, alpha, params }) {
export function applyAircraftModelTreatment({
model,
baseColor,
alpha,
params,
}) {
const tuning = resolvedParams(params);
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (Number.isFinite(model.color?.alpha)
&& Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
)
return 0;
model.color = baseColor.withAlpha(alpha);
return 1;
}
+110 -40
View File
@@ -28,11 +28,39 @@ import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
export const ANALYST_LAYERS = {
flights: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass'], flags: ['military', 'onGround'] },
military: { numeric: ['altitudeM', 'speedMps', 'verticalRateMps'], text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'], flags: ['military', 'onGround'] },
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] },
flights: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: [
'callsign',
'icao24',
'originCountry',
'operator',
'routeOrigin',
'routeDestination',
'aircraftClass',
],
flags: ['military', 'onGround'],
},
military: {
numeric: ['altitudeM', 'speedMps', 'verticalRateMps'],
text: ['callsign', 'icao24', 'originCountry', 'operator', 'aircraftClass'],
flags: ['military', 'onGround'],
},
'ais-live-vessels': {
numeric: ['speedKts', 'courseDeg'],
text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'],
flags: [],
},
'local-firms': {
numeric: ['frp'],
text: ['confidence', 'satellite'],
flags: [],
},
earthquakes: {
numeric: ['magnitude', 'depthKm'],
text: ['place'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -42,8 +70,9 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
@@ -55,17 +84,25 @@ export function applyFilter(records, filter) {
const got = r[field];
if (got === null || got === undefined) return false;
switch (op) {
case 'gt': return Number(got) > Number(value);
case 'gte': return Number(got) >= Number(value);
case 'lt': return Number(got) < Number(value);
case 'lte': return Number(got) <= Number(value);
case 'gt':
return Number(got) > Number(value);
case 'gte':
return Number(got) >= Number(value);
case 'lt':
return Number(got) < Number(value);
case 'lte':
return Number(got) <= Number(value);
case 'eq': {
if (typeof got === 'boolean' || typeof value === 'boolean') return Boolean(got) === Boolean(value);
if (typeof got === 'boolean' || typeof value === 'boolean')
return Boolean(got) === Boolean(value);
return String(got).toLowerCase() === String(value).toLowerCase();
}
case 'neq': return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains': return String(got).toLowerCase().includes(String(value).toLowerCase());
default: return true;
case 'neq':
return String(got).toLowerCase() !== String(value).toLowerCase();
case 'contains':
return String(got).toLowerCase().includes(String(value).toLowerCase());
default:
return true;
}
});
}
@@ -74,15 +111,23 @@ export function applyFilter(records, filter) {
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (scope.kind === 'region' && resolved?.ring) {
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& pointInRing(resolved.ring, r.lat, r.lon));
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
pointInRing(resolved.ring, r.lat, r.lon),
);
}
if (scope.kind === 'radius' || scope.kind === 'view') {
const c = resolved?.center;
const km = resolved?.km;
if (!c || !Number.isFinite(km)) return records;
return records.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lon)
&& haversineKm(c.lat, c.lon, r.lat, r.lon) <= km);
return records.filter(
(r) =>
Number.isFinite(r.lat) &&
Number.isFinite(r.lon) &&
haversineKm(c.lat, c.lon, r.lat, r.lon) <= km,
);
}
return records;
}
@@ -108,9 +153,12 @@ export function createAnalystEngine(providers) {
let lastResult = null;
async function query(spec = {}) {
const layers = (spec.followUp && lastResult)
? null // follow-up: re-filter the remembered set, no re-snapshot
: (Array.isArray(spec.layers) && spec.layers.length ? spec.layers : ['flights']);
const layers =
spec.followUp && lastResult
? null // follow-up: re-filter the remembered set, no re-snapshot
: Array.isArray(spec.layers) && spec.layers.length
? spec.layers
: ['flights'];
// 1) Source records
let records;
@@ -164,20 +212,23 @@ export function createAnalystEngine(providers) {
// camera made the two disagree — a parked, high-altitude camera answered
// "46 within 250 km" while the panel showed a far larger contact-centred
// count. Same question, two numbers.
const explicitCenter = scope.center && Number.isFinite(scope.center.lat)
? scope.center
: null;
const subject = explicitCenter ? null : providers.getContextSubject?.() || null;
const explicitCenter =
scope.center && Number.isFinite(scope.center.lat) ? scope.center : null;
const subject = explicitCenter
? null
: providers.getContextSubject?.() || null;
const view = providers.getViewContext();
// Only a subject that actually SUPPLIED the centre may name it. A
// subject present but without usable coordinates silently fell back to
// the camera while the label still read "within 250 km of <contact>" —
// a count centred on one place, reported as centred on another, with
// nothing in the payload to show which.
const subjectCenter = Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter || subjectCenter || { lat: view.lat, lon: view.lon };
const subjectCenter =
Number.isFinite(subject?.lat) && Number.isFinite(subject?.lon)
? { lat: subject.lat, lon: subject.lon }
: null;
const center = explicitCenter ||
subjectCenter || { lat: view.lat, lon: view.lon };
resolvedScope = { center, km: Number(scope.km) || 100 };
if (subjectCenter) resolvedScope.centeredOn = subject.label || null;
scopeNote = resolvedScope.centeredOn
@@ -188,7 +239,10 @@ export function createAnalystEngine(providers) {
: `within ${resolvedScope.km} km`;
} else if (scope.kind === 'view') {
const view = providers.getViewContext();
resolvedScope = { center: { lat: view.lat, lon: view.lon }, km: view.viewRadiusKm };
resolvedScope = {
center: { lat: view.lat, lon: view.lon },
km: view.viewRadiusKm,
};
scopeNote = `view:${Math.round(view.viewRadiusKm)}km`;
scopeLabel = 'in view';
} else {
@@ -205,15 +259,22 @@ export function createAnalystEngine(providers) {
if (sortBy === 'distance') {
const ref = resolvedScope?.center || providers.getViewContext();
for (const it of items) {
it.distanceKm = (Number.isFinite(it.lat) && Number.isFinite(it.lon))
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) / 10 : null;
it.distanceKm =
Number.isFinite(it.lat) && Number.isFinite(it.lon)
? Math.round(haversineKm(ref.lat, ref.lon, it.lat, it.lon) * 10) /
10
: null;
}
}
if (sortBy) {
const dir = spec.sortDir === 'asc' ? 1 : -1;
items.sort((a, b) => (Number(a[sortBy]) - Number(b[sortBy])) * dir
|| String(a.id).localeCompare(String(b.id)));
if (sortBy === 'distance') items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
items.sort(
(a, b) =>
(Number(a[sortBy]) - Number(b[sortBy])) * dir ||
String(a.id).localeCompare(String(b.id)),
);
if (sortBy === 'distance')
items.sort((a, b) => (a.distanceKm ?? 1e9) - (b.distanceKm ?? 1e9));
}
const limit = Math.max(1, Math.min(50, Number(spec.limit) || 10));
const top = items.slice(0, limit);
@@ -223,7 +284,10 @@ export function createAnalystEngine(providers) {
count: items.length,
items: top,
truncated: items.length > top.length,
summary: summarize(items, sortBy && sortBy !== 'distance' ? sortBy : null),
summary: summarize(
items,
sortBy && sortBy !== 'distance' ? sortBy : null,
),
scopeLabel,
coverage: {
layersQueried,
@@ -233,7 +297,9 @@ export function createAnalystEngine(providers) {
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
...(resolvedScope?.centeredOn ? { centeredOn: resolvedScope.centeredOn } : {}),
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: {}),
};
lastResult = { items, coverage: result.coverage };
return result;
@@ -241,7 +307,11 @@ export function createAnalystEngine(providers) {
return {
query,
reset() { lastResult = null; },
hasMemory() { return Boolean(lastResult); },
reset() {
lastResult = null;
},
hasMemory() {
return Boolean(lastResult);
},
};
}
+67 -19
View File
@@ -110,8 +110,12 @@ export const CCTV_THUMBNAIL_ALTITUDE_SCALE = Object.freeze({
*/
export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
if (pinned) return Number.isFinite(sy);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0) return false;
const safeTop = Math.min(CCTV_CARD_SAFE_TOP_MAX_PX, viewH * CCTV_CARD_SAFE_TOP_RATIO);
if (!Number.isFinite(sy) || !Number.isFinite(viewH) || viewH <= 0)
return false;
const safeTop = Math.min(
CCTV_CARD_SAFE_TOP_MAX_PX,
viewH * CCTV_CARD_SAFE_TOP_RATIO,
);
return sy >= safeTop;
}
@@ -126,11 +130,24 @@ export function isCctvCardAnchorSafe({ sy, viewH, pinned = false } = {}) {
* @param {number} [options.limit]
* @returns {string[]} Accepted ids, nearest-first.
*/
export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {}) {
export function declutterCctvCards(
candidates,
{ minSepPx = CCTV_CARD_MIN_SEP_PX, limit = Infinity } = {},
) {
const valid = (Array.isArray(candidates) ? candidates : [])
.filter((c) => c && typeof c.id === 'string' && c.id
&& Number.isFinite(c.sx) && Number.isFinite(c.sy))
.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) || a.id.localeCompare(b.id));
.filter(
(c) =>
c &&
typeof c.id === 'string' &&
c.id &&
Number.isFinite(c.sx) &&
Number.isFinite(c.sy),
)
.sort(
(a, b) =>
(a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity) ||
a.id.localeCompare(b.id),
);
const minSq = minSepPx * minSepPx;
const accepted = [];
const acceptedIds = [];
@@ -140,7 +157,10 @@ export function declutterCctvCards(candidates, { minSepPx = CCTV_CARD_MIN_SEP_PX
for (let i = 0; i < accepted.length; i++) {
const dx = candidate.sx - accepted[i].sx;
const dy = candidate.sy - accepted[i].sy;
if (dx * dx + dy * dy < minSq) { clear = false; break; }
if (dx * dx + dy * dy < minSq) {
clear = false;
break;
}
}
if (!clear) continue;
accepted.push(candidate);
@@ -165,15 +185,26 @@ export function cardScaleForAltitude(cameraHeightM) {
const h = Number.isFinite(cameraHeightM) ? Math.max(0, cameraHeightM) : 0;
if (h <= CCTV_CARD_SCALE_FULL_M) return { scale: 1, alpha: 1 };
if (h <= CCTV_CARD_SCALE_MID_M) {
const t = (h - CCTV_CARD_SCALE_FULL_M) / (CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const t =
(h - CCTV_CARD_SCALE_FULL_M) /
(CCTV_CARD_SCALE_MID_M - CCTV_CARD_SCALE_FULL_M);
const s = t * t * (3 - 2 * t); // smoothstep — no visible kink at either end
return { scale: 1 + (CCTV_CARD_SCALE_AT_MID - 1) * s, alpha: 1 };
}
const t = Math.min(1, (h - CCTV_CARD_SCALE_MID_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M));
const scale = CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M) return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
const t = Math.min(
1,
(h - CCTV_CARD_SCALE_MID_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_SCALE_MID_M),
);
const scale =
CCTV_CARD_SCALE_AT_MID + (CCTV_CARD_SCALE_MIN - CCTV_CARD_SCALE_AT_MID) * t;
if (h >= CCTV_CARD_FADE_END_M)
return { scale: CCTV_CARD_SCALE_MIN, alpha: 0 };
if (h <= CCTV_CARD_FADE_START_M) return { scale, alpha: 1 };
const alpha = 1 - (h - CCTV_CARD_FADE_START_M) / (CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
const alpha =
1 -
(h - CCTV_CARD_FADE_START_M) /
(CCTV_CARD_FADE_END_M - CCTV_CARD_FADE_START_M);
return { scale, alpha };
}
@@ -192,17 +223,23 @@ export function cardScaleForAltitude(cameraHeightM) {
* @param {number} [input.sinceLastLaunchMs] - Ms since the last fetch launch.
* @returns {{mode:('burst'|'steady'), launch:boolean}}
*/
export function cardFetchPolicy({ coldFill = false, inFlight = 0, sinceLastLaunchMs = Infinity } = {}) {
export function cardFetchPolicy({
coldFill = false,
inFlight = 0,
sinceLastLaunchMs = Infinity,
} = {}) {
if (coldFill) {
return {
mode: 'burst',
launch: inFlight < CCTV_CARD_FETCH_BURST_LIMIT
&& sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
launch:
inFlight < CCTV_CARD_FETCH_BURST_LIMIT &&
sinceLastLaunchMs >= CCTV_CARD_FETCH_BURST_SPACING_MS,
};
}
return {
mode: 'steady',
launch: inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
launch:
inFlight === 0 && sinceLastLaunchMs >= CCTV_CARD_FETCH_STEADY_SPACING_MS,
};
}
@@ -230,7 +267,12 @@ export function createFrameSlot() {
export function applyFrameResult(prev, result, nowMs) {
const base = prev || createFrameSlot();
if (result?.ok && result.frame) {
return { frame: result.frame, stamp: nowMs, failCount: 0, lastAttemptAt: nowMs };
return {
frame: result.frame,
stamp: nowMs,
failCount: 0,
lastAttemptAt: nowMs,
};
}
return {
frame: base.frame,
@@ -278,11 +320,17 @@ export function frameFetchDue(slot, refreshMs, nowMs) {
* @param {number} [cap]
* @returns {string[]} Ids to drop from the cache.
*/
export function planFrameCachePrune(slots, keepIds, cap = CCTV_FRAME_CACHE_MAX) {
export function planFrameCachePrune(
slots,
keepIds,
cap = CCTV_FRAME_CACHE_MAX,
) {
const keep = new Set(keepIds);
const spare = (Array.isArray(slots) ? slots : [])
.filter((slot) => slot && typeof slot.id === 'string' && !keep.has(slot.id))
.sort((a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id));
.sort(
(a, b) => (b.stamp || 0) - (a.stamp || 0) || a.id.localeCompare(b.id),
);
const capacity = Math.max(0, cap - keep.size);
return spare.slice(capacity).map((slot) => slot.id);
}
+173 -44
View File
@@ -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;
}
+9 -8
View File
@@ -46,7 +46,7 @@ export function cameraHue(index) {
* @returns {{fill: Cesium.Color, fillActive: Cesium.Color, line: Cesium.Color, lineActive: Cesium.Color}}
*/
export function viewshedColors(hueDeg) {
const hue = ((Number(hueDeg) % 360) + 360) % 360 / 360;
const hue = (((Number(hueDeg) % 360) + 360) % 360) / 360;
return {
fill: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_IDLE),
fillActive: Cesium.Color.fromHsl(hue, 0.85, 0.6, FILL_ALPHA_ACTIVE),
@@ -66,7 +66,13 @@ export function viewshedColors(hueDeg) {
* @returns {{positions: Float64Array, indices: Uint16Array}}
*/
export function frustumVolumeGeometryData(positions) {
const pts = [positions.mount, positions.tl, positions.tr, positions.br, positions.bl];
const pts = [
positions.mount,
positions.tl,
positions.tr,
positions.br,
positions.bl,
];
const flat = new Float64Array(15);
pts.forEach((p, i) => {
flat[i * 3] = p.x;
@@ -75,12 +81,7 @@ export function frustumVolumeGeometryData(positions) {
});
// apex=0, tl=1, tr=2, br=3, bl=4 — 4 side faces + far cap (2 triangles).
const indices = new Uint16Array([
0, 1, 2,
0, 2, 3,
0, 3, 4,
0, 4, 1,
1, 2, 3,
1, 3, 4,
0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 1, 1, 2, 3, 1, 3, 4,
]);
return { positions: flat, indices };
}
+1 -2
View File
@@ -21,7 +21,7 @@ export function nextCockpitNearContacts(
) {
const previous = previousIds instanceof Set ? previousIds : new Set();
const addSq = Math.max(0, Number(addDistanceM) || 0) ** 2;
const keepSq = Math.max(addSq, (Math.max(0, Number(keepDistanceM) || 0) ** 2));
const keepSq = Math.max(addSq, Math.max(0, Number(keepDistanceM) || 0) ** 2);
const next = new Set();
for (const [id, distanceSq] of distancesSquared || []) {
@@ -32,4 +32,3 @@ export function nextCockpitNearContacts(
return next;
}
+27 -8
View File
@@ -42,7 +42,9 @@ export const CONTACT_MATCH_TIER = Object.freeze({
* @returns {string} Canonical form, or '' when there is nothing to compare.
*/
export function canonicalizeContactId(value) {
return String(value ?? '').replace(/[^a-z0-9]/gi, '').toUpperCase();
return String(value ?? '')
.replace(/[^a-z0-9]/gi, '')
.toUpperCase();
}
/**
@@ -57,16 +59,31 @@ export function canonicalizeContactId(value) {
* @param {string} [params.registration] Contact's registration, if any.
* @returns {number} A {@link CONTACT_MATCH_TIER} value; `NONE` when no match.
*/
export function rankContactMatch({ query, hex = '', callsign = '', registration = '' }) {
const rawQuery = String(query ?? '').trim().toLowerCase();
export function rankContactMatch({
query,
hex = '',
callsign = '',
registration = '',
}) {
const rawQuery = String(query ?? '')
.trim()
.toLowerCase();
if (!rawQuery) return CONTACT_MATCH_TIER.NONE;
if (String(hex ?? '').trim().toLowerCase() === rawQuery) return CONTACT_MATCH_TIER.HEX_EXACT;
if (
String(hex ?? '')
.trim()
.toLowerCase() === rawQuery
)
return CONTACT_MATCH_TIER.HEX_EXACT;
const callsignText = String(callsign ?? '').trim().toLowerCase();
const callsignText = String(callsign ?? '')
.trim()
.toLowerCase();
// Callsigns carry no separators, so they compare raw — canonicalizing them
// would silently widen the long-standing callsign contract.
if (callsignText && callsignText === rawQuery) return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
if (callsignText && callsignText === rawQuery)
return CONTACT_MATCH_TIER.CALLSIGN_EXACT;
const canonicalQuery = canonicalizeContactId(rawQuery);
const canonicalRegistration = canonicalizeContactId(registration);
@@ -74,11 +91,13 @@ export function rankContactMatch({ query, hex = '', callsign = '', registration
return CONTACT_MATCH_TIER.REGISTRATION_EXACT;
}
if (callsignText && callsignText.startsWith(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (callsignText && callsignText.startsWith(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_PREFIX;
if (canonicalQuery && canonicalRegistration.startsWith(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_PREFIX;
}
if (callsignText && callsignText.includes(rawQuery)) return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (callsignText && callsignText.includes(rawQuery))
return CONTACT_MATCH_TIER.CALLSIGN_SUBSTRING;
if (canonicalQuery && canonicalRegistration.includes(canonicalQuery)) {
return CONTACT_MATCH_TIER.REGISTRATION_SUBSTRING;
}
+23 -11
View File
@@ -48,7 +48,9 @@ export function selectEntityContext(entity) {
store.selectedEntityId = contextId;
store.selectedAt = Date.now();
const record = store.entities.get(contextId);
window.dispatchEvent(new CustomEvent('gev:entity-selected', { detail: record }));
window.dispatchEvent(
new CustomEvent('gev:entity-selected', { detail: record }),
);
return record;
}
@@ -78,7 +80,8 @@ export function selectTrackedSubjectContext(metadata) {
const store = getContextStore();
const id = String(metadata.id);
for (const [key, record] of store.entities) {
if (record?.layerId === metadata.layerId && key !== id) store.entities.delete(key);
if (record?.layerId === metadata.layerId && key !== id)
store.entities.delete(key);
}
// Reuse the existing carrier so a per-poll refresh does not churn identity.
const carrier = store.entities.get(id)?.entity || { __gevContextId: id };
@@ -146,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;
}
+7 -7
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
return {
key: `transit-${feed.id}`,
html:
+219 -70
View File
@@ -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
View File
@@ -19,11 +19,14 @@ export function stableIdentityHash(layerId, sourceId) {
/** Negative means a is a better deterministic contender than b. */
export function compareCohortContenders(a, b) {
const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
const priorityDelta =
(Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0);
if (priorityDelta) return priorityDelta;
const bandDelta = (Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
const bandDelta =
(Number(b?._cohortBand) || 0) - (Number(a?._cohortBand) || 0);
if (bandDelta) return bandDelta;
const hashDelta = (Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
const hashDelta =
(Number(a?._cohortHash) >>> 0) - (Number(b?._cohortHash) >>> 0);
if (hashDelta) return hashDelta;
return String(a?._cohortSourceId).localeCompare(String(b?._cohortSourceId));
}
@@ -39,8 +42,14 @@ function isWorse(a, b) {
*/
export class BoundedCohort {
constructor(maxSize = DEFAULT_MAX_COHORT, hardMax = DEFAULT_MAX_COHORT) {
const ceiling = Math.max(1, Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT));
this.maxSize = Math.max(1, Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)));
const ceiling = Math.max(
1,
Math.floor(Number(hardMax) || DEFAULT_MAX_COHORT),
);
this.maxSize = Math.max(
1,
Math.min(ceiling, Math.floor(Number(maxSize) || DEFAULT_MAX_COHORT)),
);
this.incumbents = [];
this.heap = [];
}
@@ -62,11 +71,20 @@ export class BoundedCohort {
}
values(limit = this.maxSize) {
const cap = Math.max(0, Math.min(this.maxSize, Math.floor(Number(limit) || 0)));
const incumbents = this.incumbents.slice().sort(compareCohortContenders).slice(0, cap);
const cap = Math.max(
0,
Math.min(this.maxSize, Math.floor(Number(limit) || 0)),
);
const incumbents = this.incumbents
.slice()
.sort(compareCohortContenders)
.slice(0, cap);
const remaining = Math.max(0, cap - incumbents.length);
if (remaining === 0) return incumbents;
const contenders = this.heap.slice().sort(compareCohortContenders).slice(0, remaining);
const contenders = this.heap
.slice()
.sort(compareCohortContenders)
.slice(0, remaining);
return incumbents.concat(contenders);
}
@@ -75,7 +93,10 @@ export class BoundedCohort {
while (current > 0) {
const parent = Math.floor((current - 1) / 2);
if (!isWorse(this.heap[current], this.heap[parent])) break;
[this.heap[current], this.heap[parent]] = [this.heap[parent], this.heap[current]];
[this.heap[current], this.heap[parent]] = [
this.heap[parent],
this.heap[current],
];
current = parent;
}
}
@@ -86,10 +107,18 @@ export class BoundedCohort {
const left = current * 2 + 1;
const right = left + 1;
let worst = current;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst])) worst = left;
if (right < this.heap.length && isWorse(this.heap[right], this.heap[worst])) worst = right;
if (left < this.heap.length && isWorse(this.heap[left], this.heap[worst]))
worst = left;
if (
right < this.heap.length &&
isWorse(this.heap[right], this.heap[worst])
)
worst = right;
if (worst === current) return;
[this.heap[current], this.heap[worst]] = [this.heap[worst], this.heap[current]];
[this.heap[current], this.heap[worst]] = [
this.heap[worst],
this.heap[current],
];
current = worst;
}
}
+15 -8
View File
@@ -211,11 +211,14 @@ export function measureTrackLabel(primary, micro, charWidth) {
*/
export function rectIntersectsAny(rect, obstacles, padding = 0) {
if (!rect || !Array.isArray(obstacles)) return false;
return obstacles.some((obstacle) => obstacle
&& rect.x < obstacle.x + obstacle.w + padding
&& rect.x + rect.w + padding > obstacle.x
&& rect.y < obstacle.y + obstacle.h + padding
&& rect.y + rect.h + padding > obstacle.y);
return obstacles.some(
(obstacle) =>
obstacle &&
rect.x < obstacle.x + obstacle.w + padding &&
rect.x + rect.w + padding > obstacle.x &&
rect.y < obstacle.y + obstacle.h + padding &&
rect.y + rect.h + padding > obstacle.y,
);
}
/**
@@ -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
View File
@@ -8,7 +8,10 @@
export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]);
export const ALLOCATION_ELASTIC = 'ELASTIC';
export const ALLOCATION_WEIGHTED = 'WEIGHTED';
export const ALLOCATION_STRATEGIES = Object.freeze([ALLOCATION_ELASTIC, ALLOCATION_WEIGHTED]);
export const ALLOCATION_STRATEGIES = Object.freeze([
ALLOCATION_ELASTIC,
ALLOCATION_WEIGHTED,
]);
// The keyhole's default 1% outside opacity is appropriate for text, but it
// made side aircraft brackets effectively disappear while the same contacts
@@ -71,10 +74,16 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
: AIRCRAFT_BRACKET_FLOOR_ANCHOR;
if (outside <= 0) return 0;
if (outside <= AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
return AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR * (outside / AIRCRAFT_BRACKET_FLOOR_ANCHOR)
);
}
const progress = (outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) / (1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress;
const progress =
(outside - AIRCRAFT_BRACKET_FLOOR_ANCHOR) /
(1 - AIRCRAFT_BRACKET_FLOOR_ANCHOR);
return (
AIRCRAFT_BRACKET_ALPHA_FLOOR + (1 - AIRCRAFT_BRACKET_ALPHA_FLOOR) * progress
);
}
/**
@@ -85,7 +94,11 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* the default, so a caller that does not know it reproduces the shipped look.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(type, keyholeAlpha, outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR) {
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
@@ -97,7 +110,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) {
const x = Number(screenX);
if (!(width > 0) || !Number.isFinite(x)) return 'front';
if (x < width / 3) return 'left';
if (x > width * 2 / 3) return 'right';
if (x > (width * 2) / 3) return 'right';
return 'front';
}
@@ -140,19 +153,29 @@ export function defaultDensityForProfile(profile) {
/** Normalize current and legacy profile labels. OFF remains an enabled-state value. */
export function normalizeProfile(profile) {
const raw = String(profile || '').trim().toUpperCase();
const raw = String(profile || '')
.trim()
.toUpperCase();
if (raw === 'OFF') return 'OFF';
if (raw === 'SPARSE' || raw === 'SURVEY') return 'SPARSE';
if (raw === 'BALANCED' || raw === 'NORMAL') return 'BALANCED';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON') return 'DENSE';
if (raw === 'DENSE' || raw === 'PANOPTIC' || raw === 'GOD' || raw === 'ON')
return 'DENSE';
return null;
}
/** Normalize the user-selectable layer allocation strategy. */
export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELASTIC) {
const raw = String(strategy || '').trim().toUpperCase();
export function normalizeAllocationStrategy(
strategy,
fallback = ALLOCATION_ELASTIC,
) {
const raw = String(strategy || '')
.trim()
.toUpperCase();
if (ALLOCATION_STRATEGIES.includes(raw)) return raw;
const normalizedFallback = String(fallback || '').trim().toUpperCase();
const normalizedFallback = String(fallback || '')
.trim()
.toUpperCase();
return ALLOCATION_STRATEGIES.includes(normalizedFallback)
? normalizedFallback
: ALLOCATION_ELASTIC;
@@ -160,7 +183,9 @@ export function normalizeAllocationStrategy(strategy, fallback = ALLOCATION_ELAS
/** Classify camera altitude into the shared label-budget view scale. */
export function viewScaleForAltitude(altitudeM) {
const altitude = Number.isFinite(Number(altitudeM)) ? Math.max(0, Number(altitudeM)) : 1e9;
const altitude = Number.isFinite(Number(altitudeM))
? Math.max(0, Number(altitudeM))
: 1e9;
if (altitude < 1200) return 'street';
if (altitude < 4500) return 'city';
if (altitude < 20000) return 'metro';
@@ -181,25 +206,46 @@ export function labelBudgetFor(altitudeM, densityPct) {
* density was below 75; legacy Sparse remains inside the Sparse band.
*/
export function migrateDetectionState(mode, densityPct, fallbackDensity = 50) {
const rawMode = String(mode || '').trim().toUpperCase();
const rawMode = String(mode || '')
.trim()
.toUpperCase();
const normalized = normalizeProfile(rawMode);
if (normalized === 'OFF') {
const supplied = Number.isFinite(Number(densityPct)) ? densityPct : fallbackDensity;
const supplied = Number.isFinite(Number(densityPct))
? densityPct
: fallbackDensity;
const stop = canonicalizeDensity(supplied, fallbackDensity);
return { enabled: false, profile: profileForDensity(stop), densityPct: stop };
return {
enabled: false,
profile: profileForDensity(stop),
densityPct: stop,
};
}
if (rawMode === 'PANOPTIC' || rawMode === 'GOD' || rawMode === 'ON') {
const density = Number.isFinite(Number(densityPct)) ? Math.max(75, Number(densityPct)) : 75;
return { enabled: true, profile: 'DENSE', densityPct: canonicalizeDensity(density) };
const density = Number.isFinite(Number(densityPct))
? Math.max(75, Number(densityPct))
: 75;
return {
enabled: true,
profile: 'DENSE',
densityPct: canonicalizeDensity(density),
};
}
if (rawMode === 'SPARSE' || rawMode === 'SURVEY') {
const density = Number.isFinite(Number(densityPct)) ? Math.min(25, Number(densityPct)) : 25;
const density = Number.isFinite(Number(densityPct))
? Math.min(25, Number(densityPct))
: 25;
const stop = canonicalizeDensity(density, 25);
return { enabled: true, profile: 'SPARSE', densityPct: stop };
}
if (normalized === 'BALANCED' || normalized === 'DENSE') {
const supplied = Number.isFinite(Number(densityPct)) ? canonicalizeDensity(densityPct) : defaultDensityForProfile(normalized);
const stop = profileForDensity(supplied) === normalized ? supplied : defaultDensityForProfile(normalized);
const supplied = Number.isFinite(Number(densityPct))
? canonicalizeDensity(densityPct)
: defaultDensityForProfile(normalized);
const stop =
profileForDensity(supplied) === normalized
? supplied
: defaultDensityForProfile(normalized);
return { enabled: true, profile: normalized, densityPct: stop };
}
const stop = canonicalizeDensity(densityPct, fallbackDensity);
-1
View File
@@ -10,4 +10,3 @@ export const COCKPIT_BRACKET_OPACITY = 0.45;
export function detectionBracketOpacity(cockpitActive) {
return cockpitActive === true ? COCKPIT_BRACKET_OPACITY : 1;
}
+4 -3
View File
@@ -158,9 +158,10 @@ export function detectionPaintSkipDecision({
frameCount,
thresholdMs = DETECTION_PAINT_SKIP_THRESHOLD_MS,
} = {}) {
const skip = !layoutChanged
&& Number(lastPaintMs) > thresholdMs
&& Number(frameCount) % 2 !== 0;
const skip =
!layoutChanged &&
Number(lastPaintMs) > thresholdMs &&
Number(frameCount) % 2 !== 0;
return { skip, requestFollowUp: skip };
}
+3 -1
View File
@@ -24,7 +24,9 @@
* @returns {number} Heading in degrees [0..360), or NaN if unrecognized.
*/
export function directionToHeading(value, allowBare = false) {
const text = String(value || '').trim().toUpperCase();
const text = String(value || '')
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
+6 -2
View File
@@ -79,8 +79,12 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
const hhmm = String(time ?? '0000').padStart(4, '0');
const hours = Number(hhmm.slice(0, 2));
const minutes = Number(hhmm.slice(2, 4));
const valid = Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)
&& Number.isFinite(hours) && Number.isFinite(minutes);
const valid =
Number.isFinite(year) &&
Number.isFinite(month) &&
Number.isFinite(day) &&
Number.isFinite(hours) &&
Number.isFinite(minutes);
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
cache.set(key, ms);
return ms;
+11 -5
View File
@@ -12,10 +12,14 @@ export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
export function fireDetectionKey(fire) {
const lat = Number.isFinite(fire?.lat) ? fire.lat.toFixed(4) : 'x';
const lon = Number.isFinite(fire?.lon) ? fire.lon.toFixed(4) : 'x';
const acq = Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source = satelliteShortName(fire?.satellite)
|| String(fire?.sensor || '').trim().toUpperCase()
|| 'x';
const acq =
Number.isFinite(fire?.acqMs) && fire.acqMs > 0 ? String(fire.acqMs) : '0';
const source =
satelliteShortName(fire?.satellite) ||
String(fire?.sensor || '')
.trim()
.toUpperCase() ||
'x';
return `firms:${lat}:${lon}:${acq}:${source}`;
}
@@ -37,7 +41,9 @@ export function accentForSeverity(stopName) {
/** Raw FIRMS satellite code → short display name (N = Suomi NPP). */
export function satelliteShortName(satellite) {
const s = String(satellite || '').trim().toUpperCase();
const s = String(satellite || '')
.trim()
.toUpperCase();
if (s === 'N20' || s === 'NOAA-20') return 'N20';
if (s === 'N21' || s === 'NOAA-21') return 'N21';
if (s === 'N' || s === 'NPP' || s === 'SUOMI NPP') return 'SNPP';
+29 -9
View File
@@ -45,7 +45,9 @@ export function median(values) {
if (!Array.isArray(values) || values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = sorted.length >> 1;
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
return sorted.length % 2 === 1
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
}
/** Bearing (degrees, 0 = north, clockwise) of a projected dx/dy vector. */
@@ -98,13 +100,19 @@ export function matchFlowToRoads(roads, flowSegments) {
const roadCount = Array.isArray(roads) ? roads.length : 0;
const matches = new Array(roadCount).fill(null);
const empty = { matches, matchedCount: 0, candidateCount: 0 };
if (roadCount === 0 || !Array.isArray(flowSegments) || flowSegments.length === 0) {
if (
roadCount === 0 ||
!Array.isArray(flowSegments) ||
flowSegments.length === 0
) {
return empty;
}
// Local equirectangular projection anchored at the first flow coordinate —
// over a ≤0.05° fetch box the distortion is negligible.
const anchor = flowSegments.find((f) => Array.isArray(f?.coords) && f.coords.length >= 2);
const anchor = flowSegments.find(
(f) => Array.isArray(f?.coords) && f.coords.length >= 2,
);
if (!anchor) return empty;
const [refLon, refLat] = anchor.coords[0];
const mPerDegLon = M_PER_DEG_LAT * Math.cos((refLat * Math.PI) / 180);
@@ -114,7 +122,8 @@ export function matchFlowToRoads(roads, flowSegments) {
// ── 1. Spatial hash of flow segments (midpoint-keyed 100 m cells) ──
/** @type {Map<string, Array<{ax:number,ay:number,bx:number,by:number,bearing:number,level:number,closure:boolean}>>} */
const grid = new Map();
const cellOf = (x, y) => `${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
const cellOf = (x, y) =>
`${Math.floor(x / CELL_SIZE_M)},${Math.floor(y / CELL_SIZE_M)}`;
for (const flow of flowSegments) {
const coords = flow?.coords;
@@ -173,7 +182,8 @@ export function matchFlowToRoads(roads, flowSegments) {
for (let i = 0; i < coords.length; i++) {
xs[i] = projX(coords[i][0]);
ys[i] = projY(coords[i][1]);
if (i > 0) cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
if (i > 0)
cum[i] = cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]);
}
const totalLen = cum[coords.length - 1];
if (!(totalLen > 0)) continue;
@@ -188,10 +198,14 @@ export function matchFlowToRoads(roads, flowSegments) {
// cross-street flow lines meet at intersections.
const target = (totalLen * (s + 0.5)) / ROAD_SAMPLES;
while (cursor < coords.length - 1 && cum[cursor] < target) cursor++;
const segT = (target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const segT =
(target - cum[cursor - 1]) / (cum[cursor] - cum[cursor - 1] || 1);
const px = xs[cursor - 1] + (xs[cursor] - xs[cursor - 1]) * segT;
const py = ys[cursor - 1] + (ys[cursor] - ys[cursor - 1]) * segT;
const sampleBearing = bearingDeg(xs[cursor] - xs[cursor - 1], ys[cursor] - ys[cursor - 1]);
const sampleBearing = bearingDeg(
xs[cursor] - xs[cursor - 1],
ys[cursor] - ys[cursor - 1],
);
// 3×3 cell probe around the sample.
const cx = Math.floor(px / CELL_SIZE_M);
@@ -206,7 +220,11 @@ export function matchFlowToRoads(roads, flowSegments) {
const d2 = pointSegDist2(px, py, seg.ax, seg.ay, seg.bx, seg.by);
if (d2 > radius2) continue;
hadCandidate = true; // within radius, bearing not yet checked
if (bearingDiffDeg(seg.bearing, sampleBearing) >= BEARING_TOLERANCE_DEG) continue;
if (
bearingDiffDeg(seg.bearing, sampleBearing) >=
BEARING_TOLERANCE_DEG
)
continue;
if (d2 <= bestDist2) {
bestDist2 = d2;
best = seg;
@@ -221,7 +239,9 @@ export function matchFlowToRoads(roads, flowSegments) {
}
if (hadCandidate) candidateCount++;
if (matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)) {
if (
matchedLevels.length >= Math.max(MIN_MATCHED_SAMPLES, ROAD_SAMPLES / 2)
) {
matches[r] = { level: median(matchedLevels), closure: matchedClosure };
matchedCount++;
}
+114 -50
View File
@@ -59,24 +59,35 @@ function focusTargetEmphasisResolved(
previouslyFarther,
) {
if (!target?.screenRect || !spriteScreenPosition) return 1;
if (!Number.isFinite(spriteScreenPosition.x) || !Number.isFinite(spriteScreenPosition.y)) return 1;
if (
!Number.isFinite(spriteScreenPosition.x) ||
!Number.isFinite(spriteScreenPosition.y)
)
return 1;
// Published rects already contain their publication-time padding. The
// delta keeps direct pure-function calls and runtime A/B overrides honest.
// The ambient extent makes overlap mean visual overlap, not center-point
// containment: a class-sized icon whose edge intrudes into the focus rect
// yields even when its center remains just outside it.
const paddingDelta = tuning.paddingPx - (Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const paddingDelta =
tuning.paddingPx -
(Number.isFinite(target.paddingPx) ? target.paddingPx : 0);
const hysteresis = previouslyCompeting ? tuning.hysteresisPx : 0;
const expansionX = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY = paddingDelta + hysteresis
+ Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const expansionX =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfWidthPx) ? spriteHalfWidthPx : 0);
const expansionY =
paddingDelta +
hysteresis +
Math.max(0, Number.isFinite(spriteHalfHeightPx) ? spriteHalfHeightPx : 0);
const rect = target.screenRect;
const inside = spriteScreenPosition.x >= rect.left - expansionX
&& spriteScreenPosition.x <= rect.right + expansionX
&& spriteScreenPosition.y >= rect.top - expansionY
&& spriteScreenPosition.y <= rect.bottom + expansionY;
const inside =
spriteScreenPosition.x >= rect.left - expansionX &&
spriteScreenPosition.x <= rect.right + expansionX &&
spriteScreenPosition.y >= rect.top - expansionY &&
spriteScreenPosition.y <= rect.bottom + expansionY;
if (!inside) return 1;
const floor = clamp(tuning.dimFloor, 0.01, 1);
@@ -88,24 +99,33 @@ function focusTargetEmphasisResolved(
const fartherThreshold = Number.isFinite(target.cameraDistance)
? target.cameraDistance + (previouslyFarther ? -distanceBand : distanceBand)
: Number.NaN;
const farther = !Number.isFinite(spriteCameraDistance)
|| !Number.isFinite(fartherThreshold)
|| spriteCameraDistance >= fartherThreshold;
const farther =
!Number.isFinite(spriteCameraDistance) ||
!Number.isFinite(fartherThreshold) ||
spriteCameraDistance >= fartherThreshold;
if (farther) return floor;
if (tuning.nearerBehavior === 'partial') return floor + (1 - floor) * 0.5;
return 1;
}
function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
const from = clamp(Number.isFinite(current) ? current : 1, tuning.dimFloor, 1);
const from = clamp(
Number.isFinite(current) ? current : 1,
tuning.dimFloor,
1,
);
const to = clamp(Number.isFinite(target) ? target : 1, tuning.dimFloor, 1);
if (Math.abs(to - from) < Number.EPSILON) return to;
const duration = to < from ? tuning.attackMs : tuning.releaseMs;
if (duration <= 0) return to;
const t = clamp((Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration, 0, 1);
const t = clamp(
(Number.isFinite(elapsedMs) ? elapsedMs : 0) / duration,
0,
1,
);
// Cubic ease-out reaches the exact endpoint at the configured duration and
// stays continuous when a moving contact reverses direction mid-transition.
const eased = 1 - ((1 - t) ** 3);
const eased = 1 - (1 - t) ** 3;
return clamp(from + (to - from) * eased, tuning.dimFloor, 1);
}
@@ -118,18 +138,24 @@ function smoothFocusEmphasisResolved(current, target, elapsedMs, tuning) {
*/
export function setFocusDeemphasisParams(patch = {}) {
const next = { ..._params };
if (Number.isFinite(patch.paddingPx)) next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor)) next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (Number.isFinite(patch.paddingPx))
next.paddingPx = Math.max(0, patch.paddingPx);
if (Number.isFinite(patch.dimFloor))
next.dimFloor = clamp(patch.dimFloor, 0.01, 1);
if (['allow', 'dim', 'partial'].includes(patch.nearerBehavior)) {
next.nearerBehavior = patch.nearerBehavior;
}
if (Number.isFinite(patch.hysteresisPx)) next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.hysteresisPx))
next.hysteresisPx = Math.max(0, patch.hysteresisPx);
if (Number.isFinite(patch.distanceHysteresisRatio)) {
next.distanceHysteresisRatio = clamp(patch.distanceHysteresisRatio, 0, 0.5);
}
if (Number.isFinite(patch.attackMs)) next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs)) next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon)) next.writeEpsilon = Math.max(0, patch.writeEpsilon);
if (Number.isFinite(patch.attackMs))
next.attackMs = Math.max(0, patch.attackMs);
if (Number.isFinite(patch.releaseMs))
next.releaseMs = Math.max(0, patch.releaseMs);
if (Number.isFinite(patch.writeEpsilon))
next.writeEpsilon = Math.max(0, patch.writeEpsilon);
_params = next;
return { ..._params };
}
@@ -171,11 +197,22 @@ export function publishFocusTargetFromCachedPosition({
heightPx = 24,
params,
}) {
if (!ownerLayer || id === null || id === undefined || !scene || !camera || !displayPosition) {
if (
!ownerLayer ||
id === null ||
id === undefined ||
!scene ||
!camera ||
!displayPosition
) {
clearFocusTarget(ownerLayer, id);
return null;
}
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, displayPosition, _scratchScreen);
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
displayPosition,
_scratchScreen,
);
if (!screen || !Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
clearFocusTarget(ownerLayer, id);
return null;
@@ -195,12 +232,19 @@ export function publishFocusTargetFromCachedPosition({
bottom: screen.y + halfHeight + padding,
},
paddingPx: padding,
cameraDistance: Cesium.Cartesian3.distance(camera.positionWC, displayPosition),
cameraDistance: Cesium.Cartesian3.distance(
camera.positionWC,
displayPosition,
),
frameNumber: scene.frameState?.frameNumber ?? -1,
};
if (appeared) {
for (const listener of _focusAppearListeners) {
try { listener(); } catch (e) { console.warn('[focus] appear listener error:', e); }
try {
listener();
} catch (e) {
console.warn('[focus] appear listener error:', e);
}
}
}
return _focusTarget;
@@ -311,15 +355,18 @@ export function smoothFocusEmphasis(current, target, elapsedMs, params) {
* every needed property before the next call; never retain it as a snapshot.
* @returns {{factor:number, changed:boolean, transitioning:boolean,active:boolean,desired:number}}
*/
export function advanceSpriteFocus(sprite, {
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
}) {
export function advanceSpriteFocus(
sprite,
{
screenPosition,
cameraDistance,
nowMs,
target = _focusTarget,
params,
spriteHalfWidthPx = 0,
spriteHalfHeightPx = spriteHalfWidthPx,
},
) {
const tuning = resolvedParams(params);
let state = _spriteStates.get(sprite);
if (!state) {
@@ -369,12 +416,18 @@ export function advanceSpriteFocus(sprite, {
state.desired = desired;
}
const elapsedMs = Math.max(0, nowMs - state.transitionStartMs);
const next = smoothFocusEmphasisResolved(state.transitionFrom, desired, elapsedMs, tuning);
const next = smoothFocusEmphasisResolved(
state.transitionFrom,
desired,
elapsedMs,
tuning,
);
const changed = Math.abs(next - state.factor) > tuning.writeEpsilon;
state.factor = next;
state.desired = desired;
state.wasFarther = tuning.nearerBehavior !== 'dim'
&& Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
state.wasFarther =
tuning.nearerBehavior !== 'dim' &&
Math.abs(desired - tuning.dimFloor) <= tuning.writeEpsilon;
const transitioning = Math.abs(next - desired) > tuning.writeEpsilon;
const active = Math.abs(next - 1) > tuning.writeEpsilon;
@@ -426,12 +479,18 @@ export function advanceProjectedSpriteFocus(
_advanceResult.desired = 1;
return _advanceResult;
}
const screen = position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchScreen)
: null;
const cameraDistance = position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
const screen =
position && scene
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
_scratchScreen,
)
: null;
const cameraDistance =
position && camera?.positionWC
? Cesium.Cartesian3.distance(camera.positionWC, position)
: Number.NaN;
return advanceSpriteFocus(sprite, {
screenPosition: screen,
cameraDistance,
@@ -451,8 +510,10 @@ export function advanceProjectedSpriteFocus(
* @returns {boolean}
*/
export function focusPassIsNeeded(target, activeCount) {
return target !== null && target !== undefined
|| (Number.isFinite(activeCount) && activeCount > 0);
return (
(target !== null && target !== undefined) ||
(Number.isFinite(activeCount) && activeCount > 0)
);
}
/**
@@ -466,8 +527,9 @@ export function nearFarScalarValueAtDistance(scalar, cameraDistanceM) {
if (!scalar || !Number.isFinite(cameraDistanceM)) return 1;
const nearSq = scalar.near * scalar.near;
const farSq = scalar.far * scalar.far;
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq) return 1;
const rawT = ((cameraDistanceM * cameraDistanceM) - nearSq) / (farSq - nearSq);
if (!Number.isFinite(nearSq) || !Number.isFinite(farSq) || farSq <= nearSq)
return 1;
const rawT = (cameraDistanceM * cameraDistanceM - nearSq) / (farSq - nearSq);
const t = Math.pow(clamp(rawT, 0, 1), 0.2);
return scalar.nearValue + (scalar.farValue - scalar.nearValue) * t;
}
@@ -507,6 +569,8 @@ export function forgetSpriteFocus(sprite) {
*/
export function focusAlphaNeedsWrite(currentAlpha, nextAlpha, params) {
const tuning = resolvedParams(params);
return !Number.isFinite(currentAlpha)
|| Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon;
return (
!Number.isFinite(currentAlpha) ||
Math.abs(currentAlpha - nextAlpha) > tuning.writeEpsilon
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function geoidHeight(latDeg, lonDeg) {
if (!egm96Module) {
throw new Error(
'geoid.js: geoidHeight() called before ensureGeoidReady() resolved — ' +
'await ensureGeoidReady() first.'
'await ensureGeoidReady() first.',
);
}
return egm96Module.meanSeaLevel(latDeg, lonDeg);
+29 -1
View File
@@ -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;
+3 -1
View File
@@ -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
View File
@@ -29,8 +29,14 @@ export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
}
export function findNextIssPass({
satrec, latDeg, lonDeg, fromMs,
minElevDeg = 10, horizonHours = 24, coarseStepSec = 30, fineStepSec = 5,
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
@@ -41,13 +47,17 @@ export function findNextIssPass({
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) { hit = t; break; }
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg) riseMs -= fine;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
@@ -56,11 +66,20 @@ export function findNextIssPass({
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) { maxElevDeg = e; maxElevMs = t; }
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return { riseMs, setMs, maxElevDeg, maxElevMs, riseAzDeg: rise ? rise.azDeg : 0 };
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
}
+175 -72
View File
@@ -16,11 +16,15 @@ function clampInt(value, min, max) {
}
function demandEntries(demandByLayer) {
const entries = demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
const entries =
demandByLayer instanceof Map
? Array.from(demandByLayer.entries())
: Object.entries(demandByLayer || {});
return entries
.map(([layerId, demand]) => [String(layerId), Math.max(0, Math.floor(Number(demand) || 0))])
.map(([layerId, demand]) => [
String(layerId),
Math.max(0, Math.floor(Number(demand) || 0)),
])
.filter(([, demand]) => demand > 0)
.sort(([a], [b]) => a.localeCompare(b));
}
@@ -104,7 +108,9 @@ function allocateLayerQuotasInto(
weighted.length = idCount;
priorityOrder.length = idCount;
remainders.length = idCount;
priorityOrder.sort((a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId));
priorityOrder.sort(
(a, b) => b.weight - a.weight || a.layerId.localeCompare(b.layerId),
);
let remaining = capacity;
if (capacity >= idCount) {
@@ -115,7 +121,7 @@ function allocateLayerQuotasInto(
let apportioned = 0;
for (let i = 0; i < idCount; i++) {
const entry = weighted[i];
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
@@ -123,9 +129,12 @@ function allocateLayerQuotasInto(
entry.fraction = exact - Math.floor(exact);
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction
|| b.weight - a.weight
|| a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (let i = 0; i < idCount && remaining > 0; i++) {
const entry = remainders[i];
const current = quotas.get(entry.layerId) || 0;
@@ -156,7 +165,12 @@ function allocateLayerQuotasInto(
* Allocate a collective capacity across non-empty layers. Both strategies are
* work-conserving and deterministic; unused entitlement is always borrowed.
*/
export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATION_ELASTIC, layerWeights = {}) {
export function allocateLayerQuotas(
demandByLayer,
capacity,
strategy = ALLOCATION_ELASTIC,
layerWeights = {},
) {
const entries = demandEntries(demandByLayer);
const totalDemand = entries.reduce((sum, [, demand]) => sum + demand, 0);
const cap = clampInt(capacity, 0, totalDemand);
@@ -198,15 +212,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
if (remaining > 0 && totalWeight > 0) {
let apportioned = 0;
for (const entry of weighted) {
const exact = remaining * entry.weight / totalWeight;
const exact = (remaining * entry.weight) / totalWeight;
const room = entry.count - (quotas.get(entry.layerId) || 0);
const whole = Math.min(room, Math.floor(exact));
quotas.set(entry.layerId, (quotas.get(entry.layerId) || 0) + whole);
apportioned += whole;
remainders.push({ layerId: entry.layerId, fraction: exact - Math.floor(exact), weight: entry.weight });
remainders.push({
layerId: entry.layerId,
fraction: exact - Math.floor(exact),
weight: entry.weight,
});
}
remaining -= apportioned;
remainders.sort((a, b) => b.fraction - a.fraction || b.weight - a.weight || a.layerId.localeCompare(b.layerId));
remainders.sort(
(a, b) =>
b.fraction - a.fraction ||
b.weight - a.weight ||
a.layerId.localeCompare(b.layerId),
);
for (const entry of remainders) {
if (remaining <= 0) break;
const current = quotas.get(entry.layerId) || 0;
@@ -220,20 +243,24 @@ export function allocateLayerQuotas(demandByLayer, capacity, strategy = ALLOCATI
}
function rectIsFinite(rect) {
return rect
&& Number.isFinite(rect.x)
&& Number.isFinite(rect.y)
&& Number.isFinite(rect.w)
&& Number.isFinite(rect.h)
&& rect.w > 0
&& rect.h > 0;
return (
rect &&
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.w) &&
Number.isFinite(rect.h) &&
rect.w > 0 &&
rect.h > 0
);
}
function overlaps(a, b, padding = 4) {
return a.x < b.x + b.w + padding
&& a.x + a.w + padding > b.x
&& a.y < b.y + b.h + padding
&& a.y + a.h + padding > b.y;
return (
a.x < b.x + b.w + padding &&
a.x + a.w + padding > b.x &&
a.y < b.y + b.h + padding &&
a.y + a.h + padding > b.y
);
}
const CELL_ORIGIN = 8192;
@@ -271,8 +298,10 @@ class SpatialHash {
* never hide a real collision.
*/
_cellKey(x, y) {
const cx = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy = Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
const cx =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, x)) + CELL_ORIGIN;
const cy =
Math.max(-CELL_ORIGIN, Math.min(CELL_ORIGIN - 1, y)) + CELL_ORIGIN;
// Both coordinates occupy 14 bits. Bit-packing forces the lookup key to a
// Smi; arithmetic multiplication materializes a boxed double at each
// spatial-hash get/add on this hot solve path.
@@ -325,8 +354,14 @@ function candidateCompare(a, b, states, now) {
// A stateless candidate is never lifetime-pinned: its source shipped as a
// per-frame rebuild, so it must be free to lose its slot the moment something
// better wants it. Plain incumbency ordering below still applies.
const aPinned = !!aState?.selected && a.stateless !== true && now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned = !!bState?.selected && b.stateless !== true && now - bState.selectedAt < MIN_LIFETIME_MS;
const aPinned =
!!aState?.selected &&
a.stateless !== true &&
now - aState.selectedAt < MIN_LIFETIME_MS;
const bPinned =
!!bState?.selected &&
b.stateless !== true &&
now - bState.selectedAt < MIN_LIFETIME_MS;
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const aIncumbent = !!aState?.selected;
const bIncumbent = !!bState?.selected;
@@ -346,12 +381,20 @@ function candidateCompare(a, b, states, now) {
function cacheCandidateAnchorScalars(candidate) {
let anchorX = Number.NaN;
let anchorY = Number.NaN;
if (Number.isFinite(candidate?.screenX) && Number.isFinite(candidate?.screenY)) {
if (
Number.isFinite(candidate?.screenX) &&
Number.isFinite(candidate?.screenY)
) {
anchorX = candidate.screenX;
anchorY = candidate.screenY;
} else {
const placement = Array.isArray(candidate?.placements) ? candidate.placements[0] : null;
if (Number.isFinite(placement?.leadFromX) && Number.isFinite(placement?.leadFromY)) {
const placement = Array.isArray(candidate?.placements)
? candidate.placements[0]
: null;
if (
Number.isFinite(placement?.leadFromX) &&
Number.isFinite(placement?.leadFromY)
) {
anchorX = placement.leadFromX;
anchorY = placement.leadFromY;
} else if (rectIsFinite(placement?.rect)) {
@@ -401,7 +444,16 @@ class SpatialCandidateQueue {
* stay unboxed. Few-placement candidates already blocked by the
* monotonically growing collision field are dismissed before anchor scans.
*/
reset(candidates, count, anchors, anchorCount, attemptStamps, stamp, spatial, states) {
reset(
candidates,
count,
anchors,
anchorCount,
attemptStamps,
stamp,
spatial,
states,
) {
this.candidates = candidates;
this.count = count;
this.anchorCount = anchorCount;
@@ -529,10 +581,13 @@ class SpatialCandidateQueue {
const rectY = placementYs[rectIndex];
const rectW = placementWs[rectIndex];
const rectH = placementHs[rectIndex];
if (rectX < anchorRectX + anchorRectW + 4
&& rectX + rectW + 4 > anchorRectX
&& rectY < anchorRectY + anchorRectH + 4
&& rectY + rectH + 4 > anchorRectY) freeMask &= ~bit;
if (
rectX < anchorRectX + anchorRectW + 4 &&
rectX + rectW + 4 > anchorRectX &&
rectY < anchorRectY + anchorRectH + 4 &&
rectY + rectH + 4 > anchorRectY
)
freeMask &= ~bit;
}
placementMasks[i] = freeMask;
if (freeMask === 0 && this._dismissBlockedKey(i)) continue;
@@ -558,7 +613,8 @@ class SpatialCandidateQueue {
bestIndex = i;
continue;
}
const priorityDelta = (Number(candidate.priority) || 0) - (Number(best.priority) || 0);
const priorityDelta =
(Number(candidate.priority) || 0) - (Number(best.priority) || 0);
if (priorityDelta !== 0) {
if (priorityDelta > 0) {
best = candidate;
@@ -621,7 +677,8 @@ function firstFreePlacement(candidate, stickyCorner, spatial) {
if (stickyCorner) {
for (let i = 0; i < placements.length; i++) {
const placement = placements[i];
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect)) continue;
if (placement?.corner !== stickyCorner || !rectIsFinite(placement.rect))
continue;
if (!spatial.collides(placement.rect)) return placement;
}
}
@@ -790,7 +847,13 @@ export class LabelArbiter {
let candidateCount = 0;
for (let i = 0; i < source.length; i++) {
const candidate = source[i];
if (!candidate || !candidate.key || !candidate.layerId || !(candidate.keyholeAlpha > 0)) continue;
if (
!candidate ||
!candidate.key ||
!candidate.layerId ||
!(candidate.keyholeAlpha > 0)
)
continue;
cacheCandidateAnchorScalars(candidate);
candidateList[candidateCount++] = candidate;
let bucket = byLayer.get(candidate.layerId);
@@ -815,13 +878,15 @@ export class LabelArbiter {
});
} else if (options.demandByLayer != null) {
const supplied = demandEntries(options.demandByLayer);
for (let i = 0; i < supplied.length; i++) demand.set(supplied[i][0], supplied[i][1]);
for (let i = 0; i < supplied.length; i++)
demand.set(supplied[i][0], supplied[i][1]);
}
// A supplied demand map describes the full lightweight field while the
// candidate list may be only a bounded materialized cohort. Keep defensive
// support for candidate layers omitted by an incomplete caller map.
byLayer.forEach((bucket, layerId) => {
if (bucket.count > 0 && !demand.has(layerId)) demand.set(layerId, bucket.count);
if (bucket.count > 0 && !demand.has(layerId))
demand.set(layerId, bucket.count);
});
const activeLayers = this._activeLayersScratch;
@@ -857,14 +922,20 @@ export class LabelArbiter {
let sameActiveLayers = activeCount === this.lastActiveLayers.length;
for (let i = 0; sameActiveLayers && i < activeCount; i++) {
if (activeLayers[i] !== this.lastActiveLayers[i]) sameActiveLayers = false;
if (activeLayers[i] !== this.lastActiveLayers[i])
sameActiveLayers = false;
}
const preserveIncumbents = options.preserveIncumbents !== false && sameActiveLayers;
const preserveIncumbents =
options.preserveIncumbents !== false && sameActiveLayers;
let spatialQueueBuildCount = 0;
let spatialQueueNextCount = 0;
const attempt = (candidate, allowOverlapFallback = false) => {
if (selectedCount >= capacity || attemptStamps.get(candidate.key) === stamp) return false;
if (
selectedCount >= capacity ||
attemptStamps.get(candidate.key) === stamp
)
return false;
attemptStamps.set(candidate.key, stamp);
const previous = this.states.get(candidate.key);
const stateless = candidate.stateless === true;
@@ -872,10 +943,12 @@ export class LabelArbiter {
// very next solve, as their stateless originals did) and carry no sticky
// corner, so above/below is re-decided from geometry every solve instead
// of being pinned by whichever side happened to be free once.
if (!stateless && !previous?.selected && previous?.cooldownUntil > now) return false;
if (!stateless && !previous?.selected && previous?.cooldownUntil > now)
return false;
const sticky = stateless ? undefined : previous?.corner;
let placement = firstFreePlacement(candidate, sticky, spatial);
if (!placement && allowOverlapFallback) placement = firstOrderedPlacement(candidate, sticky);
if (!placement && allowOverlapFallback)
placement = firstOrderedPlacement(candidate, sticky);
if (!placement) return false;
spatial.add(placement.rect);
selectStamps.set(candidate.key, stamp);
@@ -953,7 +1026,8 @@ export class LabelArbiter {
}
let retainedIncumbents = 0;
for (let i = 0; i < selectedCount; i++) {
if (this.selectedKeys.has(selectedCandidates[i].key)) retainedIncumbents++;
if (this.selectedKeys.has(selectedCandidates[i].key))
retainedIncumbents++;
}
const exitingStates = this._refreshStateList();
@@ -964,7 +1038,9 @@ export class LabelArbiter {
state.exitStartedAt = now;
// Stateless entries may be re-selected immediately; the cooldown exists
// to damp thrash for sources that shipped with hysteresis.
state.cooldownUntil = state.stateless ? 0 : now + FADE_OUT_MS + COOLDOWN_MS;
state.cooldownUntil = state.stateless
? 0
: now + FADE_OUT_MS + COOLDOWN_MS;
}
}
@@ -1002,7 +1078,8 @@ export class LabelArbiter {
this._stateListDirty = true;
}
if (labelsByLayer) {
labelsByLayer[candidate.layerId] = (labelsByLayer[candidate.layerId] || 0) + 1;
labelsByLayer[candidate.layerId] =
(labelsByLayer[candidate.layerId] || 0) + 1;
}
}
@@ -1013,7 +1090,10 @@ export class LabelArbiter {
const livingStates = this._refreshStateList();
for (let i = 0; i < livingStates.length; i++) {
const state = livingStates[i];
if (!state.selected && now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS) {
if (
!state.selected &&
now - (state.exitStartedAt || now) > FADE_OUT_MS + COOLDOWN_MS
) {
this.states.delete(state.key);
this._stateListDirty = true;
}
@@ -1026,25 +1106,28 @@ export class LabelArbiter {
});
for (let i = 0; i < droppedCount; i++) this.selectedKeys.delete(dropped[i]);
dropped.length = droppedCount;
for (let i = 0; i < selectedCount; i++) this.selectedKeys.add(selectedCandidates[i].key);
for (let i = 0; i < selectedCount; i++)
this.selectedKeys.add(selectedCandidates[i].key);
const previousActiveLayers = this.lastActiveLayers;
this.lastActiveLayers = activeLayers;
this._activeLayersScratch = previousActiveLayers;
this.solveRevision++;
this.lastDiagnostics = collectDiagnostics ? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
} : null;
this.lastDiagnostics = collectDiagnostics
? {
strategy,
capacity,
selectedCount,
quotas: Object.fromEntries(quotas),
demand: Object.fromEntries(demand),
labelsByLayer,
solveRevision: this.solveRevision,
spatialQueueBuildCount,
spatialQueueNextCount,
eligibleIncumbents,
retainedIncumbents,
}
: null;
selectedCandidates.length = selectedCount;
selectedPlacements.length = selectedCount;
pruneStampMap(attemptStamps, candidateCount);
@@ -1057,9 +1140,15 @@ export class LabelArbiter {
* A caller-owned output array enables allocation-free per-frame rendering.
*/
renderEntries(currentCandidates, now = Date.now(), out = []) {
const current = currentCandidates instanceof Map
? currentCandidates
: new Map((currentCandidates || []).map((candidate) => [candidate.key, candidate]));
const current =
currentCandidates instanceof Map
? currentCandidates
: new Map(
(currentCandidates || []).map((candidate) => [
candidate.key,
candidate,
]),
);
const states = this._refreshStateList();
let outIndex = 0;
for (let i = 0; i < states.length; i++) {
@@ -1070,14 +1159,23 @@ export class LabelArbiter {
// fully painted this solve or gone. No enter ramp, no exit tail.
temporalAlpha = state.selected ? 1 : 0;
} else if (state.selected) {
temporalAlpha = Math.min(1, Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS));
temporalAlpha = Math.min(
1,
Math.max(0, (now - state.enterStartedAt) / FADE_IN_MS),
);
} else {
temporalAlpha = 1 - Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
temporalAlpha =
1 -
Math.min(1, Math.max(0, (now - state.exitStartedAt) / FADE_OUT_MS));
}
if (temporalAlpha <= 0) continue;
const candidate = current.get(state.key) || state.lastCandidate;
if (!candidate) continue;
const placement = renderPlacement(candidate, state.stateless ? undefined : state.corner, state.lastPlacement);
const placement = renderPlacement(
candidate,
state.stateless ? undefined : state.corner,
state.lastPlacement,
);
if (!placement) continue;
const entry = out[outIndex] || (out[outIndex] = {});
entry.candidate = candidate;
@@ -1100,11 +1198,16 @@ export class LabelArbiter {
for (const state of this.states.values()) {
if (!state.selected) {
if (!includeFading) continue;
if (!Number.isFinite(state.exitStartedAt) || now - state.exitStartedAt >= FADE_OUT_MS) continue;
if (
!Number.isFinite(state.exitStartedAt) ||
now - state.exitStartedAt >= FADE_OUT_MS
)
continue;
}
const candidate = state.lastCandidate;
if (!candidate?.layerId || candidate.sourceId == null) continue;
if (!grouped.has(candidate.layerId)) grouped.set(candidate.layerId, new Set());
if (!grouped.has(candidate.layerId))
grouped.set(candidate.layerId, new Set());
grouped.get(candidate.layerId).add(candidate.sourceId);
}
return grouped;
+438 -179
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -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,
});
}
+1 -6
View File
@@ -15,9 +15,4 @@ const fires = createFirmsHeatmapLayer({
source: 'NASA FIRMS · LIVE',
});
export default [
datacenters,
dams,
submarineCablesLayer,
fires,
];
export default [datacenters, dams, submarineCablesLayer, fires];
+4 -1
View File
@@ -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;
+73 -18
View File
@@ -18,10 +18,13 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
* @param {{initialRadiusM?: number, maxRadiusM?: number}} [options] Bounds.
* @returns {{candidate: T, radiusM: number}|null} First match and radius used.
*/
export function findByDoublingRadius(search, {
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {}) {
export function findByDoublingRadius(
search,
{
initialRadiusM = AWARENESS_RADIUS_M,
maxRadiusM = AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
} = {},
) {
if (typeof search !== 'function') return null;
if (!Number.isFinite(initialRadiusM) || initialRadiusM <= 0) return null;
if (!Number.isFinite(maxRadiusM) || maxRadiusM < initialRadiusM) return null;
@@ -39,13 +42,22 @@ export function findByDoublingRadius(search, {
/** @param {number} meters @returns {string} */
export function formatAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
return meters < 1000
? `${Math.round(meters)} m`
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [value.callsign, value.name, value.label, value.id, value.icao24, value.mmsi]) {
for (const candidate of [
value.callsign,
value.name,
value.label,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
}
@@ -61,19 +73,41 @@ export function formatAwarenessLabel(value) {
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
* claim in this MVP.
*/
export function summarizeAwarenessCohort(items, { available = true, stale = false, limit = 3 } = {}) {
if (!available) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed unavailable' };
if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], reason: 'feed stale' };
export function summarizeAwarenessCohort(
items,
{ available = true, stale = false, limit = 3 } = {},
) {
if (!available)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
};
const normalized = (Array.isArray(items) ? items : [])
.filter((item) => Number.isFinite(item?.distanceM ?? item?.distance)
&& (item.distanceM ?? item.distance) >= 0)
.filter(
(item) =>
Number.isFinite(item?.distanceM ?? item?.distance) &&
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM);
return {
relationship: normalized.length ? AWARENESS_RELATIONSHIP.NEARBY : AWARENESS_RELATIONSHIP.UNKNOWN,
relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
: AWARENESS_RELATIONSHIP.UNKNOWN,
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length ? 'observed or mapped nearby context' : 'no observed or mapped objects in current feeds',
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
};
}
@@ -84,21 +118,40 @@ export function summarizeAwarenessCohort(items, { available = true, stale = fals
* @param {Iterable<string>} [visitedKeys] Previously visited layer/id keys.
* @returns {Array<{layerId: string, id: string, item: Object}>} Selectable targets.
*/
export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKeys = []) {
export function getAwarenessNavigationTargets(
cohorts,
subject = null,
visitedKeys = [],
) {
const visited = new Set(visitedKeys);
const targets = [];
for (const cohort of Array.isArray(cohorts) ? cohorts : []) {
const layerId = cohort?.id;
if (!['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(layerId)) continue;
if (
![
'flights',
'military',
'ais-live-vessels',
'military-installations',
].includes(layerId)
)
continue;
const sourceItems = Array.isArray(cohort?.summary?.navigationNearest)
? cohort.summary.navigationNearest
: (Array.isArray(cohort?.summary?.nearest) ? cohort.summary.nearest : []);
: Array.isArray(cohort?.summary?.nearest)
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
targets.push({ layerId, id: String(id), item, visited: visited.has(key) });
targets.push({
layerId,
id: String(id),
item,
visited: visited.has(key),
});
}
}
targets.sort((a, b) => {
@@ -108,7 +161,9 @@ export function getAwarenessNavigationTargets(cohorts, subject = null, visitedKe
// reached vessels, installations, or the other flight layer at all.
const visitedOrder = Number(a.visited) - Number(b.visited);
if (visitedOrder) return visitedOrder;
const subjectOrder = Number(a.layerId !== subject?.layerId) - Number(b.layerId !== subject?.layerId);
const subjectOrder =
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
});
+57 -18
View File
@@ -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
);
}
+33 -7
View File
@@ -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
View File
@@ -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,
};
}
+3 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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));
}
+13 -2
View File
@@ -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;
+12 -6
View File
@@ -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
View File
@@ -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;
+19 -4
View File
@@ -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;
}
+2 -1
View File
@@ -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;
}
+4 -2
View File
@@ -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);
}
+8 -2
View File
@@ -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;
+3 -1
View File
@@ -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
View File
@@ -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;
}
+4 -1
View File
@@ -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
View File
@@ -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;
+10 -3
View File
@@ -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,
+19 -9
View File
@@ -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' };
}
+30 -9
View File
@@ -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;
}
+3 -2
View File
@@ -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 = [];
+14 -3
View File
@@ -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
View File
@@ -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);