diff --git a/CHANGELOG.md b/CHANGELOG.md index 515c20845..d663d7c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - Add an optional Nominatim geocoding adapter with configurable search/reverse endpoints, cancellation, bounded responses and retryable upstream errors. Extract portable response-reading and Overpass lexical helpers while retaining existing server exports. - Expose reference feed factories independently of standalone catalog construction; preserve source choices and asset attribution. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5e533f4e5..7c8e8b65f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Satellite and local infrastructure layers expose on-demand analyst records through their current factory owners. Analyst counts and ranks explicitly cover only bounded examined loaded records; omitted records can change nearest/count and satellite distance is ground distance. Existing tools and result fields remain available. + Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index d29e356f9..76567e605 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -36,7 +36,8 @@ const layers = createInfrastructureLayers({ | `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler | Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`, -`update(viewer)`, `destroy(viewer)`, `getStats()` and `getLodDiagnostics()`. +`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and +`getAnalystRecords()`. Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay host permits one live instance per layer ID; destroy an old instance before replacing it. Separate applications may use identical IDs with separate hosts. diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 58c28c7d8..5123dba39 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -33,6 +33,9 @@ export const ANALYST_LAYERS = { 'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] }, 'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] }, earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], flags: [] }, + satellites: { numeric: ['altitudeM', 'speedMps'], text: ['name', 'noradId', 'satelliteClass', 'group'], flags: [] }, + 'local-datacenters': { numeric: [], text: ['name', 'operator', 'capacity'], flags: [] }, + 'local-dams': { numeric: [], text: ['name', 'operator', 'river', 'output'], flags: [] }, }; const EARTH_R_KM = 6371; @@ -132,7 +135,7 @@ export function createAnalystEngine(providers) { for (const key of layers) { if (!ANALYST_LAYERS[key]) continue; const rows = providers.getRecords(key) || []; - layersQueried.push({ layerKey: key, records: rows.length }); + layersQueried.push({ layerKey: key, records: rows.length, ...providers.getRecordCoverage?.(key, rows) }); for (const row of rows) records.push({ layerKey: key, ...row }); } } @@ -229,7 +232,9 @@ export function createAnalystEngine(providers) { layersQueried, scope: scopeNote, followUp: Boolean(spec.followUp && lastResult), - note: 'client-side data only — answers cover what the enabled layers currently hold', + note: layersQueried.some((layer) => layer.basis === 'bounded-loaded-records') + ? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.' + : 'client-side data only — answers cover what the enabled layers currently hold', }, // Surfaced so the narration can name the centre it measured from rather // than implying a view-centred answer. diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 6521a5464..4456c0a12 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -195,6 +195,53 @@ test('analyst: route fields queryable from cached enrichment only', async () => assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match'); }); +test('analyst: satellites and local infrastructure are queryable layers', async () => { + const SATS = [ + { id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' }, + { id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' }, + ]; + const DAMS = [ + { id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' }, + { id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' }, + ]; + const DCS = [ + { id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' }, + ]; + const eng = createAnalystEngine({ + getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []), + resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null), + getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }), + }); + + const sats = await eng.query({ + layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2, + }); + assert.equal(sats.ok, true); + assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius'); + assert.equal(sats.items[0].noradId, '25544'); + assert.ok(Number.isFinite(sats.items[0].distanceKm)); + + const nav = await eng.query({ + layers: ['satellites'], scope: { kind: 'anywhere' }, + filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }], + }); + assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']); + + const dams = await eng.query({ + layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5, + }); + assert.equal(dams.count, 1); + assert.equal(dams.items[0].id, 'Austin Dam'); + assert.equal(dams.items[0].river, 'Colorado'); + + const dcs = await eng.query({ + layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' }, + filters: [{ field: 'operator', op: 'contains', value: 'cloud' }], + }); + assert.equal(dcs.count, 1); + assert.equal(dcs.items[0].id, 'AUS-1'); +}); + test('helpers: haversine sanity + scope radius', () => { const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs new file mode 100644 index 000000000..c695004f6 --- /dev/null +++ b/src/data/localGeojson.analyst.test.mjs @@ -0,0 +1,182 @@ +// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + createLocalGeoJsonLayer, + mapAnalystRecord, +} from './localGeojson.js'; + +const DC_RAW = { + id: '1176042553', + lat: -52.942, + lon: -70.85, + properties: { + tags: { + name: 'AWS', + operator: 'Amazon Web Services', + 'operator:short': 'AWS', + 'capacity:it_load': '27 MW', + }, + }, +}; + +const DAM_RAW = { + id: '-19685440', + lat: -25.41, + lon: -54.59, + properties: { + name: 'Usina Hidrelétrica de Itaipu', + output: '14000 MW', + tags: { + name: 'Usina Hidrelétrica de Itaipu', + 'name:en': 'Itaipu Dam', + operator: 'Itaipú Binacional', + associated_river: 'Paraná', + 'plant:output:electricity': '14000 MW', + }, + }, +}; + +test('infra analyst record: datacenter maps name, operator, capacity', () => { + const r = mapAnalystRecord(DC_RAW, 'local-datacenters'); + assert.deepEqual(r, { + id: 'AWS', + name: 'AWS', + lat: -52.942, + lon: -70.85, + operator: 'Amazon Web Services', + capacity: '27 MW', + river: null, + output: null, + }); +}); + +test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => { + const r = mapAnalystRecord(DAM_RAW, 'local-dams'); + assert.equal(r.id, 'Usina Hidrelétrica de Itaipu'); + assert.equal(r.name, 'Usina Hidrelétrica de Itaipu'); + assert.equal(r.operator, 'Itaipú Binacional'); + assert.equal(r.river, 'Paraná'); + assert.equal(r.output, '14000 MW'); + assert.equal(r.capacity, null); +}); + +test('infra analyst record: unnamed feature falls back to source id', () => { + const r = mapAnalystRecord({ + id: 'dc-42', + lat: 30.2, + lon: -97.7, + properties: { tags: { operator: 'Example Cloud' } }, + }, 'local-datacenters'); + assert.equal(r.id, 'dc-42'); + assert.equal(r.name, null); + assert.equal(r.operator, 'Example Cloud'); +}); + +test('infra analyst record: empty record yields nulls, never NaN/undefined', () => { + const r = mapAnalystRecord(undefined, 'local-dams'); + assert.equal(r.id, 'Dam'); + for (const [key, value] of Object.entries(r)) { + assert.notEqual(value, undefined, `${key} must not be undefined`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + } +}); + +test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => { + const r = mapAnalystRecord({ + ...DAM_RAW, + entity: {}, + position: { x: 1 }, + }, 'local-dams'); + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + assert.equal('entity' in r, false); + assert.equal('position' in r, false); +}); + +class MockLayerEvent { + constructor() { + this.listeners = new Set(); + } + + addEventListener(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + type: 'Feature', + id: 'runtime-dam', + properties: { + name: 'Runtime Dam', + output: '12 MW', + tags: { associated_river: 'Test River', operator: 'Test Hydro' }, + }, + geometry: { + type: 'Polygon', + coordinates: [[[-97.70, 30.20], [-97.69, 30.20], [-97.69, 30.21], [-97.70, 30.20]]], + }, + }), + }); + const viewer = { + selectedEntity: undefined, + dataSources: { + add(dataSource) { return dataSource; }, + remove() { return true; }, + }, + camera: { + positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), + frustum: { fov: Math.PI / 3 }, + moveEnd: new MockLayerEvent(), + flyTo() {}, + }, + scene: { + canvas: { clientWidth: 800, clientHeight: 600 }, + preRender: new MockLayerEvent(), + sampleHeightSupported: false, + sampleHeight() { return undefined; }, + screenSpaceCameraController: { enableInputs: true }, + pick() { return null; }, + requestRender() {}, + }, + }; + const layer = createLocalGeoJsonLayer({ + id: 'local-dams', + url: '/runtime-dam.geojsonl', + name: 'Runtime Dams', + color: '#0088ff', + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + projectToWindow: () => ({ x: 400, y: 300 }), + screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + }); + try { + assert.deepEqual(layer.getAnalystRecords(), [], 'unenabled layer has no analyst records'); + await layer.enable(viewer); + const rows = layer.getAnalystRecords(); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, 'Runtime Dam'); + assert.equal(rows[0].name, 'Runtime Dam'); + assert.equal(rows[0].operator, 'Test Hydro'); + assert.equal(rows[0].river, 'Test River'); + assert.equal(rows[0].output, '12 MW'); + assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon)); + assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]); + assert.equal(layer.getAnalystRecords(0).length, 1, 'non-finite/zero cap still returns at least one'); + + layer.disable(viewer); + assert.deepEqual(layer.getAnalystRecords(), [], 'disable keeps stems but analyst snapshot is empty'); + } finally { + layer.destroy(viewer); + globalThis.fetch = originalFetch; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + } +}); diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index edb246fdf..0f4681247 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) { return { title, details }; } +/** + * Map one local infrastructure feature to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. Layer-specific fields that do not + * apply (river/output on datacenters, capacity on dams) stay null so a + * shared compact payload can copy them without inventing values. + * Names are unclamped — overlay cards shorten for paint; queries need the + * full source string ("Usina Hidrelétrica de Itaipu"). + * @param {Object|null|undefined} raw - {id, lat, lon, properties}. + * @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`). + * @returns {{id: string, name: string|null, lat: number|null, lon: number|null, + * operator: string|null, capacity: string|null, river: string|null, + * output: string|null}} + */ +export function mapAnalystRecord(raw, layerId = '') { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { + const t = String(v ?? '').trim(); + return t && t !== 'undefined' && t !== 'null' ? t : null; + }; + const props = + raw?.properties && + typeof raw.properties === 'object' && + !Array.isArray(raw.properties) + ? raw.properties + : {}; + const tags = + props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags) + ? props.tags + : {}; + const name = + text(props.name) || + text(tags.name) || + text(tags['name:en']) || + text(tags.official_name) || + null; + const operator = + text(tags.operator) || + text(props.operator) || + text(tags['operator:short']) || + null; + const capacity = + layerId === 'local-datacenters' + ? text(tags['capacity:it_load']) || + text(tags.it_load) || + text(tags.capacity) || + text(props.capacity) + : null; + const river = + layerId === 'local-dams' + ? text(tags.associated_river) || + text(props.associated_river) || + text(tags.river) || + text(props.river) || + text(tags['river:name']) + : null; + const output = + layerId === 'local-dams' + ? text(props.output) || text(tags['plant:output:electricity']) + : null; + return { + id: name || text(raw?.id) || layerTitle(layerId), + name, + lat: num(raw?.lat), + lon: num(raw?.lon), + operator, + capacity, + river, + output, + }; +} + /** * Produce one normalized-contract input owned by a local infrastructure layer. * The host revalidates the authoritative `source` value while normalizing it. @@ -310,7 +382,7 @@ export function localDatasetError(error) { * standard scene.pick natively clicks them. * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. - * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats. + * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. * One live instance per layer id is allowed in a given viewer/context/overlay host. * Destroy the previous instance before replacing it. Importing creates no layers. */ @@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer( computed: _lodComputed, }), + /** + * Snapshot in-memory infrastructure features as plain JSON-safe objects + * for the analyst query engine. On-demand only (called at most once per + * spoken query) — zero per-frame cost, no listeners, no caching. Returns + * [] while the layer is disabled or empty. Disable keeps the loaded + * stems for reuse; this method still returns [] until the next enable. + * @param {number} [maxCount=2000] Maximum records to return (truncation). + * @returns {Array} See mapAnalystRecord for the record shape. + */ + getAnalystRecords(maxCount = 2000) { + if (!_enabled || !_stemRecords.length) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (let i = 0; i < _stemRecords.length; i++) { + if (result.length >= limit) break; + const record = _stemRecords[i]; + const carto = record.carto; + result.push( + mapAnalystRecord( + { + id: record.id, + lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, + lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, + properties: propertyObject(record.entity), + }, + id, + ), + ); + } + return result; + }, + enable: async (viewer) => { if (_destroyed) return; _enabled = true; diff --git a/src/data/satellites.analyst.test.mjs b/src/data/satellites.analyst.test.mjs new file mode 100644 index 000000000..708781422 --- /dev/null +++ b/src/data/satellites.analyst.test.mjs @@ -0,0 +1,134 @@ +// Focused tests for the satellite analyst-record mapper and snapshot seam. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { twoline2satrec } from 'satellite.js'; +import satellitesLayer, { + mapAnalystRecord, + _clearDenseCatalogStateForTest, + _setTrackedSatelliteRefreshStateForTest, +} from './satellites.js'; + +const ISS_NORAD = 25544; + +const ISS_L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; +const ISS_L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; + +const FULL_RAW = { + noradId: 25544, + name: 'ISS (ZARYA)', + group: 'stations', + lat: 51.64, + lon: -97.4, + altitudeM: 410_000, + speedMps: 7660, +}; + +test('satellite analyst record: full record maps every contract field', () => { + const r = mapAnalystRecord(FULL_RAW); + assert.deepEqual(r, { + id: 'ISS (ZARYA)', + noradId: '25544', + name: 'ISS (ZARYA)', + lat: 51.64, + lon: -97.4, + altitudeM: 410_000, + speedMps: 7660, + satelliteClass: 'STATION · ISS', + group: 'stations', + }); +}); + +test('satellite analyst record: nameless row falls back to SAT-norad id', () => { + const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 }); + assert.equal(r.id, 'SAT-25544'); + assert.equal(r.name, null); +}); + +test('satellite analyst record: ISS class wins even when ingested as visual', () => { + const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' }); + assert.equal(r.satelliteClass, 'STATION · ISS'); +}); + +test('satellite analyst record: GPS group maps to NAV · GPS', () => { + const r = mapAnalystRecord({ + noradId: 24876, + name: 'GPS BIIR-2 (PRN 13)', + group: 'gps-ops', + lat: 0, + lon: 0, + altitudeM: 20_200_000, + speedMps: 3870, + }); + assert.equal(r.satelliteClass, 'NAV · GPS'); + assert.equal(r.group, 'gps-ops'); +}); + +test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => { + const r = mapAnalystRecord(undefined); + assert.equal(r.id, 'SAT-00000'); + assert.equal(r.satelliteClass, null); + for (const [key, value] of Object.entries(r)) { + assert.notEqual(value, undefined, `${key} must not be undefined`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); + } +}); + +test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => { + const r = mapAnalystRecord({ ...FULL_RAW, position: { x: 1 }, satrec: { dummy: true } }); + assert.deepEqual(JSON.parse(JSON.stringify(r)), r); + assert.equal('position' in r, false); + assert.equal('satrec' in r, false); +}); + +test('satellite getAnalystRecords: disabled or empty catalog returns []', () => { + _clearDenseCatalogStateForTest(); + assert.deepEqual(satellitesLayer.getAnalystRecords(), []); +}); + +test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => { + const satrec = twoline2satrec(ISS_L1, ISS_L2); + const point = { position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000) }; + _setTrackedSatelliteRefreshStateForTest({ + noradId: ISS_NORAD, + name: 'ISS (ZARYA)', + satrec, + entity: {}, + point, + viewer: {}, + now: () => Date.now(), + neighbours: [ + { + noradId: 99999, + name: 'STARLINK-TEST', + satrec, + group: 'dense', + point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) }, + }, + { + noradId: 24876, + name: 'GPS BIIR-2', + satrec, + group: 'gps-ops', + point: { position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000) }, + }, + ], + }); + try { + const capped = satellitesLayer.getAnalystRecords(2); + assert.equal(capped.length, 2); + assert.deepEqual(capped.map((r) => r.noradId), ['25544', '24876']); + assert.equal(capped.some((r) => r.group === 'dense'), false); + + const all = satellitesLayer.getAnalystRecords(10); + assert.equal(all.length, 3); + assert.equal(all[2].noradId, '99999'); + assert.equal(all[2].satelliteClass, 'COMMS · STARLINK'); + for (const row of all) { + assert.ok(Number.isFinite(row.lat) && Number.isFinite(row.lon), `${row.id} needs a geodetic position`); + assert.deepEqual(JSON.parse(JSON.stringify(row)), row); + } + } finally { + _clearDenseCatalogStateForTest(); + } +}); diff --git a/src/data/satellites.js b/src/data/satellites.js index 383c583ef..e41f1ab3d 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -49,3 +49,5 @@ export { ISS_OVERLAY_SOURCE_OPTIONS, } from '../layers/satellites/index.js'; export default layer; + +export { mapAnalystRecord } from '../layers/satellites/records.js'; diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js index 7bec672c0..30cfc0c77 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -8,6 +8,7 @@ import { createTesting } from './testing.js'; import { createInteraction } from './interaction.js'; import { createLifecycle } from './lifecycle.js'; import { createIngestion } from './ingestion.js'; +import { createRecords } from './records.js'; import { createState } from './state.js'; /** Construct one layer with its own scene state and supplied application services. */ @@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) { parts.catalog = createCatalog(context); parts.labels = createLabels(context); parts.orbits = createOrbits(context); + parts.records = createRecords(context); parts.rendering = createRendering(context); parts.tracking = createTracking(context); parts.testing = createTesting(context); @@ -63,6 +65,7 @@ export function createSatellitesLayer({ services, source }) { applySatellitePointFocusDeemphasis: parts.rendering.applySatellitePointFocusDeemphasis, getNextIssPass: parts.orbits.getNextIssPass, + getAnalystRecords: parts.records.getAnalystRecords, scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch, findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle, getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack, diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js new file mode 100644 index 000000000..962e52f86 --- /dev/null +++ b/src/layers/satellites/records.js @@ -0,0 +1,96 @@ +import * as Cesium from 'cesium'; +import { satelliteClassLabel } from '../../data/satelliteClass.js'; +import { ISS_NORAD } from './policy.js'; + +/** + * Map one CelesTrak catalog satellite to a JSON-safe analyst record + * (analyst query engine seam). Pure — no Cesium types. Missing/unknown + * fields are null, never NaN/undefined. `id` is the display name (same + * convention as vessels); `noradId` is the track_entity key. + * @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}. + * @returns {{id: string, noradId: string|null, name: string|null, + * lat: number|null, lon: number|null, altitudeM: number|null, + * speedMps: number|null, satelliteClass: string|null, group: string|null}} + */ +export function mapAnalystRecord(raw) { + const num = (v) => (Number.isFinite(v) ? v : null); + const text = (v) => { const t = String(v ?? '').trim(); return t || null; }; + const noradNum = Number(raw?.noradId); + const noradId = Number.isFinite(noradNum) ? String(Math.trunc(noradNum)) : text(raw?.noradId); + const name = text(raw?.name); + const group = text(raw?.group); + const isIss = noradNum === ISS_NORAD; + return { + id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'), + noradId, + name, + lat: num(raw?.lat), + lon: num(raw?.lon), + altitudeM: num(raw?.altitudeM), + speedMps: num(raw?.speedMps), + // Empty records stay class-less; the class table's fallback is VISUAL and + // must not leak onto a row that has no satellite identity. + satelliteClass: (noradId || group) ? satelliteClassLabel(group, { isIss }) : null, + group, + }; +} + + +/** Create on-demand analyst snapshots owned by one layer instance. */ +export function createRecords({ state, parts }) { +function analystGeo(noradId, sat, now) { + const pos = sat?.satrec ? parts.orbits.propagatePosition(sat.satrec, now) : null; + if (pos && Number.isFinite(pos.latitude) && Number.isFinite(pos.longitude)) { + return { + lat: pos.latitude, + lon: pos.longitude, + altitudeM: pos.altitude, + speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null, + }; + } + const point = state._points.get(noradId); + if (!point?.position) return null; + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) return null; + return { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + speedMps: null, + }; +} + + function getAnalystRecords(maxCount = 2000) { + if (!state._enabled || !state._catalog.size) return []; + const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; + const now = new Date(); + const result = []; + const emit = (noradId, sat) => { + if (result.length >= limit) return false; + const pos = analystGeo(noradId, sat, now); + if (!pos) return true; + result.push(mapAnalystRecord({ + noradId, + name: sat?.name, + group: sat?.group, + lat: pos.lat, + lon: pos.lon, + altitudeM: pos.altitudeM, + speedMps: pos.speedMps, + })); + return result.length < limit; + }; + for (const [noradId, sat] of state._catalog) { + if (sat?.group === 'dense') continue; + if (!emit(noradId, sat)) break; + } + if (result.length < limit) { + for (const [noradId, sat] of state._catalog) { + if (sat?.group !== 'dense') continue; + if (!emit(noradId, sat)) break; + } + } + return result; + } + return { getAnalystRecords }; +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 81d1147d1..c8db4c8b4 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -771,6 +771,9 @@ const schemas = [ 'ais-live-vessels', 'local-firms', 'earthquakes', + 'satellites', + 'local-datacenters', + 'local-dams', ], }, }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index e3bb766b6..aa069ace6 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3253,6 +3253,18 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac ? (mod.getAnalystRecords(requestedLimit) || []) : (mod.getAnalystRecords() || []); }, + getRecordCoverage(layerKey, rows) { + if (!['satellites', 'local-datacenters', 'local-dams'].includes(layerKey)) return null; + const module = dataManager.layers.get(layerKey)?.module; + const loaded = module?.getStats?.().count; + return { + basis: 'bounded-loaded-records', + recordsExamined: rows.length, + loadedCount: Number.isFinite(loaded) ? loaded : null, + sourceTruncated: Number.isFinite(loaded) ? loaded > rows.length : null, + note: 'Counts and ranks apply only to these examined loaded records, not all satellites or infrastructure; distance is ground great-circle distance.', + }; + }, resolveRegionRing, /** * The active Contacts subject, when there is one — the centre the operator @@ -3308,7 +3320,7 @@ async function runAnalystQuery(analystEngine, dataManager, args = {}, _layerEnab // model burned the turn on retries (owner field session 2026-08-21, 23:48). const items = result.items.map((r) => { const compact = { layerKey: r.layerKey, id: r.id }; - for (const k of ['icao24', 'mmsi', 'registration', 'label', 'callsign', 'name', 'altitudeM', 'speedMps', 'speedKts', 'frp', 'magnitude', 'shipType', 'destination', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass', 'military', 'onGround', 'distanceKm', 'confidence', 'place']) { + for (const k of ['icao24', 'mmsi', 'registration', 'label', 'callsign', 'name', 'altitudeM', 'speedMps', 'speedKts', 'frp', 'magnitude', 'shipType', 'destination', 'operator', 'routeOrigin', 'routeDestination', 'aircraftClass', 'military', 'onGround', 'distanceKm', 'confidence', 'place', 'noradId', 'satelliteClass', 'group', 'river', 'output', 'capacity']) { if (r[k] !== null && r[k] !== undefined) compact[k] = r[k]; } return compact;