mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/scene-director-callback-errors
This commit is contained in:
51 files changed
+1540
-368
No files matched your search
+9
-7
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
+36
-6
@@ -4,6 +4,21 @@ Director clock listeners, interaction observers and scene release failures no
|
||||
longer leave the director inconsistent, and a phase error is kept when cleanup
|
||||
also fails.
|
||||
|
||||
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
|
||||
@@ -795,6 +810,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.
|
||||
@@ -1254,7 +1274,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,
|
||||
@@ -1458,7 +1484,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
|
||||
|
||||
@@ -3373,7 +3402,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 |
|
||||
@@ -4323,6 +4352,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
|
||||
@@ -5458,9 +5488,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.
|
||||
|
||||
|
||||
Generated
+124
-124
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -301,6 +301,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",
|
||||
@@ -377,6 +378,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",
|
||||
@@ -915,6 +917,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",
|
||||
@@ -1009,6 +1012,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",
|
||||
@@ -1249,6 +1253,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",
|
||||
@@ -1297,6 +1302,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",
|
||||
@@ -1340,6 +1346,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",
|
||||
@@ -1527,6 +1534,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",
|
||||
@@ -1661,6 +1669,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",
|
||||
@@ -2513,6 +2522,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",
|
||||
@@ -3075,6 +3085,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",
|
||||
@@ -3328,7 +3339,7 @@
|
||||
},
|
||||
"regional-source": {
|
||||
"exports": ["./sources/regional"],
|
||||
"modules": ["src/data/regionalModel.js"],
|
||||
"modules": ["src/data/regionalModel.js", "src/geoDistance.js"],
|
||||
"external": []
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
@@ -3491,6 +3502,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"
|
||||
@@ -3514,6 +3526,7 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/geoDistance.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js"
|
||||
],
|
||||
@@ -3529,6 +3542,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",
|
||||
@@ -3544,6 +3558,7 @@
|
||||
"modules": [
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
@@ -3565,6 +3580,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",
|
||||
@@ -3622,6 +3638,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",
|
||||
@@ -3669,6 +3686,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",
|
||||
@@ -3793,7 +3811,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"]
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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] } },
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -305,7 +305,10 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
|
||||
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' +
|
||||
'BiciMAD, <a href="https://www.emtmadrid.es" target="_blank" rel="noopener">Powered by EMT de Madrid</a>; ' +
|
||||
'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' +
|
||||
'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
@@ -324,7 +327,7 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'jcdecaux-gbfs',
|
||||
html:
|
||||
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||
'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' +
|
||||
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -677,6 +677,37 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => {
|
||||
assert.equal(launches[0].lon, -120.611);
|
||||
});
|
||||
|
||||
test('null or blank pad coordinates stay unknown instead of becoming 0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
for (const blank of [null, '', ' ', false]) {
|
||||
assert.deepEqual(
|
||||
normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW),
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('null pad coordinates use the location fallback instead of 0,0', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback',
|
||||
pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW);
|
||||
assert.equal(fallback.lat, 28.6);
|
||||
assert.equal(fallback.lon, -80.6);
|
||||
});
|
||||
|
||||
test('an undisclosed landing keeps top-level position and stage numbers unknown', () => {
|
||||
const base = { net: '2026-07-20T10:00:00Z' };
|
||||
const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 },
|
||||
rocket: { launcher_stage: [{ launcher_flight_number: null,
|
||||
landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null,
|
||||
landing_location: { latitude: null, longitude: null } } }] } }], NOW);
|
||||
const [stage] = launch.recoveryStages;
|
||||
assert.equal(stage.lat, null);
|
||||
assert.equal(stage.lon, null);
|
||||
assert.equal(stage.downrangeKm, null);
|
||||
assert.equal(stage.flightNumber, null);
|
||||
});
|
||||
|
||||
test('normalizes detailed payload and stage recovery records', () => {
|
||||
const launches = normalizeRocketLaunches({ results: [{
|
||||
id: 'recovery-details',
|
||||
|
||||
+12
-28
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
EARTH_RADIUS_KM,
|
||||
greatCircleKm,
|
||||
greatCircleMeters,
|
||||
isGeoPoint,
|
||||
} from './geoDistance.js';
|
||||
|
||||
const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 });
|
||||
const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 });
|
||||
|
||||
test('great-circle identity and symmetry', () => {
|
||||
assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0);
|
||||
assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN));
|
||||
});
|
||||
|
||||
test('great-circle one-degree calibration and canonical units', () => {
|
||||
const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 });
|
||||
assert.ok(Math.abs(kilometres - 111.195) < 0.001);
|
||||
assert.equal(
|
||||
greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }),
|
||||
kilometres * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
test('great-circle handles dateline crossing and antipodal points', () => {
|
||||
const acrossDateline = greatCircleKm(
|
||||
{ lat: 0, lon: 179 },
|
||||
{ lat: 0, lon: -179 },
|
||||
);
|
||||
assert.ok(Math.abs(acrossDateline - 222.39) < 0.01);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) -
|
||||
Math.PI * EARTH_RADIUS_KM,
|
||||
) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('canonical points require explicit latitude and longitude keys', () => {
|
||||
assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true);
|
||||
assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false);
|
||||
assert.equal(isGeoPoint([10, 80]), false);
|
||||
assert.equal(
|
||||
greatCircleKm(
|
||||
{ latitude: 10, longitude: 80 },
|
||||
{ latitude: 20, longitude: 70 },
|
||||
),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
test('latitude-before-longitude regression rejects a swapped Austin point', () => {
|
||||
assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON)));
|
||||
assert.equal(
|
||||
greatCircleKm(
|
||||
{ lat: AUSTIN.lon, lon: AUSTIN.lat },
|
||||
{ lat: HOUSTON.lon, lon: HOUSTON.lat },
|
||||
),
|
||||
Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid, missing, non-finite, and out-of-range points return Infinity', () => {
|
||||
for (const point of [
|
||||
null,
|
||||
{},
|
||||
{ lat: 0 },
|
||||
{ lat: NaN, lon: 0 },
|
||||
{ lat: 0, lon: Infinity },
|
||||
{ lat: 90.0001, lon: 0 },
|
||||
{ lat: 0, lon: -180.0001 },
|
||||
]) {
|
||||
assert.equal(greatCircleKm(point, AUSTIN), Infinity);
|
||||
assert.equal(greatCircleMeters(AUSTIN, point), Infinity);
|
||||
}
|
||||
});
|
||||
+2
-7
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json',
|
||||
provider: 'AMBici',
|
||||
},
|
||||
{
|
||||
id: 'madrid-bicimad',
|
||||
city: 'Madrid, Spain',
|
||||
centerLat: 40.4241,
|
||||
centerLon: -3.6825,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciMAD',
|
||||
},
|
||||
{
|
||||
id: 'valencia-valenbisi',
|
||||
city: 'Valencia, Spain',
|
||||
centerLat: 39.4706,
|
||||
centerLon: -0.3749,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json',
|
||||
provider: 'Valenbisi',
|
||||
},
|
||||
{
|
||||
id: 'zaragoza-bizi',
|
||||
city: 'Zaragoza, Spain',
|
||||
centerLat: 41.6481,
|
||||
centerLon: -0.8927,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bizi Zaragoza',
|
||||
},
|
||||
{
|
||||
id: 'sevilla-sevici',
|
||||
city: 'Seville, Spain',
|
||||
centerLat: 37.3726,
|
||||
centerLon: -5.9607,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json',
|
||||
provider: 'Sevici',
|
||||
},
|
||||
{
|
||||
id: 'valladolid-biki',
|
||||
city: 'Valladolid, Spain',
|
||||
centerLat: 41.6233,
|
||||
centerLon: -4.7382,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BIKI',
|
||||
},
|
||||
{
|
||||
id: 'a-coruna-bicicoruna',
|
||||
city: 'A Coruña, Spain',
|
||||
centerLat: 43.3534,
|
||||
centerLon: -8.4156,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BiciCoruña',
|
||||
},
|
||||
{
|
||||
id: 'donostia-dbizi',
|
||||
city: 'Donostia-San Sebastián, Spain',
|
||||
centerLat: 43.3075,
|
||||
centerLon: -1.9696,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Dbizi',
|
||||
},
|
||||
{
|
||||
id: 'bilbao-bilbaobizi',
|
||||
city: 'Bilbao, Spain',
|
||||
centerLat: 43.2624,
|
||||
centerLon: -2.9367,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bilbao Bizi',
|
||||
},
|
||||
{
|
||||
id: 'rivas-bicinrivas',
|
||||
city: 'Rivas-Vaciamadrid, Spain',
|
||||
centerLat: 40.3535,
|
||||
centerLon: -3.5319,
|
||||
loadRadiusKm: 40,
|
||||
stationInformationUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'BicinRivas',
|
||||
},
|
||||
];
|
||||
function normalizeRegistryEntry(entry) {
|
||||
const id = String(entry?.id || '')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each system's host, its stations farthest from the registered centre in
|
||||
// opposite directions, and the stations its live feed lists away from the
|
||||
// city: a workshop at (0,0), a lab in Madrid, planned sites with no position
|
||||
// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here.
|
||||
const SYSTEMS = {
|
||||
'madrid-bicimad': {
|
||||
host: 'madrid.publicbikesystem.net',
|
||||
provider: 'BiciMAD',
|
||||
stations: [
|
||||
{
|
||||
name: '627 - Alcalde Andrés Madrid Dávila, 92',
|
||||
lat: 40.398303,
|
||||
lon: -3.548534,
|
||||
},
|
||||
{
|
||||
name: '672 - Estación de tren de El Barrial-Pozuelo',
|
||||
lat: 40.464908,
|
||||
lon: -3.807764,
|
||||
},
|
||||
],
|
||||
},
|
||||
'valencia-valenbisi': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Valenbisi',
|
||||
stations: [
|
||||
{ name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 },
|
||||
{ name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 },
|
||||
],
|
||||
},
|
||||
'zaragoza-bizi': {
|
||||
host: 'zaragoza.publicbikesystem.net',
|
||||
provider: 'Bizi Zaragoza',
|
||||
stations: [
|
||||
{ name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 },
|
||||
{ name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 },
|
||||
],
|
||||
},
|
||||
'sevilla-sevici': {
|
||||
host: 'api.cyclocity.fr',
|
||||
provider: 'Sevici',
|
||||
stations: [
|
||||
{ name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 },
|
||||
{ name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 },
|
||||
],
|
||||
strays: [
|
||||
{
|
||||
station_id: '298',
|
||||
name: '_LAB MADRID',
|
||||
lat: 40.448779,
|
||||
lon: -3.584983,
|
||||
},
|
||||
{ station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'valladolid-biki': {
|
||||
host: 'valladolid.publicbikesystem.net',
|
||||
provider: 'BIKI',
|
||||
stations: [
|
||||
{ name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 },
|
||||
{ name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 },
|
||||
],
|
||||
},
|
||||
'a-coruna-bicicoruna': {
|
||||
host: 'acoruna.publicbikesystem.net',
|
||||
provider: 'BiciCoruña',
|
||||
stations: [
|
||||
{ name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 },
|
||||
{ name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 },
|
||||
],
|
||||
strays: [
|
||||
{ station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 },
|
||||
{ station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 },
|
||||
{ station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 },
|
||||
],
|
||||
},
|
||||
'donostia-dbizi': {
|
||||
host: 'sansebastian.publicbikesystem.net',
|
||||
provider: 'Dbizi',
|
||||
stations: [
|
||||
{ name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 },
|
||||
{ name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 },
|
||||
],
|
||||
},
|
||||
'bilbao-bilbaobizi': {
|
||||
host: 'bilbao.publicbikesystem.net',
|
||||
provider: 'Bilbao Bizi',
|
||||
stations: [
|
||||
{ name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 },
|
||||
{ name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 },
|
||||
],
|
||||
},
|
||||
'rivas-bicinrivas': {
|
||||
host: 'rivas.publicbikesystem.net',
|
||||
provider: 'BicinRivas',
|
||||
stations: [
|
||||
{
|
||||
name: 'Polideportivo Parque Sureste',
|
||||
lat: 40.3236695,
|
||||
lon: -3.5127191,
|
||||
},
|
||||
{ name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = {
|
||||
model: createModel({ state: {}, services: {}, parts: {}, source: null }),
|
||||
};
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
/** Station information for `id` through the real ingestion path. */
|
||||
async function loadThroughIngestion(id, stations) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
const state = {
|
||||
_stationInfoCache: new Map(),
|
||||
_inFlightInfo: new Map(),
|
||||
_inFlightStatus: new Map(),
|
||||
_loadingOps: 0,
|
||||
_loading: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
services: {},
|
||||
parts,
|
||||
source: {
|
||||
async getStations(url) {
|
||||
assert.equal(url, city.stationInformationUrl);
|
||||
return { data: { stations } };
|
||||
},
|
||||
},
|
||||
});
|
||||
return ingestion.loadCityStationInfo(id, 1);
|
||||
}
|
||||
|
||||
test('the Spanish city systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
assert.ok(system, `${id} is registered`);
|
||||
assert.equal(system.provider, expected.provider);
|
||||
assert.deepEqual(system.hosts, [expected.host]);
|
||||
for (const feed of [
|
||||
system.stationInformationUrl,
|
||||
system.stationStatusUrl,
|
||||
]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(
|
||||
isAllowedGbfsPath(url.pathname, url.hostname),
|
||||
`${id} path ${url.pathname}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each load radius reaches its outermost stations', () => {
|
||||
// The range check floors every radius at 100 km, but station information is
|
||||
// trimmed to the radius as registered, so it has to reach every station.
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
const system = CITY_BY_ID.get(id);
|
||||
for (const station of expected.stations) {
|
||||
const distance = parts.model.haversineKm(
|
||||
system.centerLat,
|
||||
system.centerLon,
|
||||
station.lat,
|
||||
station.lon,
|
||||
);
|
||||
assert.ok(
|
||||
distance <= system.loadRadiusKm,
|
||||
`${station.name} is ${distance.toFixed(1)} km from the ${id} centre`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over central Madrid activates BiciMAD and BicinRivas', () => {
|
||||
// Puerta del Sol.
|
||||
const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 });
|
||||
assert.ok(active.has('madrid-bicimad'));
|
||||
assert.ok(active.has('rivas-bicinrivas'));
|
||||
});
|
||||
|
||||
test('stations a feed lists away from its city are not loaded', async () => {
|
||||
for (const [id, expected] of Object.entries(SYSTEMS)) {
|
||||
if (!expected.strays) continue;
|
||||
const local = expected.stations.map((station, index) => ({
|
||||
station_id: `local-${index}`,
|
||||
...station,
|
||||
}));
|
||||
const stations = await loadThroughIngestion(id, [
|
||||
...local,
|
||||
...expected.strays,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...stations.keys()],
|
||||
local.map((station) => station.station_id),
|
||||
`${id} keeps only its local stations`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('JCDecaux station names reach the layer without underscores', async () => {
|
||||
for (const id of ['valencia-valenbisi', 'sevilla-sevici']) {
|
||||
const stations = await loadThroughIngestion(
|
||||
id,
|
||||
SYSTEMS[id].stations.map((station, index) => ({
|
||||
station_id: String(index),
|
||||
...station,
|
||||
})),
|
||||
);
|
||||
for (const station of stations.values())
|
||||
assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`);
|
||||
}
|
||||
const valenbisi = await loadThroughIngestion('valencia-valenbisi', [
|
||||
{ station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] },
|
||||
]);
|
||||
assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81');
|
||||
});
|
||||
|
||||
test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => {
|
||||
// Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17.
|
||||
const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations;
|
||||
const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [
|
||||
{ station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 },
|
||||
]);
|
||||
assert.equal(bilbaoStations.get('1').capacity, 39);
|
||||
const [madrid] = SYSTEMS['madrid-bicimad'].stations;
|
||||
const madridStations = await loadThroughIngestion('madrid-bicimad', [
|
||||
{ station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 },
|
||||
]);
|
||||
assert.equal(madridStations.get('627').capacity, 27);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
const current = propagatePosition(satrec, referenceDate);
|
||||
if (!current) return null;
|
||||
return {
|
||||
noradId: Number(satrec.satnum),
|
||||
noradId: noradIdFromSatnum(satrec.satnum),
|
||||
name: String(name || '').trim(),
|
||||
current,
|
||||
periodSec: orbitalPeriodSeconds(satrec),
|
||||
|
||||
+3
-16
@@ -3,6 +3,7 @@ import { isUnavailableCapability } from './sources/capability.js';
|
||||
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
|
||||
import { applicationServices } from './services/application.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import {
|
||||
viewportBias,
|
||||
placesNearViewRecovery,
|
||||
@@ -648,18 +649,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15;
|
||||
*/
|
||||
const PLACE_FALLBACK_HALF_SPAN_KM = 20;
|
||||
|
||||
/** Great-circle distance in km (small enough here that the spherical model is fine). */
|
||||
function greatCircleKm(lat1, lng1, lat2, lng2) {
|
||||
const dLat = Cesium.Math.toRadians(lat2 - lat1);
|
||||
const dLng = Cesium.Math.toRadians(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(Cesium.Math.toRadians(lat1)) *
|
||||
Math.cos(Cesium.Math.toRadians(lat2)) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests.
|
||||
*
|
||||
@@ -694,10 +683,8 @@ export function placeFramingViewport(
|
||||
return viewport;
|
||||
|
||||
const offsetKm = greatCircleKm(
|
||||
anchorLat,
|
||||
anchorLng,
|
||||
metrics.centerLat,
|
||||
metrics.centerLng,
|
||||
{ lat: anchorLat, lon: anchorLng },
|
||||
{ lat: metrics.centerLat, lon: metrics.centerLng },
|
||||
);
|
||||
if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport;
|
||||
|
||||
|
||||
@@ -51,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),
|
||||
|
||||
@@ -15,6 +15,25 @@ const parseName = (fields, version) =>
|
||||
data: { stations: [station(fields)] },
|
||||
}).get('s1').name;
|
||||
|
||||
const parseCapacity = (fields) =>
|
||||
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
|
||||
.capacity;
|
||||
|
||||
test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
|
||||
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
|
||||
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
|
||||
assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23);
|
||||
assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19);
|
||||
});
|
||||
|
||||
test('capacity is read as given without a usable geofenced_capacity', () => {
|
||||
assert.equal(parseCapacity({ capacity: 27 }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27);
|
||||
assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10);
|
||||
assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null);
|
||||
});
|
||||
|
||||
test('GBFS v2 station names remain trimmed strings', () => {
|
||||
assert.equal(
|
||||
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
|
||||
@@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('underscores in station names read as word breaks', () => {
|
||||
// As published by JCDecaux's Spanish contracts (checked 2026-10-17).
|
||||
for (const [published, shown] of [
|
||||
['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'],
|
||||
['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'],
|
||||
['_CAMPAMENTO_81', 'CAMPAMENTO 81'],
|
||||
['Plaza__Mayor_ ', 'Plaza Mayor'],
|
||||
]) {
|
||||
assert.equal(parseName({ name: published }), shown);
|
||||
}
|
||||
});
|
||||
|
||||
test('spacing without underscores stays as the operator published it', () => {
|
||||
assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82');
|
||||
});
|
||||
|
||||
test('a name of only underscores falls back to short_name, then to empty', () => {
|
||||
assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1');
|
||||
assert.equal(parseName({ name: '__', short_name: '_' }), '');
|
||||
});
|
||||
|
||||
test('underscores are read the same in GBFS v3 localized names', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
{
|
||||
name: [
|
||||
{ language: 'es', text: '_' },
|
||||
{ language: 'en', text: '_COLON_I' },
|
||||
],
|
||||
},
|
||||
'3.0',
|
||||
),
|
||||
'COLON I',
|
||||
);
|
||||
});
|
||||
|
||||
test('GBFS v3 station names prefer English over the first translation', () => {
|
||||
assert.equal(
|
||||
parseName(
|
||||
|
||||
+51
-5
@@ -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(/^ +/, ''));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
||||
import {
|
||||
noradIdFromSatnum,
|
||||
parseTleText,
|
||||
tleCatalogNumber,
|
||||
} from '../../sources/tle.js';
|
||||
|
||||
// Element sets published for 1 January 2024.
|
||||
const TLE = `ISS (ZARYA)
|
||||
@@ -51,6 +56,125 @@ for (const missingLine of [1, 2]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('TLE parsing rejects pairs with mismatched catalog numbers and resumes', () => {
|
||||
const text = `SAT A
|
||||
1 25544U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
SAT B
|
||||
1 48274U 21035A 24001.50000000 .00020137 00000-0 24481-3 0 9993
|
||||
2 48274 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
`;
|
||||
const entries = parseTleText(text);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT B'],
|
||||
);
|
||||
assert.equal(tleCatalogNumber(entries[0].line1), 48274);
|
||||
assert.equal(tleCatalogNumber(entries[0].line2), 48274);
|
||||
});
|
||||
|
||||
test('TLE parsing accepts matching Alpha-5 pairs and rejects mismatched Alpha-5 pairs', () => {
|
||||
const text = `SAT MISMATCH
|
||||
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 A0001 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
SAT ALPHA5
|
||||
1 A0000U 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 A0000 41.4697 175.8913 0005643 312.8370 141.6893 15.61287398150000
|
||||
`;
|
||||
const entries = parseTleText(text);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
['SAT ALPHA5'],
|
||||
);
|
||||
assert.equal(tleCatalogNumber(entries[0].line1), 100000);
|
||||
assert.equal(tleCatalogNumber(entries[0].line2), 100000);
|
||||
});
|
||||
|
||||
test('TLE parsing rejects a pair whose catalog fields are malformed and resumes', () => {
|
||||
// Read as digits up to the first non-digit, both fields would be 2554.
|
||||
const text = `SAT MALFORMED
|
||||
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 2554B 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
|
||||
SAT HALF MALFORMED
|
||||
1 2554AU 98067A 24001.50000000 .00016717 00000-0 30270-3 0 9994
|
||||
2 02554 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432414
|
||||
${TLE}`;
|
||||
assert.deepEqual(
|
||||
parseTleText(text).map((entry) => entry.name),
|
||||
['ISS (ZARYA)', 'CSS (TIANHE)'],
|
||||
);
|
||||
});
|
||||
|
||||
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
|
||||
for (const [field, id] of [
|
||||
['A0000', 100000],
|
||||
['A0404', 100404],
|
||||
['H9999', 179999],
|
||||
['J0000', 180000],
|
||||
['N9999', 229999],
|
||||
['P0000', 230000],
|
||||
['T0449', 270449],
|
||||
['Z9999', 339999],
|
||||
]) {
|
||||
assert.equal(noradIdFromSatnum(field), id, field);
|
||||
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
|
||||
}
|
||||
});
|
||||
|
||||
test('a digit catalog field reads as it did before', () => {
|
||||
assert.equal(noradIdFromSatnum('25544'), 25544);
|
||||
assert.equal(noradIdFromSatnum('00404'), 404);
|
||||
assert.equal(noradIdFromSatnum(' 404'), 404);
|
||||
assert.equal(tleCatalogNumber('1 25544U'), 25544);
|
||||
assert.equal(tleCatalogNumber('1 404U'), 404);
|
||||
// An OMM satrec carries the number unpadded, past five digits.
|
||||
assert.equal(noradIdFromSatnum('100000'), 100000);
|
||||
});
|
||||
|
||||
test('a malformed catalog field is no number', () => {
|
||||
for (const field of [
|
||||
'I0000',
|
||||
'O0000',
|
||||
'a0404',
|
||||
'A00',
|
||||
'2554A',
|
||||
'255 4',
|
||||
'-1234',
|
||||
'0x1F0',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'',
|
||||
' ',
|
||||
]) {
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
|
||||
assert.equal(
|
||||
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
|
||||
null,
|
||||
JSON.stringify(field),
|
||||
);
|
||||
}
|
||||
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
|
||||
});
|
||||
|
||||
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
|
||||
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
|
||||
const text = TLE.replaceAll('25544', 'A0000');
|
||||
const [iss] = parseTleText(text);
|
||||
const { satnum } = twoline2satrec(iss.line1, iss.line2);
|
||||
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
|
||||
assert.equal(noradIdFromSatnum(satnum), 100000);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { satellites: satellites(text), clock: CLOCK },
|
||||
});
|
||||
const result = await catalog.call('next_satellite_pass', {
|
||||
location: { lat: 40.7, lon: -74 },
|
||||
satellite: '100000',
|
||||
});
|
||||
assert.equal(result.data.satellite, 'ISS (ZARYA)');
|
||||
assert.equal(result.data.norad, 100000);
|
||||
});
|
||||
|
||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
|
||||
+6
-17
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user