mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Merge main into pr-694
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
fb8a93752f
370 files changed
+69030
-1176
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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([], [])),
|
||||
|
||||
@@ -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,7 +137,16 @@ 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 }),
|
||||
@@ -120,6 +156,7 @@ export function createApplicationCatalog({
|
||||
createApplicationTransit({ surface, source: sources.transit }),
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections(),
|
||||
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;
|
||||
|
||||
@@ -39,7 +39,26 @@ 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(
|
||||
@@ -82,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
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
@@ -53,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
+32
-136
@@ -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;
|
||||
@@ -1022,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;
|
||||
@@ -2556,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() {
|
||||
@@ -3211,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) {
|
||||
@@ -3328,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');
|
||||
}
|
||||
@@ -3428,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.
|
||||
@@ -3437,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, {
|
||||
@@ -3573,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';
|
||||
@@ -3601,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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
{
|
||||
key: 'adsbdb',
|
||||
html:
|
||||
'Aircraft type, registration & flight routes: ' +
|
||||
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
|
||||
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
|
||||
'Guillaume Michel · route data is the work of ' +
|
||||
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
|
||||
'copied, published, or incorporated into other databases without the ' +
|
||||
'explicit permission of David J Taylor, Edinburgh',
|
||||
},
|
||||
{
|
||||
key: 'aisstream',
|
||||
html:
|
||||
@@ -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 & 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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
})));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ifsObjectUrls,
|
||||
parseIfsIndex,
|
||||
ifsWindRanges,
|
||||
nearestIfsStep,
|
||||
selectLatestIfsCycle,
|
||||
fetchIfsWind,
|
||||
} from '../../server/providers/wind/ifs.js';
|
||||
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
|
||||
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
|
||||
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
|
||||
assert.match(urls.index, /9h-oper-fc\.index$/);
|
||||
assert.equal(urls.index.includes('.grib2.index'), false);
|
||||
assert.deepEqual(
|
||||
ifsWindRanges(
|
||||
parseIfsIndex(
|
||||
'{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}',
|
||||
),
|
||||
),
|
||||
{ u: { start: 20, end: 29 }, v: { start: 40, end: 54 } },
|
||||
);
|
||||
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026, 8, 15, 4)), {
|
||||
date: '20260914',
|
||||
hour: 18,
|
||||
});
|
||||
assert.equal(nearestIfsStep(8, 6), 9);
|
||||
assert.equal(nearestIfsStep(200, 6), 144);
|
||||
assert.equal(nearestIfsStep(151, 0), 150);
|
||||
assert.equal(nearestGfsStep(8.2), 8);
|
||||
});
|
||||
|
||||
test('IFS 2t and msl companions use the same issue/valid object as 10u and 10v', async () => {
|
||||
const entries = ['10u', '10v', '2t', 'msl'].map((param, i) => ({
|
||||
param,
|
||||
_offset: i * 2,
|
||||
_length: 2,
|
||||
}));
|
||||
for (const overlay of ['none', 'temperature', 'pressure']) {
|
||||
const calls = [];
|
||||
const result = await fetchIfsWind({
|
||||
overlay,
|
||||
targetDx: 90,
|
||||
now: () => Date.UTC(2026, 8, 15, 14),
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
if (url.endsWith('.index'))
|
||||
return new Response(
|
||||
entries.map((entry) => JSON.stringify(entry)).join('\n'),
|
||||
);
|
||||
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] === 4 ? 283.15 : bytes[0] === 6 ? 100000 : bytes[0],
|
||||
),
|
||||
units: bytes[0] === 4 ? 'K' : bytes[0] === 6 ? '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('-9h-oper-fc.index'));
|
||||
assert.equal(result.cycle.runIso, '2026-09-15T06:00:00.000Z');
|
||||
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
|
||||
if (overlay === 'none') assert.equal(result.scalar, undefined);
|
||||
else
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
result.grid.scalar[0] - (overlay === 'temperature' ? 10 : 1000),
|
||||
) < 0.0001,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
ifsWindRanges(
|
||||
parseIfsIndex(
|
||||
entries
|
||||
.slice(0, 2)
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n'),
|
||||
),
|
||||
{ overlay: 'pressure' },
|
||||
),
|
||||
/missing msl/,
|
||||
);
|
||||
});
|
||||
|
||||
test('IFS optional pressure refusal preserves same-cycle wind', async () => {
|
||||
const entries = ['10u', '10v', 'msl'].map((param, i) => ({
|
||||
param,
|
||||
_offset: i * 2,
|
||||
_length: 2,
|
||||
}));
|
||||
const result = await fetchIfsWind({
|
||||
overlay: 'pressure',
|
||||
targetDx: 90,
|
||||
now: () => Date.UTC(2026, 8, 15, 14),
|
||||
fetchImpl: async (url, options) => {
|
||||
if (url.endsWith('.index'))
|
||||
return new Response(
|
||||
entries.map((entry) => JSON.stringify(entry)).join('\n'),
|
||||
);
|
||||
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
|
||||
if (start === 4) return new Response('Unavailable', { status: 503 });
|
||||
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]),
|
||||
}),
|
||||
});
|
||||
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
|
||||
assert.equal(result.grid.scalar, undefined);
|
||||
assert.deepEqual([...result.grid.v], Array(12).fill(2));
|
||||
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { windProxy } from '../../server/providers/wind.js';
|
||||
|
||||
/** Boot a mounted provider middleware without a real server. */
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
const handler = [...routes.values()][0];
|
||||
return async (url = '/', method = 'GET') => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headersSent: false,
|
||||
writeHead(status, headers) {
|
||||
this.statusCode = status;
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
this.headersSent = true;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
this.headersSent = true;
|
||||
},
|
||||
};
|
||||
await handler({ url, method }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
const IDX = [
|
||||
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
|
||||
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
|
||||
'3:10:d=2026091400:HGT:surface:anl:',
|
||||
].join('\n');
|
||||
|
||||
const DECODED_U = {
|
||||
ni: 4,
|
||||
nj: 3,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
di: 90,
|
||||
dj: 90,
|
||||
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
};
|
||||
const DECODED_V = {
|
||||
...DECODED_U,
|
||||
values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
|
||||
};
|
||||
|
||||
/** Resampled-grid metadata shared by the injected model fakes. */
|
||||
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
|
||||
|
||||
/** Fetch double: `.idx` text, and range bodies of the requested length. */
|
||||
function makeFetch({ fail = false, counter } = {}) {
|
||||
return async (url, options = {}) => {
|
||||
if (counter) counter.calls += 1;
|
||||
if (fail) throw new Error('gfs upstream down');
|
||||
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
|
||||
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
|
||||
const length = Number(match[2]) - Number(match[1]) + 1;
|
||||
return new Response(new Uint8Array(length), { status: 206 });
|
||||
};
|
||||
}
|
||||
|
||||
function proxy(options = {}) {
|
||||
return windProxy({
|
||||
now: () => Date.UTC(2026, 8, 14, 12),
|
||||
targetDx: 90,
|
||||
fetchImpl: makeFetch(),
|
||||
decodeImpl: async (buffer) =>
|
||||
buffer[0] === undefined ? DECODED_U : DECODED_U,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
test('wind manifest describes the GFS cycle and resampled grid', async () => {
|
||||
const request = install(proxy());
|
||||
const res = await request('/');
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = JSON.parse(res.body);
|
||||
assert.equal(body.schemaVersion, 1);
|
||||
assert.equal(body.model, 'gfs');
|
||||
assert.equal(body.cycle.hour, 6);
|
||||
assert.equal(body.cycle.date, '20260914');
|
||||
assert.equal(body.units, 'm/s');
|
||||
assert.deepEqual(body.grid, {
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
});
|
||||
assert.match(
|
||||
body.gridUrl,
|
||||
/^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/,
|
||||
);
|
||||
assert.equal(body.stale, false);
|
||||
});
|
||||
|
||||
test('wind grid route returns Float32 U then V', async () => {
|
||||
let u = 0;
|
||||
const request = install(
|
||||
proxy({
|
||||
decodeImpl: async () => {
|
||||
u += 1;
|
||||
return u === 1 ? DECODED_U : DECODED_V;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const manifest = JSON.parse((await request('/')).body);
|
||||
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/octet-stream');
|
||||
assert.equal(res.body.length, 4 * 3 * 4 * 2);
|
||||
const floats = new Float32Array(
|
||||
res.body.buffer,
|
||||
res.body.byteOffset,
|
||||
res.body.length / 4,
|
||||
);
|
||||
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
|
||||
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
|
||||
});
|
||||
|
||||
test('wind status omits the grid url', async () => {
|
||||
const request = install(proxy());
|
||||
await request('/');
|
||||
const body = JSON.parse((await request('/status')).body);
|
||||
assert.equal('gridUrl' in body, false);
|
||||
assert.equal(body.model, 'gfs');
|
||||
});
|
||||
|
||||
test('wind refresh is cached within the TTL and refetches after it', async () => {
|
||||
const counter = { calls: 0 };
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
const request = install(
|
||||
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
|
||||
);
|
||||
await request('/');
|
||||
const afterFirst = counter.calls;
|
||||
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
|
||||
await request('/');
|
||||
assert.equal(
|
||||
counter.calls,
|
||||
afterFirst,
|
||||
'a second request inside the TTL must not refetch',
|
||||
);
|
||||
clock += 2 * 3600_000;
|
||||
await request('/');
|
||||
assert.equal(
|
||||
counter.calls,
|
||||
afterFirst + 3,
|
||||
'a request past the TTL refetches',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind serves last-good with stale on upstream failure', async () => {
|
||||
let failing = false;
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
fetchImpl: async (...args) => {
|
||||
if (failing) throw new Error('gfs upstream down');
|
||||
return makeFetch()(...args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const good = JSON.parse((await request('/')).body);
|
||||
failing = true;
|
||||
clock += 2 * 3600_000;
|
||||
const degraded = JSON.parse((await request('/')).body);
|
||||
assert.equal(degraded.stale, true);
|
||||
assert.equal(degraded.reason, 'Wind upstream unavailable');
|
||||
const frame = await request(good.gridUrl.replace('/api/wind', ''));
|
||||
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
|
||||
});
|
||||
|
||||
test('wind returns a JSON 404 for an unknown grid', async () => {
|
||||
const request = install(proxy());
|
||||
await request('/');
|
||||
const res = await request('/grid/nope.bin');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
|
||||
});
|
||||
|
||||
test('wind grid ids and URLs are model-scoped', async () => {
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => ({
|
||||
cycle: { date: '20260914', hour: 6 },
|
||||
level: 'x',
|
||||
units: 'm/s',
|
||||
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
|
||||
}),
|
||||
ifs: async () => ({
|
||||
cycle: { date: '20260914', hour: 0 },
|
||||
level: 'x',
|
||||
units: 'm/s',
|
||||
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const gfs = JSON.parse((await request('/')).body);
|
||||
const ifs = JSON.parse((await request('/?model=ifs')).body);
|
||||
assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/);
|
||||
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/);
|
||||
// Each model's grid resolves through its own model parameter.
|
||||
assert.equal(
|
||||
(await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('wind rejects a non-finite grid instead of caching it', async () => {
|
||||
const bad = new Float32Array(12);
|
||||
bad[3] = Number.POSITIVE_INFINITY;
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => ({
|
||||
cycle: { date: '20260914', hour: 6 },
|
||||
level: 'x',
|
||||
units: 'm/s',
|
||||
grid: { ...GRID, u: bad, v: new Float32Array(12) },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse((await request('/')).body);
|
||||
assert.equal(body.unavailable, true);
|
||||
assert.equal(body.reason, 'Wind upstream unavailable');
|
||||
});
|
||||
|
||||
test('wind rejects unknown models, paths and methods before acquisition', async () => {
|
||||
let calls = 0;
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => {
|
||||
calls++;
|
||||
throw new Error();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal((await request('/?model=toString')).statusCode, 400);
|
||||
assert.equal((await request('/secret')).statusCode, 404);
|
||||
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
|
||||
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
let step = 6;
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
models: {
|
||||
gfs: async () => ({
|
||||
cycle: { date: '20260914', hour: 6, forecastHour: step },
|
||||
level: '10 m',
|
||||
units: 'm/s',
|
||||
grid: {
|
||||
...GRID,
|
||||
u: new Float32Array(12).fill(step),
|
||||
v: new Float32Array(12),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const first = JSON.parse((await request('/')).body);
|
||||
clock += 3600_000;
|
||||
step = 7;
|
||||
const second = JSON.parse((await request('/')).body);
|
||||
assert.notEqual(first.gridUrl, second.gridUrl);
|
||||
assert.equal(
|
||||
(await request(first.gridUrl.replace('/api/wind', ''))).statusCode,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
|
||||
let calls = 0;
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => {
|
||||
calls++;
|
||||
throw new Error('private upstream detail');
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const results = await Promise.all([request('/'), request('/')]);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
|
||||
await request('/');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => {
|
||||
const { EventEmitter } = await import('node:events');
|
||||
let handler;
|
||||
let signal;
|
||||
let loads = 0;
|
||||
const plugin = proxy({
|
||||
models: {
|
||||
gfs: ({ signal: next }) => {
|
||||
loads++;
|
||||
signal = next;
|
||||
return new Promise((resolve, reject) =>
|
||||
next.addEventListener('abort', () => reject(next.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_path, value) => {
|
||||
handler = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
const first = new EventEmitter();
|
||||
const second = new EventEmitter();
|
||||
const a = handler({ url: '/manifest', method: 'GET' }, first);
|
||||
const b = handler({ url: '/manifest', method: 'GET' }, second);
|
||||
assert.equal(loads, 1);
|
||||
first.emit('close');
|
||||
assert.equal(signal.aborted, false);
|
||||
second.emit('close');
|
||||
assert.equal(signal.aborted, true);
|
||||
await Promise.all([a, b]);
|
||||
assert.equal(first.listenerCount('close'), 0);
|
||||
assert.equal(second.listenerCount('close'), 0);
|
||||
});
|
||||
|
||||
function weatherModel({ overlay = 'none', step = 6 } = {}) {
|
||||
const scalar =
|
||||
overlay === 'none'
|
||||
? null
|
||||
: {
|
||||
kind: overlay,
|
||||
units: overlay === 'temperature' ? '°C' : 'hPa',
|
||||
level:
|
||||
overlay === 'temperature' ? '2 m above ground' : 'mean sea level',
|
||||
};
|
||||
return {
|
||||
cycle: { date: '20260914', hour: 6, forecastHour: step },
|
||||
level: '10 m above ground',
|
||||
units: 'm/s',
|
||||
grid: {
|
||||
...GRID,
|
||||
u: new Float32Array(12).fill(1),
|
||||
v: new Float32Array(12).fill(2),
|
||||
...(scalar
|
||||
? {
|
||||
scalar: new Float32Array(12).fill(
|
||||
overlay === 'temperature' ? 20 : 1013.25,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(scalar ? { scalar } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
test('weather variants cache independently, bind binary URLs, and retain two generations per slot', async () => {
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
let step = 6;
|
||||
const calls = [];
|
||||
const model =
|
||||
(name) =>
|
||||
async ({ overlay }) => {
|
||||
calls.push(`${name}:${overlay}`);
|
||||
return weatherModel({ overlay, step });
|
||||
};
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
models: { gfs: model('gfs'), ifs: model('ifs') },
|
||||
}),
|
||||
);
|
||||
const slots = [];
|
||||
for (const name of ['gfs', 'ifs'])
|
||||
for (const overlay of ['none', 'temperature', 'pressure']) {
|
||||
const query = `?model=${name}&overlay=${overlay}`;
|
||||
const [a, b] = await Promise.all([
|
||||
request(`/manifest${query}`),
|
||||
request(`/manifest${query}`),
|
||||
]);
|
||||
const manifest = JSON.parse(a.body);
|
||||
assert.deepEqual(JSON.parse(b.body), manifest);
|
||||
assert.equal(
|
||||
manifest.scalar?.kind,
|
||||
overlay === 'none' ? undefined : overlay,
|
||||
);
|
||||
const binary = await request(manifest.gridUrl.replace('/api/wind', ''));
|
||||
assert.equal(binary.body.length, 12 * (overlay === 'none' ? 8 : 12));
|
||||
const floats = new Float32Array(
|
||||
binary.body.buffer,
|
||||
binary.body.byteOffset,
|
||||
binary.body.length / 4,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...floats.slice(0, 24)],
|
||||
[...Array(12).fill(1), ...Array(12).fill(2)],
|
||||
);
|
||||
if (overlay !== 'none') {
|
||||
assert.deepEqual(
|
||||
[...floats.slice(24)],
|
||||
Array(12).fill(overlay === 'temperature' ? 20 : 1013.25),
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
manifest.gridUrl
|
||||
.replace('/api/wind', '')
|
||||
.replace(`&overlay=${overlay}`, ''),
|
||||
)
|
||||
).statusCode,
|
||||
404,
|
||||
);
|
||||
}
|
||||
slots.push({ query, first: manifest.gridUrl });
|
||||
}
|
||||
assert.equal(
|
||||
calls.length,
|
||||
6,
|
||||
'one shared acquisition for each bounded variant',
|
||||
);
|
||||
for (const slot of slots) await request(`/manifest${slot.query}`);
|
||||
assert.equal(calls.length, 6);
|
||||
clock += 3600_000;
|
||||
step = 7;
|
||||
for (const slot of slots)
|
||||
slot.second = JSON.parse(
|
||||
(await request(`/manifest${slot.query}`)).body,
|
||||
).gridUrl;
|
||||
for (const slot of slots)
|
||||
assert.equal(
|
||||
(await request(slot.first.replace('/api/wind', ''))).statusCode,
|
||||
200,
|
||||
);
|
||||
clock += 3600_000;
|
||||
step = 8;
|
||||
for (const slot of slots) {
|
||||
await request(`/manifest${slot.query}`);
|
||||
assert.equal(
|
||||
(await request(slot.first.replace('/api/wind', ''))).statusCode,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(slot.second.replace('/api/wind', ''))).statusCode,
|
||||
200,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay failures retain only their own last-good scalar and unknown overlays never acquire', async () => {
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
let failing = false;
|
||||
let calls = 0;
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
models: {
|
||||
gfs: async ({ overlay }) => {
|
||||
calls++;
|
||||
if (failing && overlay !== 'none') throw new Error('private detail');
|
||||
return weatherModel({ overlay });
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const first = JSON.parse(
|
||||
(await request('/manifest?overlay=temperature')).body,
|
||||
);
|
||||
await request('/manifest');
|
||||
failing = true;
|
||||
clock += 3600_000;
|
||||
const stale = JSON.parse(
|
||||
(await request('/manifest?overlay=temperature')).body,
|
||||
);
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.gridUrl, first.gridUrl);
|
||||
assert.equal(stale.scalar.kind, 'temperature');
|
||||
assert.equal(
|
||||
JSON.parse((await request('/manifest?overlay=pressure')).body).unavailable,
|
||||
true,
|
||||
);
|
||||
assert.equal(JSON.parse((await request('/manifest')).body).stale, false);
|
||||
const before = calls;
|
||||
for (const overlay of ['toString', 'humidity', 'temperature,pressure']) {
|
||||
assert.equal(
|
||||
(await request(`/manifest?overlay=${overlay}`)).statusCode,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assert.equal(calls, before);
|
||||
});
|
||||
|
||||
test('weather provider rejects malformed scalar metadata, lengths and non-finite values', async () => {
|
||||
const mutations = [
|
||||
(value) => {
|
||||
value.scalar.units = 'K';
|
||||
},
|
||||
(value) => {
|
||||
value.grid.scalar = new Float32Array(11);
|
||||
},
|
||||
(value) => {
|
||||
value.grid.scalar[3] = Infinity;
|
||||
},
|
||||
(value) => {
|
||||
value.grid.v = new Float32Array(1);
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async ({ overlay }) => {
|
||||
const value = weatherModel({ overlay });
|
||||
mutate(value);
|
||||
return value;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
(await request('/manifest?overlay=temperature')).body,
|
||||
);
|
||||
assert.equal(manifest.unavailable, true);
|
||||
assert.equal(manifest.gridUrl, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnecting an overlay request does not abort a different variant', async () => {
|
||||
const { EventEmitter } = await import('node:events');
|
||||
let handler;
|
||||
const pending = new Map();
|
||||
proxy({
|
||||
models: {
|
||||
gfs: ({ overlay, signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pending.set(overlay, { signal, resolve });
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
},
|
||||
}).configureServer({
|
||||
middlewares: {
|
||||
use: (_path, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const first = new EventEmitter();
|
||||
const second = new EventEmitter();
|
||||
second.writeHead = () => {};
|
||||
second.end = (body) => {
|
||||
second.body = body;
|
||||
};
|
||||
const a = handler(
|
||||
{ url: '/manifest?overlay=temperature', method: 'GET' },
|
||||
first,
|
||||
);
|
||||
const b = handler(
|
||||
{ url: '/manifest?overlay=pressure', method: 'GET' },
|
||||
second,
|
||||
);
|
||||
first.emit('close');
|
||||
assert.equal(pending.get('temperature').signal.aborted, true);
|
||||
assert.equal(pending.get('pressure').signal.aborted, false);
|
||||
pending.get('pressure').resolve(weatherModel({ overlay: 'pressure' }));
|
||||
await Promise.all([a, b]);
|
||||
assert.equal(JSON.parse(second.body).scalar.kind, 'pressure');
|
||||
assert.equal(first.listenerCount('close'), 0);
|
||||
assert.equal(second.listenerCount('close'), 0);
|
||||
});
|
||||
|
||||
test('optional scalar failure serves wind and recovery has a distinct immutable grid URL', async () => {
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
let missing = true;
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
models: {
|
||||
gfs: async ({ overlay }) => {
|
||||
if (!missing) return weatherModel({ overlay });
|
||||
return {
|
||||
...weatherModel(),
|
||||
scalarError: 'Temperature field unavailable',
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const partial = JSON.parse(
|
||||
(await request('/manifest?overlay=temperature')).body,
|
||||
);
|
||||
assert.equal(partial.overlay, 'temperature');
|
||||
assert.equal(partial.unavailable, false);
|
||||
assert.equal(partial.stale, false);
|
||||
assert.equal(partial.scalar, undefined);
|
||||
assert.equal(partial.scalarError, 'Temperature field unavailable');
|
||||
assert.equal(
|
||||
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
|
||||
12 * 8,
|
||||
);
|
||||
clock += 3600_000;
|
||||
missing = false;
|
||||
const complete = JSON.parse(
|
||||
(await request('/manifest?overlay=temperature')).body,
|
||||
);
|
||||
assert.deepEqual(
|
||||
complete.cycle,
|
||||
partial.cycle,
|
||||
'recovery is the same cycle without mixing components',
|
||||
);
|
||||
assert.equal(complete.scalar.kind, 'temperature');
|
||||
assert.equal(complete.scalarError, undefined);
|
||||
assert.notEqual(
|
||||
complete.gridUrl,
|
||||
partial.gridUrl,
|
||||
'8-byte and 12-byte payloads cannot reuse immutable URL',
|
||||
);
|
||||
assert.equal(
|
||||
(await request(complete.gridUrl.replace('/api/wind', ''))).body.length,
|
||||
12 * 12,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
|
||||
12 * 8,
|
||||
);
|
||||
});
|
||||
@@ -657,15 +657,22 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
|
||||
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
|
||||
|
||||
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
|
||||
// ALPR deliberately adds its ID to the two layer menus and visibility aliases.
|
||||
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const block = JSON.stringify(GEV_REALTIME_TOOLS);
|
||||
assert.equal(block.length, 26208, 'serialized tool schema length drifted');
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the additive `local-adsb` set_layer_visibility value and
|
||||
// its common-name mapping; the missions still ride existing tools.
|
||||
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
|
||||
'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
|
||||
+37
-11
@@ -24,7 +24,17 @@ import {
|
||||
geoidHeight,
|
||||
} from './data/geoid.js';
|
||||
import { getBasemapLabelContext } from './voice/gevActions.js';
|
||||
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
|
||||
import {
|
||||
hudSummaryMatchesProvenance,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
isHudSummaryUnconfigured,
|
||||
} from './hudSummaryResponse.js';
|
||||
import {
|
||||
applyHudUiTheme,
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -53,8 +63,6 @@ const HUD_COLORS = {
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||
|
||||
/** Allowed HUD layout variants. */
|
||||
const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']);
|
||||
const HUD_SUMMARY_INTERVAL_MS = 15000;
|
||||
|
||||
/**
|
||||
@@ -105,7 +113,7 @@ export class IntelHUD {
|
||||
this._autoMode = true; // auto show/hide based on style
|
||||
this._currentStyle = 'normal';
|
||||
this._el = null;
|
||||
this._variant = 'tactical';
|
||||
this._variant = DEFAULT_HUD_LAYOUT;
|
||||
this._recBlinkState = true;
|
||||
this._updateInterval = null;
|
||||
this._recBlinkInterval = null;
|
||||
@@ -178,6 +186,8 @@ export class IntelHUD {
|
||||
if (!this._el) return;
|
||||
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
||||
@@ -649,7 +659,11 @@ export class IntelHUD {
|
||||
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
|
||||
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
|
||||
|
||||
return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||
const provenance = hudTelemetryProvenanceTag(
|
||||
this._dataManager?.getAll?.() || [],
|
||||
);
|
||||
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
|
||||
return provenance ? `${line} | ${provenance}` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,7 +748,12 @@ export class IntelHUD {
|
||||
if (!response.ok || !data?.summary) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this._setSummaryText(data.summary, animate);
|
||||
this._setSummaryText(
|
||||
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
|
||||
? data.summary
|
||||
: fallbackText,
|
||||
animate,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.warn('[HUD] AI summary unavailable:', error);
|
||||
@@ -775,6 +794,7 @@ export class IntelHUD {
|
||||
streetLabels: labels.streetLabels,
|
||||
nearbyPlaceLabels: labels.nearbyPlaceLabels,
|
||||
enabledLayerLabels: enabledLayers,
|
||||
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -864,15 +884,16 @@ export class IntelHUD {
|
||||
|
||||
/**
|
||||
* Switch the HUD layout variant. Falls back to `'tactical'` if the
|
||||
* name is unrecognized.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`.
|
||||
* name is unrecognized. Cyber also applies a coordinated skin to the full
|
||||
* application shell; leaving Cyber restores the standard shell tokens.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
|
||||
*/
|
||||
setVariant(variantName) {
|
||||
const normalized = String(variantName || '').toLowerCase();
|
||||
this._variant = HUD_VARIANTS.has(normalized) ? normalized : 'tactical';
|
||||
this._variant = normalizeHudLayout(variantName);
|
||||
if (this._el) {
|
||||
this._el.dataset.variant = this._variant;
|
||||
}
|
||||
applyHudUiTheme(document.documentElement, this._variant);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -904,7 +925,12 @@ export class IntelHUD {
|
||||
this._dataManager = dataManager || null;
|
||||
if (typeof this._dataManager?.subscribe === 'function') {
|
||||
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
|
||||
if (change?.type === 'visibility') this._markSummaryDirty();
|
||||
if (
|
||||
['visibility', 'refresh-transition', 'refresh-cancelled'].includes(
|
||||
change?.type,
|
||||
)
|
||||
)
|
||||
this._markSummaryDirty();
|
||||
});
|
||||
}
|
||||
this._markSummaryDirty();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
|
||||
export const HUD_LAYOUTS = Object.freeze([
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { applyCyberSonarThemePhase } from './cyberSonar.js';
|
||||
import { syncRadioPanelPlacement } from './ui/radioPanelPlacement.js';
|
||||
import { HUD_LAYOUTS } from './hudLayoutPolicy.js';
|
||||
export { HUD_LAYOUTS } from './hudLayoutPolicy.js';
|
||||
|
||||
/** Canonical HUD layouts shared by the UI, voice schema and HUD renderer. */
|
||||
|
||||
export const DEFAULT_HUD_LAYOUT = 'tactical';
|
||||
|
||||
/** Visual treatment applied only when an operator explicitly enters Cyber. */
|
||||
export const CYBER_VISUAL_DEFAULTS = Object.freeze({
|
||||
style: 'thermal',
|
||||
ironbow: 0.42,
|
||||
});
|
||||
|
||||
const HUD_LAYOUT_SET = new Set(HUD_LAYOUTS);
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function isHudLayout(value) {
|
||||
return HUD_LAYOUT_SET.has(String(value ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function normalizeHudLayout(value) {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
return isHudLayout(normalized) ? normalized : DEFAULT_HUD_LAYOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the opt-in visual defaults for an explicit layout transition.
|
||||
* Restores and repeated Cyber selections return null so they never overwrite
|
||||
* a shared link or a user's subsequent FLIR tuning.
|
||||
*/
|
||||
export function cyberVisualDefaultsForHudTransition(
|
||||
previousLayout,
|
||||
nextLayout,
|
||||
{ explicit = false } = {},
|
||||
) {
|
||||
if (!explicit) return null;
|
||||
if (normalizeHudLayout(previousLayout) === 'cyber') return null;
|
||||
if (normalizeHudLayout(nextLayout) !== 'cyber') return null;
|
||||
return CYBER_VISUAL_DEFAULTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the global shell skin derived from the selected HUD layout. The data
|
||||
* attribute lives on <html> so theme tokens are available to every UI surface.
|
||||
* @param {HTMLElement|{dataset?: Record<string, string>}|null|undefined} root
|
||||
* @param {unknown} layout
|
||||
* @returns {'cyber'|null}
|
||||
*/
|
||||
export function applyHudUiTheme(root, layout) {
|
||||
if (!root?.dataset) return null;
|
||||
if (normalizeHudLayout(layout) === 'cyber') {
|
||||
root.dataset.uiTheme = 'cyber';
|
||||
applyCyberSonarThemePhase(root, true);
|
||||
syncRadioPanelPlacement(root.ownerDocument);
|
||||
return 'cyber';
|
||||
}
|
||||
delete root.dataset.uiTheme;
|
||||
applyCyberSonarThemePhase(root, false);
|
||||
syncRadioPanelPlacement(root.ownerDocument);
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
feedProvenanceEnvelope,
|
||||
layerSnapshots,
|
||||
} from './data/layerSnapshot.js';
|
||||
|
||||
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
|
||||
|
||||
/**
|
||||
@@ -36,3 +41,72 @@ export function isHudSummaryUnconfigured(status, data) {
|
||||
data?.summary === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD summary model instructions. The five-word line may only describe
|
||||
* supplied labels, and must carry feed-state when any enabled layer is not
|
||||
* nominal.
|
||||
*/
|
||||
export const HUD_SUMMARY_INSTRUCTIONS = [
|
||||
"Write one concise intelligence-HUD summary for God's Eye View.",
|
||||
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
|
||||
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
|
||||
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
|
||||
'Do not infer from coordinates or invent a place or feed state.',
|
||||
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Stamp HUD summary context with the same layer snapshots voice tools use.
|
||||
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
|
||||
* @param {{ now?: number }} [options]
|
||||
* @returns {{
|
||||
* enabledLayerLabels: string[],
|
||||
* enabledLayers: Array<{id: string|null, name: string|null, feedState: string, source: string|null, count: number}>,
|
||||
* feedProvenance: object,
|
||||
* }}
|
||||
*/
|
||||
export function hudSummaryLayerContext(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||
return {
|
||||
enabledLayerLabels: snapshots.map((s) => s.name).filter(Boolean),
|
||||
enabledLayers: snapshots.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
feedState: s.feedState,
|
||||
source: s.source,
|
||||
count: s.count,
|
||||
})),
|
||||
feedProvenance: envelope,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic telemetry suffix when an enabled feed is not nominal.
|
||||
* @param {Array<object>} [layers]
|
||||
* @param {{ now?: number }} [options]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function hudTelemetryProvenanceTag(layers = [], options) {
|
||||
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
|
||||
const envelope = feedProvenanceEnvelope(snapshots, options);
|
||||
if (!envelope.overall || envelope.overall === 'nominal') return null;
|
||||
const names = envelope.layers
|
||||
.filter(
|
||||
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
|
||||
)
|
||||
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
|
||||
.slice(0, 2);
|
||||
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
|
||||
}
|
||||
|
||||
/** Require the supplied non-nominal state before showing an AI summary. */
|
||||
export function hudSummaryMatchesProvenance(summary, provenance) {
|
||||
const state = provenance?.overall;
|
||||
if (!state || state === 'nominal' || state === 'off') return true;
|
||||
return String(summary || '')
|
||||
.toUpperCase()
|
||||
.split(/\W+/)
|
||||
.includes(String(state).toUpperCase());
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
HUD_SUMMARY_UNCONFIGURED_CODE,
|
||||
hudSummaryLayerContext,
|
||||
hudTelemetryProvenanceTag,
|
||||
isHudSummaryUnconfigured,
|
||||
keylessHudSummaryResponse,
|
||||
} from './hudSummaryResponse.js';
|
||||
@@ -128,3 +132,48 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
|
||||
}
|
||||
});
|
||||
|
||||
test('HUD summary context and telemetry tag carry chip-identical feed-state', () => {
|
||||
const now = 1_000_000;
|
||||
const layers = [
|
||||
{
|
||||
id: 'flights',
|
||||
name: 'Live Flights',
|
||||
enabled: true,
|
||||
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
|
||||
},
|
||||
{
|
||||
id: 'earthquakes',
|
||||
name: 'Earthquakes',
|
||||
enabled: false,
|
||||
stats: { count: 3, lastUpdate: now },
|
||||
},
|
||||
];
|
||||
const context = hudSummaryLayerContext(layers, { now });
|
||||
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
|
||||
assert.equal(context.enabledLayers[0].feedState, 'stale');
|
||||
assert.equal(context.feedProvenance.overall, 'stale');
|
||||
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
|
||||
assert.equal(hudTelemetryProvenanceTag([{
|
||||
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
|
||||
}], { now }), null);
|
||||
});
|
||||
|
||||
test('HUD summary instructions require non-nominal feedState in the five words', () => {
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
|
||||
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
|
||||
});
|
||||
|
||||
test('the HUD proxy uses the shared provenance instructions', () => {
|
||||
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
|
||||
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
|
||||
assert.doesNotMatch(local, /enabled-layer text labels/);
|
||||
});
|
||||
|
||||
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
|
||||
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
|
||||
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
|
||||
});
|
||||
+28
-4
@@ -147,10 +147,29 @@ function buildRow(documentRef, key) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||
*/
|
||||
/** Move the existing setup action with the theme, retaining its listeners. */
|
||||
export function bindKeySetupPlacement(documentRef, chip, root) {
|
||||
const theme = documentRef?.documentElement;
|
||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
||||
if (!theme || !toolbar || !Observer) return () => {};
|
||||
const sync = () => {
|
||||
if (theme.dataset.uiTheme === 'cyber') {
|
||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
||||
} else if (chip.parentNode !== root.parentNode) {
|
||||
root.before(chip);
|
||||
}
|
||||
};
|
||||
const observer = new Observer(sync);
|
||||
observer.observe(theme, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-ui-theme'],
|
||||
});
|
||||
sync();
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
|
||||
export async function initKeySetup({
|
||||
documentRef = globalThis.document,
|
||||
fetchImpl,
|
||||
@@ -163,12 +182,14 @@ export async function initKeySetup({
|
||||
const lifetime = new AbortController();
|
||||
let disposed = false;
|
||||
let disposeControls = () => {};
|
||||
let disposePlacement = () => {};
|
||||
const destroy = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
signal?.removeEventListener('abort', destroy);
|
||||
disposeControls();
|
||||
disposePlacement();
|
||||
chip.remove();
|
||||
root.remove();
|
||||
};
|
||||
@@ -196,6 +217,7 @@ export async function initKeySetup({
|
||||
}
|
||||
|
||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
||||
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
|
||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||
@@ -208,6 +230,8 @@ export async function initKeySetup({
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
||||
chip.setAttribute('aria-label', chip.title);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
chip.hidden = status.setCount >= status.total;
|
||||
|
||||
@@ -4,8 +4,43 @@ import {
|
||||
collectKeyUpdates,
|
||||
keySetupChipLabel,
|
||||
stripKeylessBasemapFromHash,
|
||||
bindKeySetupPlacement,
|
||||
} from './keySetup.js';
|
||||
|
||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||
const home = {};
|
||||
const chip = { parentNode: home, hidden: true };
|
||||
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
|
||||
let moves = 0;
|
||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||
let notify, disconnected = false;
|
||||
class Observer {
|
||||
constructor(callback) { notify = callback; }
|
||||
observe(target, options) {
|
||||
assert.equal(target, theme);
|
||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
||||
}
|
||||
disconnect() { disconnected = true; }
|
||||
}
|
||||
const dispose = bindKeySetupPlacement({
|
||||
documentElement: theme,
|
||||
getElementById: () => toolbar,
|
||||
defaultView: { MutationObserver: Observer },
|
||||
}, chip, root);
|
||||
assert.equal(chip.parentNode, home);
|
||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||
theme.dataset.uiTheme = variant;
|
||||
notify();
|
||||
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
|
||||
notify();
|
||||
}
|
||||
assert.equal(moves, 2, 'identical theme sync does not reparent again');
|
||||
assert.equal(chip.hidden, true, 'placement cannot reveal a retired button');
|
||||
dispose();
|
||||
assert.equal(disconnected, true);
|
||||
});
|
||||
|
||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
@@ -105,6 +109,9 @@ export function createRendering({
|
||||
const canvas = layerState.viewer.scene.canvas;
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(width, height)
|
||||
: null;
|
||||
const geometry = getKeyholeGeometry(width, height);
|
||||
if (!(geometry.radius > 0)) {
|
||||
root.hidden = true;
|
||||
@@ -182,6 +189,15 @@ export function createRendering({
|
||||
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||
marker.style.opacity = String(projection.opacity);
|
||||
const sonarFactor =
|
||||
sonar?.at(
|
||||
geometry.centerX + Math.cos(projection.angle) * markerRadius,
|
||||
geometry.centerY + Math.sin(projection.angle) * markerRadius,
|
||||
) ?? 1;
|
||||
marker._arrow.style.opacity = String(sonarFactor);
|
||||
marker._label.style.opacity = String(
|
||||
sonar ? sonar.label(sonarFactor) : 1,
|
||||
);
|
||||
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
|
||||
const label = formatAwarenessLabel(item);
|
||||
|
||||
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
createCctvProjectionOverlayEntry:
|
||||
parts.projection.createCctvProjectionOverlayEntry,
|
||||
getActiveVideoElement: parts.projection.getActiveVideoElement,
|
||||
setCctvCardPresentationOptions:
|
||||
parts.cards.setCctvCardPresentationOptions,
|
||||
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
|
||||
|
||||
@@ -83,6 +83,9 @@ export function createPresentation({
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
isVideo:
|
||||
parts.model.isVideoFeedType(camera.feedType) &&
|
||||
record.projection?.mode !== 'image',
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
|
||||
Loaded 100 of 370 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user