Merge main into fix/cyclone-movement-knots

This commit is contained in:
Sameh Khamis committed 2026-10-24 21:20:41 -07:00
commit aa9a7d41bf
30 files changed
+748 -134

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ export function createApplicationData({
// Initialize data layer manager
const dataManager = new LayerLifecycle(viewer, {
allowQaRegistration,
visibilityTarget: document,
getSourceAvailability: catalog?.getSourceAvailability,
});
defer(async () => {
+60 -17
View File
@@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) {
/**
* LayerLifecycle — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe.
* Hosts may inject a visibilityTarget with hidden/visibilityState and event
* listener methods. Without one, periodic work retains its headless behavior.
*/
export class LayerLifecycle {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
{
allowQaRegistration = false,
getSourceAvailability,
visibilityTarget = null,
} = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -528,6 +535,13 @@ export class LayerLifecycle {
}
}
_stopUpdateLoop(entry) {
clearInterval(entry.intervalId);
entry.intervalId = null;
entry.removeVisibilityListener?.();
entry.removeVisibilityListener = null;
}
_armUpdateLoop(layerId, entry) {
const configuredRefreshInterval = Number(entry.module.refreshInterval);
const updateInterval = Number(entry.module.updateInterval);
@@ -538,9 +552,45 @@ export class LayerLifecycle {
? updateInterval
: 0;
if (refreshInterval > 0) {
entry.intervalId = setInterval(() => {
const visibilityDocument = this._visibilityTarget;
let intervalId = null;
let dueAt = 0;
const canPoll = () =>
!visibilityDocument?.hidden &&
visibilityDocument?.visibilityState !== 'hidden' &&
entry.enabled &&
!entry.destroying &&
!entry.refreshing &&
entry.intervalId === intervalId;
// dueAt and the interval phase share one origin, so every interval tick
// is a due poll. A visibility catch-up re-arms the interval at the new
// deadline; otherwise the old phase would skip its next tick.
const schedule = () => {
clearInterval(intervalId);
dueAt = Date.now() + refreshInterval;
intervalId = setInterval(onInterval, refreshInterval);
entry.intervalId = intervalId;
};
const onInterval = () => {
if (!canPoll()) return;
dueAt = Date.now() + refreshInterval;
void this._runPeriodicUpdate(layerId, entry);
}, refreshInterval);
};
const onVisibilityChange = () => {
if (!canPoll() || Date.now() < dueAt) return;
schedule();
void this._runPeriodicUpdate(layerId, entry);
};
schedule();
visibilityDocument?.addEventListener?.(
'visibilitychange',
onVisibilityChange,
);
entry.removeVisibilityListener = () =>
visibilityDocument?.removeEventListener?.(
'visibilitychange',
onVisibilityChange,
);
} else if (updateInterval === 0) {
entry.intervalId = setInterval(() => {
if (!entry.enabled) return;
@@ -772,8 +822,7 @@ export class LayerLifecycle {
entry.lifecycleUncertain = !compensated && !cleanupConfirmed;
settleLifecycle();
if (!entry.enabled && entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
this._publishActivity({ type: 'status' });
if (!compensated) {
@@ -833,8 +882,7 @@ export class LayerLifecycle {
}
if (signal?.aborted) return finishCancelledDisable('disable');
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
entry.lifecycleUncertain = false;
@@ -845,8 +893,7 @@ export class LayerLifecycle {
let abortCleanup = null;
const cancelEnable = () => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Disable immediately so modules with their own AbortController (Radio)
// cancel pending update work at the same turn boundary. A second
@@ -876,8 +923,7 @@ export class LayerLifecycle {
// a later abort cannot revoke a successful retry.
signal?.removeEventListener('abort', cancelEnable);
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
await abortCleanup;
let cleanupConfirmed = false;
@@ -918,8 +964,7 @@ export class LayerLifecycle {
};
const finishFailedEnable = async (phase, error) => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
let cleanupConfirmed = false;
try {
@@ -1029,8 +1074,7 @@ export class LayerLifecycle {
// Always clear any stale interval before assigning a new one, so we never
// orphan a running timer and end up double-polling.
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
// Manager-owned periodic refresh work has one normalized loading/error
@@ -2158,8 +2202,7 @@ export class LayerLifecycle {
return false;
}
if (entry.intervalId) {
clearInterval(entry.intervalId);
entry.intervalId = null;
this._stopUpdateLoop(entry);
}
entry.enabled = false;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js';
/** Compatibility facade for callers that construct a manager with its panel. */
export class DataLayerManager extends LayerLifecycle {
constructor(viewer, options) {
super(viewer, options);
super(viewer, { visibilityTarget: globalThis.document, ...options });
this._presentation = new LayerPresentation(this);
}
buildTogglePanel(container) {
+89
View File
@@ -3492,3 +3492,92 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async
lifecycle.refreshLayerStats();
assert.deepEqual(changes, ['status']);
});
test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('hidden-poll');
manager.register(module);
await manager.setEnabled('hidden-poll', true);
assert.equal(calls.update, 1);
doc.hidden = true;
t.mock.timers.tick(10000);
assert.equal(calls.update, 1);
assert.equal(await manager.refreshLayer('hidden-poll'), true);
assert.equal(calls.update, 2, 'explicit refresh remains allowed');
doc.hidden = false;
for (const fn of listeners) fn();
for (const fn of listeners) fn();
assert.equal(calls.update, 3);
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(10000);
assert.equal(calls.update, 3);
});
async function resumeBetweenTicks(t, { failResume = false } = {}) {
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_name, fn) => listeners.add(fn),
removeEventListener: (_name, fn) => listeners.delete(fn),
};
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
const { LayerLifecycle } = await import('./lifecycle.js');
const manager = new LayerLifecycle({}, { visibilityTarget: doc });
const { module, calls } = makeSlowLayer('resume-poll');
const updates = [];
module.update = async () => {
calls.update++;
updates.push(Date.now());
await Promise.resolve();
if (failResume && updates.length === 2) throw new Error('resume failed');
};
manager.register(module);
await manager.setEnabled('resume-poll', true);
assert.deepEqual(updates, [1000]);
doc.hidden = true;
t.mock.timers.tick(1000); // 2000: due tick skipped while hidden
t.mock.timers.tick(500); // 2500: return between ticks
doc.hidden = false;
for (const fn of listeners) fn();
assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError;
if (failResume) assert.equal(managerRefreshError, 'resume failed');
else assert.equal(managerRefreshError, null);
t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire
assert.deepEqual(updates, [1000, 2500]);
t.mock.timers.tick(1); // 3500: one interval after the resume poll
assert.deepEqual(updates, [1000, 2500, 3500]);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval');
await Promise.resolve();
await Promise.resolve();
await manager.destroyAll();
assert.equal(listeners.size, 0);
t.mock.timers.tick(5000);
assert.equal(updates.length, 4);
}
test('periodic polls realign to the deadline after a between-ticks resume', async (t) => {
await resumeBetweenTicks(t);
});
test('periodic polls realign to the deadline after a failed resume refresh', async (t) => {
await resumeBetweenTicks(t, { failResume: true });
});
+4 -1
View File
@@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) {
return Number.MAX_SAFE_INTEGER;
const latitudeScale = 111320;
const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180);
// The short way round, so places either side of 180° stay close.
let dLon = lonB - lonA;
dLon -= 360 * Math.round(dLon / 360);
return Math.round(
Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale),
Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale),
);
}
+1
View File
@@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) {
else if (hidden) {
hidden = false;
parts.rendering.resumePlayback();
parts.viewport.runProximityCheck();
}
}
function bindVisibility() {
+5
View File
@@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) {
}
function runProximityCheck() {
if (
globalThis.document?.hidden ||
globalThis.document?.visibilityState === 'hidden'
)
return;
if (!state._enabled || !state._viewer) return;
refreshViewBounds();
const altitude = getCameraAltitude();
+39 -2
View File
@@ -13,6 +13,7 @@ import {
npmProcessSpec,
readDoctorDotenvValue,
resolveOpenSkyAuthMode,
resolveOpenSkyCredentialsFile,
resolveCredential,
} from '../scripts/setup-doctor.mjs';
@@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () =>
);
});
test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => {
test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => {
const oauthCredentials = {
OPENSKY_CLIENT_ID: { configured: true },
OPENSKY_CLIENT_SECRET: { configured: true },
@@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden
for (const credentials of [{}, oauthCredentials]) {
assert.equal(
buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights,
'OpenSky Basic mode selected (credential presence and validity not verified)',
'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)',
);
}
});
@@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => {
assert.match(output, /Setup needs attention/);
assert.doesNotMatch(output, /Ready\. Run/);
});
test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => {
assert.equal(
buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights,
'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)',
);
assert.equal(
buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights,
'OpenSky keyless anonymous access (rate-limited)',
);
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-'));
try {
assert.equal(
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
false,
);
writeFileSync(
path.join(root, '.env'),
'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n',
);
assert.equal(
resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }),
true,
);
assert.equal(
resolveOpenSkyCredentialsFile({
authoritativeEnvironment: true,
environment: { OPENSKY_CREDENTIALS_FILE: '' },
rootDir: root,
}),
false,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+4 -1
View File
@@ -250,10 +250,13 @@ export function approximateAreaM2(coords) {
return Math.abs(area) / 2;
}
/** Local equirectangular distance in metres, the short way round 180°. */
export function approximateDistanceM(latA, lonA, latB, lonB) {
const latScale = 111_320;
const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180));
return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale);
let dLon = lonB - lonA;
dLon -= 360 * Math.round(dLon / 360);
return Math.hypot((latB - latA) * latScale, dLon * lonScale);
}
export function pointInPolygon(lon, lat, coords) {
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { approximateDistanceM } from './featureGeometry.js';
test('approximate distance takes the short way round 180°', () => {
// Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart.
const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33);
assert.ok(across > 150_000 && across < 200_000, String(across));
assert.equal(
Math.round(approximateDistanceM(0, 179.9, 0, -179.9)),
Math.round(approximateDistanceM(0, -0.1, 0, 0.1)),
);
// Away from 180° nothing changes.
assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320);
});
+1 -3
View File
@@ -31,9 +31,7 @@ function openSkyError(response) {
const reasons = {
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
basic_invalid_credentials: 'OpenSky username/password rejected',
missing_basic_creds: 'OpenSky auth missing',
missing_oauth_and_basic_creds: 'OpenSky auth missing',
missing_oauth_creds: 'OpenSky auth missing',
auth_required: 'OpenSky auth required',
forced_anonymous: 'OpenSky auth required',
};
+63
View File
@@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
assert.equal(callCount, 1);
});
test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_CREDENTIALS_FILE: undefined,
OPENSKY_AUTH_MODE: 'basic',
OPENSKY_USERNAME: 'fixture-user',
OPENSKY_PASSWORD: 'fixture-password',
});
const warnings = [];
t.mock.method(console, 'warn', (message) => warnings.push(message));
const authorizations = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/states/')) {
authorizations.push(options.headers.Authorization);
return Response.json({ time: Math.floor(Date.now() / 1000), states: [] });
}
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?basic=${Math.random()}`
);
const flights = install(fresh.openSkyProxy());
const first = await flights('/api/flights');
assert.equal(first.statusCode, 200);
assert.equal(first.headers['x-opensky-auth'], 'anon');
assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired');
assert.deepEqual(authorizations, [undefined]);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /retired username\/password sign-in/);
assert.ok(!warnings[0].includes('fixture-password'));
});
test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: 'fixture-client',
OPENSKY_CLIENT_SECRET: 'fixture-secret',
OPENSKY_CREDENTIALS_FILE: undefined,
OPENSKY_AUTH_MODE: 'auto',
OPENSKY_USERNAME: 'fixture-user',
OPENSKY_PASSWORD: 'fixture-password',
});
t.mock.method(console, 'warn', () => {});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'error', () => {});
const authorizations = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/token'))
return Response.json({ access_token: 'fixture-token', expires_in: 1800 });
if (url.includes('/states/')) {
authorizations.push(options.headers.Authorization);
return new Response('', { status: 401 });
}
if (url.includes('/lat/')) return regionalFallback();
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?auto=${Math.random()}`
);
await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97');
assert.deepEqual(authorizations, ['Bearer fixture-token']);
});
test('retired military history refuses without upstream I/O while validating identifiers', async (t) => {
t.mock.method(globalThis, 'fetch', async () => {
assert.fail('retired history must not contact the upstream');
+18
View File
@@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr
assert.deepEqual(projectNearbyPlaces({}, 0, 0), []);
});
test('nearby place distances take the short way round 180°', () => {
const at = (name, longitude) => ({
id: name,
displayName: { text: name },
location: { latitude: -16.8, longitude },
types: ['point_of_interest'],
});
// From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side.
const result = projectNearbyPlaces(
{ places: [at('West', 179.942), at('East', -179.999)] },
-16.8,
179.98,
);
const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM]));
assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East));
assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West));
});
test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => {
const viewport = {
low: { latitude: 1, longitude: 2 },
+13
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getTransitFeed } from '../data/transitFeeds.js';
import {
chooseLiveCities,
selectStyle,
@@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', ()
const cities = chooseLiveCities(now);
assert.ok(cities.length >= 1);
for (const city of cities) {
const feed = getTransitFeed(city.feedId);
assert.ok(feed, `${city.id} has a registered feed`);
const distanceKm =
111.32 *
Math.hypot(
feed.center.lat - city.lat,
(feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180),
);
assert.ok(
distanceKm < feed.loadRadiusKm,
`${city.id} is inside its feed's admission region`,
);
const local = Number(
new Intl.DateTimeFormat('en', {
timeZone: city.zone,