Format military-flight and classification components

This commit is contained in:
Sameh Khamis committed 2026-09-13 10:23:17 -07:00
1 parent cb790ddea1
commit 1edebcb6f8
17 files changed
+5078 -3959

No files matched your search

+143 -126
View File
@@ -1,143 +1,160 @@
/** Instance-owned military identity and active-layer classification.
* Both aircraft layers share the same application-supplied registry. */
export function createMilitaryRegistry({ source, now = Date.now } = {}) {
const MIL_POLL_INTERVAL_MS = 60000;
const MIL_POLL_INTERVAL_MS = 60000;
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
const _milIcaos = new Set();
/** @type {boolean} True while the dedicated military layer is enabled. */
let _militaryLayerActive = false;
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
const _activeChangeListeners = new Set();
/** @type {number} Epoch ms of the last registry refresh (any source). */
let _lastRefreshMs = 0;
/** @type {boolean} A self-poll fetch is in flight. */
let _polling = false;
let currentSource = null;
let lifetime = null;
let releaseSource = null;
/** @type {Set<string>} Lowercase ICAO24 hexes known to be military. */
const _milIcaos = new Set();
/** @type {boolean} True while the dedicated military layer is enabled. */
let _militaryLayerActive = false;
/** @type {Set<(active: boolean) => void>} Fired on active-state TRANSITIONS. */
const _activeChangeListeners = new Set();
/** @type {number} Epoch ms of the last registry refresh (any source). */
let _lastRefreshMs = 0;
/** @type {boolean} A self-poll fetch is in flight. */
let _polling = false;
let currentSource = null;
let lifetime = null;
let releaseSource = null;
/** Replace the classification source and cancel its outstanding work. */
function configureSource(nextSource, { signal } = {}) {
if (typeof nextSource?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
releaseSource?.();
const controller = new AbortController();
lifetime = controller;
currentSource = nextSource;
_milIcaos.clear();
_lastRefreshMs = 0;
_polling = false;
const release = () => {
controller.abort();
signal?.removeEventListener('abort', release);
if (lifetime !== controller) return;
currentSource = null;
lifetime = null;
releaseSource = null;
/** Replace the classification source and cancel its outstanding work. */
function configureSource(nextSource, { signal } = {}) {
if (typeof nextSource?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
releaseSource?.();
const controller = new AbortController();
lifetime = controller;
currentSource = nextSource;
_milIcaos.clear();
_lastRefreshMs = 0;
_polling = false;
};
releaseSource = release;
if (signal?.aborted) release();
else signal?.addEventListener('abort', release, { once: true });
return release;
}
const release = () => {
controller.abort();
signal?.removeEventListener('abort', release);
if (lifetime !== controller) return;
currentSource = null;
lifetime = null;
releaseSource = null;
_milIcaos.clear();
_lastRefreshMs = 0;
_polling = false;
};
releaseSource = release;
if (signal?.aborted) release();
else signal?.addEventListener('abort', release, { once: true });
return release;
}
/**
* True when the dedicated military layer currently renders these aircraft
* (the flights layer should suppress duplicates rather than restyle them).
* @returns {boolean}
*/
function isMilitaryLayerActive() {
return _militaryLayerActive;
}
/**
* True when the dedicated military layer currently renders these aircraft
* (the flights layer should suppress duplicates rather than restyle them).
* @returns {boolean}
*/
function isMilitaryLayerActive() {
return _militaryLayerActive;
}
/**
* Marks the dedicated military layer enabled/disabled. On a TRANSITION
* (value actually changed) the registered change listeners fire so the
* flights layer can reconcile its duplicates immediately instead of waiting
* out its 30 s poll (pre-ship audit M2).
* @param {boolean} active - Whether the military layer renders.
* @returns {void}
*/
function setMilitaryLayerActive(active) {
const next = !!active;
if (next === _militaryLayerActive) return;
_militaryLayerActive = next;
for (const listener of _activeChangeListeners) {
try {
listener(next);
} catch {
// a broken listener must never break the layer toggle
/**
* Marks the dedicated military layer enabled/disabled. On a TRANSITION
* (value actually changed) the registered change listeners fire so the
* flights layer can reconcile its duplicates immediately instead of waiting
* out its 30 s poll (pre-ship audit M2).
* @param {boolean} active - Whether the military layer renders.
* @returns {void}
*/
function setMilitaryLayerActive(active) {
const next = !!active;
if (next === _militaryLayerActive) return;
_militaryLayerActive = next;
for (const listener of _activeChangeListeners) {
try {
listener(next);
} catch {
// a broken listener must never break the layer toggle
}
}
}
}
/**
* Subscribes to military-layer active-state transitions (fired only when the
* value changes, AFTER the new state is committed).
* @param {(active: boolean) => void} listener - Change callback.
* @returns {() => void} Unsubscribe function.
*/
function onMilitaryLayerActiveChange(listener) {
if (typeof listener !== 'function') return () => {};
_activeChangeListeners.add(listener);
return () => _activeChangeListeners.delete(listener);
}
/**
* Replaces/extends the known-military set from a fresh poll.
* Adds only — transient dropouts from one poll must not declassify an
* aircraft mid-session (the set stays small: a few hundred hexes).
* @param {Iterable<string>} icaos - ICAO24 hexes from a /v2/mil response.
* @returns {void}
*/
function registerMilitaryIcaos(icaos) {
for (const icao of icaos || []) {
const hex = String(icao || '').trim().toLowerCase();
if (hex) _milIcaos.add(hex);
/**
* Subscribes to military-layer active-state transitions (fired only when the
* value changes, AFTER the new state is committed).
* @param {(active: boolean) => void} listener - Change callback.
* @returns {() => void} Unsubscribe function.
*/
function onMilitaryLayerActiveChange(listener) {
if (typeof listener !== 'function') return () => {};
_activeChangeListeners.add(listener);
return () => _activeChangeListeners.delete(listener);
}
_lastRefreshMs = now();
}
/**
* Whether an aircraft is known military.
* @param {string} icao24 - ICAO24 hex (any case).
* @returns {boolean}
*/
function isMilitaryIcao(icao24) {
return _milIcaos.has(String(icao24 || '').toLowerCase());
}
/** Refresh known identities while the dedicated layer is disabled.
* A failed or cancelled request retains the current set and never swaps sources. */
async function refreshMilitaryRegistryIfStale() {
if (!currentSource || !lifetime || lifetime.signal.aborted || _militaryLayerActive) return;
if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return;
const owner = lifetime;
const requestSource = currentSource;
const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]);
_polling = true;
try {
const snapshot = await requestSource.getSnapshot({}, { signal });
signal.throwIfAborted();
if (lifetime !== owner) return;
registerMilitaryIcaos(snapshot.records.map((record) => record.id));
} catch {
// Keep the existing classification through source outages.
} finally {
if (lifetime === owner) _polling = false;
/**
* Replaces/extends the known-military set from a fresh poll.
* Adds only — transient dropouts from one poll must not declassify an
* aircraft mid-session (the set stays small: a few hundred hexes).
* @param {Iterable<string>} icaos - ICAO24 hexes from a /v2/mil response.
* @returns {void}
*/
function registerMilitaryIcaos(icaos) {
for (const icao of icaos || []) {
const hex = String(icao || '')
.trim()
.toLowerCase();
if (hex) _milIcaos.add(hex);
}
_lastRefreshMs = now();
}
}
function dispose() {
releaseSource?.();
_milIcaos.clear();
_lastRefreshMs = 0;
_activeChangeListeners.clear();
_militaryLayerActive = false;
}
if (source) configureSource(source);
return { configureSource, dispose, isMilitaryLayerActive, setMilitaryLayerActive,
onMilitaryLayerActiveChange, registerMilitaryIcaos, isMilitaryIcao, refreshMilitaryRegistryIfStale };
/**
* Whether an aircraft is known military.
* @param {string} icao24 - ICAO24 hex (any case).
* @returns {boolean}
*/
function isMilitaryIcao(icao24) {
return _milIcaos.has(String(icao24 || '').toLowerCase());
}
/** Refresh known identities while the dedicated layer is disabled.
* A failed or cancelled request retains the current set and never swaps sources. */
async function refreshMilitaryRegistryIfStale() {
if (
!currentSource ||
!lifetime ||
lifetime.signal.aborted ||
_militaryLayerActive
)
return;
if (_polling || now() - _lastRefreshMs < MIL_POLL_INTERVAL_MS) return;
const owner = lifetime;
const requestSource = currentSource;
const signal = AbortSignal.any([owner.signal, AbortSignal.timeout(10000)]);
_polling = true;
try {
const snapshot = await requestSource.getSnapshot({}, { signal });
signal.throwIfAborted();
if (lifetime !== owner) return;
registerMilitaryIcaos(snapshot.records.map((record) => record.id));
} catch {
// Keep the existing classification through source outages.
} finally {
if (lifetime === owner) _polling = false;
}
}
function dispose() {
releaseSource?.();
_milIcaos.clear();
_lastRefreshMs = 0;
_activeChangeListeners.clear();
_militaryLayerActive = false;
}
if (source) configureSource(source);
return {
configureSource,
dispose,
isMilitaryLayerActive,
setMilitaryLayerActive,
onMilitaryLayerActiveChange,
registerMilitaryIcaos,
isMilitaryIcao,
refreshMilitaryRegistryIfStale,
};
}
+44 -14
View File
@@ -4,7 +4,11 @@ import { createMilitaryRegistry } from './classification.js';
test('classification instances have independent identities, listeners and active states', () => {
let requests = 0;
const source = { getSnapshot() { requests++; } };
const source = {
getSnapshot() {
requests++;
},
};
const first = createMilitaryRegistry({ source });
const second = createMilitaryRegistry({ source });
let firstEvents = 0;
@@ -26,9 +30,15 @@ test('classification instances have independent identities, listeners and active
test('classification uses normalized identities and respects its polling interval and active layer', async () => {
let time = 120000;
let calls = 0;
const registry = createMilitaryRegistry({ now: () => time, source: {
async getSnapshot() { calls++; return { records: [{ id: 'abc123' }] }; },
} });
const registry = createMilitaryRegistry({
now: () => time,
source: {
async getSnapshot() {
calls++;
return { records: [{ id: 'abc123' }] };
},
},
});
await registry.refreshMilitaryRegistryIfStale();
await registry.refreshMilitaryRegistryIfStale();
assert.equal(calls, 1);
@@ -48,15 +58,29 @@ test('source replacement rejects an old response and disposal cancels the curren
let resolveNew;
let oldSignal;
let newSignal;
const registry = createMilitaryRegistry({ source: {
getSnapshot(query, { signal }) { oldSignal = signal; return new Promise((resolve) => { resolveOld = resolve; }); },
} });
const registry = createMilitaryRegistry({
source: {
getSnapshot(query, { signal }) {
oldSignal = signal;
return new Promise((resolve) => {
resolveOld = resolve;
});
},
},
});
const old = registry.refreshMilitaryRegistryIfStale();
const application = new AbortController();
registry.configureSource({ getSnapshot(query, { signal }) {
newSignal = signal;
return new Promise((resolve) => { resolveNew = resolve; });
} }, { signal: application.signal });
registry.configureSource(
{
getSnapshot(query, { signal }) {
newSignal = signal;
return new Promise((resolve) => {
resolveNew = resolve;
});
},
},
{ signal: application.signal },
);
const current = registry.refreshMilitaryRegistryIfStale();
resolveOld({ records: [{ id: 'abc123' }] });
await old;
@@ -73,9 +97,15 @@ test('source replacement rejects an old response and disposal cancels the curren
test('a failed classification source preserves known identities and makes no fallback request', async () => {
let time = 120000;
let calls = 0;
const registry = createMilitaryRegistry({ now: () => time, source: {
async getSnapshot() { calls++; throw new Error('fixture source unavailable'); },
} });
const registry = createMilitaryRegistry({
now: () => time,
source: {
async getSnapshot() {
calls++;
throw new Error('fixture source unavailable');
},
},
});
registry.registerMilitaryIcaos(['abc123']);
time += 60000;
await assert.doesNotReject(registry.refreshMilitaryRegistryIfStale());