Merge main into pr-594

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-09-24 22:23:42 -07:00
commit 714d3dd1bb
492 files changed
+88532 -7676

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
function featureSource(overrides = {}) {
const source = {};
for (const method of FEATURE_SOURCE_METHODS)
source[method] = async () => null;
return Object.assign(source, overrides);
}
const texasGeocode = {
async geocode() {
return {
place: {
lat: 31.0,
lng: -99.9,
label: 'Texas, USA',
name: 'Texas',
types: ['administrative_area_level_1', 'political'],
},
};
},
};
test('region ring: Natural Earth names resolve without a geocoder', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource(),
});
for (const name of ['Gulf of Mexico', 'the Alps']) {
const region = await resolveRegionRingForQuery(name);
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
assert.equal(region.error, undefined);
}
});
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
});
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Texas',
undefined,
texasGeocode,
{ budgetMs: 50 },
);
assert.deepEqual(region, {
name: 'Texas',
ring: null,
error: 'region-timeout',
});
assert.ok(
Date.now() - started < 1000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
assert.equal(adminSignal?.aborted ?? false, false);
release(null);
});
test('region ring: an unbounded budget waits for the lookup', async () => {
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas: () =>
new Promise((resolve) => setTimeout(() => resolve(null), 30)),
}),
});
const region = await resolveRegionRingForQuery(
'Texas',
undefined,
texasGeocode,
{ budgetMs: Infinity },
);
assert.equal(region, null);
});
+29 -1
View File
@@ -25,6 +25,9 @@ import {
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/** Ms the analyst region lookup waits on geocode + admin boundary. */
export const REGION_FALLBACK_BUDGET_MS = 3_000;
/** Own annotation lookup caches and ranking of supplied feature candidates. */
export function createAnnotationResolver({
boundarySource,
@@ -1877,14 +1880,22 @@ export function createAnnotationResolver({
* resolve to a region-like boundary — the analyst engine reports that
* honestly rather than silently scoping to nothing.
*
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
* answer is not held for tens of seconds; the lookup keeps running and
* fills the boundary cache, so asking again shortly is fast.
*
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
* @param {AbortSignal} [signal]
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
* @param {object} [placeSearch]
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
*/
async function resolveRegionRingForQuery(
name,
signal,
placeSearch = unavailablePlaceSearch,
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
) {
signal = lifetime
? signal
@@ -1901,6 +1912,23 @@ export function createAnnotationResolver({
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
}
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
if (!Number.isFinite(budgetMs)) return lookup;
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
budgetMs,
);
});
try {
return await Promise.race([lookup, timeout]);
} finally {
clearTimeout(timer);
}
}
async function resolveAdminRegionRing(q, signal, placeSearch) {
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
() => null,
);
+113
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
const container = {
addEventListener(type, handler, options) {
assert.equal(type, 'wheel');
assert.deepEqual(options, { capture: true, passive: false });
handlers.add(handler);
},
removeEventListener(type, handler, capture) {
assert.equal(type, 'wheel');
assert.equal(capture, true);
handlers.delete(handler);
},
emit(event) {
for (const handler of handlers) handler(event);
},
};
const canvas = {
dispatchEvent(event) {
// A real canvas dispatch crosses the container's capture listener again.
container.emit(event);
relayed.push(event);
return true;
},
};
const controller = { zoomEventTypes };
const viewer = {
container,
canvas,
scene: { screenSpaceCameraController: controller },
};
const createEvent = (init = {}) => ({
deltaX: 0,
deltaY: 0,
deltaZ: 0,
deltaMode: 0,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
this.propagationStopped = true;
},
...init,
});
return { viewer, controller, container, handlers, relayed, createEvent };
}
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
const original = [1, 2, 3];
const f = pinchFixture({ zoomEventTypes: original });
const dispose = installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
assert.equal(f.controller.zoomEventTypes.length, 4);
const plain = f.createEvent({ deltaY: 2 });
f.container.emit(plain);
assert.equal(plain.defaultPrevented, false);
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
f.container.emit(lineMode);
assert.equal(lineMode.defaultPrevented, false);
assert.equal(f.relayed.length, 0);
dispose();
dispose();
assert.equal(f.controller.zoomEventTypes, original);
assert.equal(f.handlers.size, 0);
});
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(small);
assert.equal(small.defaultPrevented, true);
assert.equal(small.propagationStopped, true);
assert.equal(f.relayed.length, 1);
assert.equal(f.relayed[0].deltaY, 16);
assert.equal(f.relayed[0].ctrlKey, true);
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
f.container.emit(large);
assert.equal(f.relayed.length, 2);
assert.equal(f.relayed[1].deltaY, -120);
});
test('trackpad pinch falls back to the original event when relay construction fails', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent() {
throw new Error('unsupported');
},
});
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(event);
assert.equal(event.defaultPrevented, false);
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
+189
View File
@@ -0,0 +1,189 @@
/**
* Workaround for Apple's Metal/ANGLE shader translator rejecting Cesium's
* per-vertex model atmosphere.
*
* Cesium's `AtmosphereStageVS` calls a function whose `out` parameters are
* bound DIRECTLY to varyings:
*
* czm_computeGroundAtmosphereScattering(
* v_positionWC, lightDirection,
* v_atmosphereRayleighColor, v_atmosphereMieColor, v_atmosphereOpacity);
*
* ANGLE's Metal backend wraps each `out` argument in an `ANGLE_out(...)`
* helper that takes a `thread T &`. Vertex-output struct members live in
* `__metal_generic`, which cannot bind to `thread`, so the program fails to
* LINK (not compile) and Cesium tears the render loop down:
*
* RuntimeError: Program failed to link. ... MSL compilation error:
* reference to type 'thread float3' could not bind to an lvalue of type
* '__metal_generic float3'
*
* This is the only shader in Cesium that binds varyings to `out` params --
* SkyAtmosphere and the ground-atmosphere fragment path both route through
* locals first, so they are unaffected and stay on.
*
* Cesium attaches that stage in `ModelSceneGraph.configurePipeline` under a
* single condition:
*
* const fogRenderable = frameState.fog.enabled && frameState.fog.renderable;
* if (fogRenderable) modelPipelineStages.push(AtmospherePipelineStage);
*
* so clearing `scene.fog.renderable` keeps the broken stage out of the
* pipeline entirely -- the program is never built, so it cannot fail to link.
*
* We clear `renderable` rather than `enabled` deliberately:
* - `Fog.update` still computes fog density when only `renderable` is off,
* preserving the distance-based screen-space-error scaling that governs
* 3D Tiles refinement. Clearing `enabled` returns early and forfeits it.
* - `visualSettings.js` saves and restores `scene.fog.enabled` around IR
* boost. Parking our fix on `enabled` would let that restore resurrect
* the broken shader mid-session; `renderable` is orthogonal to it.
*
* @module app/atmosphereCompat
*/
/** Minimal vertex shader reproducing the exact out-param-to-varying bind. */
const PROBE_VERTEX_SHADER = `#version 300 es
precision highp float;
in vec3 a_position;
out vec3 v_probe;
void writeOut(out vec3 value) { value = vec3(1.0); }
void main() {
writeOut(v_probe);
gl_Position = vec4(a_position, 1.0);
}`;
const PROBE_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec3 v_probe;
out vec4 fragColor;
void main() { fragColor = vec4(v_probe, 1.0); }`;
/**
* True when the user agent is iOS or iPadOS.
*
* iPadOS 13+ defaults to desktop-mode browsing and reports `MacIntel` with no
* iPad token, so it is identified by touch points instead -- real Macs report
* `maxTouchPoints` of 0, including Apple Silicon ones that also claim
* `MacIntel`. Brave/Chrome/Firefox on iOS are all WebKit underneath and share
* the same Metal backend, so matching the platform rather than the browser is
* what we want.
*/
export function isAppleMobilePlatform(navigatorLike = globalThis.navigator) {
if (!navigatorLike) return false;
const ua = String(navigatorLike.userAgent || '');
if (/\b(iPad|iPhone|iPod)\b/.test(ua)) return true;
// Desktop-mode iPadOS presents as a Mac. What separates it from a real Mac
// is touch: Macs report 0 touch points, including Apple Silicon ones that
// also claim `MacIntel`. `navigator.platform` is deprecated and may be
// empty, so the UA's Macintosh token is accepted as an equal signal rather
// than relying on platform alone.
const platform = String(navigatorLike.platform || '');
const macLike =
platform === 'MacIntel' ||
platform === 'MacARM' ||
/\bMacintosh\b/.test(ua);
const touchPoints = Number(navigatorLike.maxTouchPoints) || 0;
return macLike && touchPoints > 1;
}
/**
* Link the probe program and report whether the driver rejects the pattern.
*
* Returns `false` when WebGL2 is unavailable so that a missing context is
* never mistaken for a broken driver -- Cesium has its own handling for that,
* and the caller still falls back to platform detection.
*/
export function probeOutParamVaryingLinkFailure(gl) {
if (!gl) return false;
const vs = gl.createShader(gl.VERTEX_SHADER);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
const program = gl.createProgram();
if (!vs || !fs || !program) return false;
try {
gl.shaderSource(vs, PROBE_VERTEX_SHADER);
gl.compileShader(vs);
gl.shaderSource(fs, PROBE_FRAGMENT_SHADER);
gl.compileShader(fs);
// A compile failure here is not the bug we are probing for: the driver
// rejects this pattern at LINK time, after both stages compile cleanly.
if (
!gl.getShaderParameter(vs, gl.COMPILE_STATUS) ||
!gl.getShaderParameter(fs, gl.COMPILE_STATUS)
)
return false;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
return !gl.getProgramParameter(program, gl.LINK_STATUS);
} catch {
return false;
} finally {
gl.deleteShader(vs);
gl.deleteShader(fs);
gl.deleteProgram(program);
}
}
/** Build a throwaway WebGL2 context for the probe, or null if unavailable. */
function defaultProbeContext() {
try {
const canvas =
typeof OffscreenCanvas === 'function'
? new OffscreenCanvas(1, 1)
: globalThis.document?.createElement('canvas');
return canvas?.getContext('webgl2') ?? null;
} catch {
return null;
}
}
/**
* Decide whether the per-vertex model atmosphere must be kept out of the
* pipeline on this device.
*
* The probe is authoritative when it fires, so a future driver fix silently
* restores the effect without a code change. Platform detection is the
* backstop for the case where no context can be created -- on Apple mobile a
* link failure is fatal and unrecoverable, so we would rather lose fog than
* gamble the whole render loop on the probe having run.
*/
export function shouldDisableModelAtmosphere({
navigatorLike = globalThis.navigator,
createProbeContext = defaultProbeContext,
} = {}) {
const gl = createProbeContext();
if (gl) {
let linkFails;
try {
linkFails = probeOutParamVaryingLinkFailure(gl);
} finally {
// Browsers cap live WebGL contexts (Safari especially); release the
// throwaway probe context now rather than waiting for GC.
try {
gl.getExtension?.('WEBGL_lose_context')?.loseContext();
} catch {
// A context that cannot be released is left to GC.
}
}
if (linkFails) return true;
// Probe ran and the driver accepted the pattern. Apple mobile is still
// quarantined: the probe program is far simpler than Cesium's, and a
// false negative there costs the entire scene.
return isAppleMobilePlatform(navigatorLike);
}
return isAppleMobilePlatform(navigatorLike);
}
/**
* Keep Cesium's broken model-atmosphere stage out of the pipeline.
*
* Returns true when the workaround was applied. Safe to call on any scene;
* scenes without a fog object are left untouched.
*/
export function applyModelAtmosphereWorkaround(scene, options = {}) {
if (!scene?.fog) return false;
if (!shouldDisableModelAtmosphere(options)) return false;
scene.fog.renderable = false;
return true;
}
+221
View File
@@ -0,0 +1,221 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isAppleMobilePlatform,
probeOutParamVaryingLinkFailure,
shouldDisableModelAtmosphere,
applyModelAtmosphereWorkaround,
} from './atmosphereCompat.js';
const IPAD_OS12 = {
userAgent:
'Mozilla/5.0 (iPad; CPU OS 12_5 like Mac OS X) AppleWebKit/605.1.15',
platform: 'iPad',
maxTouchPoints: 5,
};
// iPadOS 13+ desktop mode: no iPad token, reports as a Mac.
const IPAD_DESKTOP_MODE = {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
platform: 'MacIntel',
maxTouchPoints: 5,
};
const MAC_DESKTOP = {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
platform: 'MacIntel',
maxTouchPoints: 0,
};
const LINUX_DESKTOP = {
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/141',
platform: 'Linux x86_64',
maxTouchPoints: 0,
};
/** Minimal GL double. `linkOk: false` mimics the Metal translator's refusal. */
function fakeGl({ linkOk = true, compileOk = true, throwOn = null } = {}) {
const deleted = { shaders: 0, programs: 0 };
return {
deleted,
VERTEX_SHADER: 1,
FRAGMENT_SHADER: 2,
COMPILE_STATUS: 3,
LINK_STATUS: 4,
createShader: () => ({}),
createProgram: () => ({}),
shaderSource() {
if (throwOn === 'shaderSource') throw new Error('context lost');
},
compileShader() {},
attachShader() {},
linkProgram() {},
getShaderParameter: () => compileOk,
getProgramParameter: () => linkOk,
deleteShader: () => (deleted.shaders += 1),
deleteProgram: () => (deleted.programs += 1),
};
}
test('identifies iPadOS in both legacy and desktop-mode guises', () => {
assert.equal(isAppleMobilePlatform(IPAD_OS12), true);
assert.equal(isAppleMobilePlatform(IPAD_DESKTOP_MODE), true);
});
test('does not mistake a real Mac or a Linux desktop for Apple mobile', () => {
// Regression guard: the desktop-mode check keys on touch points, and a Mac
// reports the same MacIntel platform string with zero of them.
assert.equal(isAppleMobilePlatform(MAC_DESKTOP), false);
assert.equal(isAppleMobilePlatform(LINUX_DESKTOP), false);
});
test('treats a missing navigator as not-Apple rather than throwing', () => {
assert.equal(isAppleMobilePlatform(undefined), false);
assert.equal(isAppleMobilePlatform(null), false);
});
test('detects desktop-mode iPadOS without navigator.platform', () => {
// `navigator.platform` is deprecated and can be absent or stripped; the UA's
// Macintosh token plus touch points has to carry the detection on its own.
assert.equal(
isAppleMobilePlatform({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
}),
true,
);
// Same UA, no touch: a real Mac, which must stay untouched.
assert.equal(
isAppleMobilePlatform({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 0,
}),
false,
);
});
test('probe reports failure only when the program fails to link', () => {
assert.equal(
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: false })),
true,
);
assert.equal(
probeOutParamVaryingLinkFailure(fakeGl({ linkOk: true })),
false,
);
});
test('a compile failure is not reported as the link bug', () => {
// The Metal defect surfaces at link time; both stages compile cleanly.
// Treating a compile error as the bug would disable fog for the wrong reason.
assert.equal(
probeOutParamVaryingLinkFailure(
fakeGl({ compileOk: false, linkOk: false }),
),
false,
);
});
test('probe releases its GL objects on both paths', () => {
const ok = fakeGl({ linkOk: true });
probeOutParamVaryingLinkFailure(ok);
assert.equal(ok.deleted.shaders, 2);
assert.equal(ok.deleted.programs, 1);
const thrown = fakeGl({ throwOn: 'shaderSource' });
probeOutParamVaryingLinkFailure(thrown);
assert.equal(thrown.deleted.shaders, 2, 'shaders freed even when GL throws');
assert.equal(thrown.deleted.programs, 1);
});
test('probe treats a null context as healthy, not broken', () => {
assert.equal(probeOutParamVaryingLinkFailure(null), false);
});
test('a failing probe disables the stage on any platform', () => {
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => fakeGl({ linkOk: false }),
}),
true,
);
});
test('a passing probe still quarantines Apple mobile but frees desktops', () => {
const createProbeContext = () => fakeGl({ linkOk: true });
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: IPAD_DESKTOP_MODE,
createProbeContext,
}),
true,
);
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext,
}),
false,
);
});
test('falls back to platform detection when no context can be made', () => {
const createProbeContext = () => null;
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: IPAD_OS12,
createProbeContext,
}),
true,
);
assert.equal(
shouldDisableModelAtmosphere({
navigatorLike: MAC_DESKTOP,
createProbeContext,
}),
false,
);
});
test('applies to the scene by clearing renderable, never enabled', () => {
// `enabled` must survive: Fog.update short-circuits when it is false, which
// forfeits the density used for 3D Tiles screen-space-error scaling, and
// visualSettings' IR boost saves/restores that same flag.
const scene = { fog: { enabled: true, renderable: true } };
const applied = applyModelAtmosphereWorkaround(scene, {
navigatorLike: IPAD_DESKTOP_MODE,
createProbeContext: () => null,
});
assert.equal(applied, true);
assert.equal(scene.fog.renderable, false);
assert.equal(scene.fog.enabled, true);
});
test('leaves unaffected devices completely untouched', () => {
const scene = { fog: { enabled: true, renderable: true } };
const applied = applyModelAtmosphereWorkaround(scene, {
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => fakeGl({ linkOk: true }),
});
assert.equal(applied, false);
assert.equal(scene.fog.renderable, true);
});
test('tolerates a scene with no fog object', () => {
assert.equal(applyModelAtmosphereWorkaround({}, {}), false);
assert.equal(applyModelAtmosphereWorkaround(null, {}), false);
});
test('releases the throwaway probe context after probing', () => {
let lost = 0;
const gl = fakeGl({ linkOk: true });
gl.getExtension = (name) =>
name === 'WEBGL_lose_context' ? { loseContext: () => (lost += 1) } : null;
shouldDisableModelAtmosphere({
navigatorLike: LINUX_DESKTOP,
createProbeContext: () => gl,
});
assert.equal(lost, 1, 'probe context must not linger until GC');
});
+1
View File
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryAwarenessLayer: 'military-awareness',
militaryInstallationsLayer: 'military-installations',
rocketLaunchesLayer: 'rocket-launches',
localAdsbLayer: 'local-adsb',
});
/** Capture the ordered application instances and their serialization metadata. */
+2
View File
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'local-adsb',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
@@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.flightsLayer, layers[1]);
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[12]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+62 -3
View File
@@ -1,3 +1,7 @@
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -9,14 +13,17 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
import { createApplicationLaunches } from './layers/rocketLaunches.js';
import { createApplicationAlpr } from './layers/alprCameras.js';
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -42,10 +49,28 @@ const SOURCE_METHODS = Object.freeze({
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
});
/**
* Hardware-local layers are registered like any other but never enter share
* links or stored layer state: another browser cannot have this receiver.
*/
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
]);
/** Serialization metadata for every layer the application catalog constructs. */
export const APPLICATION_LAYER_METADATA = Object.freeze([
...LAYER_STATE_REGISTRY,
...LOCAL_ONLY_LAYER_METADATA,
]);
/** Construct the current catalog without choosing any source provider.
* Scene engines remain page-owned; layers and classification have this app's lifetime.
* The manager owns layer destruction, while abort releases classification even if startup fails.
@@ -54,7 +79,7 @@ export function createApplicationCatalog({
surface,
sources,
signal,
metadata = LAYER_STATE_REGISTRY,
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
resolveAsset,
nepalBoundaryResolver,
@@ -72,9 +97,11 @@ export function createApplicationCatalog({
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
@@ -110,16 +137,26 @@ export function createApplicationCatalog({
}),
flights,
military,
createApplicationLocalAdsb({
surface,
enrichment: sources.flights,
displayParams: () => flights.getParams(),
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationRecentImagery(),
vessels,
installations,
createApplicationAwareness({
@@ -128,6 +165,23 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind, clock: weatherClock }),
createWeatherLayer({
feed: sources.weather,
id: 'weather-radar',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-satellite',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -141,7 +195,12 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
return Object.freeze({
...catalog,
militaryRegistry,
surface,
weatherClock,
});
} catch (error) {
dispose();
throw error;
+29 -1
View File
@@ -39,8 +39,32 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.equal(first.layers.length, 29);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
{ id: 'local-adsb', disposition: 'local-only' },
'the hardware-local layer is never serialized into links',
);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
first.get('wind').getRowControls().info,
/Forecast · does not follow history/,
);
assert.equal(second.get('wind').getRowControls().summary.status, null);
for (const id of ['weather-radar', 'weather-satellite', 'weather-lightning'])
assert.equal(
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
order.slice(order.indexOf('traffic'), order.indexOf('directions') + 1),
['traffic', 'cctv', 'radio', 'transit', 'bikeshare', 'directions'],
);
assert.ok(first.get('bhote-koshi-2026'));
assert.ok(first.get('bhote-koshi-locator'));
const lifecycle = new LayerLifecycle({});
@@ -77,6 +101,10 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
false,
);
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
+5 -1
View File
@@ -1,5 +1,6 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -20,7 +21,9 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
const presentation = new LayerPresentation(dataManager, {
weatherClock: catalog?.weatherClock,
});
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
@@ -53,6 +56,7 @@ export function createApplicationData({
}
presentation.mount(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
defer(createCyberSonarScene(viewer, dataManager));
return { dataManager, catalog, presentation };
}
+9
View File
@@ -7,11 +7,13 @@ export class LayerPresentation {
constructor(
manager,
{
weatherClock,
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this.weatherClock = weatherClock;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
@@ -35,6 +37,7 @@ export class LayerPresentation {
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
weatherClock: this.weatherClock,
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
@@ -67,6 +70,12 @@ export class LayerPresentation {
mount(container) {
this.panel.mount(container);
}
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
attachRecentImagery(factory) {
// Detaching never builds a panel that teardown already released.
if (factory) this.panel.attachRecentImagery(factory);
else this._panel?.attachRecentImagery(null);
}
refresh() {
this._panel?._refreshTogglePanel();
}
+48
View File
@@ -0,0 +1,48 @@
import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
import { SdrController } from '../../sdr/controller.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as trails from '../../data/trailRenderer.js';
import * as geoid from '../../data/geoid.js';
import { markDetectionSourcesChanged } from '../../data/detection.js';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
/**
* Construct the Local ADS-B layer, the browser RTL-SDR session it shares with
* the Radio panel, and the decoder-feed session. Construction opens no device
* and makes no request; the SDR starts only from an explicit Connect and the
* feeds are polled only while the layer is enabled.
*
* `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference
* (`{ models3d, models3dMode }`) so local aircraft follow the same toggle;
* `enrichment` is the Flights source whose cached adsbdb proxy they share.
*/
export function createApplicationLocalAdsb({
receiver = new SdrController(),
feeds = createLocalReceiverFeeds(),
surface = null,
enrichment = null,
displayParams = null,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
} = {}) {
return createLocalAdsbLayer({
receiver,
feeds,
resolveAsset,
services: {
render,
context,
picking,
trails,
geoid,
groundSnap: surface?.groundSnap || null,
enrichment,
display: displayParams ? { getParams: displayParams } : null,
detection: { markSourcesChanged: markDetectionSourcesChanged },
overlays: { refreshReadout: refreshTrackedReadout },
},
});
}
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
import {
createFirePerimetersLayer,
createInciwebIndexSource,
createInciwebPublicationSource,
} from '../../layers/perimeters/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */
export function createApplicationFirePerimeters(options) {
return createFirePerimetersLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
return createRecentImageryLayer({
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
}),
thumbnails: createThumbnailLoader(),
});
}
+5 -1
View File
@@ -1,6 +1,9 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -52,6 +55,7 @@ export async function createApplicationScene({
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+41
View File
@@ -1,6 +1,8 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -21,6 +23,7 @@ export function createApplicationTools({
voice = {},
startChrome,
onSceneDirector,
sceneDataPacks,
signal,
defer,
}) {
@@ -28,6 +31,7 @@ export function createApplicationTools({
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, {
dataPacks: sceneDataPacks,
isMapStackAvailable: (id) =>
mapStackController?.isStackAvailable(id) === true,
});
@@ -51,6 +55,43 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
recentImagery.attachTileset(tileset);
const imageryBoxTool = initImageryBoxTool({
viewer,
onBox: (box) => recentImagery.setBox(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
onActive: (active) => recentImagery.setToolActive(active),
// The tool takes Escape in a capture listener, so the panel's order
// (clear a preview before cancelling the tool) is applied here.
onEscape: () => recentImagery.clearPreview(),
});
// The readout mounts in its rail body through the layer panel, like the
// weather readout.
data.presentation.attachRecentImagery((container) =>
createRecentImageryPanel({
container,
viewer,
layer: recentImagery,
tool: imageryBoxTool,
}),
);
// The live gate's handle (scripts/qa-recent-imagery.mjs).
const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
window.__gevRecentImagery = recentImageryHandle;
defer(() => {
if (window.__gevRecentImagery === recentImageryHandle)
delete window.__gevRecentImagery;
data.presentation.attachRecentImagery(null);
imageryBoxTool?.destroy();
});
}
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
+106
View File
@@ -1,4 +1,106 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
function boundedPinchDelta(delta) {
if (!Number.isFinite(delta) || delta === 0) return delta;
return (
Math.sign(delta) *
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
);
}
/**
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
*/
export function installTrackpadPinchZoom(
viewer,
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
) {
const controller = viewer?.scene?.screenSpaceCameraController;
const container = viewer?.container;
const canvas = viewer?.canvas;
if (!controller || !container || !canvas)
throw new TypeError('A complete Cesium viewer is required');
const originalZoomEventTypes = controller.zoomEventTypes;
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
? originalZoomEventTypes
: originalZoomEventTypes === undefined
? []
: [originalZoomEventTypes];
const alreadyHandlesControlWheel = zoomEventTypes.some(
(binding) =>
binding?.eventType === Cesium.CameraEventType.WHEEL &&
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
);
const configuredZoomEventTypes = alreadyHandlesControlWheel
? originalZoomEventTypes
: [
...zoomEventTypes,
{
eventType: Cesium.CameraEventType.WHEEL,
modifier: Cesium.KeyboardEventModifier.CTRL,
},
];
if (!alreadyHandlesControlWheel)
controller.zoomEventTypes = configuredZoomEventTypes;
const relayedEvents = new WeakSet();
const relayPinch = (event) => {
if (
!event.ctrlKey ||
relayedEvents.has(event) ||
event.deltaMode !== 0 ||
!Number.isFinite(event.deltaY) ||
event.deltaY === 0
)
return;
let relayed;
try {
relayed = createWheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: boundedPinchDelta(event.deltaY),
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: true,
bubbles: true,
cancelable: true,
view: globalThis.window,
});
} catch {
// The registered Ctrl+wheel binding can still consume the original.
return;
}
relayedEvents.add(relayed);
event.preventDefault();
event.stopPropagation();
canvas.dispatchEvent(relayed);
};
container.addEventListener('wheel', relayPinch, {
capture: true,
passive: false,
});
let active = true;
return () => {
if (!active) return;
active = false;
container.removeEventListener('wheel', relayPinch, true);
if (
!alreadyHandlesControlWheel &&
controller.zoomEventTypes === configuredZoomEventTypes
)
controller.zoomEventTypes = originalZoomEventTypes;
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
@@ -23,6 +125,10 @@ export function createApplicationViewer({ container, creditContainer }) {
});
try {
viewer.targetFrameRate = 60;
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
applyModelAtmosphereWorkaround(viewer.scene);
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = true;
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
+1 -1
View File
@@ -22,7 +22,7 @@ test('no browser-built module imports a Node core module', () => {
// import survives the build and turns into a runtime failure the moment the
// guard around it is wrong. src/data/naturalEarthRegions.js and
// src/data/neighborhoodPolygons.js both carried one to read their bundled
// JSON packs under node:test; an import attribute serves both runtimes.
// JSON packs under node:test; src/data/bundledJson.js now serves both runtimes.
const offenders = [];
for (const file of browserModules()) {
const source = readFileSync(file, 'utf8');
+8 -3
View File
@@ -1,3 +1,4 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { StyleManager } from './ui/applicationShell.js';
@@ -302,8 +303,8 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'this._navigation.destroy();',
'this._removeWorldRequestFocusListener?.();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
@@ -312,12 +313,16 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
test('teardown synchronously closes immediate camera entry points', () => {
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
ordered(LayerBindings.prototype.stop.toString(), [
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
], 'camera bindings close synchronously');
const navigation = body(
ui,
+7 -5
View File
@@ -342,11 +342,11 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
test('Cockpit Display portal retains both scroll owners across round trips', () => {
const portal = CockpitDisplayPortal.toString();
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\.scrollTop/);
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?displayPanelScroller\(this\.standardPanel\)\.scrollTop =\s*this\.standardScrollTop/);
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
@@ -408,7 +408,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
assert.match(entryPanels[1], new RegExp(`'${panelId}'`), `${panelId} must collapse on entry`);
}
assert.match(ui, /onEntered: \(\) => this\._panelChrome\.enterCockpit\(\)/);
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
const callback = { 1: shellMethod('enterCockpit').toString() };
assert.ok(callback, 'Cockpit onEntered callback is missing');
assert.match(
@@ -438,7 +439,8 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
'normal Context must not reopen over Cockpit',
);
assert.match(ui, /onExited: \(\) => this\._panelChrome\.exitCockpit\(\)/);
assert.match(ui, /onExited: \(\) => this\.exitPanels\(\)/);
assert.match(ui, /exitPanels: \(\) => this\._panelChrome\.exitCockpit\(\)/);
const exitCallback = { 1: shellMethod('exitCockpit').toString() };
assert.ok(exitCallback, 'Cockpit onExited callback is missing');
assert.match(
+44 -1
View File
@@ -17,6 +17,11 @@
* @param {number} [input.signalGap] Clearance above the briefing card.
* @param {number} [input.minTopFloor] Absolute ceiling for the strip, in px.
* @param {number} [input.minTopRatio] Viewport-relative ceiling, 0..1.
* @param {boolean} [input.signalCollapsed] Whether the briefing card is collapsed.
* @param {boolean} [input.utilityExpanded] Whether Display or Radio is expanded.
* @param {number} [input.bottomInset] Reserved viewport space below an expanded panel.
* @param {number} [input.signalHeight] Collapsed briefing header reserved below the utility.
* @param {boolean} [input.reserveViewportLane] Theme relocates the briefing below the expanded utility.
* @returns {{ top: number, maxHeight: number }}
*/
export function resolveCockpitUtilityAnchor({
@@ -29,13 +34,29 @@ export function resolveCockpitUtilityAnchor({
signalGap = 8,
minTopFloor = 96,
minTopRatio = 0.12,
signalCollapsed = false,
utilityExpanded = false,
bottomInset = 80,
signalHeight = 0,
reserveViewportLane = false,
}) {
const viewport = Math.max(0, Number(viewportHeight) || 0);
const minTop = Math.max(minTopFloor, viewport * minTopRatio);
const anchoredTop = Math.max(minTop, (Number(recBottom) || 0) + recGap);
const strip = Math.max(0, Number(stripHeight) || 0);
const signal = Number(signalTop);
const lowerBound = Number.isFinite(signal) ? signal : viewport;
const expandedLaneBottom = Math.max(
0,
viewport -
Math.max(0, Number(bottomInset) || 0) -
Math.max(0, Number(signalHeight) || 0),
);
const lowerBound =
reserveViewportLane && signalCollapsed && utilityExpanded
? expandedLaneBottom
: Number.isFinite(signal)
? signal
: viewport;
const clearedTop = lowerBound - signalGap - strip;
const top = Math.max(minTop, Math.min(anchoredTop, clearedTop));
const floor = Math.max(0, Number(collapsedHeight) || 0) || 50;
@@ -79,3 +100,25 @@ export function resolveCockpitUtilityLayout({
),
};
}
/** Shared Cyber panel slot, with footer controls above the lower frame (83vh). */
export function resolveCyberCockpitPanelLane({
viewportHeight,
top,
launcherHeight = 60,
signalHeight = 44,
contactHeight = 64,
}) {
const gap = 8;
const footerHeight = Math.max(
contactHeight + gap,
launcherHeight + signalHeight + gap * 2,
);
const bottom = viewportHeight * 0.82;
const panelHeight = Math.max(120, bottom - top - footerHeight);
return {
panelHeight,
utilityHeight: panelHeight + gap + launcherHeight,
signalTop: top + panelHeight + launcherHeight + gap * 2,
};
}
+78 -1
View File
@@ -4,11 +4,58 @@ import assert from 'node:assert/strict';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from './cockpitUtilityLayout.js';
// 1512x790, the height the strip used to collide with the briefing card at.
const desktop = { viewportHeight: 790, stripHeight: 107, collapsedHeight: 50 };
test('Cyber shared panel slot reserves both footer lanes above the decorative frame', () => {
for (const viewportHeight of [600, 673, 713, 986, 1376]) {
for (const signalHeight of [44, 76]) {
for (const contactHeight of [44, 84, 120]) {
const top = Math.max(164, Math.min(204, viewportHeight * 0.19));
const lane = resolveCyberCockpitPanelLane({ viewportHeight, top, signalHeight, contactHeight });
assert.ok(lane.panelHeight >= 120);
assert.ok(top + lane.panelHeight + 8 + contactHeight <= viewportHeight * 0.82 + .01);
assert.ok(lane.signalTop + signalHeight <= viewportHeight * 0.82 + .01);
assert.equal(lane.signalTop, top + lane.utilityHeight + 8);
}
}
}
});
test('Cyber keeps the Data Layers top while reserving briefing clearance', () => {
for (const viewportHeight of [673, 713, 986, 1376]) {
const leftTop = Math.max(164, Math.min(204, viewportHeight * 0.19));
const collapsed = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 128, collapsedHeight: 60, recBottom: 0,
signalTop: leftTop + 128 + 28, minTopFloor: leftTop, minTopRatio: 0,
});
assert.equal(collapsed.top, leftTop);
assert.ok(collapsed.maxHeight >= 128);
const expanded = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 700, collapsedHeight: 60, recBottom: 0,
signalTop: 255, minTopFloor: leftTop, minTopRatio: 0,
signalCollapsed: true, utilityExpanded: true, reserveViewportLane: true, signalHeight: 44,
});
assert.equal(expanded.top, leftTop);
assert.equal(expanded.top + expanded.maxHeight + 8 + 44 + 80, viewportHeight);
}
});
test('expanded utilities reserve the visible collapsed briefing header at every desktop height', () => {
for (const viewportHeight of [640, 720, 987, 1376]) {
for (const signalHeight of [48, 76, 100]) {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, viewportHeight, recBottom: 0, signalTop: 255,
signalCollapsed: true, utilityExpanded: true, signalHeight, reserveViewportLane: true,
});
assert.equal(top + maxHeight + 8, viewportHeight - 80 - signalHeight);
}
}
});
test('the strip hangs 12px under the REC readout when the briefing card leaves room', () => {
const { top } = resolveCockpitUtilityAnchor({ ...desktop, recBottom: 148.1, signalTop: 420 });
assert.equal(Number(top.toFixed(1)), 160.1);
@@ -54,6 +101,37 @@ test('a missing REC readout leaves the strip on the viewport ceiling', () => {
assert.equal(top, 96);
});
test('an expanded utility claims the vertical lane released by a collapsed briefing', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
utilityExpanded: true,
reserveViewportLane: true,
});
assert.equal(Number(top.toFixed(1)), 160.1);
assert.equal(Number(maxHeight.toFixed(1)), 541.9);
});
test('non-relocating themes preserve the visible briefing boundary even when collapsed', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, recBottom: 148.1, signalTop: 265.4,
signalCollapsed: true, utilityExpanded: true,
});
assert.equal(Number((top + maxHeight).toFixed(1)), 257.4);
});
test('a collapsed briefing still bounds the utility strip until a panel expands', () => {
const { maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
});
assert.equal(Number(maxHeight.toFixed(1)), 107);
});
test('keeps the collapsed sibling visible when both Cockpit utilities fit', () => {
assert.deepEqual(resolveCockpitUtilityLayout({
availableHeight: 320,
@@ -94,4 +172,3 @@ test('clamps malformed or short corridors to the minimum expanded height', () =>
expandedMaxHeight: 120,
});
});
+22 -4
View File
@@ -205,6 +205,7 @@ const RECOGNIZED = new Set([
// rail
'#right-context-rail',
'#right-context-rail.layout-focus',
'#right-context-rail[data-rail-measuring]',
// tray
'#command-dock .dock-popover-content',
'#command-dock #location-bar .dock-popover-content',
@@ -400,20 +401,26 @@ test('the model refuses every cascade construct it cannot resolve', () => {
complaints.push(`nested media queries on "${part}": ${rule.media.join(' && ')}`);
}
for (const decl of guarded) {
if (decl.important) complaints.push(`!important on ${decl.prop} of "${part}"`);
// Measurement is synchronous and restored before returning or painting.
// Only these two exact declarations earn the exemption tested below.
const railMeasurement = part === '#right-context-rail[data-rail-measuring]'
&& ((decl.prop === 'height' && decl.value === 'auto')
|| (decl.prop === 'max-height' && decl.value === 'none'));
if (decl.important && !railMeasurement) complaints.push(`!important on ${decl.prop} of "${part}"`);
if (decl.prop === 'inset' || decl.prop === 'margin' || decl.prop === 'all'
|| decl.prop.startsWith('inset-') || decl.prop.startsWith('margin-block')) {
complaints.push(`shorthand ${decl.prop} on "${part}" — the model reads longhands only`);
}
if (decl.prop === 'height' || decl.prop === 'max-height') {
// A capped height can override `bottom` and invalidate the measured
// dock/credit constants. Two exemptions, each earned by a test below:
// dock/credit constants. Exemptions are each earned by a test below:
// the rail's own max-height (resolved to `none` across the whole
// modelled band by the rail clearance test) and `.layout-focus`
// (proven inapplicable at <=720px by the mobile-mode test).
// (proven inapplicable at <=720px by the mobile-mode test), plus
// synchronous measurement, which is removed before any paint.
const railOwn = part === '#right-context-rail' && decl.prop === 'max-height';
const railFocus = part === '#right-context-rail.layout-focus';
if (!railOwn && !railFocus) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
if (!railOwn && !railFocus && !railMeasurement) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
}
if (decl.prop === 'transform' && /translateY|translate3d|matrix|scale\(/.test(decl.value)) {
const identity = decl.value === 'translateY(0) scale(1)';
@@ -490,6 +497,17 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
});
test('right rail measurement cannot persist into a painted attribution layout', () => {
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const start = rail.indexOf("stack.setAttribute('data-rail-measuring', '')");
const end = rail.indexOf("stack.removeAttribute('data-rail-measuring')", start);
assert.ok(start > rail.indexOf("layoutMode = 'mobile'"));
assert.ok(end > start);
const measurement = rail.slice(start, end);
assert.match(measurement, /try \{[\s\S]*\} finally \{/);
assert.doesNotMatch(measurement, /\b(?:await|return|yield)\b|requestAnimationFrame|setTimeout/);
});
// ── Clearance ───────────────────────────────────────────────────────────────
test('every open dock tray clears the required credit at every modelled viewport', () => {
+562
View File
@@ -0,0 +1,562 @@
/**
* Scanner design inspired by kk376's tactical naval sonar visual style:
* https://github.com/bilawalsidhu/gods-eye-view/pull/214
*
* This is a separate HUD/contact treatment, not a port of that PR's shader.
*/
export const CYBER_SONAR_PERIOD_MS = 5_200;
// Parked-scene wake-ups and shared overlays use the fleet cadence. Native
// contacts still compose after layer updates on each actual draw so owner
// color writes cannot flash through during camera/fleet animation.
export const CYBER_SONAR_RENDER_INTERVAL_MS = 80;
export const CYBER_SONAR_SWEEP_DEG = 24;
export const CYBER_SONAR_FEATHER_DEG = 10;
// The sector should still read as a clear acquisition pass, but contacts
// outside it must remain immediately legible against FLIR terrain. Icons and
// dots keep a strong operator-tunable opacity floor; labels retain an even
// brighter floor so typography never disappears between passes.
export const CYBER_SONAR_DIM_FACTOR = 0.84;
export const CYBER_SONAR_LABEL_DIM_FACTOR = 0.92;
export const CYBER_SONAR_DEFAULTS = Object.freeze({
rings: 8,
range: 100,
intensity: 70,
opacity: 84,
sector: 24,
});
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const primitiveColors = new WeakMap();
const sweepAnimations = new WeakMap();
/** Pin the compositor animation to the absolute clock used by contact sampling.
* A negative delay alone is relative to the animation's eventual start, which
* may be much later than theme initialization (or restart after HUD hiding).
* Cache the running animation: no per-frame style writes or animation queries.
*/
export function syncCyberSonarAnimation(
root = globalThis.document?.documentElement,
) {
if (!root) return;
const cached = sweepAnimations.get(root);
if (cached?.element.isConnected && cached.animation.playState !== 'idle')
return;
const element = root.querySelector?.('.hud-sonar');
const animation = element
?.getAnimations?.({ subtree: true })
.find((item) => item.animationName === 'cyber-sonar-sweep');
if (!animation) return;
// The document animation timeline and performance.now share the navigation
// time origin. Zero both offsets so delayed starts cannot shift the sector.
animation.effect.updateTiming({ delay: 0 });
animation.startTime = 0;
sweepAnimations.set(root, { element, animation });
}
const copyColor = (color) => {
if (!color || !Number.isFinite(color.alpha)) return null;
if (typeof color.clone === 'function') return color.clone();
return { ...color };
};
/** Read owner opacity before a hue-only restyle, never our composited opacity.
* An independently written color still wins, including real freshness changes.
*/
export function cyberSonarBaseAlpha(visual, property = 'color') {
const current = visual?.[property];
const state = primitiveColors.get(visual)?.[property];
const applied = state?.lastAppliedColor;
return applied &&
current &&
current.red === applied.red &&
current.green === applied.green &&
current.blue === applied.blue &&
current.alpha === applied.alpha
? state.base.alpha
: (current?.alpha ?? 1);
}
/** Apply the sector factor to a Cesium point/billboard without owning its base style. */
export function applyCyberSonarPrimitive(
visual,
factor,
active = true,
sampler,
property = 'color',
opaque = false,
) {
if (!visual) return;
const current = visual[property];
let slots = primitiveColors.get(visual);
let state = slots?.[property];
const ownsCurrentColor =
state &&
current &&
current.red === state.lastAppliedColor.red &&
current.green === state.lastAppliedColor.green &&
current.blue === state.lastAppliedColor.blue &&
current.alpha === state.lastAppliedColor.alpha;
if (!active) {
// An owner refresh between the last paint and teardown wins over our cache.
if (ownsCurrentColor)
visual[property] = state.undefinedBase
? undefined
: copyColor(state.base);
if (slots) delete slots[property];
return;
}
if (!current && property !== 'color') return;
const nextFactor = clamp01(factor);
// Cesium setters copy into a stable internal Color instead of retaining the
// assigned object. Compare its channels with our snapshot as well, so an
// owner's in-place style refresh is still recognized as a new base.
if (
ownsCurrentColor &&
Math.abs(nextFactor - state.lastAppliedFactor) <= 0.002 &&
state.floor === sampler?.floor
) {
return;
}
if (!ownsCurrentColor) {
if (!state) {
state = {
base: copyColor(current) || { red: 1, green: 1, blue: 1, alpha: 1 },
lastAppliedFactor: Number.NaN,
};
if (!slots) primitiveColors.set(visual, (slots = Object.create(null)));
slots[property] = state;
} else {
state.base.red = current?.red ?? 1;
state.base.green = current?.green ?? 1;
state.base.blue = current?.blue ?? 1;
state.base.alpha = current?.alpha ?? 1;
}
state.undefinedBase = current === undefined;
}
const next = state.lastAppliedColor || copyColor(state.base);
if (!next) return;
const tint =
property === 'outlineColor' || property === 'backgroundColor'
? state.base
: sampler
? sampler.tint(state.base, nextFactor, next)
: cyberSonarTintColor(state.base, nextFactor);
next.red = tint.red * (opaque ? nextFactor : 1);
next.green = tint.green * (opaque ? nextFactor : 1);
next.blue = tint.blue * (opaque ? nextFactor : 1);
// Keep models in their owner's opaque/translucent render pass. Dimming RGB
// avoids rebuilding model draw commands twice on every sector crossing.
next.alpha = state.base.alpha * (opaque ? 1 : nextFactor);
visual[property] = next;
state.appliedColor = visual[property];
state.lastAppliedColor = next;
state.lastAppliedFactor = nextFactor;
state.floor = sampler?.floor;
}
/** One settings read and one clock/viewport calculation per paint, not contact. */
export function createCyberSonarSampler(
width,
height,
now,
root = globalThis.document?.documentElement,
) {
if (now === undefined) now = globalThis.performance?.now?.() ?? 0;
const settings = readCyberSonarSettings(root);
const floor = settings.opacity / 100;
const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100;
const radiusSquared = radius * radius;
const angle = cyberSonarAngleDeg(now);
const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42);
return {
floor,
neutral: floor + (1 - floor) * 0.46,
at(x, y) {
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
width <= 0 ||
height <= 0
)
return 1;
const dx = x - width / 2,
dy = y - height / 2;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared > radiusSquared) return floor;
if (distanceSquared < radiusSquared * 0.000625) return 1;
const delta =
((((Math.atan2(dx, -dy) * 180) / Math.PI - angle) % 360) + 360) % 360;
if (delta > settings.sector) return floor;
return (
floor + (1 - floor) * Math.min(1, (settings.sector - delta) / feather)
);
},
label(factor) {
return (1 + factor) * 0.5;
},
tint(base, factor, out) {
const coverage =
floor >= 0.9999 ? 1 : clamp01((factor - floor) / (1 - floor));
out.red = base.red * 0.28 + (0.62 + 0.38 * coverage) * 0.72;
out.green = base.green * 0.28 + (0.68 + 0.16 * coverage) * 0.72;
out.blue = base.blue * 0.28 + (0.72 + 0.12 * coverage) * 0.72;
return out;
},
};
}
/** Remaining delay before the next contact-projection refresh is due. */
export function cyberSonarFrameDelay(
lastPaintAt,
nowMs,
intervalMs = CYBER_SONAR_RENDER_INTERVAL_MS,
) {
if (
!Number.isFinite(lastPaintAt) ||
!Number.isFinite(nowMs) ||
!Number.isFinite(intervalMs) ||
intervalMs <= 0
) {
return 0;
}
return Math.max(0, intervalMs - (nowMs - lastPaintAt));
}
const datasetNumber = (root, key, fallback) => {
const value = Number(root?.dataset?.[key]);
return Number.isFinite(value) ? value : fallback;
};
export function readCyberSonarSettings(
root = globalThis.document?.documentElement,
) {
return {
rings: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRings', CYBER_SONAR_DEFAULTS.rings),
3,
12,
),
),
range: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRange', CYBER_SONAR_DEFAULTS.range),
60,
120,
),
),
intensity: Math.round(
clamp(
datasetNumber(
root,
'cyberSonarIntensity',
CYBER_SONAR_DEFAULTS.intensity,
),
0,
100,
),
),
opacity: Math.round(
clamp(
datasetNumber(root, 'cyberSonarOpacity', CYBER_SONAR_DEFAULTS.opacity),
35,
100,
),
),
sector: Math.round(
clamp(
datasetNumber(root, 'cyberSonarSector', CYBER_SONAR_DEFAULTS.sector),
8,
60,
),
),
};
}
export function applyCyberSonarSettings(
settings,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset || !root?.style) return readCyberSonarSettings(root);
const next = {
...readCyberSonarSettings(root),
...(settings || {}),
};
// Ignore stale session input from the removed effect selector.
delete next.mode;
delete root.dataset.cyberSonarMode;
next.rings = Math.round(
clamp(Number(next.rings) || CYBER_SONAR_DEFAULTS.rings, 3, 12),
);
next.range = Math.round(
clamp(Number(next.range) || CYBER_SONAR_DEFAULTS.range, 60, 120),
);
next.intensity = Math.round(
clamp(
Number.isFinite(Number(next.intensity))
? Number(next.intensity)
: CYBER_SONAR_DEFAULTS.intensity,
0,
100,
),
);
next.opacity = Math.round(
clamp(
Number.isFinite(Number(next.opacity))
? Number(next.opacity)
: CYBER_SONAR_DEFAULTS.opacity,
35,
100,
),
);
next.sector = Math.round(
clamp(Number(next.sector) || CYBER_SONAR_DEFAULTS.sector, 8, 60),
);
root.dataset.cyberSonarRings = String(next.rings);
root.dataset.cyberSonarRange = String(next.range);
root.dataset.cyberSonarIntensity = String(next.intensity);
root.dataset.cyberSonarOpacity = String(next.opacity);
root.dataset.cyberSonarSector = String(next.sector);
root.style.setProperty('--cyber-sonar-ring-step', `${100 / next.rings}%`);
root.style.setProperty(
'--cyber-sonar-size-vw',
`${(80 * next.range) / 100}vw`,
);
root.style.setProperty(
'--cyber-sonar-size-vh',
`${(104 * next.range) / 100}vh`,
);
root.style.setProperty(
'--cyber-sonar-visual-intensity',
String(0.22 + 0.5 * (next.intensity / 100)),
);
root.style.setProperty(
'--cyber-sonar-sector-quarter',
`${next.sector * 0.25}deg`,
);
root.style.setProperty(
'--cyber-sonar-sector-middle',
`${next.sector * 0.6}deg`,
);
root.style.setProperty('--cyber-sonar-sector-width', `${next.sector}deg`);
return next;
}
const cyberSonarDimFactor = (root = globalThis.document?.documentElement) =>
readCyberSonarSettings(root).opacity / 100;
const cyberSonarLabelDimFactor = (
root = globalThis.document?.documentElement,
) => {
const contactFloor = cyberSonarDimFactor(root);
return Number((contactFloor + (1 - contactFloor) * 0.5).toFixed(4));
};
export function cyberSonarNeutralIntensity(
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
return dimFactor + (1 - dimFactor) * 0.46;
}
export function cyberSonarTintColor(
baseColor,
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const remainingRange = 1 - dimFactor;
const coverage =
remainingRange <= 0.0001
? 1
: clamp01((contactIntensity - dimFactor) / remainingRange);
const target = {
red: 0.62 + 0.38 * coverage,
green: 0.68 + 0.16 * coverage,
blue: 0.72 + 0.12 * coverage,
};
const retain = 0.28;
return {
red: (baseColor?.red ?? 1) * retain + target.red * (1 - retain),
green: (baseColor?.green ?? 1) * retain + target.green * (1 - retain),
blue: (baseColor?.blue ?? 1) * retain + target.blue * (1 - retain),
alpha: baseColor?.alpha ?? 1,
};
}
const normalizedPhaseMs = (nowMs) => {
if (!Number.isFinite(nowMs)) return 0;
return (
((nowMs % CYBER_SONAR_PERIOD_MS) + CYBER_SONAR_PERIOD_MS) %
CYBER_SONAR_PERIOD_MS
);
};
/** Clockwise sweep heading, where 0 degrees is the top of the keyhole. */
export function cyberSonarAngleDeg(nowMs) {
return (normalizedPhaseMs(nowMs) / CYBER_SONAR_PERIOD_MS) * 360;
}
/** Negative CSS animation delay that synchronizes the sector to the shared clock. */
export function cyberSonarAnimationDelay(nowMs) {
return `${-normalizedPhaseMs(nowMs)}ms`;
}
/**
* Brightness/alpha multiplier for one screen-projected contact.
* Invalid projections fail open so a render edge case never hides a contact.
*/
export function cyberSonarIntensityAtScreenPoint(
x,
y,
viewportWidth,
viewportHeight,
nowMs,
root = globalThis.document?.documentElement,
) {
if (
![x, y, viewportWidth, viewportHeight, nowMs].every(Number.isFinite) ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return 1;
}
const centerX = viewportWidth / 2;
const centerY = viewportHeight / 2;
const dx = x - centerX;
const dy = y - centerY;
const settings = readCyberSonarSettings(root);
const dimFactor = cyberSonarDimFactor(root);
const radius =
Math.min(viewportWidth * 0.4, viewportHeight * 0.52) *
(settings.range / 100);
const distance = Math.hypot(dx, dy);
if (distance > radius) return dimFactor;
if (distance < radius * 0.025) return 1;
const pointAngle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
const delta = (pointAngle - cyberSonarAngleDeg(nowMs) + 360) % 360;
const sweepDeg = settings.sector;
const featherDeg = Math.min(CYBER_SONAR_FEATHER_DEG, sweepDeg * 0.42);
if (delta > sweepDeg) return dimFactor;
const featherStart = sweepDeg - featherDeg;
const coverage =
delta <= featherStart ? 1 : clamp01((sweepDeg - delta) / featherDeg);
return dimFactor + (1 - dimFactor) * coverage;
}
/** Preserve label legibility while keeping it synchronized with the stronger contact sweep. */
export function cyberSonarLabelIntensity(
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const labelDimFactor = cyberSonarLabelDimFactor(root);
const remainingRange = 1 - dimFactor;
if (remainingRange <= 0.0001) return 1;
const normalized = clamp01((contactIntensity - dimFactor) / remainingRange);
return labelDimFactor + (1 - labelDimFactor) * normalized;
}
export function isCyberSonarEnabled(
root = globalThis.document?.documentElement,
) {
return root?.dataset?.cyberSonar !== 'off';
}
export function setCyberSonarEnabled(
enabled,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset) return false;
root.dataset.cyberSonar = enabled ? 'on' : 'off';
if (root.dataset.uiTheme === 'cyber') {
applyCyberSonarThemePhase(root, enabled);
}
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
// parameter initializers can otherwise lose the method's receiver binding.
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
if (reducedMotion === undefined)
reducedMotion =
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
false;
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
}
export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode')
);
}
// Unsupported engines retain their native contact rendering, without a dimmer
// fallback. Decorative rings remain independent from the contact adapter.
export function isCyberContactThemeActive(options = {}) {
const root = options.root ?? globalThis.document?.documentElement;
return (
isCyberMapThemeActive(options) &&
root?.dataset?.cyberSonarGpu !== 'unsupported'
);
}
export function isCyberContactSonarActive(options = {}) {
return isCyberContactThemeActive(options) && isCyberSonarActive(options);
}
/** Set or clear the shared visual phase used by the sector and contact sweep. */
export function applyCyberSonarThemePhase(root, enabled, nowMs) {
if (!root?.style) return;
if (!enabled || !isCyberSonarEnabled(root)) {
root.style.removeProperty?.('--cyber-sonar-angle');
root.style.removeProperty?.('--cyber-sonar-phase-delay');
return;
}
const clock = Number.isFinite(nowMs)
? nowMs
: (globalThis.performance?.now?.() ?? 0);
// The visual sweep is compositor-animated. Writing a custom property on the
// document root every frame invalidates styles across the entire HUD, so JS
// only seeds the shared clock when the theme or sonar state changes.
root.style.removeProperty?.('--cyber-sonar-angle');
const currentDelay = root.style
.getPropertyValue?.('--cyber-sonar-phase-delay')
?.trim();
// setVariant() may be called repeatedly with the already-active layout.
// Preserve the running animation's original timeline in that case; changing
// its negative delay after it has started would shift the sector off the
// absolute clock used by contact highlighting.
if (currentDelay) return;
root.style.setProperty?.(
'--cyber-sonar-phase-delay',
cyberSonarAnimationDelay(clock),
);
}
+370
View File
@@ -0,0 +1,370 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { transformWithEsbuild } from 'vite';
import { BillboardCollection, Color, PointPrimitive } from 'cesium';
import {
syncCyberSonarAnimation,
applyCyberSonarPrimitive,
cyberSonarBaseAlpha,
applyCyberSonarThemePhase,
cyberSonarAngleDeg,
cyberSonarAnimationDelay,
cyberSonarFrameDelay,
cyberSonarIntensityAtScreenPoint,
cyberSonarLabelIntensity,
cyberSonarTintColor,
applyCyberSonarSettings,
CYBER_SONAR_DIM_FACTOR,
CYBER_SONAR_LABEL_DIM_FACTOR,
CYBER_SONAR_PERIOD_MS,
CYBER_SONAR_RENDER_INTERVAL_MS,
isCyberSonarEnabled,
isCyberSonarActive,
setCyberSonarEnabled,
} from './cyberSonar.js';
test('production-transformed sonar defaults retain the document method receiver', async () => {
const source = await readFile(
new URL('./cyberSonar.js', import.meta.url),
'utf8',
);
const { code } = await transformWithEsbuild(source, 'cyberSonar.js', {
target: 'es2019',
minify: true,
});
const compiled = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
const hud = { classList: { contains: (name) => name === 'active' } };
const document = {
documentElement: { dataset: { uiTheme: 'cyber' } },
body: { classList: { contains: () => false } },
getElementById(id) {
assert.equal(this, document);
assert.equal(id, 'intel-hud');
return hud;
},
};
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: document,
});
try {
assert.equal(compiled.isCyberSonarActive(), true);
assert.equal(compiled.isCyberMapThemeActive(), true);
assert.ok(
Number.isFinite(compiled.createCyberSonarSampler(800, 600).at(400, 300)),
);
document.documentElement.dataset.uiTheme = 'tactical';
assert.equal(compiled.isCyberSonarActive(), false);
assert.equal(compiled.isCyberMapThemeActive(), false);
} finally {
if (previous) Object.defineProperty(globalThis, 'document', previous);
else delete globalThis.document;
}
});
test('hue-only aircraft restyles retain owner alpha across sonar opacity changes', () => {
const collection = new BillboardCollection();
const visual = collection.add({ color: Color.WHITE.withAlpha(0.8) });
try {
for (const opacity of [0.84, 0.35, 0.81, 1, 0.84]) {
for (let i = 0; i < 8; i++) {
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, opacity);
assert.equal(visual.color.alpha, 0.8 * opacity);
}
}
// A true owner freshness update must not be mistaken for our old alpha.
visual.color = Color.YELLOW.withAlpha(0.4);
assert.equal(cyberSonarBaseAlpha(visual), 0.4);
applyCyberSonarPrimitive(visual, 0.35);
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, 0.84);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, Color.CYAN.withAlpha(0.4)));
} finally {
collection.destroy();
}
});
test('the Cyber sonar clock advances clockwise and wraps cleanly', () => {
assert.equal(cyberSonarAngleDeg(0), 0);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS / 4), 90);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS), 0);
assert.equal(cyberSonarAnimationDelay(CYBER_SONAR_PERIOD_MS / 2), '-2600ms');
});
test('contact refresh cadence waits only until the next bounded sonar frame', () => {
assert.equal(CYBER_SONAR_RENDER_INTERVAL_MS, 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_000), 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_035), 45);
assert.equal(cyberSonarFrameDelay(1_000, 1_080), 0);
assert.equal(cyberSonarFrameDelay(Number.NEGATIVE_INFINITY, 1_000), 0);
});
test('the sweep brightens contacts inside its sector and dims the remainder', () => {
const width = 1_000;
const height = 800;
const centerX = width / 2;
const centerY = height / 2;
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 200, width, height, 0),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX + 200, centerY, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(
centerX + 200,
centerY,
width,
height,
CYBER_SONAR_PERIOD_MS / 4,
),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 401, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(Number.NaN, 0, width, height, 0),
1,
);
});
test('the sonar activates only for a visible Cyber map HUD with motion enabled', () => {
const root = { dataset: { uiTheme: 'cyber' } };
const classes = (...values) => ({
contains: (value) => values.includes(value),
});
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
true,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes('cockpit-mode') },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: true,
}),
false,
);
root.dataset.cyberSonar = 'off';
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
});
test('labels keep a more readable baseline while tracking the same sweep', () => {
assert.equal(CYBER_SONAR_DIM_FACTOR, 0.84);
assert.equal(CYBER_SONAR_LABEL_DIM_FACTOR, 0.92);
assert.equal(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR),
CYBER_SONAR_LABEL_DIM_FACTOR,
);
assert.equal(cyberSonarLabelIntensity(1), 1);
assert.ok(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR) > CYBER_SONAR_DIM_FACTOR,
);
});
test('the opacity setting controls every contact floor and preserves brighter labels', () => {
const values = new Map();
const root = {
dataset: {},
style: { setProperty: (name, value) => values.set(name, value) },
};
const settings = applyCyberSonarSettings({ opacity: 62 }, root);
assert.equal(settings.opacity, 62);
assert.equal(root.dataset.cyberSonarOpacity, '62');
assert.equal(
cyberSonarIntensityAtScreenPoint(900, 400, 1_000, 800, 0, root),
0.62,
);
assert.equal(cyberSonarLabelIntensity(0.62, root), 0.81);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
applyCyberSonarSettings({ opacity: 100 }, root);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
assert.deepEqual(
cyberSonarTintColor({ red: 1, green: 1, blue: 1, alpha: 1 }, 1, root),
{ red: 1, green: 0.8848, blue: 0.8848, alpha: 1 },
);
});
test('the sonar control defaults on and records explicit operator state', () => {
const root = { dataset: {} };
assert.equal(isCyberSonarEnabled(root), true);
assert.equal(setCyberSonarEnabled(false, root), false);
assert.equal(root.dataset.cyberSonar, 'off');
assert.equal(isCyberSonarEnabled(root), false);
assert.equal(setCyberSonarEnabled(true, root), true);
assert.equal(root.dataset.cyberSonar, 'on');
});
test('real Cesium points and billboards recover after repeated low/high opacity cycles', () => {
const collection = new BillboardCollection();
const base = new Color(0.7, 0.5, 0.25, 0.8);
const visuals = [
new PointPrimitive({ color: base }),
collection.add({ color: base }),
];
try {
for (const visual of visuals) {
for (const factor of [0.35, 1, 0.5, 1, 0.35]) {
for (let frame = 0; frame < 60; frame++) {
applyCyberSonarPrimitive(visual, factor);
}
assert.equal(visual.color.alpha, base.alpha * factor);
}
// Owner setters mutate the same Cesium Color object, even when the new
// alpha equals the last sonar output. Its new RGB must become the base.
const refreshed = new Color(0.2, 0.8, 0.4, visual.color.alpha);
visual.color = refreshed;
applyCyberSonarPrimitive(visual, 0.5);
assert.equal(visual.color.alpha, refreshed.alpha * 0.5);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, refreshed));
}
} finally {
collection.destroy();
}
});
test('generic Cesium primitives dim, accept owner style refreshes, and restore', () => {
const makeColor = (alpha, red = 1) => ({
red,
green: 0.5,
blue: 0.25,
alpha,
clone() {
return makeColor(this.alpha, this.red);
},
});
const visual = { color: makeColor(0.8) };
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color.alpha, 0.2);
const paintedColor = visual.color;
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color, paintedColor);
visual.color = makeColor(0.6, 0.7);
applyCyberSonarPrimitive(visual, 0.5, true);
assert.equal(visual.color.alpha, 0.3);
assert.notEqual(visual.color.red, 0.7);
applyCyberSonarPrimitive(visual, 1, false);
assert.equal(visual.color.alpha, 0.6);
assert.equal(visual.color.red, 0.7);
});
test('Cyber contact tint moves from cool-muted to warm-bright with the sector', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const base = { red: 0.2, green: 0.8, blue: 1, alpha: 0.75 };
const dim = cyberSonarTintColor(base, CYBER_SONAR_DIM_FACTOR, root);
const bright = cyberSonarTintColor(base, 1, root);
assert.ok(bright.red > dim.red);
assert.ok(bright.green > dim.green);
assert.equal(bright.alpha, 0.75);
});
test('unswept white map symbols retain a bright readable Cyber tint', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const dim = cyberSonarTintColor(
{ red: 1, green: 1, blue: 1, alpha: 1 },
CYBER_SONAR_DIM_FACTOR,
root,
);
assert.ok(dim.red >= 0.72);
assert.ok(dim.green >= 0.76);
assert.ok(dim.blue >= 0.79);
assert.equal(dim.alpha, 1);
});
test('the sweep corrects delayed starts and reacquires restarted animations without per-frame writes', () => {
let queries = 0,
writes = 0;
const makeAnimation = () => ({
animationName: 'cyber-sonar-sweep',
playState: 'running',
startTime: 1800,
effect: {
updateTiming(value) {
assert.deepEqual(value, { delay: 0 });
writes++;
},
},
});
let animation = makeAnimation();
let element = {
isConnected: true,
getAnimations() {
queries++;
return [animation];
},
};
const root = { querySelector: () => element };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
for (let i = 0; i < 100; i++) syncCyberSonarAnimation(root);
assert.equal(queries, 1);
assert.equal(writes, 1);
animation.playState = 'idle';
animation = makeAnimation();
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 2);
element.isConnected = false;
animation = makeAnimation();
element = { isConnected: true, getAnimations: () => [animation] };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 3);
assert.doesNotThrow(() => syncCyberSonarAnimation({}));
});
test('the selected Cyber theme seeds and releases the compositor sweep phase', () => {
const values = new Map();
const root = {
dataset: {},
style: {
setProperty: (name, value) => values.set(name, value),
removeProperty: (name) => values.delete(name),
getPropertyValue: (name) => values.get(name) || '',
},
};
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 4);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 2);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, false, 0);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.has('--cyber-sonar-phase-delay'), false);
});
+295
View File
@@ -0,0 +1,295 @@
import * as Cesium from 'cesium';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or
// access EntityCluster's private collections. An engine upgrade must revalidate
// this adapter; unsupported versions keep the original commands unchanged.
export const CYBER_SONAR_CESIUM_VERSION = '1.138.0';
// Follow every public DrawCommand setter, including pick/shadow/culling flags.
// Copying native.dirty would keep derived pipelines dirty forever: the scene
// consumes our command, so it never clears that flag on the native command.
const commandProperties = Object.entries(
Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype),
)
.filter(
([key, descriptor]) =>
descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap',
)
.map(([key]) => key);
export function syncCyberSonarCommand(
native,
record,
program,
uniforms,
labelBackground,
) {
if (!record.command) {
record.command = new Cesium.DrawCommand();
record.uniformMap = {
...uniforms,
u_gevSonarLabelBackground: () => record.labelBackground,
};
}
const command = record.command;
record.labelBackground = labelBackground;
// Uniform values (angle/viewport/etc.) change through stable callbacks, not
// new maps/functions. Owner callback replacement and removal still propagate.
for (const key in record.uniformMap) {
if (
!(key in uniforms) &&
key !== 'u_gevSonarLabelBackground' &&
!(key in native.uniformMap)
) {
delete record.uniformMap[key];
command.dirty = true;
}
}
for (const key in native.uniformMap) {
if (record.uniformMap[key] !== native.uniformMap[key]) {
record.uniformMap[key] = native.uniformMap[key];
command.dirty = true;
}
}
for (const key of commandProperties) command[key] = native[key];
command.shaderProgram = program;
command.uniformMap = record.uniformMap;
return command;
}
export function sonarVertexSource(source, kind) {
const text = source.sources.join('\n');
const attribute =
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
if (
!text.includes(attribute) ||
!text.includes(lowAttribute) ||
!text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text)
)
return null;
const next = source.clone();
next.sources = [
text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'),
`
uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled
uniform vec2 u_gevSonarViewport;
uniform float u_gevSonarSector;
uniform float u_gevSonarLabelBackground;
void main() {
gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z;
float factor = floorValue + (1.0 - floorValue) * 0.46;
if (u_gevSonar.w > 0.5) {
float distanceSquared = dot(pixel, pixel);
float radiusSquared = u_gevSonar.y * u_gevSonar.y;
float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0);
if (delta > 359.9999) delta = 0.0;
factor = floorValue;
if (distanceSquared < radiusSquared * 0.000625) factor = 1.0;
else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector)
factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42));
}
#ifdef SDF
factor = (1.0 + factor) * 0.5;
#else
if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5;
#endif
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
if (u_gevSonarLabelBackground < 0.5)
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main().
v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
}
`,
];
return next;
}
// Use native batch bounds, never individual contacts, for idle render demand.
// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta).
export function isCyberSonarCommandVisible(command, frameState, culling) {
const bounds = command.boundingVolume;
if (!bounds || !command.cull) return true;
culling ??= new Cesium.CullingVolume(
frameState.cullingVolume.planes.slice(0, 5),
);
if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE)
return false;
return !(
frameState.mode === Cesium.SceneMode.SCENE3D &&
frameState.occluder &&
command.occlude &&
bounds.isOccluded(frameState.occluder)
);
}
/** Work scales with draw commands, not the number of contacts in a collection.
* Source programs, colors, pick IDs, render states and other scenes stay owned
* by Cesium. Only this scene's rendered command slice receives derived shaders.
*/
export function createCyberSonarGpu(scene, readFrame) {
const root = scene.primitives;
const original = root.update;
const programs = new Map();
let commands = new WeakMap();
let disposed = false;
const stats = {
commands: 0,
visibleCommands: 0,
programs: 0,
supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION,
error: null,
};
const uniforms = new Cesium.Cartesian4();
const viewport = new Cesium.Cartesian2();
let sector = 24;
const sonarUniforms = {
u_gevSonar: () => uniforms,
u_gevSonarViewport: () => viewport,
u_gevSonarSector: () => sector,
};
const culling = new Cesium.CullingVolume();
const used = new Set();
const replaced = [];
const release = () => {
for (const { program } of programs.values())
if (!program.isDestroyed()) program.destroy();
programs.clear();
commands = new WeakMap();
used.clear();
replaced.length = 0;
stats.programs = 0;
};
const wrapped = function (frameState) {
const start = frameState.commandList.length;
original.call(this, frameState);
stats.commands = 0;
stats.visibleCommands = 0;
if (
disposed ||
!stats.supported ||
!frameState.passes.render ||
frameState.passes.pick
)
return;
const frame = readFrame();
if (!frame.enabled) {
if (programs.size) release();
return;
}
viewport.x = scene.drawingBufferWidth;
viewport.y = scene.drawingBufferHeight;
uniforms.x = frame.angle;
uniforms.y =
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
uniforms.z = frame.opacity / 100;
uniforms.w = frame.active ? 1 : 0;
sector = frame.sector;
used.clear();
replaced.length = 0;
if (frameState.cullingVolume)
for (let i = 0; i < 5; i++)
culling.planes[i] = frameState.cullingVolume.planes[i];
try {
for (let i = start; i < frameState.commandList.length; i++) {
const native = frameState.commandList[i];
const kind =
native.owner instanceof Cesium.PointPrimitiveCollection
? 'point'
: native.owner instanceof Cesium.BillboardCollection
? 'billboard'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested
// engine pick association distinguishes them without enumerating labels
// or reaching into EntityCluster. One representative per draw batch;
// Cesium's label glyph/background collections are homogeneous.
const labelBackground =
kind === 'billboard' &&
!source.vertexShaderSource.defines.includes('SDF') &&
native.owner.length > 0 &&
native.owner.get(0).pickPrimitive instanceof Cesium.Label
? 1
: 0;
let entry = programs.get(source);
if (!entry) {
const vertex = sonarVertexSource(source.vertexShaderSource, kind);
if (!vertex) throw new Error('Unsupported native contact shader');
const attributes = Object.fromEntries(
Object.entries(source.vertexAttributes).map(([name, value]) => [
name,
value.index,
]),
);
const program = Cesium.ShaderProgram.fromCache({
context: frameState.context,
vertexShaderSource: vertex,
fragmentShaderSource: source.fragmentShaderSource,
attributeLocations: attributes,
});
// Compile while errors can still leave the whole native command slice
// intact, rather than stopping the viewer's render loop at draw time.
try {
void program.allUniforms;
} catch (error) {
program.destroy();
throw error;
}
programs.set(source, (entry = { program }));
}
used.add(source);
let record = commands.get(native);
if (!record) commands.set(native, (record = {}));
const derived = syncCyberSonarCommand(
native,
record,
entry.program,
sonarUniforms,
labelBackground,
);
replaced.push(i, native);
frameState.commandList[i] = derived;
stats.commands++;
if (isCyberSonarCommandVisible(native, frameState, culling))
stats.visibleCommands++;
}
for (const [source, entry] of programs)
if (!used.has(source)) {
if (!entry.program.isDestroyed()) entry.program.destroy();
programs.delete(source);
}
stats.programs = programs.size;
} catch (error) {
// Recover the original commands already replaced in this slice.
for (let i = 0; i < replaced.length; i += 2)
frameState.commandList[replaced[i]] = replaced[i + 1];
stats.supported = false;
stats.commands = 0;
stats.visibleCommands = 0;
stats.error = String(error?.message || error);
release();
}
};
root.update = wrapped;
return {
stats,
destroy() {
if (disposed) return;
disposed = true;
if (root.update === wrapped && !root.isDestroyed?.())
root.update = original;
release();
},
};
}
+354
View File
@@ -0,0 +1,354 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarGpu,
sonarVertexSource,
CYBER_SONAR_CESIUM_VERSION,
isCyberSonarCommandVisible,
syncCyberSonarCommand,
} from './cyberSonarGpu.js';
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
const native = new Cesium.DrawCommand({
count: 6,
uniformMap: { value: () => 1 },
});
const record = {},
program = {},
uniforms = { u_gevSonar: () => 42 };
const derived = syncCyberSonarCommand(native, record, program, uniforms, 0);
const map = derived.uniformMap;
derived.dirty = false;
derived.lastDirtyTime = 7;
assert.equal(native.dirty, true);
assert.equal(
syncCyberSonarCommand(native, record, program, uniforms, 1),
derived,
);
assert.equal(
derived.dirty,
false,
'stale native dirty flag must not rebuild pipelines',
);
assert.equal(derived.lastDirtyTime, 7);
assert.equal(derived.uniformMap, map);
assert.equal(map.u_gevSonarLabelBackground(), 1);
for (const [key, value] of Object.entries({
count: 12,
offset: 2,
instanceCount: 3,
pickId: 'pick',
pass: Cesium.Pass.TRANSLUCENT,
cull: false,
occlude: false,
castShadows: true,
receiveShadows: true,
pickOnly: true,
renderState: {},
vertexArray: {},
modelMatrix: Cesium.Matrix4.IDENTITY,
framebuffer: {},
})) {
derived.dirty = false;
native[key] = value;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(derived[key], value, key);
assert.equal(derived.dirty, true, key);
}
native.uniformMap.value = () => 2;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value(), 2);
native.uniformMap = { replacement: () => 3 };
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value, undefined);
assert.equal(map.replacement(), 3);
assert.equal(map.u_gevSonar(), 42);
const nextProgram = {};
syncCyberSonarCommand(native, record, nextProgram, uniforms, 0);
assert.equal(derived.shaderProgram, nextProgram);
assert.equal(native.shaderProgram, undefined);
});
test('GPU shader transform is isolated, settings-driven, and precedes native fragment classification', () => {
assert.equal(
Cesium.VERSION,
CYBER_SONAR_CESIUM_VERSION,
'engine upgrades require GPU revalidation',
);
for (const [kind, attr, low] of [
['point', 'positionHighAndSize', 'positionLowAndOutline'],
['billboard', 'positionHighAndScale', 'positionLowAndRotation'],
]) {
const source = new Cesium.ShaderSource({
defines: ['SDF'],
sources: [
`in vec4 ${attr}; in vec4 ${low}; out vec4 v_color; void main() { v_color=vec4(1.); }`,
],
});
const original = source.sources[0];
const next = sonarVertexSource(source, kind);
assert.equal(source.sources[0], original);
assert.notEqual(next, source);
assert.deepEqual(next.defines, ['SDF']);
assert.match(next.sources[0], /void gev_sonar_main/);
assert.match(next.sources[1], /v_color.a \*= factor/);
assert.match(next.sources[1], /u_gevSonarSector/);
assert.match(next.sources[1], /floorValue \+ \(1.0 - floorValue\) \* 0.46/);
assert.ok(
next.sources[1].includes(
`czm_translateRelativeToEye(${attr}.xyz, ${low}.xyz)`,
),
);
assert.match(next.sources[1], /anchor.xy \/ anchor.w/);
assert.doesNotMatch(next.sources[1], /gl_Position\s*[=\/]/);
}
assert.equal(
sonarVertexSource(
new Cesium.ShaderSource({ sources: ['void main() {}'] }),
'point',
),
null,
);
});
test('render demand follows batch bounds, including horizon culling, without far-plane rejection', () => {
const planes = Array.from(
{ length: 6 },
(_, i) => new Cesium.Cartesian4(1, 0, 0, i),
);
const frame = {
cullingVolume: { planes },
mode: Cesium.SceneMode.SCENE3D,
occluder: {},
};
let intersections = 0;
let occluded = false;
const command = {
cull: true,
occlude: true,
boundingVolume: {
intersectPlane(plane) {
intersections++;
return plane.distance === 5
? Cesium.Intersect.OUTSIDE
: Cesium.Intersect.INSIDE;
},
isOccluded() {
return occluded;
},
},
};
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(intersections, 5);
occluded = true;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
frame.mode = Cesium.SceneMode.SCENE2D;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
command.boundingVolume.intersectPlane = () => Cesium.Intersect.OUTSIDE;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
command.cull = false;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(isCyberSonarCommandVisible({}, frame), true);
});
test('adapter never walks contacts, touches other scenes, or transforms pick-only passes', () => {
let calls = 0;
const original = function (fs) {
assert.equal(this, root);
calls++;
fs.commandList.push({ owner: {}, id: 'terrain' });
};
const root = { update: original };
const other = { update: original };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const frame = { commandList: [], passes: { render: true, pick: false } };
root.update(frame);
assert.equal(calls, 1);
assert.equal(frame.commandList[0].id, 'terrain');
assert.equal(other.update, original);
root.update({ commandList: [], passes: { render: false, pick: true } });
assert.equal(calls, 2);
adapter.destroy();
adapter.destroy();
assert.equal(root.update, original);
});
test('unknown native shaders fail closed without losing native commands', () => {
const points = new Cesium.PointPrimitiveCollection();
const command = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
const root = {
update(fs) {
fs.commandList.push(command);
},
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled: true,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
assert.equal(frame.commandList[0], command);
assert.equal(adapter.stats.supported, false);
assert.match(adapter.stats.error, /Unsupported native contact shader/);
assert.equal(adapter.stats.programs, 0);
adapter.destroy();
points.destroy();
});
test('program swaps, layer removal and late shader failure release only adapter resources', () => {
const points = new Cesium.PointPrimitiveCollection();
const source = () => ({
vertexShaderSource: new Cesium.ShaderSource({
sources: [
'in vec4 positionHighAndSize; in vec4 positionLowAndOutline; out vec4 v_color; void main() {}',
],
}),
vertexAttributes: {},
fragmentShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
});
const native = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: source(),
cull: false,
vertexArray: { generation: 1 },
pickId: 'native-pick',
});
const unknown = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
});
let nativeCommands = [native];
let enabled = true;
const root = {
update(frame) {
frame.commandList.push(...nativeCommands);
},
};
const fromCache = Cesium.ShaderProgram.fromCache;
const created = [];
Cesium.ShaderProgram.fromCache = () => {
const program = {
allUniforms: {},
destroyed: false,
isDestroyed() {
return this.destroyed;
},
destroy() {
assert.equal(this.destroyed, false);
this.destroyed = true;
},
};
created.push(program);
return program;
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const draw = () => {
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
return frame.commandList;
};
try {
const first = draw()[0];
assert.notEqual(first, native);
first.dirty = false;
assert.equal(draw()[0], first);
assert.equal(first.dirty, false);
assert.equal(created.length, 1, 'stable draws reuse the program');
const buffer = { generation: 2 };
native.vertexArray = buffer;
native.shaderProgram = source();
const swapped = draw()[0];
assert.equal(swapped, first);
assert.equal(swapped.vertexArray, buffer);
assert.equal(swapped.pickId, 'native-pick');
assert.equal(swapped.shaderProgram, created[1]);
assert.equal(created[0].destroyed, true);
assert.equal(adapter.stats.programs, 1);
nativeCommands = [];
draw();
assert.equal(created[1].destroyed, true);
assert.equal(
adapter.stats.programs,
0,
'disabled layers retain no adapter program',
);
nativeCommands = [native];
draw();
enabled = false;
assert.equal(draw()[0], native);
assert.equal(
created[2].destroyed,
true,
'theme/cockpit exit releases the derived program',
);
enabled = true;
nativeCommands = [native, unknown];
assert.deepEqual(
draw(),
nativeCommands,
'a later failure rolls back earlier replacements in the same slice',
);
assert.equal(created[3].destroyed, true);
assert.equal(adapter.stats.supported, false);
assert.equal(adapter.stats.programs, 0);
assert.equal(adapter.stats.commands, 0);
assert.equal(adapter.stats.visibleCommands, 0);
assert.deepEqual(draw(), nativeCommands);
assert.equal(
created.length,
4,
'unsupported draws do not retry compilation',
);
} finally {
adapter.destroy();
Cesium.ShaderProgram.fromCache = fromCache;
points.destroy();
}
});
test('disposal does not overwrite a successor update owner', () => {
const root = { update() {} };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const successor = () => {};
root.update = successor;
adapter.destroy();
assert.equal(root.update, successor);
});
+211
View File
@@ -0,0 +1,211 @@
import * as Cesium from 'cesium';
import { createCyberSonarGpu } from './cyberSonarGpu.js';
import {
applyCyberSonarPrimitive,
createCyberSonarSampler,
isCyberContactThemeActive,
isCyberContactSonarActive,
isCyberSonarActive,
readCyberSonarSettings,
cyberSonarAngleDeg,
syncCyberSonarAnimation,
CYBER_SONAR_RENDER_INTERVAL_MS,
} from './cyberSonar.js';
const alive = (value) => value && !value.isDestroyed?.();
const diagnostics = new WeakMap();
export const getCyberSonarSceneDiagnostics = (viewer) => ({
...diagnostics.get(viewer),
});
export function visitCyberSonarModels(primitive, visit, visible = true) {
if (!alive(primitive)) return;
visible = visible && primitive.show !== false;
if (primitive instanceof Cesium.PrimitiveCollection) {
for (let i = 0; i < primitive.length; i++)
visitCyberSonarModels(primitive.get(i), visit, visible);
} else if (primitive instanceof Cesium.Model) {
visit(primitive, primitive, 'model', visible && primitive.ready);
}
}
/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain
* their existing reversible CPU color treatment without walking native records.
*/
export function createCyberSonarScene(viewer, manager) {
const scene = viewer?.scene;
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return () => {};
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0,
timer = null,
disposed = false;
let sampler, width, height, active, count;
const stats = { contacts: 0, updateMs: 0, frames: 0 };
const root = globalThis.document?.documentElement;
const gpu = createCyberSonarGpu(scene, () => ({
...readCyberSonarSettings(),
enabled: isCyberContactThemeActive(),
active: isCyberContactSonarActive(),
angle: cyberSonarAngleDeg(performance.now()),
}));
stats.gpu = gpu.stats;
let gpuSupported = gpu.stats.supported;
diagnostics.set(viewer, stats);
const restore = (visual, state) => {
if (!alive(state.owner)) return;
for (const key of state.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const visit = (visual, owner, kind, visible) => {
let state = tracked.get(visual);
if (!visible) {
if (state) {
restore(visual, state);
tracked.delete(visual);
}
return;
}
let p = visual.position;
if (kind === 'model')
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
else if (
p &&
owner.modelMatrix &&
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
)
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
if (!p) return;
const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
const x =
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
const y =
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
if (!state) {
state = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, state);
}
state.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of state.keys) {
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
if (key === 'backgroundColor' && !visual.showBackground) continue;
applyCyberSonarPrimitive(
visual,
kind === 'label' ? sampler.label(factor) : factor,
true,
sampler,
key,
kind === 'model',
);
}
count++;
};
const update = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!isCyberContactThemeActive()) {
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = createCyberSonarSampler(width, height, started);
active = isCyberContactSonarActive();
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, state] of tracked)
if (state.epoch !== epoch) {
restore(visual, state);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
// No contacts, no scanner-owned render demand. In continuous scenes the
// next rendered frame cancels this timer; parked scenes refresh at fleet
// cadence. Idle update-only ticks must not continually postpone the timer.
};
let removeRender;
// Aircraft fleet writers run in preRender, while tracked models and entity
// visualizers update earlier. Compose AFTER all those owners and BEFORE
// Cesium uploads primitive attributes for this draw. Requeue at postUpdate
// so writers installed later (enable/re-enable/track) cannot overwrite sonar.
// This only reorders one callback; the contact walk runs once per actual draw.
const removeUpdate = scene.postUpdate.addEventListener(() => {
if (isCyberSonarActive()) syncCyberSonarAnimation();
const supportChanged = gpuSupported !== gpu.stats.supported;
gpuSupported = gpu.stats.supported;
const support = gpu.stats.supported ? 'supported' : 'unsupported';
if (root?.dataset && root.dataset.cyberSonarGpu !== support)
root.dataset.cyberSonarGpu = support;
// Shader setup runs after preRender's model tint. If it fails while the
// scene is parked (including Sonar OFF/reduced motion), draw once more so
// the now-unsupported contact path restores models before becoming idle.
if (supportChanged) scene.requestRender();
const status = globalThis.document?.getElementById?.('cyber-sonar-status');
const message = gpu.stats.supported
? ''
: 'Contact GPU unavailable. Native contacts remain visible.';
if (status && status.textContent !== message) status.textContent = message;
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
const removePostRender = scene.postRender?.addEventListener(() => {
if (timer !== null) clearTimeout(timer);
timer = null;
if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands))
timer = setTimeout(() => {
timer = null;
if (!disposed && isCyberContactSonarActive()) scene.requestRender();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
const unsubscribe = manager?.subscribe?.((change) => {
if (change.type !== 'visibility') return;
scene.requestRender();
});
return () => {
if (disposed) return;
disposed = true;
removeUpdate();
removeRender?.();
removePostRender?.();
gpu.destroy();
if (root?.dataset) delete root.dataset.cyberSonarGpu;
unsubscribe?.();
if (timer !== null) clearTimeout(timer);
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
diagnostics.delete(viewer);
};
}
+314
View File
@@ -0,0 +1,314 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
cyberSonarIntensityAtScreenPoint,
applyCyberSonarPrimitive,
applyCyberSonarSettings,
isCyberContactSonarActive,
isCyberSonarActive,
} from './cyberSonar.js';
import {
createCyberSonarScene,
visitCyberSonarModels,
getCyberSonarSceneDiagnostics,
} from './cyberSonarScene.js';
test('frame sampler matches reference without per-contact settings access', () => {
let reads = 0;
const values = {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
};
const root = {
dataset: new Proxy(values, {
get(o, k) {
reads++;
return o[k];
},
}),
};
const frame = createCyberSonarSampler(1000, 800, 1234, root),
once = reads;
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31) frame.at(x, y);
assert.equal(reads, once);
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31)
assert.ok(
Math.abs(
frame.at(x, y) -
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
dataset: values,
}),
) < 1e-10,
);
});
test('model walk never enumerates native contact or EntityCluster records', () => {
const root = new Cesium.PrimitiveCollection();
const points = root.add(new Cesium.PointPrimitiveCollection());
points.add();
points.get = () => {
throw new Error('must not enumerate contacts');
};
const cluster = root.add(new Cesium.EntityCluster());
Object.defineProperty(cluster, '_billboardCollection', {
get() {
throw new Error('must not access cluster internals');
},
configurable: true,
});
const seen = [];
visitCyberSonarModels(root, (...v) => seen.push(v));
assert.equal(seen.length, 0);
delete cluster._billboardCollection;
root.destroy();
});
test('models retain render-pass alpha and owner refresh survives teardown', () => {
const frame = createCyberSonarSampler(1000, 800, 0);
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
assert.equal(model.color.alpha, 1);
assert.ok(model.color.red < 1);
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
model.color = owner;
applyCyberSonarPrimitive(model, 1, false);
assert.equal(model.color, owner);
const unset = {};
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
applyCyberSonarPrimitive(unset, 1, false);
assert.equal(unset.color, undefined);
});
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
const options = {
root,
body: { classList: { contains: () => false } },
hud: { classList: { contains: () => true } },
reducedMotion: false,
};
root.dataset.cyberSonarMode = 'css';
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
assert.equal(root.dataset.cyberSonarMode, undefined);
assert.equal(isCyberSonarActive(options), true);
assert.equal(isCyberContactSonarActive(options), true);
assert.equal(
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
undefined,
);
assert.equal(isCyberContactSonarActive(options), true);
root.dataset.cyberSonarGpu = 'unsupported';
assert.equal(isCyberContactSonarActive(options), false);
});
test('native owner colors remain untouched and scene teardown releases listeners', () => {
const previous = globalThis.document;
const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } };
let cockpit = false;
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => cockpit } },
getElementById: () => ({ classList: { contains: () => true } }),
};
const primitives = new Cesium.PrimitiveCollection();
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const original = primitives.update;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
for (const enabled of ['on', 'off', 'on']) {
root.dataset.cyberSonar = enabled;
for (const opacity of [35, 84, 100]) {
root.dataset.cyberSonarOpacity = String(opacity);
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
let timers = 0;
globalThis.setTimeout = () => {
timers++;
return 1;
};
globalThis.clearTimeout = () => {};
try {
const gpu = getCyberSonarSceneDiagnostics(viewer).gpu;
gpu.commands = 6;
gpu.visibleCommands = 0;
scene.postRender.raiseEvent();
assert.equal(timers, 0, 'culled batches must not schedule a sonar draw');
gpu.visibleCommands = 1;
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'a visible batch keeps the sweep moving');
root.dataset.cyberSonar = 'off';
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'OFF does not schedule contact draws');
} finally {
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
cockpit = true;
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
root.dataset.uiTheme = 'tactical';
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
dispose();
dispose();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(root.dataset.cyberSonarGpu, undefined);
} finally {
dispose();
primitives.destroy();
globalThis.document = previous;
}
});
for (const reducedMotion of [false, true]) {
test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => {
const previousDocument = globalThis.document;
const previousMatchMedia = globalThis.matchMedia;
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
const root = {
dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' },
};
const status = { textContent: '' };
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => false } },
getElementById: (id) =>
id === 'cyber-sonar-status'
? status
: { classList: { contains: () => true } },
};
globalThis.matchMedia = () => ({ matches: reducedMotion });
let scheduled = 0;
globalThis.setTimeout = () => {
scheduled++;
return 1;
};
globalThis.clearTimeout = () => {};
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: nativeColor.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
primitives.add(model);
const points = new Cesium.PointPrimitiveCollection();
const nativeCommand = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
primitives.update = (frame) => frame.commandList.push(nativeCommand);
let requests = 0;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
drawingBufferWidth: 1000,
drawingBufferHeight: 800,
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {
requests++;
},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.notDeepEqual(
model.color,
nativeColor,
'Cyber neutral treatment precedes shader compilation',
);
const frame = { commandList: [], passes: { render: true } };
primitives.update(frame);
assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postRender.raiseEvent();
assert.equal(
scheduled,
0,
'inactive sweep has no timer to perform recovery',
);
scene.postUpdate.raiseEvent();
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
assert.match(status.textContent, /Native contacts remain visible/);
assert.equal(
requests,
1,
'support loss explicitly wakes the parked scene',
);
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the requested draw restores native model color',
);
assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0);
scene.postRender.raiseEvent();
for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent();
assert.equal(
requests,
1,
'unsupported idle ticks do not create a render loop',
);
assert.equal(scheduled, 0);
dispose();
scene.postUpdate.raiseEvent();
assert.equal(requests, 1, 'disposed callbacks cannot wake the scene');
} finally {
dispose();
points.destroy();
primitives.destroy();
globalThis.document = previousDocument;
globalThis.matchMedia = previousMatchMedia;
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
});
}
+14
View File
@@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
export const CLASS_SCALE_2D = {
light: 0.62,
+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aisStreamRows,
ingestAisStreamEnvelope,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
+57 -3
View File
@@ -18,12 +18,13 @@
*
* Providers (injected — keeps the engine pure and node-testable):
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
* resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null>
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
*
* @module data/analystEngine
*/
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
@@ -61,6 +62,26 @@ export const ANALYST_LAYERS = {
text: ['place'],
flags: [],
},
satellites: {
numeric: ['altitudeM', 'speedMps'],
text: ['name', 'noradId', 'satelliteClass', 'group'],
flags: [],
},
'local-datacenters': {
numeric: [],
text: ['name', 'operator', 'capacity'],
flags: [],
},
'local-dams': {
numeric: [],
text: ['name', 'operator', 'river', 'output'],
flags: [],
},
'fire-perimeters': {
numeric: ['acres', 'containedPct', 'personnel', 'costToDate'],
text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'],
flags: [],
},
};
const EARTH_R_KM = 6371;
@@ -163,12 +184,15 @@ export function createAnalystEngine(providers) {
// 1) Source records
let records;
let layersQueried;
let queriedSnapshots;
if (layers === null) {
records = lastResult.items.slice();
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
} else {
records = [];
layersQueried = [];
queriedSnapshots = [];
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
if (unknown.length) {
return {
@@ -180,7 +204,22 @@ export function createAnalystEngine(providers) {
for (const key of layers) {
if (!ANALYST_LAYERS[key]) continue;
const rows = providers.getRecords(key) || [];
layersQueried.push({ layerKey: key, records: rows.length });
const snapshot = providers.getLayerSnapshot?.(key);
if (snapshot) queriedSnapshots.push(snapshot);
layersQueried.push({
layerKey: key,
records: rows.length,
...(snapshot
? {
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
enabled: snapshot.enabled,
error: snapshot.error,
}
: {}),
...providers.getRecordCoverage?.(key, rows),
});
for (const row of rows) records.push({ layerKey: key, ...row });
}
}
@@ -195,6 +234,14 @@ export function createAnalystEngine(providers) {
const scope = spec.scope || { kind: 'view' };
if (scope.kind === 'region' && scope.name) {
const region = await providers.resolveRegionRing(scope.name);
if (region?.error === 'region-timeout') {
return {
ok: false,
code: 'region-timeout',
error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`,
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
};
}
if (!region?.ring) {
return {
ok: false,
@@ -292,8 +339,15 @@ export function createAnalystEngine(providers) {
coverage: {
layersQueried,
scope: scopeNote,
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
followUp: Boolean(spec.followUp && lastResult),
note: 'client-side data only — answers cover what the enabled layers currently hold',
note: layersQueried.some(
(layer) => layer.basis === 'bounded-loaded-records',
)
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
: 'client-side data only — answers cover what the enabled layers currently hold',
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
+92
View File
@@ -187,6 +187,19 @@ test('analyst: unresolved region is an honest failure, not empty success', async
assert.match(r.error, /Atlantis/);
});
test('analyst: a region lookup timeout is reported as region-timeout', async () => {
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'region-timeout');
assert.match(r.error, /Texas/);
assert.equal(r.coverage.scope, 'region:Texas:timeout');
});
test('analyst: route fields queryable from cached enrichment only', async () => {
const r = await makeEngine().query({
layers: ['flights'], scope: { kind: 'anywhere' },
@@ -195,9 +208,88 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
});
test('analyst: satellites and local infrastructure are queryable layers', async () => {
const SATS = [
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
];
const DAMS = [
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
];
const DCS = [
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
];
const eng = createAnalystEngine({
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const sats = await eng.query({
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
});
assert.equal(sats.ok, true);
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
assert.equal(sats.items[0].noradId, '25544');
assert.ok(Number.isFinite(sats.items[0].distanceKm));
const nav = await eng.query({
layers: ['satellites'], scope: { kind: 'anywhere' },
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
});
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
const dams = await eng.query({
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
});
assert.equal(dams.count, 1);
assert.equal(dams.items[0].id, 'Austin Dam');
assert.equal(dams.items[0].river, 'Colorado');
const dcs = await eng.query({
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
});
assert.equal(dcs.count, 1);
assert.equal(dcs.items[0].id, 'AUS-1');
});
test('helpers: haversine sanity + scope radius', () => {
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
const engine = createAnalystEngine({
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const first = await engine.query();
state = 'nominal';
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.feedProvenance.overall, 'stale');
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
engine.reset();
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
+9 -1
View File
@@ -595,8 +595,15 @@ export function createBhoteKoshiEmbeddedMedia({
facebookLoader = loadFacebookSdk,
youtubeLoader = loadYouTubeApi,
} = {}) {
if (!documentRef?.createElement || !viewer) {
// Pinokio externalizes HTTPS iframe navigation, including hidden preloads.
// Use the existing source-card/local-clip fallback before allocating provider
// resources. Browsers visiting the same Pinokio-launched server keep embeds.
const pinokioShell = /(?:^|\s)Pinokio\/[^\s]+/i.test(
globalRef.navigator?.userAgent || '',
);
if (pinokioShell || !documentRef?.createElement || !viewer) {
return {
supportsPlayback: false,
warm: () => false,
show: () => false,
play: () => false,
@@ -1078,6 +1085,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
return {
supportsPlayback: true,
warm,
show,
getPlaybackState: () => record?.youtubeSession?.getState() || null,
+51
View File
@@ -10,6 +10,57 @@ import {
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
test('Pinokio fallback never creates provider DOM or loads SDKs', async () => {
const unexpected = () => { throw new Error('Provider resource allocated in Pinokio'); };
const media = createBhoteKoshiEmbeddedMedia({
viewer: {},
documentRef: { createElement: unexpected },
globalRef: {
navigator: { userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 Pinokio/8.0.40' },
setTimeout: unexpected,
requestAnimationFrame: unexpected,
},
facebookLoader: unexpected,
youtubeLoader: unexpected,
xLoader: unexpected,
});
for (const sourceUrl of [
'https://www.youtube.com/watch?v=abcdefghijk',
'https://www.facebook.com/watch/?v=123456789',
'https://x.com/example/status/123456789',
]) {
const options = { observation: { media: { sourceUrl } }, autoplay: true };
assert.equal(media.warm(options), false, 'no hidden preload');
assert.equal(media.show(options), false, 'retain the existing fallback card');
assert.equal(media.play(), false);
assert.equal(media.pause(), false);
media.hide();
}
media.destroy();
media.hide();
await Promise.resolve();
});
test('ordinary browsers retain real preloads on the same launcher URL', () => {
for (const userAgent of [
'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36',
'Mozilla/5.0 Version/18.0 Safari/605.1.15',
'Mozilla/5.0 Electron/38.0.0',
'',
]) {
const { media, root } = preloadFixture({ globalRef: {
navigator: { userAgent }, location: { href: 'http://127.0.0.1:42003/' },
} });
assert.equal(media.warm({ observation: { media: {
sourceUrl: 'https://www.youtube.com/watch?v=abcdefghijk',
} } }), true);
assert.equal(root.children.length, 1);
media.pause();
assert.equal(root.children.length, 0);
media.destroy();
}
});
function trimmedPlaybackFixture() {
let now = 0;
let nextId = 0;
+79 -137
View File
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
let _splitLine = null;
let _splitHandle = null;
let _splitDragging = false;
let _splitPointerId = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1000,6 +999,8 @@ export function createBhoteKoshiEventLayer({
let _sceneMediaPlaybackBeatId = null;
let _sceneMediaPlaybackCompletedBeatId = null;
let _sceneMediaPlaybackTimer = null;
let _sceneMediaOwner = null;
let _removeSceneMediaAbort = null;
let _witnessEmbedActive = false;
let _sceneRevealAnimationFrame = null;
let _sceneRevealHoldActive = false;
@@ -1020,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
let _previousMapStack = null;
let _eventMapGeneration = null;
let _previousSplitPosition = null;
let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -1261,8 +1259,41 @@ export function createBhoteKoshiEventLayer({
return true;
}
// Playback authority is supplied by the Director, never restored from params.
function setSceneMediaPlayback(owner = null) {
_removeSceneMediaAbort?.();
_removeSceneMediaAbort = null;
_sceneMediaOwner = owner;
stopSceneMediaPlayback();
releaseEvidenceVideo({ restorePoster: false });
_embeddedMedia?.hide?.({ immediate: true });
_embeddedEvidenceIndex = -1;
_sceneMediaPlaybackCompletedBeatId = null;
if (owner?.token?.signal) {
const signal = owner.token.signal;
const cancel = () => setSceneMediaPlayback();
if (signal.aborted) cancel();
else {
signal.addEventListener('abort', cancel, { once: true });
_removeSceneMediaAbort = () =>
signal.removeEventListener('abort', cancel);
}
}
}
function sceneMediaPlaybackActive() {
return (
_sceneMediaOwner != null &&
!_sceneMediaOwner.token.cancelled &&
!_sceneMediaOwner.token.signal?.aborted &&
_sceneMediaOwner.sceneId === _sceneContext?.sceneId &&
_sceneMediaOwner.shotId === _sceneContext?.shotId
);
}
function sceneEvidenceMediaAutoplays() {
return (
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT &&
_sceneControls.evidenceMediaAutoplay === true &&
@@ -1333,7 +1364,7 @@ export function createBhoteKoshiEventLayer({
stopSceneMediaPlayback({ pauseMedia: false });
_sceneMediaPlaybackBeatId = beatId;
_embeddedMedia?.play?.();
if (sceneUsesTrimmedMedia(beatId)) {
if (sceneUsesTrimmedMedia(beatId) && _embeddedEvidenceIndex === index) {
const deadline = Date.now() + 18000;
const check = () => {
if (
@@ -1415,6 +1446,14 @@ export function createBhoteKoshiEventLayer({
!sceneUsesTrimmedMedia(beatId)
)
return null;
const media = _evidenceTimeline.find(
(item) => item.observation?.id === beatId,
)?.observation?.media;
// Deliberate provider suppression is a source card, not a player waiting
// to load. Returning no gate selects the Director's authored card dwell.
// A local clip still owns playback; a starting supported provider still waits.
if (_embeddedMedia?.supportsPlayback === false && !media?.videoPath)
return null;
return {
pending:
_sceneMediaPlaybackCompletedBeatId !== beatId ||
@@ -2103,6 +2142,7 @@ export function createBhoteKoshiEventLayer({
releaseEvidenceVideo();
_embeddedEvidenceIndex = -1;
const warmingEvidence =
sceneMediaPlaybackActive() &&
_presentation === BHOTE_KOSHI_SCENE_PRESENTATION &&
_sceneSurface === BHOTE_KOSHI_SCENE_EVIDENCE_BEAT;
// Keep the same preload across camera frames; stop/disable still cancels it.
@@ -2512,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
function ensureSplitLine(event) {
_splitLine = document.createElement('div');
_splitLine.id = 'bhote-koshi-split-line';
const beforeLabel = document.createElement('span');
const afterLabel = document.createElement('span');
const splitHandle = document.createElement('button');
beforeLabel.className = 'before';
afterLabel.className = 'after';
beforeLabel.textContent = 'A';
beforeLabel.title = event.imagery.before.label;
afterLabel.textContent = 'B';
afterLabel.title = event.imagery.after.label;
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
splitHandle.setAttribute(
'aria-label',
'Historical reference and post-event image divider',
);
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
splitHandle.textContent = '↔';
splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
splitHandle.addEventListener('pointermove', handleSplitPointerMove);
splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
splitHandle.addEventListener('keydown', handleSplitKeyDown);
_splitHandle = splitHandle;
_splitLine.append(beforeLabel, splitHandle, afterLabel);
document.body.appendChild(_splitLine);
}
function setSplitFromClientX(clientX) {
const viewportWidth =
Number(document.documentElement?.clientWidth) ||
Number(window.innerWidth) ||
0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
function handleSplitPointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
_splitDragging = true;
_splitPointerId = event.pointerId ?? null;
_splitHandle?.setPointerCapture?.(event.pointerId);
setSplitFromClientX(event.clientX);
}
function handleSplitPointerMove(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
event.preventDefault?.();
setSplitFromClientX(event.clientX);
}
function finishSplitPointerDrag(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
_splitHandle?.releasePointerCapture?.(event.pointerId);
_splitDragging = false;
_splitPointerId = null;
}
function handleSplitKeyDown(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') setSplit(0);
else if (event.key === 'End') setSplit(1);
else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
onChange: setSplit,
});
}
function applyCinematicCamera() {
@@ -3167,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
_splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3284,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
if (_viewer && _viewer.scene.splitPosition !== _split)
_viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
document.documentElement.style.setProperty(
'--bhote-koshi-split',
cssValue,
);
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
const beforePercent = Math.round(_split * 100);
_splitHandle.setAttribute('aria-valuenow', String(beforePercent));
_splitHandle.setAttribute(
'aria-valuetext',
`A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
);
}
_splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3384,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
_previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3393,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
if (
!sceneDirected &&
_mapStackController &&
_previousMapStack !== 'esri-imagery'
) {
await _mapStackController.setStack('esri-imagery');
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: sceneDirected ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
_eventMapGeneration = sceneDirected
? null
: (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
ensureSplitLine(event);
createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3476,6 +3435,7 @@ export function createBhoteKoshiEventLayer({
}
async function disable() {
setSceneMediaPlayback();
_enabled = false;
++_comparisonSurfaceGeneration;
_mapStackController?.clearTerrainComparison?.();
@@ -3528,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
_splitPointerId = null;
_splitLine?.remove();
_splitLine = null;
_splitHandle = null;
document.documentElement.style.removeProperty('--bhote-koshi-split');
_lastSplitCssValue = null;
if (_viewer?.scene && _previousSplitPosition != null) {
_viewer.scene.splitPosition = _previousSplitPosition;
}
_previousSplitPosition = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3556,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
const previousMapStack = _previousMapStack;
const eventMapGeneration = _eventMapGeneration;
_previousMapStack = null;
_eventMapGeneration = null;
const comparisonLease = _comparisonLease;
_comparisonLease = null;
try {
if (
_mapStackController &&
previousMapStack &&
previousMapStack !== 'esri-imagery' &&
_mapStackController.getActiveId?.() === 'esri-imagery' &&
_mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
@@ -3770,6 +3711,7 @@ export function createBhoteKoshiEventLayer({
setParams,
getParams,
getSceneShotMediaHold,
setSceneMediaPlayback,
getStats() {
return {
count: _enabled ? 1 : 0,
+96 -1
View File
@@ -30,6 +30,8 @@ import {
} from './bhoteKoshiEvent.js';
import { SCENE_RECIPES } from '../scenes/recipes.js';
import { LayerLifecycle } from './lifecycle.js';
import { createBhoteKoshiEmbeddedMedia } from './bhoteKoshiEmbeddedMedia.js';
import { SceneDirector } from '../scenes/director.js';
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
@@ -277,8 +279,9 @@ test('trimmed scene media holds through provider startup, playback and fade, the
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
layer.setParams({ presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { holdSec: 7.65 },
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaExitDurationSec: 0.65 } }, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true);
@@ -301,6 +304,94 @@ test('trimmed scene media holds through provider startup, playback and fade, the
}
});
test('Pinokio source-card fallback selects authored Director dwell, not an impossible player hold', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
globalThis.window = { setTimeout: () => 1, clearTimeout() {} };
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: options => createBhoteKoshiEmbeddedMedia({ ...options,
globalRef: { navigator: { userAgent: 'Chrome Pinokio/8.0.40' } },
documentRef: { createElement() { assert.fail('unsupported host must allocate no provider DOM'); } },
}),
});
const token = { cancelled: false, signal: new AbortController().signal };
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, mediaPlaybackHoldSec: 7, cameraSettled: true } };
try {
const viewer = eventViewer();
await layer.init(viewer);
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token });
layer.setParams(params, { origin: 'scene' });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const waits = [];
await SceneDirector.prototype._holdShot.call({
_layerStatesForShot: () => ({ [layer.id]: { enabled: true, params } }),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
_effectiveShotHoldSec: () => 7.65,
_sleep: async ms => waits.push(ms),
stopScene: () => assert.fail('a source card must not time out'),
}, { id: 'scene' }, { id: 'shot' }, token);
assert.deepEqual(waits, [7650], 'use the authored dwell instead of completing instantly or polling 20 seconds');
event.evidenceSpine.find(item => item.id === 'mailung-bazzar').media.videoPath = 'fixture-approved.mp4';
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'an approved local clip remains a real playback owner even with the provider suppressed');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('saved autoplay controls cannot authorize media; live shot ownership is cancellable and never serialized', async () => {
const previous = { document: globalThis.document, window: globalThis.window };
installEventDom();
const timers = new Map(); let timerId = 0;
globalThis.window = { setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
const calls = [];
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const layer = createBhoteKoshiEventLayer({
eventLoader: async () => event,
imageryProviderFactory: async url => ({ url }),
terrainSampler: async (_viewer, observations) => observations.map(() => ({ height: 800 })),
mediaLoader: async () => { throw Object.assign(new Error('No bundled clip'), { name: 'AbortError' }); },
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
renderHost: { request() {}, hold() {}, release() {} },
embeddedMediaFactory: () => ({ supportsPlayback: true, warm: () => true,
show: options => { calls.push(options.autoplay); return true; },
getPlaybackState: () => null, play() {}, pause() {}, hide() {}, destroy() {} }),
});
const params = { presentation: 'scene-beat', sceneSurface: 'evidence-beat', beatId: 'mailung-bazzar',
sceneContext: { sceneId: 'scene', shotId: 'shot', holdSec: 7.65 },
sceneControls: { evidenceMediaAutoplay: true, cameraSettled: true, mediaPlaybackHoldSec: 7 } };
try {
const viewer = eventViewer(); await layer.init(viewer);
layer.setParams(params, { origin: 'local-restore' }); await layer.enable(viewer);
assert.ok(calls.length); assert.ok(calls.every(value => value === false));
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
const saved = layer.getParams();
const abort = new AbortController();
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false, signal: abort.signal } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), true);
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar').pending, true,
'a temporarily null supported-provider state is still startup, not unsupported');
assert.deepEqual(layer.getParams(), saved, 'live playback authority must not enter saved params');
const late = [...timers.values()];
abort.abort(); late.forEach(fn => fn());
assert.equal(layer.getSceneShotMediaHold('mailung-bazzar'), null);
layer.setParams(params, { origin: 'local-restore' });
assert.equal(calls.at(-1), false, 'late callbacks and restored flags cannot restart revoked playback');
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'different-shot', token: { cancelled: false } });
layer.setParams(params, { origin: 'scene' });
assert.equal(calls.at(-1), false, 'a different shot cannot borrow this playback owner');
} finally { await layer.destroy(); Object.assign(globalThis, previous); }
});
test('Bhote Koshi timeline helpers clamp untrusted UI values', () => {
assert.equal(clampUnit(-1), 0);
assert.equal(clampUnit(0.42), 0.42);
@@ -1695,7 +1786,9 @@ test('Upper Valley Collapse autoplays its muted local fallback without starting
sceneControls: { evidenceMediaAutoplay: true },
beatId: 'immediate-collapse-viewpoint',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
}, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
assert.equal(videos.length, 1);
const video = videos[0];
@@ -1874,6 +1967,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
sceneSurface: 'evidence-beat',
beatId: 'gyirong-border-gate',
beatReveal: 0.36,
sceneContext: { sceneId: 'scene', shotId: 'shot' },
sceneControls: {
evidenceSequence: true,
evidenceSequenceDurationSec: 0.9,
@@ -1886,6 +1980,7 @@ test('Nepal evidence shots resume the standalone callout and flood sequence from
try {
await layer.init(viewer);
layer.setParams(params, { origin: 'scene' });
layer.setSceneMediaPlayback({ sceneId: 'scene', shotId: 'shot', token: { cancelled: false } });
await layer.enable(viewer, { origin: 'scene' });
const card = entries.find(({ id }) => id === 'evidence-card-gyirong-border-gate');
const flood = viewer._test.dataSources[0].entities.getById('bhote-koshi-flood-core');
+5
View File
@@ -5,6 +5,8 @@ import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKos
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
import {
anchoredCalloutPresentation,
BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
@@ -503,11 +505,14 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
+29
View File
@@ -0,0 +1,29 @@
/**
* @file Load a JSON file shipped beside the source modules.
*
* The browser fetches the file from `url` — a `new URL('./….json',
* import.meta.url)` written in the calling module, which Vite serves as-is in
* dev and emits as an asset in the production build. Under Node (node:test)
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
* JSON-attributed import runs instead.
*
* The attributed import cannot serve the browser: the Vite dev server answers
* it with a JavaScript module, which the browser rejects for a JSON import.
*
* @module data/bundledJson
*/
/**
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
* @param {() => Promise<{default: any}>} importJson - The caller's literal
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
* @returns {Promise<any>} The parsed JSON.
*/
export async function loadBundledJson(url, importJson) {
if (url.protocol === 'file:') return (await importJson()).default;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
}
return response.json();
}
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadBundledJson } from './bundledJson.js';
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
const importMarine = () =>
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch');
const pack = await loadBundledJson(MARINE, importMarine);
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
assert.equal(fetchMock.mock.callCount(), 0);
});
test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
const requests = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requests.push(String(url));
return new Response('{"features":[]}', {
headers: { 'content-type': 'application/json' },
});
});
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
const importJson = () => assert.fail('the browser path must not import JSON');
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
assert.deepEqual(requests, [url.href]);
});
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
await assert.rejects(
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
/HTTP 404/,
);
});
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+116 -1
View File
@@ -8,8 +8,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { Readable } from 'node:stream';
import { Readable, Writable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
import { proxyMediaResponse } from '../../server/providers/cctv/media.js';
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
@@ -586,3 +587,117 @@ test('two sequential seeks are served as two partial responses', async (t) => {
assert.equal(first.body, 'chunk-0');
assert.equal(second.body, 'chunk-100');
});
// ---------------------------------------------------------------------------
// The body idle deadline. proxyMediaResponse is driven directly here because
// the deadline is only injectable through its options; the route always passes
// the production value.
// ---------------------------------------------------------------------------
/**
* A response to pipe into, built on a real Writable so the backpressure the
* proxy reads is the stream's own rather than a flag the test sets. With
* `drains: false` the bytes are accepted and never flushed, which is what a
* viewer on a slow link looks like from the server side.
*/
function pipeTarget({ drains = true } = {}) {
const res = new Writable({
// One byte is enough to leave a target that never empties its buffer
// permanently in need of a drain.
highWaterMark: drains ? undefined : 1,
write(_chunk, _encoding, callback) {
if (drains) callback();
},
});
res.writeHead = (status, headers) => {
res.statusCode = status;
res.headers = headers || {};
};
return res;
}
/**
* An upstream that answers with headers, sends one chunk, and then either
* keeps producing every `everyMs` or goes silent. `released` reports the body's
* own cleanup: a cancelled fetch body lands in the web stream's cancel hook.
*/
function mediaUpstream({ everyMs = 0 } = {}) {
const state = { produced: 0, released: false };
let ticker = null;
const body = new ReadableStream({
start(controller) {
const send = () => {
state.produced += 1;
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]));
};
send();
if (everyMs > 0) {
ticker = setInterval(send, everyMs);
ticker.unref();
}
},
cancel() {
state.released = true;
if (ticker) clearInterval(ticker);
},
});
state.upstream = {
ok: true,
status: 200,
headers: {
get: (name) =>
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
},
body,
arrayBuffer: async () => Buffer.alloc(0),
};
return state;
}
test('an upstream that goes silent after its headers is released at the idle deadline', async (t) => {
const feed = mediaUpstream();
const res = pipeTarget();
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(feed.released, false, 'released before the deadline was due');
await new Promise((resolve) => setTimeout(resolve, 120));
// Without the deadline this stream is held open against the camera host for
// as long as that host will keep the socket, with no bytes ever arriving.
assert.equal(feed.released, true, 'the silent upstream was never released');
assert.equal(res.writableEnded, true, 'the client was left waiting');
});
test('a live feed that keeps producing is not cut off by the idle deadline', async (t) => {
const feed = mediaUpstream({ everyMs: 10 });
const res = pipeTarget();
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 40 });
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(feed.released, false, 'a healthy feed was torn down');
assert.equal(res.writableEnded, false, 'a healthy feed was ended early');
assert.ok(
feed.produced > 5,
`the feed stopped producing (${feed.produced} chunks)`,
);
});
test('a client that cannot keep up is not mistaken for a stalled upstream', async (t) => {
// Nothing arrives from upstream while the pipe is paused, which looks exactly
// like silence unless the response is asked whether it is still draining.
const feed = mediaUpstream({ everyMs: 10 });
const res = pipeTarget({ drains: false });
t.after(() => res.destroy());
await proxyMediaResponse(res, feed.upstream, { idleTimeoutMs: 30 });
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(
res.writableNeedDrain,
true,
'the client was not the bottleneck',
);
assert.equal(feed.released, false, 'a slow viewer tore down a healthy feed');
assert.equal(res.writableEnded, false, 'a slow viewer ended the response');
});
+132 -52
View File
@@ -21,23 +21,70 @@ export function trailAlpha(ageMs) {
/** One static history batch and one frequently updated head. Never entities. */
export function createContactTrailRenderer(scene) {
const heads = scene.primitives.add(new Cesium.PolylineCollection());
const backing = heads.add({
positions: [],
width: 4,
show: false,
material: Cesium.Material.fromType('Color', {
color: new Cesium.Color(0.02, 0.03, 0.05, 0.8),
}),
});
const head = heads.add({
positions: [],
width: 2,
show: false,
material: Cesium.Material.fromType('Color', {
color: Cesium.Color.WHITE.clone(),
}),
});
const ground =
!!scene.frameState?.context &&
Cesium.GroundPolylinePrimitive.isSupported(scene);
const collection = ground ? scene.groundPrimitives : scene.primitives;
// One 25 m corridor is clipped in material coordinates. Only subdivision
// crossings build geometry; frame updates write a scalar, never vertex buffers.
const headMaterial = (alpha) =>
new Cesium.Material({
fabric: {
type: 'TransitTrailHead',
uniforms: {
color: new Cesium.Color(0.37, 0.94, 0.54, alpha),
fraction: 0,
backing: true,
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
if (materialInput.st.s > fraction) discard;
czm_material m = czm_getDefaultMaterial(materialInput);
bool edge = abs(materialInput.st.t - 0.5) > 0.3;
m.diffuse = czm_gammaCorrect(vec4(edge && backing ? vec3(0.02, 0.03, 0.05) : color.rgb, 1.0)).rgb;
m.alpha = color.a;
return m;
}`,
},
translucent: true,
});
const material = headMaterial(0.8),
depthMaterial = headMaterial(0.55);
depthMaterial.uniforms.backing = false;
const head = { positions: [], show: false, width: 3, material };
const backing = { positions: [], show: false, width: 5 };
let headPrimitive = null,
headRebuilds = 0;
function geometry(positions, width, material = false) {
return ground
? new Cesium.GroundPolylineGeometry({ positions, width, granularity: 0 })
: new Cesium.PolylineGeometry({
positions,
width,
arcType: Cesium.ArcType.NONE,
vertexFormat: material
? Cesium.PolylineMaterialAppearance.VERTEX_FORMAT
: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
});
}
function primitive(instances, appearance, depthFailAppearance) {
return ground
? new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
appearance,
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
asynchronous: true,
show: visible,
})
: new Cesium.Primitive({
geometryInstances: instances,
appearance,
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
});
}
let body = null,
segments = [],
revision = -1,
@@ -68,7 +115,10 @@ export function createContactTrailRenderer(scene) {
completed = -1;
lastSecond = -1;
active = null;
if (body) scene.primitives.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
head.show = backing.show = false;
if (body) collection.remove(body);
body = null;
const instances = [];
attributeIds.length = 0;
@@ -80,18 +130,13 @@ export function createContactTrailRenderer(scene) {
instances.push(
new Cesium.GeometryInstance({
id: `${i}:${lane}`,
geometry: new Cesium.PolylineGeometry({
positions: segment.positions,
width: lane ? 2 : 4,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
geometry: geometry(segment.positions, lane ? 3 : 5),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
tint(0.8, !lane),
tint(0.55),
),
show: new Cesium.ShowGeometryInstanceAttribute(false),
},
@@ -100,20 +145,12 @@ export function createContactTrailRenderer(scene) {
}
}
if (instances.length) {
const depthFailAppearance = new Cesium.PolylineColorAppearance({
translucent: true,
fragmentShaderSource:
'in vec4 v_color; void main() { out_FragColor = czm_gammaCorrect(vec4(v_color.rgb, v_color.a * 0.2)); }',
});
body = scene.primitives.add(
new Cesium.Primitive({
geometryInstances: instances,
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
depthFailAppearance,
allowPicking: false,
asynchronous: false,
show: visible,
}),
body = collection.add(
primitive(
instances,
new Cesium.PolylineColorAppearance({ translucent: true }),
new Cesium.PolylineColorAppearance({ translucent: true }),
),
);
rebuilds++;
}
@@ -122,6 +159,7 @@ export function createContactTrailRenderer(scene) {
if (!sample || !renderedPosition) {
head.show = false;
backing.show = false;
if (headPrimitive) headPrimitive.show = false;
return;
}
const second = Math.floor(sample.displayT / 1000);
@@ -152,8 +190,10 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
attributes.show[0] = i <= done ? 1 : 0;
attributes.show = attributes.show;
// Cesium getters return copies. Write back the array we changed.
const show = attributes.show;
show[0] = i <= done ? 1 : 0;
attributes.show = show;
}
}
completed = done;
@@ -166,16 +206,20 @@ export function createContactTrailRenderer(scene) {
attributeIds[i][lane],
);
if (!attributes) continue;
const rgba = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.color,
rgba,
);
attributes.color = attributes.color;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(alpha, !lane),
attributes.depthFailColor,
);
attributes.depthFailColor = attributes.depthFailColor;
attributes.color = rgba;
if (!ground) {
const depthRgba = attributes.depthFailColor;
Cesium.ColorGeometryInstanceAttribute.toValue(
tint(0.55),
depthRgba,
);
attributes.depthFailColor = depthRgba;
}
}
}
lastSecond = second;
@@ -183,16 +227,43 @@ export function createContactTrailRenderer(scene) {
const show = visible && !!segment && sample.fraction < 1;
head.show = show;
backing.show = show;
if (headPrimitive) headPrimitive.show = show;
if (!show) return;
if (active !== segment) {
active = segment;
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = collection.add(
primitive(
new Cesium.GeometryInstance({
geometry: geometry(segment.positions, 5, true),
}),
new Cesium.PolylineMaterialAppearance({
material,
translucent: true,
}),
new Cesium.PolylineMaterialAppearance({
material: depthMaterial,
translucent: true,
}),
),
);
headRebuilds++;
headPositions[0] = segment.positions[0];
headPositions[1] = endpoint;
}
Cesium.Cartesian3.clone(renderedPosition, endpoint);
head.positions = headPositions;
backing.positions = headPositions;
Cesium.Color.clone(tint(0.8), head.material.uniforms.color);
material.uniforms.fraction = Math.max(
0,
Math.min(
1,
(sample.displayT - segment.fromT) / (segment.toT - segment.fromT),
),
);
depthMaterial.uniforms.fraction = material.uniforms.fraction;
Cesium.Color.clone(tint(0.8), material.uniforms.color);
Cesium.Color.clone(tint(0.55), depthMaterial.uniforms.color);
}
return {
replaceHistory,
@@ -205,12 +276,15 @@ export function createContactTrailRenderer(scene) {
},
setVisible(next) {
visible = next;
heads.show = next;
if (headPrimitive) headPrimitive.show = next && head.show;
if (body) body.show = next;
},
destroy() {
if (body) scene.primitives.remove(body);
scene.primitives.remove(heads);
if (body) collection.remove(body);
if (headPrimitive) collection.remove(headPrimitive);
headPrimitive = null;
material.destroy();
depthMaterial.destroy();
body = null;
segments = [];
},
@@ -222,6 +296,12 @@ export function createContactTrailRenderer(scene) {
body,
head,
backing,
headPrimitive,
headStrategy: ground
? 'draped corridor with material clip'
: 'depth-fail corridor with material clip',
ground,
headRebuilds,
entitiesAdded: 0,
};
},
+169 -22
View File
@@ -1,12 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { readFileSync } from 'node:fs';
import { TRAIL_VERTEX_LIMIT } from '../layers/transit/trails.js';
import {
createContactTrailRenderer,
trailAlpha,
} from './contactTrailRenderer.js';
function fixture(t) {
function fixture(t, ground = false) {
for (const key of [
'HTMLCanvasElement',
'HTMLImageElement',
@@ -20,9 +22,21 @@ function fixture(t) {
else globalThis[key] = prior;
});
}
const width = Cesium.ContextLimits._maximumAliasedLineWidth;
Cesium.ContextLimits._maximumAliasedLineWidth = 1;
t.after(() => {
Cesium.ContextLimits._maximumAliasedLineWidth = width;
});
const primitives = new Cesium.PrimitiveCollection();
t.after(() => primitives.destroy());
return { primitives };
const groundPrimitives = new Cesium.PrimitiveCollection();
t.after(() => groundPrimitives.destroy());
return {
primitives,
groundPrimitives,
frameState: { context: { depthTexture: ground } },
globe: { show: false },
};
}
test('body clips future subdivisions, head ends at exact marker, and frames retain geometry', (t) => {
const scene = fixture(t),
@@ -62,7 +76,9 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
for (const instance of body.geometryInstances) {
assert.deepEqual(
instance.attributes.depthFailColor.value,
instance.attributes.color.value,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
}
body._batchTableAttributeIndices = { color: 0, depthFailColor: 1 };
@@ -81,7 +97,18 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
);
// Simulate Cesium's completed upload; exercise public attribute writes.
body._ready = true;
body.getGeometryInstanceAttributes = (id) => attributes.get(id);
body.getGeometryInstanceAttributes = (id) => {
const stored = attributes.get(id),
accessor = {};
for (const name of ['show', 'color', 'depthFailColor'])
Object.defineProperty(accessor, name, {
get: () => stored[name].slice(),
set: (value) => {
stored[name] = value.slice();
},
});
return accessor;
};
const marker = Cesium.Cartesian3.lerp(
positions[1],
positions[2],
@@ -95,10 +122,19 @@ test('body clips future subdivisions, head ends at exact marker, and frames reta
[1, 1, 0, 0],
);
for (const a of attributes.values())
assert.deepEqual(a.depthFailColor, a.color);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#5EF08A').withAlpha(0.55),
),
);
const result = renderer.diagnostics();
assert.equal(result.body, body);
assert.equal(result.rebuilds, 1);
assert.equal(result.headRebuilds, 1, '120 frames retain head geometry');
assert.equal(result.head.material.uniforms.fraction, 0.5);
assert.equal(result.head.width, 3);
assert.equal(result.backing.width, 5);
assert.ok(Cesium.Cartesian3.equals(result.head.positions.at(-1), marker));
assert.equal(result.head.positions.length, 2);
renderer.setDisplaySample(
@@ -120,24 +156,130 @@ test('trail age curve uses the specified four alpha anchors', () => {
assert.ok(Math.abs(trailAlpha(60000) - 0.625) < 1e-10);
});
test('the maximum prepared body stays below the 2 MiB geometry budget', () => {
const geometry = Cesium.PolylineGeometry.createGeometry(
new Cesium.PolylineGeometry({
positions: [
Cesium.Cartesian3.fromDegrees(-71, 42, 11.5),
Cesium.Cartesian3.fromDegrees(-71, 42.0002, 11.5),
],
width: 2,
arcType: Cesium.ArcType.NONE,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
test('ground body and clipped head stay inside rebuild and frame geometry budgets', async () => {
Cesium.ApproximateTerrainHeights._terrainHeights = JSON.parse(
readFileSync(
new URL(
import.meta
.resolve('@cesium/engine/Source/Assets/approximateTerrainHeights.json'),
),
),
);
const bytes =
Object.values(geometry.attributes).reduce(
(total, attribute) => total + (attribute?.values?.byteLength || 0),
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
const start = performance.now();
const instances = async (count) =>
Promise.all(
Array.from(
{ length: count },
async (_, i) =>
new Cesium.GeometryInstance({
id: i,
geometry: await Cesium.GroundPolylineGeometry.createGeometry(
new Cesium.GroundPolylineGeometry({
positions,
width: i % 2 ? 3 : 5,
granularity: 0,
}),
),
}),
),
);
const pipelineBytes = async (count) => {
// Match GroundPolylinePrimitive's internal Primitive options and the
// application's morph-capable scene, including batch IDs and encoding.
const result = Cesium.PrimitivePipeline.combineGeometry({
instances: await instances(count),
projection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
modelMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
elementIndexUintSupported: true,
scene3DOnly: false,
vertexCacheOptimize: false,
compressVertices: false,
createPickOffsets: false,
});
return result.geometries.reduce(
(sum, geometry) =>
sum +
Object.values(geometry.attributes).reduce(
(n, a) => n + (a?.values?.byteLength || 0),
0,
) +
geometry.indices.byteLength,
0,
) + geometry.indices.byteLength;
assert.ok(bytes * 2 * 2047 < 2 * 1024 * 1024);
);
};
const bytes = await pipelineBytes(1);
const bodyInstances = 2 * (TRAIL_VERTEX_LIMIT - 1);
const bodyBytes = await pipelineBytes(bodyInstances);
assert.equal(bytes, 1576, 'measure final encoded and batched geometry');
assert.equal(bodyBytes, 2014128);
const instanceReserve = 64 * bodyInstances;
assert.ok(bodyBytes + instanceReserve <= 2 * 1024 * 1024);
assert.ok(
bytes <= 2048,
'subdivision crossings stay below 2 KiB of head geometry',
);
console.log(
`trail final pipeline: ${bytes} bytes/head crossing, 0 bytes/ordinary head frame, ${bodyBytes} bytes/body (${bodyInstances} instances), ${instanceReserve} bytes instance reserve; geometry CPU ${(performance.now() - start).toFixed(3)} ms`,
);
});
test('supported scenes drape body and clipped head on 3D tiles and retain head geometry', (t) => {
const scene = fixture(t, true),
renderer = createContactTrailRenderer(scene);
const positions = [
Cesium.Cartesian3.fromDegrees(-97.7431, 30.267),
Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672),
];
renderer.replaceHistory({
revision: 1,
segments: [{ fromSeq: 0, toSeq: 1, fromT: 0, toT: 20000, positions }],
});
const first = renderer.diagnostics();
assert.ok(first.body instanceof Cesium.GroundPolylinePrimitive);
assert.equal(first.body.classificationType, Cesium.ClassificationType.BOTH);
assert.ok(scene.groundPrimitives.contains(first.body));
for (const instance of first.body.geometryInstances)
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
for (let i = 1; i <= 120; i++)
renderer.setDisplaySample(
{ displayT: i * 100, fromSeq: 0, toSeq: 1, fraction: i / 200 },
positions[1],
);
const result = renderer.diagnostics();
assert.equal(result.body, first.body);
assert.equal(result.headRebuilds, 1);
assert.ok(result.headPrimitive instanceof Cesium.GroundPolylinePrimitive);
assert.equal(result.head.material.uniforms.fraction, 0.6);
assert.match(result.head.material.shaderSource, /discard/);
for (const stack of ['Google', 'OSM', 'Bing', 'Google']) {
scene.globe.show = stack !== 'Google';
renderer.setDisplaySample(
{ displayT: 12000, fromSeq: 0, toSeq: 1, fraction: 0.6 },
positions[1],
);
const retained = renderer.diagnostics();
assert.equal(retained.body, result.body, `${stack}: retained history`);
assert.equal(retained.headPrimitive, result.headPrimitive);
for (const primitive of [retained.body, retained.headPrimitive]) {
assert.equal(
primitive.classificationType,
Cesium.ClassificationType.BOTH,
stack,
);
assert.equal(primitive.show, true, stack);
}
}
renderer.setVisible(false);
assert.equal(result.body.show, false);
assert.equal(result.headPrimitive.show, false);
renderer.destroy();
assert.equal(scene.groundPrimitives.length, 0);
assert.equal(scene.primitives.length, 0);
});
test('subdivision crossings only write the changed show range and age colors once per second', (t) => {
@@ -206,5 +348,10 @@ test('subdivision crossings only write the changed show range and age colors onc
display(1200);
assert.deepEqual(counts, { show: 0, color: 200, depthFailColor: 200 });
for (const a of attributes.values())
assert.deepEqual(a.color, a.depthFailColor);
assert.deepEqual(
a.depthFailColor,
Cesium.ColorGeometryInstanceAttribute.toValue(
Cesium.Color.fromCssColorString('#FF4538').withAlpha(0.55),
),
);
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+60
View File
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
{
key: 'adsbdb',
html:
'Aircraft type, registration &amp; flight routes: ' +
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
'Guillaume Michel · route data is the work of ' +
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'aisstream',
html:
@@ -66,6 +77,23 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'nasa-gibs',
html:
'Recent imagery: We acknowledge the use of imagery provided by services from ' +
"NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
'Data and Information System (ESDIS). ' +
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
},
{
key: 'wfigs',
html:
'Wildfire perimeters: ' +
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'overpass',
html:
@@ -136,6 +164,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -201,6 +233,34 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
},
{
key: 'weather-cyclones',
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
},
{
key: 'weather-lightning',
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
},
{
key: 'wind-gfs',
html:
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
{
key: 'ecmwf-ifs',
html:
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
assert.equal(
new Set(keys).size,
keys.length,
'a duplicate key would silently shadow one provider’s credit',
);
for (const entry of DATA_CREDITS) {
assert.ok(entry.key, 'a credit without a key cannot be registered');
assert.ok(
entry.html && entry.html.trim().length > 0,
`credit ${entry.key} has nothing to show`,
);
}
});
test('adsbdb is credited and carries its published route-data restriction', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
assert.ok(
credit,
'adsbdb supplies aircraft type and routes and must be credited',
);
// adsbdb publishes this restriction for its route data. Pin the provider's
// credits and restriction here so a later edit cannot silently remove them.
assert.match(credit.html, /David Taylor, Edinburgh/);
assert.match(credit.html, /Jim Mason, Glasgow/);
assert.match(
credit.html,
/may not be\s+copied, published, or incorporated into other databases/,
);
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
assert.match(credit.html, /PlaneBase/);
assert.match(credit.html, /Guillaume Michel/);
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
+63 -4
View File
@@ -48,6 +48,12 @@ import {
viewScaleForAltitude,
} from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js';
import {
createCyberSonarSampler,
cyberSonarFrameDelay,
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
/**
* @module detection
@@ -206,6 +212,8 @@ let _lastRenderMs = 0;
let _lastPaintMs = 0;
let _lastSolveMs = 0;
let _throttleSkipCount = 0;
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
let _cyberSonarRenderTimer = null;
/** @type {Function|null} External callback invoked when the detection mode changes */
let _onModeChange = null;
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
@@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */
export function destroyDetection() {
_clearCyberSonarRenderTimer();
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener(
'gev:cockpit-mode-changed',
@@ -362,6 +371,25 @@ export function destroyDetection() {
_lastPaintMs = 0;
_lastSolveMs = 0;
_throttleSkipCount = 0;
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
function _clearCyberSonarRenderTimer() {
if (_cyberSonarRenderTimer != null) {
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
_cyberSonarRenderTimer = null;
}
}
function _scheduleCyberSonarRender(nowMs) {
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
_cyberSonarRenderTimer = null;
if (isCyberSonarActive()) {
governorRequestRender('cyber-sonar-cadence');
}
}, delay);
}
/**
@@ -682,7 +710,18 @@ function _applyModeState() {
function _shouldPaintDetectionLane(frame) {
if (_mode === MODE_OFF || _suspended) return false;
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
_frameCount++;
if (isCyberSonarActive()) {
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
if (!layoutChanged && sonarDelay > 0) {
_scheduleCyberSonarRender(nowMs);
return false;
}
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
// The skip and the follow-up request come from ONE decision, so the valve
// cannot drop a frame without handing its request forward — see
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
@@ -729,6 +768,14 @@ function _paintDetectionLane(frame) {
_lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
_lastCyberSonarPaintAt = nowMs;
_scheduleCyberSonarRender(nowMs);
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
if (_lastDiagnostics) {
_lastDiagnostics.frameTotalMs = _lastRenderMs;
_lastDiagnostics.paintMs = _lastPaintMs;
@@ -748,7 +795,7 @@ function _paintDetectionLane(frame) {
// 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(),
nowMs,
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
@@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
placement.centerY,
keyhole,
);
const alpha = acquireFade * temporalAlpha * radialAlpha;
const alpha =
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount];
@@ -1146,6 +1194,7 @@ function _materializeCandidate(
keyholeAlpha,
placements,
color: obj._candidateColor,
sonarFactor: obj._candidateSonarFactor ?? 1,
// 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.
@@ -1180,6 +1229,9 @@ function _drawOverlay(frame) {
// with every arbiter call in this function — so the frame that paints an
// animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const sonarActive = isCyberSonarActive();
const cyberMapActive = isCyberMapThemeActive();
const sonarSampler = createCyberSonarSampler(width, height, now);
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
const shouldSolve =
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
@@ -1362,11 +1414,16 @@ function _drawOverlay(frame) {
// 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(
const sonarFactor =
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
const sonarLabelFactor = sonarSampler.label(sonarFactor);
const admissionAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
cyberMapActive,
);
const bracketAlpha = admissionAlpha * sonarFactor;
if (bracketAlpha > 0) {
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(
@@ -1404,13 +1461,15 @@ function _drawOverlay(frame) {
obj._cohortSourceId = sourceId;
obj._cohortPriority = _semanticPriority(obj);
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
obj._cohortBand =
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
obj._cohortHash = stableIdentityHash(layerId, sourceId);
obj._candidateScreenX = sx;
obj._candidateScreenY = sy;
obj._candidateHalfW = halfW;
obj._candidateHalfH = halfH;
obj._candidateColor = color;
obj._candidateSonarFactor = sonarLabelFactor;
obj._candidatePrimary = primary;
obj._candidateMicro = micro;
+4
View File
@@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
* the default, so a caller that does not know it reproduces the shipped look.
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
* readability floor; the bracket follows the radial keyhole alpha exactly.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
strictKeyholeFade = false,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (strictKeyholeFade) return alpha;
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
}
+14
View File
@@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
});
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
assert.equal(
detectionBracketAlpha(
'AIR',
0.01,
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
true,
),
0.01,
);
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
});
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
// default ever moves, this fails rather than silently shifting the shipped
+9 -1
View File
@@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.ok(paintLane, 'detection.js still has a paint lane');
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
'demand must not re-sample the clock — that is the dropped terminal frame');
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
assert.match(
paintLane,
/const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/,
);
assert.match(
paintLane,
/detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/,
'the sonar cadence and fade demand share the same frame-clock sample',
);
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
'demand counts fades in both directions');
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { detectionBracketAlpha } from './detectionPolicy.js';
// Exercise the actual renderer's admission and paint expressions. A sweep is
// appearance only: it must not reshuffle candidates at the cohort cutoff.
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const alphaBlock = source.match(
/const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/,
)?.[0];
const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0];
test('Sonar changes paint alpha without changing detection cohort admission', () => {
assert.ok(alphaBlock, 'renderer separates admission from paint');
assert.ok(bandAssignment);
for (const cyberMapActive of [false, true]) {
for (const type of ['AIR', 'SAT', 'SEA']) {
for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) {
const alpha = detectionBracketAlpha(
type,
keyholeAlpha,
0.35,
cyberMapActive,
);
const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8);
for (const sonarFactor of [0.01, 0.12, 0.5, 1]) {
const state = {
obj: { type },
keyholeAlpha,
keyholeOutsideOpacity: 0.35,
cyberMapActive,
sonarFactor,
detectionBracketAlpha,
};
vm.runInNewContext(
alphaBlock + bandAssignment + '; result = bracketAlpha;',
state,
);
assert.equal(state.obj._cohortBand, expectedBand);
assert.equal(state.result, alpha * sonarFactor);
}
}
}
}
});
+2 -1
View File
@@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
@@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) {
state.available === false
)
return 'degraded';
if (state.partial === true) return 'partial';
return 'nominal';
}
+410
View File
@@ -0,0 +1,410 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { firePerimetersProxy } from '../../server/providers/firePerimeters.js';
const incidentHtml = `<meta property="og:updated_time" content="2026-09-23T11:32:31-04:00" />
<th>Date of Origin</th>
<td class="text-tabular text-left"><time datetime="2026-07-15T20:20:00Z">Origin</time></td>`;
const incidentTimes = {
createdMs: Date.parse('2026-07-15T20:20:00Z'),
changedMs: Date.parse('2026-09-23T11:32:31-04:00'),
};
const incidentRedirect = () =>
new Response('redirect', {
status: 301,
headers: { location: 'http://inciweb.wildfire.gov/test-fire' },
});
const feature = (id = 'fire') => ({
properties: { attr_UniqueFireIdentifier: id, poly_DateCurrent: 123 },
geometry: {
type: 'Polygon',
coordinates: [
[
[-108, 35],
[-107, 35],
[-107, 36],
[-108, 35],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = firePerimetersProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/fire-perimeters');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(String(calls.length))],
exceededTransferLimit: true,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 5);
assert.deepEqual(
calls.map((url) => url.searchParams.get('resultOffset')),
[null, '1', '2', '3', '4'],
);
assert.ok(calls.every((url) => url.hostname === 'services3.arcgis.com'));
assert.equal(calls[0].searchParams.get('maxAllowableOffset'), '0.001');
});
}
test('an explicit transfer-limit false stops paging; a fresh cache does not fetch', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({
features: [feature()],
exceededTransferLimit: false,
});
},
});
const first = await request();
assert.deepEqual((await request()).body, first.body);
assert.equal(calls, 1);
});
test('a failed refresh serves the last good perimeter snapshot as stale', async () => {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
if (++calls > 1) throw new Error('private upstream details');
return Response.json({ features: [feature()] });
},
});
await request();
clock = 300_000;
const res = await request();
assert.equal(res.status, 200);
assert.equal(res.body.stale, true);
assert.equal(res.body.fetchedAt, 0);
assert.equal(res.body.rows[0].stableId, 'fire');
});
test('oversized streamed and declared bodies produce sanitized 502 responses', async () => {
for (const declared of [true, false]) {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'Content-Length': String(17 * 1024 * 1024) }
: {},
},
),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'fire_perimeters_unavailable' });
assert.equal(cancelled, true);
}
});
test('malformed WFIGS data does not become a cached empty snapshot', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ error: 'upstream detail' });
},
});
assert.equal((await request()).status, 502);
assert.equal((await request()).status, 502);
assert.equal(calls, 2);
});
test('concurrent requests share one upstream operation for every route', async () => {
for (const [path, payload] of [
['/', { features: [] }],
['/inciweb/index', []],
['/inciweb/publication/42', {}],
]) {
let release,
calls = 0;
const pending = new Promise((resolve) => {
release = resolve;
});
const request = install({
fetchImpl: async () => {
calls++;
await pending;
if (path.includes('/publication/'))
return calls === 1 ? incidentRedirect() : new Response(incidentHtml);
return Response.json(payload);
},
});
const responses = [request(path), request(path)];
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
for (const res of await Promise.all(responses))
assert.equal(res.status, 200);
assert.equal(calls, path.includes('/publication/') ? 2 : 1);
}
});
test('bad publication ids and non-GET requests never fetch upstream', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const id of ['', 'abc', '-1', '1234567890', '1/2', '%31', '1.0'])
assert.equal((await request(`/inciweb/publication/${id}`)).status, 400);
for (const path of ['/', '/inciweb/index', '/inciweb/publication/123']) {
for (const method of ['POST', 'HEAD', 'PUT', 'DELETE'])
assert.equal((await request(path, method)).status, 405);
}
assert.equal(calls, 0);
});
test('InciWeb index preserves the fixed POST, caches for one hour and serves stale arrays', async () => {
let clock = 0,
calls = 0;
const rows = [{ incident_id: '42', incident_title: 'Fire' }];
const request = install({
now: () => clock,
fetchImpl: async (url, options) => {
calls++;
assert.equal(url, 'https://inciweb.wildfire.gov/api/single-publication/');
assert.equal(options.method, 'POST');
assert.deepEqual(options.headers, { 'Content-Type': 'application/json' });
assert.deepEqual(JSON.parse(options.body), { title: '' });
if (calls > 1) throw new Error('offline');
return Response.json(rows);
},
});
assert.deepEqual((await request('/inciweb/index')).body, rows);
clock = 3_599_999;
assert.deepEqual((await request('/inciweb/index')).body, rows);
assert.equal(calls, 1);
clock++;
const stale = await request('/inciweb/index');
assert.deepEqual(stale.body, rows);
assert.equal(stale.headers['X-Data-Stale'], 'true');
});
test('publication timestamps, thirty-minute TTL and oldest-entry eviction', async () => {
let clock = 0;
const calls = [];
const request = install({
now: () => clock,
fetchImpl: async (url) => {
calls.push(url);
return url.includes('/node/')
? incidentRedirect()
: new Response(incidentHtml);
},
});
const get = (id) =>
request(`/inciweb/publication/${id}`, 'GET', `peer-${id}`);
assert.deepEqual((await get(1)).body, incidentTimes);
clock = 1_799_999;
await get(1);
assert.equal(calls.length, 2);
clock++;
await get(1);
assert.equal(calls.length, 4);
for (let id = 2; id <= 257; id++) assert.equal((await get(id)).status, 200);
await get(2);
assert.equal(calls.length, 516);
await get(1);
assert.equal(calls.length, 518);
assert.equal(calls.at(-2), 'https://inciweb.wildfire.gov/node/1');
});
test('InciWeb index and publication reads enforce their smaller caps', async () => {
for (const [path, cap] of [
['/inciweb/index', 4 * 1024 * 1024],
['/inciweb/publication/1', 2 * 1024 * 1024],
]) {
const request = install({
fetchImpl: async () =>
new Response('{}', { headers: { 'Content-Length': String(cap + 1) } }),
});
assert.equal((await request(path)).status, 502);
}
});
test('the per-client limit stops a loop while another client can read the cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ features: [] });
},
});
for (let i = 0; i < 60; i++) assert.equal((await request()).status, 200);
assert.equal((await request()).status, 429);
assert.equal((await request('/', 'GET', 'another-client')).status, 200);
assert.equal(calls, 1);
});
test('publication redirects share a signal, cancel the redirect body and force HTTPS', async () => {
for (const status of [301, 302, 303, 307, 308]) {
const calls = [];
const redirect = new Response('redirect', {
status,
headers: {
location: 'http://inciweb.wildfire.gov/incident/test-fire#details',
},
});
const request = install({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return calls.length === 1 ? redirect : new Response(incidentHtml);
},
});
assert.deepEqual(
(await request('/inciweb/publication/42')).body,
incidentTimes,
);
assert.equal(calls.length, 2);
assert.equal(redirect.bodyUsed, true);
assert.equal(calls[0].url, 'https://inciweb.wildfire.gov/node/42');
assert.equal(calls[0].options.redirect, 'manual');
assert.equal(
calls[1].url,
'https://inciweb.wildfire.gov/incident/test-fire',
);
assert.equal(calls[1].options.redirect, 'error');
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(calls[0].options.signal, calls[1].options.signal);
}
});
test('unsafe publication redirect locations fail before a second fetch', async () => {
for (const location of [
'https://evil.example/x',
'/a/../x',
'/x?q=1',
'/x?',
'/%2e%2e/x',
'/a/b/c',
'',
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response('redirect', { status: 301, headers: { location } });
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 1);
}
});
test('incident pages without valid timestamps fail closed and are not cached', async () => {
for (const html of [
'<html>No markers</html>',
incidentHtml.replaceAll(/2026-[^"]+/g, 'invalid'),
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return calls % 2 ? incidentRedirect() : new Response(html);
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 4);
}
});
test('direct 200 incident pages work, including one missing timestamp', async () => {
for (const [html, expected] of [
[incidentHtml, incidentTimes],
[
incidentHtml.replace('og:updated_time', 'unrelated'),
{ ...incidentTimes, changedMs: null },
],
[
incidentHtml.replace('Date of Origin', 'Unrelated'),
{ ...incidentTimes, createdMs: null },
],
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(html);
},
});
assert.deepEqual((await request('/inciweb/publication/42')).body, expected);
assert.equal(calls, 1);
}
});
test('publication HTML streams are capped at 2 MiB', async () => {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
),
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(cancelled, true);
});
+7 -1
View File
@@ -5,9 +5,15 @@
* VIIRS header (confirmed live 2026-07-16):
* latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_ti5,frp,daynight
* MODIS header:
* latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_t31,frp,daynight
*
* Quirks this module owns:
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw.
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) and numeric 0-100 for
* MODIS — passed through raw.
* - Brightness maps from `bright_ti4`/`brightness`; the secondary band maps
* from `bright_ti5`/`bright_t31`.
* - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records;
* {@link acquisitionMsUtc} does the padding.
* - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"),
+19
View File
@@ -48,6 +48,25 @@ test('fixture first row maps every field, categorical confidence preserved raw',
assert.equal(first.instrument, 'VIIRS');
});
test('MODIS schema maps fields and preserves numeric confidence raw', () => {
const header =
'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight';
const row =
'34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D';
const [record] = parseFirmsCsv(`${header}\n${row}\n`);
assert.equal(record.lat, 34.123);
assert.equal(record.lon, -117.456);
assert.equal(record.frp, 12.4);
assert.equal(record.brightness, 321.5);
assert.equal(record.brightnessTi5, 290.25);
assert.equal(record.daynight, 'D');
assert.equal(record.acqDate, '2026-07-16');
assert.equal(record.acqTime, '1045');
assert.equal(record.satellite, 'Aqua');
assert.equal(record.instrument, 'MODIS');
assert.equal(record.confidence, '78');
});
test('acquisitionMsUtc: "1006" = 10:06 UTC', () => {
assert.equal(
acquisitionMsUtc('2026-07-16', '1006'),
+4 -4
View File
@@ -9,7 +9,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
@@ -42,7 +42,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0][index], ok: true,
source, count: [200_000, 1, 0, 0][index], ok: true,
})));
});
@@ -51,7 +51,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
@@ -65,7 +65,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
+13
View File
@@ -7,3 +7,16 @@
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
browser mode — it is a point-in-time congestion snapshot, not a bundled
data layer, and is never loaded by ordinary application startup. © TomTom.
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<hex>;`, one per line,
in receive order) received over Austin, TX, and
`adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode
of the same frames, trimmed to the fields the tests assert on. Used by the
Local ADS-B decoder and record-adapter tests; never loaded at runtime.
- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX:
846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with
dump1090-fa's decoded positions and emitter categories for the same
interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as
`[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's
time is interpolated between the frames whose decoded position equals a
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
category, speed-check and track-accuracy tests; never loaded at runtime.
@@ -0,0 +1 @@
{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c55Line truncated
@@ -0,0 +1,53 @@
{
"now": 1790127097.5,
"messages": 112,
"aircraft": [
{
"hex": "a15c54",
"flight": "SKW3301 ",
"alt_baro": 5050,
"gs": 287.5,
"track": 291.2,
"baro_rate": 3072,
"seen": 31.5,
"messages": 14,
"rssi": -28.2
},
{
"hex": "ae5d8a",
"flight": "SHINR42 ",
"lat": 30.269662,
"lon": -97.793262,
"alt_baro": 1600,
"gs": 129.9,
"track": 330.5,
"baro_rate": 64,
"seen": 30.5,
"seen_pos": 34.6,
"messages": 74,
"rssi": -30.0
},
{
"hex": "a3c775",
"lat": 30.360625,
"lon": -97.919659,
"alt_baro": 34000,
"gs": 434.4,
"track": 283.7,
"baro_rate": 0,
"seen": 38.2,
"seen_pos": 43.4,
"messages": 20,
"rssi": -30.5
},
{
"hex": "a46e6a",
"gs": 319.8,
"track": 351.2,
"baro_rate": 2688,
"seen": 32.0,
"messages": 4,
"rssi": -37.1
}
]
}
+112
View File
@@ -0,0 +1,112 @@
*8da46e6a99143227b0ac21405d5c;
*8da46e6aea428858015c080bb810;
*5da46e6ae86d20;
*8da3c775990da70d180457361e57;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a580d77d658d63e32c82f;
*5dae5d8a54a93d;
*8da3c77558af803cce430dad950f;
*200015b8f3156e;
*02e615b82df0c7;
*02e615b82df0c7;
*8da3c775990da70d180458c9be1f;
*8dae5d8a99a4400e70088202ff80;
*5dae5d8a54a93d;
*200015b8f3156e;
*200015b8f3156e;
*8dae5d8a580d702c844b24b6523e;
*8dae5d8af80300020049bc1cd8f3;
*200015b8f3156e;
*20000197d80069;
*5dae5d8a54a93d;
*020001970f9c90;
*8dae5d8a580d77d688d63170d088;
*8da3c77558af87e6a0cdf2c8bbf8;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a274c824e4b4ca06f9608;
*5dae5d8a54a93d;
*8dae5d8a99a4400e700c823ac980;
*020001970f9c90;
*5da15c54111a4b;
*8dae5d8a580d77d6a0d62aa4760f;
*5da3c7758f35d4;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500c827cbf67;
*8da15c54581b8019e24c96ebec6d;
*8dae5d8a580d77d6ccd61fccec2f;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6d8d61cd91129;
*8dae5d8a99a4410e500c827cbf67;
*8dae5d8a580d77d6e2d619cbefa5;
*8dae5d8af80300020049bc1cd8f3;
*20000197d80069;
*80000197580d77d6e8d6172cc0e3;
*8dae5d8a580d702d044b0189a64f;
*020001970f9c90;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d77d6fad6128791c3;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*020001970f9c90;
*8da15c54581bc01a424c2f038351;
*8dae5d8ae1080800000000e6168f;
*8da3c77558af87e730ccd3243e2f;
*8dae5d8a580d87d728d6051c99a8;
*5dae5d8a54a93d;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802d584aea9aed46;
*5da15c54111a4b;
*8dae5d8a99a4410e500c827cbf67;
*2000019827a021;
*8dae5d8af80300020049bc1cd8f3;
*8dae5d8a580d87d754d5f94050dd;
*8dae5d8a99a4410e500c827cbf67;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500c827cbf67;
*02000198f03cd8;
*8dae5d8a580d87d76ed5f252fa10;
*8dae5d8aea00085ee53e004546c3;
*8da15c5499150a0df0ac0d713d8d;
*200015b8f3156e;
*8da3c775990da70d100458a71c17;
*8dae5d8a580d87d778d5ef5c1b0d;
*8dae5d8a99a4410e500882448967;
*5dae5d8a54a93d;
*02818393027eb0;
*5da3c7758f35d5;
*5da3c7758f35d5;
*5da3c7758f35d4;
*200015b8f3156e;
*8dae5d8a580d87d782d5ec2e3d53;
*8da15c54ea178858015e88a53d08;
*5dae5d8a54a93d;
*8dae5d8a99a4410e500882448967;
*02000198f03cd8;
*8dae5d8ae1080800000000e6168f;
*200015b8f3156e;
*8dae5d8a580d802db04ad2d09e9c;
*02e195b8f5cd24;
*8da15c5499150b0db0b40d687b11;
*8dae5d8aea00085ee53e004546c3;
*8dae5d8a99a4410e500882448967;
*8dae5d8a580d802dc64accee13e8;
*2000019827a021;
*8dae5d8a99a4410e500881bb6175;
*8dae5d8a580d802ddc4ac5b9d917;
*8dae5d8af80300020049bc1cd8f3;
*80000198580d87d7cad5d9283036;
*02000198f03cd8;
*5da15c54111a4b;
*8dae5d8a580d87d7ecd5d0a28b94;
*8dae5d8a99a4410e500881bb6175;
*8da15c54234cb5f3cf0c6055934a;
*5dae5d8a54a93d;
*8da15c5499150d0d50c40e1a396a;
*8da15c5499150d0d30c40ed5bd11;
*5da15c54111a4b;
*5da46e6ae86d29;
*8da15c54581f201b484af27089b0;
*02000198f03cd8;
*5dae5d8a54a93d;
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+208 -4
View File
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -204,7 +212,131 @@ function integerOption(key, token, defaultValue) {
});
}
/**
* A share-link-only option: the whole-state codec carries it, but the stored
* local blob always holds its default.
*/
function shareOnlyOption(spec) {
return Object.freeze({ ...spec, shareOnly: true });
}
/**
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
* clamped box edge would be a different box, not a shorter spelling.
*/
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
const parse = (value) => {
const number =
typeof value === 'number'
? value
: /^-?\d{1,9}$/.test(String(value).trim())
? Number(String(value).trim())
: NaN;
return Number.isInteger(number) && number >= min && number <= max
? number
: null;
};
return Object.freeze({
key,
token,
defaultValue,
normalize: parse,
encode: (value) => String(value),
decode: parse,
});
}
/**
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
* The calendar is checked both ways, and "today" is never consulted, so a
* link decodes the same whenever it is opened.
*/
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
function imageryKey(product, year, month, day) {
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
const iso = Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: '';
return product && iso === `${year}-${month}-${day}`
? `${product}:${iso}`
: null;
}
function imageryPinOption(key, token) {
return Object.freeze({
key,
token,
defaultValue: null,
normalize: (value) => {
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
typeof value === 'string' ? value.trim() : '',
);
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
},
encode: (value) => {
const [product, day] = value.split(':');
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
},
decode: (value) => {
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
typeof value === 'string' ? value : '',
);
return match
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
: null;
},
});
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -249,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
'recent-imagery': Object.freeze([
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
imageryPinOption('a', 'a'),
imageryPinOption('b', 'b'),
// 0 one image, 1 image against the basemap, 2 two images A / B.
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
// The swipe position is one comparison's framing, not a preference: a
// stored split resurfaced in the next session's first comparison.
shareOnlyOption(
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
),
booleanOption('viirs', 'v', false),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -331,6 +480,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
token: '2',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'flights',
token: 'f',
@@ -366,6 +520,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'radio',
}),
Object.freeze({
id: 'recent-imagery',
token: '1',
disposition: 'enabled+options',
optionOwner: 'recent-imagery',
}),
Object.freeze({
id: 'rocket-launches',
token: 'x',
@@ -384,6 +544,35 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
Object.freeze({
id: 'weather-cyclones',
token: 'y',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'weather-lightning',
token: 'l',
disposition: 'enabled+options',
optionOwner: 'weather-lightning',
}),
Object.freeze({
id: 'weather-radar',
token: 'v',
disposition: 'enabled+options',
optionOwner: 'weather-radar',
}),
Object.freeze({
id: 'weather-satellite',
token: 'o',
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wind',
token: 'k',
disposition: 'enabled+options',
optionOwner: 'wind',
}),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(
@@ -616,13 +805,28 @@ export function decodeLayerStateParams(params) {
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
}
/** Stable local-storage representation (full IDs for debuggability). */
/** Options with share-link-only values reset to their defaults. */
function withoutShareOnlyOptions(options) {
const out = {};
for (const ownerId of OPTION_OWNER_IDS) {
out[ownerId] = { ...(options?.[ownerId] || {}) };
for (const spec of optionSpecs(ownerId)) {
if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
}
}
return out;
}
/**
* Stable local-storage representation (full IDs for debuggability), with
* share-link-only options at their defaults.
*/
export function serializeStoredLayerState(state) {
const normalized = normalizeLayerState(state);
return JSON.stringify({
v: LAYER_STATE_VERSION,
l: normalized.enabledLayerIds,
o: normalized.options,
o: withoutShareOnlyOptions(normalized.options),
});
}
@@ -634,7 +838,7 @@ export function parseStoredLayerState(raw) {
return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
options: parsed.o,
options: withoutShareOnlyOptions(parsed.o),
});
} catch {
return null;
+1095 -274
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
'enabled-only',
'enabled+options',
'enabled+mirrored-options',
// Registered but never serialized (for example hardware-local layers).
'local-only',
]);
function isAbortError(error) {
@@ -2302,6 +2304,14 @@ export class LayerLifecycle {
return () => this._activityListeners.delete(callback);
}
/**
* Ask presentation to repaint layer rows now, for data that lands outside
* the manager tick (Transit's proximity polls, Directions' route steps).
*/
refreshLayerStats() {
this._publishActivity({ type: 'status' });
}
_publishActivity(change) {
for (const callback of this._activityListeners) {
try {
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+107 -1
View File
@@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
+459
View File
@@ -0,0 +1,459 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
LOCAL_RECEIVERS_ROUTE,
createLocalReceiversHandler,
localReceiversProxy,
parseLocalReceiverFeeds,
} from '../../server/providers/local-receivers.js';
import { localProviderPlugins } from '../../server/providers/local.js';
const FIXTURE = readFileSync(
new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url),
'utf8',
);
const FIXTURE_NOW_MS = JSON.parse(FIXTURE).now * 1000;
const FEED_1090 = 'http://localhost:8080/data/aircraft.json';
const FEED_978 = 'http://127.0.0.1:8978/data/aircraft.json';
function quietLogger() {
const lines = [];
return {
lines,
warn: (line) => lines.push(line),
info: (line) => lines.push(line),
};
}
function respond(status = 200, body = FIXTURE, headers = {}) {
return new Response(body, {
status,
headers: { 'content-type': 'application/json', ...headers },
});
}
async function call(handler, method = 'GET') {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler.handle({ url: '/', method }, res);
return { ...res, json: JSON.parse(res.body) };
}
test('LOCAL_RECEIVER_FEEDS accepts local http(s) aircraft.json feeds per band', () => {
const { configured, feeds } = parseLocalReceiverFeeds(
` 1090=${FEED_1090} , 978=http://skyaware.local/skyaware978/data/aircraft.json,1090=https://192.168.1.20:8443/tar1090/data/aircraft.json`,
);
assert.equal(configured, true);
assert.deepEqual(
feeds.map(({ band, label, url }) => ({ band, label, url })),
[
{ band: '1090', label: '1090 MHz #1', url: FEED_1090 },
{
band: '978',
label: '978 MHz UAT',
url: 'http://skyaware.local:80/skyaware978/data/aircraft.json',
},
{
band: '1090',
label: '1090 MHz #2',
url: 'https://192.168.1.20:8443/tar1090/data/aircraft.json',
},
],
);
assert.deepEqual(parseLocalReceiverFeeds(undefined), {
configured: false,
feeds: [],
});
assert.equal(parseLocalReceiverFeeds(' , ').configured, false);
});
test('LOCAL_RECEIVER_FEEDS rejects public, link-local and named hosts, bad paths and bands', () => {
const cases = {
'1090=http://8.8.8.8/data/aircraft.json': 'host must be',
'1090=http://169.254.169.254/latest/aircraft.json': 'host must be',
'1090=http://example.com/data/aircraft.json': 'host must be',
'1090=http://[::1]:8080/data/aircraft.json': 'host must be',
'1090=http://localhost:8080/data/receiver.json': 'path must end',
'1090=http://localhost:8080/data/aircraft.json.bak': 'path must end',
'1090=http://localhost:8080/data/aircraft.json?x=1': 'query',
'1090=http://user:pw@localhost:8080/data/aircraft.json': 'credentials',
'1090=ftp://localhost/data/aircraft.json': 'scheme',
'1090=not a url': 'not a valid URL',
'868=http://localhost:8080/data/aircraft.json': 'band must be',
'http://localhost:8080/data/aircraft.json': 'expected band=url',
};
for (const [entry, reason] of Object.entries(cases)) {
const [feed] = parseLocalReceiverFeeds(entry).feeds;
assert.equal(feed.url, undefined, `${entry} must not be fetchable`);
assert.match(feed.reason, new RegExp(reason), entry);
}
const [badBand] = parseLocalReceiverFeeds(
'868=http://localhost/aircraft.json',
).feeds;
assert.equal(badBand.band, null);
assert.equal(badBand.label, 'entry 1');
});
test('an unconfigured route answers 200 without fetching anything', async (t) => {
t.mock.method(globalThis, 'fetch', () => {
throw Error('must not fetch');
});
const handler = createLocalReceiversHandler({
feedsValue: undefined,
logger: quietLogger(),
});
const res = await call(handler);
assert.equal(res.status, 200);
assert.deepEqual(res.json, { configured: false, feeds: [], records: [] });
});
test('construction mounts the route once and neither parses nor fetches', (t) => {
t.mock.method(globalThis, 'fetch', () => {
throw Error('construction must not fetch');
});
const plugins = localProviderPlugins();
assert.equal(
plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length,
1,
);
const routes = [];
localReceiversProxy({
feedsValue: '',
logger: quietLogger(),
}).configureServer({ middlewares: { use: (route) => routes.push(route) } });
assert.deepEqual(routes, [LOCAL_RECEIVERS_ROUTE]);
});
test('live feeds are read in parallel and labelled with their band', async () => {
const calls = [];
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090},978=${FEED_978}`,
now: () => FIXTURE_NOW_MS + 2_000,
logger: quietLogger(),
fetchImpl: async (url, options) => {
calls.push({ url, redirect: options.redirect });
return respond();
},
});
const { status, json } = await call(handler);
assert.equal(status, 200);
assert.deepEqual(
calls.sort((a, b) => (a.url < b.url ? 1 : -1)),
[
{ url: FEED_1090, redirect: 'manual' },
{ url: FEED_978, redirect: 'manual' },
],
);
assert.equal(json.configured, true);
assert.deepEqual(json.feeds, [
{
band: '1090',
label: '1090 MHz',
status: 'live',
aircraft: 4,
ageMs: 2_000,
},
{
band: '978',
label: '978 MHz UAT',
status: 'live',
aircraft: 4,
ageMs: 2_000,
},
]);
assert.equal(json.records.length, 8);
const shinr = json.records.filter((record) => record.icao === 'ae5d8a');
assert.deepEqual(
shinr.map(({ band, source }) => ({ band, source })),
[
{ band: '1090', source: 'feed' },
{ band: '978', source: 'feed' },
],
);
// Ages are anchored at the feed's own `now`.
assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600);
assert.equal(shinr[0].lat, 30.269662);
});
test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => {
const logger = quietLogger();
const handler = createLocalReceiversHandler({
feedsValue: [
`1090=${FEED_1090}`,
`978=${FEED_978}`,
'1090=http://10.0.0.5/redirect/aircraft.json',
'1090=http://10.0.0.6/big/aircraft.json',
'1090=http://10.0.0.7/error/aircraft.json',
'978=http://10.0.0.8/garbage/aircraft.json',
'1090=http://8.8.8.8/aircraft.json',
].join(','),
now: () => FIXTURE_NOW_MS + 10_001,
logger,
maxBytes: 4_096,
fetchImpl: async (url) => {
if (url === FEED_978) throw new TypeError('connect ECONNREFUSED secret');
if (url.includes('/redirect/'))
return respond(302, '', { location: 'http://169.254.169.254/' });
if (url.includes('/big/'))
return respond(200, `{"aircraft":[${' '.repeat(5_000)}]}`);
if (url.includes('/error/'))
return respond(500, 'upstream internal detail');
if (url.includes('/garbage/')) return respond(200, '<html>');
if (url.includes('8.8.8.8')) throw Error('invalid feed was fetched');
return respond();
},
});
const { json, body } = await call(handler);
assert.deepEqual(
json.feeds.map(({ band, status }) => `${band} ${status}`),
[
'1090 stale',
'978 unreachable',
'1090 unreachable',
'1090 unreachable',
'1090 unreachable',
'978 unreachable',
'1090 invalid',
],
);
assert.equal(json.feeds[0].ageMs, 10_001);
assert.equal(json.records.length, 4, 'stale feed records still flow');
for (const secret of ['ECONNREFUSED', 'secret', 'internal detail', '169.254'])
assert.equal(body.includes(secret), false, `${secret} leaked`);
assert.equal(
body.includes('10.0.0'),
false,
'feed addresses stay server-side',
);
assert.ok(
logger.lines.some((line) =>
/LOCAL_RECEIVER_FEEDS feed-7 .* rejected: host must be/.test(line),
),
);
assert.ok(logger.lines.some((line) => /REDIRECT_REFUSED/.test(line)));
assert.ok(logger.lines.some((line) => /RESPONSE_TOO_LARGE/.test(line)));
});
test('a feed that exceeds the timeout is unreachable', async () => {
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090}`,
logger: quietLogger(),
timeoutMs: 5,
fetchImpl: (url, { signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
});
const { json } = await call(handler);
assert.equal(json.feeds[0].status, 'unreachable');
});
test('requests within a second share one read of every feed', async () => {
let clock = FIXTURE_NOW_MS;
let fetches = 0;
const handler = createLocalReceiversHandler({
feedsValue: `1090=${FEED_1090}`,
now: () => clock,
logger: quietLogger(),
fetchImpl: async () => {
fetches += 1;
return respond();
},
});
await Promise.all([call(handler), call(handler), call(handler)]);
assert.equal(fetches, 1, 'concurrent requests are single-flight');
clock += 500;
await call(handler);
assert.equal(fetches, 1, 'cached for about a second');
clock += 1_000;
await call(handler);
assert.equal(fetches, 2);
const res = await call(handler, 'POST');
assert.equal(res.status, 405);
});
test('a feed name is resolved, every address checked, and the connection pinned to it', async () => {
const lookups = [];
const answers = {
'good.local': [{ address: '10.0.0.9', family: 4 }],
'public.local': [{ address: '93.184.216.34', family: 4 }],
'linklocal.local': [{ address: '169.254.169.254', family: 4 }],
'mixed.local': [
{ address: '192.168.1.4', family: 4 },
{ address: '8.8.8.8', family: 4 },
],
};
const fetched = [];
const logger = quietLogger();
const handler = createLocalReceiversHandler({
feedsValue: [
'1090=http://good.local:8080/data/aircraft.json',
'1090=http://public.local:8080/data/aircraft.json',
'978=http://linklocal.local/data/aircraft.json',
'978=http://mixed.local/data/aircraft.json',
`1090=${FEED_978}`,
].join(','),
now: () => FIXTURE_NOW_MS,
logger,
lookupImpl: async (hostname, options) => {
lookups.push({ hostname, all: options?.all });
return answers[hostname];
},
fetchImpl: async (url, options) => {
fetched.push({ url, lookup: options.lookup });
return respond();
},
});
const { json } = await call(handler);
assert.deepEqual(
json.feeds.map(({ status }) => status),
['live', 'unreachable', 'unreachable', 'unreachable', 'live'],
);
assert.deepEqual(
lookups.map(({ hostname }) => hostname).sort(),
['good.local', 'linklocal.local', 'mixed.local', 'public.local'],
'IP literals are not resolved',
);
assert.ok(
lookups.every(({ all }) => all === true),
'every address',
);
assert.deepEqual(
fetched.map(({ url }) => url).sort(),
[FEED_978, 'http://good.local:8080/data/aircraft.json'],
'a name resolving outside loopback/RFC1918 is never contacted',
);
// The connection resolves only to the validated address, whatever the
// resolver would answer later.
answers['good.local'] = [{ address: '93.184.216.34', family: 4 }];
const pinned = fetched.find(({ url }) => url.includes('good.local')).lookup;
assert.equal(typeof pinned, 'function');
const one = await new Promise((resolve) =>
pinned('good.local', {}, (error, address, family) =>
resolve({ error, address, family }),
),
);
assert.deepEqual(one, { error: null, address: '10.0.0.9', family: 4 });
const all = await new Promise((resolve) =>
pinned('good.local', { all: true }, (error, addresses) =>
resolve(addresses),
),
);
assert.deepEqual(all, [{ address: '10.0.0.9', family: 4 }]);
assert.ok(logger.lines.some((line) => /FORBIDDEN_ADDRESS/.test(line)));
});
test('the default transport connects to the validated address, not a re-resolution', async (t) => {
const { createServer } = await import('node:http');
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ now: FIXTURE_NOW_MS / 1000, aircraft: [] }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const { port } = server.address();
// `receiver-pin-test.local` has no real DNS entry: only the pinned lookup
// can reach the loopback server.
const handler = createLocalReceiversHandler({
feedsValue: `1090=http://receiver-pin-test.local:${port}/data/aircraft.json`,
now: () => FIXTURE_NOW_MS,
logger: quietLogger(),
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
});
const { json } = await call(handler);
assert.equal(json.feeds[0].status, 'live');
});
test('null-body statuses (204, 205, 304) from a named feed are a clean feed error, never an uncaught exception', async (t) => {
const { createServer } = await import('node:http');
const server = createServer((req, res) => {
const status = Number(req.url.split('/')[1]);
res.writeHead(status, { 'content-type': 'application/json' });
res.end();
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
const { port } = server.address();
const uncaught = [];
const onUncaught = (error) => uncaught.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
const handler = createLocalReceiversHandler({
feedsValue: [204, 205, 304]
.map(
(status) =>
`1090=http://receiver-null-body.local:${port}/${status}/aircraft.json`,
)
.join(','),
now: () => FIXTURE_NOW_MS,
logger: quietLogger(),
timeoutMs: 300,
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
});
const { status, json } = await call(handler);
// Let any exception thrown inside the response callback surface.
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(uncaught, [], 'no uncaught exception');
assert.equal(status, 200);
assert.deepEqual(
json.feeds.map((feed) => feed.status),
['unreachable', 'unreachable', 'unreachable'],
);
});
test('a stalled DNS lookup fails its feed at the deadline without holding other feeds or later polls', async () => {
let clock = FIXTURE_NOW_MS;
const lookups = [
// Never answers.
() => new Promise(() => {}),
// Answers only after the deadline: the late answer is ignored.
() =>
new Promise((resolve) =>
setTimeout(() => resolve([{ address: '127.0.0.1', family: 4 }]), 80),
),
];
const fetched = [];
const handler = createLocalReceiversHandler({
feedsValue: `1090=http://stalled.local/data/aircraft.json,978=${FEED_978}`,
now: () => clock,
logger: quietLogger(),
timeoutMs: 20,
lookupImpl: () => lookups.shift()(),
fetchImpl: async (url) => {
fetched.push(url);
return respond();
},
});
const within = (promise) =>
Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve('timed out'), 60)),
]);
const first = await within(call(handler));
assert.notEqual(first, 'timed out', 'the snapshot is not held by DNS');
assert.deepEqual(
first.json.feeds.map((feed) => feed.status),
['unreachable', 'live'],
);
clock += 2_000;
const second = await within(call(handler));
assert.notEqual(second, 'timed out', 'a later poll is not held either');
assert.deepEqual(
second.json.feeds.map((feed) => feed.status),
['unreachable', 'live'],
);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.ok(
fetched.every((url) => !url.includes('stalled.local')),
'a late DNS answer is never fetched',
);
});
+150
View File
@@ -2746,6 +2746,12 @@ function makeControlElement() {
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
@@ -3204,6 +3210,7 @@ test('an async layer pushes its own row refresh, and a busy chip refuses clicks'
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
@@ -3336,3 +3343,146 @@ test('row action chips use live disabled state and descriptive counts without wr
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
const { LayerLifecycle } = await import('./lifecycle.js');
const lifecycle = new LayerLifecycle({});
const changes = [];
lifecycle.subscribeActivity((change) => changes.push(change.type));
lifecycle.refreshLayerStats();
assert.deepEqual(changes, ['status']);
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared 3D-model eligibility for the aircraft layers.
*
* Eligibility is by DISTANCE (the mode's add/keep band) with ON-SCREEN
* PRIORITY under the cap, in four visible-first passes so the slots are spent
* on what the operator can see:
* 1. KEEP on-screen contacts that already have a model (hysteresis for
* visible planes);
* 2. ADD on-screen new contacts inside the add radius, nearest first;
* 3. KEEP off-screen contacts that already have a model;
* 4. ADD off-screen new contacts inside the add radius with leftover slots.
* KEEP is split by frustum so an off-screen retained model can never starve
* an on-screen contact that wants one.
*
* Portable: no Cesium, DOM or layer state.
*/
/**
* @param {Array<[string, number, boolean]>} candidates `[id, distanceSq,
* onScreen]` for every contact inside the KEEP radius, sorted nearest first.
* @param {object} options
* @param {number} options.cap Maximum number of eligible contacts.
* @param {number} options.addDistSq Squared ADD radius (m²).
* @param {(id: string) => boolean} options.isModeled Whether the contact
* already has a model (live, not merely pending).
* @returns {Set<string>} Contacts that may hold a model this tick.
*/
export function selectModelEligible(candidates, { cap, addDistSq, isModeled }) {
const eligible = new Set();
const passes = [
(onScreen, modeled) => onScreen && modeled,
(onScreen, modeled, inAdd) => onScreen && inAdd,
(onScreen, modeled) => !onScreen && modeled,
(onScreen, modeled, inAdd) => !onScreen && inAdd,
];
for (const admit of passes) {
for (const [id, distanceSq, onScreen] of candidates) {
if (eligible.size >= cap) return eligible;
if (eligible.has(id)) continue;
if (admit(onScreen, isModeled(id), distanceSq <= addDistSq))
eligible.add(id);
}
}
return eligible;
}
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectModelEligible } from './modelEligibility.js';
const ADD_SQ = 100 * 100;
test('visible contacts win the cap before off-screen retained models', () => {
// Nearest first: an off-screen retained model, then two on-screen contacts.
const candidates = [
['off-kept', 10, false],
['on-new', 20, true],
['on-kept', 30, true],
];
const modeled = new Set(['off-kept', 'on-kept']);
const eligible = selectModelEligible(candidates, {
cap: 2,
addDistSq: ADD_SQ,
isModeled: (id) => modeled.has(id),
});
assert.deepEqual([...eligible], ['on-kept', 'on-new']);
});
test('retained models keep their slot beyond the add radius; new ones do not', () => {
const candidates = [
['near-new', 50, true],
['far-kept', ADD_SQ + 1, true],
['far-new', ADD_SQ + 2, true],
['off-new', 60, false],
];
const eligible = selectModelEligible(candidates, {
cap: 10,
addDistSq: ADD_SQ,
isModeled: (id) => id === 'far-kept',
});
assert.deepEqual([...eligible], ['far-kept', 'near-new', 'off-new']);
});
test('an empty cap admits nothing', () => {
assert.equal(
selectModelEligible([['a', 1, true]], {
cap: 0,
addDistSq: ADD_SQ,
isModeled: () => true,
}).size,
0,
);
});
+22 -19
View File
@@ -11,11 +11,12 @@
*
* PURE data module — no Cesium imports, node-testable. The packs are lazy-
* loaded on first lookup and cached in module scope (bbox/area computed once
* at load). In the browser Vite bundles the JSON via dynamic import; under
* node the same files are read from disk. A failed load is retried on the
* next lookup rather than cached (see `createRetryableLoader`).
* at load) through `loadBundledJson`, which works in the browser and under
* node:test. A failed load is retried on the next lookup rather than cached
* (see `createRetryableLoader`).
*/
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
const EARTH_RADIUS_KM = 6371;
@@ -120,20 +121,22 @@ function suffixVariants(norm) {
/** @type {Array|null} flat entry list for listRegions() */
let _entries = null;
async function loadPackFile(base) {
// Vite bundles these JSON files as modules; the import attribute is what Node
// needs to load the same files under node:test (same pattern as
// neighborhoodPolygons.js). One path, so no node: import reaches the browser.
const mod =
base === 'regions'
? await import('./local_data/natural_earth/regions.json', {
with: { type: 'json' },
})
: await import('./local_data/natural_earth/marine.json', {
with: { type: 'json' },
});
return mod.default || mod;
}
const PACKS = {
regions: {
url: new URL('./local_data/natural_earth/regions.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/regions.json', {
with: { type: 'json' },
}),
},
marine: {
url: new URL('./local_data/natural_earth/marine.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/marine.json', {
with: { type: 'json' },
}),
},
};
function buildEntries(pack, kind) {
const out = [];
@@ -175,8 +178,8 @@ function buildEntries(pack, kind) {
*/
const loadIndex = createRetryableLoader(async () => {
const [regions, marine] = await Promise.all([
loadPackFile('regions'),
loadPackFile('marine'),
loadBundledJson(PACKS.regions.url, PACKS.regions.importJson),
loadBundledJson(PACKS.marine.url, PACKS.marine.importJson),
]);
_entries = [
...buildEntries(regions, 'natural'),
+7 -5
View File
@@ -10,6 +10,7 @@
* touching this module — see `local_data/neighborhoods/SOURCE.md`.
*/
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
@@ -17,7 +18,11 @@ const CITY_FILES = [
{
id: 'san-francisco',
bbox: [-122.55, 37.7, -122.35, 37.84],
loader: () =>
url: new URL(
'./local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
importJson: () =>
import('./local_data/neighborhoods/san-francisco.json', {
with: { type: 'json' },
}),
@@ -107,10 +112,7 @@ function cityLoader(city) {
let loader = _cityLoaders.get(city.id);
if (!loader) {
loader = createRetryableLoader(async () => {
// One path for both runtimes: Vite bundles the JSON as a module, and the
// import attribute is what Node needs to load the same file under node:test.
const mod = await city.loader();
const fc = mod.default || mod;
const fc = await loadBundledJson(city.url, city.importJson);
return Array.isArray(fc.features) ? fc.features : [];
});
_cityLoaders.set(city.id, loader);
+4
View File
@@ -1059,6 +1059,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
this.parentElement = null;
}
setAttribute() {}
// This fixture has no parsed DOM children; it exercises native label
// ownership, not the replay overlay's separately rendered icon children.
querySelectorAll() { return []; }
querySelector() { return null; }
getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; }
getContext() { return this.tagName === 'CANVAS' ? context : null; }
}
+458
View File
@@ -0,0 +1,458 @@
// src/data/satellitePass.js
/**
* Satellite pass prediction with naked-eye visibility.
* Look angles come from SGP4; rise/set are bisected to sub-second precision,
* the peak is refined with a parabola, and visibility uses the USNO
* low-precision Sun position with a cylindrical Earth shadow. Scan pattern from
* skylight (MIT) shared/src/celestial.ts nextISSPass.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
const EARTH_RADIUS_KM = 6371.0;
const AU_KM = 149597870.7;
/**
* Observer look angles at an instant, or null when propagation fails.
* @param {Object} satrec SGP4 satellite record
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @param {number} latDeg Observer latitude in degrees [-90, 90]
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
* @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null}
*/
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
let pv;
try {
pv = propagate(satrec, date);
} catch {
return null;
}
const pos = pv && pv.position;
if (
!pos ||
typeof pos === 'boolean' ||
![pos.x, pos.y, pos.z].every(Number.isFinite)
)
return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
satECI: pos,
};
}
/**
* Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates.
* Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0.
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }}
*/
export function solarPositionECI(dateMs) {
const JD = dateMs / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days since J2000.0
const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R;
const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360;
const L =
((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) *
D2R;
const e = (23.439 - 0.00000036 * D) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
return {
x: AU_KM * Math.cos(dec) * Math.cos(ra),
y: AU_KM * Math.cos(dec) * Math.sin(ra),
z: AU_KM * Math.sin(dec),
raDeg: (((ra * R2D) % 360) + 360) % 360,
decDeg: dec * R2D,
};
}
/**
* Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow).
* Uses a cylindrical Earth shadow model with mean radius 6,371 km.
* @param {{ x: number, y: number, z: number }} satECI Satellite position in km
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {boolean} True if sunlit, false if in eclipse (Earth shadow)
*/
export function isSatelliteSunlit(satECI, dateMs) {
if (!satECI || typeof satECI === 'boolean') return false;
const sun = solarPositionECI(dateMs);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz;
if (proj > 0) return true; // Sunward side of Earth
const dx = satECI.x - proj * sx;
const dy = satECI.y - proj * sy;
const dz = satECI.z - proj * sz;
return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM;
}
/**
* Compute the observer local solar elevation angle in degrees.
* @param {number} latDeg Observer latitude in degrees
* @param {number} lonDeg Observer longitude in degrees
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {number} Solar elevation in degrees [-90, 90]
*/
export function observerSolarElevation(latDeg, lonDeg, dateMs) {
const sun = solarPositionECI(dateMs);
const gmst = gstime(new Date(dateMs));
const gmstDeg = (((gmst * R2D) % 360) + 360) % 360;
const localHourAngleDeg =
(((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360;
const H = localHourAngleDeg * D2R;
const phi = latDeg * D2R;
const dec = sun.decDeg * D2R;
const sinElev =
Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H);
return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D;
}
/**
* Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night).
* Default threshold is -6° (civil twilight).
* @param {number} latDeg Observer latitude
* @param {number} lonDeg Observer longitude
* @param {number} dateMs UTC timestamp
* @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight)
* @returns {boolean} True if sun is at or below the threshold
*/
export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) {
return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg;
}
/**
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
* @private
*/
function _bisectBoundary(
satrec,
latDeg,
lonDeg,
tLow,
tHigh,
minElevDeg,
isRising,
steps = 10,
) {
let low = tLow;
let high = tHigh;
for (let i = 0; i < steps; i++) {
const mid = (low + high) / 2;
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (isRising) {
if (elev >= minElevDeg) {
high = mid;
} else {
low = mid;
}
} else {
if (elev >= minElevDeg) {
low = mid;
} else {
high = mid;
}
}
}
return isRising ? Math.ceil(high) : Math.floor(low);
}
/**
* Find the next pass of a satellite over an observer location.
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
* parabolic interpolation for the peak, and optional naked-eye visibility filtering.
*
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude [-90, 90]
* @param {number} options.lonDeg Observer longitude [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
* @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible
* @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight)
* @returns {{
* riseMs: number,
* setMs: number,
* maxElevDeg: number,
* maxElevMs: number,
* riseAzDeg: number,
* visible: boolean,
* sunlit: boolean,
* observerDark: boolean
* } | null} `visible` is true when any sampled part of the pass is sunlit under a
* dark sky; `sunlit` and `observerDark` describe the peak only.
*/
export function findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 20,
fineStepSec = 5,
requireVisible = false,
maxSunElevDeg = -6,
}) {
if (
!satrec ||
![
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
maxSunElevDeg,
].every(Number.isFinite) ||
Math.abs(latDeg) > 90 ||
Math.abs(lonDeg) > 180 ||
Math.abs(fromMs) > 8.63e15 ||
minElevDeg < 0 ||
minElevDeg > 90 ||
horizonHours <= 0 ||
horizonHours > 72 ||
coarseStepSec < 1 ||
coarseStepSec > 120 ||
fineStepSec < 0.1 ||
fineStepSec > 60
)
return null;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarseMs = coarseStepSec * 1000;
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
let searchCursor = fromMs;
while (searchCursor <= horizonMs) {
let tPrev = searchCursor;
let tHit = null;
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
// Check if we start already inside a pass
if (elevPrev >= minElevDeg) {
tHit = searchCursor;
} else {
// Coarse forward scan
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (elev >= minElevDeg) {
tHit = t;
break;
}
tPrev = t;
elevPrev = elev;
}
}
if (tHit == null) return null; // No pass found in remaining horizon
// Resolve rise
let riseMs;
if (tHit === searchCursor && elevPrev >= minElevDeg) {
riseMs = searchCursor;
} else {
riseMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tPrev,
tHit,
minElevDeg,
true,
);
}
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
let tCurr = riseMs;
let maxElevDeg = -90;
let maxElevMs = riseMs;
let tStepPrev = riseMs;
let setMs = null;
let hasVisibleSegment = false;
let elevPrevSample = null;
let peakLeftElev = null;
let peakRightElev = null;
let peakStepMs = fineMs;
// Visibility at an instant inside [riseMs, setMs], so no elevation gate
const checkVisibility = (tMs) => {
if (hasVisibleSegment) return;
const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg);
if (!look) return;
const sunlit = isSatelliteSunlit(look.satECI, tMs);
const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg);
if (sunlit && dark) {
hasVisibleSegment = true;
}
};
// Check visibility at rise
checkVisibility(riseMs);
// Transit forward in fine steps
while (tCurr <= horizonMs) {
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (look) {
const sunlit = isSatelliteSunlit(look.satECI, tCurr);
const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg);
if (sunlit && dark && elev >= minElevDeg) {
hasVisibleSegment = true;
}
}
if (elev < minElevDeg && tCurr > riseMs) {
if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
// Below threshold: bisect set between tStepPrev and tCurr
setMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tStepPrev,
tCurr,
minElevDeg,
false,
);
break;
}
if (elev > maxElevDeg) {
maxElevDeg = elev;
maxElevMs = tCurr;
peakLeftElev = elevPrevSample;
peakRightElev = null;
} else if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
elevPrevSample = elev;
tStepPrev = tCurr;
tCurr += fineMs;
}
if (setMs == null) {
setMs = Math.min(horizonMs, tCurr);
}
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
// using adjacent transit samples (zero additional SGP4 calls)
let refinedPeakMs = maxElevMs;
let refinedPeakElevDeg = maxElevDeg;
if (
Number.isFinite(peakLeftElev) &&
Number.isFinite(peakRightElev) &&
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
) {
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
if (Math.abs(shift) < peakStepMs) {
refinedPeakMs = Math.max(
riseMs,
Math.min(setMs, Math.round(maxElevMs + shift)),
);
refinedPeakElevDeg = Math.min(
90,
maxElevDeg -
Math.pow(peakLeftElev - peakRightElev, 2) /
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
);
}
}
// Sun and sky state at the peak
const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg);
const peakSunlit = peakLook
? isSatelliteSunlit(peakLook.satECI, refinedPeakMs)
: false;
const peakDark = isObserverDark(
latDeg,
lonDeg,
refinedPeakMs,
maxSunElevDeg,
);
// Check visibility at the refined peak and at set
checkVisibility(refinedPeakMs);
checkVisibility(setMs);
if (!hasVisibleSegment) {
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
return [
Boolean(look && isSatelliteSunlit(look.satECI, time)),
isObserverDark(latDeg, lonDeg, time, maxSunElevDeg),
];
});
}
// Skip passes with no visible part when requireVisible is set
if (requireVisible && !hasVisibleSegment) {
searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs);
continue;
}
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg: refinedPeakElevDeg,
maxElevMs: refinedPeakMs,
riseAzDeg: riseLook ? riseLook.azDeg : 0,
visible: hasVisibleSegment,
sunlit: peakSunlit,
observerDark: peakDark,
};
}
return null;
}
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
export function hasVisibleInterval(start, end, conditionsAt) {
const visible = (states) => states[0] && states[1];
for (let left = start; left < end; left += 5000) {
const right = Math.min(end, left + 5000);
const a = conditionsAt(left);
const b = conditionsAt(right);
if (visible(a) || visible(b)) return true;
const cuts = [left, right];
for (let condition = 0; condition < 2; condition++) {
if (a[condition] === b[condition]) continue;
let low = left;
let high = right;
while (high - low > 20) {
const mid = (low + high) / 2;
if (conditionsAt(mid)[condition] === a[condition]) low = mid;
else high = mid;
}
cuts.push((low + high) / 2);
}
cuts.sort((a, b) => a - b);
for (let i = 1; i < cuts.length; i++) {
if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true;
}
}
return false;
}
+282
View File
@@ -0,0 +1,282 @@
// src/data/satellitePass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec, propagate } from 'satellite.js';
import {
findNextSatellitePass,
hasVisibleInterval,
lookAnglesAt,
solarPositionECI,
isSatelliteSunlit,
observerSolarElevation,
isObserverDark,
} from './satellitePass.js';
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
test('solarPositionECI matches the Sun at J2000.0', () => {
// Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC
// gives RA 281.289°, Dec -23.033°.
const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0));
assert.ok(
Math.abs(j2000.raDeg - 281.289) < 0.05,
`RA ${j2000.raDeg}° within 0.05°`,
);
assert.ok(
Math.abs(j2000.decDeg - -23.033) < 0.05,
`Dec ${j2000.decDeg}° within 0.05°`,
);
});
test('solarPositionECI calculates reasonable solar coordinates across seasons', () => {
// Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0°
const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0));
assert.ok(
Math.abs(equinox.decDeg) < 1.0,
'declination near equinox must be close to 0°',
);
// Near Summer Solstice (June 21, 2020): declination ≈ +23.44°
const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeSummer.decDeg - 23.44) < 0.5,
'summer declination ≈ 23.44°',
);
// Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44°
const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeWinter.decDeg - -23.44) < 0.5,
'winter declination ≈ -23.44°',
);
});
test('observerSolarElevation distinguishes local noon from local midnight', () => {
// Greenwich noon on equinox: solar elevation should be close to 90 - lat
const greenwichNoon = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 12, 0, 0),
);
assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon');
// Greenwich midnight: solar elevation must be deeply negative
const greenwichMidnight = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 0, 0, 0),
);
assert.ok(
greenwichMidnight < -20,
'sun must be well below horizon at midnight',
);
assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6));
});
test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => {
const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0);
const sun = solarPositionECI(summerDate);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
// Point toward the Sun: must be sunlit
const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz };
assert.equal(isSatelliteSunlit(daysideSat, summerDate), true);
// Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed
const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false);
// Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit
const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(highAltSat, summerDate), true);
});
test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => {
const satrec = twoline2satrec(L1, L2);
// Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone
const tShadowed = FROM_MS + 17 * 60_000;
const pvShadowed = propagate(satrec, new Date(tShadowed));
assert.equal(
isSatelliteSunlit(pvShadowed.position, tShadowed),
false,
'ISS must be eclipsed in Earth umbra during orbital night',
);
// Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight
const tSunlit = FROM_MS + 18 * 60_000;
const pvSunlit = propagate(satrec, new Date(tSunlit));
assert.equal(
isSatelliteSunlit(pvSunlit.position, tSunlit),
true,
'ISS must be illuminated upon orbital sunrise',
);
});
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
});
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Verify bisection accuracy at rise time:
const beforeRise = lookAnglesAt(
satrec,
pass.riseMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atRise = lookAnglesAt(
satrec,
pass.riseMs,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const afterRise = lookAnglesAt(
satrec,
pass.riseMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeRise && beforeRise.elevDeg < 10.05,
'elevation before riseMs must be below threshold',
);
assert.ok(
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
'elevation at riseMs must be within 0.2° of 10°',
);
assert.ok(
afterRise && afterRise.elevDeg > 9.95,
'elevation after riseMs must be rising above threshold',
);
// Verify bisection accuracy at set time:
const beforeSet = lookAnglesAt(
satrec,
pass.setMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
const afterSet = lookAnglesAt(
satrec,
pass.setMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeSet && beforeSet.elevDeg > 9.95,
'elevation before setMs must be above threshold',
);
assert.ok(
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
'elevation at setMs must be within 0.2° of 10°',
);
assert.ok(
afterSet && afterSet.elevDeg < 10.05,
'elevation after setMs must be below threshold',
);
// Parabolic culmination accuracy: max elevation must be peak
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
});
test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => {
const satrec = twoline2satrec(L1, L2);
const geometricPass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
requireVisible: false,
});
assert.ok(geometricPass);
const visiblePass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
horizonHours: 72,
requireVisible: true,
});
assert.ok(visiblePass, 'expected at least one visible pass within horizon');
assert.equal(visiblePass.visible, true);
assert.ok(visiblePass.riseMs >= geometricPass.riseMs);
});
test('findNextSatellitePass samples the peak at fineStepSec', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
assert.ok(fine && coarse);
// The step is used: peak samples differ.
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
// Rise comes from bisection, so it does not depend on the step.
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
// Parabolic refinement keeps 10s sampling close to the 1s peak.
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
});
test('invalid numerical search options return promptly without unbounded scans', () => {
const satrec = twoline2satrec(L1, L2);
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
for (const change of [
{ coarseStepSec: 0 },
{ coarseStepSec: -1 },
{ horizonHours: Infinity },
{ horizonHours: 73 },
{ fineStepSec: NaN },
{ latDeg: 91 },
{ fromMs: NaN },
]) {
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
}
});
test('visibility catches a short overlap between shadow exit and dawn', () => {
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]),
true,
);
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]),
false,
);
});
test('a pass already underway clips rise to the requested start', () => {
const satrec = twoline2satrec(L1, L2);
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
const clipped = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: first.maxElevMs,
});
assert.equal(clipped.riseMs, first.maxElevMs);
assert.ok(clipped.setMs >= clipped.riseMs);
assert.ok(
clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs,
);
});
+154
View File
@@ -0,0 +1,154 @@
// Focused tests for the satellite analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import satellitesLayer, {
mapAnalystRecord,
_clearDenseCatalogStateForTest,
_setTrackedSatelliteRefreshStateForTest,
} from './satellites.js';
const ISS_NORAD = 25544;
const ISS_L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const ISS_L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const FULL_RAW = {
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'stations',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
};
test('satellite analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RAW);
assert.deepEqual(r, {
id: 'ISS (ZARYA)',
noradId: '25544',
name: 'ISS (ZARYA)',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
satelliteClass: 'STATION · ISS',
group: 'stations',
});
});
test('satellite analyst record: nameless row falls back to SAT-norad id', () => {
const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 });
assert.equal(r.id, 'SAT-25544');
assert.equal(r.name, null);
});
test('satellite analyst record: ISS class wins even when ingested as visual', () => {
const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' });
assert.equal(r.satelliteClass, 'STATION · ISS');
});
test('satellite analyst record: GPS group maps to NAV · GPS', () => {
const r = mapAnalystRecord({
noradId: 24876,
name: 'GPS BIIR-2 (PRN 13)',
group: 'gps-ops',
lat: 0,
lon: 0,
altitudeM: 20_200_000,
speedMps: 3870,
});
assert.equal(r.satelliteClass, 'NAV · GPS');
assert.equal(r.group, 'gps-ops');
});
test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined);
assert.equal(r.id, 'SAT-00000');
assert.equal(r.satelliteClass, null);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord({
...FULL_RAW,
position: { x: 1 },
satrec: { dummy: true },
});
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('position' in r, false);
assert.equal('satrec' in r, false);
});
test('satellite getAnalystRecords: disabled or empty catalog returns []', () => {
_clearDenseCatalogStateForTest();
assert.deepEqual(satellitesLayer.getAnalystRecords(), []);
});
test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const point = {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000),
};
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point,
viewer: {},
now: () => Date.now(),
neighbours: [
{
noradId: 99999,
name: 'STARLINK-TEST',
satrec,
group: 'dense',
point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) },
},
{
noradId: 24876,
name: 'GPS BIIR-2',
satrec,
group: 'gps-ops',
point: {
position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000),
},
},
],
});
try {
const capped = satellitesLayer.getAnalystRecords(2);
assert.equal(capped.length, 2);
assert.deepEqual(
capped.map((r) => r.noradId),
['25544', '24876'],
);
assert.equal(
capped.some((r) => r.group === 'dense'),
false,
);
const all = satellitesLayer.getAnalystRecords(10);
assert.equal(all.length, 3);
assert.equal(all[2].noradId, '99999');
assert.equal(all[2].satelliteClass, 'COMMS · STARLINK');
for (const row of all) {
assert.ok(
Number.isFinite(row.lat) && Number.isFinite(row.lon),
`${row.id} needs a geodetic position`,
);
assert.deepEqual(JSON.parse(JSON.stringify(row)), row);
}
} finally {
_clearDenseCatalogStateForTest();
}
});
+3
View File
@@ -41,6 +41,7 @@ export const _clearSatelliteLabelLifecycleForTest =
export const applySatellitePointFocusDeemphasis =
layer.applySatellitePointFocusDeemphasis;
export const getNextIssPass = layer.getNextIssPass;
export const getNextSatellitePass = layer.getNextSatellitePass;
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
@@ -49,3 +50,5 @@ export {
ISS_OVERLAY_SOURCE_OPTIONS,
} from '../layers/satellites/index.js';
export default layer;
export { mapAnalystRecord } from '../layers/satellites/records.js';
+149
View File
@@ -0,0 +1,149 @@
/**
* @file Shared address contract for receiver taps.
*
* A "tap" is a layer fed by hardware the user owns and runs themselves. What
* this module governs is narrower than "tap" in general: it is for taps whose
* upstream address arrives **from the browser at request time**, which is what
* makes the route an SSRF surface. Today that is the Rayhunter device (#56),
* which is addressed as `?base=host:port`, and it would cover an RTL-SDR /
* dump1090 receiver (#57) if that one is built server-side.
*
* It deliberately does NOT cover the other layers in the receiver-taps lane,
* because they are a different shape and forcing them through this check would
* be wrong:
*
* - Meshtastic (#6) reads a fixed public broker (`mqtt.meshtastic.org`).
* - WiGLE (#11) reads a fixed public API behind a token.
* - TAK (#7) takes its host from operator-set env config, not from the
* browser, and a TAK server is frequently a legitimate REMOTE host — a
* private-only rule would break it.
*
* This module is the single place that constraint lives, so taps cannot each
* re-derive it and disagree. The rule is deliberately narrow:
*
* A tap may address the loopback interface or an RFC1918 private network,
* plus the `localhost` and `*.local` (mDNS) names. Nothing else.
*
* That is the whole point of a tap — it reads *your* device on *your* network.
* A tap pointed at a public address is either a mistake or someone using the
* proxy as a confused deputy, and both deserve the same answer.
*
* Two deliberate non-goals, so the next person does not think they were
* oversights:
*
* 1. No general hostname resolution. A name other than `localhost`/`*.local`
* is rejected outright rather than resolved-and-pinned. Local devices are
* reachable by IP or mDNS, so supporting arbitrary names would buy nothing
* and would drag in DNS-rebinding defence (resolve, pin, re-check on every
* redirect) that `server/providers/radio/transport.js` already shows is not
* small. If a tap ever genuinely needs it, pin it there rather than
* loosening the check here.
* 2. No IPv6. Link-local IPv6 carries a zone index (`%eth0`) whose handling is
* platform-specific, and no tap has needed it. Rejected explicitly rather
* than half-supported.
*
* Lives in `src/data/` rather than under `server/providers/` because both
* sides need the same answer: the server as a trust boundary, the browser to
* give immediate feedback on a typed address without a round trip. Sharing one
* module means they cannot drift — the same reason #274 exists about eight
* copies of haversine. `server/providers/gbfs.js` importing
* `src/data/gbfsSource.js` is the established direction for this.
*
* Note on reuse: `isNonGlobalIpv4()` in `server/providers/radio/stations.js` answers a
* neighbouring question and was deliberately NOT reused. It reports malformed
* input such as `999.1.1.1` as non-global, which is the safe direction for the
* radio proxy's "is this public enough to fetch?" test but inverts into
* *allowing* garbage under a tap's "is this local enough to fetch?" test. The
* positive allowlist below cannot invert that way.
*
* @module data/tapAddress
*/
/** Link-local (169.254/16) is excluded wholesale, which also covers the cloud
* metadata address at 169.254.169.254 by construction rather than by blocklist. */
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
/** Single mDNS label or dotted name ending in `.local`, e.g. `rayhunter.local`. */
const MDNS_RE = /^(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})*\.local$/;
const MAX_PORT = 65535;
/**
* Whether a dotted-quad string is a loopback or RFC1918 private address.
*
* Written as a positive allowlist: every octet must parse and be in range
* before any range test runs, so malformed input can never fall through to
* "allowed".
*
* @param {string} host
* @returns {boolean}
*/
export function isLocalIpv4(host) {
const match = IPV4_RE.exec(host);
if (!match) return false;
const [a, b] = [Number(match[1]), Number(match[2])];
const octets = match.slice(1).map(Number);
if (
octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
)
return false;
return (
a === 127 || // loopback
a === 10 || // 10/8
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
(a === 192 && b === 168) // 192.168/16
);
}
/**
* Parse and validate a user-supplied `host:port` tap address.
*
* @param {unknown} raw e.g. `"192.168.1.1:8080"`, `"localhost:8080"`.
* @returns {{host: string, port: number, origin: string}|null} null when the
* address is malformed or not local. Callers must treat null as a 400 and
* must not fall back to a default host — a tap with no valid address has
* nothing to read.
*/
export function parseTapAddress(raw) {
const value = String(raw ?? '')
.trim()
.toLowerCase();
if (!value || value.length > 263) return null;
// Reject a scheme, credentials, path, or query outright rather than
// stripping them: each is a sign the caller meant a URL, not an address,
// and quietly repairing it is how a parser ends up disagreeing with the
// thing that later builds the request.
if (/[/\\?#@]/.test(value) || value.includes('://')) return null;
const colon = value.lastIndexOf(':');
if (colon <= 0 || colon === value.length - 1) return null;
const host = value.slice(0, colon);
const portText = value.slice(colon + 1);
if (!/^\d{1,5}$/.test(portText)) return null;
const port = Number(portText);
if (port < 1 || port > MAX_PORT) return null;
const hostOk =
host === 'localhost' || MDNS_RE.test(host) || isLocalIpv4(host);
if (!hostOk) return null;
return { host, port, origin: `http://${host}:${port}` };
}
/**
* Build an absolute upstream URL for a tap request.
*
* The path is supplied by the tap module, never by the client, so this only
* has to guarantee the two halves join predictably.
*
* @param {{origin: string}} address from {@link parseTapAddress}.
* @param {string} path absolute path beginning with `/`.
* @returns {string}
*/
export function tapUrl(address, path) {
if (!address?.origin)
throw new TypeError('tapUrl: address must come from parseTapAddress');
if (typeof path !== 'string' || !path.startsWith('/')) {
throw new TypeError('tapUrl: path must be an absolute path');
}
return `${address.origin}${path}`;
}
+147
View File
@@ -0,0 +1,147 @@
// src/data/tapAddress.test.mjs
// Contract tests for the shared receiver-tap address check. Pure — no server.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isLocalIpv4, parseTapAddress, tapUrl } from './tapAddress.js';
test('parseTapAddress: accepts loopback and every RFC1918 range', () => {
for (const address of [
'127.0.0.1:8080',
'10.0.0.5:1234',
'172.16.0.1:80',
'172.31.255.254:65535',
'192.168.1.1:8080',
'localhost:8080',
'rayhunter.local:8080',
'dump1090.lan.local:30003',
]) {
assert.ok(parseTapAddress(address), `${address} should be accepted`);
}
});
test('parseTapAddress: rejects public addresses — a tap reads your own network', () => {
for (const address of [
'8.8.8.8:80',
'1.1.1.1:443',
'93.184.216.34:80',
'example.com:80',
'api.wigle.net:443',
// 172.15 and 172.32 sit just outside 172.16/12.
'172.15.0.1:80',
'172.32.0.1:80',
// 192.167/192.169 bracket 192.168/16.
'192.167.1.1:80',
'192.169.1.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects link-local, so the cloud metadata address cannot be reached', () => {
assert.equal(parseTapAddress('169.254.169.254:80'), null);
assert.equal(parseTapAddress('169.254.1.1:80'), null);
});
test('parseTapAddress: malformed dotted-quads are rejected, not treated as private', () => {
// The trap this module exists to avoid: a classifier that reports garbage as
// "not public" would let these through under an is-it-local test.
for (const address of [
'999.1.1.1:80',
'10.0.0:80',
'10.0.0.1.5:80',
'10.0.0.256:80',
'-1.0.0.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects anything shaped like a URL rather than repairing it', () => {
for (const address of [
'http://127.0.0.1:8080',
'127.0.0.1:8080/api/status',
'127.0.0.1:8080?x=1',
'127.0.0.1:8080#f',
'[email protected]:8080',
'127.0.0.1:8080\\x',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects a missing, empty, or out-of-range port', () => {
for (const address of [
'127.0.0.1',
'127.0.0.1:',
'127.0.0.1:0',
'127.0.0.1:65536',
'127.0.0.1:abc',
':8080',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects IPv6 explicitly rather than half-supporting it', () => {
assert.equal(parseTapAddress('[::1]:8080'), null);
assert.equal(parseTapAddress('::1:8080'), null);
assert.equal(parseTapAddress('[fe80::1%eth0]:8080'), null);
});
test('parseTapAddress: handles non-string and absent input without throwing', () => {
for (const value of [null, undefined, 0, {}, [], NaN, true]) {
assert.equal(parseTapAddress(value), null);
}
});
test('parseTapAddress: normalizes case and surrounding whitespace', () => {
const parsed = parseTapAddress(' RayHunter.LOCAL:8080 ');
assert.deepEqual(parsed, {
host: 'rayhunter.local',
port: 8080,
origin: 'http://rayhunter.local:8080',
});
});
test('isLocalIpv4: boundaries of each allowed range', () => {
assert.ok(isLocalIpv4('127.255.255.255'));
assert.ok(isLocalIpv4('10.255.255.255'));
assert.ok(isLocalIpv4('172.16.0.0'));
assert.ok(isLocalIpv4('172.31.255.255'));
assert.ok(!isLocalIpv4('172.15.255.255'));
assert.ok(!isLocalIpv4('172.32.0.0'));
assert.ok(!isLocalIpv4('126.255.255.255'));
assert.ok(!isLocalIpv4('128.0.0.1'));
});
test('tapUrl: joins a parsed address with a module-supplied path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.equal(
tapUrl(address, '/api/qmdl-manifest'),
'http://192.168.1.1:8080/api/qmdl-manifest',
);
});
test('tapUrl: refuses an unparsed address or a relative path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.throws(() => tapUrl(null, '/x'), TypeError);
assert.throws(() => tapUrl({ origin: '' }, '/x'), TypeError);
assert.throws(() => tapUrl(address, 'api/status'), TypeError);
assert.throws(() => tapUrl(address, ''), TypeError);
});
+4 -2
View File
@@ -415,8 +415,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
`${name}: converted contacts are dropped BEFORE entering the capped candidate list`);
// ...and the cap really is applied to that list, so a dropped candidate is a freed slot.
assert.match(source, /modelEligible\.size >= cap/,
// ...and the cap really is applied to that list, so a dropped candidate is a
// freed slot. The four-pass selection itself is the shared
// selectModelEligible (behavior-tested in modelEligibility.test.mjs).
assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/,
`${name}: the cap bounds the candidate-derived eligible set`);
}
});
+1
View File
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
const READOUT_CONTEXT_LAYERS = new Set([
'military-installations',
'alpr-cameras',
'local-adsb',
]);
/**
+46 -4
View File
@@ -123,12 +123,51 @@ export function deriveFetchCenter({
return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' };
}
/**
* Wrapped longitude span between two meridians, in degrees.
*
* Longitude is cyclic, so a box crossing the antimeridian (±180°) has
* `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west`
* goes negative in that case; wrap through 360° instead.
*
* @param {number} west - West edge longitude (degrees).
* @param {number} east - East edge longitude (degrees).
* @returns {number} Span in degrees, always >= 0.
*/
function longitudeSpanDeg(west, east) {
const raw = east - west;
return raw >= 0 ? raw : raw + 360;
}
/** Keep a monotonic longitude range inside the traffic source's legal domain. */
function boundedLongitudeRange(center, span) {
let west = center - span / 2;
let east = center + span / 2;
if (east > 180) {
west -= east - 180;
east = 180;
}
if (west < -180) {
east += -180 - west;
west = -180;
}
return { west, east };
}
/**
* Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`.
*
* Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km)
* but centers the box on the derived look-at point instead of the view
* rectangle's midpoint. Idempotent when `center` is the box's own midpoint.
* rectangle's midpoint. At the antimeridian the whole box shifts just far
* enough to remain inside [-180, 180], because the road source accepts one
* monotonic Overpass bounding box. Idempotent when `center` is the box's own
* midpoint.
*
* Longitude is handled as a cyclic axis for the span calculation, so the
* input span wraps correctly across the antimeridian. Output remains
* monotonic because callers such as `getBoundsCenter` and `boundsOverlap`
* rely on `west <= east`.
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* Source bounds (span donor).
@@ -138,11 +177,14 @@ export function deriveFetchCenter({
*/
export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) {
const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg);
const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg);
const lonSpan = Math.min(
longitudeSpanDeg(bounds.west, bounds.east),
maxSpanDeg,
);
const longitude = boundedLongitudeRange(center.lon, lonSpan);
return {
south: center.lat - latSpan / 2,
north: center.lat + latSpan / 2,
west: center.lon - lonSpan / 2,
east: center.lon + lonSpan / 2,
...longitude,
};
}
+16
View File
@@ -108,3 +108,19 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re-
const twice = clampBoundsAroundCenter(once, midpoint, 0.05);
assert.deepEqual(twice, once);
});
test('span clamp keeps antimeridian bounds monotonic and request-safe', () => {
for (const centerLon of [179.99, -179.99]) {
const result = clampBoundsAroundCenter({
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
}, { lat: 0, lon: centerLon }, 0.05);
assert.ok(result.west >= -180 && result.east <= 180);
assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`);
assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12);
assert.ok(result.west <= centerLon && centerLon <= result.east);
}
});
+13 -12
View File
@@ -189,20 +189,21 @@ export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
let uri = _iconCache.get(key);
if (!uri) {
const size = VIEW + 2 * frame.pad;
// Sensor signatures use a broad rounded envelope, without dark interior
// joints or a second outline eating into the hot core at display resolution.
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
const halfH = 48;
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
const body = profile === 'mono' ? capsule : BODIES[k];
// Reuse the exact outer path. Sensors fill its interior without panel ink.
const silhouette = BODIES[k].match(/<path d="([^"]+)"/)[1];
const body =
profile === 'mono'
? `<path d="${silhouette}" fill="white" />`
: BODIES[k];
const halo =
frame.units > 0
? body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
? profile === 'mono'
? `<path d="${silhouette}" fill="none" stroke="#05080C" stroke-opacity="1" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`
: body.replace(
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
(_m, d) =>
`<path d="${d}" fill="none" stroke="#05080C" stroke-opacity="0.95" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
)
: '';
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
+128 -24
View File
@@ -32,10 +32,10 @@ test('a haloed glyph draws the dark ring under the shipped white body', () => {
assert.notEqual(plain, haloed);
assert.match(
haloed,
/stroke="#000000" stroke-opacity="1" stroke-width="19\.20"/,
/stroke="#05080C" stroke-opacity="1" stroke-width="14\.77"/,
);
assert.ok(
haloed.indexOf('#000000') < haloed.indexOf('fill="white"'),
haloed.indexOf('#05080C') < haloed.indexOf('fill="white"'),
'ring first, body on top',
);
assert.match(haloed, /viewBox="-\d+ -\d+ \d+ \d+"/, 'padded frame');
@@ -97,40 +97,144 @@ test('all 36 variants retain the specified final halo at both display sizes', as
assert.equal(transitIconCacheSize(), 36);
});
test('sensor bodies cover sixty percent of the padded display with opaque white', async () => {
test('every mono raster preserves the normal silhouette mask and aspect at CRT display size', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const rasterBody = async (svg) => {
// Compare fills in the same frame; the separately tested external halo is excluded.
const d =
[...svg.matchAll(/<path d="([^"]+)" fill="white"/g)][0]?.[1] ??
svg.match(/<path d="([^"]+)"\s+fill="white"/)[1];
const { data, info } = await sharp(
Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="-48 -48 96 96"><path d="${d}" fill="white"/></svg>`,
),
)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const mask = [];
let minX = info.width,
minY = info.height,
maxX = 0,
maxY = 0;
for (let y = 0; y < info.height; y++)
for (let x = 0; x < info.width; x++) {
const alpha = data[(y * info.width + x) * 4 + 3];
mask.push(alpha);
if (alpha > 127) {
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
return { mask, aspect: (maxX - minX + 1) / (maxY - minY + 1) };
};
for (const selected of [false, true])
for (const kind of TRANSIT_ICON_KINDS) {
const normal = await rasterBody(
decode(transitIcon(kind, selected ? 96 : 48, { style: 'normal' })),
);
for (const style of ['surveillance', 'thermal', 'noir', 'nvg']) {
const svg = decode(transitIcon(kind, selected ? 96 : 48, { style }));
const mono = await rasterBody(svg);
assert.deepEqual(
mono.mask,
normal.mask,
`${kind}/${style}: no envelope`,
);
assert.ok(
Math.abs(mono.aspect / normal.aspect - 1) <= 0.05,
`${kind}/${style}: aspect`,
);
const display =
(selected ? 30 : 20) * presetSpriteScale(style, selected);
const crt = (selected ? 30 : 20) * presetSpriteScale('retro', selected);
assert.equal(display, crt);
const frame = haloFrame(
presetSpriteOutlinePx(style, selected),
display,
);
const crtFrame = haloFrame(
presetSpriteOutlinePx('retro', selected),
crt,
);
assert.equal(
(display * frame.ratio) / frame.ratio,
(crt * crtFrame.ratio) / crtFrame.ratio,
'unpadded silhouette matches CRT; only external halo padding grows',
);
assert.equal(
(svg.match(/<path /g) || []).length,
2,
'only outer halo and opaque silhouette',
);
assert.match(svg, /fill="white" \/>/);
}
}
});
test('sensor raster halo stays dark across its screen-space band on a white roof', async () => {
const sharp = (await import('sharp')).default;
const { presetSpriteScale, presetSpriteOutlinePx } =
await import('./transitPresetStyle.js');
const { reduceSensorContrast } =
await import('../layers/transit/qaMetrics.js');
const sides = {
bus: 17,
tram: 10,
subway: 11.5,
rail: 10.5,
ferry: 12.5,
unknown: 10,
};
for (const selected of [false, true]) {
const pixels = [];
for (const kind of TRANSIT_ICON_KINDS) {
const display =
(selected ? 30 : 20) * presetSpriteScale('thermal', selected);
assert.equal(display, selected ? 36 : 30);
assert.equal(presetSpriteOutlinePx('thermal', selected), 3);
const frame = haloFrame(
presetSpriteOutlinePx('thermal', selected),
display,
);
const size = Math.round(display * frame.ratio * 8);
const svg = decode(
transitIcon(kind, selected ? 96 : 48, { style: 'thermal' }),
);
const frame = haloFrame(3, display);
const width = Math.round(display * frame.ratio);
const { data, info } = await sharp(Buffer.from(svg))
.resize(width, width)
.ensureAlpha()
.resize(size, size)
.flatten({ background: '#ffffff' })
.raw()
.toBuffer({ resolveWithObject: true });
let white = 0;
for (let i = 0; i < data.length; i += 4)
if (
data[i] >= 240 &&
data[i + 1] >= 240 &&
data[i + 2] >= 240 &&
data[i + 3] >= 240
)
white++;
assert.ok(
white / (info.width * info.height) >= 0.6,
`${kind}/${display}: white ${white / (info.width * info.height)}`,
);
const c = (Math.floor(width / 2) * width + Math.floor(width / 2)) * 4;
assert.deepEqual([...data.subarray(c, c + 4)], [255, 255, 255, 255]);
const sample = (offsetPx) => {
const x = Math.floor(
size / 2 + (offsetPx * size) / (display * frame.ratio),
);
const i = (Math.floor(size / 2) * info.width + x) * info.channels;
return (
(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255
);
};
// The body stays at CRT size; the ring must cover this fixed external band.
const ringMin = sample((sides[kind] * display) / 96 + 1.25);
pixels.push({
key: kind,
verified: true,
centre: sample(0),
ringMin,
ringMax: 1 - ringMin,
background: 1,
});
}
const white = reduceSensorContrast(pixels, 'white', 'thermal');
assert.ok(white.pass, JSON.stringify({ selected, pixels, white }));
const black = reduceSensorContrast(
pixels.map((p) => ({ ...p, centre: 1 - p.centre })),
'black',
'thermal',
);
assert.ok(black.pass, JSON.stringify({ selected, black }));
}
});
+3 -5
View File
@@ -12,7 +12,7 @@
* Encoding per profile, the traffic-dot precedent applied to a glyph:
* - `mono` (surveillance/NVG, thermal/FLIR, noir, cockpit nvg/thermal): the
* sprite is WHITE — the hottest end of grayscale FLIR and of the Ironbow
* ramp, and the brightest phosphor under NVG — drawn larger, with a dark
* ramp, and the brightest phosphor under NVG — at CRT size, with a thin dark
* halo sized in SCREEN pixels so it keeps local contrast on a bright road
* through any luma mapping. Black-hot FLIR inverts it, deliberately: a
* black sprite with a light ring is what a hot object looks like there.
@@ -52,8 +52,8 @@ const PROFILE_BY_STYLE = Object.freeze({
const SPRITE_STYLE = Object.freeze({
mono: Object.freeze({
rgba: Object.freeze([255, 255, 255, 1]),
scale: 1.5,
outlinePx: 3,
scale: 1.3,
outlinePx: 2,
}),
crt: Object.freeze({
scale: 1.3,
@@ -111,8 +111,6 @@ export function presetSpriteRgba(styleName, mode) {
* @returns {number} 1 under the normal profile.
*/
export function presetSpriteScale(styleName, selected = false) {
if (transitStyleProfile(styleName) === 'mono')
return selected ? 36 / 30 : 30 / 20;
return SPRITE_STYLE[transitStyleProfile(styleName)]?.scale ?? 1;
}
+7 -7
View File
@@ -45,13 +45,13 @@ test('mono: every mode is a white core, larger, with a dark halo in screen pixel
`${style} ${mode}`,
);
}
assert.ok(presetSpriteScale(style) === 1.5, `${style} is drawn larger`);
// At least a whole screen pixel and a half of ring around a five-pixel
// body: thinner than that vanishes through blur and pixelation.
assert.ok(
presetSpriteOutlinePx(style) >= 1.5,
`${style} halo is ${presetSpriteOutlinePx(style)} px`,
);
for (const selected of [false, true]) {
assert.equal(
presetSpriteScale(style, selected),
presetSpriteScale('retro', selected),
);
assert.equal(presetSpriteOutlinePx(style, selected), 2);
}
}
});
+3 -2
View File
@@ -135,7 +135,7 @@ export function applyVesselOverlayPolicy(
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
@@ -144,7 +144,8 @@ export function applyVesselOverlayPolicy(
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
+1
View File
@@ -75,6 +75,7 @@ test('vessel host policy uses always-on shared fade and protected selected lane'
id: 'vessel:2', position, title: 'SELECTED', gapPx: 12, selected: true,
});
assert.equal(selected.variant, 'selected');
assert.equal(selected.verticalOnly, false, 'selected detail can clear panels on either side');
assert.equal(selected.protected, true);
assert.equal(selected.collisionGroup, 'ambient-card');
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
createWindSource(),
['getSnapshot'],
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gfsObjectKey,
selectLatestGfsCycle,
} from '../../server/providers/wind/catalog.js';
import {
parseGfsIdx,
windMessageRanges,
fetchRange,
fetchGfsWind,
loadOptionalScalar,
} from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(
gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }),
'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003',
);
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
const p = parseGfsIdx(
'1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z',
);
assert.deepEqual(windMessageRanges(p), {
u: { start: 10, end: 19 },
v: { start: 20, end: 29 },
});
const b = await fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
});
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => {
await assert.rejects(
fetchRange({ url: 'x', start: -1, end: 2 }),
/range too large/,
);
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(10));
},
cancel() {
cancelled = true;
},
});
await assert.rejects(
fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () => new Response(body, { status: 206 }),
}),
/budget/,
);
assert.equal(cancelled, true);
});
const WEATHER_IDX = [
'1:0:d=2026091506:UGRD:10 m above ground:6 hour fcst:',
'2:2:d=2026091506:VGRD:10 m above ground:6 hour fcst:',
'3:4:d=2026091506:TMP:850 mb:6 hour fcst:',
'4:6:d=2026091506:TMP:2 m above ground:6 hour fcst:',
'5:8:d=2026091506:PRMSL:mean sea level:6 hour fcst:',
'6:10:d=2026091506:HGT:surface:6 hour fcst:',
].join('\n');
test('GFS overlays select exact levels from the same issue and forecast object', async () => {
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'temperature' })
.scalar,
{ start: 6, end: 7 },
);
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'pressure' }).scalar,
{ start: 8, end: 9 },
);
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchGfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.idx')) return new Response(WEATHER_IDX);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 6 ? 293.15 : bytes[0] === 8 ? 101325 : bytes[0],
),
units: bytes[0] === 6 ? 'K' : bytes[0] === 8 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('.f006.idx'));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 20 : 1013.25),
) < 0.0001,
);
}
assert.throws(
() =>
windMessageRanges(
parseGfsIdx(WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')),
{ overlay: 'temperature' },
),
/missing TMP/,
);
});
test('GFS preserves wind when its optional temperature is missing or malformed', async () => {
for (const failure of ['missing', 'fetch', 'decode', 'geometry', 'units']) {
const result = await fetchGfsWind({
overlay: 'temperature',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
if (url.endsWith('.idx'))
return new Response(
failure === 'missing'
? WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')
: WEATHER_IDX,
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 6 && failure === 'fetch')
throw new Error('private provider failure');
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => {
if (bytes[0] === 6 && failure === 'decode')
throw new Error('bad message');
return {
ni: 4,
nj: 3,
lo1: bytes[0] === 6 && failure === 'geometry' ? 90 : 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0] === 6 ? 293.15 : bytes[0]),
units: bytes[0] === 6 && failure !== 'units' ? 'K' : 'm/s',
};
},
});
assert.equal(result.scalar, undefined);
assert.equal(result.grid.scalar, undefined);
assert.equal(result.scalarError, 'Temperature field unavailable');
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
}
});
test('optional scalar timeout cancels only its own fetch and honors parent cancellation', async () => {
const parent = new AbortController();
let active;
const value = await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'https://fixed.test/field',
timeoutMs: 5,
signal: parent.signal,
decodeImpl: () => {
throw new Error('not reached');
},
fetchImpl: (_, { signal }) => {
active = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
});
assert.equal(value, undefined);
assert.equal(active.aborted, true);
assert.equal(parent.signal.aborted, false);
parent.abort();
let calls = 0;
assert.equal(
await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'x',
signal: parent.signal,
fetchImpl: () => {
calls++;
},
decodeImpl: () => {},
}),
undefined,
);
assert.equal(calls, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resampleWindGrid,
resampleWeatherFields,
} from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
v: new Array(12).fill(1),
dx: 90,
dy: 90,
});
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0);
assert.equal(r.u[3], 3);
assert.equal(r.u[4], 4);
});
const component = (values, extra = {}) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(values),
...extra,
});
test('weather resampling converts temperature and MSL pressure on the wind geometry', () => {
const u = component(3);
const v = component(-4);
for (const [overlay, raw, units, expected, outUnits] of [
['temperature', 293.15, 'K', 20, '°C'],
['pressure', 101325, 'Pa', 1013.25, 'hPa'],
]) {
const result = resampleWeatherFields({
u,
v,
scalar: component(raw, { units }),
overlay,
targetDx: 90,
});
assert.equal(result.scalar.kind, overlay);
assert.equal(result.scalar.units, outUnits);
assert.deepEqual([...result.grid.u], Array(12).fill(3));
assert.deepEqual([...result.grid.v], Array(12).fill(-4));
assert.ok(result.grid.scalar.every((n) => Math.abs(n - expected) < 0.0001));
}
assert.equal(
'scalar' in resampleWeatherFields({ u, v, targetDx: 90 }).grid,
false,
);
});
test('weather components cannot share a grid when shifted, truncated or non-finite', () => {
const u = component(1);
const v = component(2);
const scalar = component(273.15, { units: 'K' });
for (const bad of [
{ ...v, lo1: 90 },
{ ...v, ni: 3 },
{ ...v, dj: 45 },
]) {
assert.throws(() => resampleWeatherFields({ u, v: bad }), /Mismatched/);
}
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, la1: 89 },
overlay: 'temperature',
}),
/Mismatched/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, units: '°C' },
overlay: 'temperature',
}),
/units/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: [1] },
overlay: 'temperature',
}),
/geometry/,
);
const invalid = component(1);
invalid.values[5] = NaN;
assert.throws(
() => resampleWeatherFields({ u, v: invalid, targetDx: 180 }),
/values/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: invalid.values },
overlay: 'temperature',
}),
/values/,
);
assert.throws(
() => resampleWeatherFields({ u, v, overlay: 'dewpoint' }),
/Unknown/,
);
});
test('optional degradation cannot hide invalid wind geometry', async () => {
const { resampleWeatherSnapshot } =
await import('../../server/providers/wind/grid.js');
const u = component(1);
const v = component(2);
const result = resampleWeatherSnapshot({
u,
v,
overlay: 'pressure',
targetDx: 90,
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.throws(
() =>
resampleWeatherSnapshot({ u, v: { ...v, la1: 89 }, overlay: 'pressure' }),
/Mismatched/,
);
assert.throws(
() =>
resampleWindGrid({ ...u, u: u.values, v: v.values, dx: 0.25, dy: 0.25 }),
/budget/,
);
});
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff. Show more