mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge upstream/main into cctv-stills-aspect
This commit is contained in:
commit
56c5d03b65
94 files changed
+4265
-602
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
parseStationStatus,
|
||||
toNonNegativeInteger,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
@@ -28,15 +29,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(aLat, aLon, bLat, bLon) {
|
||||
const toRad = (value) => (value * Math.PI) / 180;
|
||||
const dLat = toRad(bLat - aLat);
|
||||
const dLon = toRad(bLon - aLon);
|
||||
const p1 = toRad(aLat);
|
||||
const p2 = toRad(bLat);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
|
||||
provider: 'AMBici',
|
||||
},
|
||||
{
|
||||
id: 'madrid-bicimad',
|
||||
city: 'Madrid, Spain',
|
||||
centerLat: 40.4241,
|
||||
centerLon: -3.6825,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciMAD',
|
||||
},
|
||||
{
|
||||
id: 'valencia-valenbisi',
|
||||
city: 'Valencia, Spain',
|
||||
centerLat: 39.4706,
|
||||
centerLon: -0.3749,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
|
||||
provider: 'Valenbisi',
|
||||
},
|
||||
{
|
||||
id: 'zaragoza-bizi',
|
||||
city: 'Zaragoza, Spain',
|
||||
centerLat: 41.6481,
|
||||
centerLon: -0.8927,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bizi Zaragoza',
|
||||
},
|
||||
{
|
||||
id: 'sevilla-sevici',
|
||||
city: 'Seville, Spain',
|
||||
centerLat: 37.3726,
|
||||
centerLon: -5.9607,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
|
||||
provider: 'Sevici',
|
||||
},
|
||||
{
|
||||
id: 'valladolid-biki',
|
||||
city: 'Valladolid, Spain',
|
||||
centerLat: 41.6233,
|
||||
centerLon: -4.7382,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BIKI',
|
||||
},
|
||||
{
|
||||
id: 'a-coruna-bicicoruna',
|
||||
city: 'A Coruña, Spain',
|
||||
centerLat: 43.3534,
|
||||
centerLon: -8.4156,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciCoruña',
|
||||
},
|
||||
{
|
||||
id: 'donostia-dbizi',
|
||||
city: 'Donostia-San Sebastián, Spain',
|
||||
centerLat: 43.3075,
|
||||
centerLon: -1.9696,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Dbizi',
|
||||
},
|
||||
{
|
||||
id: 'bilbao-bilbaobizi',
|
||||
city: 'Bilbao, Spain',
|
||||
centerLat: 43.2624,
|
||||
centerLon: -2.9367,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bilbao Bizi',
|
||||
},
|
||||
{
|
||||
id: 'rivas-bicinrivas',
|
||||
city: 'Rivas-Vaciamadrid, Spain',
|
||||
centerLat: 40.3535,
|
||||
centerLon: -3.5319,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BicinRivas',
|
||||
},
|
||||
];
|
||||
function normalizeRegistryEntry(entry) {
|
||||
const id = String(entry?.id || '')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each system's host, its stations farthest from the registered centre in
|
||||
// opposite directions, and the stations its live feed lists away from the
|
||||
// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
|
||||
// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
|
||||
const SYSTEMS = {
|
||||
'madrid-bicimad': {
|
||||
host: 'madrid.publicbikesystem.net',
|
||||
provider: 'BiciMAD',
|
||||
stations: [
|
||||
{
|
||||
name: '627 - Alcalde Andrés Madrid Dávila, 92',
|
||||
lat: 40.398303,
|
||||
lon: -3.548534,
|
||||
},
|
||||
{
|
||||
name: '672 - Estación de tren de El Barrial-Pozuelo',
|
||||
lat: 40.464908,
|
||||
lon: -3.807764,
|
||||
},
|
||||
],
|
||||
},
|
||||
'valencia-valenbisi': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Valenbisi',
|
||||
stations: [
|
||||
{ name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
|
||||
{ name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
|
||||
],
|
||||
},
|
||||
'zaragoza-bizi': {
|
||||
host: 'zaragoza.publicbikesystem.net',
|
||||
provider: 'Bizi Zaragoza',
|
||||
stations: [
|
||||
{ name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
|
||||
{ name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
|
||||
],
|
||||
},
|
||||
'sevilla-sevici': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Sevici',
|
||||
stations: [
|
||||
{ name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
|
||||
{ name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
|
||||
],
|
||||
strays: [
|
||||
{
|
||||
station_id: '298',
|
||||
name: '_LAB MADRID',
|
||||
lat: 40.448779,
|
||||
lon: -3.584983,
|
||||
},
|
||||
{ station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'valladolid-biki': {
|
||||
host: 'valladolid.publicbikesystem.net',
|
||||
provider: 'BIKI',
|
||||
stations: [
|
||||
{ name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
|
||||
{ name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
|
||||
],
|
||||
},
|
||||
'a-coruna-bicicoruna': {
|
||||
host: 'acoruna.publicbikesystem.net',
|
||||
provider: 'BiciCoruña',
|
||||
stations: [
|
||||
{ name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
|
||||
{ name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
|
||||
],
|
||||
strays: [
|
||||
{ station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
|
||||
{ station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
|
||||
{ station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'donostia-dbizi': {
|
||||
host: 'sansebastian.publicbikesystem.net',
|
||||
provider: 'Dbizi',
|
||||
stations: [
|
||||
{ name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
|
||||
{ name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
|
||||
],
|
||||
},
|
||||
'bilbao-bilbaobizi': {
|
||||
host: 'bilbao.publicbikesystem.net',
|
||||
provider: 'Bilbao Bizi',
|
||||
stations: [
|
||||
{ name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
|
||||
{ name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
|
||||
],
|
||||
},
|
||||
'rivas-bicinrivas': {
|
||||
host: 'rivas.publicbikesystem.net',
|
||||
provider: 'BicinRivas',
|
||||
stations: [
|
||||
{
|
||||
name: 'Polideportivo Parque Sureste',
|
||||
lat: 40.3236695,
|
||||
lon: -3.5127191,
|
||||
},
|
||||
{ name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = {
|
||||
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
|
||||
};
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
/** Station information for `id` through the real ingestion path. */
|
||||
async function loadThroughIngestion(id, stations) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
const state = {
|
||||
_stationInfoCache: new Map(),
|
||||
_inFlightInfo: new Map(),
|
||||
_inFlightStatus: new Map(),
|
||||
_loadingOps: 0,
|
||||
_loading: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
services: {},
|
||||
parts,
|
||||
source: {
|
||||
async getStations(url) {
|
||||
assert.equal(url, city.stationInformationUrl);
|
||||
return { data: { stations } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return ingestion.loadCityStationInfo(id, 1);
|
||||
}
|
||||
|
||||
test('the Spanish city systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
assert.ok(system, `${id} is registered`);
|
||||
assert.equal(system.provider, expected.provider);
|
||||
assert.deepEqual(system.hosts, [expected.host]);
|
||||
for (const feed of [
|
||||
system.stationInformationUrl,
|
||||
system.stationStatusUrl,
|
||||
]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(
|
||||
isAllowedGbfsPath(url.pathname, url.hostname),
|
||||
`${id} path ${url.pathname}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each load radius reaches its outermost stations', () => {
|
||||
// The range check floors every radius at 100 km, but station information is
|
||||
// trimmed to the radius as registered, so it has to reach every station.
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
for (const station of expected.stations) {
|
||||
const distance = parts.model.haversineKm(
|
||||
system.centerLat,
|
||||
system.centerLon,
|
||||
station.lat,
|
||||
station.lon,
|
||||
);
|
||||
assert.ok(
|
||||
distance <= system.loadRadiusKm,
|
||||
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over central Madrid activates BiciMAD and BicinRivas', () => {
|
||||
// Puerta del Sol.
|
||||
const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
|
||||
assert.ok(active.has('madrid-bicimad'));
|
||||
assert.ok(active.has('rivas-bicinrivas'));
|
||||
});
|
||||
|
||||
test('stations a feed lists away from its city are not loaded', async () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
if (!expected.strays) continue;
|
||||
const local = expected.stations.map((station, index) => ({
|
||||
station_id: `local-${index}`,
|
||||
...station,
|
||||
}));
|
||||
const stations = await loadThroughIngestion(id, [
|
||||
...local,
|
||||
...expected.strays,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...stations.keys()],
|
||||
local.map((station) => station.station_id),
|
||||
`${id} keeps only its local stations`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('JCDecaux station names reach the layer without underscores', async () => {
|
||||
for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
|
||||
const stations = await loadThroughIngestion(
|
||||
id,
|
||||
SYSTEMS[id].stations.map((station, index) => ({
|
||||
station_id: String(index),
|
||||
...station,
|
||||
})),
|
||||
);
|
||||
for (const station of stations.values())
|
||||
assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
|
||||
}
|
||||
const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
|
||||
{ station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
|
||||
]);
|
||||
assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
|
||||
});
|
||||
|
||||
test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
|
||||
// Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
|
||||
const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
|
||||
const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
|
||||
{ station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
|
||||
]);
|
||||
assert.equal(bilbaoStations.get('1').capacity, 39);
|
||||
const [madrid] = SYSTEMS['madrid-bicimad'].stations;
|
||||
const madridStations = await loadThroughIngestion('madrid-bicimad', [
|
||||
{ station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
|
||||
]);
|
||||
assert.equal(madridStations.get('627').capacity, 27);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from '../../geoDistance.js';
|
||||
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
|
||||
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
|
||||
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
|
||||
@@ -309,12 +310,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -219,8 +219,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25;
|
||||
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
|
||||
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
|
||||
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
|
||||
// caches per-key on disk forever, negative results included, so repeat
|
||||
// sessions never re-hit adsbdb). Each key is requested at most once per
|
||||
// caches successful and 404 responses per key for 24 hours, so later sessions
|
||||
// may refresh them). Each key is requested at most once per
|
||||
// session. Priority jobs (tracked plane, model-eligible planes) jump the
|
||||
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -243,7 +243,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
|
||||
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
|
||||
// request rate (repeat sessions resolve instantly from the proxy's
|
||||
// permanent disk cache).
|
||||
// 24-hour disk cache).
|
||||
// Fail-silent by contract: the sweep never throws into the poll loop, never
|
||||
// blocks rendering, and never touches tracking state. When a type answer
|
||||
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
|
||||
@@ -257,7 +257,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
|
||||
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
|
||||
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
|
||||
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
|
||||
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
|
||||
// the 4-concurrent limit + the proxy's 24-hour disk cache) is the REAL
|
||||
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
|
||||
// burst so a fresh region still classifies quickly.
|
||||
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
|
||||
|
||||
Vendored
+1
@@ -57,6 +57,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
orbitMatches: layerState._orbitMatches,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
error: layerState._lastError,
|
||||
stale: layerState._stale,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -168,9 +168,16 @@ export function createIngestion({
|
||||
async function performMissionUpdate(token) {
|
||||
try {
|
||||
ensureActiveTleLookup(token);
|
||||
const payload = await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
});
|
||||
const { payload, stale } = source.getLaunchSnapshot
|
||||
? await source.getLaunchSnapshot({
|
||||
signal: layerState._sourceController.signal,
|
||||
})
|
||||
: {
|
||||
payload: await source.getLaunches({
|
||||
signal: layerState._sourceController.signal,
|
||||
}),
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -214,6 +221,7 @@ export function createIngestion({
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function fixture({ legacySource = false } = {}) {
|
||||
const launch = {
|
||||
id: 'owned-launch',
|
||||
name: 'Owned mission fixture',
|
||||
net: new Date(Date.now() - 3600000).toISOString(),
|
||||
status: { name: 'Launch Successful' },
|
||||
pad: { name: 'Owned pad', latitude: 28.6, longitude: -80.6 },
|
||||
rocket: { payloads: [] },
|
||||
};
|
||||
const mode = { cache: 'HIT', results: [launch], hold: false };
|
||||
let arrival;
|
||||
let release;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/celestrak/active') {
|
||||
res.end('');
|
||||
return;
|
||||
}
|
||||
assert.equal(req.url, '/api/launches');
|
||||
const send = () => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'X-GEV-Cache': mode.cache,
|
||||
});
|
||||
res.end(JSON.stringify({ results: mode.results }));
|
||||
};
|
||||
if (mode.hold) {
|
||||
release = send;
|
||||
arrival?.();
|
||||
} else send();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const launchSource = createLaunchSource({
|
||||
fetchImpl: (url, options) => {
|
||||
assert.ok(['/api/launches', '/api/celestrak/active'].includes(url));
|
||||
return fetch(new URL(url, origin), options);
|
||||
},
|
||||
});
|
||||
const source = legacySource
|
||||
? {
|
||||
getLaunches: launchSource.getLaunches,
|
||||
getActiveTle: launchSource.getActiveTle,
|
||||
}
|
||||
: launchSource;
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
panel: {
|
||||
createMissionPanel: noop,
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
orbitRendering: {
|
||||
removeMissionOrbitPrimitives: noop,
|
||||
syncMissionOrbitPrimitiveVisibility: noop,
|
||||
},
|
||||
overlays: {
|
||||
createReplayVehicleOverlay: noop,
|
||||
clearMissionOverlaySources: noop,
|
||||
hideReplayVehicleOverlay: noop,
|
||||
destroyReplayVehicleOverlay: noop,
|
||||
},
|
||||
launchPad: {
|
||||
initLaunchPadZonePrimitive: noop,
|
||||
hideLaunchPadZone: noop,
|
||||
destroyLaunchPadZonePrimitive: noop,
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { state, services, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fresh launches remain nominal through ingestion and the layer stats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.ok(value.stats.lastUpdate);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE-ERROR provenance reaches the layer feed state without dropping records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
assert.equal((await f.source.getLaunchSnapshot()).stale, true);
|
||||
await f.update();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 1);
|
||||
assert.deepEqual(value.rendered, ['owned-launch']);
|
||||
assert.equal(value.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh recovery clears stale launch provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
const stale = f.current();
|
||||
f.mode.cache = 'MISS';
|
||||
await f.update();
|
||||
const recovered = f.current();
|
||||
assert.equal(recovered.feed, 'nominal');
|
||||
assert.equal(recovered.stats.count, 1);
|
||||
assert.equal(recovered.stats.error, null);
|
||||
assert.equal(stale.feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty stale snapshot still identifies its fallback provenance', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.results = [];
|
||||
await f.update();
|
||||
assert.equal(f.current().stats.count, 0);
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('getLaunches-only injected sources remain supported', async () => {
|
||||
const f = await fixture({ legacySource: true });
|
||||
try {
|
||||
await f.update();
|
||||
assert.equal(f.current().feed, 'nominal');
|
||||
assert.deepEqual(f.current().rendered, ['owned-launch']);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disable cancels a pending stale response without committing it', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.update();
|
||||
const healthy = f.current();
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
f.mode.hold = true;
|
||||
const arrived = f.holdArrival();
|
||||
const pending = f.update();
|
||||
await arrived;
|
||||
await f.disable();
|
||||
f.release();
|
||||
await pending;
|
||||
const value = f.current();
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(value.stats.lastUpdate, healthy.stats.lastUpdate);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(f.state._sourceController.signal.aborted, true);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('hiding retained stale records does not relabel them as fresh', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.disable();
|
||||
assert.equal(f.current().feed, 'stale');
|
||||
assert.equal(f.current().stats.count, 1);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy clears the previous feed state and records', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
const value = f.current();
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.enabled, false);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy followed by actual init starts a clean instance with owned handlers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.mode.cache = 'STALE-ERROR';
|
||||
await f.update();
|
||||
await f.destroy();
|
||||
await f.init();
|
||||
const value = f.current();
|
||||
assert.equal(value.feed, 'nominal');
|
||||
assert.equal(value.stats.count, 0);
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.ok(click instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.ok(move instanceof Cesium.ScreenSpaceEventHandler);
|
||||
assert.equal(
|
||||
typeof click.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK),
|
||||
'function',
|
||||
);
|
||||
await f.destroy();
|
||||
assert.equal(click.isDestroyed(), true);
|
||||
assert.equal(move.isDestroyed(), true);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export function createLifecycle({
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
@@ -160,6 +161,7 @@ export function createLifecycle({
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._activeTleText = null;
|
||||
layerState._activeTlePromise = null;
|
||||
layerState._activeTlePromiseToken = 0;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._stale = false;
|
||||
|
||||
state._orbitMatches = 0;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_GROUP_PATH,
|
||||
@@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
const entry = entries[i];
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
const noradId = Number(satrec.satnum);
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
@@ -111,7 +112,7 @@ export function createIngestion({
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
|
||||
const noradId = Number(satrec.satnum);
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (seen.has(noradId)) continue;
|
||||
seen.add(noradId);
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
|
||||
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
|
||||
const ISS_LINE1 =
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
|
||||
const ISS_LINE2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
|
||||
|
||||
function tleSet(name, field) {
|
||||
return [
|
||||
name,
|
||||
ISS_LINE1.replace('25544', field),
|
||||
ISS_LINE2.replace('25544', field),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ingestionFor(catalogText) {
|
||||
const state = {
|
||||
_trackingRefreshEpoch: 0,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_pointCollection: {
|
||||
removeAll() {},
|
||||
add: (point) => point,
|
||||
},
|
||||
_points: new Map(),
|
||||
_orbitPaths: new Map(),
|
||||
_catalog: new Map(),
|
||||
_detectionObjects: new Map(),
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
};
|
||||
const parts = {
|
||||
orbits: {
|
||||
parseTLE: parseTleText,
|
||||
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
controls: { _pointStyleFor: () => ({}) },
|
||||
rendering: { _showOrbitPath() {} },
|
||||
tracking: {
|
||||
async _reconcileTrackedSubjectContext() {},
|
||||
_applyPendingTrackingRestore() {},
|
||||
},
|
||||
catalog: {},
|
||||
};
|
||||
const source = {
|
||||
async readGroup() {
|
||||
return { ok: true, text: catalogText };
|
||||
},
|
||||
};
|
||||
const { methods } = createIngestion({ state, services: {}, parts, source });
|
||||
return { state, update: () => methods.update({ scene: {} }) };
|
||||
}
|
||||
|
||||
test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(
|
||||
[
|
||||
tleSet('FIRST', 'A0000'),
|
||||
tleSet('SECOND', 'B0001'),
|
||||
tleSet('THIRD', 'Z9999'),
|
||||
].join('\n'),
|
||||
);
|
||||
await update();
|
||||
assert.deepEqual(
|
||||
[...state._catalog].map(([id, entry]) => [id, entry.name]),
|
||||
[
|
||||
[100000, 'FIRST'],
|
||||
[110001, 'SECOND'],
|
||||
[339999, 'THIRD'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
|
||||
assert.equal(state._count, 3);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
const current = propagatePosition(satrec, referenceDate);
|
||||
if (!current) return null;
|
||||
return {
|
||||
noradId: Number(satrec.satnum),
|
||||
noradId: noradIdFromSatnum(satrec.satnum),
|
||||
name: String(name || '').trim(),
|
||||
current,
|
||||
periodSec: orbitalPeriodSeconds(satrec),
|
||||
|
||||
Reference in new issue
Block a user