Merge remote-tracking branch 'origin/main' into feat/tools-area-breakdowns

This commit is contained in:
quanticsoul4772 committed 2026-10-17 14:32:52 -07:00
commit 661e1d7fa3
44 files changed
+1069 -360

No files matched your search

+2 -10
View File
@@ -1,6 +1,7 @@
import { isUnavailableCapability } from '../sources/capability.js';
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from '../geoDistance.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -1567,16 +1568,7 @@ async function fetchRoute(coordPairs, mode, signal, service) {
}
function greatCircleM(a, b) {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleMeters(a, b);
}
function formatDistance(m) {
+3 -8
View File
@@ -20,6 +20,8 @@ import { t, tn } from '../i18n/index.js';
* `drawTool.js`.
*/
import { greatCircleMeters } from '../geoDistance.js';
export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']);
export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 });
/**
@@ -49,14 +51,7 @@ export function createDrawSession(shape = 'area') {
/** Great-circle distance in metres between two {lon, lat} points. */
export function greatCircleM(a, b) {
const R = 6371000;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
return greatCircleMeters(a, b);
}
/**
+4 -17
View File
@@ -1,5 +1,6 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleMeters } from './geoDistance.js';
import {
deriveWeatherEffectProfile,
weatherAltitudeFactors,
@@ -127,23 +128,9 @@ function compileShader(gl, type, source) {
}
function greatCircleM(a, b) {
if (
![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every(
Number.isFinite,
)
)
return Infinity;
const latitudeA = Cesium.Math.toRadians(a.latitude);
const latitudeB = Cesium.Math.toRadians(b.latitude);
const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude);
const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitudeA) *
Math.cos(latitudeB) *
Math.sin(longitudeDelta / 2) ** 2;
return (
6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine))
return greatCircleMeters(
{ lat: a?.latitude, lon: a?.longitude },
{ lat: b?.latitude, lon: b?.longitude },
);
}
+3 -10
View File
@@ -27,6 +27,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
/**
* Layers the engine understands, with the fields queries may reference,
@@ -98,17 +99,9 @@ function toEpochMs(value) {
return Date.parse(text);
}
const EARTH_R_KM = 6371;
/** Great-circle distance in km. */
/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */
export function haversineKm(lat1, lon1, lat2, lon2) {
const d2r = Math.PI / 180;
const dLat = (lat2 - lat1) * d2r;
const dLon = (lon2 - lon1) * d2r;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a)));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
+5 -11
View File
@@ -18,8 +18,8 @@
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js';
const EARTH_RADIUS_KM = 6371;
const toRad = (d) => (d * Math.PI) / 180;
/** Spherical-excess ring area (km²) — same family as turf/geojson-area. */
@@ -36,15 +36,6 @@ function ringAreaKm2(ring) {
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
function haversineKm(lon1, lat1, lon2, lat2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* Normalize a query/name for matching: lowercase, strip diacritics and
* punctuation, collapse whitespace, strip a leading "the ".
@@ -157,7 +148,10 @@ function buildEntries(pack, kind) {
polygons,
areaKm2,
bbox: [minLon, minLat, maxLon, maxLat],
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
bboxDiagonalKm: greatCircleKm(
{ lat: minLat, lon: minLon },
{ lat: maxLat, lon: maxLon },
),
});
}
return out;
+11
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { statSync, readFileSync } from 'node:fs';
import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js';
import { greatCircleKm } from '../geoDistance.js';
const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url);
@@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)'
assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000,
`Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`);
assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km');
const coordinates = rockies.polygons.flat();
const minLon = Math.min(...coordinates.map(([lon]) => lon));
const minLat = Math.min(...coordinates.map(([, lat]) => lat));
const maxLon = Math.max(...coordinates.map(([lon]) => lon));
const maxLat = Math.max(...coordinates.map(([, lat]) => lat));
assert.equal(
rockies.bboxDiagonalKm,
greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }),
'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points',
);
});
test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => {
+6 -15
View File
@@ -1,3 +1,5 @@
import { greatCircleMeters } from '../geoDistance.js';
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
@@ -153,19 +155,8 @@ export function weatherCodeLabel(code) {
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (
![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(
Number.isFinite,
)
) {
return Infinity;
}
const phi1 = (from.latitude * Math.PI) / 180;
const phi2 = (to.latitude * Math.PI) / 180;
const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180;
const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) ** 2 +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleMeters(
{ lat: from?.latitude, lon: from?.longitude },
{ lat: to?.latitude, lon: to?.longitude },
);
}
+31
View File
@@ -677,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
assert.equal(launches[0].lon, -120.611);
});
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
for (const blank of [null, '', ' ', false]) {
assert.deepEqual(
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
[],
);
}
});
test('null pad coordinates use the location fallback instead of 0,0', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
assert.equal(fallback.lat, 28.6);
assert.equal(fallback.lon, -80.6);
});
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
const base = { net: '2026-07-20T10:00:00Z' };
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
rocket: { launcher_stage: [{ launcher_flight_number: null,
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
const [stage] = launch.recoveryStages;
assert.equal(stage.lat, null);
assert.equal(stage.lon, null);
assert.equal(stage.downrangeKm, null);
assert.equal(stage.flightNumber, null);
});
test('normalizes detailed payload and stage recovery records', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'recovery-details',
+12 -28
View File
@@ -10,19 +10,9 @@
* low → its destination should be nearby).
*/
const D2R = Math.PI / 180;
const R_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
/** Haversine great-circle distance in km. */
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
const p2 = lat2 * D2R;
const dp = (lat2 - lat1) * D2R;
const dl = (lon2 - lon1) * D2R;
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const D2R = Math.PI / 180;
function bearingRad(lat1, lon1, lat2, lon2) {
const p1 = lat1 * D2R;
@@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) {
/** Signed cross-track distance (km) of point from the great circle p1→p2. */
export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM;
const d13 =
greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM;
const b13 = bearingRad(lat1, lon1, lat, lon);
const b12 = bearingRad(lat1, lon1, lat2, lon2);
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM;
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
}
const NEAR_ENDPOINT_KM = 130;
@@ -65,14 +56,14 @@ export function routePlausible({
origin = null,
destination = null,
}) {
const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon);
const haveD =
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
const position = { lat: latDeg, lon: lonDeg };
if (!isGeoPoint(position)) return true;
const haveO = isGeoPoint(origin);
const haveD = isGeoPoint(destination);
if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide
// (a) Geographic consistency: near an endpoint, or roughly on the path.
const near = (pt) =>
greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM;
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
if (!geomOk && haveO && haveD) {
geomOk =
@@ -99,17 +90,10 @@ export function routePlausible({
Math.abs(verticalRateMps) > VERT_TREND_MPS
) {
if (verticalRateMps > 0) {
if (
haveO &&
greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM
)
if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM)
return false; // departing — origin should be local
} else {
if (
haveD &&
greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) >
LOCAL_AIRPORT_KM
)
if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM)
return false; // arriving — destination should be local
}
}
+7 -2
View File
@@ -1,14 +1,15 @@
// src/data/routePlausible.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { routePlausible, greatCircleKm } from './routePlausible.js';
import { greatCircleKm } from '../geoDistance.js';
import { routePlausible } from './routePlausible.js';
const SFO = { lat: 37.6188, lon: -122.3754 };
const LAX = { lat: 33.9416, lon: -118.4085 };
const JFK = { lat: 40.6413, lon: -73.7781 };
test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => {
const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon);
const d = greatCircleKm(SFO, LAX);
assert.ok(Math.abs(d - 543) < 15, `got ${d}`);
});
@@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => {
latDeg: 51.5, lonDeg: -0.12,
origin: { lat: null, lon: null }, destination: null,
}), true);
assert.equal(routePlausible({
latDeg: 95, lonDeg: 0,
origin: SFO, destination: LAX,
}), true);
});
+6 -23
View File
@@ -13,31 +13,11 @@
* @module data/trafficBounds
*/
/** @const {number} Mean Earth radius in km (spherical approximation). */
const EARTH_RADIUS_KM = 6371;
import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js';
const toRad = (deg) => (deg * Math.PI) / 180;
const toDeg = (rad) => (rad * 180) / Math.PI;
/**
* Great-circle distance between two lat/lon points (haversine).
*
* @param {number} lat1 - First point latitude (degrees).
* @param {number} lon1 - First point longitude (degrees).
* @param {number} lat2 - Second point latitude (degrees).
* @param {number} lon2 - Second point longitude (degrees).
* @returns {number} Distance in kilometres.
*/
export function greatCircleKm(lat1, lon1, lat2, lon2) {
const p1 = toRad(lat1);
const p2 = toRad(lat2);
const dp = toRad(lat2 - lat1);
const dl = toRad(lon2 - lon1);
const a =
Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Initial bearing (radians) from point 1 toward point 2 along the great circle.
*
@@ -111,10 +91,13 @@ export function deriveFetchCenter({
hitLon,
maxPullKm = 12,
}) {
if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) {
if (!isGeoPoint({ lat: hitLat, lon: hitLon })) {
return { lat: nadirLat, lon: nadirLon, source: 'nadir' };
}
const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon);
const distKm = greatCircleKm(
{ lat: nadirLat, lon: nadirLon },
{ lat: hitLat, lon: hitLon },
);
if (distKm <= maxPullKm) {
return { lat: hitLat, lon: hitLon, source: 'hit' };
}
+7 -7
View File
@@ -1,8 +1,8 @@
// src/data/trafficBounds.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { greatCircleKm } from '../geoDistance.js';
import {
greatCircleKm,
deriveFetchCenter,
clampBoundsAroundCenter,
} from './trafficBounds.js';
@@ -11,7 +11,7 @@ import {
const NADIR = { lat: 30.2672, lon: -97.7431 };
test('greatCircleKm sanity: Austin -> ~5 km north', () => {
const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon);
const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon });
assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`);
});
@@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => {
test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => {
// Hit ~100 km due east of nadir.
const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 };
assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit');
assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit');
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat: hit.lat, hitLon: hit.lon,
maxPullKm: 12,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`);
// Due-east bearing: latitude stays ~constant, longitude moves east but well
// short of the hit.
@@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => {
maxPullKm: 5,
});
assert.equal(c.source, 'pulled');
const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon);
const d = greatCircleKm(NADIR, c);
assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`);
});
test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) {
test('invalid ground hit falls back to the camera nadir', () => {
for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) {
const c = deriveFetchCenter({
nadirLat: NADIR.lat, nadirLon: NADIR.lon,
hitLat, hitLon,
+5 -8
View File
@@ -1,3 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
/**
* @module transitFeeds
* @description Registry of keyless, openly licensed GTFS-Realtime
@@ -773,7 +775,8 @@ export function getRegisteredTransitFeed(id) {
}
/**
* Great-circle distance in km.
* Great-circle distance in km. Retained for compatibility with existing
* transit consumers; new shared spatial code should use `greatCircleKm`.
* @param {number} aLat
* @param {number} aLon
* @param {number} bLat
@@ -781,13 +784,7 @@ export function getRegisteredTransitFeed(id) {
* @returns {number}
*/
export function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (deg) => (deg * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
}
/**
+70
View File
@@ -0,0 +1,70 @@
/**
* Canonical geographic point used by shared spatial utilities.
*
* @typedef {{lat: number, lon: number}} GeoPoint
*/
/** IUGG mean spherical Earth radius, in kilometres. */
export const EARTH_RADIUS_KM = 6371.0088;
const DEGREES_TO_RADIANS = Math.PI / 180;
/**
* Whether a value is a canonical, finite WGS84-style point in degrees.
* Longitudes are not implicitly wrapped because an out-of-range value usually
* indicates a broken coordinate contract at the caller.
*
* @param {*} point
* @returns {point is GeoPoint}
*/
export function isGeoPoint(point) {
return (
Number.isFinite(point?.lat) &&
point.lat >= -90 &&
point.lat <= 90 &&
Number.isFinite(point?.lon) &&
point.lon >= -180 &&
point.lon <= 180
);
}
/**
* Great-circle distance in kilometres between two `{ lat, lon }` points.
*
* `{ lat, lon }` is the canonical shared coordinate representation. Invalid,
* missing, non-finite, or out-of-range coordinates return `Infinity`, which
* keeps invalid points out of threshold and nearest-neighbour selections.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleKm(from, to) {
if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY;
const latitude1 = from.lat * DEGREES_TO_RADIANS;
const latitude2 = to.lat * DEGREES_TO_RADIANS;
const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS;
const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS;
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(latitude1) *
Math.cos(latitude2) *
Math.sin(longitudeDelta / 2) ** 2;
const bounded = Math.min(1, Math.max(0, haversine));
return (
2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded))
);
}
/**
* Great-circle distance in metres, with the same point and invalid-input
* contract as {@link greatCircleKm}.
*
* @param {GeoPoint} from
* @param {GeoPoint} to
* @returns {number}
*/
export function greatCircleMeters(from, to) {
return greatCircleKm(from, to) * 1000;
}
+78
View File
@@ -0,0 +1,78 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
EARTH_RADIUS_KM,
greatCircleKm,
greatCircleMeters,
isGeoPoint,
} from './geoDistance.js';
const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 });
const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 });
test('great-circle identity and symmetry', () => {
assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0);
assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN));
});
test('great-circle one-degree calibration and canonical units', () => {
const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
assert.ok(Math.abs(kilometres - 111.195) < 0.001);
assert.equal(
greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }),
kilometres * 1000,
);
});
test('great-circle handles dateline crossing and antipodal points', () => {
const acrossDateline = greatCircleKm(
{ lat: 0, lon: 179 },
{ lat: 0, lon: -179 },
);
assert.ok(Math.abs(acrossDateline - 222.39) < 0.01);
assert.ok(
Math.abs(
greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) -
Math.PI * EARTH_RADIUS_KM,
) < 1e-9,
);
});
test('canonical points require explicit latitude and longitude keys', () => {
assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true);
assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false);
assert.equal(isGeoPoint([10, 80]), false);
assert.equal(
greatCircleKm(
{ latitude: 10, longitude: 80 },
{ latitude: 20, longitude: 70 },
),
Infinity,
);
});
test('latitude-before-longitude regression rejects a swapped Austin point', () => {
assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON)));
assert.equal(
greatCircleKm(
{ lat: AUSTIN.lon, lon: AUSTIN.lat },
{ lat: HOUSTON.lon, lon: HOUSTON.lat },
),
Infinity,
);
});
test('invalid, missing, non-finite, and out-of-range points return Infinity', () => {
for (const point of [
null,
{},
{ lat: 0 },
{ lat: NaN, lon: 0 },
{ lat: 0, lon: Infinity },
{ lat: 90.0001, lon: 0 },
{ lat: 0, lon: -180.0001 },
]) {
assert.equal(greatCircleKm(point, AUSTIN), Infinity);
assert.equal(greatCircleMeters(AUSTIN, point), Infinity);
}
});
+2 -7
View File
@@ -15,6 +15,7 @@ import { applicationServices } from './services/application.js';
*/
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js';
@@ -683,13 +684,7 @@ export class IntelHUD {
* @returns {number} Distance in kilometers.
*/
_haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (deg) => Cesium.Math.toRadians(deg);
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
+2 -9
View File
@@ -5,6 +5,7 @@ import {
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
import { greatCircleKm } from '../../geoDistance.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
@@ -28,15 +29,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/
function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (value) => (value * Math.PI) / 180;
const dLat = toRad(bLat - aLat);
const dLon = toRad(bLon - aLon);
const p1 = toRad(aLat);
const p2 = toRad(bLat);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon });
}
/**
+2 -6
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { greatCircleKm } from '../../geoDistance.js';
import { staticFrameRefreshMs } from '../../data/cctvLod.js';
import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js';
import { DEFAULT_CAMERA_CALIBRATION } from './policy.js';
@@ -309,12 +310,7 @@ export function createModel({ state: layerState, services, parts, source }) {
*/
function haversineKm(lat1, lon1, lat2, lon2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
+261
View File
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeEarthquakeSnapshot } from './records.js';
test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => {
const geojson = {
type: 'FeatureCollection',
features: [
{
id: 'us7000m123',
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-122.4194, 37.7749, 10.5],
},
properties: {
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
},
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(Array.isArray(rows), true);
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
stableId: 'us7000m123',
usgsId: 'us7000m123',
lon: -122.4194,
lat: 37.7749,
depthKm: 10.5,
mag: 4.2,
place: 'San Francisco Bay Area, CA',
time: 1711929600000,
});
});
test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => {
const geojson = {
features: [
{
id: null,
geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] },
properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 },
},
{
id: '',
geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] },
properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'event-1');
assert.equal(rows[0].usgsId, null);
assert.equal(rows[1].stableId, 'event-2');
assert.equal(rows[1].usgsId, '');
});
test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => {
const geojson = {
features: [
{
id: 'ev-sub',
geometry: { type: 'Point', coordinates: [10, 20, 5] },
properties: { mag: 2.49, place: 'Minor tremor' },
},
{
id: 'ev-null-mag',
geometry: { type: 'Point', coordinates: [11, 21, 5] },
properties: { mag: null, place: 'Uncalculated' },
},
{
id: 'ev-threshold',
geometry: { type: 'Point', coordinates: [12, 22, 5] },
properties: { mag: 2.5, place: 'Threshold event' },
},
{
id: 'ev-strong',
geometry: { type: 'Point', coordinates: [13, 23, 5] },
properties: { mag: 6.8, place: 'Major event' },
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 2);
assert.equal(rows[0].stableId, 'ev-threshold');
assert.equal(rows[0].mag, 2.5);
assert.equal(rows[1].stableId, 'ev-strong');
assert.equal(rows[1].mag, 6.8);
});
test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => {
const geojson = {
features: [
{
id: 'ev-sparse',
geometry: { coordinates: [-100, 40] }, // depth omitted
properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number
},
],
};
const rows = normalizeEarthquakeSnapshot(geojson);
assert.equal(rows.length, 1);
assert.equal(rows[0].depthKm, null);
assert.equal(rows[0].place, null);
assert.equal(rows[0].time, null);
});
test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => {
assert.equal(normalizeEarthquakeSnapshot(null), null);
assert.equal(normalizeEarthquakeSnapshot(undefined), null);
assert.equal(normalizeEarthquakeSnapshot({}), null);
assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null);
assert.equal(normalizeEarthquakeSnapshot({ features: null }), null);
});
test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => {
const baseFeature = {
id: 'test',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
};
// Non-Point geometry
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: {
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
},
},
],
}),
null,
);
// Missing or short coordinates
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{ ...baseFeature, geometry: { type: 'Point', coordinates: [0] } },
],
}),
null,
);
// Longitude out of range [-180, 180]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [180.1, 0, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [-180.1, 0, 0] },
},
],
}),
null,
);
// Latitude out of range [-90, 90]
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 90.1, 0] },
},
],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, -90.1, 0] },
},
],
}),
null,
);
// Non-finite depth
assert.equal(
normalizeEarthquakeSnapshot({
features: [
{
...baseFeature,
geometry: { type: 'Point', coordinates: [0, 0, NaN] },
},
],
}),
null,
);
// Magnitude > 10
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: { mag: 10.1 } }],
}),
null,
);
// Non-object or array properties
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: 'invalid' }],
}),
null,
);
assert.equal(
normalizeEarthquakeSnapshot({
features: [{ ...baseFeature, properties: [] }],
}),
null,
);
});
test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => {
const geojson = {
features: [
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [0, 0, 0] },
properties: { mag: 3.0 },
},
{
id: 'duplicate-id',
geometry: { type: 'Point', coordinates: [1, 1, 1] },
properties: { mag: 4.0 },
},
],
};
assert.equal(normalizeEarthquakeSnapshot(geojson), null);
});
+4 -4
View File
@@ -219,8 +219,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25;
// adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4
// concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart
// (≤5/s — adsbdb is a free community API; the dev-server proxy additionally
// caches per-key on disk forever, negative results included, so repeat
// sessions never re-hit adsbdb). Each key is requested at most once per
// caches successful and 404 responses per key for 24 hours, so later sessions
// may refresh them). Each key is requested at most once per
// session. Priority jobs (tracked plane, model-eligible planes) jump the
// queue; the ambient fleet sweep (below) fills the back at poll cadence.
// ---------------------------------------------------------------------------
@@ -243,7 +243,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
// of drip, so the backlog can't outgrow a poll and re-sorts fresh), and
// - a ROLLING token-bucket budget (below) bounding the sustained ambient
// request rate (repeat sessions resolve instantly from the proxy's
// permanent disk cache).
// 24-hour disk cache).
// Fail-silent by contract: the sweep never throws into the poll loop, never
// blocks rendering, and never touches tracking state. When a type answer
// lands, _requestTypeEnrichment's callback swaps the billboard glyph + scale
@@ -257,7 +257,7 @@ export const ENRICH_DISPATCH_GAP_MS = 200;
// ENRICH_AMBIENT_REFILL_TOKENS every ENRICH_AMBIENT_REFILL_WINDOW_MS, clamped
// at the ceiling (no banking beyond one bucket). Numbers: 150 / 5 min sustains
// 0.5 req/s worst case — an order of magnitude under the 5/s drip that (with
// the 4-concurrent limit + the proxy's permanent disk cache) is the REAL
// the 4-concurrent limit + the proxy's 24-hour disk cache) is the REAL
// politeness bound on adsbdb; the 300 ceiling preserves the old first-look
// burst so a fresh region still classifies quickly.
/** Bucket ceiling: max ambient tokens held at once (= the initial burst). */
+8 -9
View File
@@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) {
// Number(null), Number('') and Number(false) are 0: absent is not zero.
if (value == null || typeof value === 'boolean') return null;
if (typeof value === 'string' && value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
@@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) {
const pad = launch.pad || {};
const location = pad.location || {};
const coordinates = location.coordinates || '';
const [coordinateLon, coordinateLat] = String(coordinates)
.split(',')
.map(Number);
const lat = Number.isFinite(Number(pad.latitude))
? Number(pad.latitude)
: coordinateLat;
const lon = Number.isFinite(Number(pad.longitude))
? Number(pad.longitude)
: coordinateLon;
const [coordinateLon, coordinateLat] = String(coordinates).split(',');
const lat =
finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
const lon =
finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
const payloads = normalizePayloadFlights(launch);
return {
id: String(
+2 -1
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entry = entries[i];
const satrec = twoline2satrec(entry.line1, entry.line2);
if (!satrec || satrec.error !== 0) continue;
const noradId = Number(satrec.satnum);
const noradId = noradIdFromSatnum(satrec.satnum);
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
+2 -1
View File
@@ -1,6 +1,7 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -111,7 +112,7 @@ export function createIngestion({
const satrec = twoline2satrec(entry.line1, entry.line2);
if (!satrec || satrec.error !== 0) continue;
const noradId = Number(satrec.satnum);
const noradId = noradIdFromSatnum(satrec.satnum);
if (seen.has(noradId)) continue;
seen.add(noradId);
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTleText } from '../../sources/tle.js';
import { createIngestion } from './ingestion.js';
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
const ISS_LINE1 =
'1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
const ISS_LINE2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
function tleSet(name, field) {
return [
name,
ISS_LINE1.replace('25544', field),
ISS_LINE2.replace('25544', field),
].join('\n');
}
function ingestionFor(catalogText) {
const state = {
_trackingRefreshEpoch: 0,
_activeUpdateControllers: new Set(),
_pointCollection: {
removeAll() {},
add: (point) => point,
},
_points: new Map(),
_orbitPaths: new Map(),
_catalog: new Map(),
_detectionObjects: new Map(),
_denseLoadToken: 0,
_catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false },
};
const parts = {
orbits: {
parseTLE: parseTleText,
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
},
labels: { _syncIssOverlay() {} },
controls: { _pointStyleFor: () => ({}) },
rendering: { _showOrbitPath() {} },
tracking: {
async _reconcileTrackedSubjectContext() {},
_applyPendingTrackingRestore() {},
},
catalog: {},
};
const source = {
async readGroup() {
return { ok: true, text: catalogText };
},
};
const { methods } = createIngestion({ state, services: {}, parts, source });
return { state, update: () => methods.update({ scene: {} }) };
}
test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const { state, update } = ingestionFor(
[
tleSet('FIRST', 'A0000'),
tleSet('SECOND', 'B0001'),
tleSet('THIRD', 'Z9999'),
].join('\n'),
);
await update();
assert.deepEqual(
[...state._catalog].map(([id, entry]) => [id, entry.name]),
[
[100000, 'FIRST'],
[110001, 'SECOND'],
[339999, 'THIRD'],
],
);
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
assert.equal(state._count, 3);
});
+2 -2
View File
@@ -8,7 +8,7 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { parseTleText } from '../../sources/tle.js';
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
const current = propagatePosition(satrec, referenceDate);
if (!current) return null;
return {
noradId: Number(satrec.satnum),
noradId: noradIdFromSatnum(satrec.satnum),
name: String(name || '').trim(),
current,
periodSec: orbitalPeriodSeconds(satrec),
+3 -16
View File
@@ -3,6 +3,7 @@ import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import {
viewportBias,
placesNearViewRecovery,
@@ -648,18 +649,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15;
*/
const PLACE_FALLBACK_HALF_SPAN_KM = 20;
/** Great-circle distance in km (small enough here that the spherical model is fine). */
function greatCircleKm(lat1, lng1, lat2, lng2) {
const dLat = Cesium.Math.toRadians(lat2 - lat1);
const dLng = Cesium.Math.toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(Cesium.Math.toRadians(lat1)) *
Math.cos(Cesium.Math.toRadians(lat2)) *
Math.sin(dLng / 2) ** 2;
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests.
*
@@ -694,10 +683,8 @@ export function placeFramingViewport(
return viewport;
const offsetKm = greatCircleKm(
anchorLat,
anchorLng,
metrics.centerLat,
metrics.centerLng,
{ lat: anchorLat, lon: anchorLng },
{ lat: metrics.centerLat, lon: metrics.centerLng },
);
if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport;
+51 -5
View File
@@ -13,7 +13,14 @@ export function parseTleText(text) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const cat1 = tleCatalogNumber(line1);
const cat2 = tleCatalogNumber(line2);
if (
line1.startsWith('1 ') &&
line2.startsWith('2 ') &&
cat1 !== null &&
cat1 === cat2
) {
result.push({ name, line1, line2 });
i += 2;
}
@@ -21,8 +28,47 @@ export function parseTleText(text) {
return result;
}
/** The NORAD catalog number from TLE line 1, or null. */
export function tleCatalogNumber(line1) {
const number = Number.parseInt(String(line1).slice(2, 7), 10);
return Number.isInteger(number) ? number : null;
// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits.
const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/;
const DIGITS = /^\d+$/;
/**
* The catalog number a catalog field stands for, or null for any other field.
*
* A field is either plain digits or Alpha-5, the TLE spelling of numbers above
* 99999: the letter stands for 10–33 in the ten-thousands place, skipping I
* and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a
* decimal point, hex, an embedded space, a lowercase or trailing letter) is
* not a catalog number.
* @param {string} field the field with any leading spaces already removed
* @returns {number | null}
*/
function decodeCatalogField(field) {
if (DIGITS.test(field)) return Number(field);
if (!ALPHA5.test(field)) return null;
const letter = field[0];
let tens = letter.charCodeAt(0) - 65 + 10;
if (letter > 'I') tens -= 1;
if (letter > 'O') tens -= 1;
return tens * 10000 + Number(field.slice(1));
}
/**
* NORAD catalog number of a satrec as a number, or NaN.
*
* satellite.js keeps the TLE's five-character field verbatim in
* `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite
* would share one NaN key. Digits of any length (an OMM satrec carries the
* number unpadded) and Alpha-5 decode; anything else, blank included, is NaN.
* @param {string} satnum
* @returns {number}
*/
export function noradIdFromSatnum(satnum) {
return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN;
}
/** The NORAD catalog number from TLE line 1 or 2 (columns 3–7), or null. */
export function tleCatalogNumber(line) {
const field = String(line).slice(2, 7);
return decodeCatalogField(field.replace(/^ +/, ''));
}
+21 -4
View File
@@ -89,17 +89,34 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
config.plugins.slice(3, -4).map((plugin) => plugin.name),
config.plugins.slice(3, -5).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-5).name, 'local-mcp');
assert.equal(config.plugins.at(-4).name, 'api-not-found');
// Config-only, so it sits after the provider slice rather than in it.
assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
test('directories the server writes while it runs are kept out of the dev watcher', () => {
// Providers write cache files and logs here while the app runs; none of it is
// source, so the dev watcher has no reason to stat it.
const config = standaloneConfig({ mode: 'test' });
const plugin = config.plugins.find(
({ name }) => name === 'gev-unwatched-runtime-dirs',
);
// Dev only: preview and build have no watcher.
assert.equal(plugin.apply, 'serve');
assert.deepEqual(plugin.config().server.watch.ignored, [
'**/.gev-cache/**',
'**/.gev-logs/**',
]);
});
test('build export resolves in Node and has no browser fallback', async () => {
const exported = await import('gods-eye-view/build/vite');
assert.equal(exported.createBrowserViteConfig, createBrowserViteConfig);
+125 -1
View File
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { twoline2satrec } from 'satellite.js';
import { composeCatalog, coreTools } from '../index.js';
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleCatalogNumber,
} from '../../sources/tle.js';
// Element sets published for 1 January 2024.
const TLE = `ISS (ZARYA)
@@ -51,6 +56,125 @@ for (const missingLine of [1, 2]) {
});
}
test('TLE parsing rejects pairs with mismatched catalog numbers and resumes', () => {
const text = `SAT A
1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
SAT B
1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
`;
const entries = parseTleText(text);
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT B'],
);
assert.equal(tleCatalogNumber(entries[0].line1), 48274);
assert.equal(tleCatalogNumber(entries[0].line2), 48274);
});
test('TLE parsing accepts matching Alpha-5 pairs and rejects mismatched Alpha-5 pairs', () => {
const text = `SAT MISMATCH
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 A0001 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
SAT ALPHA5
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 A0000 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
`;
const entries = parseTleText(text);
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT ALPHA5'],
);
assert.equal(tleCatalogNumber(entries[0].line1), 100000);
assert.equal(tleCatalogNumber(entries[0].line2), 100000);
});
test('TLE parsing rejects a pair whose catalog fields are malformed and resumes', () => {
// Read as digits up to the first non-digit, both fields would be 2554.
const text = `SAT MALFORMED
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 2554B 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
SAT HALF MALFORMED
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
2 02554 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
${TLE}`;
assert.deepEqual(
parseTleText(text).map((entry) => entry.name),
['ISS (ZARYA)', 'CSS (TIANHE)'],
);
});
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
for (const [field, id] of [
['A0000', 100000],
['A0404', 100404],
['H9999', 179999],
['J0000', 180000],
['N9999', 229999],
['P0000', 230000],
['T0449', 270449],
['Z9999', 339999],
]) {
assert.equal(noradIdFromSatnum(field), id, field);
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
}
});
test('a digit catalog field reads as it did before', () => {
assert.equal(noradIdFromSatnum('25544'), 25544);
assert.equal(noradIdFromSatnum('00404'), 404);
assert.equal(noradIdFromSatnum(' 404'), 404);
assert.equal(tleCatalogNumber('1 25544U'), 25544);
assert.equal(tleCatalogNumber('1 404U'), 404);
// An OMM satrec carries the number unpadded, past five digits.
assert.equal(noradIdFromSatnum('100000'), 100000);
});
test('a malformed catalog field is no number', () => {
for (const field of [
'I0000',
'O0000',
'a0404',
'A00',
'2554A',
'255 4',
'-1234',
'0x1F0',
'1.5',
'1e3',
'',
' ',
]) {
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
assert.equal(
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
null,
JSON.stringify(field),
);
}
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
});
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
const text = TLE.replaceAll('25544', 'A0000');
const [iss] = parseTleText(text);
const { satnum } = twoline2satrec(iss.line1, iss.line2);
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
assert.equal(noradIdFromSatnum(satnum), 100000);
const catalog = composeCatalog({
tools: coreTools,
services: { satellites: satellites(text), clock: CLOCK },
});
const result = await catalog.call('next_satellite_pass', {
location: { lat: 40.7, lon: -74 },
satellite: '100000',
});
assert.equal(result.data.satellite, 'ISS (ZARYA)');
assert.equal(result.data.norad, 100000);
});
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
const catalog = composeCatalog({
tools: coreTools,
+6 -17
View File
@@ -11,6 +11,7 @@ import { defaultGeospatial } from '../search/defaults.js';
import { msg } from '../i18n/index.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
import {
findPoiByName,
flyToGlobeView,
@@ -3742,8 +3743,10 @@ function centerMatchesSubject(center, subjectPosition) {
const subjectLat = Cesium.Math.toDegrees(carto.latitude);
const subjectLon = Cesium.Math.toDegrees(carto.longitude);
return (
haversineKm(subjectLat, subjectLon, center.lat, center.lon) <=
SUBJECT_CENTER_TOLERANCE_KM
greatCircleKm(
{ lat: subjectLat, lon: subjectLon },
{ lat: center.lat, lon: center.lon },
) <= SUBJECT_CENTER_TOLERANCE_KM
);
}
@@ -4033,7 +4036,7 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
for (const [cityId, city] of Object.entries(CITY_POIS)) {
for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) {
const poi = city.pois[poiIndex];
const distanceKm = haversineKm(latitude, longitude, poi.lat, poi.lon);
const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi);
if (distanceKm > maxDistanceKm) continue;
matches.push({
name: poi.name,
@@ -4048,20 +4051,6 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5);
}
function haversineKm(lat1, lon1, lat2, lon2) {
const toRad = (value) => Cesium.Math.toRadians(value);
const radiusKm = 6371.0088;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function coarseBasemapPlace(
viewScale,
latitude,