mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
|
# 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.
|
- 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.
|
- 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
|
# 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.
|
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.
|
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 |
|
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||||
|
|
||||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
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
|
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
|
host permits one live instance per layer ID; destroy an old instance before
|
||||||
replacing it. Separate applications may use identical IDs with separate hosts.
|
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: [] },
|
'ais-live-vessels': { numeric: ['speedKts', 'courseDeg'], text: ['name', 'mmsi', 'shipType', 'destination', 'navStatus'], flags: [] },
|
||||||
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
'local-firms': { numeric: ['frp'], text: ['confidence', 'satellite'], flags: [] },
|
||||||
earthquakes: { numeric: ['magnitude', 'depthKm'], text: ['place'], 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;
|
const EARTH_R_KM = 6371;
|
||||||
@@ -132,7 +135,7 @@ export function createAnalystEngine(providers) {
|
|||||||
for (const key of layers) {
|
for (const key of layers) {
|
||||||
if (!ANALYST_LAYERS[key]) continue;
|
if (!ANALYST_LAYERS[key]) continue;
|
||||||
const rows = providers.getRecords(key) || [];
|
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 });
|
for (const row of rows) records.push({ layerKey: key, ...row });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +232,9 @@ export function createAnalystEngine(providers) {
|
|||||||
layersQueried,
|
layersQueried,
|
||||||
scope: scopeNote,
|
scope: scopeNote,
|
||||||
followUp: Boolean(spec.followUp && lastResult),
|
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
|
// Surfaced so the narration can name the centre it measured from rather
|
||||||
// than implying a view-centred answer.
|
// 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');
|
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', () => {
|
test('helpers: haversine sanity + scope radius', () => {
|
||||||
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
|
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
|
||||||
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
|
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 };
|
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.
|
* Produce one normalized-contract input owned by a local infrastructure layer.
|
||||||
* The host revalidates the authoritative `source` value while normalizing it.
|
* The host revalidates the authoritative `source` value while normalizing it.
|
||||||
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
|
|||||||
* standard scene.pick natively clicks them.
|
* standard scene.pick natively clicks them.
|
||||||
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
|
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
|
||||||
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
|
* @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.
|
* 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.
|
* Destroy the previous instance before replacing it. Importing creates no layers.
|
||||||
*/
|
*/
|
||||||
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
|
|||||||
computed: _lodComputed,
|
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) => {
|
enable: async (viewer) => {
|
||||||
if (_destroyed) return;
|
if (_destroyed) return;
|
||||||
_enabled = true;
|
_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,
|
ISS_OVERLAY_SOURCE_OPTIONS,
|
||||||
} from '../layers/satellites/index.js';
|
} from '../layers/satellites/index.js';
|
||||||
export default layer;
|
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 { createInteraction } from './interaction.js';
|
||||||
import { createLifecycle } from './lifecycle.js';
|
import { createLifecycle } from './lifecycle.js';
|
||||||
import { createIngestion } from './ingestion.js';
|
import { createIngestion } from './ingestion.js';
|
||||||
|
import { createRecords } from './records.js';
|
||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
|
|
||||||
/** Construct one layer with its own scene state and supplied application services. */
|
/** 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.catalog = createCatalog(context);
|
||||||
parts.labels = createLabels(context);
|
parts.labels = createLabels(context);
|
||||||
parts.orbits = createOrbits(context);
|
parts.orbits = createOrbits(context);
|
||||||
|
parts.records = createRecords(context);
|
||||||
parts.rendering = createRendering(context);
|
parts.rendering = createRendering(context);
|
||||||
parts.tracking = createTracking(context);
|
parts.tracking = createTracking(context);
|
||||||
parts.testing = createTesting(context);
|
parts.testing = createTesting(context);
|
||||||
@@ -63,6 +65,7 @@ export function createSatellitesLayer({ services, source }) {
|
|||||||
applySatellitePointFocusDeemphasis:
|
applySatellitePointFocusDeemphasis:
|
||||||
parts.rendering.applySatellitePointFocusDeemphasis,
|
parts.rendering.applySatellitePointFocusDeemphasis,
|
||||||
getNextIssPass: parts.orbits.getNextIssPass,
|
getNextIssPass: parts.orbits.getNextIssPass,
|
||||||
|
getAnalystRecords: parts.records.getAnalystRecords,
|
||||||
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
||||||
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
||||||
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
|
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',
|
'ais-live-vessels',
|
||||||
'local-firms',
|
'local-firms',
|
||||||
'earthquakes',
|
'earthquakes',
|
||||||
|
'satellites',
|
||||||
|
'local-datacenters',
|
||||||
|
'local-dams',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-1
@@ -3253,6 +3253,18 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac
|
|||||||
? (mod.getAnalystRecords(requestedLimit) || [])
|
? (mod.getAnalystRecords(requestedLimit) || [])
|
||||||
: (mod.getAnalystRecords() || []);
|
: (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,
|
resolveRegionRing,
|
||||||
/**
|
/**
|
||||||
* The active Contacts subject, when there is one — the centre the operator
|
* 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).
|
// model burned the turn on retries (owner field session 2026-08-21, 23:48).
|
||||||
const items = result.items.map((r) => {
|
const items = result.items.map((r) => {
|
||||||
const compact = { layerKey: r.layerKey, id: r.id };
|
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];
|
if (r[k] !== null && r[k] !== undefined) compact[k] = r[k];
|
||||||
}
|
}
|
||||||
return compact;
|
return compact;
|
||||||
|
|||||||
Reference in new issue
Block a user