From 9c638fa2907ec10273a8ed3d3c379bad5ce87da1 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:48:31 -0400 Subject: [PATCH 01/18] refactor(geo): define shared distance contract --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 8 +++ scripts/package-boundaries.json | 20 ++++++- src/annotations/annotationEngine.js | 12 +---- src/annotations/drawMode.js | 11 ++-- src/cockpitCloudEffects.js | 21 ++------ src/data/analystEngine.js | 13 ++--- src/data/naturalEarthRegions.js | 16 ++---- src/data/naturalEarthRegions.test.mjs | 11 ++++ src/data/regionalModel.js | 21 +++----- src/data/routePlausible.js | 40 +++++--------- src/data/routePlausible.test.mjs | 9 +++- src/data/trafficBounds.js | 29 +++------- src/data/trafficBounds.test.mjs | 14 ++--- src/data/transitFeeds.js | 13 ++--- src/geoDistance.js | 70 ++++++++++++++++++++++++ src/geoDistance.test.mjs | 78 +++++++++++++++++++++++++++ src/hud.js | 9 +--- src/layers/bikeshare/model.js | 12 ++--- src/layers/cctv/model.js | 8 +-- src/locations.js | 19 ++----- src/voice/gevActions.js | 23 +++----- 22 files changed, 267 insertions(+), 195 deletions(-) create mode 100644 src/geoDistance.js create mode 100644 src/geoDistance.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 261946f7a..432125bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Share one validated great-circle distance utility across runtime consumers. + Canonical coordinates use `{ lat, lon }`, invalid points stay outside spatial + selections, kilometres and metres share the IUGG mean Earth radius, and + compatibility `haversineKm` exports delegate to the canonical implementation. + - Add Director import previews, validated scene/shot detail drafts and selected-scene JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes before admission and release staged work on cancellation or teardown. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bd7d6cfd7..be65908d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Shared distance checks use `geoDistance.js` with `{ lat, lon }` points and the +IUGG mean spherical Earth radius. `greatCircleKm` is canonical; +`greatCircleMeters` is its exact metre conversion. Missing, non-finite, and +out-of-range points return `Infinity`, so threshold and nearest-item consumers +exclude invalid positions. Older exported `haversineKm` entry points remain as +compatibility wrappers only. Intentional local angular, projection, ellipsoidal +prefilter, and small-viewport approximations remain separate. + Director imports now open a non-mutating preview before Apply. EDIT DETAILS authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports a selected scene or a bounded bundle of explicitly chosen pack files. Bundled diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..632984059 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -246,6 +246,7 @@ "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", "src/sources/rateLimit.js" @@ -303,6 +304,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -713,6 +715,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/flights/controller.js", @@ -786,6 +789,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/aircraft/classification.js", @@ -953,6 +957,7 @@ "src/data/flowMatch.js", "src/data/tomtomTiles.js", "src/data/trafficBounds.js", + "src/geoDistance.js", "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", @@ -984,6 +989,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/geoDistance.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -1021,6 +1027,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/geoDistance.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1115,6 +1122,7 @@ "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", + "src/geoDistance.js", "src/data/neighborhoodPolygons.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", @@ -1208,6 +1216,7 @@ "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", @@ -1834,6 +1843,7 @@ "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/catalog.js", @@ -2224,6 +2234,7 @@ "src/app/surfaceServices.js", "src/data/dataCredits.js", "src/data/geoid.js", + "src/geoDistance.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", @@ -2405,7 +2416,7 @@ }, "regional-source": { "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "modules": ["src/data/regionalModel.js", "src/geoDistance.js"], "external": [] }, "voice-action-schemas": { @@ -2511,6 +2522,7 @@ "src/search/google.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -2533,6 +2545,7 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js" ], "external": [] @@ -2547,6 +2560,7 @@ "server/providers/transitHistory.js", "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/httpBody.js", "src/sources/transitHistory.js", @@ -2561,6 +2575,7 @@ "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], @@ -2580,6 +2595,7 @@ "src/data/geoid.js", "src/data/groundFloor.js", "src/data/gtfsRealtime.js", + "src/geoDistance.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/labelArbiter.js", @@ -2632,6 +2648,7 @@ "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -2675,6 +2692,7 @@ "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", "src/sources/httpBody.js", diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index ccf9cb06a..529dff5cc 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,5 +1,6 @@ import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1530,16 +1531,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) { diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index f28a7ae6f..d8cde2ab8 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -18,6 +18,8 @@ * `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 }); /** @@ -47,14 +49,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); } /** diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 053217810..ccbf31831 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -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 }, ); } diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index a84d6b855..1d2baccaf 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -25,6 +25,7 @@ */ import { pointInRing } from './naturalEarthRegions.js'; +import { greatCircleKm } from '../geoDistance.js'; /** Layers the engine understands, with the fields queries may reference. */ export const ANALYST_LAYERS = { @@ -63,17 +64,9 @@ export const ANALYST_LAYERS = { }, }; -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 }); } /** One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. */ diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 891e7e9a1..c3664e266 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -17,8 +17,8 @@ */ 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. */ @@ -35,15 +35,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 ". @@ -162,7 +153,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; diff --git a/src/data/naturalEarthRegions.test.mjs b/src/data/naturalEarthRegions.test.mjs index 36c1946ad..5dd78adea 100644 --- a/src/data/naturalEarthRegions.test.mjs +++ b/src/data/naturalEarthRegions.test.mjs @@ -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 () => { diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..116697c6f 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -1,3 +1,5 @@ +import { greatCircleMeters } from '../geoDistance.js'; + const MAX_ARTICLES = 5; function cleanText(value, maxLength = 180) { @@ -136,19 +138,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 }, + ); } diff --git a/src/data/routePlausible.js b/src/data/routePlausible.js index a8c0e9259..238c2e6cb 100644 --- a/src/data/routePlausible.js +++ b/src/data/routePlausible.js @@ -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 } } diff --git a/src/data/routePlausible.test.mjs b/src/data/routePlausible.test.mjs index dab932c6e..aa59a0c18 100644 --- a/src/data/routePlausible.test.mjs +++ b/src/data/routePlausible.test.mjs @@ -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); }); diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 4bd80ff32..f41eff6a9 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -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' }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..3def68111 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -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, diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 2858efa62..4ef87971f 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../geoDistance.js'; + /** * @module transitFeeds * @description Registry of keyless, openly licensed GTFS-Realtime @@ -316,7 +318,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 @@ -324,13 +327,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 }); } /** diff --git a/src/geoDistance.js b/src/geoDistance.js new file mode 100644 index 000000000..e71b72bb3 --- /dev/null +++ b/src/geoDistance.js @@ -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; +} diff --git a/src/geoDistance.test.mjs b/src/geoDistance.test.mjs new file mode 100644 index 000000000..67d6c32fc --- /dev/null +++ b/src/geoDistance.test.mjs @@ -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); + } +}); diff --git a/src/hud.js b/src/hud.js index c3177d808..102cc73c2 100644 --- a/src/hud.js +++ b/src/hud.js @@ -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 './locations.js'; import { composeLocalityTag } from './hudLocality.js'; @@ -589,13 +590,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 }); } /** diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..07a937340 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../../geoDistance.js'; + export function createModel({ state: layerState, services, parts, source }) { /** * Coerce a GBFS boolean field to a native boolean. @@ -52,15 +54,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 }); } /** diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js index 48c8cddd4..ae7a84ad9 100644 --- a/src/layers/cctv/model.js +++ b/src/layers/cctv/model.js @@ -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'; @@ -298,12 +299,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 }); } /** diff --git a/src/locations.js b/src/locations.js index d0f90ede6..ef6c01696 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,6 +1,7 @@ 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, @@ -1103,18 +1104,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. * @@ -1149,10 +1138,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; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d607322c8..555ecf70e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2,6 +2,7 @@ import { readLayerLifecycleSummary } from './layerSummary.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from '../geoDistance.js'; import { CITY_POIS, findPoiByName, @@ -3204,8 +3205,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 ); } @@ -3495,7 +3498,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, @@ -3510,20 +3513,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, From b681d094ca0e2d0ea0d2cea0b0a6636410ea5e94 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 10:43:30 +0800 Subject: [PATCH 02/18] docs: correct ADSBDB cache persistence note --- src/layers/flights/policy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..87e4617ee 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -213,8 +213,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. // --------------------------------------------------------------------------- From 003b92f64a60af2f4681863f974a777bdc89ea2e Mon Sep 17 00:00:00 2001 From: VG31OP Date: Tue, 13 Oct 2026 16:57:55 +0530 Subject: [PATCH 03/18] fix(satellites): reject TLE pairs with mismatched catalog numbers (#906, #936) --- CHANGELOG.md | 7 ++++--- src/sources/tle.js | 15 +++++++++++---- src/tools/queries/space.test.mjs | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..83b32baaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,9 +95,10 @@ ## [Unreleased] -- Keep later satellites when a TLE catalog entry is missing an orbital line. - The parser skips the incomplete set and resumes at the next complete named - set, instead of losing the rest of the group (#906). +- Keep later satellites when a TLE catalog entry is missing an orbital line or + pairs lines with mismatched NORAD catalog numbers. The parser skips corrupt sets + and resumes at the next complete named set, instead of losing the rest of the + group (#906, #936). - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..8f97e4eda 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -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,8 @@ 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); +/** The NORAD catalog number from a TLE line, or null. */ +export function tleCatalogNumber(line) { + const number = Number.parseInt(String(line).slice(2, 7), 10); return Number.isInteger(number) ? number : null; } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index f6c2a8df8..2b2586b45 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -51,6 +51,23 @@ 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('the next pass defaults to the ISS and reports times, peak and direction', async () => { const catalog = composeCatalog({ tools: coreTools, From ac62542641d47f06330f56398b2d0e5f75a9b041 Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:31 -0400 Subject: [PATCH 04/18] fix(satellites): decode Alpha-5 catalog numbers in the shared TLE module Catalog numbers above 99999 arrive in a TLE as Alpha-5: a letter A-Z, skipping I and O, stands for 10-33 in the ten-thousands place, so 100000 is A0000. satellite.js keeps the field as the string satrec.satnum, and the three catalog builders converted it with Number(), which gives NaN; the Set and Map used to deduplicate and store treat NaN as equal to NaN, so the first such satellite was stored under NaN and every later one was dropped as a duplicate (#751). The satellite tools read the same field with parseInt and reported no number, so such a satellite could not be found by it. The decoder lives in src/sources/tle.js, beside parseTleText, which the layer and the tools both import already: noradIdFromSatnum() for the three builders, and tleCatalogNumber() decodes through the same function. Every other field reads as it did before: Number() in the layer, parseInt or null in the tools. --- src/layers/satellites/catalog.js | 3 +- src/layers/satellites/ingestion.js | 3 +- src/layers/satellites/orbits.js | 4 +-- src/sources/tle.js | 44 ++++++++++++++++++++++++- src/tools/queries/space.test.mjs | 52 +++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..fd136c82d 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -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; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..14e464892 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,5 +1,6 @@ import { twoline2satrec } from 'satellite.js'; import * as Cesium from 'cesium'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -108,7 +109,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); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 32bb57cf9..0e4461e85 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -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), diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..f5fad48bd 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -21,8 +21,50 @@ export function parseTleText(text) { return result; } +/** + * The catalog number an Alpha-5 field stands for, or null for any other field. + * + * Since the catalog passed 99999 (July 2026) a TLE writes the numbers above it + * in Alpha-5: a letter A–Z, skipping I and O, stands for 10–33 in the + * ten-thousands place, so 100000 is "A0000" and 339999 is "Z9999". + */ +function alpha5CatalogNumber(field) { + const letter = field[0]; + if ( + letter >= 'A' && + letter <= 'Z' && + letter !== 'I' && + letter !== 'O' && + /^\d{4}$/.test(field.slice(1)) + ) { + let tens = letter.charCodeAt(0) - 65 + 10; + if (letter > 'I') tens -= 1; + if (letter > 'O') tens -= 1; + return tens * 10000 + Number(field.slice(1)); + } + return null; +} + +/** + * NORAD catalog number of a satrec as a number. + * + * 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. An Alpha-5 field decodes to its number; a + * five-digit field is unchanged; anything else stays NaN. + * @param {string} satnum + * @returns {number} + */ +export function noradIdFromSatnum(satnum) { + const field = String(satnum ?? '').trim(); + return alpha5CatalogNumber(field) ?? Number(field); +} + /** The NORAD catalog number from TLE line 1, or null. */ export function tleCatalogNumber(line1) { - const number = Number.parseInt(String(line1).slice(2, 7), 10); + const field = String(line1).slice(2, 7); + const alpha5 = alpha5CatalogNumber(field); + if (alpha5 !== null) return alpha5; + const number = Number.parseInt(field, 10); return Number.isInteger(number) ? number : null; } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index f6c2a8df8..5ff267ce1 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -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,51 @@ for (const missingLine of [1, 2]) { }); } +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('every other catalog field reads as it did before', () => { + assert.equal(noradIdFromSatnum('25544'), 25544); + assert.equal(noradIdFromSatnum('00404'), 404); + assert.equal(noradIdFromSatnum(''), 0); // as Number('') did + for (const field of ['I0000', 'O0000', 'a0404', 'A00']) { + assert.ok(Number.isNaN(noradIdFromSatnum(field)), field); + assert.equal(tleCatalogNumber(`1 ${field}U`), null, field); + } +}); + +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, From 6b70392f3fa8d0c984c8cbf693f2485263bc575d Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:32 -0400 Subject: [PATCH 05/18] docs: note Alpha-5 catalog numbers in the changelog and current state --- CHANGELOG.md | 8 ++++++++ docs/CURRENT-STATE.md | 1 + 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c885e2caa..213563788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -386,6 +386,14 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Read satellite catalog numbers above 99999. A TLE writes them in Alpha-5, + a letter for the first two digits (`A0000` is 100000), which the satellites + layer converted to `NaN`: every such satellite shared one catalog entry and + all but the first were dropped as duplicates. The satellite tools read the + same field as no number, so such a satellite could not be found by it. The + layer and the tools now decode the field in `src/sources/tle.js` + (Honorius Neogy, #767, fixes #751). + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2cc281812..15a4ec999 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4139,6 +4139,7 @@ silently demoting every later lookup for the session. - **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. +- **Catalog numbers above 99999** arrive in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. `src/sources/tle.js` decodes the field: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. A five-digit field reads as before; a field that is neither stays `NaN` in the layer and `null` in the tools. - **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash `fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration From f60eeacb2d76d2b8254cdc55708c6ab077419266 Mon Sep 17 00:00:00 2001 From: djabhi31 Date: Fri, 16 Oct 2026 19:12:46 +0530 Subject: [PATCH 06/18] test(earthquakes): add unit test coverage for earthquake snapshot normalization - Pin GeoJSON earthquake snapshot normalization contract in src/layers/earthquakes/records.test.mjs - Cover complete-feed validation, M2.5 magnitude filtering, coordinate and depth bounds - Verify stable fallback IDs, duplicate ID rejection, and malformed structure handling - Register records.test.mjs in scripts/format-scope.json and apply Prettier formatting --- scripts/format-scope.json | 1 + src/layers/earthquakes/records.test.mjs | 261 ++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/layers/earthquakes/records.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 34e12d6c2..bbc7960a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -722,6 +722,7 @@ "src/voice/session.test.mjs", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/layers/earthquakes/records.test.mjs", "src/sources/sourceSlot.js", "src/data/feedState.js", "src/app/localGeojsonServices.js", diff --git a/src/layers/earthquakes/records.test.mjs b/src/layers/earthquakes/records.test.mjs new file mode 100644 index 000000000..cd7b6a874 --- /dev/null +++ b/src/layers/earthquakes/records.test.mjs @@ -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); +}); From 0d8caaafd8c245dda6524ce4aadfb62bc1308203 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 15:24:08 -0400 Subject: [PATCH 07/18] fix(launches): keep null pad and landing coordinates unknown instead of 0 Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ++++ src/data/rocketLaunches.test.mjs | 25 +++++++++++++++++++++++++ src/layers/launches/model.js | 17 ++++++++--------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee00d839..38c4f31ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Launch Library records with a null or blank pad latitude/longitude, landing + position, downrange distance or stage flight number now keep those values + unknown instead of reading them as 0, so a launch is no longer placed at + 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index a1a3fab44..ce2ad6c2a 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,6 +676,31 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); +test('null or blank pad and landing 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), + [], + ); + } + // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. + 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); + // An undisclosed landing keeps position, distance and flight number unknown. + const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 }, + rocket: { launcher_stage: [{ launcher_flight_number: null, + landing: { attempt: true, 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', diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 0a9dd67ea..5697e07e8 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -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( From d3e11bfbcaa7656fdd7db5b607c152537b4cd4bd Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:19:12 -0400 Subject: [PATCH 08/18] test(launches): cover null top-level landing coordinates Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ---- changelog.d/1186-launch-unknown-coordinates.md | 4 ++++ src/data/rocketLaunches.test.mjs | 14 ++++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1186-launch-unknown-coordinates.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c4f31ef..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Launch Library records with a null or blank pad latitude/longitude, landing - position, downrange distance or stage flight number now keep those values - unknown instead of reading them as 0, so a launch is no longer placed at - 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md new file mode 100644 index 000000000..d03c2d902 --- /dev/null +++ b/changelog.d/1186-launch-unknown-coordinates.md @@ -0,0 +1,4 @@ +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index ce2ad6c2a..0d1eb8956 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,7 +676,7 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); -test('null or blank pad and landing coordinates stay unknown instead of becoming 0', () => { +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( @@ -684,15 +684,21 @@ test('null or blank pad and landing coordinates stay unknown instead of becoming [], ); } - // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. +}); + +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); - // An undisclosed landing keeps position, distance and flight number unknown. +}); + +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, downrange_distance: 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); From 4690a5bea97f2969c77fb35219ceed9af7d02c48 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:20:07 -0400 Subject: [PATCH 09/18] docs(launches): describe unknown-coordinate and flight labels Signed-off-by: Rudy Celekli --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..105fdd37f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -791,6 +791,11 @@ and `./layers/launches`. Each owns its catalog, render state, tracking, interact and teardown. Source adapters retain the existing CelesTrak and Launch Library endpoints; standalone entries provide scene services and the satellite dependency. The mission factory separates paths, replay, camera, panel, overlays and placement. +Launch Library pad coordinates retain unknown values: missing fields use the +reported location coordinates when available, and launches without a known pad +position stay off the map. Genuine zero coordinates remain valid. Undisclosed +stage coordinates, downrange distance and flight numbers remain unknown; the +mission panel omits the flight-number label when no number was reported. Pending mission requests abort on disable/destruction and malformed launch snapshots preserve the last accepted display. Catalog groups, propagation cadence, tracking intent, replay timing, controls and source attribution remain unchanged. From 40442ab85444d526588b59d67764b796404c1ebb Mon Sep 17 00:00:00 2001 From: VG31OP Date: Sat, 17 Oct 2026 17:01:13 +0530 Subject: [PATCH 10/18] style: fix whitespace formatting in tle.js and space.test.mjs --- src/sources/tle.js | 1 - src/tools/queries/space.test.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/sources/tle.js b/src/sources/tle.js index feeb2f94b..e46266244 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -60,4 +60,3 @@ export function tleCatalogNumber(line) { const number = Number.parseInt(field, 10); return Number.isInteger(number) ? number : null; } - diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index ad7287004..dcde81cba 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -85,7 +85,6 @@ SAT ALPHA5 assert.equal(tleCatalogNumber(entries[0].line2), 100000); }); - test('the next pass defaults to the ISS and reports times, peak and direction', async () => { const catalog = composeCatalog({ tools: coreTools, From 243e84150c50fa68d30cddd17169c6d9a2a81d18 Mon Sep 17 00:00:00 2001 From: wasif Date: Sat, 17 Oct 2026 19:53:02 +0400 Subject: [PATCH 11/18] docs: list every npm alias for the qa gates CONTRIBUTING.md said "Only a couple (`qa:map-source-tray`, `qa:transit`) have an `npm run` alias". package.json defines five: `qa:map-source-tray`, `qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and `qa:transit`. The two missing names are not incidental -- .github/workflows/ci.yml already invokes `npm run qa:street-level:fixtures` and `npm run qa:panel-resize`, so a contributor reading the guide is told to reach for `node scripts/.mjs` for gates the project itself runs through npm. Verified against package.json scripts and .github/workflows/ci.yml on main (1df2a27). --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d6b968db..01a9ac374 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,8 +54,9 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i Runnable gate entrypoints document what they assert and how to invoke them. Most need a dev server; some also need a specific provider key or port. Follow the entrypoint's header rather than assuming every matching file accepts the -same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an -`npm run` alias. +same arguments. Five have an `npm run` alias: `qa:map-source-tray`, +`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and +`qa:transit`. If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md` for the feature: it names the gate for many of them, and it's the authoritative From 7828f6903852c370d1d0cf37af56157fdfa9aa74 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 11:30:38 -0700 Subject: [PATCH 12/18] Name the two QA gates CI runs The CI callout said no qa-*.mjs gate runs in CI; the workflow runs the Street Level fixtures and panel-resize gates against the built preview. --- CONTRIBUTING.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01a9ac374..ba0d38768 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,12 +64,13 @@ runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader release-candidate checks and selected harnesses; it does not replace the focused gate for the feature you changed. -> **CI does not cover this for you.** The workflow runs the setup policy +> **CI covers only two of these for you.** The workflow runs the setup policy > checks, the formatting and package-boundary checks, the unit suite and the -> production build, plus a Windows onboarding job. It runs neither -> `npm run test:track` nor any `qa-*.mjs` gate — both need a live dev server -> and a browser. Include the applicable local run in your PR's validation -> evidence. +> production build, plus a Windows onboarding job, and against the built preview +> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run +> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev +> server and a browser. Include the applicable local run in your PR's +> validation evidence. ## Share-link layer tokens From 761953b0bf49907132406c1add8a95bd842daeda Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 11:30:46 -0700 Subject: [PATCH 13/18] Correct the remaining permanent-cache comments The adsbdb proxy keeps successful and 404 answers for 24 hours; two policy comments and the provider's doc comment still said forever. --- server/providers/aircraft/enrichment.js | 5 +++-- src/layers/flights/policy.js | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index 9b7c1b1bb..4c069fa7f 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -7,8 +7,9 @@ const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024; /** * adsbdb.com enrichment proxy: callsign → route (airline + origin/destination * airports) and hex → aircraft type/registration. Free community API — cached - * aggressively: ONE upstream request per new key ever (404s negative-cached), - * persisted to disk so restarts don't re-hammer it. Adapted from skylight + * aggressively: successes and 404s are kept per key for 24 hours (TTL_MS) and + * persisted to disk so restarts don't re-hammer it; other failures are not + * cached. Adapted from skylight * (MIT) server/src/enrich/routes.ts. */ export function adsbdbProxy() { diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 40cf8879a..2c015e6c9 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -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). */ From 7ec84ac316a4976ebffa2891fe69d5fe3cdbeede Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 11:40:30 -0700 Subject: [PATCH 14/18] Decode catalog numbers strictly and add the changelog fragment Route tleCatalogNumber() and noradIdFromSatnum() through one decoder that accepts only plain digits or a valid Alpha-5 field. parseInt and Number() read '2554A', '255 4', '-1234', '0x1F0', '1.5' and blank as numbers; they now return null/NaN. Digit satnums of any length still decode, so OMM satrecs numbered past 99999 keep working. Add a layer test that several Alpha-5 satellites survive ingestion dedupe, the changelog fragment for #767, and note in CURRENT-STATE that this is Alpha-5 TLE compatibility and malformed fields are rejected. --- changelog.d/767-alpha5-catalog-numbers.md | 11 ++++ docs/CURRENT-STATE.md | 2 +- src/layers/satellites/ingestion.test.mjs | 80 +++++++++++++++++++++++ src/sources/tle.js | 53 +++++++-------- src/tools/queries/space.test.mjs | 35 ++++++++-- 5 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 changelog.d/767-alpha5-catalog-numbers.md create mode 100644 src/layers/satellites/ingestion.test.mjs diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md new file mode 100644 index 000000000..8a96698a1 --- /dev/null +++ b/changelog.d/767-alpha5-catalog-numbers.md @@ -0,0 +1,11 @@ +- Read satellite catalog numbers written in Alpha-5, the TLE spelling of + numbers above 99999: a letter for the first two digits, skipping I and O + (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a + field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all + but the first were dropped as duplicates; the satellite tools read the same + field as no number, so such a satellite could not be found by it. The layer + and the tools now decode the field in `src/sources/tle.js`, and reject a + malformed one (a sign, decimal point, hex, embedded space or stray letter) + instead of reading part of it as a number. This is Alpha-5 TLE + compatibility: CelesTrak's TLE feed carries no satellite above 99999, which + needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 82d93b458..dad629b7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4324,7 +4324,7 @@ silently demoting every later lookup for the session. - **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. -- **Catalog numbers above 99999** arrive in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. `src/sources/tle.js` decodes the field: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. A five-digit field reads as before; a field that is neither stays `NaN` in the layer and `null` in the tools. +- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools. - **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash `fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs new file mode 100644 index 000000000..313d999ee --- /dev/null +++ b/src/layers/satellites/ingestion.test.mjs @@ -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); +}); diff --git a/src/sources/tle.js b/src/sources/tle.js index f5fad48bd..f2b2ca3d7 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -21,50 +21,47 @@ export function parseTleText(text) { return result; } +// 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 an Alpha-5 field stands for, or null for any other field. + * The catalog number a catalog field stands for, or null for any other field. * - * Since the catalog passed 99999 (July 2026) a TLE writes the numbers above it - * in Alpha-5: a letter A–Z, skipping I and O, stands for 10–33 in the - * ten-thousands place, so 100000 is "A0000" and 339999 is "Z9999". + * 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 alpha5CatalogNumber(field) { +function decodeCatalogField(field) { + if (DIGITS.test(field)) return Number(field); + if (!ALPHA5.test(field)) return null; const letter = field[0]; - if ( - letter >= 'A' && - letter <= 'Z' && - letter !== 'I' && - letter !== 'O' && - /^\d{4}$/.test(field.slice(1)) - ) { - let tens = letter.charCodeAt(0) - 65 + 10; - if (letter > 'I') tens -= 1; - if (letter > 'O') tens -= 1; - return tens * 10000 + Number(field.slice(1)); - } - return null; + 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. + * 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. An Alpha-5 field decodes to its number; a - * five-digit field is unchanged; anything else stays NaN. + * 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) { - const field = String(satnum ?? '').trim(); - return alpha5CatalogNumber(field) ?? Number(field); + return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN; } -/** The NORAD catalog number from TLE line 1, or null. */ +/** The NORAD catalog number from TLE line 1 (columns 3–7), or null. */ export function tleCatalogNumber(line1) { const field = String(line1).slice(2, 7); - const alpha5 = alpha5CatalogNumber(field); - if (alpha5 !== null) return alpha5; - const number = Number.parseInt(field, 10); - return Number.isInteger(number) ? number : null; + return decodeCatalogField(field.replace(/^ +/, '')); } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 5ff267ce1..d0f2ffca8 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -72,14 +72,39 @@ test('Alpha-5 catalog numbers decode, skipping I and O', () => { } }); -test('every other catalog field reads as it did before', () => { +test('a digit catalog field reads as it did before', () => { assert.equal(noradIdFromSatnum('25544'), 25544); assert.equal(noradIdFromSatnum('00404'), 404); - assert.equal(noradIdFromSatnum(''), 0); // as Number('') did - for (const field of ['I0000', 'O0000', 'a0404', 'A00']) { - assert.ok(Number.isNaN(noradIdFromSatnum(field)), field); - assert.equal(tleCatalogNumber(`1 ${field}U`), null, field); + 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 () => { From eb10f7ab0cdd7bb4ecd48acaf63db4cb8aeb2cb2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 11:43:23 -0700 Subject: [PATCH 15/18] Add the changelog fragment and document the shared distance contract --- .../287-shared-great-circle-distance.md | 5 +++++ docs/CURRENT-STATE.md | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 changelog.d/287-shared-great-circle-distance.md diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md new file mode 100644 index 000000000..c2db9d54d --- /dev/null +++ b/changelog.d/287-shared-great-circle-distance.md @@ -0,0 +1,5 @@ +- The client's great-circle distances now come from one shared + `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` + points). The Earth radius is standardized on 6371.0088 km, so distances shift + by about 1.4 ppm and region areas by about 2.8 ppm, and missing or + out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 967fdfc32..3b69fb11d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,12 +1,19 @@ # God's Eye View Current State -Shared distance checks use `geoDistance.js` with `{ lat, lon }` points and the -IUGG mean spherical Earth radius. `greatCircleKm` is canonical; -`greatCircleMeters` is its exact metre conversion. Missing, non-finite, and -out-of-range points return `Infinity`, so threshold and nearest-item consumers -exclude invalid positions. Older exported `haversineKm` entry points remain as -compatibility wrappers only. Intentional local angular, projection, ellipsoidal -prefilter, and small-viewport approximations remain separate. +Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and +the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is +canonical; `greatCircleMeters` is its exact metre conversion. Missing, +non-finite, and out-of-range points (latitude outside ±90, longitude outside +±180) return `Infinity`, so threshold and nearest-item consumers exclude invalid +positions. Older exported `haversineKm` entry points remain as compatibility +wrappers only. `src/data/approxDistance.js`, the equirectangular +viewer-proximity approximation, is deliberately separate, as are intentional +local angular, projection, ellipsoidal prefilter, and small-viewport +approximations. Four great-circle copies remain for a follow-up: +`src/data/adminBoundaries.js` and `src/tools/area.js` pass unwrapped longitudes +across the antimeridian, so they cannot use the strict ±180 check as-is, and +`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own +implementations. Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message From 2f4b84600b94cea1c8387c8814c79da3c6b1193d Mon Sep 17 00:00:00 2001 From: Nurstar <127499386+NurstarK@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:13:47 +0200 Subject: [PATCH 16/18] fix(deps): bump dompurify, sharp and source-map-js for disclosed advisories (#1094) Update DOMPurify 3.4.15 -> 3.4.17, Sharp 0.35.4 -> 0.35.5 (with its @img platform packages) and source-map-js 1.2.1 -> 1.2.2 in package-lock.json for GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and GHSA-68fv-2mgg-jv7q. npm audit reports no vulnerabilities. --- .../1094-dependency-security-patches.md | 4 + docs/CURRENT-STATE.md | 6 +- package-lock.json | 248 +++++++++--------- 3 files changed, 131 insertions(+), 127 deletions(-) create mode 100644 changelog.d/1094-dependency-security-patches.md diff --git a/changelog.d/1094-dependency-security-patches.md b/changelog.d/1094-dependency-security-patches.md new file mode 100644 index 000000000..058359efc --- /dev/null +++ b/changelog.d/1094-dependency-security-patches.md @@ -0,0 +1,4 @@ +- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for + GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and + GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, + #1094). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 105fdd37f..b23f2f011 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5459,9 +5459,9 @@ When runtime behavior or architecture changes, update this file in the same chan ## Dependency security baseline -The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and -nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0; -image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous +The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28, +source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA +uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain 24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree. diff --git a/package-lock.json b/package-lock.json index e89fd9c65..7b6d3476f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -548,9 +548,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -567,13 +567,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -590,13 +590,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -604,7 +604,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -614,9 +614,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -631,9 +631,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -648,9 +648,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -665,9 +665,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -682,9 +682,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -699,9 +699,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -716,9 +716,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -733,9 +733,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -750,9 +750,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -767,9 +767,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -784,9 +784,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -803,13 +803,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -826,13 +826,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -849,13 +849,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -872,13 +872,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -895,13 +895,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -918,13 +918,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -941,13 +941,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -964,13 +964,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -985,9 +985,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -995,7 +995,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1005,9 +1005,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1025,9 +1025,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1045,9 +1045,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1833,9 +1833,9 @@ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "node_modules/dompurify": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", - "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2782,9 +2782,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2799,31 +2799,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -2832,9 +2832,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { From 743ed639e56a037af10385eec01571d966f31205 Mon Sep 17 00:00:00 2001 From: monwiss <547445+monwiss@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:18:32 -0400 Subject: [PATCH 17/18] fix(dev): stop the dev server watching .gev-cache and .gev-logs (#761) Providers write cache files and logs into .gev-cache and .gev-logs while the app runs. A serve-only standalone plugin adds both to server.watch.ignored, so the dev watcher no longer stats them; source changes still reload. --- changelog.d/761-dev-unwatched-runtime-dirs.md | 5 ++++ server/standalone/unwatched-cache.js | 28 +++++++++++++++++++ server/standalone/vite.config.js | 2 ++ src/tooling/viteBuild.test.mjs | 25 ++++++++++++++--- 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 changelog.d/761-dev-unwatched-runtime-dirs.md create mode 100644 server/standalone/unwatched-cache.js diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md new file mode 100644 index 000000000..d3aaf0a91 --- /dev/null +++ b/changelog.d/761-dev-unwatched-runtime-dirs.md @@ -0,0 +1,5 @@ +- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write + cache files and logs there while the app runs, and none of it is source, so + the watcher now skips it; this guards against the DNS-lookup delays heavy + cache writes have caused, though no difference was measured on current main + (monwiss, #761). diff --git a/server/standalone/unwatched-cache.js b/server/standalone/unwatched-cache.js new file mode 100644 index 000000000..130d0ece4 --- /dev/null +++ b/server/standalone/unwatched-cache.js @@ -0,0 +1,28 @@ +/** + * Directories the app writes to while it runs. None is source, and none + * should reload anything. + */ +const RUNTIME_WRITE_GLOBS = Object.freeze([ + '**/.gev-cache/**', + '**/.gev-logs/**', +]); + +/** + * Keep the directories the app writes to out of the dev server's file watcher. + * + * Disk-cached providers write a file per response into `.gev-cache`, and the + * voice debug log appends to `.gev-logs`. Left watched, each write costs the + * watcher stat calls on libuv's thread pool, which `getaddrinfo` shares; heavy + * cache writes have been seen to delay upstream DNS lookups. + * + * @returns {import('vite').Plugin} Config-only plugin; no server hooks. + */ +export function unwatchedRuntimeDirsPlugin() { + return { + name: 'gev-unwatched-runtime-dirs', + apply: 'serve', + config: () => ({ + server: { watch: { ignored: [...RUNTIME_WRITE_GLOBS] } }, + }), + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index a14292597..03a935092 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -6,6 +6,7 @@ import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; import { standaloneVoiceTools } from './voiceTools.js'; +import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -20,6 +21,7 @@ export default defineConfig(({ command, mode }) => { ...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }), localMcpPlugin(), apiNotFoundPlugin(), + unwatchedRuntimeDirsPlugin(), ], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 86a9afb63..9e38a0295 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -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); From df5ad4ca4a9e1606334ae787bfddc3ddddf7b36b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 13:46:00 -0700 Subject: [PATCH 18/18] Pair TLE lines through the shared decoder and add the changelog fragment --- changelog.d/1101-tle-catalog-number-match.md | 3 +++ docs/CURRENT-STATE.md | 5 ++++- src/tools/queries/space.test.mjs | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1101-tle-catalog-number-match.md diff --git a/changelog.d/1101-tle-catalog-number-match.md b/changelog.d/1101-tle-catalog-number-match.md new file mode 100644 index 000000000..d1e2aa407 --- /dev/null +++ b/changelog.d/1101-tle-catalog-number-match.md @@ -0,0 +1,3 @@ +- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a + spliced catalog cannot pair one satellite's elements with another's; the next + valid set still parses (VG31OP, #1101). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 458c44c6e..6d066a475 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1474,7 +1474,10 @@ access. Callers retain validation, transport and response policy. The shared TLE text parser skips incomplete named sets and resumes at the next complete set. A missing orbital line does not discard the later satellites in -the catalog; satellite layers and next-pass tools use the same parser. +the catalog. A set whose two lines carry different catalog numbers, or a +malformed catalog number, is skipped the same way, so a spliced catalog cannot +pair one satellite's elements with another's. Satellite layers and next-pass +tools use the same parser. ## Build configuration and local provider boundaries diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 5b0fa5837..0eb5ce346 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -90,6 +90,21 @@ SAT ALPHA5 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],