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/58] 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 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH 02/58] feat(flights): publish verticalRateMps on the tracked-aircraft descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapAnalystRecord` has always published `verticalRateMps`, but the tracked seam — `getTrackedInfo()` / `getTrackedSubject()`, built by `_describeFlight` — did not, so anything needing climb/descent had to reach past the public descriptor into the raw poll record. Both layers now map it, which matters because they store it under different keys: the civil records write `verticalRate` (records.js), the military ones write `verticalRateMps`. Mapping only one would leave `resolveTrackedAircraftInfo()` handing back two different shapes, and a consumer reading the field off a civil contact would silently read every military contact as level flight. Reported, never derived. `stickyNumber` seeds the field with null, so an aircraft that has not transmitted a rate publishes null rather than a level-flight 0 — absent is not level, and a flight-phase classifier that cannot tell them apart calls a climbing aircraft cruise. Tests: both layers, both halves (a reported rate arrives with its sign; an absent one stays null). Mutation-verified — removing either field or defaulting it to 0 turns them red. `docs/CURRENT-STATE.md` records the descriptor field, the two record keys it reconciles, and the reported-never-derived rule. --- CHANGELOG.md | 5 ++++ docs/CURRENT-STATE.md | 7 +++++ src/data/flights.test.mjs | 49 ++++++++++++++++++++++++++++++- src/data/militaryFlights.test.mjs | 45 ++++++++++++++++++++++++++++ src/layers/flights/queries.js | 13 ++++++-- src/layers/military/queries.js | 11 +++++-- 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..ae79c5dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,11 @@ Native point, billboard and label highlighting uses GPU draw commands; there is no scene-dimming effect selector. Unsupported shaders retain native contact rendering, and leaving Cyber restores the standard shell and contact treatment. +- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the + civil and military layers, so a consumer reading `getTrackedInfo()` no longer + has to reach past it into the raw poll record for climb/descent. Reported, + never derived: an aircraft that has not transmitted a rate reads as `null` + rather than a level-flight `0`. - Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). Select a box (drag, the current view, or around a pin; up to 1,000 km a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..87db7a7ac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -567,6 +567,13 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +The tracked-aircraft descriptor both flight layers hand to the cockpit carries +`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as +`verticalRate` and military records as `verticalRateMps`; the query seam maps both to +one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The +rate is reported, never derived: a blank poll keeps the last transmitted value and an +absent rate stays null rather than reading as level flight. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 2834e8833..48fb05737 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -487,7 +487,15 @@ const LABEL_ICAO = 'ae1fa4'; const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); /** Seed one civil contact through the production refresh seam. */ -function seedLabelContact({ callsign, registration, tracked = false }) { +function seedLabelContact({ + callsign, + registration, + tracked = false, + // Per-test metadata overrides, spread last so a test can set a field to + // `undefined` to model "the feed never sent it" — a default parameter + // could not express that, it would just re-supply the default. + meta: metaOverrides = {}, +}) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, @@ -509,6 +517,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { turnRateDps: 0, rawLat: 30.21, rawLon: -97.71, + ...metaOverrides, }, }); } @@ -574,6 +583,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration', assert.equal(flightsLayer.getTrackedInfo()?.registration, null); }); +test('the tracked descriptor publishes the reported vertical rate', () => { + // `mapAnalystRecord` has always published `verticalRateMps`; the tracked + // seam did not, so a consumer that needs climb/descent (a flight-phase + // classifier, the cockpit) had to reach past getTrackedInfo() into the + // raw record. Both seams now answer with the same field name and unit. + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: -6.4 }, + }); + assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4); + assert.equal( + mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps, + -6.4, + 'the analyst seam and the tracked seam must agree', + ); +}); + +test('an unreported vertical rate stays null, never a level-flight zero', () => { + // Absent is not level. `stickyNumber` seeds this field with null, so a + // contact that has never transmitted a rate must not read as 0 m/s — a + // phase classifier would call that cruise while the aircraft climbs. + for (const missing of [undefined, null, Number.NaN]) { + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: missing }, + }); + assert.equal( + flightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); + test('civil label chain: identity stays icao24 while the label moves', () => { for (const fixture of [ { callsign: 'SWA696', registration: 'N123AB' }, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 122b6b132..faf9722fc 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -603,3 +603,48 @@ test('two contacts matching at the same strength resolve deterministically', () assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); assert.equal(second, first, 'and the same query resolves the same way every time'); }); + +test('the military tracked descriptor publishes the reported vertical rate', () => { + // Shape parity with the civil layer's getTrackedInfo(). The two layers store + // the rate under different record keys (`verticalRateMps` here, + // `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one + // shape if BOTH descriptors map it — a consumer that reads the field off the + // civil seam and finds it missing on the military one would silently treat + // every military contact as level flight. + const seedRate = (verticalRateMps) => + _setTrackedMilitaryRefreshStateForTest({ + icao24: 'ae7f01', + entity: { gevLabelModel: { title: 'TEST', details: [] } }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), + color: Cesium.Color.WHITE, + show: true, + }, + billboardCollection: { show: true, remove() {} }, + viewer: { camera: { positionCartographic: null }, scene: {} }, + meta: { + callsign: 'RCH512', + rawLat: 31, + rawLon: -97, + altitudeFt: 26_000, + speedMps: 220, + track: 95, + onGround: false, + verticalRateMps, + }, + }); + + seedRate(12.7); + assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7); + + // Absent is not level: a contact that has never transmitted a rate must read + // as null, not as a 0 m/s a phase classifier would call cruise. + for (const missing of [undefined, null, Number.NaN]) { + seedRate(missing); + assert.equal( + militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..423f43788 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -113,7 +113,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -163,6 +163,15 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Climb/descent rate, the one kinematic field `mapAnalystRecord` already + // published (as `verticalRateMps`) but this descriptor did not, so a + // consumer reading the tracked seam had to reach back into `_flightData` + // for it. Reported, never derived: `stickyNumber` in records.js keeps the + // last value across a blank poll, and an absent rate stays null rather + // than becoming a level-flight 0 the aircraft never transmitted. + verticalRateMps: Number.isFinite(info?.verticalRate) + ? info.verticalRate + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -834,7 +843,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..9553cf181 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -86,7 +86,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -129,6 +129,13 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Same field, same name, same null-when-absent rule as the civil + // descriptor — the two layers store the record under different keys + // (`verticalRateMps` here, `verticalRate` there), so only the mapping + // makes `resolveTrackedAircraftInfo` hand back one shape for both. + verticalRateMps: Number.isFinite(info?.verticalRateMps) + ? info.verticalRateMps + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -755,7 +762,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { From b681d094ca0e2d0ea0d2cea0b0a6636410ea5e94 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 10:43:30 +0800 Subject: [PATCH 03/58] 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 1f1dc2dca8a99ad48402517f4a2906936d6822cb Mon Sep 17 00:00:00 2001 From: Sunil Date: Thu, 8 Oct 2026 20:59:38 +0530 Subject: [PATCH 04/58] fix(traffic): refuse cross-site requests before they spend the TomTom budget /api/tomtom/flow was the only keyed, billed proxy left outside the cross-site gate added in #900. Every uncached tile is an upstream attempt counted against the persistent daily budget, so any web page the user visits could fire requests at distinct z8-z16 tiles, spend the TomTom quota and leave the traffic layer on 429 budget for the rest of the UTC day. Gate both routes with admitSameSite, like the Places routes. --- scripts/package-boundaries.json | 7 ++- server/providers/traffic.js | 10 ++++ src/tooling/environmentProviders.test.mjs | 56 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2a188b4b0..f253f924a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -123,7 +123,12 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" + ], "external": [] }, "traffic-source": { diff --git a/server/providers/traffic.js b/server/providers/traffic.js index 2a3c2e60a..b078ff2df 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -7,6 +7,7 @@ import { normalizeBudget as normalizeTomTomBudget, isOverBudget as isTomTomOverBudget, } from '../../src/data/tomtomTiles.js'; +import { admitSameSite } from './common/same-site.js'; /** * TomTom traffic-flow vector-tile proxy with a daily budget governor. @@ -34,6 +35,13 @@ import { * for the rest of the billing period. 6,000/day keeps a full 31-day month * inside the allowance (186,000) with headroom to spare. * + * Cross-site gate: both routes refuse cross-site browser requests with 403 + * (the gate Mapillary, OpenAI and Places already use). Every uncached tile is + * a billed upstream attempt counted against the persistent daily budget, so + * without the gate any web page the user visits could fire `` requests + * at distinct tiles, spend the TomTom quota and leave the traffic layer on + * 429 budget for the rest of the UTC day. + * * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} * without touching upstream — the traffic layer then stays in simulation mode. @@ -154,6 +162,8 @@ export function tomtomProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/tomtom', async (req, res) => { + // Gate first: a cross-site /fetch must not reach the budget counter. + if (admitSameSite(req, res)) return; // Sanitized responses only (proxy/security baseline): no upstream // error details, and never echo the key or the upstream URL. const sendJson = (status, obj, extraHeaders = {}) => { diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index f8f02595c..ffdff0600 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -17,9 +17,16 @@ function install(plugin) { }, }); assert.equal(routes.size, 1); - return async (url = '/', method = 'GET') => { + return async (url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, + set statusCode(status) { + this.status = status; + }, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, @@ -27,7 +34,7 @@ function install(plugin) { this.body = body; }, }; - await [...routes.values()][0]({ url, method }, res); + await [...routes.values()][0]({ url, method, headers, socket: {} }, res); return res; }; } @@ -168,6 +175,51 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback assert.equal(calls, 2); }); +test('traffic refuses cross-site requests before they spend the TomTom budget', async (t) => { + isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response(new Uint8Array([1, 2, 3])); + }); + const request = install(tomtomProxy()); + const host = 'localhost:5173'; + const refused = [ + { host, 'sec-fetch-site': 'cross-site' }, // from any web page + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://evil.example' }, // fetch from another page + { host, origin: 'null' }, // sandboxed frame + { host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy + ]; + // Each refused shape asks for distinct uncached tiles: on main every one + // was a billed upstream attempt counted against the daily budget. + let x = 0; + for (const headers of refused) + for (let i = 0; i < 2; i++) { + const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers); + assert.equal(res.status, 403, JSON.stringify(headers)); + } + assert.equal((await request('/status', 'GET', refused[0])).status, 403); + assert.equal(calls, 0, 'TomTom is never asked'); + const app = { host, 'sec-fetch-site': 'same-origin' }; + assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0); + // The app itself and loopback tools (no browser headers) still get tiles. + const tile = await request('/flow/8/1/1.pbf', 'GET', app); + assert.equal(tile.status, 200); + assert.equal(tile.headers['x-tomtom-cache'], 'MISS'); + assert.equal((await request('/flow/8/2/1.pbf')).status, 200); + assert.equal( + ( + await request('/flow/8/1/1.pbf', 'GET', { + host, + origin: `http://${host}`, + }) + ).headers['x-tomtom-cache'], + 'HIT', + ); + assert.equal(calls, 2); +}); + test('FIRMS retains a large successful source during partial failure and filters stale data at serve time', async (t) => { isolate(t, { FIRMS_MAP_KEY: '' }); let now = Date.UTC(2026, 8, 12, 12); From ce5df4b8abe1b254b9a2fc73f1308e90f776768b Mon Sep 17 00:00:00 2001 From: Sunil Date: Fri, 9 Oct 2026 14:44:29 +0530 Subject: [PATCH 05/58] docs(security): include TomTom in same-site gate inventory --- CHANGELOG.md | 5 +++++ SECURITY.md | 2 +- docs/CURRENT-STATE.md | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f343b4d2..3dfacb35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,11 @@ ## [Unreleased] +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported. + - 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 the auth button and reports completion, failure, timeout, or expired diff --git a/SECURITY.md b/SECURITY.md index 53437a83c..fdb10b877 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Sanitized errors** — internal error details are not echoed back to clients. - **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. -- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an ``/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `