diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1713,6 +1714,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3208,6 +3213,8 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/feedState.js", + "src/loadingFeedback.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) { 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, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -8,6 +8,7 @@ import { t, translateKnown, } from '../../i18n/index.js'; +import { sourceStatusLabel } from '../../ui/layerNames.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; import { distanceParts } from '../../data/distanceFormat.js'; @@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) { relationship: summary.relationship.toLowerCase(), label: cohort.label, count: `${presentAwarenessCount(summary)}${pageLabel}`, - detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`, + detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`, nearest, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPanel } from './panel.js'; + +test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { + const cohort = (id, source, sourceStatus) => ({ + id, + label: id, + source, + sourceStatus, + summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' }, + }); + const state = { + enabled: true, + results: { + subject: { label: 'AFR38JF' }, + cohorts: [ + cohort('flights', 'adsb.lol', 'fallback'), + cohort('military', 'adsb.lol', null), + cohort('ais-live-vessels', 'Datalastic', 'stale'), + ], + }, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + assert.deepEqual( + state.panelView.rows.map((row) => row.detail), + ['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'], + ); + state.offPanelLocale?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj assert.equal(moved.reason, 'mapped matches within 100 km of the subject'); assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 }); }); + +test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => { + const services = { installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } }; + const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source) } }; + parts.queries = createQueries({ state: {}, services, parts }); + const subjectOwner = createSubject({ state: {}, services, parts }); + const states = { + // The OpenSky proxy answers from adsb.lol when OpenSky fails. + flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } }, + military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } }, + 'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } }, + 'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const { cohorts } = subjectOwner.evaluateSubject({ + id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000), + }, states); + const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort])); + assert.equal(byId.flights.source, 'adsb.lol'); + assert.equal(byId.flights.sourceStatus, 'fallback'); + assert.equal(byId.military.sourceStatus, null); + assert.equal(byId['ais-live-vessels'].source, 'Datalastic'); + assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale'); + assert.equal(byId['military-installations'].sourceStatus, null); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -3,6 +3,7 @@ import { AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; import * as Cesium from 'cesium'; +import { layerFeedState } from '../../data/feedState.js'; import { t } from '../../i18n/index.js'; import { AWARENESS_QUERY_LIMIT, @@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); + // Whether the provider answered as a fallback or from a stale snapshot. + const sourceStatus = (sourceState) => { + const feedState = layerFeedState(sourceState.stats); + return feedState === 'fallback' || feedState === 'stale' + ? feedState + : null; + }; const retainedProvenance = (id, label, sourceState, fallbackSource) => { const retainedSource = sourceState.stats.source || fallbackSource; return { @@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'flights', label: t('Flights'), source: flightsState.stats.source || SOURCE_LABEL.flights, + sourceStatus: sourceStatus(flightsState), provenance: retainedProvenance( 'flights', 'Flights', @@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'military', label: t('Military flights'), source: militaryState.stats.source || SOURCE_LABEL.military, + sourceStatus: sourceStatus(militaryState), provenance: retainedProvenance( 'military', 'Military flights', @@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'ais-live-vessels', label: t('AIS vessels'), source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + sourceStatus: sourceStatus(vesselsState), provenance: retainedProvenance( 'ais-live-vessels', 'AIS vessels', @@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) { source: installationsState.stats.source || SOURCE_LABEL['military-installations'], + sourceStatus: sourceStatus(installationsState), provenance: retainedProvenance( 'military-installations', 'Mapped installations', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('vessel stats name the provider the last snapshot reported', async () => { + // HAL-721: Contacts named a fixed AIS provider while this layer showed the + // one its server reported. + const { layer } = setup({ + label: 'Vessels', + async getSnapshot() { + return snapshot([observation('111')], { source: 'Datalastic' }); + }, + }); + assert.equal(layer.getStats().source, 'Vessels'); + await layer.update(); + assert.equal(layer.getStats().source, 'Datalastic'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,6 +548,8 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), + // The provider the last snapshot named, as flights and military report it. + source: layer.source, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,7 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; import { formatNumber, t, tn } from '../i18n/index.js'; +import { sourceStatusLabel } from './layerNames.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -110,6 +111,8 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; + // The provider that answered, named as the Layers panel names it. + shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t ), ['—', '—', '—', '—'], ); + // No provider is named before a snapshot reports one. + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] small')].map( + (source) => source.textContent, + ), + ['—', '—', '—', '—'], + ); assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); assert.equal( $('#cockpit-context-nearest-label').hasAttribute('aria-label'), @@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => assert.equal(layouts.length, 1); }); +test('each count names the provider its snapshot reports, with its feed state', async (t) => { + // HAL-721: the counters named fixed providers while the layer and the + // Contacts list named the one that answered. + const reported = snapshot(); + reported.cohorts[0].source = 'adsb.lol'; + reported.cohorts[0].sourceStatus = 'fallback'; + reported.cohorts[1].source = 'Plane Finder'; + reported.cohorts.push( + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'Datalastic', + sourceStatus: 'stale', + count: 3, + relationship: 'OBSERVED', + nearest: [], + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + sourceStatus: null, + count: 0, + relationship: 'MAPPED', + nearest: [], + }, + ); + const { $, owner } = fixture(t, reported); + updateContext.call(owner, INFO, 0); + await nextTick(); + const source = (id) => $(`[data-context-cohort="${id}"] small`); + assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK'); + assert.equal(source('military').textContent, 'Plane Finder'); + assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE'); + assert.equal(source('military-installations').textContent, 'OpenStreetMap'); + // The tile ellipsizes; the full name stays readable on hover. + assert.equal(source('flights').title, 'adsb.lol · FALLBACK'); +}); + test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { const { $, text, owner, layouts } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -25,7 +25,10 @@ export function createContextState() { canPrevious: true, canNext: true, cohorts: Object.fromEntries( - COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + COCKPIT_CONTEXT_COHORTS.map((id) => [ + id, + { count: '—', unknown: false, source: '—' }, + ]), ), nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, distance: { text: '—', ariaLabel: null }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -137,3 +137,17 @@ export function sourceLabel(source) { ? source.split(' · ').map(translateKnown).join(' · ') : translateKnown(source); } + +/** + * A provider name with the feed state it answered in, so a fallback or stale + * snapshot does not read as the live primary feed. + * @param {string} source English source name. + * @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`. + * @returns {string} + */ +export function sourceStatusLabel(source, status) { + const name = sourceLabel(source); + if (status === 'fallback') return `${name} · ${t('FALLBACK')}`; + if (status === 'stale') return `${name} · ${t('STALE')}`; + return name; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -7,6 +7,7 @@ import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS, sourceLabel, + sourceStatusLabel, } from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every @@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a provider name carries the fallback or stale state it answered in', () => { + assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder'); + assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK'); + assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE'); + setLocale('ru'); + try { + assert.equal( + sourceStatusLabel('adsb.lol', 'fallback'), + `adsb.lol · ${t('FALLBACK')}`, + ); + assert.notEqual(t('FALLBACK'), 'FALLBACK'); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}