mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat: port PR #351 analyst records to current layer owners
This commit is contained in:
commit
b3f1e17733
13 files changed
+600
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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<Object>} 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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -49,3 +49,5 @@ export {
|
||||
ISS_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../layers/satellites/index.js';
|
||||
export default layer;
|
||||
|
||||
export { mapAnalystRecord } from '../layers/satellites/records.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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -771,6 +771,9 @@ const schemas = [
|
||||
'ais-live-vessels',
|
||||
'local-firms',
|
||||
'earthquakes',
|
||||
'satellites',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user