import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js'; export function createQueries({ state: layerState, services, parts, source }) { const flightsLayer = services.flights; const militaryFlightsLayer = services.military; function sourceState(layerId) { const lifecycle = layerState.dataManager?.getLayerLifecycleState?.(layerId) || null; const enabled = lifecycle?.enabled === true || layerState.dataManager?.isEnabled(layerId) === true; const enabling = lifecycle?.lifecycleState === 'enabling'; const moduleStats = layerState.dataManager?.layers?.get(layerId)?.module?.getStats?.() || {}; const stats = enabling ? { ...moduleStats, loading: true, status: 'loading' } : moduleStats; // A deferred source that has started but not settled is not evidence of an // empty cohort. Keep it explicitly unavailable so the panel cannot flash a // false all-clear, even if a lifecycle adapter briefly reports the requested // visibility intent as enabled. // // `enabling` and the never-answered predicate cover two DIFFERENT windows, and // both are needed because these dependencies do not settle the same way. // // - military-installations is covered by `enabling` alone. Its enable() is // SYNCHRONOUS and the manager awaits update(), which owns the first // Overpass fetch, so the lifecycle stays `enabling` for that whole window // however long the fetch takes. Confirmed live: a held 17 s first fetch // read `enabling` across 34 samples with the panel non-numeric throughout, // and a failing one settled to `enabled` with status 'unavailable'. Its // getStats() also reports loading while a request is in progress — // setInstallationStatus is only ever called with // loading/zoom-in/ready/stale/empty/unavailable. // - ais-live-vessels is what the predicate below is FOR. Its enable() and // update() both resolve as soon as the first /api/ais-live poll answers, // so the lifecycle settles to `enabled` — but until the server-side socket // delivers a position, firstConnectPhase is 'loading' and getStats() // reports loading: true, lastUpdate: null, count 0, and an UNDEFINED // status. That zero is an absence of evidence, not evidence of absence, // and neither the lifecycle nor the status list can tell. // // Deliberately `loading === true` and not truthiness: layers that never report // a busy flag (flights, military) must keep answering for themselves, and a // source that HAS answered once (lastUpdate set) keeps its last real count // through every later poll rather than blanking to `?` on each refresh. const neverAnswered = stats.loading === true && !stats.lastUpdate; const unavailable = enabling || !enabled || neverAnswered || ['unavailable', 'zoom-in'].includes(stats.status) || Boolean(stats.error && stats.count === 0); return { available: !unavailable, enabled, stale: Boolean(stats.stale), stats, }; } function collectSourceStates() { return Object.fromEntries( DEPENDENCIES.map((layerId) => [layerId, sourceState(layerId)]), ); } function sourceRevision(sourceStates) { return DEPENDENCIES.map((layerId) => { const source = sourceStates[layerId]; const stats = source?.stats || {}; return [ layerId, source?.available, source?.stale, stats.lastUpdate || null, stats.count ?? null, stats.status || null, stats.error || null, ]; }) .map((parts) => parts.join(':')) .join('|'); } function isSame(subject, item, prefix, key) { return ( subject?.layerId === prefix && String(subject.id) === String(item?.[key]) ); } /** * Summarize viewport-backed installations without implying radius-complete coverage. * @param {Array} items Loaded installation matches. * @param {object} source Current source availability and staleness. * @returns {object} Awareness summary with viewport-honest reasoning. */ function summarizeInstallationViewport(items, source) { const coverage = source.stats?.coverage; // getNearby measures these distances from the current subject, independent // of the retained square used to fetch installation tiles. const withinRadius = coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM) ? items.filter( (item) => (item.distanceM ?? item.distance) <= coverage.radiusM, ) : items; const summary = parts.navigation.summarizeAwarenessCohortForNavigation( withinRadius, source, ); if (summary.count === null) return source.stats?.statusMessage ? { ...summary, reason: source.stats.statusMessage } : summary; // A subject window is its own bounded area: name it instead of the viewport. if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) { const km = Math.round(coverage.radiusM / 1000); return { ...summary, reason: summary.count ? `mapped matches within ${km} km of the subject` : `none mapped within ${km} km; not a complete 250 km survey`, }; } return { ...summary, reason: summary.count ? 'mapped matches from the loaded viewport' : 'viewport feed is not a complete 250 km survey', }; } /** * THE aircraft-proximity engine. One computation, two consumers: the Contacts * panel window and the voice analyst's entity-centred "how many nearby". * * They used to be separate. The panel read live billboard positions through * `getNearby` with a 20 000 cap; the analyst re-derived its own answer from * last-fix coordinates over a 2 000-record slice. Same question, same centre, * two numbers — and in the owner's trial the spoken answer (15) and the panel * (111) disagreed badly enough that the model narrated the difference away. * Routing both through here makes them the same number BY CONSTRUCTION, so * they cannot drift again. * * The subject is excluded from its own window, which is why the panel reads * "contacts around X" rather than "including X". * @param {Cesium.Cartesian3} position Window centre. * @param {object} [options] * @param {number} [options.radiusM=AWARENESS_RADIUS_M] Window radius. * @param {object|null} [options.subject=null] Contact at the centre, excluded. * @returns {{flights: Array, military: Array, aircraft: number, * completeByLayer: {flights: boolean, military: boolean}}|null} Cohorts, * their lower-bound completeness, and the combined aircraft count, or * null without a position. */ function collectAircraftProximityWindow( position, { radiusM = AWARENESS_RADIUS_M, subject = null } = {}, ) { if (!position) return null; // Ask for one usable row beyond the retained limit so a capped cohort is // reported as a lower bound instead of an exact count. The selected // subject can consume one slot before exclusion, hence the extra two. const sampleLimit = AWARENESS_QUERY_LIMIT + 2; const flightSample = flightsLayer .getNearby(position, radiusM, sampleLimit, { includeHidden: true, }) .filter( (item) => !subject || !isSame(subject, item, 'flights', 'icao24'), ); const militarySample = militaryFlightsLayer .getNearby(position, radiusM, sampleLimit, { includeHidden: true, }) .filter( (item) => !subject || !isSame(subject, item, 'military', 'icao24'), ); const flights = flightSample.slice(0, AWARENESS_QUERY_LIMIT); const military = militarySample.slice(0, AWARENESS_QUERY_LIMIT); return { flights, military, aircraft: flights.length + military.length, completeByLayer: { flights: flightSample.length <= AWARENESS_QUERY_LIMIT, military: militarySample.length <= AWARENESS_QUERY_LIMIT, }, }; } return { sourceState, collectSourceStates, sourceRevision, isSame, summarizeInstallationViewport, collectAircraftProximityWindow, }; }