mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol,
AISStream) while the Layers panel and Contacts named the provider the last
snapshot reported, and the vessels layer kept that name out of getStats(),
so Contacts always said AISStream. Each cohort now carries the provider and
whether it answered as a fallback or from a stale snapshot, and both views
show it the way the Layers row does ("adsb.lol · FALLBACK").
Fixes HAL-721.
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
import {
|
|
AWARENESS_MOTION_REFRESH_MS,
|
|
AWARENESS_REFRESH_MS,
|
|
AWARENESS_MOTION_SETTLE_MS,
|
|
AWARENESS_REEVALUATE_DISTANCE_M,
|
|
} from './policy.js';
|
|
|
|
export function createModel({ state: layerState, services, parts, source }) {
|
|
const flightsLayer = services.flights;
|
|
const militaryFlightsLayer = services.military;
|
|
|
|
/**
|
|
* Whether focusing a Context target may take ownership of the camera.
|
|
* Cockpit owns the camera at 20 Hz, so non-aircraft targets remain selectable
|
|
* there but must not start a competing flyTo animation.
|
|
*
|
|
* @param {string} layerId Context target layer id.
|
|
* @param {HTMLElement|null} [body=document.body] Document body to inspect.
|
|
* @returns {boolean} Whether the target may start a camera flight.
|
|
*/
|
|
|
|
function contextTargetFlyToAllowed(
|
|
layerId,
|
|
body = globalThis.document?.body,
|
|
) {
|
|
const nonAircraft =
|
|
layerId === 'ais-live-vessels' || layerId === 'military-installations';
|
|
return !nonAircraft || !body?.classList?.contains('cockpit-mode');
|
|
}
|
|
|
|
/**
|
|
* Refresh cadence for the Contacts direction arrows and card readouts.
|
|
*
|
|
* A MOVING camera earns the snappy cadence; a parked one keeps the cheap 750 ms
|
|
* one. This is safe for requestRenderMode because the cadence is applied inside
|
|
* `scene.preRender`: a faster interval can only ever consume frames the camera
|
|
* movement is ALREADY forcing. A parked scene renders no frames at all, so no
|
|
* extra refresh happens and no timer is introduced — the render governor and
|
|
* the idle-leak fix both depend on parked staying quiet.
|
|
* @param {boolean} cameraMoving Whether the camera pose changed since the last frame.
|
|
* @returns {number} Minimum ms between refreshes.
|
|
*/
|
|
|
|
function awarenessRefreshIntervalMs(cameraMoving) {
|
|
return cameraMoving ? AWARENESS_MOTION_REFRESH_MS : AWARENESS_REFRESH_MS;
|
|
}
|
|
|
|
/**
|
|
* Decide, for one rendered frame, whether the view counts as MOVING and whether
|
|
* the Contacts readout may refresh.
|
|
*
|
|
* Motion is HYSTERETIC. The pose signature is quantized (~10 m / ~0.06°), so a
|
|
* slow drag crosses a bin only every few frames: treating a single unchanged
|
|
* frame as "parked" made every crossing look like a motion-end and refreshed at
|
|
* nearly display rate. Motion therefore ends only after the pose has been
|
|
* unchanged for AWARENESS_MOTION_SETTLE_MS, and the motion cadence is a HARD
|
|
* floor in every state — no bin-crossing pattern, and no motion-end settle, can
|
|
* refresh faster than AWARENESS_MOTION_REFRESH_MS.
|
|
*
|
|
* @param {object} input
|
|
* @param {number} input.nowMs This frame's clock.
|
|
* @param {number} input.lastRefreshMs When the readout last refreshed.
|
|
* @param {number} input.lastPoseChangeMs When the camera pose last changed bins.
|
|
* @param {boolean} input.wasMoving Whether the previous frame counted as moving.
|
|
* @returns {{moving: boolean, refresh: boolean}}
|
|
*/
|
|
|
|
function awarenessRefreshDecision({
|
|
nowMs,
|
|
lastRefreshMs,
|
|
lastPoseChangeMs,
|
|
wasMoving,
|
|
}) {
|
|
const moving = nowMs - lastPoseChangeMs < AWARENESS_MOTION_SETTLE_MS;
|
|
// The single frame where hysteresis expires: settle the readout at rest
|
|
// instead of leaving it up to a parked interval stale.
|
|
const settling = Boolean(wasMoving) && !moving;
|
|
const sinceRefresh = nowMs - lastRefreshMs;
|
|
const refresh =
|
|
sinceRefresh >= awarenessRefreshIntervalMs(true) &&
|
|
(moving || settling || sinceRefresh >= awarenessRefreshIntervalMs(false));
|
|
return { moving, refresh };
|
|
}
|
|
|
|
/**
|
|
* Decide whether a source-scoped clear was an eviction rather than a deselect.
|
|
*
|
|
* Both arrive on the same events: the owning layer calls the same teardown for
|
|
* "the user clicked away" and "this contact aged out of the feed". Only the
|
|
* origin separates them, and it matters — a deliberate clear takes the Contact
|
|
* panel down, while an eviction must keep it up in its CONTACT LOST state, or
|
|
* the panel disappears out from under its own PREVIOUS/NEXT controls.
|
|
* @param {{reason?: string}|null} cleared Cleared source detail.
|
|
* @returns {boolean} Whether the subject left the feed on its own.
|
|
*/
|
|
|
|
function awarenessClearIsEviction(cleared) {
|
|
return cleared?.reason === 'evicted';
|
|
}
|
|
|
|
/**
|
|
* Decide whether proximity cohorts need an expensive rescan.
|
|
* @param {object} input Refresh evidence.
|
|
* @param {boolean} input.force Explicit invalidation.
|
|
* @param {boolean} input.hasResults Whether a prior evaluation exists.
|
|
* @param {number} input.movementM Subject displacement since evaluation.
|
|
* @param {boolean} input.sourceRevisionChanged Whether any source state changed.
|
|
* @returns {boolean} Whether cohorts should be evaluated again.
|
|
*/
|
|
|
|
function awarenessRefreshRequired({
|
|
force,
|
|
hasResults,
|
|
movementM,
|
|
sourceRevisionChanged,
|
|
}) {
|
|
return Boolean(
|
|
force ||
|
|
!hasResults ||
|
|
sourceRevisionChanged ||
|
|
!Number.isFinite(movementM) ||
|
|
movementM >= AWARENESS_REEVALUATE_DISTANCE_M,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The per-layer counts the Contacts panel is showing, as one flat block.
|
|
*
|
|
* Three honest numbers were reaching the operator at once: this cohort count
|
|
* (the panel), `analyst_query`'s count of CURRENTLY-LOADED records, and the
|
|
* layer-wide loaded total in the coverage note. After the camera dives to a
|
|
* tracked contact the flights layer reloads by viewport, so the loaded set can
|
|
* hold a fraction of the cohort — 8 against the panel's 42 in the field. The
|
|
* numbers are all correct and the disagreement still reads as chaos.
|
|
*
|
|
* Derived from the same snapshot the panel renders (`cohort.summary.count` via
|
|
* `buildAwarenessContextSnapshot`), so the two cannot drift apart. A cohort
|
|
* whose feed cannot answer reports 'unknown' rather than a misleading zero.
|
|
* @param {object|null} snapshot `getContextSnapshot()` result.
|
|
* @returns {{centeredOn: string|null, radiusKm: number|null, aircraft: number|string,
|
|
* flights: number|string, military: number|string, vessels: number|string}|null}
|
|
* Panel-equivalent counts.
|
|
*/
|
|
|
|
function contactsWindowFromSnapshot(snapshot) {
|
|
if (!snapshot?.subject) return null;
|
|
const aircraftCohorts = (snapshot.cohorts || []).filter((cohort) =>
|
|
['flights', 'military'].includes(cohort?.id),
|
|
);
|
|
const countFor = (cohortId) => {
|
|
const cohort = Array.isArray(snapshot.cohorts)
|
|
? snapshot.cohorts.find((item) => item?.id === cohortId)
|
|
: null;
|
|
return Number.isFinite(cohort?.count) ? cohort.count : 'unknown';
|
|
};
|
|
const flights = countFor('flights');
|
|
const military = countFor('military');
|
|
return {
|
|
centeredOn: snapshot.subject.label || snapshot.subject.id || null,
|
|
radiusKm: Number.isFinite(snapshot.radiusM)
|
|
? Math.round(snapshot.radiusM / 1000)
|
|
: null,
|
|
aircraft:
|
|
Number.isFinite(flights) && Number.isFinite(military)
|
|
? flights + military
|
|
: 'unknown',
|
|
flights,
|
|
military,
|
|
vessels: countFor('ais-live-vessels'),
|
|
complete:
|
|
aircraftCohorts.length === 2 &&
|
|
aircraftCohorts.every(
|
|
(cohort) =>
|
|
Number.isFinite(cohort.count) && cohort.complete !== false,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** Build the read-only Awareness snapshot shared with compact HUD consumers. */
|
|
|
|
function buildAwarenessContextSnapshot(
|
|
results,
|
|
navigation = {},
|
|
{ subjectPresent = true } = {},
|
|
) {
|
|
if (!results) return null;
|
|
return {
|
|
subject: { ...results.subject },
|
|
// Whether the subject is still reported by its source. The cockpit Contact
|
|
// readout holds its last-known values behind a CONTACT LOST cue when this
|
|
// is false, rather than presenting frozen geometry as a live reading.
|
|
subjectPresent: subjectPresent !== false,
|
|
evaluatedAt: results.evaluatedAt,
|
|
radiusM: results.radiusM,
|
|
cohorts: results.cohorts.map((cohort) => ({
|
|
id: cohort.id,
|
|
label: cohort.label,
|
|
source: cohort.source,
|
|
sourceStatus: cohort.sourceStatus || null,
|
|
coverage: cohort.coverage || null,
|
|
relationship: cohort.summary.relationship,
|
|
count: cohort.summary.count,
|
|
complete: cohort.summary.complete !== false,
|
|
truncated: cohort.summary.truncated === true,
|
|
reason: cohort.summary.reason,
|
|
nearest: cohort.summary.nearest.slice(),
|
|
})),
|
|
navigation: { ...navigation },
|
|
};
|
|
}
|
|
|
|
function navigationState() {
|
|
return {
|
|
canPrevious: layerState.navigationIndex > 0,
|
|
canFocus: Boolean(layerState.subject),
|
|
canNext: parts.navigation.canNavigateNext(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether Contacts genuinely has per-frame work right now.
|
|
*
|
|
* The direction arrows are screen-projected against the camera basis, so they
|
|
* must be redrawn every frame while the view MOVES. Parked, or with no live
|
|
* cohort to point at — nothing selected, every feed empty, every feed failed —
|
|
* there is nothing to animate and Contacts must not be the reason the whole
|
|
* scene keeps repainting.
|
|
* @param {object} [snapshot] Explicit state, for tests.
|
|
* @returns {boolean}
|
|
*/
|
|
|
|
function awarenessNeedsContinuousRender({
|
|
cameraMoving = layerState.cameraMoving,
|
|
hasSubject = Boolean(layerState.subject),
|
|
hasLiveResults = parts.panel.awarenessResultsAreLive(layerState.results),
|
|
} = {}) {
|
|
return Boolean(cameraMoving && hasSubject && hasLiveResults);
|
|
}
|
|
|
|
/**
|
|
* Release any aircraft-owned follow camera before a non-aircraft context
|
|
* selection takes ownership. Both layers are safe no-ops when idle and release
|
|
* Cesium tracking in place, so the subsequent vessel/site framing starts from
|
|
* the current view without the previous aircraft continuing to drag it.
|
|
*/
|
|
|
|
function releaseAircraftTracking() {
|
|
flightsLayer.stopTracking?.();
|
|
militaryFlightsLayer.stopTracking?.();
|
|
}
|
|
return {
|
|
contextTargetFlyToAllowed,
|
|
awarenessRefreshIntervalMs,
|
|
awarenessRefreshDecision,
|
|
awarenessClearIsEviction,
|
|
awarenessRefreshRequired,
|
|
contactsWindowFromSnapshot,
|
|
buildAwarenessContextSnapshot,
|
|
navigationState,
|
|
awarenessNeedsContinuousRender,
|
|
releaseAircraftTracking,
|
|
};
|
|
}
|