diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6d6b968db..ba0d38768 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,8 +54,9 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
-same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an
-`npm run` alias.
+same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
+`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
+`qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -63,12 +64,13 @@ runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader
release-candidate checks and selected harnesses; it does not replace the focused
gate for the feature you changed.
-> **CI does not cover this for you.** The workflow runs the setup policy
+> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
-> production build, plus a Windows onboarding job. It runs neither
-> `npm run test:track` nor any `qa-*.mjs` gate — both need a live dev server
-> and a browser. Include the applicable local run in your PR's validation
-> evidence.
+> production build, plus a Windows onboarding job, and against the built preview
+> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
+> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
+> server and a browser. Include the applicable local run in your PR's
+> validation evidence.
## Share-link layer tokens
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 0e04fb39b..d930bcb8f 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -69,10 +69,12 @@ How to read this:
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
+| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` |
+| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" |
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
-| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" |
+| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" |
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
diff --git a/changelog.d/1094-dependency-security-patches.md b/changelog.d/1094-dependency-security-patches.md
new file mode 100644
index 000000000..058359efc
--- /dev/null
+++ b/changelog.d/1094-dependency-security-patches.md
@@ -0,0 +1,4 @@
+- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
+ GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
+ GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
+ #1094).
diff --git a/changelog.d/1101-tle-catalog-number-match.md b/changelog.d/1101-tle-catalog-number-match.md
new file mode 100644
index 000000000..d1e2aa407
--- /dev/null
+++ b/changelog.d/1101-tle-catalog-number-match.md
@@ -0,0 +1,3 @@
+- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
+ spliced catalog cannot pair one satellite's elements with another's; the next
+ valid set still parses (VG31OP, #1101).
diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md
new file mode 100644
index 000000000..d03c2d902
--- /dev/null
+++ b/changelog.d/1186-launch-unknown-coordinates.md
@@ -0,0 +1,4 @@
+- Keep missing launch-pad coordinates unknown so launches use the location
+ fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
+ flight numbers no longer show "FLIGHT 0"
+ (Rudy Celekli, @rudycelekli, #1186).
diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md
new file mode 100644
index 000000000..f07faa813
--- /dev/null
+++ b/changelog.d/1190-bikeshare-spain-cities.md
@@ -0,0 +1,6 @@
+- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
+ (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
+ (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
+ BicinRivas, about 1,840 stations. Station names read underscores as word
+ breaks, and station capacity counts docks rather than PBSC's geofenced area
+ (ulihhhh, #1190).
diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md
new file mode 100644
index 000000000..c2db9d54d
--- /dev/null
+++ b/changelog.d/287-shared-great-circle-distance.md
@@ -0,0 +1,5 @@
+- The client's great-circle distances now come from one shared
+ `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
+ points). The Earth radius is standardized on 6371.0088 km, so distances shift
+ by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
+ out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md
new file mode 100644
index 000000000..d3aaf0a91
--- /dev/null
+++ b/changelog.d/761-dev-unwatched-runtime-dirs.md
@@ -0,0 +1,5 @@
+- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
+ cache files and logs there while the app runs, and none of it is source, so
+ the watcher now skips it; this guards against the DNS-lookup delays heavy
+ cache writes have caused, though no difference was measured on current main
+ (monwiss, #761).
diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md
new file mode 100644
index 000000000..8a96698a1
--- /dev/null
+++ b/changelog.d/767-alpha5-catalog-numbers.md
@@ -0,0 +1,11 @@
+- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
+ numbers above 99999: a letter for the first two digits, skipping I and O
+ (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
+ field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
+ but the first were dropped as duplicates; the satellite tools read the same
+ field as no number, so such a satellite could not be found by it. The layer
+ and the tools now decode the field in `src/sources/tle.js`, and reject a
+ malformed one (a sign, decimal point, hex, embedded space or stray letter)
+ instead of reading part of it as a number. This is Alpha-5 TLE
+ compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
+ needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index ab80aa723..ca07db85f 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1,5 +1,20 @@
# God's Eye View Current State
+Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
+the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
+canonical; `greatCircleMeters` is its exact metre conversion. Missing,
+non-finite, and out-of-range points (latitude outside ±90, longitude outside
+±180) return `Infinity`, so threshold and nearest-item consumers exclude invalid
+positions. Older exported `haversineKm` entry points remain as compatibility
+wrappers only. `src/data/approxDistance.js`, the equirectangular
+viewer-proximity approximation, is deliberately separate, as are intentional
+local angular, projection, ellipsoidal prefilter, and small-viewport
+approximations. Four great-circle copies remain for a follow-up:
+`src/data/adminBoundaries.js` and `src/tools/area.js` pass unwrapped longitudes
+across the antimeridian, so they cannot use the strict ±180 check as-is, and
+`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
+implementations.
+
Vessel sources can report healthy empty coverage using a current, complete
snapshot with zero raw rows, a healthy transport and a positive last-message
time. This clears obsolete contacts and settles startup without manufacturing
@@ -791,6 +806,11 @@ and `./layers/launches`. Each owns its catalog, render state, tracking, interact
and teardown. Source adapters retain the existing CelesTrak and Launch Library
endpoints; standalone entries provide scene services and the satellite dependency.
The mission factory separates paths, replay, camera, panel, overlays and placement.
+Launch Library pad coordinates retain unknown values: missing fields use the
+reported location coordinates when available, and launches without a known pad
+position stay off the map. Genuine zero coordinates remain valid. Undisclosed
+stage coordinates, downrange distance and flight numbers remain unknown; the
+mission panel omits the flight-number label when no number was reported.
Pending mission requests abort on disable/destruction and malformed launch
snapshots preserve the last accepted display. Catalog groups, propagation cadence,
tracking intent, replay timing, controls and source attribution remain unchanged.
@@ -1250,7 +1270,13 @@ String arrays regardless of the reported feed version. Nonempty English text
(including regional `en-*` tags, case-insensitive) takes precedence, then the first
usable translation. An unusable `name` falls back to `short_name` with the same
rules; if neither supplies text, the station remains available with an empty name
-(the globe uses its station-ID label and the tool returns `null`). Malformed
+(the globe uses its station-ID label and the tool returns `null`). A run of underscores,
+with the spaces around it, reads as one word break and the ends are trimmed:
+JCDecaux's Spanish contracts publish `_PLAZA_MUSICO_LOPEZ_CHAVARRI`. Other spacing
+stays as published. Capacity counts docks: PBSC adds the bikes a station accepts
+in its geofenced area to `capacity` and reports that part as `geofenced_capacity`
+(Bilbao Bizi: 839 = 39 docks + 800), so the layer, its colour ratio and
+`get_bike_share` read `capacity − geofenced_capacity` when the extension is present. Malformed
translation entries do not interrupt loading of the other stations. This covers
station names, not complete GBFS v3 support.
`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer,
@@ -1466,7 +1492,10 @@ access. Callers retain validation, transport and response policy.
The shared TLE text parser skips incomplete named sets and resumes at the next
complete set. A missing orbital line does not discard the later satellites in
-the catalog; satellite layers and next-pass tools use the same parser.
+the catalog. A set whose two lines carry different catalog numbers, or a
+malformed catalog number, is skipped the same way, so a spliced catalog cannot
+pair one satellite's elements with another's. Satellite layers and next-pass
+tools use the same parser.
## Build configuration and local provider boundaries
@@ -3392,7 +3421,7 @@ its criteria cannot be silently ignored.
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
-| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
+| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
@@ -4342,6 +4371,7 @@ silently demoting every later lookup for the session.
- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert.
- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe.
+- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools.
- **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash
`fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
@@ -5477,9 +5507,9 @@ When runtime behavior or architecture changes, update this file in the same chan
## Dependency security baseline
-The lockfile uses DOMPurify 3.4.15, protobufjs 8.8.0, PostCSS 8.5.28, and
-nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0;
-image-processing tools use Sharp 0.35.4. QA scripts await Puppeteer's asynchronous
+The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28,
+source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA
+uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous
executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain
24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree.
diff --git a/package-lock.json b/package-lock.json
index e89fd9c65..7b6d3476f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -548,9 +548,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
- "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -567,13 +567,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.3.3"
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
- "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -590,13 +590,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.3.3"
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
- "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
@@ -604,7 +604,7 @@
"freebsd"
],
"dependencies": {
- "@img/sharp-wasm32": "0.35.4"
+ "@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -614,9 +614,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
- "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -631,9 +631,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
- "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -648,9 +648,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
- "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@@ -665,9 +665,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
- "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@@ -682,9 +682,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
- "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@@ -699,9 +699,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
- "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@@ -716,9 +716,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
- "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@@ -733,9 +733,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
- "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@@ -750,9 +750,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
- "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@@ -767,9 +767,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
- "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@@ -784,9 +784,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
- "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@@ -803,13 +803,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.3.3"
+ "@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
- "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@@ -826,13 +826,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.3.3"
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
- "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@@ -849,13 +849,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.3.3"
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
- "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@@ -872,13 +872,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.3.3"
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
- "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@@ -895,13 +895,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.3.3"
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
- "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@@ -918,13 +918,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.3.3"
+ "@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
- "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@@ -941,13 +941,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
- "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@@ -964,13 +964,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.3.3"
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
- "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
@@ -985,9 +985,9 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
- "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
@@ -995,7 +995,7 @@
"license": "Apache-2.0",
"optional": true,
"dependencies": {
- "@img/sharp-wasm32": "0.35.4"
+ "@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -1005,9 +1005,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
- "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -1025,9 +1025,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
- "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -1045,9 +1045,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
- "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -1833,9 +1833,9 @@
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/dompurify": {
- "version": "3.4.15",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
- "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
+ "version": "3.4.17",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
+ "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -2782,9 +2782,9 @@
"license": "ISC"
},
"node_modules/sharp": {
- "version": "0.35.4",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
- "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2799,31 +2799,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.35.4",
- "@img/sharp-darwin-x64": "0.35.4",
- "@img/sharp-freebsd-wasm32": "0.35.4",
- "@img/sharp-libvips-darwin-arm64": "1.3.3",
- "@img/sharp-libvips-darwin-x64": "1.3.3",
- "@img/sharp-libvips-linux-arm": "1.3.3",
- "@img/sharp-libvips-linux-arm64": "1.3.3",
- "@img/sharp-libvips-linux-ppc64": "1.3.3",
- "@img/sharp-libvips-linux-riscv64": "1.3.3",
- "@img/sharp-libvips-linux-s390x": "1.3.3",
- "@img/sharp-libvips-linux-x64": "1.3.3",
- "@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
- "@img/sharp-libvips-linuxmusl-x64": "1.3.3",
- "@img/sharp-linux-arm": "0.35.4",
- "@img/sharp-linux-arm64": "0.35.4",
- "@img/sharp-linux-ppc64": "0.35.4",
- "@img/sharp-linux-riscv64": "0.35.4",
- "@img/sharp-linux-s390x": "0.35.4",
- "@img/sharp-linux-x64": "0.35.4",
- "@img/sharp-linuxmusl-arm64": "0.35.4",
- "@img/sharp-linuxmusl-x64": "0.35.4",
- "@img/sharp-webcontainers-wasm32": "0.35.4",
- "@img/sharp-win32-arm64": "0.35.4",
- "@img/sharp-win32-ia32": "0.35.4",
- "@img/sharp-win32-x64": "0.35.4"
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -2832,9 +2832,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index f91c3a1ac..d14e64442 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -732,6 +732,7 @@
"src/voice/session.test.mjs",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
+ "src/layers/earthquakes/records.test.mjs",
"src/sources/sourceSlot.js",
"src/data/feedState.js",
"src/app/localGeojsonServices.js",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index e647564d0..7085cfc74 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -305,6 +305,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/i18n/marker.js",
@@ -381,6 +382,7 @@
"src/search/nominatim.js",
"src/nominatimGeocode.js",
"src/data/regionalModel.js",
+ "src/geoDistance.js",
"src/data/naturalEarthRegions.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
@@ -919,6 +921,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",
@@ -1013,6 +1016,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",
@@ -1253,6 +1257,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",
@@ -1301,6 +1306,7 @@
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
+ "src/geoDistance.js",
"src/sources/gbfsStations.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
@@ -1344,6 +1350,7 @@
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
+ "src/geoDistance.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
@@ -1531,6 +1538,7 @@
"exports": ["./geography/natural-earth", "./geography/neighborhoods"],
"modules": [
"src/data/naturalEarthRegions.js",
+ "src/geoDistance.js",
"src/data/neighborhoodPolygons.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js",
@@ -1665,6 +1673,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",
@@ -2517,6 +2526,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",
@@ -3079,6 +3089,7 @@
"src/data/approxDistance.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",
@@ -3332,7 +3343,7 @@
},
"regional-source": {
"exports": ["./sources/regional"],
- "modules": ["src/data/regionalModel.js"],
+ "modules": ["src/data/regionalModel.js", "src/geoDistance.js"],
"external": []
},
"voice-action-schemas": {
@@ -3495,6 +3506,7 @@
"src/search/google.js",
"src/search/geospatial.js",
"src/data/regionalModel.js",
+ "src/geoDistance.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/sources/nominatim.js"
@@ -3518,6 +3530,7 @@
"src/nominatimGeocode.js",
"src/search/geospatial.js",
"src/data/regionalModel.js",
+ "src/geoDistance.js",
"src/sources/httpBody.js",
"src/i18n/marker.js"
],
@@ -3533,6 +3546,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/i18n/marker.js",
@@ -3548,6 +3562,7 @@
"modules": [
"src/data/gtfsRealtime.js",
"src/data/transitFeeds.js",
+ "src/geoDistance.js",
"src/data/transitProxy.js",
"src/sources/transitHistory.js"
],
@@ -3569,6 +3584,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",
@@ -3626,6 +3642,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",
@@ -3673,6 +3690,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",
@@ -3797,7 +3815,8 @@
"src/view/index.js",
"src/tools/views.js",
"src/tools/globePanel.js",
- "src/tools/queries/panelRequest.js"
+ "src/tools/queries/panelRequest.js",
+ "src/geoDistance.js"
],
"external": ["satellite.js"]
},
diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js
index 9b7c1b1bb..4c069fa7f 100644
--- a/server/providers/aircraft/enrichment.js
+++ b/server/providers/aircraft/enrichment.js
@@ -7,8 +7,9 @@ const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024;
/**
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
* airports) and hex → aircraft type/registration. Free community API — cached
- * aggressively: ONE upstream request per new key ever (404s negative-cached),
- * persisted to disk so restarts don't re-hammer it. Adapted from skylight
+ * aggressively: successes and 404s are kept per key for 24 hours (TTL_MS) and
+ * persisted to disk so restarts don't re-hammer it; other failures are not
+ * cached. Adapted from skylight
* (MIT) server/src/enrich/routes.ts.
*/
export function adsbdbProxy() {
diff --git a/server/standalone/unwatched-cache.js b/server/standalone/unwatched-cache.js
new file mode 100644
index 000000000..130d0ece4
--- /dev/null
+++ b/server/standalone/unwatched-cache.js
@@ -0,0 +1,28 @@
+/**
+ * Directories the app writes to while it runs. None is source, and none
+ * should reload anything.
+ */
+const RUNTIME_WRITE_GLOBS = Object.freeze([
+ '**/.gev-cache/**',
+ '**/.gev-logs/**',
+]);
+
+/**
+ * Keep the directories the app writes to out of the dev server's file watcher.
+ *
+ * Disk-cached providers write a file per response into `.gev-cache`, and the
+ * voice debug log appends to `.gev-logs`. Left watched, each write costs the
+ * watcher stat calls on libuv's thread pool, which `getaddrinfo` shares; heavy
+ * cache writes have been seen to delay upstream DNS lookups.
+ *
+ * @returns {import('vite').Plugin} Config-only plugin; no server hooks.
+ */
+export function unwatchedRuntimeDirsPlugin() {
+ return {
+ name: 'gev-unwatched-runtime-dirs',
+ apply: 'serve',
+ config: () => ({
+ server: { watch: { ignored: [...RUNTIME_WRITE_GLOBS] } },
+ }),
+ };
+}
diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js
index a14292597..03a935092 100644
--- a/server/standalone/vite.config.js
+++ b/server/standalone/vite.config.js
@@ -6,6 +6,7 @@ import { localProviderPlugins } from '../providers/local.js';
import { localMcpPlugin } from '../mcp/plugin.js';
import { apiNotFoundPlugin } from './api-not-found.js';
import { standaloneVoiceTools } from './voiceTools.js';
+import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js';
const root = fileURLToPath(new URL('../../', import.meta.url));
@@ -20,6 +21,7 @@ export default defineConfig(({ command, mode }) => {
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
localMcpPlugin(),
apiNotFoundPlugin(),
+ unwatchedRuntimeDirsPlugin(),
],
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
cesiumToken: process.env.CESIUM_ION_TOKEN,
diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js
index 645aa3f3d..b0622579d 100644
--- a/src/annotations/annotationEngine.js
+++ b/src/annotations/annotationEngine.js
@@ -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) {
diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js
index 34f08e8f1..02a501010 100644
--- a/src/annotations/drawMode.js
+++ b/src/annotations/drawMode.js
@@ -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);
}
/**
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 697469dba..6c3361aff 100644
--- a/src/data/analystEngine.js
+++ b/src/data/analystEngine.js
@@ -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 });
}
/**
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index c13c7dd94..8d3949351 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -305,7 +305,10 @@ export const DATA_CREDITS = [
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
- 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
+ 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
+ 'BiciMAD, Powered by EMT de Madrid; ' +
+ 'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
+ 'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
},
{
key: 'urbansharing-gbfs',
@@ -324,7 +327,7 @@ export const DATA_CREDITS = [
{
key: 'jcdecaux-gbfs',
html:
- 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
+ 'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
'Licence Ouverte',
},
{
diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js
index 0c7af42d0..c40e180ac 100644
--- a/src/data/naturalEarthRegions.js
+++ b/src/data/naturalEarthRegions.js
@@ -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;
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 fca473994..46f162d6f 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) {
@@ -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 },
+ );
}
diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs
index ccfd280e2..b68a11db3 100644
--- a/src/data/rocketLaunches.test.mjs
+++ b/src/data/rocketLaunches.test.mjs
@@ -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',
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 ff5142133..5919f26f1 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 0ff38628d..bbc60cbfe 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 dfd3d6b18..b8476a6ee 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
@@ -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 });
}
/**
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 783e1d566..8190f4b1b 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 './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 });
}
/**
diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js
index d501d073c..96a543232 100644
--- a/src/layers/bikeshare/model.js
+++ b/src/layers/bikeshare/model.js
@@ -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 });
}
/**
diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js
index b56678187..9b3c29f20 100644
--- a/src/layers/bikeshare/registry.js
+++ b/src/layers/bikeshare/registry.js
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
provider: 'AMBici',
},
+ {
+ id: 'madrid-bicimad',
+ city: 'Madrid, Spain',
+ centerLat: 40.4241,
+ centerLon: -3.6825,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'BiciMAD',
+ },
+ {
+ id: 'valencia-valenbisi',
+ city: 'Valencia, Spain',
+ centerLat: 39.4706,
+ centerLon: -0.3749,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
+ stationStatusUrl:
+ 'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
+ provider: 'Valenbisi',
+ },
+ {
+ id: 'zaragoza-bizi',
+ city: 'Zaragoza, Spain',
+ centerLat: 41.6481,
+ centerLon: -0.8927,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bizi Zaragoza',
+ },
+ {
+ id: 'sevilla-sevici',
+ city: 'Seville, Spain',
+ centerLat: 37.3726,
+ centerLon: -5.9607,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
+ stationStatusUrl:
+ 'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
+ provider: 'Sevici',
+ },
+ {
+ id: 'valladolid-biki',
+ city: 'Valladolid, Spain',
+ centerLat: 41.6233,
+ centerLon: -4.7382,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'BIKI',
+ },
+ {
+ id: 'a-coruna-bicicoruna',
+ city: 'A Coruña, Spain',
+ centerLat: 43.3534,
+ centerLon: -8.4156,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'BiciCoruña',
+ },
+ {
+ id: 'donostia-dbizi',
+ city: 'Donostia-San Sebastián, Spain',
+ centerLat: 43.3075,
+ centerLon: -1.9696,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Dbizi',
+ },
+ {
+ id: 'bilbao-bilbaobizi',
+ city: 'Bilbao, Spain',
+ centerLat: 43.2624,
+ centerLon: -2.9367,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'Bilbao Bizi',
+ },
+ {
+ id: 'rivas-bicinrivas',
+ city: 'Rivas-Vaciamadrid, Spain',
+ centerLat: 40.3535,
+ centerLon: -3.5319,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
+ stationStatusUrl:
+ 'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
+ provider: 'BicinRivas',
+ },
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
diff --git a/src/layers/bikeshare/spainCitiesSystems.test.mjs b/src/layers/bikeshare/spainCitiesSystems.test.mjs
new file mode 100644
index 000000000..2c502456f
--- /dev/null
+++ b/src/layers/bikeshare/spainCitiesSystems.test.mjs
@@ -0,0 +1,256 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { CITY_BY_ID } from './registry.js';
+import { createIngestion } from './ingestion.js';
+import { createModel } from './model.js';
+import { createViewport } from './viewport.js';
+import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
+
+// Each system's host, its stations farthest from the registered centre in
+// opposite directions, and the stations its live feed lists away from the
+// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
+// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
+const SYSTEMS = {
+ 'madrid-bicimad': {
+ host: 'madrid.publicbikesystem.net',
+ provider: 'BiciMAD',
+ stations: [
+ {
+ name: '627 - Alcalde Andrés Madrid Dávila, 92',
+ lat: 40.398303,
+ lon: -3.548534,
+ },
+ {
+ name: '672 - Estación de tren de El Barrial-Pozuelo',
+ lat: 40.464908,
+ lon: -3.807764,
+ },
+ ],
+ },
+ 'valencia-valenbisi': {
+ host: 'api.cyclocity.fr',
+ provider: 'Valenbisi',
+ stations: [
+ { name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
+ { name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
+ ],
+ },
+ 'zaragoza-bizi': {
+ host: 'zaragoza.publicbikesystem.net',
+ provider: 'Bizi Zaragoza',
+ stations: [
+ { name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
+ { name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
+ ],
+ },
+ 'sevilla-sevici': {
+ host: 'api.cyclocity.fr',
+ provider: 'Sevici',
+ stations: [
+ { name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
+ { name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
+ ],
+ strays: [
+ {
+ station_id: '298',
+ name: '_LAB MADRID',
+ lat: 40.448779,
+ lon: -3.584983,
+ },
+ { station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
+ ],
+ },
+ 'valladolid-biki': {
+ host: 'valladolid.publicbikesystem.net',
+ provider: 'BIKI',
+ stations: [
+ { name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
+ { name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
+ ],
+ },
+ 'a-coruna-bicicoruna': {
+ host: 'acoruna.publicbikesystem.net',
+ provider: 'BiciCoruña',
+ stations: [
+ { name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
+ { name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
+ ],
+ strays: [
+ { station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
+ { station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
+ { station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
+ ],
+ },
+ 'donostia-dbizi': {
+ host: 'sansebastian.publicbikesystem.net',
+ provider: 'Dbizi',
+ stations: [
+ { name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
+ { name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
+ ],
+ },
+ 'bilbao-bilbaobizi': {
+ host: 'bilbao.publicbikesystem.net',
+ provider: 'Bilbao Bizi',
+ stations: [
+ { name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
+ { name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
+ ],
+ },
+ 'rivas-bicinrivas': {
+ host: 'rivas.publicbikesystem.net',
+ provider: 'BicinRivas',
+ stations: [
+ {
+ name: 'Polideportivo Parque Sureste',
+ lat: 40.3236695,
+ lon: -3.5127191,
+ },
+ { name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
+ ],
+ },
+};
+
+// The production range check, with no viewer or fetches behind it.
+const parts = {
+ model: createModel({ state: {}, services: {}, parts: {}, source: null }),
+};
+const { computeInRangeCities } = createViewport({
+ state: {},
+ services: { render: { governorRequestRender() {} } },
+ parts,
+ source: null,
+});
+
+/** Station information for `id` through the real ingestion path. */
+async function loadThroughIngestion(id, stations) {
+ const city = CITY_BY_ID.get(id);
+ const state = {
+ _stationInfoCache: new Map(),
+ _inFlightInfo: new Map(),
+ _inFlightStatus: new Map(),
+ _loadingOps: 0,
+ _loading: false,
+ };
+ const ingestion = createIngestion({
+ state,
+ services: {},
+ parts,
+ source: {
+ async getStations(url) {
+ assert.equal(url, city.stationInformationUrl);
+ return { data: { stations } };
+ },
+ },
+ });
+ return ingestion.loadCityStationInfo(id, 1);
+}
+
+test('the Spanish city systems are registered and readable through the proxy', () => {
+ for (const [id, expected] of Object.entries(SYSTEMS)) {
+ const system = CITY_BY_ID.get(id);
+ assert.ok(system, `${id} is registered`);
+ assert.equal(system.provider, expected.provider);
+ assert.deepEqual(system.hosts, [expected.host]);
+ for (const feed of [
+ system.stationInformationUrl,
+ system.stationStatusUrl,
+ ]) {
+ const url = new URL(feed);
+ assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
+ assert.ok(
+ isAllowedGbfsPath(url.pathname, url.hostname),
+ `${id} path ${url.pathname}`,
+ );
+ }
+ }
+});
+
+test('a camera over each outermost station activates its system', () => {
+ for (const [id, expected] of Object.entries(SYSTEMS)) {
+ for (const station of expected.stations) {
+ assert.ok(
+ computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
+ `${station.name} activates ${id}`,
+ );
+ }
+ }
+});
+
+test('each load radius reaches its outermost stations', () => {
+ // The range check floors every radius at 100 km, but station information is
+ // trimmed to the radius as registered, so it has to reach every station.
+ for (const [id, expected] of Object.entries(SYSTEMS)) {
+ const system = CITY_BY_ID.get(id);
+ for (const station of expected.stations) {
+ const distance = parts.model.haversineKm(
+ system.centerLat,
+ system.centerLon,
+ station.lat,
+ station.lon,
+ );
+ assert.ok(
+ distance <= system.loadRadiusKm,
+ `${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
+ );
+ }
+ }
+});
+
+test('a camera over central Madrid activates BiciMAD and BicinRivas', () => {
+ // Puerta del Sol.
+ const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
+ assert.ok(active.has('madrid-bicimad'));
+ assert.ok(active.has('rivas-bicinrivas'));
+});
+
+test('stations a feed lists away from its city are not loaded', async () => {
+ for (const [id, expected] of Object.entries(SYSTEMS)) {
+ if (!expected.strays) continue;
+ const local = expected.stations.map((station, index) => ({
+ station_id: `local-${index}`,
+ ...station,
+ }));
+ const stations = await loadThroughIngestion(id, [
+ ...local,
+ ...expected.strays,
+ ]);
+ assert.deepEqual(
+ [...stations.keys()],
+ local.map((station) => station.station_id),
+ `${id} keeps only its local stations`,
+ );
+ }
+});
+
+test('JCDecaux station names reach the layer without underscores', async () => {
+ for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
+ const stations = await loadThroughIngestion(
+ id,
+ SYSTEMS[id].stations.map((station, index) => ({
+ station_id: String(index),
+ ...station,
+ })),
+ );
+ for (const station of stations.values())
+ assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
+ }
+ const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
+ { station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
+ ]);
+ assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
+});
+
+test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
+ // Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
+ const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
+ const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
+ { station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
+ ]);
+ assert.equal(bilbaoStations.get('1').capacity, 39);
+ const [madrid] = SYSTEMS['madrid-bicimad'].stations;
+ const madridStations = await loadThroughIngestion('madrid-bicimad', [
+ { station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
+ ]);
+ assert.equal(madridStations.get('627').capacity, 27);
+});
diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js
index bdf350b4e..3354e0d43 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';
@@ -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 });
}
/**
diff --git a/src/layers/earthquakes/records.test.mjs b/src/layers/earthquakes/records.test.mjs
new file mode 100644
index 000000000..cd7b6a874
--- /dev/null
+++ b/src/layers/earthquakes/records.test.mjs
@@ -0,0 +1,261 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { normalizeEarthquakeSnapshot } from './records.js';
+
+test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => {
+ const geojson = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ id: 'us7000m123',
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [-122.4194, 37.7749, 10.5],
+ },
+ properties: {
+ mag: 4.2,
+ place: 'San Francisco Bay Area, CA',
+ time: 1711929600000,
+ },
+ },
+ ],
+ };
+
+ const rows = normalizeEarthquakeSnapshot(geojson);
+ assert.equal(Array.isArray(rows), true);
+ assert.equal(rows.length, 1);
+ assert.deepEqual(rows[0], {
+ stableId: 'us7000m123',
+ usgsId: 'us7000m123',
+ lon: -122.4194,
+ lat: 37.7749,
+ depthKm: 10.5,
+ mag: 4.2,
+ place: 'San Francisco Bay Area, CA',
+ time: 1711929600000,
+ });
+});
+
+test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => {
+ const geojson = {
+ features: [
+ {
+ id: null,
+ geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] },
+ properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 },
+ },
+ {
+ id: '',
+ geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] },
+ properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 },
+ },
+ ],
+ };
+
+ const rows = normalizeEarthquakeSnapshot(geojson);
+ assert.equal(rows.length, 2);
+ assert.equal(rows[0].stableId, 'event-1');
+ assert.equal(rows[0].usgsId, null);
+ assert.equal(rows[1].stableId, 'event-2');
+ assert.equal(rows[1].usgsId, '');
+});
+
+test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => {
+ const geojson = {
+ features: [
+ {
+ id: 'ev-sub',
+ geometry: { type: 'Point', coordinates: [10, 20, 5] },
+ properties: { mag: 2.49, place: 'Minor tremor' },
+ },
+ {
+ id: 'ev-null-mag',
+ geometry: { type: 'Point', coordinates: [11, 21, 5] },
+ properties: { mag: null, place: 'Uncalculated' },
+ },
+ {
+ id: 'ev-threshold',
+ geometry: { type: 'Point', coordinates: [12, 22, 5] },
+ properties: { mag: 2.5, place: 'Threshold event' },
+ },
+ {
+ id: 'ev-strong',
+ geometry: { type: 'Point', coordinates: [13, 23, 5] },
+ properties: { mag: 6.8, place: 'Major event' },
+ },
+ ],
+ };
+
+ const rows = normalizeEarthquakeSnapshot(geojson);
+ assert.equal(rows.length, 2);
+ assert.equal(rows[0].stableId, 'ev-threshold');
+ assert.equal(rows[0].mag, 2.5);
+ assert.equal(rows[1].stableId, 'ev-strong');
+ assert.equal(rows[1].mag, 6.8);
+});
+
+test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => {
+ const geojson = {
+ features: [
+ {
+ id: 'ev-sparse',
+ geometry: { coordinates: [-100, 40] }, // depth omitted
+ properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number
+ },
+ ],
+ };
+
+ const rows = normalizeEarthquakeSnapshot(geojson);
+ assert.equal(rows.length, 1);
+ assert.equal(rows[0].depthKm, null);
+ assert.equal(rows[0].place, null);
+ assert.equal(rows[0].time, null);
+});
+
+test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => {
+ assert.equal(normalizeEarthquakeSnapshot(null), null);
+ assert.equal(normalizeEarthquakeSnapshot(undefined), null);
+ assert.equal(normalizeEarthquakeSnapshot({}), null);
+ assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null);
+ assert.equal(normalizeEarthquakeSnapshot({ features: null }), null);
+});
+
+test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => {
+ const baseFeature = {
+ id: 'test',
+ geometry: { type: 'Point', coordinates: [0, 0, 0] },
+ properties: { mag: 3.0 },
+ };
+
+ // Non-Point geometry
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [0, 0],
+ [1, 1],
+ ],
+ },
+ },
+ ],
+ }),
+ null,
+ );
+
+ // Missing or short coordinates
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ { ...baseFeature, geometry: { type: 'Point', coordinates: [0] } },
+ ],
+ }),
+ null,
+ );
+
+ // Longitude out of range [-180, 180]
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: { type: 'Point', coordinates: [180.1, 0, 0] },
+ },
+ ],
+ }),
+ null,
+ );
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: { type: 'Point', coordinates: [-180.1, 0, 0] },
+ },
+ ],
+ }),
+ null,
+ );
+
+ // Latitude out of range [-90, 90]
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: { type: 'Point', coordinates: [0, 90.1, 0] },
+ },
+ ],
+ }),
+ null,
+ );
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: { type: 'Point', coordinates: [0, -90.1, 0] },
+ },
+ ],
+ }),
+ null,
+ );
+
+ // Non-finite depth
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [
+ {
+ ...baseFeature,
+ geometry: { type: 'Point', coordinates: [0, 0, NaN] },
+ },
+ ],
+ }),
+ null,
+ );
+
+ // Magnitude > 10
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [{ ...baseFeature, properties: { mag: 10.1 } }],
+ }),
+ null,
+ );
+
+ // Non-object or array properties
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [{ ...baseFeature, properties: 'invalid' }],
+ }),
+ null,
+ );
+ assert.equal(
+ normalizeEarthquakeSnapshot({
+ features: [{ ...baseFeature, properties: [] }],
+ }),
+ null,
+ );
+});
+
+test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => {
+ const geojson = {
+ features: [
+ {
+ id: 'duplicate-id',
+ geometry: { type: 'Point', coordinates: [0, 0, 0] },
+ properties: { mag: 3.0 },
+ },
+ {
+ id: 'duplicate-id',
+ geometry: { type: 'Point', coordinates: [1, 1, 1] },
+ properties: { mag: 4.0 },
+ },
+ ],
+ };
+
+ assert.equal(normalizeEarthquakeSnapshot(geojson), null);
+});
diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js
index 80d77dddd..2c015e6c9 100644
--- a/src/layers/flights/policy.js
+++ b/src/layers/flights/policy.js
@@ -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). */
diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js
index 0a9dd67ea..5697e07e8 100644
--- a/src/layers/launches/model.js
+++ b/src/layers/launches/model.js
@@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) {
+ // Number(null), Number('') and Number(false) are 0: absent is not zero.
+ if (value == null || typeof value === 'boolean') return null;
+ if (typeof value === 'string' && value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
@@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) {
const pad = launch.pad || {};
const location = pad.location || {};
const coordinates = location.coordinates || '';
- const [coordinateLon, coordinateLat] = String(coordinates)
- .split(',')
- .map(Number);
- const lat = Number.isFinite(Number(pad.latitude))
- ? Number(pad.latitude)
- : coordinateLat;
- const lon = Number.isFinite(Number(pad.longitude))
- ? Number(pad.longitude)
- : coordinateLon;
+ const [coordinateLon, coordinateLat] = String(coordinates).split(',');
+ const lat =
+ finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
+ const lon =
+ finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
const payloads = normalizePayloadFlights(launch);
return {
id: String(
diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js
index 25eaf9b2c..fd136c82d 100644
--- a/src/layers/satellites/catalog.js
+++ b/src/layers/satellites/catalog.js
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
+import { noradIdFromSatnum } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entry = entries[i];
const satrec = twoline2satrec(entry.line1, entry.line2);
if (!satrec || satrec.error !== 0) continue;
- const noradId = Number(satrec.satnum);
+ const noradId = noradIdFromSatnum(satrec.satnum);
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js
index 7284cdd8c..f8ca1db56 100644
--- a/src/layers/satellites/ingestion.js
+++ b/src/layers/satellites/ingestion.js
@@ -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);
diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs
new file mode 100644
index 000000000..313d999ee
--- /dev/null
+++ b/src/layers/satellites/ingestion.test.mjs
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { parseTleText } from '../../sources/tle.js';
+import { createIngestion } from './ingestion.js';
+
+// The ISS set (1 January 2024) with its catalog field swapped per satellite.
+const ISS_LINE1 =
+ '1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
+const ISS_LINE2 =
+ '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
+
+function tleSet(name, field) {
+ return [
+ name,
+ ISS_LINE1.replace('25544', field),
+ ISS_LINE2.replace('25544', field),
+ ].join('\n');
+}
+
+function ingestionFor(catalogText) {
+ const state = {
+ _trackingRefreshEpoch: 0,
+ _activeUpdateControllers: new Set(),
+ _pointCollection: {
+ removeAll() {},
+ add: (point) => point,
+ },
+ _points: new Map(),
+ _orbitPaths: new Map(),
+ _catalog: new Map(),
+ _detectionObjects: new Map(),
+ _denseLoadToken: 0,
+ _catalogRevision: 0,
+ _params: { catalog: 'core', showOrbits: false },
+ };
+ const parts = {
+ orbits: {
+ parseTLE: parseTleText,
+ propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
+ },
+ labels: { _syncIssOverlay() {} },
+ controls: { _pointStyleFor: () => ({}) },
+ rendering: { _showOrbitPath() {} },
+ tracking: {
+ async _reconcileTrackedSubjectContext() {},
+ _applyPendingTrackingRestore() {},
+ },
+ catalog: {},
+ };
+ const source = {
+ async readGroup() {
+ return { ok: true, text: catalogText };
+ },
+ };
+ const { methods } = createIngestion({ state, services: {}, parts, source });
+ return { state, update: () => methods.update({ scene: {} }) };
+}
+
+test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => {
+ t.mock.method(console, 'log', () => {});
+ t.mock.method(console, 'warn', () => {});
+ const { state, update } = ingestionFor(
+ [
+ tleSet('FIRST', 'A0000'),
+ tleSet('SECOND', 'B0001'),
+ tleSet('THIRD', 'Z9999'),
+ ].join('\n'),
+ );
+ await update();
+ assert.deepEqual(
+ [...state._catalog].map(([id, entry]) => [id, entry.name]),
+ [
+ [100000, 'FIRST'],
+ [110001, 'SECOND'],
+ [339999, 'THIRD'],
+ ],
+ );
+ assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
+ assert.equal(state._count, 3);
+});
diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js
index 32bb57cf9..0e4461e85 100644
--- a/src/layers/satellites/orbits.js
+++ b/src/layers/satellites/orbits.js
@@ -8,7 +8,7 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
-import { parseTleText } from '../../sources/tle.js';
+import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
const current = propagatePosition(satrec, referenceDate);
if (!current) return null;
return {
- noradId: Number(satrec.satnum),
+ noradId: noradIdFromSatnum(satrec.satnum),
name: String(name || '').trim(),
current,
periodSec: orbitalPeriodSeconds(satrec),
diff --git a/src/locations.js b/src/locations.js
index 1c5e94f2f..d17186905 100644
--- a/src/locations.js
+++ b/src/locations.js
@@ -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;
diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js
index 74f2efd23..c37c2fae4 100644
--- a/src/sources/gbfsStations.js
+++ b/src/sources/gbfsStations.js
@@ -51,15 +51,25 @@ export function extractStationsArray(payload) {
return [];
}
+/**
+ * Station name text as a reader expects it. JCDecaux's Spanish contracts
+ * (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words
+ * with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word
+ * break. Other spacing is the operator's own and stays as published.
+ */
+function tidyStationName(text) {
+ return text.replace(/\s*_+\s*/g, ' ').trim();
+}
+
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) {
- if (typeof value === 'string') return value.trim();
+ if (typeof value === 'string') return tidyStationName(value);
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
- const text = translation.text.trim();
+ const text = tidyStationName(translation.text);
if (!text) continue;
if (!fallback) fallback = text;
if (
@@ -71,6 +81,23 @@ function stationName(value) {
return fallback;
}
+/**
+ * Docks a station physically has. PBSC adds the bikes a station accepts in its
+ * geofenced area around the docks to `capacity` and reports that part as
+ * `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as
+ * capacity 839 with geofenced_capacity 800. Without the extension, or when it
+ * would exceed the total, `capacity` is read as given. PBSC also counts the
+ * bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so
+ * a crowded station can report more bikes than docks.
+ */
+function dockCapacity(raw) {
+ const capacity = toNonNegativeInteger(raw?.capacity);
+ const geofenced = toNonNegativeInteger(raw?.geofenced_capacity);
+ if (capacity == null || geofenced == null || geofenced > capacity)
+ return capacity;
+ return capacity - geofenced;
+}
+
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
@@ -95,7 +122,7 @@ export function parseStationInformation(payload) {
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
- capacity: toNonNegativeInteger(raw?.capacity),
+ capacity: dockCapacity(raw),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs
index 8c60da90c..91983df98 100644
--- a/src/sources/gbfsStations.test.mjs
+++ b/src/sources/gbfsStations.test.mjs
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
data: { stations: [station(fields)] },
}).get('s1').name;
+const parseCapacity = (fields) =>
+ parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
+ .capacity;
+
+test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
+ // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
+ assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
+ assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
+ assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
+});
+
+test('capacity is read as given without a usable geofenced_capacity', () => {
+ assert.equal(parseCapacity({ capacity: 27 }), 27);
+ assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
+ assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
+ assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
+ assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
+});
+
test('GBFS v2 station names remain trimmed strings', () => {
assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
@@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => {
);
});
+test('underscores in station names read as word breaks', () => {
+ // As published by JCDecaux's Spanish contracts (checked 2026-10-17).
+ for (const [published, shown] of [
+ ['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
+ ['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
+ ['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
+ ['Plaza__Mayor_ ', 'Plaza Mayor'],
+ ]) {
+ assert.equal(parseName({ name: published }), shown);
+ }
+});
+
+test('spacing without underscores stays as the operator published it', () => {
+ assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
+});
+
+test('a name of only underscores falls back to short_name, then to empty', () => {
+ assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
+ assert.equal(parseName({ name: '__', short_name: '_' }), '');
+});
+
+test('underscores are read the same in GBFS v3 localized names', () => {
+ assert.equal(
+ parseName(
+ {
+ name: [
+ { language: 'es', text: '_' },
+ { language: 'en', text: '_COLON_I' },
+ ],
+ },
+ '3.0',
+ ),
+ 'COLON I',
+ );
+});
+
test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal(
parseName(
diff --git a/src/sources/tle.js b/src/sources/tle.js
index ac7aedc2c..61c409a2b 100644
--- a/src/sources/tle.js
+++ b/src/sources/tle.js
@@ -13,7 +13,14 @@ export function parseTleText(text) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
- if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
+ const cat1 = tleCatalogNumber(line1);
+ const cat2 = tleCatalogNumber(line2);
+ if (
+ line1.startsWith('1 ') &&
+ line2.startsWith('2 ') &&
+ cat1 !== null &&
+ cat1 === cat2
+ ) {
result.push({ name, line1, line2 });
i += 2;
}
@@ -21,8 +28,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(/^ +/, ''));
}
diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs
index 86a9afb63..9e38a0295 100644
--- a/src/tooling/viteBuild.test.mjs
+++ b/src/tooling/viteBuild.test.mjs
@@ -89,17 +89,34 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
- config.plugins.slice(3, -4).map((plugin) => plugin.name),
+ config.plugins.slice(3, -5).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
- assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
+ assert.equal(config.plugins.at(-6).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
- assert.equal(config.plugins.at(-4).name, 'local-mcp');
- assert.equal(config.plugins.at(-3).name, 'api-not-found');
+ assert.equal(config.plugins.at(-5).name, 'local-mcp');
+ assert.equal(config.plugins.at(-4).name, 'api-not-found');
+ // Config-only, so it sits after the provider slice rather than in it.
+ assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
+test('directories the server writes while it runs are kept out of the dev watcher', () => {
+ // Providers write cache files and logs here while the app runs; none of it is
+ // source, so the dev watcher has no reason to stat it.
+ const config = standaloneConfig({ mode: 'test' });
+ const plugin = config.plugins.find(
+ ({ name }) => name === 'gev-unwatched-runtime-dirs',
+ );
+ // Dev only: preview and build have no watcher.
+ assert.equal(plugin.apply, 'serve');
+ assert.deepEqual(plugin.config().server.watch.ignored, [
+ '**/.gev-cache/**',
+ '**/.gev-logs/**',
+ ]);
+});
+
test('build export resolves in Node and has no browser fallback', async () => {
const exported = await import('gods-eye-view/build/vite');
assert.equal(exported.createBrowserViteConfig, createBrowserViteConfig);
diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs
index f6c2a8df8..0eb5ce346 100644
--- a/src/tools/queries/space.test.mjs
+++ b/src/tools/queries/space.test.mjs
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
+import { twoline2satrec } from 'satellite.js';
import { composeCatalog, coreTools } from '../index.js';
-import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
+import {
+ noradIdFromSatnum,
+ parseTleText,
+ tleCatalogNumber,
+} from '../../sources/tle.js';
// Element sets published for 1 January 2024.
const TLE = `ISS (ZARYA)
@@ -51,6 +56,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,
diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js
index db089cecf..26974ce60 100644
--- a/src/voice/gevActions.js
+++ b/src/voice/gevActions.js
@@ -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,