Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Trygve Hemminghytt committed 2026-10-18 20:02:39 +02:00
commit 52603f0afe
123 files changed
+4348 -561

No files matched your search

+2 -10
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -1567,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
}
function greatCircleM(a, b) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleMeters(a, b);
}
function formatDistance(m) {
+3 -8
View File
@@ -20,6 +20,8 @@ import { t, tn } from '../i18n/index.js';
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -49,14 +51,7 @@ export function createDrawSession(shape = 'area') {
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
return greatCircleMeters(a, b);
}
/**
+4 -17
View File
@@ -1,5 +1,6 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from './geoDistance.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
}
function greatCircleM(a, b) {
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
return greatCircleMeters(
{ lat: a?.latitude, lon: a?.longitude },
{ lat: b?.latitude, lon: b?.longitude },
);
}
+3 -10
View File
@@ -27,6 +27,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
/**
* Layers the engine understands, with the fields queries may reference,
@@ -98,17 +99,9 @@ function toEpochMs(value) {
return Date.parse(text);
}
const EARTH_R_KM = 6371;
/** Great-circle distance in km. */
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
+12
View File
@@ -0,0 +1,12 @@
/**
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
* short way round, so points either side of 180° stay close.
*/
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
let dLonDeg = lon2 - lon1;
dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
+252
View File
@@ -0,0 +1,252 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
fallbackHeadingFromId,
isLikelyCataloniaCoordinate,
toFiniteNumber,
} from '../../server/providers/cctv/normalize.js';
import {
loadCaltransSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTflSourcesFromOpenData,
parseCataloniaXml,
} from '../../server/providers/cctv/sources.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
import { createModel } from '../layers/cctv/model.js';
/** Values `Number` reads as 0 (or 1) although they carry no number. */
const ABSENT = [null, undefined, '', ' ', false, true];
/** Set env vars for one test and restore them afterwards. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** The production browser model; only the catalog's pose pass is stubbed. */
function browserModel() {
const parts = {};
parts.model = createModel({
state: {},
services: { focus: {} },
parts,
source: {},
});
return parts.model;
}
/** The browser catalog over the production model. */
function browserCatalog(model, cityPois = {}) {
return createCatalog({
state: {},
services: { locations: { CITY_POIS: cityPois } },
parts: { model: { ...model, ensureCameraPose: () => {} } },
source: {},
});
}
/** GET /api/cctv/sources through the real proxy middleware. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
const { safeNumber } = browserModel();
for (const value of ABSENT) {
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
}
for (const [value, expected] of [
[0, 0],
['0', 0],
[-17, -17],
[' 12.5 ', 12.5],
]) {
assert.equal(toFiniteNumber(value, 99), expected);
assert.equal(safeNumber(value, 99), expected);
}
});
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
quiet(t);
const pose = {
headingDeg: null,
pitchDeg: null,
fovDeg: null,
rangeM: null,
mountHeightM: null,
groundElevationM: null,
};
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
withEnv(t, {
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
]),
});
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
const model = browserModel();
const cameras = browserCatalog(model, {
austin: { name: 'Austin', groundElevation: 150 },
}).buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
const camera = byId.get(id);
assert.ok(camera, id);
assert.equal(camera.headingDeg, model.headingFromId(id), id);
assert.equal(camera.pitchDeg, -17, id);
assert.equal(camera.fovDeg, 74, id);
assert.equal(camera.rangeM, 700, id);
assert.equal(camera.mountHeightM, 24, id);
assert.equal(camera.groundElevationM, 150, id);
}
// No position is not a camera in the Gulf of Guinea.
assert.equal(byId.has('pack-coords-null'), false);
assert.equal(byId.has('pack-coords-blank'), false);
});
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'tallinn.json');
writeFileSync(
file,
JSON.stringify([
{
id: 'tln-null-heading',
lat: 59.437,
lon: 24.753,
headingDeg: null,
groundElevationM: null,
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
},
]),
);
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
assert.equal(camera.groundElevationM, 15);
});
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
quiet(t);
const jamCam = (id, lat, lon) => ({
id: `JamCams_${id}`,
commonName: `JamCam ${id}`,
lat,
lon,
additionalProperties: [
{ key: 'available', value: 'true' },
{
key: 'imageUrl',
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
},
],
});
t.mock.method(globalThis, 'fetch', async () =>
Response.json([
jamCam('00001.00001', 51.5072, -0.1276),
jamCam('00001.00002', null, null),
jamCam('00001.00003', 51.5072, ''),
]),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.id),
['tfl-00001.00001'],
);
});
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
const feature = (coordinates) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
const [blank, valid] = parseCataloniaXml(
feature(',41.5') + feature('2.17,41.39'),
);
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
assert.ok(Number.isNaN(blank.lon));
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
});
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
quiet(t);
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
data: [
{
cctv: {
inService: 'true',
location: {
latitude: '37.8044',
longitude: '-122.2712',
elevation: '',
locationName: 'TV101 -- I-880 : Test Ave',
},
imageData: {
static: {
currentImageURL:
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
},
},
},
},
],
}),
);
const [camera] = await loadCaltransSourcesFromOpenData();
assert.ok(camera);
assert.equal(camera.groundElevationM, 150);
});
+5 -2
View File
@@ -305,7 +305,10 @@ export const DATA_CREDITS = [
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
},
{
key: 'urbansharing-gbfs',
@@ -324,7 +327,7 @@ export const DATA_CREDITS = [
{
key: 'jcdecaux-gbfs',
html:
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
},
{
+48 -1
View File
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
/** Seed one civil contact through the production refresh seam. */
function seedLabelContact({ callsign, registration, tracked = false }) {
function seedLabelContact({
callsign,
registration,
tracked = false,
// Per-test metadata overrides, spread last so a test can set a field to
// `undefined` to model "the feed never sent it" — a default parameter
// could not express that, it would just re-supply the default.
meta: metaOverrides = {},
}) {
_setTrackedFlightRefreshStateForTest({
icao24: LABEL_ICAO,
entity: null,
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
turnRateDps: 0,
rawLat: 30.21,
rawLon: -97.71,
...metaOverrides,
},
});
}
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
});
test('the tracked descriptor publishes the reported vertical rate', () => {
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
// seam did not, so a consumer that needs climb/descent (a flight-phase
// classifier, the cockpit) had to reach past getTrackedInfo() into the
// raw record. Both seams now answer with the same field name and unit.
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: -6.4 },
});
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
assert.equal(
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
-6.4,
'the analyst seam and the tracked seam must agree',
);
});
test('an unreported vertical rate stays null, never a level-flight zero', () => {
// Absent is not level. `stickyNumber` seeds this field with null, so a
// contact that has never transmitted a rate must not read as 0 m/s — a
// phase classifier would call that cruise while the aircraft climbs.
for (const missing of [undefined, null, Number.NaN]) {
seedLabelContact({
callsign: 'SWA696',
registration: 'N123AB',
tracked: true,
meta: { verticalRate: missing },
});
assert.equal(
flightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
test('civil label chain: identity stays icao24 while the label moves', () => {
for (const fixture of [
{ callsign: 'SWA696', registration: 'N123AB' },
+26
View File
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
});
// --- Antimeridian ------------------------------------------------------------
// Raw longitude arithmetic walks a segment across 180° the long way round and
// reads a contact on the seam as half a world from its previous cell.
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
for (const [from, to, across, behind] of [
[179.999, -179.998, -179.999, 179.998],
[-179.999, 179.998, 179.999, -179.998],
]) {
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
const lons = cells.map((c) => c.lon);
assert.equal(lons[0], from);
assert.equal(lons.at(-1), to);
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
}
});
test('stickyFloorCell holds across the antimeridian', () => {
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
const prev = { lat: -16.69, lon: prevLon };
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
}
});
// --- F2: corridor budget fairness ------------------------------------------
// The first cut spent the budget in Map insertion order and decremented it
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
+9
View File
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
reset();
let calls = 0;
const scene = fakeScene(() => { calls += 1; return undefined; });
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
+45
View File
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
'and the same query resolves the same way every time',
);
});
test('the military tracked descriptor publishes the reported vertical rate', () => {
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
// the rate under different record keys (`verticalRateMps` here,
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
// shape if BOTH descriptors map it — a consumer that reads the field off the
// civil seam and finds it missing on the military one would silently treat
// every military contact as level flight.
const seedRate = (verticalRateMps) =>
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae7f01',
entity: { gevLabelModel: { title: 'TEST', details: [] } },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
color: Cesium.Color.WHITE,
show: true,
},
billboardCollection: { show: true, remove() {} },
viewer: { camera: { positionCartographic: null }, scene: {} },
meta: {
callsign: 'RCH512',
rawLat: 31,
rawLon: -97,
altitudeFt: 26_000,
speedMps: 220,
track: 95,
onGround: false,
verticalRateMps,
},
});
seedRate(12.7);
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
// Absent is not level: a contact that has never transmitted a rate must read
// as null, not as a 0 m/s a phase classifier would call cruise.
for (const missing of [undefined, null, Number.NaN]) {
seedRate(missing);
assert.equal(
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
null,
`vertical rate ${String(missing)} must publish as null`,
);
}
});
+5 -11
View File
@@ -18,8 +18,8 @@
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js';
const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical-excess ring area (km²) — same family as turf/geojson-area. */
@@ -36,15 +36,6 @@ function ringAreaKm2(ring) {
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
function haversineKm(lon1, lat1, lon2, lat2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* Normalize a query/name for matching: lowercase, strip diacritics and
* punctuation, collapse whitespace, strip a leading "the ".
@@ -157,7 +148,10 @@ function buildEntries(pack, kind) {
polygons,
areaKm2,
bbox: [minLon, minLat, maxLon, maxLat],
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
bboxDiagonalKm: greatCircleKm(
{ lat: minLat, lon: minLon },
{ lat: maxLat, lon: maxLon },
),
});
}
return out;
+11
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { statSync, readFileSync } from 'node:fs';
import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js';
import { greatCircleKm } from '../geoDistance.js';
const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url);
@@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000,
`Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`);
assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km');
const coordinates = rockies.polygons.flat();
const minLon = Math.min(...coordinates.map(([lon]) => lon));
const minLat = Math.min(...coordinates.map(([, lat]) => lat));
const maxLon = Math.max(...coordinates.map(([lon]) => lon));
const maxLat = Math.max(...coordinates.map(([, lat]) => lat));
assert.equal(
rockies.bboxDiagonalKm,
greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }),
'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points',
);
});
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
+6 -15
View File
@@ -1,3 +1,5 @@
import { greatCircleMeters } from '../geoDistance.js';
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
@@ -153,19 +155,8 @@ export function weatherCodeLabel(code) {
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
Number.isFinite,
)
) {
return Infinity;
}
const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) ** 2 +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleMeters(
{ lat: from?.latitude, lon: from?.longitude },
{ lat: to?.latitude, lon: to?.longitude },
);
}
+152
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
@@ -676,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
assert.equal(launches[0].lon, -120.611);
});
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
for (const blank of [null, '', ' ', false]) {
assert.deepEqual(
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
[],
);
}
});
test('null pad coordinates use the location fallback instead of 0,0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
assert.equal(fallback.lat, 28.6);
assert.equal(fallback.lon, -80.6);
});
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
rocket: { launcher_stage: [{ launcher_flight_number: null,
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
const [stage] = launch.recoveryStages;
assert.equal(stage.lat, null);
assert.equal(stage.lon, null);
assert.equal(stage.downrangeKm, null);
assert.equal(stage.flightNumber, null);
});
test('normalizes detailed payload and stage recovery records', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'recovery-details',
@@ -728,6 +760,126 @@ test('normalizes detailed payload and stage recovery records', () => {
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
});
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
const model = createModel({});
assert.equal(model.finiteCoordinate(42.5), 42.5);
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
assert.equal(model.finiteCoordinate(0), 0);
assert.equal(model.finiteCoordinate('0'), 0);
assert.equal(model.finiteCoordinate(undefined), null);
assert.equal(model.finiteCoordinate('invalid'), null);
assert.equal(model.finiteCoordinate('TBD'), null);
assert.equal(model.finiteCoordinate(Number.NaN), null);
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
});
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'recovery-fallbacks',
name: 'Falcon 9 | Edge Case Stage Normalization',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '28.608', longitude: '-80.604' },
rocket: {
launcher_stage: [
{
id: 88,
type: { name: 'Booster' },
launcher_flight_number: 'invalid-flight',
landing: {
attempt: true,
success: false,
downrange_distance: 'TBD',
landing_location: {
name: 'Ocean Impact',
latitude: 'N/A',
longitude: undefined,
},
},
},
{
id: 89,
type: { name: 'Expended Stage' },
landing: {
attempt: false,
type: { name: 'Expendable' },
},
},
],
},
},
],
},
NOW,
);
assert.equal(launches[0].recoveryStages.length, 2);
const booster = launches[0].recoveryStages[0];
assert.equal(booster.status, 'LOST');
assert.equal(booster.success, false);
assert.equal(booster.attempted, true);
assert.equal(booster.flightNumber, null);
assert.equal(booster.downrangeKm, null);
assert.equal(booster.lat, null);
assert.equal(booster.lon, null);
const expendable = launches[0].recoveryStages[1];
assert.equal(expendable.status, 'EXPENDABLE');
assert.equal(expendable.attempted, false);
assert.equal(expendable.success, null);
});
test('normalizes payload amounts and fractional string masses', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'payload-edge-cases',
name: 'Electron | Mixed Payloads',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '-39.26', longitude: '177.86' },
rocket: {
payloads: [
{
id: 601,
amount: 'not-a-number',
payload: { id: 11, name: 'Unknown Amount Payload' },
},
{
id: 602,
amount: 3,
payload: { id: 12, name: 'Triple Payload' },
},
{
id: 603,
payload: {
id: 13,
name: 'Valid Fractional Mass Payload',
mass: '12.75',
},
},
],
},
},
],
},
NOW,
);
// Blank, negative and non-numeric masses are pinned by "missing payload
// details stay unknown without inventing mass or classification".
const payloads = launches[0].payloads;
assert.equal(payloads.length, 3);
assert.equal(payloads[0].amount, 1);
assert.equal(payloads[1].amount, 3);
assert.equal(payloads[2].massKg, 12.75);
});
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'undisclosed',
+12 -28
View File
@@ -10,19 +10,9 @@
* low → its destination should be nearby).
*/
const D2R = Math.PI / 180;
const R_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
/** Haversine great-circle distance in km. */
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * D2R;
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const D2R = Math.PI / 180;
function bearingRad(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
@@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) {
/** Signed cross-track distance (km) of point from the great circle p1→p2. */
export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM;
const d13 =
greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM;
const b13 = bearingRad(lat1, lon1, lat, lon);
const b12 = bearingRad(lat1, lon1, lat2, lon2);
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM;
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
}
const NEAR_ENDPOINT_KM = 130;
@@ -65,14 +56,14 @@ export function routePlausible({
origin = null,
destination = null,
}) {
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
const haveD =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const position = { lat: latDeg, lon: lonDeg };
if (!isGeoPoint(position)) return true;
const haveO = isGeoPoint(origin);
const haveD = isGeoPoint(destination);
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path.
const near = (pt) =>
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) {
geomOk =
@@ -99,17 +90,10 @@ export function routePlausible({
Math.abs(verticalRateMps) > VERT_TREND_MPS
) {
if (verticalRateMps > 0) {
if (
haveO &&
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
)
if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM)
return false; // departing — origin should be local
} else {
if (
haveD &&
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
LOCAL_AIRPORT_KM
)
if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM)
return false; // arriving — destination should be local
}
}
+7 -2
View File
@@ -1,14 +1,15 @@
// src/data/routePlausible.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { routePlausible, greatCircleKm } from './routePlausible.js';
import { greatCircleKm } from '../geoDistance.js';
import { routePlausible } from './routePlausible.js';
const SFO = { lat: 37.6188, lon: -122.3754 };
const LAX = { lat: 33.9416, lon: -118.4085 };
const JFK = { lat: 40.6413, lon: -73.7781 };
test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => {
const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon);
const d = greatCircleKm(SFO, LAX);
assert.ok(Math.abs(d - 543) < 15, `got ${d}`);
});
@@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => {
latDeg: 51.5, lonDeg: -0.12,
origin: { lat: null, lon: null }, destination: null,
}), true);
assert.equal(routePlausible({
latDeg: 95, lonDeg: 0,
origin: SFO, destination: LAX,
}), true);
});
+6 -23
View File
@@ -13,31 +13,11 @@
* @module data/trafficBounds
*/
/** @const {number} Mean Earth radius in km (spherical approximation). */
const EARTH_RADIUS_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Great-circle distance between two lat/lon points (haversine).
*
* @param {number} lat1 - First point latitude (degrees).
* @param {number} lon1 - First point longitude (degrees).
* @param {number} lat2 - Second point latitude (degrees).
* @param {number} lon2 - Second point longitude (degrees).
* @returns {number} Distance in kilometres.
*/
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1);
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Initial bearing (radians) from point 1 toward point 2 along the great circle.
*
@@ -111,10 +91,13 @@ export function deriveFetchCenter({
hitLon,
maxPullKm = 12,
}) {
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
if (!isGeoPoint({ lat: hitLat, lon: hitLon })) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
}
const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon);
const distKm = greatCircleKm(
{ lat: nadirLat, lon: nadirLon },
{ lat: hitLat, lon: hitLon },
);
if (distKm <= maxPullKm) {
return { lat: hitLat, lon: hitLon, source: 'hit' };
}
+7 -7
View File
@@ -1,8 +1,8 @@
// src/data/trafficBounds.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greatCircleKm } from '../geoDistance.js';
import {
greatCircleKm,
deriveFetchCenter,
clampBoundsAroundCenter,
} from './trafficBounds.js';
@@ -11,7 +11,7 @@ import {
const NADIR = { lat: 30.2672, lon: -97.7431 };
test('greatCircleKm sanity: Austin -> ~5 km north', () => {
const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon);
const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon });
assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`);
});
@@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => {
test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => {
// Hit ~100 km due east of nadir.
const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 };
assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit');
assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit');
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat: hit.lat, hitLon: hit.lon,
maxPullKm: 12,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`);
// Due-east bearing: latitude stays ~constant, longitude moves east but well
// short of the hit.
@@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => {
maxPullKm: 5,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`);
});
test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) {
test('invalid ground hit falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) {
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat, hitLon,
+5 -8
View File
@@ -1,3 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
/**
* @module transitFeeds
* @description Registry of keyless, openly licensed GTFS-Realtime
@@ -773,7 +775,8 @@ export function getRegisteredTransitFeed(id) {
}
/**
* Great-circle distance in km.
* Great-circle distance in km. Retained for compatibility with existing
* transit consumers; new shared spatial code should use `greatCircleKm`.
* @param {number} aLat
* @param {number} aLon
* @param {number} bLat
@@ -781,13 +784,7 @@ export function getRegisteredTransitFeed(id) {
* @returns {number}
*/
export function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
}
/**
+12 -4
View File
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
// Compare in progress units: a shot's startProgress (start / total)
// times total can round below its start and select the previous shot.
if (
normalized < endElapsedSec / totalSec ||
index === scene.shots.length - 1
) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
// Keep the elapsed time inside the selected shot, so it agrees with
// shotIndex when targetSec rounded below the shot's start.
const elapsedSec = Math.max(targetSec, startElapsedSec);
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
Math.min(shotDurationSec, elapsedSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
sceneElapsedSec: elapsedSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
+36
View File
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
test('seeking to a shot start lands in that shot for every shot count and duration', () => {
const misses = [];
for (let count = 2; count <= 12; count += 1) {
for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
const shots = Array.from({ length: count }, (_, index) => ({
id: `s${index}`,
// Every third shot is longer, so boundaries are not all equal steps.
durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
holdSec: holdTenths / 10,
camera: camera(20, 0),
}));
const mixed = { shots };
for (const [index, shot] of shots.entries()) {
const { startProgress, startElapsedSec } = sceneTimingForShot(
mixed,
shot,
duration,
);
const seek = sceneSeekState(mixed, startProgress, duration, hold);
// One or two ulps earlier still belongs to the previous shot.
const before = startProgress * (1 - Number.EPSILON);
const previous = sceneSeekState(mixed, before, duration, hold);
if (
seek.shotIndex !== index ||
seek.sceneElapsedSec < startElapsedSec ||
previous.shotIndex !== Math.max(0, index - 1)
)
misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
}
}
}
}
assert.deepEqual(misses.slice(0, 5), []);
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Canonical geographic point used by shared spatial utilities.
*
* @typedef {{lat: number, lon: number}} GeoPoint
*/
/** IUGG mean spherical Earth radius, in kilometres. */
export const EARTH_RADIUS_KM = 6371.0088;
const DEGREES_TO_RADIANS = Math.PI / 180;
/**
* Whether a value is a canonical, finite WGS84-style point in degrees.
* Longitudes are not implicitly wrapped because an out-of-range value usually
* indicates a broken coordinate contract at the caller.
*
* @param {*} point
* @returns {point is GeoPoint}
*/
export function isGeoPoint(point) {
return (
Number.isFinite(point?.lat) &&
point.lat >= -90 &&
point.lat <= 90 &&
Number.isFinite(point?.lon) &&
point.lon >= -180 &&
point.lon <= 180
);
}
/**
* Great-circle distance in kilometres between two `{ lat, lon }` points.
*
* `{ lat, lon }` is the canonical shared coordinate representation. Invalid,
* missing, non-finite, or out-of-range coordinates return `Infinity`, which
* keeps invalid points out of threshold and nearest-neighbour selections.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleKm(from, to) {
if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY;
const latitude1 = from.lat * DEGREES_TO_RADIANS;
const latitude2 = to.lat * DEGREES_TO_RADIANS;
const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS;
const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS;
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitude1) *
Math.cos(latitude2) *
Math.sin(longitudeDelta / 2) ** 2;
const bounded = Math.min(1, Math.max(0, haversine));
return (
2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded))
);
}
/**
* Great-circle distance in metres, with the same point and invalid-input
* contract as {@link greatCircleKm}.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleMeters(from, to) {
return greatCircleKm(from, to) * 1000;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
EARTH_RADIUS_KM,
greatCircleKm,
greatCircleMeters,
isGeoPoint,
} from './geoDistance.js';
const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 });
const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 });
test('great-circle identity and symmetry', () => {
assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0);
assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN));
});
test('great-circle one-degree calibration and canonical units', () => {
const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
assert.ok(Math.abs(kilometres - 111.195) < 0.001);
assert.equal(
greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }),
kilometres * 1000,
);
});
test('great-circle handles dateline crossing and antipodal points', () => {
const acrossDateline = greatCircleKm(
{ lat: 0, lon: 179 },
{ lat: 0, lon: -179 },
);
assert.ok(Math.abs(acrossDateline - 222.39) < 0.01);
assert.ok(
Math.abs(
greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) -
Math.PI * EARTH_RADIUS_KM,
) < 1e-9,
);
});
test('canonical points require explicit latitude and longitude keys', () => {
assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true);
assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false);
assert.equal(isGeoPoint([10, 80]), false);
assert.equal(
greatCircleKm(
{ latitude: 10, longitude: 80 },
{ latitude: 20, longitude: 70 },
),
Infinity,
);
});
test('latitude-before-longitude regression rejects a swapped Austin point', () => {
assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON)));
assert.equal(
greatCircleKm(
{ lat: AUSTIN.lon, lon: AUSTIN.lat },
{ lat: HOUSTON.lon, lon: HOUSTON.lat },
),
Infinity,
);
});
test('invalid, missing, non-finite, and out-of-range points return Infinity', () => {
for (const point of [
null,
{},
{ lat: 0 },
{ lat: NaN, lon: 0 },
{ lat: 0, lon: Infinity },
{ lat: 90.0001, lon: 0 },
{ lat: 0, lon: -180.0001 },
]) {
assert.equal(greatCircleKm(point, AUSTIN), Infinity);
assert.equal(greatCircleMeters(AUSTIN, point), Infinity);
}
});
+2 -7
View File
@@ -15,6 +15,7 @@ import { applicationServices } from './services/application.js';
*/
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js';
@@ -683,13 +684,7 @@ export class IntelHUD {
* @returns {number} Distance in kilometers.
*/
_haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (deg) => Cesium.Math.toRadians(deg);
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 });
}
/**
+2 -9
View File
@@ -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 });
}
/**
+108
View File
@@ -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);
});
+13 -6
View File
@@ -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';
@@ -97,12 +98,23 @@ export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerces a value to a finite number or returns the fallback.
*
* Absent, blank and boolean values return the fallback as well. The source
* list serializes an unknown number (NaN) as JSON null, and `Number(null)`
* is 0, so without this an unknown pose field arrived as zero and was
* clamped to the field's floor instead of taking its default.
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
function safeNumber(value, fallback = NaN) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
@@ -298,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 });
}
/**
+13 -2
View File
@@ -3,6 +3,12 @@ import {
FRAME_ENDPOINT,
MEDIA_ENDPOINT,
} from './sourcePolicy.js';
const FRAME_SOURCE_KINDS = new Set([
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
]);
function safeNumber(value, fallback = NaN) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
@@ -51,7 +57,7 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
/** Read a registered frame and its provenance, including fallback frames. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
@@ -62,7 +68,12 @@ export function createCctvSource({
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
const sourceKind = response.headers.get('x-cctv-source');
return {
contentType: type.split(';')[0].trim().toLowerCase(),
bytes,
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
};
},
};
}
+23
View File
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
for (const kind of [
'upstream-image',
'hls-pull',
'streetview',
'synthetic',
'other',
null,
]) {
const source = createCctvSource({
fetchImpl: async () =>
new Response(new Uint8Array([1]), {
headers: {
'Content-Type': 'image/png',
...(kind ? { 'X-CCTV-Source': kind } : {}),
},
}),
});
const frame = await source.getFrame(camera);
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
}
});
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeEarthquakeSnapshot } from './records.js';
test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => {
const geojson = {
type: 'FeatureCollection',
features: [
{
id: 'us7000m123',
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749, 10.5],
},
properties: {
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
},
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(Array.isArray(rows), true);
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
stableId: 'us7000m123',
usgsId: 'us7000m123',
lon: -122.4194,
lat: 37.7749,
depthKm: 10.5,
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
});
});
test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => {
const geojson = {
features: [
{
id: null,
geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] },
properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 },
},
{
id: '',
geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] },
properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'event-1');
assert.equal(rows[0].usgsId, null);
assert.equal(rows[1].stableId, 'event-2');
assert.equal(rows[1].usgsId, '');
});
test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => {
const geojson = {
features: [
{
id: 'ev-sub',
geometry: { type: 'Point', coordinates: [10, 20, 5] },
properties: { mag: 2.49, place: 'Minor tremor' },
},
{
id: 'ev-null-mag',
geometry: { type: 'Point', coordinates: [11, 21, 5] },
properties: { mag: null, place: 'Uncalculated' },
},
{
id: 'ev-threshold',
geometry: { type: 'Point', coordinates: [12, 22, 5] },
properties: { mag: 2.5, place: 'Threshold event' },
},
{
id: 'ev-strong',
geometry: { type: 'Point', coordinates: [13, 23, 5] },
properties: { mag: 6.8, place: 'Major event' },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'ev-threshold');
assert.equal(rows[0].mag, 2.5);
assert.equal(rows[1].stableId, 'ev-strong');
assert.equal(rows[1].mag, 6.8);
});
test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => {
const geojson = {
features: [
{
id: 'ev-sparse',
geometry: { coordinates: [-100, 40] }, // depth omitted
properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 1);
assert.equal(rows[0].depthKm, null);
assert.equal(rows[0].place, null);
assert.equal(rows[0].time, null);
});
test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => {
assert.equal(normalizeEarthquakeSnapshot(null), null);
assert.equal(normalizeEarthquakeSnapshot(undefined), null);
assert.equal(normalizeEarthquakeSnapshot({}), null);
assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null);
assert.equal(normalizeEarthquakeSnapshot({ features: null }), null);
});
test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => {
const baseFeature = {
id: 'test',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
};
// Non-Point geometry
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: {
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
},
},
],
}),
null,
);
// Missing or short coordinates
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{ ...baseFeature, geometry: { type: 'Point', coordinates: [0] } },
],
}),
null,
);
// Longitude out of range [-180, 180]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [180.1, 0, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [-180.1, 0, 0] },
},
],
}),
null,
);
// Latitude out of range [-90, 90]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 90.1, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, -90.1, 0] },
},
],
}),
null,
);
// Non-finite depth
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 0, NaN] },
},
],
}),
null,
);
// Magnitude > 10
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: { mag: 10.1 } }],
}),
null,
);
// Non-object or array properties
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: 'invalid' }],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: [] }],
}),
null,
);
});
test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => {
const geojson = {
features: [
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
},
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [1, 1, 1] },
properties: { mag: 4.0 },
},
],
};
assert.equal(normalizeEarthquakeSnapshot(geojson), null);
});
+4 -4
View File
@@ -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). */
+11 -2
View File
@@ -114,7 +114,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO 24-bit transponder address.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -164,6 +164,15 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Climb/descent rate, the one kinematic field `mapAnalystRecord` already
// published (as `verticalRateMps`) but this descriptor did not, so a
// consumer reading the tracked seam had to reach back into `_flightData`
// for it. Reported, never derived: `stickyNumber` in records.js keeps the
// last value across a blank poll, and an absent rate stays null rather
// than becoming a level-flight 0 the aircraft never transmitted.
verticalRateMps: Number.isFinite(info?.verticalRate)
? info.verticalRate
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -856,7 +865,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+1 -6
View File
@@ -13,9 +13,4 @@ export const MISSING_POLL_LIMIT = 3;
export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
export { approxDistanceKm } from '../../data/approxDistance.js';
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
approxDistanceKm,
GROUND_FLOOR_CLAMP_RADIUS_KM,
} from './recordPolicy.js';
test('approximate distance takes the short way across the antimeridian', () => {
// 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km.
for (const [lon1, lon2] of [
[179.9, -179.9],
[-179.9, 179.9],
]) {
const km = approxDistanceKm(-16.8, lon1, -16.8, lon2);
assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`);
assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM);
}
});
+5 -4
View File
@@ -134,15 +134,16 @@ export function createIngestion({
let placesError = null;
if (layerState.googleSearchRequested) {
layerState.googleSearchRequested = false;
// A view across 180° has west > east; its width runs through 180°.
const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0);
const latitude = (box.south + box.north) / 2;
const longitude = (box.west + box.east) / 2;
let longitude = box.west + lonSpan / 2;
if (longitude > 180) longitude -= 360;
const radiusM = Math.min(
50000,
Math.max(
1000,
Math.round(
Math.max(box.north - box.south, box.east - box.west) * 55_000,
),
Math.round(Math.max(box.north - box.south, lonSpan) * 55_000),
),
);
try {
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createIngestion } from './ingestion.js';
// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view
// is 0.4° wide and 0.2° tall; the last one does not cross 180°.
for (const [west, east, centre] of [
[179.7, -179.9, 179.9],
[179.9, -179.7, -179.9],
[179.5, 179.9, 179.7],
])
test(`a nearby search is centred on the view at ${centre}`, async () => {
const box = { south: -17, west, north: -16.8, east };
const searches = [];
const state = {
enabled: true,
viewer: {},
records: [],
googleSearchRequested: true,
};
const ingestion = createIngestion({
state,
source: {
getMappedSites: async () => ({ records: [], wide: true }),
searchNearby: async (query) => {
searches.push(query);
return { places: [] };
},
},
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
parts: {
viewport: {
loadArea: () => ({ box, coverage: {} }),
clearUnavailableRetry() {},
scheduleUnavailableRetry() {},
},
model: { installationWithinViewport: () => true },
rendering: { renderRecords() {}, warmInstallationFloors() {} },
},
});
await ingestion.loadInstallations();
assert.equal(searches.length, 1);
const { latitude, longitude, radiusM } = searches[0];
assert.ok(Math.abs(latitude + 16.9) < 1e-9);
assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`);
// The width, not the height, sets the radius.
assert.equal(radiusM, 22000);
});
+8 -9
View File
@@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) {
// Number(null), Number('') and Number(false) are 0: absent is not zero.
if (value == null || typeof value === 'boolean') return null;
if (typeof value === 'string' && value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
@@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) {
const pad = launch.pad || {};
const location = pad.location || {};
const coordinates = location.coordinates || '';
const [coordinateLon, coordinateLat] = String(coordinates)
.split(',')
.map(Number);
const lat = Number.isFinite(Number(pad.latitude))
? Number(pad.latitude)
: coordinateLat;
const lon = Number.isFinite(Number(pad.longitude))
? Number(pad.longitude)
: coordinateLon;
const [coordinateLon, coordinateLat] = String(coordinates).split(',');
const lat =
finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
const lon =
finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
const payloads = normalizePayloadFlights(launch);
return {
id: String(
+9 -2
View File
@@ -88,7 +88,7 @@ export function createQueries({
* Build a public descriptor for one aircraft using its best current position
* (dead-reckoned when history exists, billboard position otherwise).
* @param {string} icao24 - ICAO hex identifier of the aircraft.
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Descriptor with a cloned position, or null if the aircraft is unknown.
*/
@@ -131,6 +131,13 @@ export function createQueries({
onGround: info?.onGround === true,
velocityMps: displayed.speedMps,
track: displayed.trackDeg,
// Same field, same name, same null-when-absent rule as the civil
// descriptor — the two layers store the record under different keys
// (`verticalRateMps` here, `verticalRate` there), so only the mapping
// makes `resolveTrackedAircraftInfo` hand back one shape for both.
verticalRateMps: Number.isFinite(info?.verticalRateMps)
? info.verticalRateMps
: null,
stale: Boolean(
flightState.records.missingPolls.get(icao24) ||
flightState.feed._backoff,
@@ -777,7 +784,7 @@ export function createQueries({
/**
* Describe the currently tracked aircraft at its dead-reckoned position.
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
* Tracked aircraft info, or null when nothing is tracked.
*/
getTrackedInfo() {
+9 -4
View File
@@ -39,8 +39,6 @@ export class MilitaryFlightRecords {
? prevMeta.altitudeFt * 0.3048
: 0
: 3048);
const track = aircraft.courseDeg || 0;
const speedMps = aircraft.speedMps ?? 0;
const verticalRateMps = aircraft.verticalRateMps;
const callsign = aircraft.callsign;
const type = aircraft.typeCode;
@@ -148,8 +146,15 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null),
track: stickyNumber(track, prevMeta?.track, null),
// A poll that omits track/speed holds the last values, like the civil
// records; 0 only until one is known. A grounded row without a speed
// reads 0, so a parked plane never coasts at its landing speed.
speedMps: stickyNumber(
aircraft.speedMps,
onGround ? 0 : prevMeta?.speedMps,
0,
),
track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0),
// Analyst seam (additive): sticky like the other kinematics.
verticalRateMps: stickyNumber(
verticalRateMps,
+36
View File
@@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', ()
);
assert.equal(next.meta.emergency, null);
});
test('military records hold the last track and speed when a poll omits them', () => {
const records = store();
records.receive(observation({ courseDeg: 270, speedMps: 200 }), context());
const missing = records.receive(
observation({ courseDeg: null, speedMps: null }),
context(),
);
assert.equal(missing.meta.track, 270);
assert.equal(missing.meta.speedMps, 200);
const north = records.receive(
observation({ courseDeg: 0, speedMps: 0 }),
context(),
);
assert.equal(north.meta.track, 0);
assert.equal(north.meta.speedMps, 0);
const unseen = store().receive(
observation({ courseDeg: undefined, speedMps: undefined }),
context(),
);
assert.equal(unseen.meta.track, 0);
assert.equal(unseen.meta.speedMps, 0);
const landing = store();
landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context());
const parked = landing.receive(
observation({
onGround: true,
baroAltitudeM: null,
courseDeg: null,
speedMps: null,
}),
context(),
);
assert.equal(parked.meta.speedMps, 0);
assert.equal(parked.meta.track, 90);
});
+18 -9
View File
@@ -11,20 +11,26 @@ export function containmentAccent(containedPct) {
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
/** Shoelace area along short longitude edges (degree², relative sizes only). */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
let [x1, y1] = ring[0];
for (let i = 1; i < ring.length; i++) {
let delta = ring[i][0] - ring[i - 1][0];
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
const x2 = x1 + delta;
const y2 = ring[i][1];
sum += x1 * y2 - x2 * y1;
x1 = x2;
y1 = y2;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* Anchor point for the incident card: the vertex mean of the largest polygon's
* outer ring, with circular longitude. Callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
@@ -41,14 +47,17 @@ export function perimeterAnchorDegrees(polygons) {
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let lat = 0;
const count = best.length - 1;
const toRad = Math.PI / 180;
for (let i = 0; i < count; i++) {
lon += best[i][0];
sinLon += Math.sin(best[i][0] * toRad);
cosLon += Math.cos(best[i][0] * toRad);
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
return { lon: Math.atan2(sinLon, cosLon) / toRad, lat: lat / count };
}
/**
+68 -1
View File
@@ -16,12 +16,79 @@ const tiny = [
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
test('the card anchors at the vertex mean of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('incident cards stay at the dateline for closed, reversed and rotated outlines', () => {
const vertices = [
[179, 10],
[-179, 10],
[-179, 12],
[179, 12],
];
for (let start = 0; start < vertices.length; start++) {
const rotated = [...vertices.slice(start), ...vertices.slice(0, start)];
for (const points of [rotated, [...rotated].reverse()]) {
const anchor = perimeterAnchorDegrees([[points.concat([points[0]])]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
});
test('incident card longitude treats vertices at either sign of 180 as the same meridian', () => {
for (const boundary of [-180, 180]) {
const ring = [
[179, 10],
[boundary, 10],
[-179, 10],
[-179, 12],
[179, 12],
[179, 10],
];
const anchor = perimeterAnchorDegrees([[ring]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 10.8) < 1e-9);
}
});
test('multipart cards compare polygon areas along short dateline edges', () => {
const ordinary = [
[10, 20],
[11, 20],
[11, 21],
[10, 21],
[10, 20],
];
for (const width of [0.2, 2]) {
const crossing = [
[180 - width / 2, 10],
[-180 + width / 2, 10],
[-180 + width / 2, 10 + width],
[180 - width / 2, 10 + width],
[180 - width / 2, 10],
];
for (const ring of [crossing, [...crossing].reverse()]) {
for (const polygons of [
[[ring], [ordinary]],
[[ordinary], [ring]],
]) {
const anchor = perimeterAnchorDegrees(polygons);
if (width < 1) {
assert.ok(Math.abs(anchor.lon - 10.5) < 1e-9);
assert.ok(Math.abs(anchor.lat - 20.5) < 1e-9);
} else {
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
}
}
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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);
+80
View File
@@ -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);
});
+2 -2
View File
@@ -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),
@@ -97,6 +97,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
if (!current())
throw new Error('Street View details lookup was released');
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
@@ -236,6 +238,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
cancelShown?.();
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
@@ -403,3 +403,69 @@ test('a remount looks up again a panorama whose lookup hung', async () => {
await settle();
assert.equal(poses.length, count);
});
test('superseded panorama waits cannot steal the current close cleanup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS } = await import('./policy.js');
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
panorama.setPano = (id) => {
panorama.pano = id;
};
const first = viewer.open('pano-a');
await flush();
const second = viewer.open('pano-b');
await flush();
panorama.fire('position_changed'); // new panorama has not reached OK
await flush();
viewer.close();
const remaining = loader.listenerCount();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await Promise.allSettled([first, second]);
assert.equal(
remaining,
0,
'close disposes every pending open without waiting 20 seconds',
);
assert.equal(panorama.visible, false);
});
test('unmount during details-service acquisition makes no later billed lookup', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const { createGoogleProvider } = await import('./index.js');
let release;
const acquiring = new Promise((resolve) => {
release = resolve;
});
const importLibrary = loader.importLibrary.bind(loader);
let imports = 0;
loader.importLibrary = async (name) => {
if (++imports > 1) await acquiring;
return importLibrary(name);
};
const provider = createGoogleProvider({
getApiKey: () => 'fixture-key',
loader,
});
const instance = provider.create({
notify() {},
actions: { reportError() {} },
});
const viewer = instance.viewer;
await viewer.mount(fakeHost());
await viewer.open('pano-a');
viewer.unmount();
release();
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(
fake.lookups.length,
0,
'teardown invalidates the request before calling getPanorama',
);
instance.destroy();
});
+3 -16
View File
@@ -3,6 +3,7 @@ import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import {
viewportBias,
placesNearViewRecovery,
@@ -648,18 +649,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15;
*/
const PLACE_FALLBACK_HALF_SPAN_KM = 20;
/** Great-circle distance in km (small enough here that the spherical model is fine). */
function greatCircleKm(lat1, lng1, lat2, lng2) {
const dLat = Cesium.Math.toRadians(lat2 - lat1);
const dLng = Cesium.Math.toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(Cesium.Math.toRadians(lat1)) *
Math.cos(Cesium.Math.toRadians(lat2)) *
Math.sin(dLng / 2) ** 2;
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests.
*
@@ -694,10 +683,8 @@ export function placeFramingViewport(
return viewport;
const offsetKm = greatCircleKm(
anchorLat,
anchorLng,
metrics.centerLat,
metrics.centerLng,
{ lat: anchorLat, lon: anchorLng },
{ lat: metrics.centerLat, lon: metrics.centerLng },
);
if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport;
+191 -8
View File
@@ -9,6 +9,18 @@ import { acquireImageryComparison } from './imageryComparison.js';
*/
const AUTOMATIC = Symbol('automatic switch');
/** Background retries of a fallback in use back off from 30 s to 10 min. */
const FALLBACK_RETRY_MS = 30_000;
const FALLBACK_RETRY_MAX_MS = 600_000;
/** A retry attempt still unsettled after a minute counts as failed. */
const FALLBACK_ATTEMPT_MS = 60_000;
const resolved = (provider, effectiveStackId) => ({
provider,
effectiveStackId,
fallbackMessage: null,
});
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
export class MapSourceController {
constructor(
@@ -46,8 +58,18 @@ export class MapSourceController {
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._activeResolution = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._terrainFallback = null;
this._retryTimer = null;
this._retrying = false;
this._retryRequested = false;
this._retryDelay = FALLBACK_RETRY_MS;
/** Expire callbacks of retry attempts in flight; destroy() runs them. */
this._retryDeadlines = new Set();
/** A fallback terrain a retry replaced while it stayed installed. */
this._replacedTerrain = null;
this._subscribers = new Set();
this._destroyed = false;
}
@@ -285,21 +307,171 @@ export class MapSourceController {
this._showTileset(null);
this.viewer.scene.globe.show = true;
this._activeId = resolution.effectiveStackId;
this._activeResolution = resolution;
if (source?.terrain && source.terrain.id !== this._terrainMode) {
const terrain = source.terrain;
const result = await this._cached(
this._terrainProviders,
terrain.id,
() => terrain.create({ signal: this._abort.signal }),
const pending = this._cached(this._terrainProviders, terrain.id, () =>
terrain.create({ signal: this._abort.signal }),
);
let result = await pending;
if (gen !== this._switchGen) return;
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
// A background retry may have replaced a fallback while this waited.
const latest = this._terrainProviders.get(terrain.id);
if (latest !== pending) result = await latest;
if (gen !== this._switchGen) return;
this._applyTerrain(result);
this._terrainMode = terrain.id;
}
if (resolution.fallbackFor || this._terrainFallback) this._scheduleRetry();
return resolution;
}
_applyTerrain(result) {
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
this._terrainFallback = result.fallback ? result : null;
if (this._replacedTerrain && this._replacedTerrain !== result) {
this._dispose(this._replacedTerrain.provider);
this._replacedTerrain = null;
}
}
/**
* One timer retries the real provider behind whichever fallback is on
* screen: Esri's OSM stand-in, or flat terrain on a shown globe. Fallbacks
* stay cached and shown meanwhile, so no switch waits on a retry; a
* fallback out of use is left alone until an activation shows it again.
* One retry runs at a time; a request made meanwhile re-arms once it ends.
*/
_scheduleRetry() {
if (this._retryTimer || this._destroyed) return;
if (this._retrying) {
this._retryRequested = true;
return;
}
this._retryTimer = setTimeout(() => {
this._retryTimer = null;
void this._retryFallbacks();
}, this._retryDelay);
}
async _retryFallbacks() {
const resolution = this._activeResolution;
const flat = this.viewer.scene.globe.show ? this._terrainFallback : null;
const id = resolution?.fallbackFor;
let terrain = flat && this._sources.get(this._activeId)?.terrain;
if (terrain?.id !== this._terrainMode) terrain = null;
if (!id && !terrain) return;
this._retrying = true;
this._retryRequested = false;
// An attempt that outlives its deadline counts as a failure. Its signal is
// aborted, but the Esri and keyless factories ignore it and Cesium's
// requests have no timeout, so a late result is released, never used.
const attempt = async (create, release) => {
const expiry = new AbortController();
let expire;
const deadline = new Promise((resolve) => {
expire = () => {
clearTimeout(timer);
resolve(expiry);
};
});
const timer = setTimeout(expire, FALLBACK_ATTEMPT_MS);
this._retryDeadlines.add(expire);
const pending = Promise.resolve().then(() =>
create?.({
signal: AbortSignal.any([this._abort.signal, expiry.signal]),
}),
);
try {
const result = await Promise.race([pending, deadline]);
if (result !== expiry) return result;
expiry.abort();
pending.then(release, () => {});
return null;
} catch {
return null; // Still unavailable: keep the fallback.
} finally {
clearTimeout(timer);
this._retryDeadlines.delete(expire);
}
};
// Each recovers as soon as it loads, never waiting on the other.
const retryImagery = async () => {
const cached = this._imageryProviders.get(id);
const provider = await attempt(this._sources.get(id).imagery, (late) =>
this._dispose(late),
);
// Obsolete: destroyed, or the cache moved on while this was in flight.
if (this._destroyed || this._imageryProviders.get(id) !== cached) {
this._dispose(provider);
return true;
}
if (!provider) return false;
this._imageryProviders.set(id, Promise.resolve(resolved(provider, id)));
// Still showing the fallback once any switch settles: use the real one.
const recover = () => {
if (this._activeResolution === resolution)
void this.setStack(id, { [AUTOMATIC]: true });
};
if (!this._isSwitching) recover();
else {
const stop = this.subscribe(() => {
stop();
recover();
});
}
return true;
};
const retryTerrain = async () => {
const cached = this._terrainProviders.get(terrain.id);
const real = await attempt(terrain.create, (late) =>
this._dispose(late?.provider),
);
if (
this._destroyed ||
this._terrainProviders.get(terrain.id) !== cached ||
!real ||
real.fallback
) {
this._dispose(real?.provider);
return Boolean(real && !real.fallback);
}
this._terrainProviders.set(terrain.id, Promise.resolve(real));
if (this._terrainFallback === flat && this.viewer.scene.globe.show) {
this._applyTerrain(real);
this._dispose(flat.provider);
this._requestRender('map-stack');
} else {
this._terrainMode = null; // The next globe activation installs it.
// Release the replaced fallback once nothing shows it.
if (this._terrainFallback === flat) this._replacedTerrain = flat;
else this._dispose(flat.provider);
}
return true;
};
const settled = (retry) =>
retry().catch((error) => {
console.warn('[MapSourceController] fallback recovery failed:', error);
return false;
});
let failed = false;
try {
const results = await Promise.all([
id && settled(retryImagery),
terrain && settled(retryTerrain),
]);
failed = results.includes(false);
} finally {
this._retrying = false;
}
if (this._destroyed) return;
this._retryDelay = failed
? Math.min(this._retryDelay * 2, FALLBACK_RETRY_MAX_MS)
: FALLBACK_RETRY_MS;
if (failed || this._retryRequested) this._scheduleRetry();
}
_cached(cache, id, create) {
if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve()
@@ -323,7 +495,7 @@ export class MapSourceController {
try {
const provider = await source.imagery({ signal: this._abort.signal });
if (this._destroyed) this._dispose(provider);
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
return resolved(provider, stack.id);
} catch (error) {
const fallback = source.constructionFallback;
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
@@ -333,7 +505,11 @@ export class MapSourceController {
this.getStack(fallback.id),
next,
);
return { ...resolution, fallbackMessage: fallback.message };
return {
...resolution,
fallbackMessage: fallback.message,
fallbackFor: stack.id,
};
}
});
}
@@ -388,6 +564,7 @@ export class MapSourceController {
this.viewer.imageryLayers.remove(this._imageryLayer, true);
this._imageryLayer = null;
this._activeImageryProvider = null;
this._activeResolution = null;
}
_dispose(value) {
if (!value || this._disposed.has(value)) return;
@@ -401,6 +578,12 @@ export class MapSourceController {
this._subscribers.clear();
this._switchGen++;
this._abort.abort();
clearTimeout(this._retryTimer);
for (const expire of this._retryDeadlines) expire();
this._retryDeadlines.clear();
this._retrying = false;
this._dispose(this._replacedTerrain?.provider);
this._replacedTerrain = null;
this._isSwitching = false;
this._removeImageryLayer();
this._credits.destroy();
+545 -2
View File
@@ -70,7 +70,7 @@ const settle = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
function publicFixture() {
function publicFixture(options) {
const tileset = { show: true };
const registry = createDefaultMapSources({ googleTileset: tileset });
const providers = new Map();
@@ -84,7 +84,7 @@ function publicFixture() {
create: async () => ({ provider: { id: 'terrain' } }),
};
}
return { ...fixture(registry), registry, providers, tileset };
return { ...fixture(registry, options), registry, providers, tileset };
}
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
@@ -839,3 +839,546 @@ test('each switch generation reports its origin: an outside setStack is manual,
]);
recovering.controller.destroy();
});
function failingEsri(env) {
const esri = { failing: true, attempts: 0, next: null };
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
esri.attempts++;
if (esri.next) await esri.next;
if (esri.failing) throw new Error('Esri unreachable');
return env.providers.get('esri-imagery');
};
return esri;
}
const deferred = () => {
let resolve;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
};
test('a failed Esri construction shows OSM at once, backs off while retries fail, and recovers in place', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
const osmLayer = env.imagery.at(-1);
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'one background retry');
assert.equal(env.imagery.at(-1), osmLayer, 'a failed retry keeps the layer');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(esri.attempts, 2, 'a reselection does not retry at once');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the second retry waits twice as long');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 3);
esri.failing = false;
t.mock.timers.tick(120_000);
await settle();
assert.equal(esri.attempts, 4);
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.controller.getSwitchOrigin(), 'automatic');
assert.equal(env.controller.getState().lastError, null);
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
t.mock.timers.tick(600_000);
await settle();
assert.equal(esri.attempts, 4, 'the real provider ends the retries');
env.controller.destroy();
});
test('retries of a fallback that keeps failing back off to a 10 minute cap', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
for (const delay of [30, 60, 120, 240, 480, 600, 600]) {
t.mock.timers.tick(delay * 1000);
await settle();
}
assert.equal(esri.attempts, 8);
env.controller.destroy();
});
test('Esri is retried only while its fallback is shown', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
await env.controller.setStack('osm');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 1, 'no retry while OSM was chosen');
await env.controller.setStack('esri-imagery');
assert.equal(esri.attempts, 1, 'the cached fallback shows at once');
assert.equal(env.controller.getActiveId(), 'osm');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2);
assert.equal(env.controller.getActiveId(), 'esri-imagery');
env.controller.destroy();
});
test('an Esri recovery that lands mid-switch applies once the switch settles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const retry = deferred();
esri.next = retry.promise;
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
// The fallback activates OSM's terrain; make that switch slow.
const slow = deferred();
env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
).terrain = { id: 'slow', create: () => slow.promise };
const switching = env.controller.setStack('esri-imagery');
retry.resolve();
await settle();
assert.equal(env.controller.getActiveId(), 'osm', 'the switch is still open');
slow.resolve({ provider: { id: 'slow' } });
await switching;
await settle();
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
env.controller.destroy();
});
function terrainFixture(t, create) {
t.mock.timers.enable({ apis: ['setTimeout'] });
let renders = 0;
const env = publicFixture({ requestRender: () => renders++ });
const installed = [];
Object.defineProperty(env.viewer, 'terrainProvider', {
get: () => installed.at(-1),
set: (provider) => installed.push(provider),
});
const terrain = { id: 'keyless', calls: 0 };
terrain.create = () => create(++terrain.calls);
for (const source of env.registry.sources)
if (source.imagery) source.terrain = terrain;
return { env, installed, terrain, renders: () => renders };
}
const flatTerrain = (n) => ({ provider: { id: `flat-${n}` }, fallback: true });
test('flat fallback terrain stays installed while retries run, and real terrain replaces it live', async (t) => {
const real = { id: 'real' };
let working = false;
const { env, installed, terrain, renders } = terrainFixture(t, async (n) =>
working ? { provider: real } : flatTerrain(n),
);
await env.controller.setStack('esri-imagery');
const [flat] = installed;
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'one background retry');
await env.controller.setStack('osm');
assert.deepEqual(installed, [flat], 'failed retries and switches keep it');
working = true;
const before = renders();
t.mock.timers.tick(60_000);
await settle();
assert.equal(terrain.calls, 3);
assert.deepEqual(installed, [flat, real]);
assert.equal(renders(), before + 1, 'the live swap requests a render');
t.mock.timers.tick(600_000);
await settle();
assert.equal(terrain.calls, 3, 'the real terrain ends the retries');
env.controller.destroy();
});
test('terrain is not retried or installed while a 3D tileset hides the globe', async (t) => {
const real = { id: 'real' };
let working = false;
const { env, installed, terrain } = terrainFixture(t, async (n) =>
working ? { provider: real } : flatTerrain(n),
);
await env.controller.setStack('esri-imagery');
await env.controller.setStack('photoreal');
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 1, 'no retry behind a hidden globe');
await env.controller.setStack('esri-imagery');
const hidden = deferred();
terrain.create = async () => {
terrain.calls++;
await hidden.promise;
return { provider: real };
};
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'the globe activation re-arms the retry');
await env.controller.setStack('photoreal');
hidden.resolve();
await settle();
assert.equal(installed.length, 1, 'nothing is installed behind a tileset');
await env.controller.setStack('osm');
assert.equal(installed.at(-1), real, 'the next globe activation installs it');
assert.equal(terrain.calls, 2);
env.controller.destroy();
});
test('a switch that read the flat fallback installs the terrain a retry just cached', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
// Sweep the microtask distance between the retry landing and the switch.
for (let hops = 0; hops < 12; hops++) {
const real = { id: 'real' },
gate = deferred();
const keyless = {
id: 'keyless',
create: async () => {
if (!keyless.retried) return flatTerrain(1);
await gate.promise;
return { provider: real };
},
};
const other = { id: 'other', create: async () => ({ provider: {} }) };
const source = (id, terrain) => ({
descriptor: descriptor(id),
imagery: async () => ({ id }),
terrain,
});
const env = fixture({
defaultId: 'a',
sources: [source('a', keyless), source('c', other)],
});
const installed = [];
Object.defineProperty(env.viewer, 'terrainProvider', {
get: () => installed.at(-1),
set: (provider) => installed.push(provider),
});
await env.controller.setStack('a');
keyless.retried = true;
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('c');
gate.resolve();
for (let i = 0; i < hops; i++) await null;
await env.controller.setStack('a');
await settle();
assert.equal(installed.at(-1), real, `hops ${hops}`);
env.controller.destroy();
}
});
test('destroy cancels a pending retry and releases one in flight', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
env.controller.destroy();
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 1, 'a pending retry is cancelled');
const late = publicFixture();
const lateEsri = failingEsri(late);
await late.controller.setStack('esri-imagery');
const retry = deferred();
lateEsri.next = retry.promise;
lateEsri.failing = false;
let destroyed = 0;
late.providers.get('esri-imagery').destroy = () => destroyed++;
t.mock.timers.tick(30_000);
await settle();
late.controller.destroy();
retry.resolve();
await settle();
assert.equal(destroyed, 1, 'a late provider is released');
assert.equal(late.imagery.length, 0);
t.mock.timers.tick(600_000);
await settle();
assert.equal(lateEsri.attempts, 2);
});
test('retries stay single-flight when an activation re-shows the fallback mid-retry', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const created = [],
pending = [];
let attempts = 0;
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
if (++attempts === 1) throw new Error('Esri unreachable');
const gate = deferred();
pending.push(gate);
await gate.promise;
const provider = { id: `esri-${attempts}`, errorEvent: event() };
provider.destroy = () => (provider.destroyed = true);
created.push(provider);
return provider;
};
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(attempts, 2, 'retry #1 is in flight');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm', 'the fallback is shown');
t.mock.timers.tick(30_000);
await settle();
assert.equal(attempts, 2, 'no second retry while one is in flight');
for (const gate of pending.reverse()) {
gate.resolve();
await settle();
}
const shown = env.imagery.at(-1).provider;
assert.equal(env.controller.getActiveId(), 'esri-imagery');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.imagery.at(-1).provider, shown, 'the cached one is shown');
env.controller.destroy();
await settle();
assert.ok(created.length > 0);
for (const provider of created)
assert.equal(provider.destroyed, true, `${provider.id} is disposed`);
});
test('a stalled terrain retry does not hold back recovered imagery', async (t) => {
const stalled = deferred();
const { env, terrain } = terrainFixture(t, async (n) => {
if (n > 1) await stalled.promise;
return flatTerrain(n);
});
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(terrain.calls, 2, 'the terrain retry is in flight');
assert.equal(env.controller.getActiveId(), 'esri-imagery');
assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery'));
stalled.resolve();
await settle();
env.controller.destroy();
});
test('a stalled imagery retry does not hold back recovered terrain', async (t) => {
const real = { id: 'real' };
const { env, installed } = terrainFixture(t, async (n) =>
n > 1 ? { provider: real } : flatTerrain(n),
);
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const stalled = deferred();
esri.next = stalled.promise;
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the imagery retry is in flight');
assert.equal(installed.at(-1), real, 'real terrain is installed');
stalled.resolve();
await settle();
env.controller.destroy();
});
test('a retry attempt that never settles is abandoned at its deadline and released if it lands late', async (t) => {
const hung = deferred();
const late = { id: 'late', destroy: () => (late.destroyed = true) };
const { env, installed, terrain } = terrainFixture(t, async (n) => {
if (n === 2) await hung.promise;
return n === 2 ? { provider: late } : flatTerrain(n);
});
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'imagery failed');
assert.equal(terrain.calls, 2, 'terrain hangs');
t.mock.timers.tick(60_000); // The hung attempt's deadline.
await settle();
t.mock.timers.tick(60_000); // The doubled backoff.
await settle();
assert.equal(esri.attempts, 3, 'imagery is retried again');
assert.equal(terrain.calls, 3, 'terrain is retried again');
hung.resolve();
await settle();
assert.equal(late.destroyed, true, 'the late provider is released');
assert.ok(!installed.includes(late), 'and never installed');
env.controller.destroy();
});
test('a failed retry after a mid-retry re-show re-arms once, after the doubled delay', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
const gate = deferred();
esri.next = gate.promise;
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
gate.resolve();
await settle();
assert.equal(esri.attempts, 2);
t.mock.timers.tick(59_999);
await settle();
assert.equal(esri.attempts, 2, 'not before the doubled delay');
t.mock.timers.tick(1);
await settle();
assert.equal(esri.attempts, 3, 'one retry after it');
t.mock.timers.tick(119_999);
await settle();
assert.equal(esri.attempts, 3, 'no second timer was armed');
env.controller.destroy();
});
test('destroy releases recovered imagery and terrain that lands afterwards', async (t) => {
const stalled = deferred();
const real = { id: 'real', destroy: () => (real.destroyed = true) };
const { env, installed } = terrainFixture(t, async (n) => {
if (n === 1) return flatTerrain(n);
await stalled.promise;
return { provider: real };
});
const esri = failingEsri(env);
const imagery = env.providers.get('esri-imagery');
imagery.destroy = () => (imagery.destroyed = true);
await env.controller.setStack('esri-imagery');
esri.failing = false;
t.mock.timers.tick(30_000);
await settle();
assert.equal(env.imagery.at(-1).provider, imagery, 'imagery has landed');
env.controller.destroy();
stalled.resolve();
await settle();
assert.equal(imagery.destroyed, true);
assert.equal(real.destroyed, true);
assert.ok(!installed.includes(real));
});
function liveTimers(t) {
const live = new Set();
const { setTimeout: set, clearTimeout: clear } = globalThis;
globalThis.setTimeout = (fn, ms, ...args) => {
const id = set(
(...values) => {
live.delete(id);
fn(...values);
},
ms,
...args,
);
live.add(id);
return id;
};
globalThis.clearTimeout = (id) => {
live.delete(id);
clear(id);
};
t.after(() =>
Object.assign(globalThis, { setTimeout: set, clearTimeout: clear }),
);
return live;
}
test('destroy during a hung retry attempt clears its deadline at once', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const live = liveTimers(t);
const env = publicFixture();
const esri = failingEsri(env);
await env.controller.setStack('esri-imagery');
esri.next = new Promise(() => {});
t.mock.timers.tick(30_000);
await settle();
assert.equal(esri.attempts, 2, 'the attempt hangs');
assert.equal(live.size, 1, 'only its deadline is armed');
env.controller.destroy();
assert.equal(live.size, 0, 'destroy clears the deadline');
assert.equal(env.controller._retrying, false);
await settle();
assert.equal(live.size, 0, 'and nothing re-arms');
});
function destroyableFlat(n) {
const provider = { id: `flat-${n}` };
provider.destroy = () => (provider.destroyed = true);
return { provider, fallback: true };
}
test('terrain recovered on screen releases the fallback it replaced', async (t) => {
const real = { id: 'real' };
let flat;
const { env, installed } = terrainFixture(t, async (n) =>
n === 1 ? (flat = destroyableFlat(n)) : { provider: real },
);
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
assert.equal(installed.at(-1), real);
assert.equal(flat.provider.destroyed, true);
env.controller.destroy();
});
test('terrain recovered behind a tileset releases the fallback once a globe replaces it', async (t) => {
const real = { id: 'real' };
const gate = deferred();
let flat;
const { env, installed } = terrainFixture(t, async (n) => {
if (n === 1) return (flat = destroyableFlat(n));
await gate.promise;
return { provider: real };
});
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
await env.controller.setStack('photoreal');
gate.resolve();
await settle();
assert.equal(installed.at(-1), flat.provider);
assert.ok(!flat.provider.destroyed, 'still installed behind the tileset');
await env.controller.setStack('osm');
assert.equal(installed.at(-1), real);
assert.equal(flat.provider.destroyed, true);
env.controller.destroy();
});
test('a hung imagery attempt that lands after its deadline is released, never cached or shown', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const env = publicFixture();
const hung = deferred();
const late = { id: 'late', errorEvent: event() };
const good = { id: 'good', errorEvent: event() };
for (const provider of [late, good])
provider.destroy = () => (provider.destroyed = true);
let attempts = 0;
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
if (++attempts === 1) throw new Error('Esri unreachable');
if (attempts === 2) {
await hung.promise;
return late;
}
return good;
};
await env.controller.setStack('esri-imagery');
t.mock.timers.tick(30_000);
await settle();
t.mock.timers.tick(60_000); // The deadline.
await settle();
hung.resolve();
await settle();
assert.equal(late.destroyed, true, 'the late provider is released');
assert.equal(env.controller.getActiveId(), 'osm', 'and not shown');
t.mock.timers.tick(60_000);
await settle();
assert.equal(attempts, 3);
assert.equal(env.imagery.at(-1).provider, good, 'the next retry installs');
await env.controller.setStack('osm');
await env.controller.setStack('esri-imagery');
assert.equal(env.imagery.at(-1).provider, good, 'only its own is cached');
env.controller.destroy();
await settle();
assert.equal(good.destroyed, true);
});
+10
View File
@@ -317,3 +317,13 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn
assert.equal(calls[1][1].asynchronouslyLoadImagery, true);
assert.equal(calls[1][1].enableCollision, true);
});
test('unreachable keyless terrain falls back to flat terrain marked as a fallback', async (t) => {
t.mock.method(Cesium.CesiumTerrainProvider, 'fromUrl', async () => {
throw new Error('layer.json unreachable');
});
t.mock.method(console, 'warn', () => {});
const result = await createKeylessTerrain();
assert.ok(result.provider instanceof Cesium.EllipsoidTerrainProvider);
assert.equal(result.fallback, true);
});
+1 -1
View File
@@ -59,6 +59,6 @@ export async function createKeylessTerrain() {
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
error,
);
return { provider: new Cesium.EllipsoidTerrainProvider() };
return { provider: new Cesium.EllipsoidTerrainProvider(), fallback: true };
}
}
+26
View File
@@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies'
}
});
test('element data that mentions a failure phrase is data, and sets no cooldown', async () => {
const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited'];
for (const [i, name] of names.entries()) {
const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] });
let count = 0;
const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => {
count++; return new Response(body);
} };
const payload = await fetchOverpassPayload('first', 1024, options);
assert.equal(payload.status, 200, name);
assert.ok(overpassPayloadIsData(payload), name);
assert.ok(payload.body.includes(name), name);
await fetchOverpassPayload('different', 1024, options);
assert.equal(count, 2, name);
}
const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' });
const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'],
fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) });
assert.equal(limited.status, 429);
assert.equal(limited.retryAfterMs, 90_000);
const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'],
fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) });
assert.equal(error.status, 429);
assert.equal(error.retryAfterMs, 120_000);
});
function proxyHandler() {
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
const routes = new Map();
+50 -39
View File
@@ -189,20 +189,22 @@ export class SceneDirector {
...this._presentation,
hasRun: !!this._lastRunJson,
}));
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
if (!this._storageReadError) {
this._bootstrapLegacyShotPacks();
// Upgrade only already-installed packs; unrelated Scenes are untouched.
for (const scene of this._project.scenes) {
for (const marker of [...(scene.appliedShotPacks || [])]) {
const recipe = getSceneAppendRecipeById(marker.id);
if (
recipe?.expansionFromVersion &&
marker.version > 0 &&
marker.version <= recipe.expansionFromVersion
) {
this.appendShotPack(scene.id, marker.id, {
render: false,
announce: false,
});
}
}
}
}
@@ -324,13 +326,14 @@ export class SceneDirector {
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
),
);
return;
return false;
}
this._project.updatedAt = new Date().toISOString();
try {
const payload = JSON.stringify(this._project);
parseSceneDocument(payload);
localStorage.setItem(STORAGE_KEY, payload);
return true;
} catch (e) {
// Private browsing / block-all-cookies / quota-exceeded throws here. The
// in-memory project stays usable this session, but persistence failed —
@@ -351,6 +354,7 @@ export class SceneDirector {
})
: undefined,
);
return false;
}
}
@@ -923,12 +927,12 @@ export class SceneDirector {
}
this._selectedSceneId = scene.id;
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
this._saveProject();
const saved = this._saveProject();
if (render) {
this._renderSceneSelect();
this._renderShotList();
}
if (announce) {
if (announce && saved) {
this._updateStatus(
marker
? tn(
@@ -984,14 +988,16 @@ export class SceneDirector {
scene.shots.push(shot);
this._selectedShotId = shot.id;
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-captured', scene, shot);
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Captured: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1017,14 +1023,16 @@ export class SceneDirector {
shot.visual = this.styleManager.getVisualState();
shot.layers = this._captureLayerStates();
this._saveProject();
const saved = this._saveProject();
this._shotOutcome('shot-updated', scene, shot);
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
if (saved) {
this._updateStatus(
t('Updated: {scene} / {shot}', {
scene: displaySceneTitle(scene),
shot: displayShotTitle(shot.title, shot, scene),
}),
);
}
}
/**
@@ -1755,13 +1763,13 @@ export class SceneDirector {
) {
if (this._running) return { started: false, reason: 'already-running' };
let queue = this._buildPlaybackQueue(
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
{ single },
);
const startSceneId =
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
let queue = this._buildPlaybackQueue(startSceneId, { single });
if (afterShotId !== null) {
const index = queue.findIndex(
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
({ scene, shot }) =>
scene.id === startSceneId && shot.id === afterShotId,
);
if (index < 0) return { started: false, reason: 'shot-not-found' };
queue = queue.slice(index + 1);
@@ -1834,7 +1842,7 @@ export class SceneDirector {
title: 'Editable Scene Run',
startedAt: new Date().toISOString(),
estimatedDurationSec,
scenesRun: queue.length,
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
events: [],
};
@@ -1842,6 +1850,7 @@ export class SceneDirector {
this._logEvent('scene_run_start', { count: queue.length });
this._setPlaybackKeyboardEnabled(true);
let runResult = { started: true, shots: queue.length };
try {
await playSceneQueue(queue, {
token,
@@ -1860,9 +1869,11 @@ export class SceneDirector {
this._logEvent('scene_run_error', {
message: error.message || 'unknown error',
});
runResult = { started: false, reason: 'run-failed' };
} finally {
this._finishRun();
}
return runResult;
}
/**
@@ -2090,9 +2101,9 @@ export class SceneDirector {
this._selectedShotId =
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
this._loadedSceneId = null;
this._saveProject();
const saved = this._saveProject();
this._publish({ type: 'project-imported', project });
this._updateStatus(t('Imported {file}', { file: file.name }));
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
return true;
} catch (error) {
if (
+80 -6
View File
@@ -601,8 +601,12 @@ const PROJECT_FIXTURE = {
}],
};
/** Stub the browser globals the director touches, headlessly. */
function installSceneRuntime(project = PROJECT_FIXTURE) {
/**
* Stub the browser globals the director touches, headlessly. `storage` replaces
* the seeded entries (key -> raw string); setting `localStorage.full` makes
* every write throw a quota error.
*/
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
const originalDocument = globalThis.document;
const originalLocalStorage = globalThis.localStorage;
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
@@ -614,10 +618,17 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
removeEventListener() {},
body: { classList: noopClassList, appendChild() {} },
};
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
const stored = new Map(storage
? Object.entries(storage)
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
globalThis.localStorage = {
stored,
full: false,
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => stored.set(key, value),
setItem(key, value) {
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
stored.set(key, value);
},
removeItem: (key) => stored.delete(key),
};
@@ -718,7 +729,8 @@ async function settle(turns = 8) {
/** Build a director over doubles, with the fixture project loaded. */
function makeDirector(options = {}) {
const restore = installSceneRuntime(options.project);
const restore = installSceneRuntime(options.project, { storage: options.storage });
const storage = globalThis.localStorage;
const viewer = fakeViewer();
const styleManager = fakeStyleManager(options.style);
const dataManager = fakeDataManager(options.data);
@@ -728,7 +740,7 @@ function makeDirector(options = {}) {
// Telemetry is only accumulated during a run; observable-failure assertions
// need the accumulator without driving a whole run.
director._activeRun = { events: [] };
return { director, viewer, styleManager, dataManager, restore };
return { director, viewer, styleManager, dataManager, storage, restore };
}
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
@@ -1584,3 +1596,65 @@ test('camera refusal starts no authored frame or playback clock', async () => {
assert.deepEqual(viewer.flights, []);
} finally { restore(); }
});
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
const unreadable = '{"version":99,"scenes":[]}';
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector({ storage: {
'godsEyeView.sceneProject.v2': unreadable,
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
} });
try {
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
const warn = console.warn;
console.warn = () => {};
const { director, storage, restore } = makeDirector();
let status = '';
director.subscribe(({ state }) => { status = state.status; });
try {
storage.full = true;
director._selectedSceneId = 'scene-1';
director._selectedShotId = 'shot-a';
director.captureShot();
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
director.updateSelectedShot();
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
assert.equal(await director.importProjectFile(
{ name: 'fixture.json' },
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
), true);
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
} finally { console.warn = warn; await director.destroy(); restore(); }
});
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
const { director, restore } = makeDirector();
try {
const options = { single: true, preview: false };
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
assert.deepEqual(
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
{ started: true, shots: 1 },
);
} finally { await director.destroy(); restore(); }
});
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
const { director, styleManager, restore } = makeDirector();
const warn = console.warn;
console.warn = () => {};
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
try {
assert.deepEqual(
await director.startScene('scene-1', { single: true, preview: false }),
{ started: false, reason: 'run-failed' },
);
} finally { console.warn = warn; await director.destroy(); restore(); }
});
+1 -1
View File
@@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) {
}
/**
* Answer a decimal-degree query without asking anyone.
* Answer a decimal-degree or geo-URI query without asking anyone.
*
* It sits ahead of the network geocoders, so a coordinate costs no request and
* works with no key. Anything that is not exactly a coordinate is passed on
+13 -3
View File
@@ -5,7 +5,8 @@
* as 12, 34 flies the operator somewhere they did not ask for and looks like a
* successful search. So every rule here is exact — a component is a decimal
* number, optionally signed, optionally marked with one hemisphere letter, and
* nothing else. Degrees/minutes/seconds and grid references are not handled;
* nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar.
* Degrees/minutes/seconds and grid references are not handled;
* they fall through to the geocoders, which is what happens today.
*/
@@ -13,6 +14,10 @@
const COMPONENT =
/^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i;
/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */
const GEO_URI =
/^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i;
/** Hemisphere letters, by the axis each one can name. */
const LATITUDE_LETTERS = new Set(['N', 'S']);
@@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) {
}
/**
* Parse a decimal-degree query, or return null.
* Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null.
*
* Both orders are accepted when the hemisphere letters say which is which
* (`W 40, N 74` is longitude then latitude); with no letters the conventional
* latitude-then-longitude order applies. Two letters naming the same axis, a
* letter that fixes nothing, an out-of-range latitude or longitude, and any
* trailing text all decline.
* Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`;
* altitude, uncertainty, other parameters and query/fragment suffixes decline.
*
* @param {string} query
* @returns {{ lat: number, lon: number, label: string } | null}
*/
export function parseCoordinateQuery(query) {
if (typeof query !== 'string') return null;
const parts = splitComponents(query);
const text = query.trim();
const parts = /^geo:/i.test(text)
? GEO_URI.exec(text)?.slice(1, 3)
: splitComponents(text);
if (!parts) return null;
const first = readComponent(parts[0]);
+64
View File
@@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => {
assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90);
assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180);
});
test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => {
for (const query of [
'geo:39.7392,-104.9903',
'geo:39.7392,-104.9903;crs=wgs84',
' GEO:39.7392,-104.9903;CRS=WGS84 ',
]) {
assert.deepEqual(parseCoordinateQuery(query), {
lat: 39.7392,
lon: -104.9903,
label: '39.7392° N, 104.9903° W',
});
}
for (const [query, decimal] of [
['geo:0,0', '0,0'],
['geo:-33.8568,151.2153', '-33.8568,151.2153'],
['geo:90,180', '90,180'],
['geo:-90,-180;crs=wgs84', '-90,-180'],
['geo:09,007', '9,7'],
['geo:-0,-0', '-0,-0'],
]) {
assert.deepEqual(
parseCoordinateQuery(query),
parseCoordinateQuery(decimal),
);
}
});
test('malformed or unsupported geo URIs are refused without losing their meaning', () => {
for (const query of [
'geo:',
'geo:39.7392',
'geo:,104.9903',
'geo:39.7392,,104.9903',
'geo:39.7392,-104.9903,',
'geo:91,0',
'geo:-90.1,0',
'geo:0,181',
'geo:0,-180.1',
'geo:000,0',
'geo:0,0000',
'geo:+39.7392,-104.9903',
'geo:.5,-.25',
'geo:1e1,2',
'geo:NaN,0',
'geo:Infinity,0',
'geo:39.7392, -104.9903',
'geo:39.7392;-104.9903',
'geo:39.7392N,104.9903W',
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84',
'geo:39.7392,-104.9903;crs=wgs84;u=0',
'geo:39.7392,-104.9903;custom=value',
'geo:0,0?q=39.7392,-104.9903(Denver)',
'geo:39.7392,-104.9903#Denver',
'geo:39.7392,-104.9903 trailing text',
'geo://39.7392,-104.9903',
'https://example.com/geo:39.7392,-104.9903',
]) {
assert.equal(parseCoordinateQuery(query), null, query);
}
});
+50
View File
@@ -9,6 +9,7 @@ import assert from 'node:assert/strict';
import { createCoordinateGeocoder } from './coordinateGeocoder.js';
import { createPresetGeocoder } from './presetGeocoder.js';
import { createPlaceSearch } from './placeSearch.js';
import { createDefaultPlaceSearch } from './defaults.js';
const PRESETS = {
austin: {
@@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => {
);
}
});
test('default search resolves a geo URI exactly without requesting a geocoder', async () => {
const requests = [];
const search = createDefaultPlaceSearch({
resolveApiKey: () => 'test-key',
fetchImpl: async (url) => {
requests.push(url);
throw new Error('No network request should be needed');
},
});
const { place, fallbackUsed } = await search.geocode(
'geo:39.7392,-104.9903;crs=wgs84',
);
assert.deepEqual(requests, []);
assert.equal(fallbackUsed, false);
assert.equal(place.lat, 39.7392);
assert.equal(place.lng, -104.9903);
assert.equal(place.exact, true);
assert.deepEqual(place.types, ['coordinate']);
assert.ok(place.viewport.southwest.lat < place.lat);
assert.ok(place.viewport.northeast.lat > place.lat);
assert.ok(place.viewport.southwest.lng < place.lng);
assert.ok(place.viewport.northeast.lng > place.lng);
});
test('unsupported geo URIs reach the next search provider unchanged', async () => {
const queries = [
'geo:39.7392,-104.9903,1600',
'geo:39.7392,-104.9903;u=10',
'geo:39.7392,-104.9903;crs=unknown',
'geo:0,0?q=Denver',
];
const received = [];
const search = createPlaceSearch({
providers: [
createCoordinateGeocoder(),
{
async geocode(query) {
received.push(query);
return { place: null, answered: true };
},
},
],
});
for (const query of queries) {
assert.equal((await search.geocode(query)).place, null);
}
assert.deepEqual(received, queries);
});
+18 -11
View File
@@ -204,12 +204,16 @@ export function createGroundFloor({ terrain, signal }) {
* @returns {{lat: number, lon: number}} The cell to read.
*/
function stickyFloorCell(lat, lon, previousCell) {
if (
previousCell &&
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
if (previousCell) {
// The short way round, so a contact on the 180° seam keeps its cell.
let dLon = lon - previousCell.lon;
dLon -= 360 * Math.round(dLon / 360);
if (
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(dLon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
}
}
return coarseFloorCoord(lat, lon);
}
@@ -269,17 +273,20 @@ export function createGroundFloor({ terrain, signal }) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
// The short way round, so a segment across 180° walks across it.
let dLon = b.lon - a.lon;
dLon -= 360 * Math.round(dLon / 360);
const steps = Math.ceil(
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
Math.max(Math.abs(b.lat - a.lat), Math.abs(dLon)) /
CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
const cell = coarseFloorCoord(
a.lat + (b.lat - a.lat) * t,
a.lon + (b.lon - a.lon) * t,
);
let lon = a.lon + dLon * t;
if (lon > 180) lon -= 360;
else if (lon < -180) lon += 360;
const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, lon);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
+3 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { approxDistanceKm } from '../data/approxDistance.js';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
@@ -79,16 +80,6 @@ export function createMeshFloorSampler({
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
@@ -133,7 +124,8 @@ export function createMeshFloorSampler({
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) >
MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
+14 -4
View File
@@ -69,12 +69,22 @@ test('placeholder values are never counted as configured credentials', () => {
});
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
assert.deepEqual(npmProcessSpec('win32'), {
command: 'npm.cmd',
// Windows passes no argument array with shell: true (Node DEP0190).
assert.deepEqual(npmProcessSpec(['ci'], 'win32'), {
command: 'npm.cmd ci',
args: [],
shell: true,
});
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
assert.deepEqual(npmProcessSpec(['--version'], 'darwin'), {
command: 'npm',
args: ['--version'],
shell: false,
});
assert.deepEqual(npmProcessSpec(['ci'], 'linux'), {
command: 'npm',
args: ['ci'],
shell: false,
});
});
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
+30 -3
View File
@@ -51,15 +51,25 @@ export function extractStationsArray(payload) {
return [];
}
/**
* Station name text as a reader expects it. JCDecaux's Spanish contracts
* (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words
* with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word
* break. Other spacing is the operator's own and stays as published.
*/
function tidyStationName(text) {
return text.replace(/\s*_+\s*/g, ' ').trim();
}
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'string') return tidyStationName(value);
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
const text = translation.text.trim();
const text = tidyStationName(translation.text);
if (!text) continue;
if (!fallback) fallback = text;
if (
@@ -71,6 +81,23 @@ function stationName(value) {
return fallback;
}
/**
* Docks a station physically has. PBSC adds the bikes a station accepts in its
* geofenced area around the docks to `capacity` and reports that part as
* `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as
* capacity 839 with geofenced_capacity 800. Without the extension, or when it
* would exceed the total, `capacity` is read as given. PBSC also counts the
* bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so
* a crowded station can report more bikes than docks.
*/
function dockCapacity(raw) {
const capacity = toNonNegativeInteger(raw?.capacity);
const geofenced = toNonNegativeInteger(raw?.geofenced_capacity);
if (capacity == null || geofenced == null || geofenced > capacity)
return capacity;
return capacity - geofenced;
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
@@ -95,7 +122,7 @@ export function parseStationInformation(payload) {
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
capacity: dockCapacity(raw),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
+55
View File
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
data: { stations: [station(fields)] },
}).get('s1').name;
const parseCapacity = (fields) =>
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
.capacity;
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
});
test('capacity is read as given without a usable geofenced_capacity', () => {
assert.equal(parseCapacity({ capacity: 27 }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
});
test('GBFS v2 station names remain trimmed strings', () => {
assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
@@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => {
);
});
test('underscores in station names read as word breaks', () => {
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
for (const [published, shown] of [
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
['Plaza__Mayor_ ', 'Plaza Mayor'],
]) {
assert.equal(parseName({ name: published }), shown);
}
});
test('spacing without underscores stays as the operator published it', () => {
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
});
test('a name of only underscores falls back to short_name, then to empty', () => {
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
assert.equal(parseName({ name: '__', short_name: '_' }), '');
});
test('underscores are read the same in GBFS v3 localized names', () => {
assert.equal(
parseName(
{
name: [
{ language: 'es', text: '_' },
{ language: 'en', text: '_COLON_I' },
],
},
'3.0',
),
'COLON I',
);
});
test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal(
parseName(
+51 -5
View File
@@ -13,7 +13,14 @@ export function parseTleText(text) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const cat1 = tleCatalogNumber(line1);
const cat2 = tleCatalogNumber(line2);
if (
line1.startsWith('1 ') &&
line2.startsWith('2 ') &&
cat1 !== null &&
cat1 === cat2
) {
result.push({ name, line1, line2 });
i += 2;
}
@@ -21,8 +28,47 @@ export function parseTleText(text) {
return result;
}
/** The NORAD catalog number from TLE line 1, or null. */
export function tleCatalogNumber(line1) {
const number = Number.parseInt(String(line1).slice(2, 7), 10);
return Number.isInteger(number) ? number : null;
// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits.
const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/;
const DIGITS = /^\d+$/;
/**
* The catalog number a catalog field stands for, or null for any other field.
*
* A field is either plain digits or Alpha-5, the TLE spelling of numbers above
* 99999: the letter stands for 10–33 in the ten-thousands place, skipping I
* and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a
* decimal point, hex, an embedded space, a lowercase or trailing letter) is
* not a catalog number.
* @param {string} field the field with any leading spaces already removed
* @returns {number | null}
*/
function decodeCatalogField(field) {
if (DIGITS.test(field)) return Number(field);
if (!ALPHA5.test(field)) return null;
const letter = field[0];
let tens = letter.charCodeAt(0) - 65 + 10;
if (letter > 'I') tens -= 1;
if (letter > 'O') tens -= 1;
return tens * 10000 + Number(field.slice(1));
}
/**
* NORAD catalog number of a satrec as a number, or NaN.
*
* satellite.js keeps the TLE's five-character field verbatim in
* `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite
* would share one NaN key. Digits of any length (an OMM satrec carries the
* number unpadded) and Alpha-5 decode; anything else, blank included, is NaN.
* @param {string} satnum
* @returns {number}
*/
export function noradIdFromSatnum(satnum) {
return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN;
}
/** The NORAD catalog number from TLE line 1 or 2 (columns 3–7), or null. */
export function tleCatalogNumber(line) {
const field = String(line).slice(2, 7);
return decodeCatalogField(field.replace(/^ +/, ''));
}
+70 -4
View File
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
import { localProviderPlugins } from '../../server/providers/local.js';
function install(plugin) {
function install(plugin, hook = 'configureServer') {
const routes = new Map();
plugin.configureServer({
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
plugin[hook]({
middlewares: {
use(route, handler) {
routes.set(route, handler);
@@ -17,9 +18,16 @@ function install(plugin) {
},
});
assert.equal(routes.size, 1);
return async (url = '/', method = 'GET') => {
return async (url = '/', method = 'GET', headers = {}) => {
const res = {
headersSent: false,
headers: {},
set statusCode(status) {
this.status = status;
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true });
},
@@ -27,7 +35,7 @@ function install(plugin) {
this.body = body;
},
};
await [...routes.values()][0]({ url, method }, res);
await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
return res;
};
}
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(calls, 2);
});
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response(new Uint8Array([1, 2, 3]));
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
];
for (const [mode, hook] of [
['dev', 'configureServer'],
['preview', 'configurePreviewServer'],
]) {
const request = install(tomtomProxy(), hook);
// Each refused shape asks for distinct uncached tiles: without the gate
// every one would be a billed attempt counted against the daily budget.
let x = 0;
for (const headers of refused)
for (let i = 0; i < 2; i++) {
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
}
assert.equal(
(await request('/status', 'GET', refused[0])).status,
403,
`${mode}: status is gated too`,
);
assert.equal(
calls,
mode === 'dev' ? 0 : 2,
`${mode}: no refused upstream work`,
);
const app = { host, 'sec-fetch-site': 'same-origin' };
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
// The app itself and loopback tools (no browser headers) still get tiles.
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
assert.equal(tile.status, 200);
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
assert.equal(
(
await request('/flow/8/1/1.pbf', 'GET', {
host,
origin: `http://${host}`,
})
).headers['x-tomtom-cache'],
'HIT',
);
assert.equal(calls, mode === 'dev' ? 2 : 4);
}
});
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
const bodies = [];
+21 -4
View File
@@ -89,17 +89,34 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
config.plugins.slice(3, -4).map((plugin) => plugin.name),
config.plugins.slice(3, -5).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-5).name, 'local-mcp');
assert.equal(config.plugins.at(-4).name, 'api-not-found');
// Config-only, so it sits after the provider slice rather than in it.
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
test('directories the server writes while it runs are kept out of the dev watcher', () => {
// Providers write cache files and logs here while the app runs; none of it is
// source, so the dev watcher has no reason to stat it.
const config = standaloneConfig({ mode: 'test' });
const plugin = config.plugins.find(
({ name }) => name === 'gev-unwatched-runtime-dirs',
);
// Dev only: preview and build have no watcher.
assert.equal(plugin.apply, 'serve');
assert.deepEqual(plugin.config().server.watch.ignored, [
'**/.gev-cache/**',
'**/.gev-logs/**',
]);
});
test('build export resolves in Node and has no browser fallback', async () => {
const exported = await import('gods-eye-view/build/vite');
assert.equal(exported.createBrowserViteConfig, createBrowserViteConfig);
+21 -4
View File
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
function ringContains(ring, point) {
if (ring.length < 3) return false;
// Keep adjacent vertices on the same longitude sheet, as the edge tests do.
// Wrapping each vertex around the query point would split a dateline ring
// when queried from Greenwich and mistake its complement for its interior.
let previous = ring[0][0];
let west = previous;
let east = previous;
const vertices = ring.map(([lon, lat]) => {
if (Math.abs(lon - previous) > 180)
lon += 360 * Math.round((previous - lon) / 360);
previous = lon;
west = Math.min(west, lon);
east = Math.max(east, lon);
return [lon, lat];
});
const middle = (west + east) / 2;
const lon = point.lon + 360 * Math.round((middle - point.lon) / 360);
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
const [xi, yi] = vertices[i];
const [xj, yj] = vertices[j];
if (
yi > point.lat !== yj > point.lat &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
+75
View File
@@ -262,3 +262,78 @@ test('a box radius reaches every point of the box, however large', async () => {
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
assert.ok(areaRadiusKm(small) < 30);
});
test('polygon containment follows the short edges across the antimeridian', async () => {
const ring = [
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
];
const areas = [
[{ bbox: [179.4, -0.1, 179.6, 0.1] }, true],
[{ bbox: [-179.6, -0.1, -179.4, 0.1] }, true],
[{ bbox: [179.8, -0.1, -179.8, 0.1] }, true],
[{ lat: 0, lon: 179.5, radius_km: 10 }, true],
[{ lat: 0, lon: -179.5, radius_km: 10 }, true],
[{ lat: 0, lon: 180, radius_km: 10 }, true],
[{ lat: 0, lon: -180, radius_km: 10 }, true],
[{ bbox: [-0.1, -0.1, 0.1, 0.1] }, false],
[{ lat: 0, lon: 0, radius_km: 10 }, false],
];
for (const vertices of [
ring,
[...ring, [...ring[0]]],
[...ring].reverse(),
[...ring.slice(2), ...ring.slice(0, 2)],
]) {
for (const [input, expected] of areas) {
assert.equal(
polygonsTouchArea([[vertices]], await resolveArea(input)),
expected,
JSON.stringify({ vertices, input }),
);
}
}
});
test('antimeridian polygons keep holes empty and include their boundaries', async () => {
const outer = [
[178, -3],
[-178, -3],
[-178, 3],
[178, 3],
];
const hole = [
[179, -1],
[-179, -1],
[-179, 1],
[179, 1],
];
for (const input of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const area = await resolveArea(input);
assert.ok(polygonsTouchArea([[outer]], area));
assert.ok(!polygonsTouchArea([[outer, hole]], area));
assert.ok(!polygonsTouchArea([[outer, [...hole].reverse()]], area));
}
const solid = await resolveArea({ bbox: [178.4, -0.1, 178.6, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], solid));
const boundary = await resolveArea({ bbox: [178.9, -0.1, 179.1, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], boundary));
});
test('polygon containment retains rings spanning more than half the globe', async () => {
const longitudes = [-170, -110, -50, 10, 70, 130];
const ring = [
...longitudes.map((lon) => [lon, -5]),
...[...longitudes].reverse().map((lon) => [lon, 5]),
];
const inside = await resolveArea({ bbox: [99, -1, 101, 1] });
const outside = await resolveArea({ bbox: [169, -1, 171, 1] });
assert.ok(polygonsTouchArea([[ring]], inside));
assert.ok(!polygonsTouchArea([[ring]], outside));
});
+2
View File
@@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
!(args.airborne_only && record.onGround) &&
((args.min_altitude_m == null && args.max_altitude_m == null) ||
Number.isFinite(altitude(record))) &&
(args.min_altitude_m == null ||
altitude(record) >= args.min_altitude_m) &&
(args.max_altitude_m == null ||
+59
View File
@@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
assert.deepEqual(result.data.stale_feeds, ['civil']);
assert.match(result.summary, /may be stale\.$/);
});
test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
const { normalizeOpenSkyAircraft } =
await import('../../sources/live/aircraft.js');
const state = (id, baro, geometric) =>
normalizeOpenSkyAircraft([
id,
'TEST',
'Test',
1767225600,
1767225600,
0,
0,
baro,
false,
10,
90,
0,
[],
geometric,
]);
const records = [
state('abc001', null, null),
state('abc002', 0, null),
state('abc003', null, 50),
state('abc004', 500, null),
];
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: { getSnapshot: async () => ({ records }) },
},
});
const area = { bbox: [-1, -1, 1, 1] };
assert.equal(
(await catalog.call('aircraft_in_area', { area })).data.total,
4,
'unfiltered answers retain unknown altitude',
);
for (const band of [
{ max_altitude_m: 100 },
{ min_altitude_m: -500, max_altitude_m: 100 },
]) {
const result = await catalog.call('aircraft_in_area', { area, ...band });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['abc002', 'abc003'],
);
assert.deepEqual(
result.data.rows.map((row) => row.altitude_m),
[0, 50],
);
}
const minimum = await catalog.call('aircraft_in_area', {
area,
min_altitude_m: -500,
});
assert.equal(minimum.data.total, 3);
});
+9 -4
View File
@@ -288,20 +288,25 @@ export const getFirePerimeters = defineTool({
},
});
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
function polygonCenter(polygons) {
let lat = 0;
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let count = 0;
const toRad = Math.PI / 180;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
lon += x;
sinLon += Math.sin(x * toRad);
cosLon += Math.cos(x * toRad);
lat += y;
count += 1;
}
}
return count ? { lat: lat / count, lon: lon / count } : null;
return count
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
: null;
}
export const getTerrainHeight = defineTool({
+91
View File
@@ -227,6 +227,97 @@ test('fire perimeters are placed by their outline and sorted by size', async ()
});
});
test('fire perimeter queries include dateline interiors and exclude the opposite meridian', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'dateline',
name: 'Dateline Fire',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
],
};
const catalog = catalogWith({ perimeters });
for (const area of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const result = await catalog.call('get_fire_perimeters', { area });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['dateline'],
);
}
const elsewhere = await catalog.call('get_fire_perimeters', {
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
});
assert.equal(elsewhere.data.total, 0);
});
test('fire perimeter coordinates stay at the dateline for closed and multipart outlines', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'closed',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
{
stableId: 'multipart',
acres: 50,
polygons: [
[
[
[179, -1],
[179.5, -1],
[179.5, 1],
[179, 1],
],
],
[
[
[-179.5, -1],
[-179, -1],
[-179, 1],
[-179.5, 1],
],
],
],
},
],
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [178, -3, -178, 3] },
});
assert.equal(result.data.total, 2);
for (const row of result.data.rows) {
assert.ok(Math.abs(row.lon) > 179, `${row.id}: ${row.lon}`);
assert.ok(Math.abs(row.lon) <= 180);
}
});
test('terrain heights are returned in request order', async () => {
const terrain = {
async getHeights(points) {
+24 -6
View File
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
export const getCctvSnapshot = defineTool({
name: 'get_cctv_snapshot',
title: 'Current camera view',
title: 'Camera snapshot',
description:
'The current image from one public camera, by the id from find_cctv_cameras.',
'An image from one public camera, by the id from find_cctv_cameras. ' +
'Street View fallback imagery is historical, not the current camera view; ' +
'unknown frame provenance is explicit.',
inputSchema: {
type: 'object',
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
@@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
);
const frame = await services.cctv.getFrame(camera, { signal });
if (!FRAME_TYPES.has(frame.contentType))
if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType))
throw new ToolError(
'unavailable',
`Camera ${camera.name || camera.id} has no still image right now`,
@@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({
'The camera image is too large to return',
);
const name = text(camera.name) || camera.id;
const fallback = frame.sourceKind === 'streetview';
const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind);
const credit = fallback ? 'Google Street View' : text(camera.credit);
const label = fallback
? 'Street View fallback for'
: current
? 'Current view from'
: 'Image from';
return {
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
summary:
`${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` +
(fallback
? ' Historical imagery; the current camera frame is unavailable.'
: current
? ''
: ' Frame provenance is unknown.'),
data: {
view: suggestView(services, {
point: { lat: camera.lat, lon: camera.lon },
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
id: camera.id,
name,
city: text(camera.city),
provider: text(camera.provider),
credit: text(camera.credit),
provider: fallback ? 'Google Street View' : text(camera.provider),
credit,
source_kind: frame.sourceKind ?? null,
current_view: current ? true : fallback ? false : null,
mime_type: frame.contentType,
bytes: frame.bytes.byteLength,
},
+184 -1
View File
@@ -30,7 +30,13 @@ const cameras = [
{ id: 'no-position', name: 'Unplaced' },
];
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
const cctv = (
frame = {
contentType: 'image/png',
bytes: PNG,
sourceKind: 'upstream-image',
},
) => ({
frames: [],
async getCatalog() {
return { sources: cameras };
@@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => {
});
});
test('mounted camera fallback provenance survives the source and MCP image result', async (t) => {
const { cctvProxy } = await import('../../../server/providers/cctv.js');
const { createCctvSource } = await import('../../layers/cctv/source.js');
const camera = {
id: 'fixture-camera',
name: 'Fixture crossing',
city: 'Fixture City',
provider: 'Fixture transport agency',
credit: 'Fixture transport agency',
lat: 30.2685,
lon: -97.7425,
headingDeg: 180,
fovDeg: 60,
url: 'https://camera.example/current.png',
feedType: 'image',
};
const environment = {
CCTV_SOURCES_JSON: JSON.stringify([camera]),
CCTV_PREFER_AUSTIN: '0',
CCTV_FORCE_AUSTIN: '0',
CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json',
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key',
GOOGLE_MAPS_API_KEY: '',
};
for (const [name, value] of Object.entries(environment)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
process.env[name] = value;
}
t.mock.method(console, 'warn', () => {});
let upstreamAvailable = false;
const calls = [];
t.mock.method(globalThis, 'fetch', async (raw) => {
const url = new URL(raw);
calls.push(url.origin);
if (url.origin === 'https://camera.example')
return upstreamAvailable
? new Response(PNG, { headers: { 'Content-Type': 'image/png' } })
: new Response(null, { status: 503 });
assert.equal(
url.origin,
'https://maps.googleapis.com',
'no actual upstream is contacted',
);
assert.equal(url.searchParams.get('key'), 'fixture-key');
return new Response(PNG, { headers: { 'Content-Type': 'image/png' } });
});
let middleware;
cctvProxy().configureServer({
middlewares: {
use(route, handler) {
assert.equal(route, '/api/cctv');
middleware = handler;
},
},
});
const source = createCctvSource({
fetchImpl: async (path) => {
assert.ok(path.startsWith('/api/cctv/'));
const response = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = body;
},
};
await middleware(
{ url: path.slice('/api/cctv'.length), method: 'GET', headers: {} },
response,
);
return new Response(response.body, {
status: response.status,
headers: response.headers,
});
},
});
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const fallback = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(fallback.data.source_kind, 'streetview');
assert.equal(fallback.data.current_view, false);
assert.equal(fallback.data.credit, 'Google Street View');
assert.match(fallback.summary, /Street View fallback/);
assert.match(fallback.summary, /Historical imagery/);
assert.doesNotMatch(
fallback.summary,
/Current view|courtesy of Fixture transport agency/,
);
assert.equal(
fallback.images.length,
1,
'the historical image stays available with its caveat',
);
assert.deepEqual(calls, [
'https://camera.example',
'https://maps.googleapis.com',
]);
upstreamAvailable = true;
const current = await catalog.call('get_cctv_snapshot', {
camera_id: camera.id,
});
assert.equal(current.data.source_kind, 'upstream-image');
assert.equal(current.data.current_view, true);
assert.equal(current.data.credit, camera.credit);
assert.match(current.summary, /^Current view from/);
const server = createMcpServer({ catalog, name: 'test', version: '1' });
upstreamAvailable = false;
const result = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.ok(
result.result.content.some(
(entry) => entry.type === 'text' && /Historical imagery/.test(entry.text),
),
);
assert.ok(result.result.content.some((entry) => entry.type === 'image'));
process.env.GOOGLE_MAPS_SERVER_API_KEY = '';
const placeholder = await source.getFrame(camera);
assert.equal(placeholder.sourceKind, 'synthetic');
assert.equal(placeholder.contentType, 'image/svg+xml');
await assert.rejects(
catalog.call('get_cctv_snapshot', { camera_id: camera.id }),
(error) => error.code === 'unavailable',
);
const unavailable = await server.handle({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
});
assert.equal(unavailable.result.isError, true);
assert.ok(
!unavailable.result.content.some((entry) => entry.type === 'image'),
);
});
test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => {
const synthetic = composeCatalog({
tools: coreTools,
services: {
cctv: cctv({
contentType: 'image/png',
bytes: PNG,
sourceKind: 'synthetic',
}),
},
});
await assert.rejects(
synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }),
(error) => error.code === 'unavailable',
);
const unknown = await composeCatalog({
tools: coreTools,
services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) },
}).call('get_cctv_snapshot', { camera_id: 'austin-1' });
assert.equal(unknown.data.current_view, null);
assert.equal(
unknown.data.credit,
cameras[0].credit,
'catalog attribution is preserved',
);
assert.match(unknown.summary, /provenance is unknown/);
assert.doesNotMatch(unknown.summary, /Current view/);
});
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
const catalog = (frame) =>
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
+125 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { twoline2satrec } from 'satellite.js';
import { composeCatalog, coreTools } from '../index.js';
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleCatalogNumber,
} from '../../sources/tle.js';
// Element sets published for 1 January 2024.
const TLE = `ISS (ZARYA)
@@ -51,6 +56,125 @@ for (const missingLine of [1, 2]) {
});
}
test('TLE parsing rejects pairs with mismatched catalog numbers and resumes', () => {
const text = `SAT A
1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
SAT B
1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
`;
const entries = parseTleText(text);
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT B'],
);
assert.equal(tleCatalogNumber(entries[0].line1), 48274);
assert.equal(tleCatalogNumber(entries[0].line2), 48274);
});
test('TLE parsing accepts matching Alpha-5 pairs and rejects mismatched Alpha-5 pairs', () => {
const text = `SAT MISMATCH
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 A0001 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
SAT ALPHA5
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 A0000 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
`;
const entries = parseTleText(text);
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT ALPHA5'],
);
assert.equal(tleCatalogNumber(entries[0].line1), 100000);
assert.equal(tleCatalogNumber(entries[0].line2), 100000);
});
test('TLE parsing rejects a pair whose catalog fields are malformed and resumes', () => {
// Read as digits up to the first non-digit, both fields would be 2554.
const text = `SAT MALFORMED
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 2554B 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
SAT HALF MALFORMED
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 02554 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
${TLE}`;
assert.deepEqual(
parseTleText(text).map((entry) => entry.name),
['ISS (ZARYA)', 'CSS (TIANHE)'],
);
});
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
for (const [field, id] of [
['A0000', 100000],
['A0404', 100404],
['H9999', 179999],
['J0000', 180000],
['N9999', 229999],
['P0000', 230000],
['T0449', 270449],
['Z9999', 339999],
]) {
assert.equal(noradIdFromSatnum(field), id, field);
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
}
});
test('a digit catalog field reads as it did before', () => {
assert.equal(noradIdFromSatnum('25544'), 25544);
assert.equal(noradIdFromSatnum('00404'), 404);
assert.equal(noradIdFromSatnum(' 404'), 404);
assert.equal(tleCatalogNumber('1 25544U'), 25544);
assert.equal(tleCatalogNumber('1 404U'), 404);
// An OMM satrec carries the number unpadded, past five digits.
assert.equal(noradIdFromSatnum('100000'), 100000);
});
test('a malformed catalog field is no number', () => {
for (const field of [
'I0000',
'O0000',
'a0404',
'A00',
'2554A',
'255 4',
'-1234',
'0x1F0',
'1.5',
'1e3',
'',
' ',
]) {
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
assert.equal(
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
null,
JSON.stringify(field),
);
}
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
});
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
const text = TLE.replaceAll('25544', 'A0000');
const [iss] = parseTleText(text);
const { satnum } = twoline2satrec(iss.line1, iss.line2);
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
assert.equal(noradIdFromSatnum(satnum), 100000);
const catalog = composeCatalog({
tools: coreTools,
services: { satellites: satellites(text), clock: CLOCK },
});
const result = await catalog.call('next_satellite_pass', {
location: { lat: 40.7, lon: -74 },
satellite: '100000',
});
assert.equal(result.data.satellite, 'ISS (ZARYA)');
assert.equal(result.data.norad, 100000);
});
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
const catalog = composeCatalog({
tools: coreTools,
+2 -4
View File
@@ -344,7 +344,6 @@ export function createRecentImageryPanel({
const detailsHost = el('div', 'ri-details', root);
detailsHost.id = 'ri-details';
let detailsOpen = false;
let detailsShownOpen = false;
const details = createRailCards({
container: detailsHost,
document,
@@ -352,6 +351,8 @@ export function createRecentImageryPanel({
onOpen: () => {
detailsOpen = !detailsOpen;
render();
// After the render: preserveScroll() puts the old position back.
if (detailsOpen) revealCard(container, detailsHost.children[0]);
},
});
@@ -875,9 +876,6 @@ export function createRecentImageryPanel({
],
},
]);
if (detailsOpen && !detailsShownOpen)
revealCard(container, detailsHost.children[0]);
detailsShownOpen = detailsOpen;
}
// ---- divider ----------------------------------------------------------
+23
View File
@@ -1147,6 +1147,29 @@ test('the body scroll position survives renders even when DOM mutations reset it
assert.equal(f.body.scrollTop, 120);
});
test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => {
const f = fixture();
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
f.body.clientHeight = 100;
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const card = header.parentNode;
card.getBoundingClientRect = () => ({
top: 150,
height: card.dataset.open === 'true' ? 60 : 0,
});
header.click();
assert.equal(card.dataset.open, 'true');
assert.equal(f.body.scrollTop, 150);
header.click();
assert.equal(card.dataset.open, 'false');
assert.equal(f.body.scrollTop, 150);
});
test('destroy returns every listener, the divider and the panel state', async () => {
const f = fixture();
await f.ready();
+6 -17
View File
@@ -11,6 +11,7 @@ import { defaultGeospatial } from '../search/defaults.js';
import { msg } from '../i18n/index.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
import {
findPoiByName,
flyToGlobeView,
@@ -3742,8 +3743,10 @@ function centerMatchesSubject(center, subjectPosition) {
const subjectLat = Cesium.Math.toDegrees(carto.latitude);
const subjectLon = Cesium.Math.toDegrees(carto.longitude);
return (
haversineKm(subjectLat, subjectLon, center.lat, center.lon) <=
SUBJECT_CENTER_TOLERANCE_KM
greatCircleKm(
{ lat: subjectLat, lon: subjectLon },
{ lat: center.lat, lon: center.lon },
) <= SUBJECT_CENTER_TOLERANCE_KM
);
}
@@ -4033,7 +4036,7 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
for (const [cityId, city] of Object.entries(CITY_POIS)) {
for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) {
const poi = city.pois[poiIndex];
const distanceKm = haversineKm(latitude, longitude, poi.lat, poi.lon);
const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi);
if (distanceKm > maxDistanceKm) continue;
matches.push({
name: poi.name,
@@ -4048,20 +4051,6 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5);
}
function haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (value) => Cesium.Math.toRadians(value);
const radiusKm = 6371.0088;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function coarseBasemapPlace(
viewScale,
latitude,