mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa0cf6d49 | ||
|
|
d7fac874c1 | ||
|
|
9efe169a6e |
No files matched your search
+1
-9
@@ -218,7 +218,6 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# OTTAWA_CAMERA_CERTIFICATE= # City of Ottawa Traffic Camera Images API certificate (free; request it via the form on traffic.ottawa.ca 'About the data'); without it the Ottawa pack is skipped
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
@@ -239,8 +238,6 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
|
||||
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
|
||||
# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras
|
||||
# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today)
|
||||
# CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras
|
||||
# CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200
|
||||
# CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL
|
||||
@@ -271,12 +268,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_OTTAWA_ENABLED=1 # set 0 to skip City of Ottawa cameras (also needs OTTAWA_CAMERA_CERTIFICATE)
|
||||
# CCTV_OTTAWA_MAX_SOURCES=250 # cap Ottawa cameras (8-500, nearest downtown first)
|
||||
# CCTV_QUEBEC_ENABLED=1 # set 0 to skip the Québec camera locations (MTQ, Montréal, scenic links)
|
||||
# CCTV_QUEBEC_VIDEO_ENABLED= # off by default; set 1 to let viewers play Québec 511 clips in their browser after pressing Play (511 terms of use unconfirmed; see DATA_SOURCES.md)
|
||||
# CCTV_WORLD_CAMERAS_ENABLED=1 # set 0 to skip the Oregon and MLIT (Japan) still cameras
|
||||
# CCTV_MAX_SOURCES=7350 # catalog-wide ceiling: 4,000 slots shared round-robin, the rest reserved for Québec and World
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Applications can ask Draw for one area or line and get its geometry back
|
||||
without placing a mark (`drawShape` from `createApplicationTools`), and their
|
||||
own click handlers can yield while a tool holds the pointer, as the layers'
|
||||
do (`pointerFree`) (samehkhamis, #1641).
|
||||
|
||||
## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language
|
||||
|
||||
- Show a Street Level view facing 359.5° or more as 0°. The viewer caption
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ prints anything; never fix that by renaming or deleting PR A's published token.
|
||||
|
||||
The highest-leverage places to jump in:
|
||||
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md).
|
||||
- **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane.
|
||||
- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers/<family>/`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs.
|
||||
- **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened).
|
||||
- **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`.
|
||||
|
||||
+3
-32
@@ -31,7 +31,7 @@ How to read this:
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
@@ -65,11 +65,9 @@ How to read this:
|
||||
| **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" |
|
||||
| **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" |
|
||||
| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner |
|
||||
| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 |
|
||||
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
|
||||
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
|
||||
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
|
||||
| **Webcam Romania — Giroc** (`webcamromania.ro`, `w4.webcamromania.ro`) | Optional Giroc camera metadata, JPEG snapshot and live HLS video | [Provider terms](https://webcamromania.ro/termeni-si-conditii/): video belongs to the original camera operator; no open video redistribution licence asserted. The [camera page](https://webcamromania.ro/webcam-orase/webcam-giroc/) offers an embed. Local opt-in via `CCTV_SOURCES_FILE`; media is fetched at runtime, never bundled. | "Webcam Romania · webcamromania.ro"; source link and rights notice in the Data attribution popover |
|
||||
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||
@@ -124,8 +122,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||
|
||||
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||
- **Freeway Bureau and Highway Bureau, MOTC (Taiwan).** National freeway cameras use the Freeway Bureau’s keyless [MOTC 2.0 CCTV table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml), refreshed daily and fetched at runtime (never bundled). Freeway Bureau data follows the [Open Government Data License, Taiwan v1.0](https://data.gov.tw/license); the app credits the Bureau, names the dataset and links the licence. If that catalog is unreachable and no last-good source list exists, the app falls back to the Highway Bureau’s free [provincial-road CCTV XML feed](https://thbapp.thb.gov.tw/opendata/), with attribution as requested by the Bureau. Its MJPEG streams are accepted only from cctv-ss01 through cctv-ss08.thb.gov.tw over HTTPS; the frame proxy extracts one bounded JPEG per request. This fallback covers provincial roads, not national freeways. Neither feed requires an API key. CCTV_TAIWAN_ENABLED=0 disables both; CCTV_TAIWAN_MAX_SOURCES defaults to 600 (bounded to 1–2000).
|
||||
- **Québec CCTV (MTQ / Québec 511, Montréal and scenic links).** `public/quebec-cameras.json` contains 680 MTQ positions from the official georeferenced WFS (CC BY 4.0), 426 Montréal camera locations derived from OSM (ODbL 1.0), and 14 scenic records with per-row provenance and accuracy. The MTQ video is **opt-in** (`CCTV_QUEBEC_VIDEO_ENABLED=1`): the CC BY 4.0 licence covers the camera locations only, and Québec 511's terms for embedding its clips on another site are unconfirmed, so by default MTQ cameras link to the Québec 511 viewer page instead. With the flag set, the 680 MTQ cameras play the operator's five-second MP4 clip directly in the viewer's browser from `www.quebec511.info/Carte/Fenetres/camera.ashx?id=<id>&format=mp4`, only after the viewer presses Play, so Québec 511 sees the viewer's IP address; Québec 511 refreshes those clips every few minutes. A second browser request supplies an approximate map still. The app does not proxy, store, or read video pixels, and no camera images are bundled. The selected-camera credit names both sources: locations MTQ under CC BY 4.0, video Québec 511. Montréal and scenic entries have provider-page links rather than direct frame URLs; their source-page terms apply. The video remains hosted by Québec 511 under its service terms. No API key is required; `CCTV_QUEBEC_ENABLED=0` disables the pack. Confirm Québec 511's terms of use before setting `CCTV_QUEBEC_VIDEO_ENABLED=1` on a public deployment.
|
||||
- **Freeway Bureau, MOTC (Taiwan).** National freeway cameras from the Freeway Bureau's keyless [CCTV static table](https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml) in the MOTC 2.0 standard, refreshed daily and fetched at runtime (never bundled). Freeway Bureau traffic data is published under the [Open Government Data License, Taiwan (Version 1.0)](https://data.gov.tw/license), which permits reuse, including commercial reuse, with attribution; the app credits the Freeway Bureau, MOTC, names the dataset and links the licence, as its attribution exhibit asks. The table publishes each camera's MJPEG stream on one of four official camera servers (`cctvn`, `cctvn5`, `cctvc`, `cctvs` under `freeway.gov.tw`). The frame proxy relays single images, so each published stream URL is mapped to the same server's single-JPEG path for the same camera number (`/abs2mjpg/bmjpg` → `/abs2mjpg/jpg`, `/live-view/mjpg/video.cgi` → `/live-view/jpg/image.cgi`); only those exact HTTPS pairs are registered, and the still path is not itself listed in the table. These are on-demand snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. The table host does not answer from some networks outside Taiwan (the camera servers do); there the pack is empty, logs one warning and retries hourly. On by default; `CCTV_TAIWAN_ENABLED=0` skips the pack, and `CCTV_TAIWAN_MAX_SOURCES` defaults to 600 (bounded to 1–2000).
|
||||
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **Sea Marks and Ski Pistes (#10).** Two keyless raster overlay layers under Infrastructure, OpenSeaMap sea marks and OpenSnowMap ski pistes, requested tile by tile as the camera views them, with no prefetch, bulk download or caching on our side (OpenSnowMap forbids bulk tile download without prior agreement). They drape on the globe on the OSM and Bing stacks and on the Google Photorealistic tileset on Google 3D (see `src/maps/imageryHost.js`). OpenRailwayMap and OpenInfraMap, also requested in #10, are not included: OpenRailwayMap's tile server only answers requests whose Referer is its own site, and OpenInfraMap serves vector tiles that need a vector rendering pipeline.
|
||||
@@ -173,8 +170,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
- **City of Ottawa traffic cameras.** Camera images come from the city's [Traffic Camera Images API](https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data), which requires an access certificate requested through the city's registration form and allows one certificate and user id to fetch a camera at most once every 60 seconds. Set `OTTAWA_CAMERA_CERTIFICATE`; without it the pack is skipped and makes no request. The camera list is the documented keyless `traffic.ottawa.ca/map/service/camera` JSON (camera number, English and French names, coordinates, owner). Frames are `traffic.ottawa.ca/opendata/camera?c=<number>`; the server adds the certificate and the user id `gev` at fetch time, so no catalog response carries the key, and serves each camera from one shared in-memory frame per minute. MTO cameras carried on the city feed keep their owner as a per-camera credit. The feed publishes no bearing, so headings use the shared id-hash fallback at low confidence, and no status field, so a camera the city takes offline still registers. Frames are never stored on disk. The city states no redistribution licence for the images; the certificate request asks how they will be used, so describe the deployment there. `CCTV_OTTAWA_MAX_SOURCES` caps the pack (default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
- **World cams image subset.** The normal CCTV catalog adds 1,176 Oregon TripCheck stills and 1,054 MLIT river cameras from the curated `config/cctv_sources.world-image-cameras.json` export; disable it with `CCTV_WORLD_CAMERAS_ENABLED=0`. The export's Vegagerðin rows are left out because the Iceland pack already loads those cameras. The server validates and pins each image URL to its provider path, fetches a frame only when a camera is viewed, and never stores or redistributes it. [ODOT allows businesses, private citizens and organizations to use TripCheck images and asks for credit](https://www.tripcheck.com/Pages/Frequently-Asked-Questions) ("Camera courtesy of ODOT"); TripCheck also carries partner cameras run by Oregon counties and cities, Caltrans and WSDOT, which keep their owner's name in the camera label. MLIT's [site terms](https://www.river.go.jp/kawabou/kwb_apend/html/caution.html) follow the Government of Japan Standard Terms of Use 2.0 (CC BY 4.0 compatible, commercial use allowed, source credit required) and ask tools not to collect data periodically, pointing regular collection to the river information data service; frames are therefore fetched only on view and refresh at most every ten minutes per camera. Both credits are registered in the Data attribution popover.
|
||||
- **TxDOT ITS cameras.** The catalog comes from the keyless `its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict` endpoint, one call per district. Frames come from the per-camera `GetCctvSnapshotByIcdId` endpoint, which answers JSON carrying a base64 JPEG rather than an image body; the proxy decodes it only for responses from the official TxDOT origin and validates the JPEG header before serving. Only cameras reporting `Device Online` are registered. Districts default to Austin and San Antonio (`CCTV_TXDOT_DISTRICTS`; 25 codes exist statewide); `CCTV_TXDOT_MAX_SOURCES` caps the pack and `CCTV_TXDOT_ENABLED=0` turns it off. Frames are fetched live at request time and are never stored or redistributed.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
@@ -183,7 +178,6 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||
- **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent.
|
||||
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||
- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`.
|
||||
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||
- **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK.
|
||||
@@ -223,18 +217,13 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
|
||||
@@ -296,14 +285,6 @@ transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### UCS Satellite Database (`ucs_satellites/`)
|
||||
|
||||
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
@@ -323,16 +304,6 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w
|
||||
Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver
|
||||
wiring (see below).
|
||||
|
||||
### Natural Earth populated places (`natural_earth/populated_places.json`)
|
||||
|
||||
Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned
|
||||
`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one
|
||||
row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max`
|
||||
(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half
|
||||
its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving
|
||||
the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild:
|
||||
`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README.
|
||||
|
||||
### Sky background (`sky/`)
|
||||
|
||||
Sources, licences, the changes CC BY asks to state, hashes and build commands
|
||||
|
||||
@@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
@@ -436,11 +436,11 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
|
||||
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | up to 7,350 camera locations (4,000 shared round-robin slots, plus room for 1,120 Québec and 2,230 World image cameras) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Ottawa (City of Ottawa, free access certificate) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Québec (MTQ locations with Québec 511 links, Montréal locations, regional webcams) · Oregon (TripCheck) · Japan (MLIT river cameras) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Québec 511 in-browser video (embedding terms unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published or attributed; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
@@ -473,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
|
||||
|
||||
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
|
||||
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||
|
||||

|
||||
|
||||
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
@@ -639,7 +639,7 @@ and panel. DelDOT uses its official HTTPS HLS catalog links. Disable that
|
||||
pack with `CCTV_DELDOT_ENABLED=0`.
|
||||
|
||||
The server allows two concurrent sessions. Each retains at most 12 segments
|
||||
and 24 MiB in memory; individual downloads are capped at 8 MiB with a ten
|
||||
and 24 MiB in memory; individual downloads are capped at 4 MiB with a ten
|
||||
second deadline. There are no segment files or ffmpeg processes. Redirects,
|
||||
off-origin references, encrypted playlists and non-MPEG-TS segments are refused.
|
||||
Each decoder has its own client lease (at most eight per session), including
|
||||
|
||||
+2
-3
@@ -69,14 +69,13 @@ browser-key fallback for existing single-key setups.
|
||||
The data proxies under `server/providers/` are written so the browser cannot turn the server into an open relay:
|
||||
|
||||
- **No arbitrary-URL fetching.** The CCTV frame proxy fetches only server-registered camera/frame URLs — clients cannot pass an upstream URL to fetch (SSRF mitigation). Other proxies target fixed upstream hosts.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists to 256 KiB and segment bodies to 8 MiB while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Live HLS is bounded and origin-pinned.** Registered HTTP(S) HLS sources use a Node puller that rejects redirects and off-origin playlist references, bounds playlists and segment bodies while streaming, and aborts timed-out or released sessions. At most two sessions retain 12 segments/24 MiB each, plus bounded download buffers. Stores are memory-only and idle sessions expire after 15 seconds. RTMP/ffmpeg execution is not enabled. Locally configured source URLs remain an operator trust boundary.
|
||||
- **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio.
|
||||
- **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511.
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
- Expand CCTV coverage with a keyless Taiwan Highway Bureau fallback, 1,120
|
||||
Québec camera locations, City of Ottawa traffic cameras (needs a free city
|
||||
access certificate, `OTTAWA_CAMERA_CERTIFICATE`), and 2,230 curated
|
||||
image-backed cameras from Oregon and Japan (InoukT, #1506).
|
||||
- Québec 511 in-browser video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) and
|
||||
loads only after the viewer presses Play; enforce provider-specific frame
|
||||
refresh limits, and report recognized MLIT maintenance images as degraded
|
||||
instead of healthy camera snapshots.
|
||||
@@ -1,5 +0,0 @@
|
||||
- One world clock now drives everything with a time of its own: the sky,
|
||||
the HUD clock, satellites, launches and weather. A timeline along the
|
||||
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||
and scrubs it, with a tick for each launch or frame and a way back to
|
||||
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||
@@ -1,7 +0,0 @@
|
||||
- The timeline brings launches up as the time passes them, offers play
|
||||
speeds from real time to a week a second, and plays smoothly, holding only
|
||||
when the next weather frame has yet to load, with a spinner by the time
|
||||
while it waits. The time rides in a bubble over the handle. The track zooms
|
||||
with a scroll or a pinch, drags along, and goes to the time under a tap, on
|
||||
a phone as on a desktop. Zooming out on the globe stops while the globe is
|
||||
still in view instead of leaving it a speck (samehkhamis, #1513).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Add an adjustable event-age filter across S2 GhostMaps products, defaulting
|
||||
to seven days with 1-, 3-, 14- and 30-day choices and an All markers option.
|
||||
Preserve undated features, save the selected window and expire old events
|
||||
while the map stays open. Thanks @dylanmaniatakes (#1528).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
|
||||
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
|
||||
with the arrow keys, and a waterline read from the scene so river boats sit on
|
||||
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
|
||||
@@ -1,4 +0,0 @@
|
||||
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
|
||||
Probes and Debris, each with a toggle; all but Starlink show by default.
|
||||
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
|
||||
(Denis Groshev, @ZloyS, #1558).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
|
||||
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
|
||||
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
|
||||
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
|
||||
confirmed swath allows at the live altitude, with a to-scale swath strip, a
|
||||
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
|
||||
the TLE's age (Denis Groshev, @ZloyS, #1560).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Space Missions no longer locks the workspace when its mission index fails to
|
||||
load (429, timeout or bad payload): entry rolls back to the previous layers
|
||||
and camera view and a toast says the index could not load (German, @gekh,
|
||||
#1578).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Hold the orbital camera on a mapped site as the satellite passes over it,
|
||||
from a click on its marker or its Live Signals row, and list nearby sites
|
||||
and satellites in orbit's Live Signals, where a row focuses a site or rides
|
||||
a satellite (Denis Groshev, @ZloyS, #1580).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||
language, the airspace layer names the airspace again, and Traditional
|
||||
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Keep keyless place search on a nearby exact match for names written in
|
||||
non-Latin scripts instead of discarding the match and retrying without its
|
||||
proximity ranking (mertkayacs, #1585).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
|
||||
Provider Settings, instead of showing the raw server error with a hint to
|
||||
check microphone permissions. `/api/realtime/token` returns
|
||||
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||
the globe. A share link that turns a source off is respected
|
||||
(asturksever, #1601).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Street Traffic offers separate Lines and Dots toggles while live flow is on:
|
||||
Lines draws each road with flow in green, amber or red, and Dots shows or
|
||||
hides the moving dots. A host can choose which road modes it offers; without
|
||||
OpenStreetMap the layer never draws simulated roads (#1609).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Typed chat on OpenAI answers again: OpenAI refused every message because
|
||||
`resolve_area`'s area-id pattern uses Unicode classes Chat Completions does
|
||||
not accept. Chat leaves those patterns out; area ids are still checked
|
||||
before the tool runs (samehkhamis, #1616).
|
||||
@@ -1,12 +0,0 @@
|
||||
- Add Icelandic road cameras from the Icelandic Road and Coastal Administration
|
||||
(Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to
|
||||
`gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166
|
||||
stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`,
|
||||
and only links inside that directory are registered. The catalog is fetched
|
||||
over https with redirects refused and a 2 MiB body cap. An explicit facing in
|
||||
the view description ("til vesturs", "í suður", "séð austur") sets a
|
||||
high-confidence heading; other views keep the low-confidence fallback pose.
|
||||
IRCA publishes the images under CC BY 4.0, credited in the Data attribution
|
||||
popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir,
|
||||
Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and
|
||||
`CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617).
|
||||
@@ -1,5 +0,0 @@
|
||||
- Add an optional Giroc / Webcam Romania CCTV source pack with corrected
|
||||
coordinates, live HLS, a snapshot fallback and provider attribution. Allow
|
||||
HLS segments up to 8 MiB so high-resolution cameras whose segments exceed
|
||||
4 MiB can play, while retaining the 24 MiB per-session cache and existing
|
||||
origin and session limits (NeoXrated23, #1619).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Turning CCTV on opens the camera nearest you instead of the first camera in
|
||||
the catalog, which showed a Sydney camera in London. A share link opens on
|
||||
the camera nearest the shared view once its flight lands (samehkhamis, #1620).
|
||||
@@ -1,3 +0,0 @@
|
||||
- An application built with fewer layers no longer restores, saves or shares a
|
||||
layer it does not have when a link or saved state from a full build names it
|
||||
(samehkhamis, #1634).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Draw rows take an application's own actions: `createApplicationTools({
|
||||
drawMarkActions })` adds buttons to drawn areas' and lines' rows, each given
|
||||
the shape's `[lon, lat]` geometry, with what it answers shown under the row.
|
||||
The standalone build passes none and is unchanged (samehkhamis, #1636).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
|
||||
satellite owner flags at startup from one generated 15 KB sheet, and name
|
||||
"Place names" in the loading status while the place-name packs load on first
|
||||
lookup (Sameh Khamis, @samehkhamis, #1637).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||
its official advisory, position, intensity and movement. Clearing the
|
||||
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||
second) while visible contacts and contacts with fleet models retain the full
|
||||
update path (Wilmer Alzate, #870).
|
||||
@@ -1,2 +0,0 @@
|
||||
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
|
||||
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
|
||||
@@ -1,23 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "ro-giroc-webcamromania",
|
||||
"name": "Giroc — Webcam Romania",
|
||||
"city": "Giroc, Timiș",
|
||||
"cityId": "giroc",
|
||||
"provider": "Webcam Romania · webcamromania.ro",
|
||||
"sourceKind": "configured",
|
||||
"feedType": "hls",
|
||||
"url": "https://w4.webcamromania.ro/giroc/index.m3u8",
|
||||
"snapshotUrl": "https://webcamromania.ro/media/snapshots/01a0267e-d893-7203-a7eb-7ce3c8c99703/latest.jpg",
|
||||
"lat": 45.70028947266781,
|
||||
"lon": 21.233675914004227,
|
||||
"headingDeg": 0,
|
||||
"headingConfidence": "low",
|
||||
"pitchDeg": -17,
|
||||
"fovDeg": 74,
|
||||
"rangeM": 700,
|
||||
"mountHeightM": 24,
|
||||
"groundElevationM": 87,
|
||||
"license": "Public camera: https://webcamromania.ro/webcam-orase/webcam-giroc/ . Provider terms: https://webcamromania.ro/termeni-si-conditii/ . Video rights remain with the camera operator. Coordinates corrected by the contributor; direction, height, elevation and field of view are uncalibrated estimates."
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,155 +0,0 @@
|
||||
# CCTV integration workflow
|
||||
|
||||
Use this workflow when adding a camera provider or repairing an existing feed.
|
||||
The CCTV catalog comes through the local server; media normally does too. Keep
|
||||
provider-specific URL policy on the server, including any validated browser-only
|
||||
media URL permitted by the exception below.
|
||||
|
||||
## 1. Check the source before coding
|
||||
|
||||
- Identify the catalog endpoint and the actual frame or live-media endpoint.
|
||||
They may be different hosts or use different formats.
|
||||
- Read the provider's license, terms, attribution, rate limits, and access
|
||||
requirements. Publicly reachable data is not automatically licensed for
|
||||
reuse. Record whether the app may display it and whether any use is
|
||||
restricted.
|
||||
- Prefer a public, keyless feed. If credentials are required, keep them in a
|
||||
server-only environment variable; never put a secret in browser code or a
|
||||
Vite-exposed variable. A pack that needs a key is opt-in by that key: with
|
||||
the variable unset it makes no request and logs once (Ontario 511 and
|
||||
Ottawa). Keep the key out of catalog URLs and add it only at fetch time. Use
|
||||
the provider's documented API, not a website endpoint that skips its key. If
|
||||
access terms require an agreement or remain unconfirmed, make the pack
|
||||
opt-in and ensure it makes no request by default.
|
||||
- Confirm the feed can be fetched from the app's Node server environment. A URL
|
||||
opening in a browser does not prove the server can reach it, and browser CORS
|
||||
behavior does not diagnose a server-side fetch.
|
||||
- Treat camera directories and aggregators such as [TW Live](https://tw.live/)
|
||||
as discovery aids. Trace each listing to the original agency or camera owner;
|
||||
their data and media terms govern reuse. For example, SkylineWebcams says
|
||||
only camera hosts may embed its live feed and its terms restrict copying or
|
||||
retransmitting footage, so do not proxy its stream without written
|
||||
authorization ([FAQ](https://www.skylinewebcams.com/support/faq.html),
|
||||
[terms](https://www.skylinewebcams.com/en/terms-of-use.html)).
|
||||
- Decide whether the provider supplies still snapshots or continuous video.
|
||||
Prefer stills unless live video is necessary and supported by the existing
|
||||
proxy path.
|
||||
|
||||
## 2. Add and register the provider pack
|
||||
|
||||
Use the smallest existing provider as a template. Current owners are:
|
||||
|
||||
- `server/providers/cctv/sources.js` for standard open-data loaders.
|
||||
- `server/providers/cctv/<provider>.js` for a provider with its own parser,
|
||||
fallback, or more involved URL policy.
|
||||
- `server/providers/cctv/catalog.js` to import the loader and add it to
|
||||
`LIVE_PACKS`. Each pack is independently gated and the shared catalog cap
|
||||
allocates space across packs.
|
||||
- `server/providers/cctv/constants.js` for provider endpoints, caps, and
|
||||
timeouts.
|
||||
|
||||
Return normalized camera records with stable unique IDs, readable names,
|
||||
`city`/`cityId`, provider attribution, valid `lat`/`lon`, and the correct
|
||||
`feedType`. Include camera heading only when the source actually provides a
|
||||
bearing; descriptive direction text is not a numeric heading. Mark uncertain
|
||||
heading data with the existing low-confidence conventions. Set `sourceKind` so
|
||||
health and source diagnostics identify the provider. Apply a bounded per-pack
|
||||
camera limit and geographic filtering where appropriate; avoid one provider
|
||||
silently consuming the whole shared catalog cap.
|
||||
|
||||
Camera coordinates need their own verification. Prefer the original provider's
|
||||
camera coordinates, then corroborate them against its location description,
|
||||
route/mile marker, or official map. An aggregator's map dot, a town/county name,
|
||||
or a geocoded attraction name is not a precise camera position. Do not pin a
|
||||
camera to a city center or nearby landmark; if its actual location cannot be
|
||||
verified, leave it out until it can. For manually curated coordinates, record
|
||||
the evidence in the provider data or an adjacent comment. For example, TW Live
|
||||
camera pages identify the originating agency; use that agency's catalog for
|
||||
the camera point when available.
|
||||
|
||||
If the source becomes opt-in, use the catalog's opt-in gate (`envOptIn`) and
|
||||
document the flag and reason in `DATA_SOURCES.md`. Otherwise use the normal
|
||||
enabled-by-default flag pattern and document how to disable the pack.
|
||||
|
||||
## 3. Keep frame and media requests server-registered
|
||||
|
||||
The browser should request the local `/api/cctv/frame/:id` or
|
||||
`/api/cctv/media/:id` endpoint. Do not pass an arbitrary upstream URL from the
|
||||
browser, and do not fetch an unvalidated URL supplied by a catalog record.
|
||||
Every provider must have a server-side rule that pins frame and media URLs to
|
||||
the expected scheme, host, and path; use HTTPS where the provider supports it.
|
||||
Use `pinnedCameraUrl()` for URLs that belong under a fixed provider directory,
|
||||
or a stricter provider-specific validator when hosts or paths vary. Register
|
||||
any new permitted image/media origin in the existing
|
||||
`server/providers/cctv` media policy.
|
||||
|
||||
Catalog and media loaders must also:
|
||||
|
||||
- refuse unsafe schemes, credentials, unexpected ports, redirects outside the
|
||||
registered origin/path, and malformed identifiers;
|
||||
- set request deadlines and cap catalog and buffered frame bodies;
|
||||
- parse only the expected format and validate coordinates before returning a
|
||||
camera;
|
||||
- handle offline cameras and partial provider failures without failing the
|
||||
entire merged catalog; and
|
||||
- avoid persistent frame storage unless the provider's terms explicitly allow
|
||||
it and the design has been reviewed.
|
||||
|
||||
For live media, prefer the existing proxy and supported feed type (`mjpeg`,
|
||||
`mp4`, `webm`, or `hls`). A direct browser-only video is a narrow fallback for
|
||||
a source whose terms allow embedding when the browser can play it but the app
|
||||
server cannot fetch it. Unconfirmed embedding terms make it opt-in like any
|
||||
other pack, and the browser loads it only after an explicit play action, never
|
||||
on selection, because the provider sees the viewer's IP address. Build its URL
|
||||
from a validated provider ID on the server; never accept a browser-supplied
|
||||
media URL. Render it in a native DOM
|
||||
`<video>` without `crossOrigin`, and do not read its pixels into canvas, WebGL,
|
||||
or a Cesium texture. If a map preview is needed, a second video request may be
|
||||
paused as a DOM overlay at the camera location; label it as an approximate
|
||||
still and scale it with map distance. If terms disallow embedding, use a source
|
||||
link instead. Read [SECURITY.md](../SECURITY.md) before changing URL
|
||||
validation, redirects, or proxy behavior.
|
||||
|
||||
When an image provider sets a minimum refresh interval, enforce it at the
|
||||
server frame boundary as well as in the client. A bounded in-memory cache and
|
||||
single-flight request per camera prevent projection, panel, and re-selection
|
||||
paths from multiplying upstream requests; do not persist frames unless the
|
||||
provider's terms explicitly allow it.
|
||||
|
||||
## 4. Add focused regression coverage
|
||||
|
||||
Add provider tests under `src/data/` following the existing CCTV test naming
|
||||
and fixtures. Cover at least:
|
||||
|
||||
- representative valid catalog rows and their normalized output;
|
||||
- missing or invalid IDs, coordinates, and frame URLs;
|
||||
- duplicate records and the pack's cap/filter behavior;
|
||||
- unsafe host/path, credentials, and redirect cases;
|
||||
- timeout, oversized, malformed, and unavailable upstream responses; and
|
||||
- the frame or live-media content type the provider actually serves.
|
||||
|
||||
Use `src/data/cctvProxy.test.mjs` for shared proxy/media behavior and
|
||||
`src/data/cctvCatalogCap.test.mjs` for shared catalog allocation. For a
|
||||
browser-only source, verify native playback, denied canvas pixel access, the
|
||||
paused second-player overlay, and its cleanup separately from proxy tests. Run
|
||||
the focused provider and proxy tests, then the standard checks from
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) that apply to the change. For runtime
|
||||
verification, inspect `/api/cctv/sources`, `/api/cctv/health`, and the camera's
|
||||
`/api/cctv/frame/:id` or `/api/cctv/stream/:id` response locally. Confirm a
|
||||
successful frame is from the upstream camera by checking its content type and
|
||||
`X-CCTV-Source`; a fallback image is not proof that the upstream feed works.
|
||||
|
||||
## 5. Update source documentation and submit
|
||||
|
||||
- Add the provider, endpoint, terms/license, required attribution, key status,
|
||||
caching behavior, and any restrictions to `DATA_SOURCES.md`.
|
||||
- Update the CCTV source section in `docs/CURRENT-STATE.md` when runtime
|
||||
behavior, flags, caps, or fallback behavior changes.
|
||||
- Register in-app attribution through the existing source/provider credit
|
||||
fields; update `src/data/dataCredits.js` when the source needs a layer-level
|
||||
credit.
|
||||
- Add the required `changelog.d/<pr-number>-<slug>.md` entry for a runtime
|
||||
change, and include the source, license, attribution, key/runtime-fetch
|
||||
details, and verification evidence in the PR description.
|
||||
- For a new layer, also follow the camera/source requirements in the
|
||||
[new-layer PR checklist](../CONTRIBUTING.md#adding-a-new-layer).
|
||||
@@ -163,8 +163,7 @@ local composition mounts them in the same order. Importing the entry performs
|
||||
no acquisition; factory calls create independent cache state.
|
||||
|
||||
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
|
||||
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
|
||||
30-day detailed feed.
|
||||
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
|
||||
Callers supply the group or end date and own validation, credentials, transport,
|
||||
response limits and cache policy. The boundary gate checks this portable entry
|
||||
separately from the Node providers. Satellite rendering and launch replay remain
|
||||
|
||||
+81
-507
@@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
413 before disk-cache loading or upstream work; malformed coordinates remain 400.
|
||||
The browser continues to split larger workloads into sequential 64-point requests.
|
||||
|
||||
Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading.
|
||||
|
||||
Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and
|
||||
the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is
|
||||
@@ -250,6 +250,7 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -741,12 +742,6 @@ geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
|
||||
advisory provenance, source position, the English classification name and,
|
||||
while the proxy serves its cache, the cache fetch time. The automatic row
|
||||
selection does not claim shared context; refreshes do not re-announce it.
|
||||
Clear or disable reports `deliberate`; a storm leaving the feed reports
|
||||
`evicted`, with no fallback.
|
||||
Consult the linked official advisory for safety decisions.
|
||||
|
||||
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
|
||||
@@ -1524,297 +1519,6 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti
|
||||
and queued panel work stop before asynchronous layer restoration; final camera
|
||||
and portal cleanup follows that restoration.
|
||||
|
||||
### Ship bridge view
|
||||
|
||||
With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
|
||||
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a
|
||||
selected vessel; Contacts and every other layer may be off, before or during
|
||||
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode
|
||||
change still in flight blocks entry until it settles. Double-clicking the
|
||||
selected vessel enters it, and the entry button reads BRIDGE with a ship icon
|
||||
both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD
|
||||
readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the
|
||||
HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends
|
||||
the bridge: the layer then has no subject (`getCockpitSubject` returns null).
|
||||
The bridge picture uses AIS alone, so no other feed being off makes it partial.
|
||||
Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only;
|
||||
voice `enter` on a bridge or in an orbital view is refused before it touches
|
||||
tracking, with the reason to exit it first.
|
||||
The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's
|
||||
sprite, card, detection bracket and trail while it is held
|
||||
(`setCockpitSubject`), and
|
||||
ignores scene clicks so open water cannot deselect it. The eye sits on the AIS
|
||||
position at a representative bridge height for the AIS type family
|
||||
(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m,
|
||||
tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text
|
||||
type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no
|
||||
family, including the reserved and wing-in-ground codes the vessel layer also
|
||||
files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor
|
||||
(`src/cockpitSubjects.js`). The camera faces true heading (course over ground
|
||||
when heading is missing) and moves along course over ground. A ship reporting
|
||||
neither has no direction: the camera keeps the heading it had on entry, the
|
||||
heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal
|
||||
names the ship without a heading, no bow or stern mark is drawn, and the eye
|
||||
stays on the fix rather than moving along a made-up course. The vessel layer
|
||||
redraws a ship only on its 60-second refresh, so the camera's target is the last
|
||||
fix dead-reckoned along course over ground for up to 180 s, and the existing
|
||||
inertial anchor absorbs each new fix. The fix's age is read without comparing
|
||||
the two clocks (`vesselFixAgeSec`): the time since this client first received
|
||||
the fix, plus how far it trailed that refresh's newest fix, so a client clock
|
||||
running fast or slow neither projects the ship nor marks it stale, and a fix
|
||||
the feed keeps re-serving from its cache goes on ageing into STALE FEED. A
|
||||
stale vessel, or a projection past that limit, holds the camera on the target.
|
||||
The vessel layer floats ships on the geoid, which on a river or lake can lie
|
||||
well below the water (the Seine through Paris is ~27 m up), so the bridge also
|
||||
reads the rendered surface around its ship: the ship's position and a ring of
|
||||
eight probes 30 m out, the lowest probe taken as the water when it is 2 m to
|
||||
4,000 m above sea level. The first read starts from the tiles already loaded, then
|
||||
the full-detail read replaces it; it repeats every 5 s. Only one full-detail
|
||||
read runs at a time: while one is still running, a refresh reads the loaded
|
||||
tiles instead, and the slow read applies whenever it lands. Once a
|
||||
full-detail read has answered, loaded-tile reads no longer change the height;
|
||||
before then they may find raised water but never take it back to the sea.
|
||||
A read that sampled nothing, or failed, keeps the height it had and is
|
||||
tried again on the next refresh.
|
||||
|
||||
The HUD keeps its frame, vision styles, briefing, weather and Display/Radio
|
||||
utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows
|
||||
speed over ground in knots, true heading and course over ground in place of
|
||||
altitude, hides the altitude tape, and shows the crew-entered AIS destination as
|
||||
a Reported Voyage card without an origin or direction arrow. The arrow keys
|
||||
look around the bridge in 5° steps (Shift for 45°): Left and Right turn the
|
||||
view all the way round, Up and Down pitch it up to ±90°, and the camera eases
|
||||
there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD
|
||||
button shown while the view is turned, bring it back over the bow at the rest
|
||||
pitch. The keys stay with a focused text field, slider, tab, radio group, list
|
||||
or menu. The heading tape is a straight strip that scrolls continuously with
|
||||
the camera, so the caret reads the view's exact heading, while the readout
|
||||
above it keeps the ship's true heading; a ring with a centre dot marks the bow
|
||||
(prograde) and a ring with a cross the stern (retrograde) at their bearings
|
||||
from the caret. The bridge radar
|
||||
shows nearby AIS ships heading-up and opens at 3 NM
|
||||
(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps
|
||||
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship
|
||||
sits where the camera rides (its fix dead-reckoned) and contacts at their
|
||||
last fixes, where the vessel layer draws them, so the scope matches the view
|
||||
out of the window. Contact
|
||||
Previous/Next and Live Signals aircraft handoffs are refused on the bridge and
|
||||
the Contact card stays hidden. Exit returns the camera to the vessel's map view
|
||||
through the layer's own focus. Tracking an aircraft or losing the vessel
|
||||
selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs`
|
||||
covers entry, readouts, refusal and exit.
|
||||
|
||||
### Orbital view
|
||||
|
||||
With a satellite tracked and no aircraft tracked, Cockpit can ride the
|
||||
satellite instead. Entry needs only the Satellites layer
|
||||
(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other
|
||||
layer may be off. A Context mode change in flight blocks entry until it
|
||||
settles. Debris (rocket bodies, fragments and dead satellites; see the
|
||||
satellite class toggles) can be tracked but never entered: no ORBIT entry is
|
||||
offered and double-click refuses it. A tracked aircraft still takes
|
||||
entry first, and a tracked satellite
|
||||
takes it before a selected vessel. Double-clicking the tracked satellite
|
||||
enters it, and the entry button reads
|
||||
ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel
|
||||
HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays
|
||||
aircraft-only; its state reports an orbital view in use as a `satellite`
|
||||
subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD
|
||||
id.
|
||||
|
||||
The camera sits on the satellite's own per-frame SGP4 position
|
||||
(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's
|
||||
centre track-up: the ground track points to the top of the frame, so the
|
||||
picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A
|
||||
satellite with no ground track (a geostationary one) is shown north-up, and
|
||||
over a pole the previous frame's up is kept. It updates on every rendered
|
||||
frame, with no ground clamp. Entry opens in the plain optical vision style
|
||||
(the other styles stay one click away) and leases the map for flat imagery:
|
||||
with Google 3D tiles shown, which stay coarse from hundreds of kilometres up,
|
||||
the map switches to Esri imagery, and exit puts it back unless the operator
|
||||
picked another map meanwhile; another owner already holding the map (an
|
||||
imagery comparison) keeps it as it is. While orbit holds the lease, Recent
|
||||
Imagery cannot take it: turned on in orbit, the layer reports the comparison
|
||||
in use until, after exit, it is turned off and on again or its shown days
|
||||
are cleared and picked again. The Cockpit cloud pass is off in orbit
|
||||
(it fades out above 24 km); cloud cover from orbit is the Weather layer's,
|
||||
and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked
|
||||
companions, its own orbit ring and its detection bracket, and draws every
|
||||
other orbit ring (the ISS ring included) at 30% of its alpha; other
|
||||
satellites' points and labels stay. Scene clicks and the layer's Escape are
|
||||
ignored by the satellites layer while it is ridden (a click on a mapped
|
||||
site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for
|
||||
every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and
|
||||
the mouse wheel (one step per notch; trackpad scrolling is summed to a step):
|
||||
the 1× frame is 60° vertical in low orbit and fits the Earth's disk with
|
||||
margin from high orbits, each step divides the tangent of the half-angle, and
|
||||
Cesium's `frustum.fov` is set from it for the canvas aspect. A step animates
|
||||
over 400 ms with a focus-pull blur and a reticle lock tick, and cuts at once
|
||||
under `prefers-reduced-motion`. An imaging satellite matched to an entry of
|
||||
the bundled sensor pack (`src/data/local_data/imaging_sensors/`, by NORAD
|
||||
number or name in `src/layers/satellites/sensors.js`) whose narrowest swath
|
||||
is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at
|
||||
the satellite's live altitude and the window's shape is still at least as
|
||||
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
|
||||
cap falls below the zoom shown, the view steps back to it. Unconfirmed
|
||||
swaths add no steps, but are still drawn, read out and offered to SENSOR.
|
||||
The sensor's swath is drawn to scale as a strip along the ground track (its
|
||||
width follows the view through a zoom step, not ahead of it) with
|
||||
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
|
||||
it lies between 1× and the cap. No strip is drawn for a geostationary view
|
||||
(no ground track; its sensors image areas, not strips) or a SAR (it images
|
||||
to one side of nadir, not under it); the readout stays. Tilted off nadir
|
||||
the strip stays (view and swath widen alike), the readout scales as the swath
|
||||
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
|
||||
hidden; tilted past the Earth's limb neither the sensor nor the swath
|
||||
reads. A swath is confirmed only on the operator's, an agency's (ESA's
|
||||
eoPortal included) or an academic page for its platform. From
|
||||
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
|
||||
nadir, every 2 s the view asks the base imagery layer for the tiles it will
|
||||
need 5 to 20 s ahead along the ground track (`prefetchImageryAhead` in
|
||||
`src/ui/cockpitOrbitalView.js`), so the browser has them cached when the
|
||||
view arrives. The level is the one Cesium will draw there
|
||||
(`imageryLevelForView` in `src/cockpitOrbital.js`, following Cesium's own
|
||||
choice: the terrain level whose error spans at most the globe's maximum
|
||||
screen-space error, then the imagery level whose texels are nearest that
|
||||
error, about one to two screen pixels per texel). It asks for at most 12
|
||||
tiles at a time through Cesium's request scheduler, throttled per server and
|
||||
at the lowest priority, so a request is declined while the imagery server's
|
||||
slots are taken by the tiles on screen and is the first dropped when the
|
||||
scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask
|
||||
for those again, starts over on another base map (cancelling the old map's
|
||||
pending requests), and cancels the requests still pending on exit. Across the track it samples the centre line, a third
|
||||
out and the frame's edges. While tilted off nadir the view looks away from
|
||||
the track, and with the world clock more than a minute from now (paused or
|
||||
played back) it will not pass where the look-ahead points, so nothing is
|
||||
pre-loaded. The arrows tilt the view off nadir in 5° steps
|
||||
along the frame's axes (Up/Down tilt forward/aft along the ground track,
|
||||
Left/Right across it, each axis to ±45°; Shift takes no larger step); Home,
|
||||
`0` or NADIR return to nadir. While tilted the panel shows the total angle
|
||||
from nadir, acos(cos along · cos across), before each axis
|
||||
(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view)
|
||||
and a NADIR button, and the swath readout turns approximate
|
||||
(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the
|
||||
nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of
|
||||
the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the
|
||||
footprint without ≈. After a zoom step lands, the focus pull holds until the
|
||||
imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then
|
||||
the reticle locks. The look keys and zoom keys keep the bridge's focus
|
||||
guards.
|
||||
|
||||
In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists
|
||||
what is around the satellite, as the aircraft Cockpit lists the contacts
|
||||
of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at
|
||||
most, rebuilt once a second with stable keys, so a row keeps its place and
|
||||
time. TRACK ACQUIRED stays first. Then:
|
||||
|
||||
- **Sites**: the Mapped Installations layer's loaded records around the
|
||||
sub-satellite point (`getNearby`, asked only as far as the reach extends
|
||||
over the ground, and never past 3,000 km, so from a high orbit only the
|
||||
sites within 3,000 km of the point below are listed) that face the
|
||||
satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row
|
||||
reads the site's name over its class and off-nadir angle; its button
|
||||
(`Keep the camera on …`) focuses the site, and on the focused site's row,
|
||||
marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends
|
||||
the focus. With Mapped Installations off the layer keeps its records, so
|
||||
nothing is listed then: a SITES STANDBY row says to turn the layer on.
|
||||
- **Satellites**: the nearest shown satellites to the ridden one, debris
|
||||
left out (the satellites layer's bounded `getNearestPositions`, measured
|
||||
from the satellite itself), with their bearing from the top of the frame
|
||||
and distance. A row (`Ride …`) rides that satellite. While there is a
|
||||
satellite ridden before this one, a RETURN · *name* row goes back to it; if
|
||||
that satellite has left the catalogue it is dropped and RETURN offers the
|
||||
one before. Riding stays in orbit: the satellites layer tracks the new
|
||||
satellite (`trackById`), the Cockpit takes the camera back at once and
|
||||
marks it as the ridden subject, any ground focus ends with the view easing
|
||||
back to nadir, and the camera hops to the new satellite along the great
|
||||
circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the
|
||||
camera is on the new satellite the page shows only TRACK ACQUIRED and
|
||||
RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds
|
||||
at once. The new satellite's TRACK ACQUIRED carries the time it was
|
||||
ridden. The hop starts from where the camera was, which does not follow
|
||||
the old satellite on.
|
||||
|
||||
Sites take two rows while satellites fill the rest, more when there are
|
||||
fewer satellites. The Contact card stays hidden in orbit.
|
||||
|
||||
The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in
|
||||
`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's
|
||||
position to the site's Earth-fixed position in the same track-up nadir
|
||||
frame the arrows tilt, so the site stays at the centre as the satellite
|
||||
moves. The turn onto it (and between sites) eases what is left of the swing
|
||||
with the arrows' easing on a slower clock (250 ms time constant, about
|
||||
0.75 s to 95%), then holds the site exactly, and cuts under
|
||||
`prefers-reduced-motion`. A single click on a site's marker focuses it too:
|
||||
the pick is resolved through the layer's own pick ids (`findByPick`: a named
|
||||
marker's `{ installationId }` or a site entity). A click anywhere else
|
||||
(a site beyond the reach included), the
|
||||
focused site's row, NADIR, Home or `0` ends the focus and the view eases
|
||||
back to nadir on the same clock; an arrow ends it (or the ease back) and
|
||||
tilts on from where the view is. The layer reloads its thinned view as the
|
||||
camera moves (about once a second in orbit), so a site in view can leave
|
||||
its records for a moment: while the layer is on, a site seen in the last
|
||||
10 s stays a candidate (sites do not move), so the rows hold still and a
|
||||
row's site can be focused, and a focused site keeps its last position. The focus ends by
|
||||
itself, easing back, when the site leaves the reach (either tilt axis past
|
||||
±45°, or the site below the satellite's horizon), when the layer is turned
|
||||
off, when another satellite is ridden, and with the orbital view. A site
|
||||
beyond the reach cannot be focused. While focused the readout gives the
|
||||
single off-nadir angle to the site, as image metadata does
|
||||
(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it
|
||||
in a polite live region (`FOCUS · Camp Alpha`), and the swath reads
|
||||
approximate as for a tilt, scaled by the tilt onto the site. Clicks still
|
||||
reach Cesium's own handler and never untrack the satellite; a click a tool
|
||||
owns (drawing) is ignored, and the draw tool still borrows the click while
|
||||
it draws. A double-click on a site keeps or starts the focus on it and
|
||||
never reaches Cesium's double-click, which would track the site and end the
|
||||
view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any
|
||||
other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs`
|
||||
covers the rows, the easing in and out, the reach release, switching sites
|
||||
and satellites, the hop, the layer turned off, click handling and the
|
||||
readout, against the layers' method shapes.
|
||||
|
||||
The HUD (`#cockpit-hud[data-subject='satellite']`, body class
|
||||
`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class,
|
||||
altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a
|
||||
ground track) with a pointer that turns to keep north marked N on the frame,
|
||||
zoom and swath. Under
|
||||
the vision selector a "CURRENTLY OVER" band names what the sub-satellite point
|
||||
is over, looked up offline from the bundled Natural Earth packs
|
||||
(`src/data/placeBelow.js`: on land the first-level division as Natural Earth
|
||||
names it and the country in the reader's language through `Intl.DisplayNames`,
|
||||
"Sichuan, China", and from 8× the most prominent populated place within half
|
||||
the view's smaller side, so it is in the picture, "Sichuan, China · near
|
||||
Chengdu", from a bundled pack of Natural Earth populated places loaded on
|
||||
first use (each pack loads on the first lookup that needs it, and the
|
||||
top-centre loading status lists "Place names" until it arrives); else the
|
||||
smallest named sea, gulf, strait, channel, sound or bay;
|
||||
else the ocean; nothing in a coastline gap), at most every 2 s and only after
|
||||
20 km of ground travel, or at once when a zoom step changes how much ground
|
||||
the view shows. A division or populated-places pack that fails to load drops
|
||||
only that part (the country still shows); a lookup that fails outright hides
|
||||
the band rather than leave the last place up, and the next HUD beat looks
|
||||
again. The readout card puts
|
||||
the owner country's flag (drawn from one 15 KB sheet of every owner
|
||||
country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs`
|
||||
and loaded at startup, so a ride never waits for a flag; if the sheet will
|
||||
not load the country code shows as a badge) or a text badge for an organisation before the name, the operator
|
||||
under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element
|
||||
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
|
||||
side, between the epoch and the world-clock time the satellite is shown at), whose TLE button
|
||||
unfolds the two element lines; each row is left out when the satellites layer does not
|
||||
know it. The
|
||||
aircraft and bridge instruments, tapes, compass, radar, look controls,
|
||||
position readout, route card and Contact card are hidden; the vision-style
|
||||
switcher, briefing card (Live Signals as above, Local and Regional for the sub-satellite point; the
|
||||
regional brief refreshes at most once a minute) and the reset and EXIT ORBIT
|
||||
buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT
|
||||
ORBIT) restores the camera's own field of view exactly and returns to the
|
||||
satellite's follow camera through the layer's `refocusTrackedById`. Losing
|
||||
the subject (tracking stopped, Satellites off) or tracking another target
|
||||
ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs`
|
||||
covers entry, camera, zoom, look, HUD and exit.
|
||||
|
||||
## Context coordination
|
||||
|
||||
Context controls own Contacts/Space Missions state, entry and exit transactions,
|
||||
@@ -1849,32 +1553,6 @@ owners. Tuner calculations have a pure entry, with existing layer exports
|
||||
preserved. Destruction removes listeners and state subscriptions before ending
|
||||
tuning; a delayed Enable result cannot reveal or refocus removed controls.
|
||||
|
||||
## Giroc / Webcam Romania CCTV source pack
|
||||
|
||||
`config/cctv_sources.romania.json` is an optional single-camera pack for Giroc,
|
||||
Timiș, Romania. To use it, set `CCTV_SOURCES_FILE=config/cctv_sources.romania.json`
|
||||
in the local `.env` and restart the server. Set `CCTV_FORCE_AUSTIN=1` as well to
|
||||
retain the built-in live packs; despite its historical name, this flag applies
|
||||
to all live packs. The default catalog is unchanged.
|
||||
|
||||
The camera uses Webcam Romania's public HLS stream and JPEG snapshot, fetched
|
||||
at runtime without an API key. Its position, 45.70028947266781, 21.233675914004227,
|
||||
was corrected by the contributor rather than copied from the directory's
|
||||
inaccurate pin. Heading, pitch, field of view, mounting height and ground
|
||||
elevation are uncalibrated priors; the camera retains the RAW PRIOR badge.
|
||||
See `DATA_SOURCES.md` for attribution and provider terms. Webcam Romania's
|
||||
terms grant no reuse licence for the video and name no camera operator; the
|
||||
provider's sanctioned route is its iframe embed. Keep this pack to local use
|
||||
and off hosted deployments unless the operator grants permission.
|
||||
|
||||
The existing HLS puller resolves same-origin relative master/media references
|
||||
and serves local leased playlists and segments. It downloads complete segments
|
||||
without forwarding a browser Range header. Its per-segment streamed-byte cap
|
||||
is 8 MiB: Giroc's roughly ten-second 2702×1520 segments can exceed the former
|
||||
4 MiB cap (one measured 6,437,120 bytes). Retained data remains capped at
|
||||
24 MiB and 12 segments per session, with two sessions maximum. Redirect
|
||||
refusal, origin pinning, timeouts and lease expiry are unchanged.
|
||||
|
||||
## NYC DOT TMC CCTV source pack
|
||||
|
||||
The CCTV catalog can add New York City traffic cameras from the NYC DOT
|
||||
@@ -1890,11 +1568,9 @@ from the payload. There is no heading in the feed, so gizmos use the shared
|
||||
id-hash fallback at low confidence.
|
||||
|
||||
Once enabled, the 250 cameras nearest the five borough cores load.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The default catalog limit is
|
||||
7,350: 4,000 shared round-robin slots, plus room above them for the 1,120
|
||||
Québec locations and 2,230 World image sources. Lower explicit
|
||||
`CCTV_MAX_SOURCES` values are honored; reservations only use the capacity
|
||||
above the 4,000 shared slots.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
|
||||
round-robin: no region is dropped outright, but when the packs together exceed
|
||||
the ceiling, adding a pack trims cameras from the largest packs.
|
||||
|
||||
## City of Toronto RESCU CCTV source pack
|
||||
|
||||
@@ -1910,9 +1586,8 @@ cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
|
||||
|
||||
By default all ~336 cameras load, nearest downtown first.
|
||||
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the default
|
||||
7,350-source catalog limit with the other packs; Québec and the World image
|
||||
pack have reserved slots and the remaining sources use round-robin allocation.
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
|
||||
catalog-wide ceiling round-robin with the other packs.
|
||||
|
||||
## Lithuania CCTV source pack
|
||||
|
||||
@@ -1932,8 +1607,24 @@ video; camera poses use the existing low-confidence heading prior.
|
||||
|
||||
## Taiwan CCTV source pack
|
||||
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
|
||||
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
|
||||
The CCTV catalog includes Taiwan national freeway cameras from the Freeway
|
||||
Bureau, MOTC. One keyless request reads the daily MOTC 2.0 `CCTV.xml` table
|
||||
(~1,800 cameras); the request refuses redirects, has a 15-second deadline and a
|
||||
6 MiB response cap, and uses the shared 15-minute cache and fair catalog
|
||||
allocation. A failed download serves the last good table (up to a day old),
|
||||
or an empty pack, and is not retried for an hour: the catalog host does not
|
||||
answer from some networks outside Taiwan, and without the backoff every refresh
|
||||
there would wait out the connect timeout. The table
|
||||
publishes MJPEG stream URLs; each is mapped to the same camera server's
|
||||
single-JPEG path for the same camera number, and only those exact HTTPS pairs
|
||||
on the four `freeway.gov.tw` camera servers are registered with the frame
|
||||
proxy. Camera keys come from the server and camera number, because published
|
||||
CCTV IDs contain Chinese place names and a few repeat across cameras. Poses use
|
||||
the low-confidence heading prior. `CCTV_TAIWAN_ENABLED=0` disables the pack;
|
||||
`CCTV_TAIWAN_MAX_SOURCES` sets its cap (default 600, bounded to 1–2000), and
|
||||
`CCTV_TAIWAN_URL` overrides the table URL.
|
||||
Gate: `src/data/cctvTaiwan.test.mjs`.
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
|
||||
@@ -3705,7 +3396,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
|
||||
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
|
||||
> the duplicate floating map entry and topline exit; the bottom-center
|
||||
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus
|
||||
> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus
|
||||
> transfer and failure-safe shortcut routing. The exit control sits at the
|
||||
> bottom-center compass position. A Cockpit-only control strip sits
|
||||
> directly above Live Signals, anchored 12px under the REC readout it shares
|
||||
@@ -4651,35 +4342,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + 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), 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/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| 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, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + 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), 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/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
@@ -4733,10 +4424,8 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
|
||||
keyless provider (KartaView), including a restored "Mapillary on" unless the link
|
||||
switches the keyless providers off; filter-only updates never change the source.
|
||||
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
@@ -4750,7 +4439,7 @@ the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
@@ -5640,7 +5329,7 @@ silently demoting every later lookup for the session.
|
||||
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
|
||||
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
|
||||
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
|
||||
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
|
||||
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
|
||||
@@ -5661,16 +5350,14 @@ silently demoting every later lookup for the session.
|
||||
|
||||
### Motion & Symbology Correctness (June 10, 2026)
|
||||
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
- **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class.
|
||||
- Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened.
|
||||
- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not.
|
||||
- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/<group>`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead.
|
||||
- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia.
|
||||
- **The STARLINK chip (formerly DENSE) 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 `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` 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.
|
||||
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
- **Satellite classes** (`src/data/satelliteClass.js`): every satellite is classified from the CelesTrak group it was ingested with — no extra fetch and no heuristics — into STATION (warm white), NAV (cyan; GPS/GLONASS/Galileo deliberately share one color so the GNSS family reads as one thing), GEO (violet), VISUAL (muted blue-gray catch-all), and COMMS (dim slate, dense Starlink shell only). That module is the single source of truth for class, label, and color, so the dot, the card, and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and COMMS stays far below VISUAL in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATION color — it carries a permanent name label and its card still reads `STATION · ISS`. `satelliteClassOf` is the single owner of that ISS rule, so the card label and the legend tally can never disagree: during a stations-feed outage the ISS is ingested as `visual`, and both surfaces still file it under STATION.
|
||||
- Class is also a **text field**, not just a color: `NAV · GPS` / `GEO` / `COMMS · STARLINK` / `STATION · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened.
|
||||
- The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): a **DENSE** chip exposing the existing `catalog` param, and a swatch legend with live per-class counts. Default is the sparse core catalog. The chip is stateless — it declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not.
|
||||
- **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.
|
||||
@@ -5728,10 +5415,8 @@ silently demoting every later lookup for the session.
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
on the main road spine. `CCTV_MAX_SOURCES` defaults to 7,350: 4,000 shared
|
||||
round-robin slots plus 3,350 reserved Québec and World image slots.
|
||||
Reservations only use capacity above the 4,000 shared slots, so at 4,000 or
|
||||
less every pack, Québec and World included, shares the cap round-robin.
|
||||
on the main road spine. `CCTV_MAX_SOURCES` is a 4,000 catalog-wide ceiling shared round-robin
|
||||
across packs, so a lower global cap thins every region instead of starving the last pack.
|
||||
~1,100 cameras total, all RAW PRIOR poses, stills-first. The catalog lives for 15 minutes; once
|
||||
it expires, `/api/cctv` keeps answering from it while one background refresh reloads every
|
||||
pack, so only the very first load waits for the slowest pack (a live first load took about
|
||||
@@ -5920,13 +5605,6 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
explicit-empty form); unknown option tokens are ignored. Restoration
|
||||
settles independently per layer so one failed or unavailable source cannot
|
||||
block its siblings.
|
||||
- An application that registers fewer layers (an embedder with a smaller
|
||||
catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids
|
||||
the application did not register before it normalizes a share, saved state or
|
||||
explicit change, so such a layer is never restored, written to local storage
|
||||
or carried into a link this application shares. Options that depend on a
|
||||
dropped layer (a tracked selection) go with it. An application with every
|
||||
layer registered is unchanged.
|
||||
- Layer tokens are permanent public compatibility identifiers. The reservation
|
||||
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
|
||||
one-character assignments (all letters plus `0`, `1` and
|
||||
@@ -6203,11 +5881,8 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
|
||||
deadline, with Photon requests capped at six seconds each. Caller/application
|
||||
cancellation stops retries and late cache writes. Replacing a location search
|
||||
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
||||
candidates, and an unbiased retry for name mismatches. Name comparison folds
|
||||
Latin accents while preserving letters and marks in other scripts, so a
|
||||
non-Latin name spelled exactly, marks included, keeps its proximity ranking. A
|
||||
leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a
|
||||
candidate named exactly for the place whose city, county, state,
|
||||
candidates, and an unbiased retry for name mismatches. A "place, region" query
|
||||
prefers a candidate named exactly for the place whose city, county, state,
|
||||
country or country code (or USA/UK) contains every word of the region, and a
|
||||
settlement or administrative area over a POI when both fit. Nearby results that
|
||||
fail that check are retried unbiased. After the region checks of both passes, a
|
||||
@@ -6310,9 +5985,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- UTILITIES ▸ **Draw** turns the globe into a whiteboard drawn by hand. Pick a shape (icon buttons for Area, Line and Pin) and a colour swatch for the next shape, click the vertices on the real world, and finish. **Keyboard contract, live only while Draw is on and never while you are typing in another field:** Enter finishes the shape (as does a double-click, and the two cannot both submit it — the session is replaced before the annotate call is awaited); Backspace removes the last vertex; Esc cancels the shape in progress, and a second Esc — Esc on an empty session — leaves draw mode. **Clear** wipes the board (the shape in progress and every placed mark), so removing marks does not require the console or the microphone.
|
||||
- **Drawn shapes are listed under Draw like Import's files** (`src/ui/views/panels/DrawMarkList.vue`, rows worded by `presentDrawMarks` in `src/annotations/drawMarks.js`): one row per hand-drawn mark (`manual: true`; voice marks are not listed) with its colour swatch, its name, and show/hide, fly-to and remove. The draw tool publishes the rows with the rest of its controls and answers the row actions (`renameDrawMark`, `recolorDrawMark`, `toggleDrawMark`, `flyToDrawMark`, `removeDrawMark`) through the display store; it republishes on the engine's `onChange`, so a voice clear or an expiry updates the list. Rows are keyed and updated in place: a board change never takes a name being typed (the field keeps its draft while focused) or the button under a click. The name is saved on Enter or on leaving the field, once; Esc restores it, before anything else and with the row's colours open too. Both keep focus in the field, so neither drops it to the page or leaves Draw. The swatch opens the five colours right after it (shown at the end of the row), with the current colour focused; the arrows, Home and End try another, and a click or Esc closes them and returns focus to the swatch. Both colour groups (the next shape's and a row's) are one `DrawSwatches` radio group with a single tab stop, coloured from `DRAW_COLOR_CSS`; on touch screens each dot answers a 24 px target. An unnamed shape shows its kind, and an unnamed line its length; a renamed line keeps its length (`Ridge walk — 950 m`). **Hidden** keeps a mark on the board at alpha 0, and the world renderer takes every one of its entities out (`show = false`), so no material that keeps its own colour (the line's flowing stroke) still paints it; the screen renderer does not project a hidden callout, so it cannot push a visible label aside; `fly_route` skips a hidden route; and with every mark hidden the annotations render hold is released. **Fly to** is an immediate navigation (`runImmediateNavigation('drawing', …)`), like Import's: Cockpit refuses it and a running orbit, follow or track lets go first. A line is framed by its whole path, across the antimeridian too.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect, markActions })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- **One shape, handed back.** `createApplicationTools` also returns `drawShape({ shape, signal })` (`drawTool.drawShape`): it opens draw mode for one `area` or `line` and resolves with its geometry, a closed `[lon, lat]` `Polygon` or a `LineString`, without placing a mark; the shape chosen before comes back and draw mode ends. While it is open the controls cannot change the shape, and one across the antimeridian is refused as a row's actions refuse it ("That shape could not be placed.") and drawn again. It resolves `null` when the drawing is cancelled (the second Escape), draw mode is left, `signal` aborts, another request replaces it, the pointer is held elsewhere, the tool is destroyed, or the page has no Draw controls. It returns `pointerFree` (`data/inputOwnership.js` `isPointerFree`) too, so an application's own click handlers yield to the tool holding the pointer, as the layers' do.
|
||||
- **Row actions are an application's own.** `createApplicationTools({ drawMarkActions })` hands `initDrawTool` buttons for drawn areas' and lines' rows; the standalone shell passes none, so the open-source build shows no new button and makes no new request. Each action (`drawMarkActions` in `src/annotations/drawMarks.js`) is `{ id, icon, label(name), shapes: ['area', 'line'], accepts?(geometry, shape), run({ id, shape, name, title, geometry }) }`: the icon a Material Symbols name, the label worded by the application, `name` the person's own and `title` the row's (the name, else `Area` or `Line · 950 m`), the geometry `drawMarkGeometry`'s `[lon, lat]` `Polygon` (closed; a `MultiPolygon` for an area handle with several parts) or `LineString`, longitudes wrapped into [-180, 180]. A pin, and a shape that crosses the antimeridian or has a part that cannot be given whole, has no geometry and is offered nothing. Malformed actions and repeated ids are dropped once, at construction. What `run` returns or resolves to is a note under the row (`role=status`; past 200 characters it is cut at a word with an ellipsis); the row is busy (`aria-disabled`, no second press) until it settles, a rejection or a throw clears the note and is logged (so an action words its own refusals and resolves with them), and a shape's note goes with the shape, so a late answer never lands on a removed one.
|
||||
- Runtime entry: `createApplicationTools` (`src/app/tools.js`, behind the `src/standalone/tools.js` shim) calls `initDrawTool({ viewer, annotations, navigation, publish, connect })` after `initAnnotations` and defers its `destroy()`, so disposing the shell returns every DOM listener, the Cesium handler, the preview data source, the borrowed click actions and the `window.__gevDrawTool` handle. Its controls are Vue views in the UTILITIES panel (`DrawControls.vue`, `DrawMarkList.vue`) that render what the tool publishes to the display store and call its actions. That handle (`setActive`, `setShape`, `addVertex(lon, lat)`, `finish`, `cancel`, `clearAll`, `marks`, `setColor`, `diagnostics`) is the no-mouse test seam.
|
||||
- Contract: a finished shape is submitted through the SAME `annotationEngine.annotate()` the voice agent uses, with geometry supplied and `manual: true` (`type: area` + `ring`, `type: route` + `path`, `type: pin` + coordinates). `resolveSpec` short-circuits manual specs (`resolveManualSpec`) so no geocoder, Places or Overpass call is made. A drawn area is a real (not synthesized) outline and drapes solid; a drawn line is a `route` with `mode: 'manual'`, labelled with its length and never a travel time; a pin is a point. Drawn marks therefore render with the whiteboard look, persist, appear in `.list()`, and clear with `clear_annotations` / `.clear()`. A drawn shape is never merged with another as a duplicate: every one is drawn on purpose, and every one is unnamed until its row names it, so two pins dropped close together are two pins.
|
||||
- **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it.
|
||||
- **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen".
|
||||
@@ -6332,7 +6005,6 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
||||
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
|
||||
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
|
||||
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
|
||||
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
|
||||
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||
@@ -6572,22 +6244,19 @@ apply to `public/` assets or built-preview serving.
|
||||
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
|
||||
layer, Live AIS Vessels and every other layer may be turned on or off before
|
||||
or during a session. A Context mode change still in flight blocks entry until
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft,
|
||||
or a vessel from its bridge, while Contacts is on adopts its layer as the
|
||||
user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts
|
||||
ending, by its own switch or one of its layers going off, restores the
|
||||
pre-Contacts layers but keeps the ridden one and the view; Live Signals then
|
||||
drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible
|
||||
Cockpit actions, double-click and voice use the same gate; voice entry still
|
||||
opens Contacts first, as its layer-targeted entry navigates through Contacts.
|
||||
The ship bridge and the orbital view have their own own-layer gates
|
||||
(`bridgeEntryAllowed`, `orbitEntryAllowed`; see
|
||||
[Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)).
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving bothLine truncated
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft
|
||||
while Contacts is on adopts its layer as the user's own, as a direct selection
|
||||
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
|
||||
its layers going off, restores the pre-Contacts layers but keeps the ridden
|
||||
one and the view; Live Signals then drops the rows Contacts built and shows
|
||||
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
|
||||
the same gate; voice entry still opens Contacts first, as its layer-targeted
|
||||
entry navigates through Contacts.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
|
||||
- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`.
|
||||
- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`.
|
||||
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
|
||||
@@ -6757,26 +6426,7 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
- Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has
|
||||
TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer
|
||||
state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid`
|
||||
overrides restored state until the user picks a chip. A host may offer
|
||||
fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset,
|
||||
default all three): only offered modes get chips (none for a single mode),
|
||||
and other choices from chips, restores, share links or the query are
|
||||
ignored. Without `osm` the layer never simulates: keyless or with the
|
||||
status probe unreachable, loads fail with "TomTom roads need a TomTom key"
|
||||
or "TomTom roads unavailable while the traffic service is unreachable" and
|
||||
no roads are drawn.
|
||||
- Row toggles (live mode only): after the road-source chips, Lines draws
|
||||
every road with flow as a steady draped line in its bucket color (green
|
||||
free, amber slow, red jam; jam first, then slow, then free, longest first,
|
||||
capped at 1,500; on 3D tiles under photoreal, on terrain or tiles
|
||||
otherwise), and Dots shows or hides the moving dots (hidden dots are kept,
|
||||
are not offered to detection overlays, and hold no render frames).
|
||||
Without live flow only a hidden Dots toggle is offered, so dots hidden by a
|
||||
live choice, a restore or a share link can always be shown again.
|
||||
Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the
|
||||
layer state `lo` field (`l`, `d`). Toggling either restyles in place, with
|
||||
no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines
|
||||
is off. With no stored choice
|
||||
overrides restored state until the user picks a chip. With no stored choice
|
||||
the default is Hybrid with a TomTom key and OSM without one; share links
|
||||
without a road-source choice (including older ones) likewise use the
|
||||
installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key
|
||||
@@ -7420,79 +7070,3 @@ the container healthy. Checks time out after 10 seconds, with a 30-second
|
||||
startup grace period and three consecutive failures before reporting unhealthy.
|
||||
This reports app HTTP availability, not individual provider health, and does not
|
||||
configure automatic restarts.
|
||||
|
||||
## Québec CCTV integration
|
||||
|
||||
The regular CCTV source loader reads `public/quebec-cameras.json` by default
|
||||
when `CCTV_QUEBEC_ENABLED` is not `0`. The pack contains 1,120 records: 680
|
||||
MTQ highway cameras, 426 Montréal OSM camera locations, and 14 scenic webcams.
|
||||
The default catalog limit is 7,350: it reserves all 1,120 Québec locations and
|
||||
2,230 World image feeds, and keeps 4,000 slots for the existing round-robin
|
||||
camera packs. Reservations only use capacity above those 4,000 slots: a lower
|
||||
explicit `CCTV_MAX_SOURCES` thins every pack round-robin, Québec and World
|
||||
included, instead of taking the shared slots.
|
||||
|
||||
The Québec 511 video is opt-in (`CCTV_QUEBEC_VIDEO_ENABLED=1`) because Québec
|
||||
511's terms for embedding its clips on another site are unconfirmed; the camera
|
||||
locations themselves are CC BY 4.0. With the flag unset, MTQ cameras show
|
||||
their location, credit and a link to the Québec 511 viewer page. With it set,
|
||||
selecting an MTQ camera opens a floating panel with a Play button and a notice
|
||||
that Québec 511 sees the viewer's IP address; the browser contacts Québec 511
|
||||
only after Play, never on selection alone (layer enable, auto-hop, a scene or
|
||||
voice), and a different camera starts unplayed. After Play, the panel plays
|
||||
the official MP4 in a native browser player and requests a second video for the
|
||||
paused map preview. The official feed is a five-second clip refreshed by
|
||||
Québec 511 every 2–4 minutes, not continuous live video; the browser player
|
||||
reloads every two minutes while the tab is visible. The
|
||||
map preview is a DOM overlay attached to the camera's coordinates and scales
|
||||
down with camera distance. Neither video is read into a canvas or Cesium texture:
|
||||
Québec 511 does not grant localhost pixel access, and its MP4 is not available
|
||||
to the app's server proxy. Montréal and scenic entries are regular CCTV camera
|
||||
locations with per-camera source-page links, but no direct frame URLs; those
|
||||
show a location label and open the official/operator page. MTQ positions carry
|
||||
CC BY 4.0 attribution, Montréal positions are attributed to OpenStreetMap
|
||||
contributors under ODbL, and scenic rows retain their position accuracy and
|
||||
provenance. Québec cameras load through the normal CCTV layer.
|
||||
|
||||
## City of Ottawa CCTV source pack
|
||||
|
||||
The CCTV catalog can add City of Ottawa traffic cameras, including the MTO
|
||||
cameras the city carries. Camera images come from the city's Traffic Camera
|
||||
Images API (`traffic.ottawa.ca/opendata/camera`), which needs a certificate
|
||||
from the city's registration form and lets one certificate and user id fetch a
|
||||
camera at most once a minute. Without `OTTAWA_CAMERA_CERTIFICATE` the pack is
|
||||
skipped with no request and one log line. The camera list is the documented
|
||||
keyless `traffic.ottawa.ca/map/service/camera` JSON. Catalog URLs carry no
|
||||
key: the frame proxy adds the certificate and the fixed user id `gev` at fetch
|
||||
time, only for a numeric camera number inside the pinned `/opendata/` path,
|
||||
and serves each camera from one shared server-side frame per minute. Inside
|
||||
that minute a camera with no cached frame gets the placeholder, never the
|
||||
Street View fallback. The feed publishes no bearing, so headings use the
|
||||
id-hash fallback at low confidence. `CCTV_OTTAWA_MAX_SOURCES` caps the pack
|
||||
(default 250, nearest downtown) and `CCTV_OTTAWA_ENABLED=0` turns it off.
|
||||
|
||||
## World camera image feeds
|
||||
|
||||
The default `world-cameras` pack reads only the checked-in, curated
|
||||
`config/cctv_sources.world-image-cameras.json` file. It adds 1,176 Oregon
|
||||
TripCheck and 1,054 MLIT river cameras. Duplicate feeds are excluded because
|
||||
DriveBC, Ottawa, Québec and Iceland (Vegagerðin) already have app packs. NZTA's
|
||||
249 rows are excluded because they were collected from a Journey Planner web
|
||||
cache whose current terms prohibit automated scraping; an authorized API feed
|
||||
or permission is required before adding them. The wider discovery export is
|
||||
not shipped with the app.
|
||||
|
||||
The app validates and pins each still-image URL to its provider directory
|
||||
before the server proxy uses it. Oregon frames refresh no faster
|
||||
than every five minutes; MLIT river frames refresh every ten minutes to reduce
|
||||
requests. Images are not persisted; the proxy reuses frames in a bounded 32 MB
|
||||
in-memory cache until their provider cadence. Set
|
||||
`CCTV_WORLD_CAMERAS_ENABLED=0` to disable the pack.
|
||||
|
||||
Only the curated image-backed rows are bundled. Camera images are fetched from
|
||||
their registered providers at request time and are not included in this data
|
||||
file. The additive `/api/cctv/sources` metadata includes provider source links,
|
||||
browser-only media links, refresh cadence, and location provenance where
|
||||
available. When MLIT serves its recognized adjustment image, `/api/cctv/frame`
|
||||
returns a God’s Eye maintenance card and `/api/cctv/health` reports the camera
|
||||
as degraded with the provider-adjustment reason.
|
||||
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
||||
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||
expand multipart geometries into multiple entities; entity and stem counts are
|
||||
different measurements. This change does not refresh or relicense the datasets.
|
||||
@@ -119,7 +119,6 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||
|
||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||
so v1 never writes it; a later view kind writes its code.
|
||||
@@ -133,10 +132,6 @@ style also extends the pinned style-defaults table.
|
||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||
layout and restores it as `tactical`. The code stays reserved.
|
||||
|
||||
Field 28 is the world time the link was made at, written only when it was away
|
||||
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||
link's creation (a forecast moment), and it opens paused at that time.
|
||||
|
||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
-8
@@ -28,7 +28,6 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -2412,13 +2411,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
"@happy-dom/global-registrator": "^20.14.6",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"flag-icons": "7.5.0",
|
||||
"happy-dom": "^20.14.6",
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.16.0",
|
||||
@@ -91,7 +90,6 @@
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./time": "./src/time/worldClock.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"source":"Revos addons/Qc cams/quebec_all_cameras.geojson","count":1120,"cameras":[{"id":"4103","kind":"traffic","name":"Route 117 à la jonction de la route 101 et à la hauteur du carrefour giratoire (Rouyn-Noranda)","nameEn":"Junction of route 117 and route 101 and at roundabout (Rouyn-Noranda)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Abitibi-Témiscamingue","lat":48.26461,"lon":-79.01808,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=4103","notes":"Live since 2025/11/26"},{"id":"3820","kind":"traffic","name":"Aut. 20 à la hauteur de la montée Industrielle-et-Commerciale","nameEn":"Aut. 20 at montée Industrielle-et-Commerciale","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.43993,"lon":-68.47784,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3820","notes":"Live since 2018/12/12"},{"id":"3821","kind":"traffic","name":"Montée Industrielle-et-Commerciale à Rimouski","nameEn":"Montée Industrielle-et-Commerciale at Rimouski","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.44639,"lon":-68.48528,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3821","notes":"Live since 2018/12/12"},{"id":"3822","kind":"traffic","name":"Route 132 à la hauteur du Bic à la jonction de l'Aut. 20","nameEn":"Route 132 at Bic at the junction of Aut. 20","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.3775,"lon":-68.68694,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3822","notes":"Live since 2018/12/12"},{"id":"3834","kind":"traffic","name":"Route 132 à la hauteur de Saint-Fabien","nameEn":"Route 132 at Saint-Fabien","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.32927,"lon":-68.80262,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3834","notes":"Live since 2019/02/07"},{"id":"3835","kind":"traffic","name":"Route 132 à la hauteur de Cap-Chat (secteur Capucins)","nameEn":"Route 132 at Cap-Chat (Capucins sector)","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.04153,"lon":-66.86847,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3835","notes":"Live since 2019/02/07"},{"id":"3856","kind":"traffic","name":"Route 132 à l'est de Gros-Morne","nameEn":"Route 132, east of Gros-Morne","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":49.25302,"lon":-65.48355,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3856","notes":"Live since 2019/12/06"},{"id":"3836","kind":"traffic","name":"Route 132 à la hauteur de la baie du Petit Pabos","nameEn":"Route 132 at baie du Petit Pabos","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.37216,"lon":-64.58866,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3836","notes":"Live since 2019/02/07"},{"id":"3837","kind":"traffic","name":"Route 132 à la hauteur de Bonaventure","nameEn":"Route 132 at Bonaventure","category":"Highway traffic","operator":"Ministere des Transports et de la Mobilite durable (MTQ) - Quebec 511","region":"Bas-Saint-Laurent—Gaspésie—Îles-de-la-Madeleine","lat":48.03448,"lon":-65.46953,"accuracyM":0,"positionSource":"MTQ georeferenced point (WFS, EPSG:4326, 5 decimals)","viewerUrl":"https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3837","Line truncated
|
||||
@@ -7,16 +7,13 @@
|
||||
* Natural Earth 10m admin-1 states and provinces (public domain).
|
||||
* src/data/local_data/us_census_counties/counties.json
|
||||
* US Census Bureau cartographic boundary counties (public domain).
|
||||
* src/data/local_data/natural_earth/populated_places.json
|
||||
* Natural Earth 10m populated places as points, for the orbital view's
|
||||
* "near <city>" (`src/data/placeBelow.js`; public domain).
|
||||
*
|
||||
* Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs
|
||||
* byte for byte. Downloads are cached outside the repository (default: the
|
||||
* system temp directory) and are never committed.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties|places]
|
||||
* node scripts/build-admin-packs.mjs [--cache <dir>] [--only countries|admin1|counties]
|
||||
*
|
||||
* The provenance READMEs beside each pack record these parameters.
|
||||
*/
|
||||
@@ -104,9 +101,6 @@ export const PARAMS = Object.freeze({
|
||||
elsewhereMinPopulation: 50_000,
|
||||
cityStateMaxKm2: 1_000,
|
||||
},
|
||||
// Every populated place, kept as a point: name, position and the two
|
||||
// prominence ranks the orbital view picks "near <city>" by.
|
||||
places: { decimals: 3 },
|
||||
});
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -694,47 +688,6 @@ async function buildAdmin1() {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPlaces() {
|
||||
const params = PARAMS.places;
|
||||
const source = await fetchPinned('places');
|
||||
const factor = 10 ** params.decimals;
|
||||
const round = (value) => Math.round(value * factor) / factor;
|
||||
const places = JSON.parse(source.bytes.toString('utf8'))
|
||||
.features.map((f) => f.properties)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name &&
|
||||
Number.isFinite(p.latitude) &&
|
||||
Number.isFinite(p.longitude) &&
|
||||
Number.isFinite(p.scalerank),
|
||||
)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
round(p.latitude),
|
||||
round(p.longitude),
|
||||
p.scalerank,
|
||||
Number.isFinite(p.rank_max) ? p.rank_max : 0,
|
||||
])
|
||||
.sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0]));
|
||||
return {
|
||||
meta: {
|
||||
title: 'Natural Earth populated places',
|
||||
source: 'ne_10m_populated_places_simple',
|
||||
url: SOURCES.places.url,
|
||||
sha256: source.digest,
|
||||
commit: NE_COMMIT,
|
||||
license:
|
||||
'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)',
|
||||
schema:
|
||||
'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]',
|
||||
decimals: params.decimals,
|
||||
curation: params,
|
||||
script: 'scripts/build-admin-packs.mjs',
|
||||
},
|
||||
places,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCounties() {
|
||||
const params = PARAMS.counties;
|
||||
const source = await fetchPinned('counties');
|
||||
@@ -815,13 +768,4 @@ if (isMain) {
|
||||
'src/data/local_data/us_census_counties/counties.json',
|
||||
await buildCounties(),
|
||||
);
|
||||
if (!only || only === 'places') {
|
||||
const relative = 'src/data/local_data/natural_earth/populated_places.json';
|
||||
const pack = await buildPlaces();
|
||||
const text = JSON.stringify(pack) + '\n';
|
||||
await writeFile(path.join(ROOT, relative), text);
|
||||
console.log(
|
||||
`${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
|
||||
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
|
||||
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
|
||||
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
|
||||
*
|
||||
* src/data/local_data/owner_flags/owner_flags.png the sheet
|
||||
* src/ui/ownerFlagSheet.js its layout (generated)
|
||||
*
|
||||
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
|
||||
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
|
||||
* reproduces both files.
|
||||
*
|
||||
* Usage: node scripts/build-owner-flags.mjs
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
import sharp from 'sharp';
|
||||
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const FLAGS_DIR = path.join(
|
||||
path.dirname(require.resolve('flag-icons/package.json')),
|
||||
'flags/4x3',
|
||||
);
|
||||
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
|
||||
|
||||
/** One flag's cell, CSS pixels × 2. */
|
||||
export const CELL = Object.freeze({ width: 40, height: 30 });
|
||||
/**
|
||||
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
|
||||
* flag (a 125% display) never reaches into its neighbour.
|
||||
*/
|
||||
export const GUTTER = 2;
|
||||
export const COLUMNS = 16;
|
||||
|
||||
const SHEET = path.join(
|
||||
ROOT,
|
||||
'src/data/local_data/owner_flags/owner_flags.png',
|
||||
);
|
||||
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
|
||||
|
||||
async function flagCell(code) {
|
||||
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
|
||||
return sharp(svg, { density: 300 })
|
||||
.resize(CELL.width, CELL.height, { fit: 'fill' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const codes = [...OWNER_COUNTRY_CODES];
|
||||
const rows = Math.ceil(codes.length / COLUMNS);
|
||||
const pitchX = CELL.width + GUTTER;
|
||||
const pitchY = CELL.height + GUTTER;
|
||||
const cells = await Promise.all(codes.map(flagCell));
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: COLUMNS * pitchX,
|
||||
height: rows * pitchY,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
cells.map((input, i) => ({
|
||||
input,
|
||||
left: (i % COLUMNS) * pitchX,
|
||||
top: Math.floor(i / COLUMNS) * pitchY,
|
||||
})),
|
||||
)
|
||||
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
|
||||
.toBuffer();
|
||||
await writeFile(SHEET, sheet);
|
||||
|
||||
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
|
||||
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||
cellWidthPx: ${CELL.width},
|
||||
cellHeightPx: ${CELL.height},
|
||||
gutterPx: ${GUTTER},
|
||||
columns: ${COLUMNS},
|
||||
rows: ${rows},
|
||||
codes: Object.freeze(${JSON.stringify(codes)}),
|
||||
});
|
||||
`;
|
||||
await writeFile(
|
||||
LAYOUT,
|
||||
await prettier.format(layout, {
|
||||
...(await prettier.resolveConfig(LAYOUT)),
|
||||
filepath: LAYOUT,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
|
||||
);
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
|
||||
|
||||
Reads the UCS Excel release and writes the compact pack the satellites layer
|
||||
loads: one row per satellite, keyed by NORAD number, with string-table indexes
|
||||
for the repeated text columns. Starlink rows are left out: the layer files
|
||||
every STARLINK object under its own class and operator without them.
|
||||
|
||||
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
|
||||
'''
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import openpyxl
|
||||
|
||||
LOG = logging.getLogger('build-ucs-satellites')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / 'src/data/local_data/ucs_satellites'
|
||||
VERSION = 'UCS-Satellite-Database 5-1-2023'
|
||||
DATA_CURRENT_TO = '2023-05-01'
|
||||
URL = 'https://www.ucsusa.org/satellite_database'
|
||||
|
||||
# Output column -> spreadsheet header.
|
||||
COLUMNS = {
|
||||
'operator': 'Operator/Owner',
|
||||
'operatorCountry': 'Country of Operator/Owner',
|
||||
'purpose': 'Purpose',
|
||||
}
|
||||
STARLINK = re.compile(r'\bstarlink\b', re.I)
|
||||
|
||||
|
||||
def clean(value: object) -> str:
|
||||
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
|
||||
text = re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
return '' if text.lower() in ('', 'none', 'n/a') else text
|
||||
|
||||
|
||||
def build(xlsx: Path) -> dict:
|
||||
'''Read the release's first sheet into the pack the layer loads.'''
|
||||
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
|
||||
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
|
||||
norad_col = header['NORAD Number']
|
||||
tables = {key: [''] for key in COLUMNS}
|
||||
index = {key: {'': 0} for key in COLUMNS}
|
||||
records = {}
|
||||
source_rows = skipped_starlink = invalid_norad = 0
|
||||
for row in rows:
|
||||
if not any(row):
|
||||
continue
|
||||
source_rows += 1
|
||||
if any(STARLINK.search(clean(row[i])) for i in names):
|
||||
skipped_starlink += 1
|
||||
continue
|
||||
try:
|
||||
norad = int(float(row[norad_col]))
|
||||
except (TypeError, ValueError):
|
||||
invalid_norad += 1
|
||||
continue
|
||||
if norad <= 0:
|
||||
invalid_norad += 1
|
||||
continue
|
||||
record = [norad]
|
||||
for key, column in COLUMNS.items():
|
||||
text = clean(row[header[column]])
|
||||
if text not in index[key]:
|
||||
index[key][text] = len(tables[key])
|
||||
tables[key].append(text)
|
||||
record.append(index[key][text])
|
||||
# A duplicate NORAD number keeps the first row, as UCS lists it.
|
||||
records.setdefault(norad, record)
|
||||
ordered = [records[n] for n in sorted(records)]
|
||||
return {
|
||||
'meta': {
|
||||
'source': 'Union of Concerned Scientists Satellite Database',
|
||||
'version': VERSION,
|
||||
'url': URL,
|
||||
'dataCurrentTo': DATA_CURRENT_TO,
|
||||
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
|
||||
'curation': {
|
||||
'sourceRows': source_rows,
|
||||
'starlinkRowsDropped': skipped_starlink,
|
||||
'invalidNoradDropped': invalid_norad,
|
||||
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
|
||||
'columns': COLUMNS,
|
||||
},
|
||||
'recordCount': len(ordered),
|
||||
},
|
||||
'fields': ['noradId', *COLUMNS],
|
||||
'strings': tables,
|
||||
'records': ordered,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
'''Build the pack from the release given on the command line.'''
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
|
||||
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
|
||||
pack = build(parser.parse_args().xlsx)
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
out = PACK / 'ucs_satellites.json'
|
||||
# LF on every platform: the repository stores text with LF endings.
|
||||
out.write_text(
|
||||
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
|
||||
encoding='utf-8',
|
||||
newline='\n',
|
||||
)
|
||||
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -44,7 +44,7 @@ CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-7350}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
|
||||
@@ -328,12 +328,6 @@
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/cockpitOrbital.css",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/layers/satellites/sensors.test.mjs",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
@@ -353,7 +347,6 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
@@ -481,7 +474,6 @@
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
@@ -589,7 +581,6 @@
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvIceland.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvHouston.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
@@ -1363,10 +1354,5 @@
|
||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"scripts/build-owner-flags.mjs",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs"
|
||||
"server/providers/inaturalist/proxy.fixture.mjs"
|
||||
]
|
||||
+13
-165
@@ -39,10 +39,6 @@
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
"src/ui/views/panels/CockpitContext.vue",
|
||||
"src/ui/views/panels/CockpitRoute.vue",
|
||||
"src/ui/views/panels/CockpitRadar.vue",
|
||||
"src/ui/views/panels/CockpitOrbital.vue",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
@@ -159,19 +155,10 @@
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/styleParameters.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/ui/kit/UiIcon.vue",
|
||||
"src/ui/stores/worldTime.js",
|
||||
"src/ui/views/panels/WorldTimeline.vue",
|
||||
"src/ui/surfaceKeyboard.js"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
"time": {
|
||||
"exports": ["./time"],
|
||||
"modules": ["src/time/worldClock.js"],
|
||||
"external": []
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": [
|
||||
@@ -376,9 +363,6 @@
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"server/providers/cctv/taiwan.js",
|
||||
"server/providers/cctv/quebec.js",
|
||||
"server/providers/cctv/ottawa.js",
|
||||
"server/providers/cctv/worldCameras.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
@@ -588,7 +572,6 @@
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/data/placeNames.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js",
|
||||
@@ -822,7 +805,6 @@
|
||||
"cctv-controls": {
|
||||
"exports": ["./ui/cctv"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/ui/cctv.js",
|
||||
"src/ui/cctvBindings.js",
|
||||
@@ -831,7 +813,6 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
@@ -851,7 +832,7 @@
|
||||
"src/ui/stores/status.js",
|
||||
"src/ui/i18n.js"
|
||||
],
|
||||
"external": ["vue", "pinia", "vue-i18n", "cesium"]
|
||||
"external": ["vue", "pinia", "vue-i18n"]
|
||||
},
|
||||
"context-controls": {
|
||||
"exports": ["./ui/context", "./ui/context/policy"],
|
||||
@@ -903,19 +884,6 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -930,13 +898,6 @@
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -1005,13 +966,6 @@
|
||||
"src/cockpitTracking.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
@@ -1019,7 +973,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -1052,8 +1005,6 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
@@ -1062,20 +1013,6 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -1234,7 +1171,6 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/ui/kit/UiSegmented.vue",
|
||||
"src/ui/wildlifeSightingCard.js",
|
||||
"src/layers/wildlifeSightings/records.js",
|
||||
@@ -1256,13 +1192,10 @@
|
||||
"src/ui/styles/location.css",
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/quebecCctvOverlay.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/bhote-koshi.css",
|
||||
"src/ui/styles/recording.css",
|
||||
@@ -1416,7 +1349,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/aircraftModelLighting.js",
|
||||
@@ -1458,9 +1390,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1482,7 +1412,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/aircraftModelLighting.js",
|
||||
@@ -1518,9 +1447,7 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js"
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1539,7 +1466,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1580,8 +1506,6 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -1681,11 +1605,9 @@
|
||||
"exports": ["./layers/satellites"],
|
||||
"modules": [
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/sources/tle.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -1714,14 +1636,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/time/worldClock.js"
|
||||
"src/ui/unitLabels.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
},
|
||||
@@ -1761,8 +1676,7 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2087,8 +2001,7 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/time/worldClock.js"
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||
},
|
||||
@@ -2136,7 +2049,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
@@ -2423,7 +2335,6 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -2462,11 +2373,6 @@
|
||||
"src/cockpitTracking.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/creditKeyboard.js",
|
||||
@@ -2531,7 +2437,6 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/lifecycle.js",
|
||||
@@ -2761,7 +2666,6 @@
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -2902,7 +2806,6 @@
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/quebecCctvOverlay.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
@@ -2911,15 +2814,6 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/ownerFlag.js",
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -3039,7 +2933,6 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMode.js",
|
||||
@@ -3380,9 +3273,6 @@
|
||||
"src/ui/stores/globeActions.js",
|
||||
"src/ui/stores/recording.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/viewerClock.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -3428,11 +3318,7 @@
|
||||
"src/ui/wildlifeSightingPlacement.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3493,7 +3379,6 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -3544,7 +3429,6 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
@@ -3746,7 +3630,6 @@
|
||||
"src/layers/radio/tuningNoise.js",
|
||||
"src/layers/radio/volume.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/cockpitSubject.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/ingestion.js",
|
||||
@@ -3834,7 +3717,6 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/worldAnnotationRenderer.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
@@ -4052,8 +3934,6 @@
|
||||
"src/ui/stores/bhoteKoshiPanel.js",
|
||||
"src/hudReadouts.js",
|
||||
"src/ui/i18n.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/time/julian.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
"src/sources/openFreeMapOutlines.js",
|
||||
"src/sources/nominatimOutlines.js",
|
||||
@@ -4087,11 +3967,7 @@
|
||||
"src/layers/wildlifeSightings/sprites.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4225,7 +4101,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/loadingFeedback.js",
|
||||
@@ -4245,8 +4120,7 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -4259,14 +4133,6 @@
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/styles/anime.js",
|
||||
@@ -4598,15 +4464,7 @@
|
||||
},
|
||||
"satellites-source": {
|
||||
"exports": ["./layers/satellites/source"],
|
||||
"modules": [
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
"modules": ["src/layers/satellites/source.js"],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source-factory": {
|
||||
@@ -4805,7 +4663,6 @@
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js",
|
||||
"src/overlays/worldOverlayChrome.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
@@ -4859,8 +4716,7 @@
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/time/worldClock.js"
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
@@ -4977,14 +4833,12 @@
|
||||
"src/view/index.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/scopeMaskPolicy.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/visualStylePolicy.js",
|
||||
"src/time/worldClock.js"
|
||||
"src/visualStylePolicy.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -5104,7 +4958,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/sources/tle.js",
|
||||
@@ -5228,11 +5081,7 @@
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/sources/eventPacks.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
"src/sources/eventPacks.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -5243,7 +5092,6 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/visualStylePolicy.js"
|
||||
],
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
* (viii) AGE — seven days is the default; shorter/longer windows and All
|
||||
* markers update the globe, and the age choice survives a reload.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
@@ -226,77 +224,6 @@ async function main() {
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
const ageChips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
|
||||
),
|
||||
].map((option) => ({
|
||||
id: option.dataset.chipId,
|
||||
active: option.getAttribute('aria-checked') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'AGE: seven days is the default with an All markers option',
|
||||
live.ageDays === 7 &&
|
||||
ageChips.length === 6 &&
|
||||
ageChips
|
||||
.filter((chip) => chip.active)
|
||||
.map((chip) => chip.id)
|
||||
.join() === 'age-7' &&
|
||||
ageChips.some((chip) => chip.id === 'age-all'),
|
||||
);
|
||||
const selectAge = async (days) => {
|
||||
await showRow(page);
|
||||
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 15000 });
|
||||
// Dispatch through the rendered button, matching the product-chip gate.
|
||||
await page.$eval(selector, (button) => button.click());
|
||||
await page.waitForFunction(
|
||||
(id, age) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return s.ageDays === age && !s.loading;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS },
|
||||
LAYER_ID,
|
||||
days,
|
||||
);
|
||||
return stats(page);
|
||||
};
|
||||
const all = await selectAge(null);
|
||||
record(
|
||||
'AGE: All markers restores old events without duplicate data sources',
|
||||
all.count > live.count &&
|
||||
(await sources(page)).length === live.products.length,
|
||||
`${live.count} → ${all.count}`,
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await enableAndSettle(page);
|
||||
const persistedAge = await stats(page);
|
||||
record(
|
||||
'AGE: All markers survives a reload',
|
||||
persistedAge.ageDays === null && persistedAge.count === all.count,
|
||||
);
|
||||
const thirty = await selectAge(30);
|
||||
const one = await selectAge(1);
|
||||
const seven = await selectAge(7);
|
||||
record(
|
||||
'AGE: counts follow the window order and seven days restores the default',
|
||||
one.count <= live.count &&
|
||||
thirty.count >= live.count &&
|
||||
thirty.count <= all.count &&
|
||||
seven.count === live.count &&
|
||||
seven.ageDays === 7,
|
||||
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
|
||||
@@ -296,22 +296,6 @@ async function expandPanel(page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||
async function assertKeylessDefault(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await uiUntil(page, litAlone, 'kartaview');
|
||||
const shown = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||
assert.equal(shown.disabled, false, 'controls stay live');
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
@@ -544,25 +528,11 @@ async function main() {
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||
if (!configured) {
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else if (!configured) {
|
||||
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
|
||||
assertKeylessDefault(page),
|
||||
);
|
||||
await step(
|
||||
'keyless server: the MAPILLARY chip explains its key instead of switching',
|
||||
async () => {
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(litAlone(await getUI(page), 'kartaview'));
|
||||
},
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
@@ -781,8 +751,6 @@ async function main() {
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
|
||||
@@ -409,15 +409,6 @@ async function main() {
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
// CelesTrak SATCAT, which the satellite classes read beside the TLEs.
|
||||
// An empty CSV (header only) keeps the run hermetic: the classes fall
|
||||
// back to names and groups, as they do when SATCAT is unavailable.
|
||||
if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) {
|
||||
return Promise.resolve(new Response(
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
|
||||
));
|
||||
}
|
||||
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
|
||||
// the satellite assertions are deterministic and the run never depends
|
||||
// on an upstream that rate-limits (it was answering 403 the night this
|
||||
|
||||
+5
-187
@@ -1,5 +1,4 @@
|
||||
import { createCctvCatalog } from './cctv/catalog.js';
|
||||
import { quebec511VideoUrl, quebecBrowserVideoEnabled } from './cctv/quebec.js';
|
||||
import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js';
|
||||
import {
|
||||
buildSyntheticCctvSvg,
|
||||
@@ -19,8 +18,6 @@ import { sanitizeCctvRangeHeader } from './cctv/range.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
|
||||
import { createHlsPuller } from './cctv/stream.js';
|
||||
import { isMlitRiverAdjustmentImage } from './cctv/worldCameras.js';
|
||||
import { ottawaAuthorizedFrameUrl } from './cctv/ottawa.js';
|
||||
import { googleServerApiKey } from './places/google-key.js';
|
||||
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
|
||||
/**
|
||||
@@ -46,120 +43,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
const HEALTH_MAX_ENTRIES = CCTV_MAX_SOURCES_CEILING;
|
||||
/** Live HLS strategies (see ./cctv/stream.js). Shared across dev and preview. */
|
||||
const puller = createHlsPuller();
|
||||
/**
|
||||
* Bounded transient image reuse keeps cadence-limited providers (World
|
||||
* stills, Ottawa's once-a-minute API) inside their refresh floor. The byte
|
||||
* cap is the real bound; the entry cap only limits map overhead.
|
||||
*/
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRIES = 1024;
|
||||
/** Retry a failed fetch sooner than a full refresh window, never under a minute. */
|
||||
const THROTTLED_FRAME_FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
const WORLD_FRAME_CACHE_MAX_ENTRY_BYTES = 2 * 1024 * 1024;
|
||||
const WORLD_FRAME_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
const worldFrameCache = new Map();
|
||||
const worldFrameInflight = new Map();
|
||||
const worldFrameNextAllowedAt = new Map();
|
||||
let worldFrameCacheBytes = 0;
|
||||
|
||||
function getWorldFrameCache(cameraId) {
|
||||
const entry = worldFrameCache.get(cameraId);
|
||||
if (!entry) return null;
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function storeWorldFrame(cameraId, image) {
|
||||
if (
|
||||
!Buffer.isBuffer(image?.body) ||
|
||||
image.body.length > WORLD_FRAME_CACHE_MAX_ENTRY_BYTES
|
||||
)
|
||||
return;
|
||||
const previous = worldFrameCache.get(cameraId);
|
||||
if (previous) worldFrameCacheBytes -= previous.body.length;
|
||||
const entry = {
|
||||
body: image.body,
|
||||
contentType: image.contentType,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
worldFrameCache.delete(cameraId);
|
||||
worldFrameCache.set(cameraId, entry);
|
||||
worldFrameCacheBytes += entry.body.length;
|
||||
while (
|
||||
worldFrameCache.size > WORLD_FRAME_CACHE_MAX_ENTRIES ||
|
||||
worldFrameCacheBytes > WORLD_FRAME_CACHE_MAX_BYTES
|
||||
) {
|
||||
const oldestId = worldFrameCache.keys().next().value;
|
||||
const oldest = worldFrameCache.get(oldestId);
|
||||
worldFrameCache.delete(oldestId);
|
||||
worldFrameCacheBytes -= oldest.body.length;
|
||||
}
|
||||
}
|
||||
|
||||
function setNextAllowedAt(cameraId, at) {
|
||||
worldFrameNextAllowedAt.delete(cameraId);
|
||||
worldFrameNextAllowedAt.set(cameraId, at);
|
||||
while (worldFrameNextAllowedAt.size > 4096) {
|
||||
worldFrameNextAllowedAt.delete(
|
||||
worldFrameNextAllowedAt.keys().next().value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a frame from a provider with a per-camera refresh floor. Inside the
|
||||
* floor the last frame is reused; with no frame to reuse the caller gets
|
||||
* `{ throttled: true }` and serves a placeholder, so a cadence-limited camera
|
||||
* never falls through to the key-bearing Street View fallback. A failed
|
||||
* fetch backs off for a minute (capped at the floor) instead of a full
|
||||
* window.
|
||||
*/
|
||||
async function fetchThrottledFrame(source, cameraId, frameUrl) {
|
||||
const cached = getWorldFrameCache(cameraId);
|
||||
const rawRefreshMs = Number(source?.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(rawRefreshMs) && rawRefreshMs > 0
|
||||
? rawRefreshMs
|
||||
: 5 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.fetchedAt < refreshMs) {
|
||||
return { ok: true, ...cached, cached: true };
|
||||
}
|
||||
|
||||
const inflight = worldFrameInflight.get(cameraId);
|
||||
if (inflight) return inflight;
|
||||
const stale = cached
|
||||
? {
|
||||
ok: true,
|
||||
body: cached.body,
|
||||
contentType: cached.contentType,
|
||||
stale: true,
|
||||
}
|
||||
: null;
|
||||
const nextAllowedAt = worldFrameNextAllowedAt.get(cameraId) || 0;
|
||||
if (now < nextAllowedAt) return stale || { ok: false, throttled: true };
|
||||
if (!frameUrl) return stale;
|
||||
setNextAllowedAt(cameraId, now + refreshMs);
|
||||
let request;
|
||||
request = fetchCctvImageFromUpstream(frameUrl)
|
||||
.then((image) => {
|
||||
if (image?.ok) {
|
||||
storeWorldFrame(cameraId, image);
|
||||
return image;
|
||||
}
|
||||
setNextAllowedAt(
|
||||
cameraId,
|
||||
Date.now() + Math.min(refreshMs, THROTTLED_FRAME_FAILURE_BACKOFF_MS),
|
||||
);
|
||||
return stale || image;
|
||||
})
|
||||
.finally(() => {
|
||||
if (worldFrameInflight.get(cameraId) === request)
|
||||
worldFrameInflight.delete(cameraId);
|
||||
});
|
||||
worldFrameInflight.set(cameraId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
@@ -270,7 +153,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
);
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const quebecVideo = quebecBrowserVideoEnabled();
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.(sources) ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
@@ -282,7 +164,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
provider: source.provider,
|
||||
lat: source.lat,
|
||||
lon: source.lon,
|
||||
frameRefreshMs: source.frameRefreshMs,
|
||||
headingDeg: source.headingDeg,
|
||||
headingConfidence: source.headingConfidence || '',
|
||||
pitchDeg: source.pitchDeg,
|
||||
@@ -293,13 +174,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
feedType: normalizeFeedType(source.feedType),
|
||||
sourceKind:
|
||||
source.sourceKind || (source.url ? 'configured' : 'fallback'),
|
||||
sourceUrl: source.sourceUrl || '',
|
||||
browserMediaUrl:
|
||||
quebecVideo && source.sourceKind === 'quebec-511-browser'
|
||||
? quebec511VideoUrl(source.id)
|
||||
: '',
|
||||
accuracyM: source.accuracyM,
|
||||
positionSource: source.positionSource || '',
|
||||
poseSource: source.poseSource,
|
||||
model: source.model || '',
|
||||
manufacturer: source.manufacturer || '',
|
||||
@@ -652,71 +526,15 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
: '');
|
||||
|
||||
const upstreamImage =
|
||||
source?.pack === 'world-cameras'
|
||||
? await fetchThrottledFrame(source, cameraId, upstreamCandidate)
|
||||
: source?.sourceKind === 'ottawa-traffic'
|
||||
? await fetchThrottledFrame(
|
||||
source,
|
||||
cameraId,
|
||||
ottawaAuthorizedFrameUrl(upstreamCandidate),
|
||||
)
|
||||
: source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate, {
|
||||
allowMjpeg: source?.sourceKind === 'taiwan-highway-motc',
|
||||
});
|
||||
if (
|
||||
source?.sourceKind === 'mlit-river' &&
|
||||
isMlitRiverAdjustmentImage(upstreamImage)
|
||||
) {
|
||||
const svg = buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'CAMERA UNDER ADJUSTMENT',
|
||||
});
|
||||
setHealth(cameraId, {
|
||||
status: 'degraded',
|
||||
sourceKind: 'snapshot',
|
||||
label: source.provider || 'MLIT river cameras',
|
||||
message: 'Provider camera under adjustment',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'provider-maintenance',
|
||||
});
|
||||
res.end(svg);
|
||||
return;
|
||||
}
|
||||
if (upstreamImage?.throttled) {
|
||||
// Inside the provider's refresh floor with no frame to reuse: a
|
||||
// placeholder, not Street View, and no health change.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-CCTV-Source': 'synthetic',
|
||||
});
|
||||
res.end(
|
||||
buildSyntheticCctvSvg({
|
||||
cameraId,
|
||||
label,
|
||||
city,
|
||||
status: 'WAITING FOR NEXT FRAME',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
source?.sourceKind === 'txdot-its'
|
||||
? await fetchTxdotSnapshot(upstreamCandidate)
|
||||
: await fetchCctvImageFromUpstream(upstreamCandidate);
|
||||
if (upstreamImage?.ok) {
|
||||
setHealth(cameraId, {
|
||||
status: upstreamImage.stale ? 'degraded' : 'ok',
|
||||
status: 'ok',
|
||||
sourceKind: 'snapshot',
|
||||
label: source?.provider || 'Configured source',
|
||||
message: upstreamImage.stale
|
||||
? 'Serving last snapshot; refresh failed'
|
||||
: upstreamImage.cached
|
||||
? 'Cached snapshot active'
|
||||
: 'Upstream snapshot active',
|
||||
message: 'Upstream snapshot active',
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': upstreamImage.contentType,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from './constants.js';
|
||||
|
||||
@@ -24,27 +23,16 @@ export function resolveCatalogCap(raw) {
|
||||
* exceed the catalog cap, the cap is filled ROUND-ROBIN — one camera from
|
||||
* each pack per turn, in each pack's own order — so a lowered
|
||||
* CCTV_MAX_SOURCES thins every region a little instead of deleting whichever
|
||||
* pack happened to be appended last. A pack with a `reservedCount` keeps up to
|
||||
* that many rows only from the capacity ABOVE the shared floor, so a
|
||||
* reservation adds room and never shrinks the other packs' round-robin
|
||||
* share; at or below the floor reserved packs are ordinary lanes. Duplicate
|
||||
* ids resolve last-pack-wins so
|
||||
* pack happened to be appended last. Duplicate ids resolve last-pack-wins so
|
||||
* file/env overrides keep replacing live records; the record is then counted
|
||||
* against the pack that won it.
|
||||
*
|
||||
* @param {Array<{name:string, sources:Array<object>}>} packs - Normalized
|
||||
* packs in merge order (earlier packs lose duplicate ids to later ones).
|
||||
* @param {number} maxCount - Catalog cap.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.sharedFloor=CCTV_SHARED_SOURCE_SLOTS] - Slots kept
|
||||
* for the round-robin before reservations apply.
|
||||
* @returns {{sources:Array<object>, packs:Array<{name:string, offered:number, kept:number}>}}
|
||||
*/
|
||||
export function allocateSourceCap(
|
||||
packs,
|
||||
maxCount,
|
||||
{ sharedFloor = CCTV_SHARED_SOURCE_SLOTS } = {},
|
||||
) {
|
||||
export function allocateSourceCap(packs, maxCount) {
|
||||
const owner = new Map();
|
||||
for (const pack of packs) {
|
||||
for (const source of pack.sources) {
|
||||
@@ -52,53 +40,30 @@ export function allocateSourceCap(
|
||||
owner.set(source.id, { pack: pack.name, source });
|
||||
}
|
||||
}
|
||||
const lanes = packs.map((pack) => {
|
||||
const lanes = packs.map((pack) => ({
|
||||
name: pack.name,
|
||||
// Only the record that WON its id (the last occurrence anywhere, so also
|
||||
// the last within this pack) takes a lane slot; earlier duplicates never
|
||||
// count against the cap.
|
||||
const queue = pack.sources.filter(
|
||||
queue: pack.sources.filter(
|
||||
(source) => source?.id && owner.get(source.id)?.source === source,
|
||||
);
|
||||
return {
|
||||
name: pack.name,
|
||||
queue,
|
||||
reserved: Math.max(
|
||||
0,
|
||||
Math.min(queue.length, Math.floor(Number(pack.reservedCount) || 0)),
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
};
|
||||
});
|
||||
),
|
||||
next: 0,
|
||||
kept: 0,
|
||||
}));
|
||||
const total = lanes.reduce((sum, lane) => sum + lane.queue.length, 0);
|
||||
const limit = Math.min(total, Math.max(0, Math.floor(maxCount)));
|
||||
|
||||
const sources = [];
|
||||
const reservedSources = [];
|
||||
if (limit >= total) {
|
||||
for (const lane of lanes) {
|
||||
sources.push(...lane.queue);
|
||||
lane.kept = lane.queue.length;
|
||||
}
|
||||
} else {
|
||||
// Reserved packs draw only on the capacity above the shared floor, then
|
||||
// every lane (reserved ones included) shares the rest round-robin.
|
||||
const reserveBudget = Math.max(0, limit - Math.max(0, sharedFloor));
|
||||
while (reservedSources.length < reserveBudget) {
|
||||
let tookReserved = false;
|
||||
while (sources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (reservedSources.length >= reserveBudget) break;
|
||||
if (lane.kept >= lane.reserved) continue;
|
||||
reservedSources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
lane.kept += 1;
|
||||
tookReserved = true;
|
||||
}
|
||||
if (!tookReserved) break;
|
||||
}
|
||||
while (sources.length + reservedSources.length < limit) {
|
||||
for (const lane of lanes) {
|
||||
if (sources.length + reservedSources.length >= limit) break;
|
||||
if (sources.length >= limit) break;
|
||||
if (lane.next >= lane.queue.length) continue;
|
||||
sources.push(lane.queue[lane.next]);
|
||||
lane.next += 1;
|
||||
@@ -107,9 +72,7 @@ export function allocateSourceCap(
|
||||
}
|
||||
}
|
||||
return {
|
||||
// Keep the shared round-robin sequence first so the default active camera
|
||||
// remains in its established region; guaranteed regional rows follow it.
|
||||
sources: [...sources, ...reservedSources],
|
||||
sources,
|
||||
packs: lanes.map((lane) => ({
|
||||
name: lane.name,
|
||||
offered: lane.queue.length,
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_CCTV_SOURCE_FILE,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import { loadLithuaniaSources } from './lithuania.js';
|
||||
import { loadOttawaSourcesFromOpenData } from './ottawa.js';
|
||||
import { loadQuebecSources, QUEBEC_CAMERA_SOURCE_COUNT } from './quebec.js';
|
||||
import { loadTaiwanSources } from './taiwan.js';
|
||||
import { loadWorldCameraSources } from './worldCameras.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -27,7 +20,6 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
@@ -83,11 +75,6 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DRIVEBC_ENABLED'),
|
||||
load: loadDriveBcSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'ottawa',
|
||||
enabled: () => envEnabled('CCTV_OTTAWA_ENABLED'),
|
||||
load: loadOttawaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'txdot',
|
||||
enabled: () => envEnabled('CCTV_TXDOT_ENABLED'),
|
||||
@@ -123,11 +110,6 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||
load: loadCataloniaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'iceland',
|
||||
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
|
||||
load: loadIcelandSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'hk',
|
||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||
@@ -190,18 +172,6 @@ const LIVE_PACKS = [
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'quebec',
|
||||
enabled: () => envEnabled('CCTV_QUEBEC_ENABLED'),
|
||||
load: loadQuebecSources,
|
||||
reservedCount: QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'world-cameras',
|
||||
enabled: () => envEnabled('CCTV_WORLD_CAMERAS_ENABLED'),
|
||||
load: loadWorldCameraSources,
|
||||
reservedCount: WORLD_CCTV_SOURCE_COUNT,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
@@ -346,7 +316,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
: [];
|
||||
// Live packs first so file/env overrides win on duplicate IDs; each pack
|
||||
// keeps its own priority order and the catalog cap is shared fairly.
|
||||
const normalizePack = (name, items, reservedCount = 0) => ({
|
||||
const normalizePack = (name, items) => ({
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
@@ -356,7 +326,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
reservedCount,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -365,7 +334,6 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
liveResults[index]?.status === 'fulfilled'
|
||||
? liveResults[index].value
|
||||
: [],
|
||||
pack.reservedCount,
|
||||
),
|
||||
),
|
||||
normalizePack('file', fromFile),
|
||||
|
||||
@@ -5,18 +5,15 @@ export const DEFAULT_AUSTIN_ROWS_URL =
|
||||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
export const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/**
|
||||
* Slots every pack shares round-robin before any reservation applies. A
|
||||
* reserved pack (Québec, World) only takes capacity above this floor, so a
|
||||
* cap at or below it thins every region evenly (see cap.js).
|
||||
* Catalog-wide safety ceiling on served cameras. Each pack already caps
|
||||
* itself (nearest-to-anchor first); this bound only matters when the packs
|
||||
* together exceed it, and it is then filled round-robin across packs (see
|
||||
* cap.js) so no region is silently dropped. The default per-pack caps
|
||||
* together can exceed it, so a default install may be thinned evenly here.
|
||||
*/
|
||||
export const CCTV_SHARED_SOURCE_SLOTS = 4000;
|
||||
/**
|
||||
* Catalog-wide default: the 4,000 shared slots plus room reserved for the
|
||||
* 1,120 Québec and 2,230 World image locations.
|
||||
*/
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 7350;
|
||||
export const DEFAULT_CCTV_MAX_SOURCES = 4000;
|
||||
/** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */
|
||||
export const CCTV_MAX_SOURCES_CEILING = 8000;
|
||||
export const CCTV_MAX_SOURCES_CEILING = 5000;
|
||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||
export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
@@ -200,39 +197,6 @@ export const DRIVEBC_ANCHORS = [
|
||||
{ lat: 49.2827, lon: -123.1207 }, // Vancouver
|
||||
{ lat: 48.4284, lon: -123.3656 }, // Victoria
|
||||
];
|
||||
/** City of Ottawa traffic cameras: documented JSON list (keyless). */
|
||||
export const OTTAWA_CAMERAS_URL =
|
||||
'https://traffic.ottawa.ca/map/service/camera';
|
||||
/**
|
||||
* Keyless canonical frame URL for one camera number. The city's Traffic Camera
|
||||
* Images API needs a certificate (OTTAWA_CAMERA_CERTIFICATE), which the frame
|
||||
* proxy adds at request time so it never appears in a catalog response.
|
||||
*/
|
||||
export const OTTAWA_IMAGE_URL = (cameraNumber) =>
|
||||
`https://traffic.ottawa.ca/opendata/camera?c=${encodeURIComponent(cameraNumber)}`;
|
||||
/** Pinned frame directory, for redirect containment in the media proxy. */
|
||||
export const OTTAWA_IMAGE_ORIGIN = 'https://traffic.ottawa.ca/opendata/';
|
||||
/** The API lets one certificate and user id fetch a camera once a minute. */
|
||||
export const OTTAWA_FRAME_REFRESH_MS = 60 * 1000;
|
||||
/** Alphanumeric user id this server sends with its certificate. */
|
||||
export const OTTAWA_API_USER_ID = 'gev';
|
||||
export const DEFAULT_OTTAWA_MAX_SOURCES = 250;
|
||||
/** Prioritization anchor: downtown Ottawa (Parliament Hill). */
|
||||
export const OTTAWA_ANCHORS = [{ lat: 45.4215, lon: -75.6972 }];
|
||||
/**
|
||||
* Taiwan Highway Bureau MJPEG servers. Each camera is one root path segment,
|
||||
* so the pin is the host root: a redirect must stay on the same server.
|
||||
*/
|
||||
export const TAIWAN_HIGHWAY_IMAGE_ORIGINS = Object.freeze(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `https://cctv-ss0${n}.thb.gov.tw/`),
|
||||
);
|
||||
/** Direct still-image directories from the World cams add-on's eligible packs. */
|
||||
export const TRIPCHECK_IMAGE_ORIGIN =
|
||||
'https://www.tripcheck.com/roadcams/cams/';
|
||||
export const MLIT_RIVER_IMAGE_ORIGIN = 'https://cam.river.go.jp/cam/now/';
|
||||
export const DEFAULT_WORLD_CCTV_SOURCE_FILE =
|
||||
'config/cctv_sources.world-image-cameras.json';
|
||||
export const WORLD_CCTV_SOURCE_COUNT = 2230;
|
||||
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
|
||||
export const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||
export const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||
@@ -345,37 +309,6 @@ export const CATALONIA_ANCHORS = [
|
||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
||||
];
|
||||
|
||||
/**
|
||||
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
|
||||
* one keyless JSON list, one row per camera view (about 500 views at roughly
|
||||
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
|
||||
* rewritten by the publisher every few minutes. Camera images are published
|
||||
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
|
||||
*/
|
||||
export const ICELAND_CAMERAS_URL =
|
||||
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
|
||||
/** The only directory Iceland camera frames may come from. */
|
||||
export const ICELAND_IMAGE_ORIGIN =
|
||||
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
|
||||
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
|
||||
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres: the feed carries none, and the country
|
||||
* runs from sea level to highland passes. The client's ground snap corrects
|
||||
* this on 3D-tile stacks. */
|
||||
export const ICELAND_GROUND_ELEVATION_M = 60;
|
||||
/** The feed lists about 500 views; the default keeps half so the shared
|
||||
* catalog cap still leaves room for denser regions. */
|
||||
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
|
||||
* every region's nearest cameras instead of only the capital's. */
|
||||
export const ICELAND_ANCHORS = [
|
||||
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
|
||||
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
|
||||
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
|
||||
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
|
||||
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
|
||||
];
|
||||
|
||||
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
|
||||
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
|
||||
export const DEFAULT_HK_CAMERAS_URL =
|
||||
|
||||
@@ -10,15 +10,10 @@ import {
|
||||
CCTV_MEDIA_MAX_BODY_BYTES,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
@@ -528,18 +523,13 @@ for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
...TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
@@ -631,216 +621,6 @@ export function cctvUpstreamUserAgent(url) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest single JPEG the MJPEG first-frame reader will buffer. */
|
||||
export const CCTV_MJPEG_FRAME_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Read one bounded JPEG part from an open MJPEG response, then stop the stream.
|
||||
*
|
||||
* The first part may follow a CRLF preamble, and its delimiter may be written
|
||||
* as `--<boundary>` or, when the declared boundary already starts with `--`,
|
||||
* as the boundary itself. A part with Content-Length is read to that length;
|
||||
* a part without one ends at the next delimiter, so a JPEG end marker inside
|
||||
* the image (an EXIF thumbnail) never truncates it. Bytes are copied once
|
||||
* into a preallocated buffer and every search resumes where it stopped, so a
|
||||
* trickling upstream costs linear time.
|
||||
*/
|
||||
async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
const mediaType = cctvLiveMediaType(
|
||||
'mjpeg',
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
const boundary = parseMediaType(mediaType)?.params.get('boundary') || '';
|
||||
const frameMax = Math.min(maxBytes, CCTV_MJPEG_FRAME_MAX_BYTES);
|
||||
if (
|
||||
!boundary ||
|
||||
!upstream.body ||
|
||||
!Number.isSafeInteger(frameMax) ||
|
||||
frameMax < 4
|
||||
) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const delimiters = [Buffer.from('--' + boundary, 'latin1')];
|
||||
if (boundary.startsWith('--') && boundary.length > 2)
|
||||
delimiters.push(Buffer.from(boundary, 'latin1'));
|
||||
const lineEnd = Buffer.from('\r\n', 'ascii');
|
||||
const headerTerminator = Buffer.from('\r\n\r\n', 'ascii');
|
||||
const jpegStart = Buffer.from([0xff, 0xd8]);
|
||||
const jpegEnd = Buffer.from([0xff, 0xd9]);
|
||||
const maxPreambleBytes = 64;
|
||||
const maxHeaderBytes = 8 * 1024;
|
||||
const maxBufferedBytes =
|
||||
maxPreambleBytes +
|
||||
delimiters[0].length +
|
||||
lineEnd.length +
|
||||
maxHeaderBytes +
|
||||
headerTerminator.length +
|
||||
frameMax +
|
||||
lineEnd.length +
|
||||
delimiters[0].length;
|
||||
const buffer = Buffer.allocUnsafe(maxBufferedBytes);
|
||||
let length = 0;
|
||||
let reader;
|
||||
let headerStart = -1;
|
||||
let delimiter = null;
|
||||
let headerScanFrom = 0;
|
||||
let bodyStart = -1;
|
||||
let declaredLength = null;
|
||||
let boundaryScanFrom = -1;
|
||||
|
||||
/** Match the opening delimiter after an optional CRLF preamble. */
|
||||
function findOpening() {
|
||||
let at = 0;
|
||||
while (at < length && (buffer[at] === 0x0d || buffer[at] === 0x0a)) at++;
|
||||
if (at > maxPreambleBytes) return { failed: true };
|
||||
for (const candidate of delimiters) {
|
||||
const line = Buffer.concat([candidate, lineEnd]);
|
||||
const need = at + line.length;
|
||||
if (length < need) {
|
||||
const seen = buffer.subarray(at, length);
|
||||
if (line.subarray(0, seen.length).equals(seen))
|
||||
return { pending: true };
|
||||
continue;
|
||||
}
|
||||
if (buffer.subarray(at, need).equals(line))
|
||||
return { start: need, delimiter: candidate };
|
||||
}
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
/** Parse the part headers; false rejects the stream. */
|
||||
function parseHeaders(headerEnd) {
|
||||
const headerText = buffer
|
||||
.subarray(headerStart, headerEnd)
|
||||
.toString('latin1');
|
||||
const headers = new Map();
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
const name = line.slice(0, colon).trim().toLowerCase();
|
||||
if (!name || headers.has(name)) return false;
|
||||
headers.set(name, line.slice(colon + 1).trim());
|
||||
}
|
||||
if (cctvRasterImageType(headers.get('content-type')) !== 'image/jpeg')
|
||||
return false;
|
||||
const declared = headers.get('content-length');
|
||||
if (declared !== undefined) {
|
||||
if (!/^\d+$/.test(declared)) return false;
|
||||
declaredLength = Number(declared);
|
||||
if (
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < jpegStart.length + jpegEnd.length ||
|
||||
declaredLength > frameMax
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A complete JPEG body, or null. */
|
||||
function asJpeg(frame) {
|
||||
return frame.length >= jpegStart.length + jpegEnd.length &&
|
||||
frame.subarray(0, jpegStart.length).equals(jpegStart) &&
|
||||
frame.subarray(-jpegEnd.length).equals(jpegEnd)
|
||||
? Buffer.from(frame)
|
||||
: null;
|
||||
}
|
||||
|
||||
try {
|
||||
reader = upstream.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
|
||||
const chunk = Buffer.from(value);
|
||||
const room = maxBufferedBytes - length;
|
||||
const included = Math.min(room, chunk.length);
|
||||
chunk.copy(buffer, length, 0, included);
|
||||
length += included;
|
||||
const overflow = included < chunk.length;
|
||||
|
||||
if (headerStart < 0) {
|
||||
const opening = findOpening();
|
||||
if (opening.failed) return null;
|
||||
if (opening.pending) {
|
||||
if (overflow) return null;
|
||||
continue;
|
||||
}
|
||||
headerStart = opening.start;
|
||||
delimiter = opening.delimiter;
|
||||
headerScanFrom = headerStart;
|
||||
}
|
||||
|
||||
if (bodyStart < 0) {
|
||||
const headerEnd = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(headerTerminator, headerScanFrom);
|
||||
if (headerEnd < 0) {
|
||||
if (length - headerStart > maxHeaderBytes || overflow) return null;
|
||||
headerScanFrom = Math.max(
|
||||
headerStart,
|
||||
length - headerTerminator.length + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (headerEnd - headerStart > maxHeaderBytes) return null;
|
||||
if (!parseHeaders(headerEnd)) return null;
|
||||
bodyStart = headerEnd + headerTerminator.length;
|
||||
boundaryScanFrom = bodyStart;
|
||||
}
|
||||
|
||||
const available = length - bodyStart;
|
||||
if (available >= jpegStart.length) {
|
||||
if (
|
||||
!buffer
|
||||
.subarray(bodyStart, bodyStart + jpegStart.length)
|
||||
.equals(jpegStart)
|
||||
)
|
||||
return null;
|
||||
}
|
||||
const nextDelimiter = Buffer.concat([lineEnd, delimiter]);
|
||||
const nextPart = buffer
|
||||
.subarray(0, length)
|
||||
.indexOf(nextDelimiter, boundaryScanFrom);
|
||||
if (declaredLength !== null) {
|
||||
if (nextPart >= bodyStart && nextPart - bodyStart < declaredLength)
|
||||
return null;
|
||||
if (available >= declaredLength)
|
||||
return asJpeg(buffer.subarray(bodyStart, bodyStart + declaredLength));
|
||||
} else if (nextPart >= bodyStart) {
|
||||
const frame = buffer.subarray(bodyStart, nextPart);
|
||||
return frame.length > frameMax ? null : asJpeg(frame);
|
||||
} else if (available > frameMax) {
|
||||
return null;
|
||||
}
|
||||
if (nextPart < 0)
|
||||
boundaryScanFrom = Math.max(
|
||||
bodyStart,
|
||||
length - nextDelimiter.length + 1,
|
||||
);
|
||||
if (overflow) return null;
|
||||
}
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
/* already closed or aborted */
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Fetch one upstream CCTV image within the frame-refresh budget.
|
||||
*
|
||||
@@ -853,10 +633,6 @@ async function readFirstMjpegFrame(upstream, maxBytes) {
|
||||
* @param {typeof fetch} [options.fetchImpl=fetch] - Fetch implementation.
|
||||
* @param {number} [options.timeoutMs=CCTV_FRAME_FETCH_TIMEOUT_MS] - Abort timeout.
|
||||
* @param {number} [options.maxBytes=CCTV_FRAME_MAX_BODY_BYTES] - Snapshot byte cap.
|
||||
* @param {boolean} [options.allowMjpeg=false] - Accept a multipart MJPEG
|
||||
* answer and keep its first JPEG. Only sources registered as MJPEG stills
|
||||
* (the Taiwan Highway Bureau feeds) set it; any other host answering
|
||||
* multipart falls through to the next fallback.
|
||||
* @returns {Promise<{ok:true,body:Buffer,contentType:string}|null>}
|
||||
*/
|
||||
export async function fetchCctvImageFromUpstream(
|
||||
@@ -865,7 +641,6 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
maxBytes = CCTV_FRAME_MAX_BODY_BYTES,
|
||||
allowMjpeg = false,
|
||||
} = {},
|
||||
) {
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
@@ -885,7 +660,10 @@ export async function fetchCctvImageFromUpstream(
|
||||
fetchImpl,
|
||||
);
|
||||
if (!upstream) return null;
|
||||
if (!upstream.ok) {
|
||||
const contentType = cctvRasterImageType(
|
||||
upstream.headers.get('content-type'),
|
||||
);
|
||||
if (!upstream.ok || !contentType) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
@@ -893,24 +671,9 @@ export async function fetchCctvImageFromUpstream(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const upstreamType = upstream.headers.get('content-type');
|
||||
const contentType = cctvRasterImageType(upstreamType);
|
||||
if (contentType) {
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType };
|
||||
}
|
||||
if (!allowMjpeg || !cctvLiveMediaType('mjpeg', upstreamType)) {
|
||||
try {
|
||||
await upstream.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const body = await readFirstMjpegFrame(upstream, maxBytes);
|
||||
const body = await readCappedResponseBytes(upstream, maxBytes);
|
||||
if (!body) return null;
|
||||
return { ok: true, body, contentType: 'image/jpeg' };
|
||||
return { ok: true, body, contentType };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -460,17 +460,6 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Iceland bounding box (mainland and nearby islands, with a margin). */
|
||||
export function isLikelyIcelandCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 63.0 &&
|
||||
lat <= 66.7 &&
|
||||
lon >= -25.0 &&
|
||||
lon <= -13.0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode numeric HTML character references, decimal ("í") and hex
|
||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||
@@ -650,10 +639,6 @@ export function normalizeSourceItem(item) {
|
||||
feedType: normalizeFeedType(item.feedType || item.type || ''),
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
snapshotUrl: typeof item.snapshotUrl === 'string' ? item.snapshotUrl : '',
|
||||
frameRefreshMs: toFiniteNumber(item.frameRefreshMs),
|
||||
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
|
||||
accuracyM: toFiniteNumber(item.accuracyM),
|
||||
positionSource: String(item.positionSource || ''),
|
||||
license: String(item.license || item.licenseNote || ''),
|
||||
// Per-camera attribution for feeds a partner supplies inside a pack
|
||||
// (DriveBC: TransLink, city cameras). Shown beside the provider.
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
OTTAWA_ANCHORS,
|
||||
OTTAWA_API_USER_ID,
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_FRAME_REFRESH_MS,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
OTTAWA_IMAGE_URL,
|
||||
} from './constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
prioritizeSources,
|
||||
toFiniteNumber,
|
||||
} from './normalize.js';
|
||||
|
||||
let ottawaCertificateMissingLogged = false;
|
||||
|
||||
/** Test hook: let the next missing-certificate skip log again. */
|
||||
export function _resetOttawaCertificateWarningForTest() {
|
||||
ottawaCertificateMissingLogged = false;
|
||||
}
|
||||
|
||||
/** The operator's Traffic Camera Images API certificate, trimmed. */
|
||||
function ottawaCertificate(env = process.env) {
|
||||
return String(env.OTTAWA_CAMERA_CERTIFICATE ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the operator's certificate to a registered keyless Ottawa frame URL.
|
||||
* Only a URL inside the pinned `/opendata/` directory with a numeric camera
|
||||
* number is signed, so the certificate never goes to any other path or host.
|
||||
*
|
||||
* @param {string} url - Canonical frame URL from the catalog.
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {string} The signed URL, or '' when there is no certificate or the
|
||||
* URL is not a registered Ottawa frame.
|
||||
*/
|
||||
export function ottawaAuthorizedFrameUrl(url, env = process.env) {
|
||||
const certificate = ottawaCertificate(env);
|
||||
const pinned = pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN);
|
||||
if (!certificate || !pinned) return '';
|
||||
const parsed = new URL(pinned);
|
||||
const cameraNumber = parsed.searchParams.get('c') || '';
|
||||
if (parsed.pathname !== '/opendata/camera' || !/^\d+$/.test(cameraNumber))
|
||||
return '';
|
||||
const signed = new URL(OTTAWA_IMAGE_URL(cameraNumber));
|
||||
signed.searchParams.set('certificate', certificate);
|
||||
signed.searchParams.set('id', OTTAWA_API_USER_ID);
|
||||
return signed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* City of Ottawa envelope: Kanata to Orléans, Barrhaven to the river, with
|
||||
* slack for the MTO cameras on the 416/417 that the city feed also carries.
|
||||
*/
|
||||
const OTTAWA_BOUNDS = Object.freeze({
|
||||
minLat: 45.1,
|
||||
maxLat: 45.65,
|
||||
minLon: -76.2,
|
||||
maxLon: -75.1,
|
||||
});
|
||||
|
||||
/** Whether a point is inside the city feed's service area. */
|
||||
function isOttawaCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= OTTAWA_BOUNDS.minLat &&
|
||||
lat <= OTTAWA_BOUNDS.maxLat &&
|
||||
lon >= OTTAWA_BOUNDS.minLon &&
|
||||
lon <= OTTAWA_BOUNDS.maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the City of Ottawa's traffic cameras.
|
||||
*
|
||||
* Camera images come from the city's Traffic Camera Images API, which needs a
|
||||
* certificate from the city's registration form (OTTAWA_CAMERA_CERTIFICATE)
|
||||
* and allows one fetch per camera per minute for a certificate and user id.
|
||||
* Without a certificate the pack is skipped, with no request and one log line.
|
||||
* The camera list is the documented `map/service/camera` JSON: a camera
|
||||
* number, English and French names, coordinates and the owner (CITY or MTO).
|
||||
* The feed carries no bearing, no elevation and no status field, so headings
|
||||
* use the same deterministic low-confidence fallback as the other
|
||||
* heading-less packs and the client's ground snap refines the elevation
|
||||
* prior.
|
||||
*
|
||||
* @param {object} [_options]
|
||||
* @param {Record<string,string|undefined>} [env]
|
||||
* @returns {Promise<Array<object>>} Cameras nearest downtown first, or [] on
|
||||
* a missing certificate or any upstream or payload failure.
|
||||
*/
|
||||
export async function loadOttawaSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
if (!ottawaCertificate(env)) {
|
||||
if (!ottawaCertificateMissingLogged) {
|
||||
ottawaCertificateMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ottawa cameras skipped: the city camera image API needs a certificate (set OTTAWA_CAMERA_CERTIFICATE)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(OTTAWA_CAMERAS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ottawa camera download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = await resp.json();
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : null;
|
||||
if (!rows) return [];
|
||||
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
const number = toFiniteNumber(row?.camera_number);
|
||||
if (!Number.isSafeInteger(number) || number <= 0 || seen.has(number))
|
||||
continue;
|
||||
const lat = toFiniteNumber(row?.latitude);
|
||||
const lon = toFiniteNumber(row?.longitude);
|
||||
if (!isOttawaCoordinate(lat, lon)) continue;
|
||||
seen.add(number);
|
||||
|
||||
const cameraId = `ottawa-${number}`;
|
||||
const imageUrl = OTTAWA_IMAGE_URL(number);
|
||||
// The docs name the field camera_owner; the live feed sends cameraOwner.
|
||||
const owner = String(row?.camera_owner || row?.cameraOwner || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(row?.name || '').trim() || `Ottawa camera ${number}`,
|
||||
city: 'Ottawa',
|
||||
cityId: 'ottawa',
|
||||
provider: 'City of Ottawa',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
frameRefreshMs: OTTAWA_FRAME_REFRESH_MS,
|
||||
sourceKind: 'ottawa-traffic',
|
||||
license: 'City of Ottawa Traffic Camera Images API (registered access)',
|
||||
credit:
|
||||
owner === 'MTO'
|
||||
? 'Ministry of Transportation of Ontario (MTO) camera'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_OTTAWA_MAX_SOURCES || DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(500, Math.floor(maxRaw)))
|
||||
: DEFAULT_OTTAWA_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, OTTAWA_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Ottawa camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Ottawa camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cameraDisplayCode, fallbackHeadingFromId } from './normalize.js';
|
||||
|
||||
const QUEBEC_CATALOG_PATH = 'public/quebec-cameras.json';
|
||||
export const QUEBEC_CAMERA_SOURCE_COUNT = 1120;
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to the browser-direct Québec 511 video. Off by
|
||||
* default: the camera locations are CC BY 4.0, but whether Québec 511's
|
||||
* service terms allow another site to embed its clips is unconfirmed, so an
|
||||
* unset flag lists the cameras with a link to the 511 viewer page only.
|
||||
*/
|
||||
export function quebecBrowserVideoEnabled(env = process.env) {
|
||||
return String(env.CCTV_QUEBEC_VIDEO_ENABLED || '').trim() === '1';
|
||||
}
|
||||
|
||||
/** Build the direct browser-only MP4 URL for one namespaced MTQ camera id. */
|
||||
export function quebec511VideoUrl(cameraId) {
|
||||
const match = /^qc-511-(\d+)$/.exec(String(cameraId || ''));
|
||||
return match
|
||||
? `https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=${match[1]}&format=mp4`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeViewerUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function sourceId(camera) {
|
||||
const id = String(camera?.id || '').trim();
|
||||
if (camera?.kind === 'traffic' && /^\d+$/.test(id)) return `qc-511-${id}`;
|
||||
if (camera?.kind === 'montreal' && /^mtl-\d+(?:-\d+)*$/.test(id))
|
||||
return `qc-mtl-${id.slice(4)}`;
|
||||
if (camera?.kind === 'webcam' && /^web-\d+$/.test(id))
|
||||
return `qc-web-${id.slice(4)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the checked-in, no-key Québec location catalog and adapt it to the
|
||||
* regular CCTV source schema. MTQ video stays browser-only; other rows link to
|
||||
* their operator page because this dataset has no direct image URL for them.
|
||||
*/
|
||||
export function loadQuebecSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, QUEBEC_CATALOG_PATH);
|
||||
let cameras;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Québec camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
const seen = new Set();
|
||||
for (const camera of cameras) {
|
||||
const id = sourceId(camera);
|
||||
const lat = Number(camera.lat);
|
||||
const lon = Number(camera.lon);
|
||||
if (
|
||||
!id ||
|
||||
seen.has(id) ||
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
lat < 44.9 ||
|
||||
lat > 62.6 ||
|
||||
lon < -79.9 ||
|
||||
lon > -57.1
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
|
||||
const name = String(camera.nameEn || camera.name || id).trim();
|
||||
const isTraffic = camera.kind === 'traffic';
|
||||
const isMontreal = camera.kind === 'montreal';
|
||||
const sourceKind = isTraffic
|
||||
? 'quebec-511-browser'
|
||||
: isMontreal
|
||||
? 'quebec-montreal-osm'
|
||||
: 'quebec-webcam-link';
|
||||
const provider = isTraffic
|
||||
? 'Québec 511 / MTQ'
|
||||
: isMontreal
|
||||
? 'Ville de Montréal / OpenStreetMap'
|
||||
: String(camera.operator || 'Québec webcam');
|
||||
|
||||
sources.push({
|
||||
id,
|
||||
name: name.slice(0, 180),
|
||||
city: String(camera.region || 'Québec'),
|
||||
cityId: 'quebec',
|
||||
provider,
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(id),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 74,
|
||||
rangeM: 400,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 0,
|
||||
feedType: 'image',
|
||||
// No frame URL is registered: the server never fetches 511 media. With
|
||||
// CCTV_QUEBEC_VIDEO_ENABLED=1 the browser plays the 511 MP4 in the DOM
|
||||
// overlay after a play click; otherwise the camera links to 511.
|
||||
url: '',
|
||||
snapshotUrl: '',
|
||||
sourceKind,
|
||||
sourceUrl: safeViewerUrl(camera.viewerUrl),
|
||||
accuracyM: Number.isFinite(Number(camera.accuracyM))
|
||||
? Number(camera.accuracyM)
|
||||
: null,
|
||||
positionSource: String(camera.positionSource || ''),
|
||||
license: isTraffic
|
||||
? 'Locations: Données Québec / MTQ, CC BY 4.0. Video: hosted by Québec 511 under its terms of use'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors, ODbL 1.0'
|
||||
: 'Operator terms apply; location provenance shown per camera',
|
||||
credit: isTraffic
|
||||
? 'Locations: MTQ · CC BY 4.0 · Video: Québec 511'
|
||||
: isMontreal
|
||||
? 'OpenStreetMap contributors · ODbL'
|
||||
: provider,
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
@@ -134,12 +134,6 @@ import {
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_ANCHORS,
|
||||
ICELAND_CAMERAS_URL,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
ICELAND_MAX_CATALOG_BYTES,
|
||||
ICELAND_GROUND_ELEVATION_M,
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
ICELAND_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -158,7 +152,6 @@ import {
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
isLikelyIcelandCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
isLikelyHoustonCoordinate,
|
||||
@@ -2036,174 +2029,6 @@ export async function loadCataloniaSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compass headings for Icelandic direction words, as they appear after "til"
|
||||
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
|
||||
* as in "séð austur").
|
||||
*/
|
||||
const ICELAND_DIRECTION_HEADINGS = {
|
||||
norður: 0,
|
||||
norðaustur: 45,
|
||||
austur: 90,
|
||||
suðaustur: 135,
|
||||
suður: 180,
|
||||
suðvestur: 225,
|
||||
vestur: 270,
|
||||
norðvestur: 315,
|
||||
};
|
||||
const ICELAND_DIRECTION_WORD =
|
||||
'(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?';
|
||||
/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */
|
||||
const ICELAND_DIRECTION_RE = new RegExp(
|
||||
`(?<![\\p{L}])(?:til|í|séð)\\s+${ICELAND_DIRECTION_WORD}` +
|
||||
`(?:\\s+og\\s+(?:til\\s+|í\\s+)?${ICELAND_DIRECTION_WORD})?(?![\\p{L}])`,
|
||||
'gu',
|
||||
);
|
||||
|
||||
/**
|
||||
* The compass heading an Icelandic view description states, or NaN. Only an
|
||||
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
|
||||
* a road direction ("Miklabraut austur") is not a facing. A description naming
|
||||
* two different directions ("til norðurs og til suðurs", "til austurs og
|
||||
* vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the
|
||||
* road) states none.
|
||||
*
|
||||
* @param {string} text - The feed's `Skyring` field.
|
||||
* @returns {number} Heading in degrees [0..360), or NaN.
|
||||
*/
|
||||
export function icelandHeadingFromDescription(text) {
|
||||
const headings = new Set();
|
||||
for (const match of String(text || '')
|
||||
.toLowerCase()
|
||||
.matchAll(ICELAND_DIRECTION_RE)) {
|
||||
for (const word of [match[1], match[2]]) {
|
||||
if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]);
|
||||
}
|
||||
}
|
||||
return headings.size === 1 ? headings.values().next().value : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
|
||||
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
|
||||
* file name, which is unique per view in the live feed.
|
||||
*
|
||||
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function icelandCameraToSource(row) {
|
||||
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
|
||||
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
|
||||
// Numbers only: Number(null) and Number('') are 0.
|
||||
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
|
||||
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
|
||||
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
|
||||
const stem = new URL(url).pathname
|
||||
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
|
||||
.replace(/\.jpe?g$/i, '')
|
||||
.toLowerCase();
|
||||
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
|
||||
const cameraId = `is-vegagerdin-${stem}`;
|
||||
const description = String(row.Skyring || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
// Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá
|
||||
// Árnessýslu" while missing "séð séð".
|
||||
.replace(/(?<!\p{L})(\p{L}+) \1(?!\p{L})/giu, '$1')
|
||||
.trim();
|
||||
const station = String(row.Myndavel || '').trim();
|
||||
const heading = icelandHeadingFromDescription(description);
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
return {
|
||||
id: cameraId,
|
||||
name: description || station || `Vegagerðin ${row.Maelist_nr ?? stem}`,
|
||||
city: 'Iceland',
|
||||
cityId: 'iceland',
|
||||
provider: 'Vegagerðin (IRCA)',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: ICELAND_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url,
|
||||
snapshotUrl: url,
|
||||
sourceKind: 'iceland-vegagerdin',
|
||||
license:
|
||||
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
|
||||
code: cameraDisplayCode(station.toUpperCase() || description),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
|
||||
* Frames are stills on the IRCA host and the loader registers only links
|
||||
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
|
||||
* ids, coordinates, labels and which frame path is proxied, so it is fetched
|
||||
* over https, redirects are refused and the body read is capped at
|
||||
* ICELAND_MAX_CATALOG_BYTES.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadIcelandSourcesFromOpenData() {
|
||||
try {
|
||||
const resp = await fetch(ICELAND_CAMERAS_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
// A response this loader will not read still owns its transport until the
|
||||
// body is released, so every rejection path cancels before returning.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Iceland camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
|
||||
const rows = Array.isArray(body) ? body : [];
|
||||
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
|
||||
const unique = Array.from(
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||
: DEFAULT_ICELAND_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one XML child tag's text from an `<image>` block.
|
||||
*
|
||||
|
||||
@@ -5,9 +5,7 @@ export const HLS_LIMITS = Object.freeze({
|
||||
sessions: 2,
|
||||
leasesPerSession: 8,
|
||||
playlistBytes: 256 * 1024,
|
||||
// High-resolution cameras can exceed 4 MiB in a single ten-second TS segment.
|
||||
// Retained data still shares the independent 24 MiB session budget below.
|
||||
segmentBytes: 8 * 1024 * 1024,
|
||||
segmentBytes: 4 * 1024 * 1024,
|
||||
sessionBytes: 24 * 1024 * 1024,
|
||||
segments: 12,
|
||||
pollMs: 2000,
|
||||
|
||||
+11
-158
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS,
|
||||
} from './constants.js';
|
||||
import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js';
|
||||
import {
|
||||
cameraDisplayCode,
|
||||
fallbackHeadingFromId,
|
||||
@@ -13,8 +10,6 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* standard, published keyless and refreshed daily (~1,800 cameras). */
|
||||
export const TAIWAN_FREEWAY_CCTV_URL =
|
||||
'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml';
|
||||
export const TAIWAN_HIGHWAY_CCTV_URL =
|
||||
'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
/** The whole network is ~1.2 MB. */
|
||||
export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024;
|
||||
const DEFAULT_TAIWAN_MAX_SOURCES = 600;
|
||||
@@ -40,9 +35,6 @@ const STREAM_TO_FRAME = new Map([
|
||||
]);
|
||||
|
||||
const DIRECTIONS = { N: 'N', S: 'S', E: 'E', W: 'W' };
|
||||
const TAIWAN_HIGHWAY_CAMERA_HOSTS = new Set(
|
||||
TAIWAN_HIGHWAY_IMAGE_ORIGINS.map((origin) => new URL(origin).hostname),
|
||||
);
|
||||
|
||||
/** Prioritization anchors, north to south, so a cap keeps the whole island
|
||||
* rather than only greater Taipei. */
|
||||
@@ -219,74 +211,6 @@ export function taiwanCameraToSource(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One Highway Bureau row -> a source whose first MJPEG frame is served as a
|
||||
* snapshot by the existing camera-frame proxy.
|
||||
*
|
||||
* @param {object} row - Output of parseTaiwanCctvXml.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function taiwanHighwayCameraToSource(row) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { lat, lon } = row;
|
||||
if (!isLikelyTaiwanCoordinate(lat, lon)) return null;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(row.streamUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.port ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
!TAIWAN_HIGHWAY_CAMERA_HOSTS.has(url.hostname) ||
|
||||
!/^\/[A-Za-z0-9._+~-]{1,100}$/.test(url.pathname) ||
|
||||
url.pathname.includes('..')
|
||||
)
|
||||
return null;
|
||||
|
||||
const rawId = String(row.cctvId ?? '').trim();
|
||||
if (!/^[A-Za-z0-9-]{1,80}$/.test(rawId)) return null;
|
||||
const cameraId = 'tw-thb-' + rawId.toLowerCase();
|
||||
const road = String(row.roadName ?? '').trim();
|
||||
const direction = DIRECTIONS[String(row.direction ?? '').trim()] || '';
|
||||
const mile = String(row.mile ?? '').trim();
|
||||
const section = [row.sectionStart, row.sectionEnd]
|
||||
.map((part) => String(part ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('→');
|
||||
const name =
|
||||
(road || mile || section
|
||||
? [road, direction, mile, section].filter(Boolean).join(' ')
|
||||
: '') || rawId;
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: name.slice(0, 160),
|
||||
city: 'Taiwan / 臺灣',
|
||||
cityId: 'taiwan',
|
||||
provider: 'Highway Bureau, MOTC',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
feedType: 'mjpeg',
|
||||
url: url.href,
|
||||
snapshotUrl: url.href,
|
||||
sourceKind: 'taiwan-highway-motc',
|
||||
license: 'Highway Bureau, MOTC open data',
|
||||
code: cameraDisplayCode((road + ' ' + mile).trim().toUpperCase() || rawId),
|
||||
};
|
||||
}
|
||||
let lastGood = null;
|
||||
let lastGoodAt = 0;
|
||||
let retryAfter = 0;
|
||||
@@ -313,49 +237,6 @@ function taiwanSourceCap() {
|
||||
: DEFAULT_TAIWAN_MAX_SOURCES;
|
||||
}
|
||||
|
||||
/** Fetch the public provincial-road CCTV table as a no-key fallback. */
|
||||
async function loadTaiwanHighwaySources() {
|
||||
const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS);
|
||||
const response = await fetch(TAIWAN_HIGHWAY_CCTV_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'Highway Bureau catalog redirected; redirects are not followed'
|
||||
: 'Highway Bureau catalog HTTP ' + response.status,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
response,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
signal,
|
||||
);
|
||||
const cameras = new Map();
|
||||
for (const row of parseTaiwanCctvXml(xml)) {
|
||||
const camera = taiwanHighwayCameraToSource(row);
|
||||
if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera);
|
||||
}
|
||||
if (!cameras.size)
|
||||
throw new Error('no usable Highway Bureau cameras in the table');
|
||||
const sources = prioritizeSources(
|
||||
[...cameras.values()],
|
||||
taiwanSourceCap(),
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
'[CCTV] Loaded Taiwan Highway Bureau fallback: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Taiwan Freeway Bureau cameras from the keyless MOTC 2.0 XML table.
|
||||
* One bounded request per shared catalog refresh. A failed download serves the
|
||||
@@ -381,7 +262,7 @@ export async function loadTaiwanSources() {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: 'HTTP ' + response.status,
|
||||
: `HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const xml = await readResponseTextCapped(
|
||||
@@ -401,11 +282,7 @@ export async function loadTaiwanSources() {
|
||||
ANCHORS,
|
||||
);
|
||||
console.log(
|
||||
'[CCTV] Loaded Taiwan camera sources: ' +
|
||||
cameras.size +
|
||||
' (using ' +
|
||||
sources.length +
|
||||
')',
|
||||
`[CCTV] Loaded Taiwan camera sources: ${cameras.size} (using ${sources.length})`,
|
||||
);
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
@@ -414,37 +291,13 @@ export async function loadTaiwanSources() {
|
||||
} catch (error) {
|
||||
retryAfter = Date.now() + TAIWAN_RETRY_AFTER_FAILURE_MS;
|
||||
const fallback = fallbackSources();
|
||||
if (fallback.length) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); serving the last good ' +
|
||||
fallback.length +
|
||||
' cameras, next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const sources = await loadTaiwanHighwaySources();
|
||||
lastGood = sources;
|
||||
lastGoodAt = Date.now();
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); using ' +
|
||||
sources.length +
|
||||
' Highway Bureau cameras as fallback, next attempt in 1 h',
|
||||
);
|
||||
return sources;
|
||||
} catch (fallbackError) {
|
||||
console.warn(
|
||||
'[CCTV] Taiwan Freeway camera download failed (' +
|
||||
(error?.message || error) +
|
||||
'); Highway Bureau fallback failed (' +
|
||||
(fallbackError?.message || fallbackError) +
|
||||
'); no Taiwan cameras, next attempt in 1 h',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
console.warn(
|
||||
`[CCTV] Taiwan camera download failed (${error?.message || error}); ` +
|
||||
(fallback.length
|
||||
? `serving the last good ${fallback.length} cameras`
|
||||
: 'no Taiwan cameras') +
|
||||
', next attempt in 1 h',
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
MLIT_RIVER_IMAGE_ORIGIN,
|
||||
TRIPCHECK_IMAGE_ORIGIN,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from './constants.js';
|
||||
import { pinnedCameraUrl } from './normalize.js';
|
||||
|
||||
const SOURCE_POLICIES = Object.freeze({
|
||||
'tripcheck-odot': {
|
||||
idPrefix: 'world-or-',
|
||||
origin: TRIPCHECK_IMAGE_ORIGIN,
|
||||
bounds: [41, 47, -125, -116],
|
||||
filename: /^\/roadcams\/cams\/[^/]+\.jpe?g$/i,
|
||||
frameRefreshMs: 5 * 60 * 1000,
|
||||
},
|
||||
'mlit-river': {
|
||||
idPrefix: 'world-jp-river-',
|
||||
origin: MLIT_RIVER_IMAGE_ORIGIN,
|
||||
bounds: [24, 46, 122, 154],
|
||||
filename: /^\/cam\/now\/\d+\.jpg$/i,
|
||||
// The provider asks clients to avoid frequent repeated image collection.
|
||||
frameRefreshMs: 10 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// MLIT currently serves this fixed JPEG on cameras whose images are paused
|
||||
// for maintenance (verified against two Yamagata cameras on 2026-11-02).
|
||||
const MLIT_ADJUSTMENT_IMAGE_SHA256 =
|
||||
'dbd2c3790eb75edf9ca44162f7979f2a3799090e23f2c5142f63211a035cb191';
|
||||
|
||||
export function isMlitRiverAdjustmentImage(image) {
|
||||
return (
|
||||
image?.ok === true &&
|
||||
Buffer.isBuffer(image.body) &&
|
||||
createHash('sha256').update(image.body).digest('hex') ===
|
||||
MLIT_ADJUSTMENT_IMAGE_SHA256
|
||||
);
|
||||
}
|
||||
|
||||
function safeFrameUrl(value, policy) {
|
||||
const normalized = pinnedCameraUrl(value, policy.origin);
|
||||
if (!normalized) return '';
|
||||
const url = new URL(normalized);
|
||||
return !url.search && !url.hash && policy.filename.test(url.pathname)
|
||||
? url.href
|
||||
: '';
|
||||
}
|
||||
|
||||
function inBounds(lat, lon, [minLat, maxLat, minLon, maxLon]) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= minLat &&
|
||||
lat <= maxLat &&
|
||||
lon >= minLon &&
|
||||
lon <= maxLon
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the new image-backed networks from the add-on export. Existing
|
||||
* Canadian networks, Québec entries, JMA placeholder images, and NZTA's
|
||||
* scraped Journey Planner rows are deliberately excluded. Vegagerðin (Iceland)
|
||||
* rows are excluded too: the Iceland pack loads the full live list of those
|
||||
* cameras, so a World copy would draw each one twice.
|
||||
*/
|
||||
export function loadWorldCameraSources({ sourceRoot = process.cwd() } = {}) {
|
||||
const filename = path.resolve(sourceRoot, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
let rows;
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
rows = Array.isArray(payload) ? payload : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] World camera catalog unavailable:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const sources = [];
|
||||
for (const row of rows) {
|
||||
const sourceKind = String(row?.sourceKind || '');
|
||||
const policy = SOURCE_POLICIES[sourceKind];
|
||||
if (!policy) continue;
|
||||
|
||||
const id = String(row.id || '').trim();
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
const url = safeFrameUrl(row.url || row.snapshotUrl, policy);
|
||||
const snapshotUrl = safeFrameUrl(row.snapshotUrl || row.url, policy);
|
||||
if (
|
||||
!id.startsWith(policy.idPrefix) ||
|
||||
seen.has(id) ||
|
||||
!inBounds(lat, lon, policy.bounds) ||
|
||||
!url ||
|
||||
!snapshotUrl ||
|
||||
url !== snapshotUrl
|
||||
)
|
||||
continue;
|
||||
seen.add(id);
|
||||
sources.push({
|
||||
id,
|
||||
name: String(row.name || id)
|
||||
.trim()
|
||||
.slice(0, 180),
|
||||
city: String(row.city || row.country || 'World'),
|
||||
cityId: String(row.cityId || ''),
|
||||
provider: String(row.provider || 'World traffic camera'),
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: Number.isFinite(Number(row.headingDeg))
|
||||
? Number(row.headingDeg)
|
||||
: undefined,
|
||||
headingConfidence: String(row.headingConfidence || 'low'),
|
||||
feedType: 'image',
|
||||
sourceKind,
|
||||
url,
|
||||
snapshotUrl,
|
||||
frameRefreshMs: policy.frameRefreshMs,
|
||||
license: String(row.license || ''),
|
||||
credit: String(row.credit || row.provider || ''),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[CCTV] Loaded World camera image sources: ${sources.length}/${WORLD_CCTV_SOURCE_COUNT}`,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
@@ -64,30 +64,6 @@ function validMessages(messages) {
|
||||
return !pending.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool schema without the `pattern`s Chat Completions refuses: it answers
|
||||
* 400 for a whole request whose tool uses Unicode property classes (`\p{L}`),
|
||||
* as `resolve_area`'s area ids do. Every argument is still checked against
|
||||
* the action's own contract before the tool runs, so the model loses a hint,
|
||||
* not a rule. Realtime voice accepts these patterns and keeps them.
|
||||
*/
|
||||
export function chatSchema(schema) {
|
||||
if (Array.isArray(schema)) return schema.map(chatSchema);
|
||||
if (!object(schema)) return schema;
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!(
|
||||
key === 'pattern' &&
|
||||
typeof value === 'string' &&
|
||||
/\\[pP]\{/.test(value)
|
||||
),
|
||||
)
|
||||
.map(([key, value]) => [key, chatSchema(value)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function createOpenAiChatHandler({
|
||||
annotationGuidance,
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
@@ -150,12 +126,7 @@ export function createOpenAiChatHandler({
|
||||
tools: offeredTools(tools, { overpass }).map(
|
||||
({ name, description, parameters }) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: chatSchema(parameters),
|
||||
strict: false,
|
||||
},
|
||||
function: { name, description, parameters, strict: false },
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -224,7 +224,12 @@ function createRealtimeTokenHandler({
|
||||
if (!bearer) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import {
|
||||
celestrakSatcatUrl,
|
||||
celestrakTleUrl,
|
||||
} from '../../../src/data/spaceProviderRequests.js';
|
||||
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
||||
import { providerCooldownMs } from './cooldown.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
@@ -25,131 +22,84 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
|
||||
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
|
||||
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
|
||||
*
|
||||
* The same plugin serves CelesTrak SATCAT records for the satellites layer's
|
||||
* groups at `/api/celestrak-satcat/<group>` (object type, operational status
|
||||
* and owner, which file each object under a class). The catalog changes
|
||||
* slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not
|
||||
* the endpoint, so a 403 or 429 holds both routes off; any other failure
|
||||
* holds off only the route that failed, so a bad SATCAT answer never stops
|
||||
* TLE refreshes.
|
||||
*/
|
||||
export function celestrakProxy() {
|
||||
const TLE_TTL_MS = 6 * 3600_000;
|
||||
const SATCAT_TTL_MS = 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
/** The satellites layer's core groups (src/layers/satellites/policy.js). */
|
||||
const coreGroups = [
|
||||
const groups = new Set([
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
];
|
||||
const feeds = [
|
||||
{
|
||||
route: '/api/celestrak',
|
||||
ttlMs: TLE_TTL_MS,
|
||||
cacheHeader: 'x-tle-cache',
|
||||
cachedAtHeader: 'X-TLE-Cached-At',
|
||||
diskName: (group) => `celestrak-${group}.json`,
|
||||
upstreamUrl: celestrakTleUrl,
|
||||
groups: new Set([
|
||||
...coreGroups,
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]),
|
||||
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
||||
validate: (body) => /^1 /m.test(body),
|
||||
invalid: 'no TLE lines in response',
|
||||
},
|
||||
{
|
||||
route: '/api/celestrak-satcat',
|
||||
ttlMs: SATCAT_TTL_MS,
|
||||
cacheHeader: 'x-satcat-cache',
|
||||
cachedAtHeader: 'X-SATCAT-Cached-At',
|
||||
diskName: (group) => `celestrak-satcat-${group}.json`,
|
||||
upstreamUrl: celestrakSatcatUrl,
|
||||
groups: new Set(coreGroups),
|
||||
// A real answer, even for an empty group, starts with the CSV header.
|
||||
validate: (body) => /^OBJECT_NAME,/.test(body),
|
||||
invalid: 'no SATCAT header in response',
|
||||
},
|
||||
];
|
||||
// CelesTrak throttling (403/429) is per client, so it holds every feed
|
||||
// off; any other failure holds off only the feed that failed, so a bad
|
||||
// SATCAT answer never blocks the TLEs the layer cannot do without.
|
||||
let hostCooldownUntil = 0;
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]);
|
||||
let cooldownUntil = 0;
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
|
||||
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
|
||||
|
||||
async function readDisk(group) {
|
||||
try {
|
||||
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
|
||||
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
|
||||
return parsed;
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeDisk(group, entry) {
|
||||
try {
|
||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[celestrak-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
|
||||
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function createFeedHandler(feed) {
|
||||
let feedCooldownUntil = 0;
|
||||
const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil);
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group));
|
||||
|
||||
async function readDisk(group) {
|
||||
try {
|
||||
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
|
||||
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
|
||||
return parsed;
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeDisk(group, entry) {
|
||||
try {
|
||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[celestrak-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstream(group) {
|
||||
const url = feed.upstreamUrl(group);
|
||||
const signal = AbortSignal.timeout(20000);
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
||||
// descriptive User-Agent with a contact point.
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
const until = Date.now() + providerCooldownMs(res);
|
||||
if (res.status === 403 || res.status === 429)
|
||||
hostCooldownUntil = Math.max(hostCooldownUntil, until);
|
||||
else feedCooldownUntil = Math.max(feedCooldownUntil, until);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
res,
|
||||
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
async function fetchUpstream(group) {
|
||||
const url = celestrakTleUrl(group);
|
||||
const signal = AbortSignal.timeout(20000);
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
||||
// descriptive User-Agent with a contact point.
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
cooldownUntil = Math.max(
|
||||
cooldownUntil,
|
||||
Date.now() + providerCooldownMs(res),
|
||||
);
|
||||
if (!feed.validate(body)) throw new Error(feed.invalid);
|
||||
return { at: Date.now(), body };
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
res,
|
||||
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
||||
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
|
||||
return { at: Date.now(), body };
|
||||
}
|
||||
|
||||
return async (req, res) => {
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/celestrak', async (req, res) => {
|
||||
const group = String(req.url || '')
|
||||
.replace(/^\//, '')
|
||||
.split('?')[0];
|
||||
@@ -161,7 +111,7 @@ export function celestrakProxy() {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!feed.groups.has(group)) {
|
||||
if (!groups.has(group)) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('invalid group');
|
||||
return;
|
||||
@@ -173,15 +123,15 @@ export function celestrakProxy() {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/plain',
|
||||
[feed.cacheHeader]: cacheStatus,
|
||||
'x-tle-cache': cacheStatus,
|
||||
'Cache-Control': 'no-store',
|
||||
...(entry
|
||||
? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() }
|
||||
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
|
||||
: {}),
|
||||
...(cooldownUntil() > 0 && Date.now() < cooldownUntil()
|
||||
...(cooldownUntil > 0 && Date.now() < cooldownUntil
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((cooldownUntil() - Date.now()) / 1000),
|
||||
Math.ceil((cooldownUntil - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -195,11 +145,11 @@ export function celestrakProxy() {
|
||||
entry = await readDisk(group);
|
||||
if (entry) mem.set(group, entry);
|
||||
}
|
||||
if (entry && now - entry.at < feed.ttlMs) {
|
||||
if (entry && now - entry.at < TLE_TTL_MS) {
|
||||
send(200, entry.body, 'HIT', entry);
|
||||
return;
|
||||
}
|
||||
if (Date.now() < cooldownUntil()) {
|
||||
if (Date.now() < cooldownUntil) {
|
||||
send(
|
||||
entry ? 200 : 502,
|
||||
entry?.body || 'celestrak upstream cooling down',
|
||||
@@ -219,8 +169,8 @@ export function celestrakProxy() {
|
||||
return fresh;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (Date.now() >= feedCooldownUntil)
|
||||
feedCooldownUntil = Date.now() + 60000;
|
||||
if (Date.now() >= cooldownUntil)
|
||||
cooldownUntil = Date.now() + 60000;
|
||||
console.warn(
|
||||
'[celestrak-proxy] refresh failed — serving cache if any',
|
||||
);
|
||||
@@ -241,13 +191,7 @@ export function celestrakProxy() {
|
||||
console.error('[celestrak-proxy] request failed');
|
||||
send(500, 'celestrak proxy error', 'ERROR');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlers = feeds.map((feed) => [feed.route, createFeedHandler(feed)]);
|
||||
const installMiddleware = (server) => {
|
||||
for (const [route, handler] of handlers)
|
||||
server.middlewares.use(route, handler);
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'celestrak-proxy',
|
||||
|
||||
@@ -43,167 +43,24 @@ export function drawMarkFallbackName(mark) {
|
||||
: shape;
|
||||
}
|
||||
|
||||
/** The kind of shape a row action is offered for: `area` or `line`. */
|
||||
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
|
||||
|
||||
const lonLat = (point) =>
|
||||
Array.isArray(point)
|
||||
? [Number(point[0]), Number(point[1])]
|
||||
: [Number(point?.lon), Number(point?.lat)];
|
||||
const finitePairs = (points) =>
|
||||
(Array.isArray(points) ? points : [])
|
||||
.map(lonLat)
|
||||
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
||||
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
|
||||
const wrapLon = (lon) =>
|
||||
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
/** Whether consecutive positions step across the antimeridian once wrapped. */
|
||||
const crossesAntimeridian = (points) =>
|
||||
points.some(
|
||||
(point, index) =>
|
||||
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
|
||||
);
|
||||
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
|
||||
const inRange = (points) => {
|
||||
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
|
||||
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
|
||||
return crossesAntimeridian(wrapped) ? null : wrapped;
|
||||
};
|
||||
const closed = (ring) => {
|
||||
if (ring.length < 3) return ring;
|
||||
const [first, last] = [ring[0], ring[ring.length - 1]];
|
||||
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
|
||||
};
|
||||
|
||||
/**
|
||||
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
|
||||
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
|
||||
* handle has several parts), a line a `LineString`. A pin, a shape too small
|
||||
* to be one, or one that crosses the antimeridian has none, so it is offered
|
||||
* no action rather than a shape that wraps the wrong way round the world.
|
||||
*/
|
||||
export function drawMarkGeometry(mark) {
|
||||
if (mark?.type === 'route') {
|
||||
const path = inRange(finitePairs(mark.path));
|
||||
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
|
||||
}
|
||||
if (mark?.type !== 'area') return null;
|
||||
const ringOf = (points) => {
|
||||
const ring = inRange(closed(finitePairs(points)));
|
||||
return ring?.length >= 4 ? ring : null;
|
||||
};
|
||||
if (Array.isArray(mark.polygons) && mark.polygons.length) {
|
||||
const polygons = [];
|
||||
for (const rings of mark.polygons) {
|
||||
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
|
||||
// A part that cannot be given whole is no part: never a hole dropped.
|
||||
if (!parts.length || parts.some((ring) => !ring)) return null;
|
||||
polygons.push(parts);
|
||||
}
|
||||
return polygons.length > 1
|
||||
? { type: 'MultiPolygon', coordinates: polygons }
|
||||
: { type: 'Polygon', coordinates: polygons[0] };
|
||||
}
|
||||
const ring = ringOf(mark.ring);
|
||||
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions an application adds to Draw's rows, checked once: each has an
|
||||
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
|
||||
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
|
||||
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
|
||||
* as the row shows it). Malformed or repeated ids are dropped.
|
||||
*/
|
||||
export function drawMarkActions(actions = []) {
|
||||
const kept = new Map();
|
||||
for (const action of Array.isArray(actions) ? actions : []) {
|
||||
const wellFormed =
|
||||
typeof action?.id === 'string' &&
|
||||
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
|
||||
typeof action.icon === 'string' &&
|
||||
typeof action.label === 'function' &&
|
||||
typeof action.run === 'function' &&
|
||||
Array.isArray(action.shapes);
|
||||
if (!wellFormed || kept.has(action.id)) continue;
|
||||
kept.set(
|
||||
action.id,
|
||||
Object.freeze({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: action.label,
|
||||
shapes: Object.freeze(
|
||||
action.shapes.filter((shape) =>
|
||||
Object.values(ACTION_SHAPES).includes(shape),
|
||||
),
|
||||
),
|
||||
accepts:
|
||||
typeof action.accepts === 'function' ? action.accepts : () => true,
|
||||
run: action.run,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze([...kept.values()]);
|
||||
}
|
||||
|
||||
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
|
||||
export function drawMarkActionsFor(mark, actions = []) {
|
||||
const shape = ACTION_SHAPES[mark?.type];
|
||||
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
|
||||
const geometry = drawMarkGeometry(mark);
|
||||
if (!geometry) return { shape, geometry, actions: [] };
|
||||
return {
|
||||
shape,
|
||||
geometry,
|
||||
actions: actions.filter((action) => {
|
||||
if (!action.shapes.includes(shape)) return false;
|
||||
try {
|
||||
return action.accepts(geometry, shape) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** An action's words for a row; an application's label that throws shows its id. */
|
||||
function actionLabel(action, title) {
|
||||
try {
|
||||
const text = action.label(title);
|
||||
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
|
||||
} catch {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for the hand-drawn marks on the board, in board order. Text is
|
||||
* worded here, in the current language; the view only lays it out.
|
||||
* @param {object[]} marks The engine's `list()`.
|
||||
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
|
||||
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
|
||||
*/
|
||||
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
|
||||
export function presentDrawMarks(marks = []) {
|
||||
return marks
|
||||
.filter((mark) => mark?.manual === true)
|
||||
.map((mark) => {
|
||||
const fallback = drawMarkFallbackName(mark);
|
||||
const title = mark.name || fallback;
|
||||
const visible = !mark.hidden;
|
||||
const offered = drawMarkActionsFor(mark, actions).actions;
|
||||
return {
|
||||
id: mark.id,
|
||||
name: mark.name || '',
|
||||
placeholder: fallback,
|
||||
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
|
||||
hidden: !visible,
|
||||
busy: notes.get(mark.id)?.busy === true,
|
||||
note: offered.length ? notes.get(mark.id)?.text || '' : '',
|
||||
actions: offered.map((action) => ({
|
||||
id: action.id,
|
||||
icon: action.icon,
|
||||
label: actionLabel(action, title),
|
||||
})),
|
||||
labels: {
|
||||
name: t('Name of {name}', { name: title }),
|
||||
swatch: t('Change the colour of {name}', { name: title }),
|
||||
|
||||
@@ -3,10 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
drawMarkGeometry,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
|
||||
@@ -81,219 +78,3 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
|
||||
test('every colour has its swatch', () => {
|
||||
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
|
||||
});
|
||||
|
||||
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
}).coordinates[0].at(-1),
|
||||
[0, 0],
|
||||
'an open ring is closed',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
{ lon: 1, lat: 2, height: 9 },
|
||||
{ lon: 3, lat: 4, height: 0 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
|
||||
null,
|
||||
);
|
||||
const square = (x) => [
|
||||
[x, 0],
|
||||
[x + 1, 0],
|
||||
[x + 1, 1],
|
||||
[x, 0],
|
||||
];
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: square(0),
|
||||
polygons: [[square(0)], [square(5)]],
|
||||
}),
|
||||
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
|
||||
'an area handle with several parts keeps them all',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
polygons: [
|
||||
[
|
||||
square(0),
|
||||
[
|
||||
[0.2, 0.2],
|
||||
[0.3, 0.2],
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
'a hole that cannot be given whole is never dropped silently',
|
||||
);
|
||||
});
|
||||
|
||||
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[370, 1],
|
||||
[371, 1],
|
||||
],
|
||||
}).coordinates,
|
||||
[
|
||||
[10, 1],
|
||||
[11, 1],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[180, 0],
|
||||
[179, 0],
|
||||
[179, 1],
|
||||
],
|
||||
}).coordinates[0][0],
|
||||
[180, 0],
|
||||
'the antimeridian itself stays put',
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'area',
|
||||
ring: [
|
||||
[179.9, 0],
|
||||
[-179.9, 0],
|
||||
[-179.9, 1],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[179.5, 0],
|
||||
[180.5, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
drawMarkGeometry({
|
||||
type: 'route',
|
||||
path: [
|
||||
[0, 91],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
|
||||
const run = () => {};
|
||||
const label = () => 'x';
|
||||
const actions = drawMarkActions([
|
||||
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
|
||||
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area', 'line', 'pin'],
|
||||
},
|
||||
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
|
||||
null,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
|
||||
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
|
||||
);
|
||||
assert.deepEqual(drawMarkActions('nope'), []);
|
||||
const area = {
|
||||
type: 'area',
|
||||
ring: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
],
|
||||
};
|
||||
const offer = drawMarkActionsFor(area, actions);
|
||||
assert.equal(offer.shape, 'area');
|
||||
assert.deepEqual(
|
||||
offer.actions.map((a) => a.id),
|
||||
['tag'],
|
||||
);
|
||||
const refusing = drawMarkActions([
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'a',
|
||||
label,
|
||||
run,
|
||||
shapes: ['area'],
|
||||
accepts: () => {
|
||||
throw new Error('no');
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
drawMarkActionsFor(area, refusing).actions,
|
||||
[],
|
||||
'an accepts that throws offers nothing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
|
||||
.actions,
|
||||
[],
|
||||
);
|
||||
});
|
||||
+6
-155
@@ -50,29 +50,9 @@ import {
|
||||
removeLastVertex,
|
||||
} from './drawMode.js';
|
||||
|
||||
import {
|
||||
DRAW_COLORS,
|
||||
DRAW_COLOR_CSS,
|
||||
drawMarkActions,
|
||||
drawMarkGeometry,
|
||||
drawMarkActionsFor,
|
||||
drawMarkFallbackName,
|
||||
presentDrawMarks,
|
||||
} from './drawMarks.js';
|
||||
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
|
||||
|
||||
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
|
||||
/** The longest note a row action leaves under its row, in characters. */
|
||||
const NOTE_MAX = 200;
|
||||
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
|
||||
function noteText(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= NOTE_MAX) return trimmed;
|
||||
const cut = trimmed.slice(0, NOTE_MAX - 1);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
const DRAW_POINTER_OWNER = 'draw';
|
||||
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
|
||||
@@ -87,10 +67,7 @@ const PREVIEW = DRAW_COLOR_CSS;
|
||||
* through the actions it gives `connect`, which returns their disconnect.
|
||||
* A row's fly-to is an immediate navigation through `navigation`, like
|
||||
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
|
||||
* `markActions` are the application's own buttons on an area's or a line's
|
||||
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
|
||||
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
|
||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
|
||||
* @returns {{destroy: Function}|null}
|
||||
*/
|
||||
export function initDrawTool({
|
||||
@@ -99,12 +76,7 @@ export function initDrawTool({
|
||||
navigation = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
markActions = [],
|
||||
}) {
|
||||
const rowActions = drawMarkActions(markActions);
|
||||
// What a row action said last on each drawn shape: `{ run, busy, text }`.
|
||||
const notes = new Map();
|
||||
let noteRun = 0;
|
||||
// A page without the Draw controls (the Display controls view omitted) has
|
||||
// no draw tool.
|
||||
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
|
||||
@@ -121,9 +93,6 @@ export function initDrawTool({
|
||||
let savedDoubleClick = null;
|
||||
let savedSingleClick = null;
|
||||
let cursor = null; // last mouse position on the canvas, for the rubber band
|
||||
// One shape asked for by `drawShape`: `{ resolve, restoreShape, off }`. Its
|
||||
// finish hands the geometry back instead of placing a mark.
|
||||
let request = null;
|
||||
// Bumped by anything that supersedes an in-flight finish: cancelling, clearing
|
||||
// the board, leaving draw mode, teardown. An `annotate()` that resolves after
|
||||
// one of those must not write its outcome over the newer state.
|
||||
@@ -146,19 +115,14 @@ export function initDrawTool({
|
||||
() => null,
|
||||
);
|
||||
|
||||
const present = () => {
|
||||
const marks = annotations.list?.() || [];
|
||||
// A note goes with its shape.
|
||||
for (const id of notes.keys())
|
||||
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
|
||||
const present = () =>
|
||||
publish({
|
||||
active,
|
||||
shape,
|
||||
color,
|
||||
hint,
|
||||
marks: presentDrawMarks(marks, rowActions, notes),
|
||||
marks: presentDrawMarks(annotations.list?.() || []),
|
||||
});
|
||||
};
|
||||
// The hint is kept as the call that words it, so a language switch can
|
||||
// word it again.
|
||||
let hintText = () => '';
|
||||
@@ -284,20 +248,6 @@ export function initDrawTool({
|
||||
// Named afterwards, in the shape's row.
|
||||
const spec = finishSpec(session, { label: '', color });
|
||||
if (!spec) return null;
|
||||
if (request) {
|
||||
// As a row's actions get it: a shape across the antimeridian has none,
|
||||
// and is drawn again rather than handed back wrapped round the world.
|
||||
const geometry = drawMarkGeometry(spec);
|
||||
if (!geometry) {
|
||||
session = createDrawSession(shape);
|
||||
cursor = null;
|
||||
syncPreview();
|
||||
setHint(() => t('That shape could not be placed.'));
|
||||
return null;
|
||||
}
|
||||
endRequest(geometry);
|
||||
return geometry;
|
||||
}
|
||||
// Start a fresh session BEFORE awaiting, so a second Enter or the second
|
||||
// half of a double-click cannot submit the same shape twice.
|
||||
session = createDrawSession(shape);
|
||||
@@ -425,7 +375,6 @@ export function initDrawTool({
|
||||
generation += 1;
|
||||
session = null;
|
||||
cursor = null;
|
||||
endRequest(null);
|
||||
releaseSceneHandler();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
@@ -501,96 +450,6 @@ export function initDrawTool({
|
||||
return annotations.frame?.(id) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application's row action `actionId` on drawn shape `id`, if the
|
||||
* shape is still on the board and still offered it. Returns whether it ran.
|
||||
* What the action returns, or resolves to, is the row's note (a measurement,
|
||||
* "saved"): the row is busy until it settles, a newer press or the shape's
|
||||
* removal makes a late answer moot, and a failure clears the note, so an
|
||||
* action words its own refusals and resolves with them.
|
||||
*/
|
||||
function runMarkAction(id, actionId) {
|
||||
const mark = (annotations.list?.() || []).find(
|
||||
(entry) => entry.id === id && entry.manual === true,
|
||||
);
|
||||
const offer = drawMarkActionsFor(mark, rowActions);
|
||||
const action = offer.actions.find((entry) => entry.id === actionId);
|
||||
if (!action || notes.get(mark.id)?.busy) return false;
|
||||
const run = ++noteRun;
|
||||
const settle = (text) => {
|
||||
if (destroyed || notes.get(mark.id)?.run !== run) return;
|
||||
notes.set(mark.id, {
|
||||
run,
|
||||
busy: false,
|
||||
text: noteText(text),
|
||||
});
|
||||
present();
|
||||
};
|
||||
const fail = (error) => {
|
||||
console.warn(`[draw] ${actionId} failed`, error);
|
||||
settle('');
|
||||
};
|
||||
notes.set(mark.id, { run, busy: true, text: '' });
|
||||
let answer;
|
||||
try {
|
||||
answer = action.run({
|
||||
id: mark.id,
|
||||
shape: offer.shape,
|
||||
name: mark.name || '',
|
||||
title: mark.name || drawMarkFallbackName(mark),
|
||||
geometry: offer.geometry,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
return false;
|
||||
}
|
||||
if (typeof answer?.then === 'function') {
|
||||
present();
|
||||
Promise.resolve(answer).then(settle, fail);
|
||||
} else settle(answer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the asked-for shape with `geometry` (null when there is none): the
|
||||
* shape chosen before it comes back, and draw mode ends.
|
||||
*/
|
||||
function endRequest(geometry) {
|
||||
const open = request;
|
||||
if (!open) return;
|
||||
request = null;
|
||||
open.off();
|
||||
chooseShape(open.restoreShape);
|
||||
open.resolve(geometry);
|
||||
if (active) setActive(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one area or line and hand back its geometry (a Polygon or a
|
||||
* LineString) without placing a mark. Escape works as in Draw: the first
|
||||
* clears a shape in progress, and one on an empty shape leaves. Resolves null
|
||||
* when draw mode is left, `signal` aborts, or the pointer is taken.
|
||||
* A second request ends the first with null.
|
||||
* @param {{shape?: 'area'|'line', signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{type: 'Polygon', coordinates: number[][][]}|{type: 'LineString', coordinates: number[][]}|null>}
|
||||
*/
|
||||
function drawShape({ shape: wanted = 'area', signal } = {}) {
|
||||
if (destroyed || signal?.aborted) return Promise.resolve(null);
|
||||
endRequest(null);
|
||||
const restoreShape = shape;
|
||||
return new Promise((resolve) => {
|
||||
const abort = () => endRequest(null);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
request = {
|
||||
resolve,
|
||||
restoreShape,
|
||||
off: () => signal?.removeEventListener('abort', abort),
|
||||
};
|
||||
chooseShape(normalizeShape(wanted) === 'line' ? 'line' : 'area');
|
||||
if (!active && !setActive(true)) endRequest(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Draw `next` from now on; an open shape starts over in it. */
|
||||
function chooseShape(next) {
|
||||
shape = normalizeShape(next);
|
||||
@@ -604,10 +463,7 @@ export function initDrawTool({
|
||||
}
|
||||
const disconnect = connect({
|
||||
toggleDraw: () => setActive(!active),
|
||||
// An asked-for shape is the one asked for.
|
||||
setDrawShape: (next) => {
|
||||
if (!request) chooseShape(next);
|
||||
},
|
||||
setDrawShape: chooseShape,
|
||||
setDrawColor: (value) => {
|
||||
color = DRAW_COLORS.includes(value) ? value : 'primary';
|
||||
present();
|
||||
@@ -622,7 +478,6 @@ export function initDrawTool({
|
||||
},
|
||||
flyToDrawMark: flyToMark,
|
||||
removeDrawMark: (id) => annotations.remove?.(id),
|
||||
runDrawMarkAction: runMarkAction,
|
||||
});
|
||||
// The rows follow the board: a shape drawn, renamed, hidden or removed
|
||||
// here, by voice or by a clear.
|
||||
@@ -645,15 +500,13 @@ export function initDrawTool({
|
||||
setActive,
|
||||
setShape: chooseShape,
|
||||
/** The hand-drawn marks the list shows, as its rows. */
|
||||
marks: () =>
|
||||
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
|
||||
marks: () => presentDrawMarks(annotations.list?.() || []),
|
||||
setColor(next) {
|
||||
color = DRAW_COLORS.includes(next) ? next : 'primary';
|
||||
present();
|
||||
syncPreview();
|
||||
},
|
||||
flyToMark,
|
||||
runMarkAction,
|
||||
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
|
||||
addVertex(lon, lat, height = 0) {
|
||||
if (!session) return false;
|
||||
@@ -664,7 +517,6 @@ export function initDrawTool({
|
||||
finish,
|
||||
cancel,
|
||||
clearAll,
|
||||
drawShape,
|
||||
shapes: DRAW_SHAPES,
|
||||
/** What this tool currently holds — for teardown and leak assertions. */
|
||||
diagnostics() {
|
||||
@@ -701,7 +553,6 @@ export function initDrawTool({
|
||||
if (destroyed) return attaching;
|
||||
// Supersede any finish still in flight before anything is torn down.
|
||||
generation += 1;
|
||||
endRequest(null);
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
offLocale();
|
||||
|
||||
@@ -223,7 +223,7 @@ function fakeAnnotations() {
|
||||
}
|
||||
|
||||
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
|
||||
function harness({ navigation = null, markActions = [] } = {}) {
|
||||
function harness({ navigation = null } = {}) {
|
||||
resetPointerOwnership();
|
||||
const { byId, documentDouble } = fakeDom();
|
||||
const viewer = fakeViewer();
|
||||
@@ -239,7 +239,6 @@ function harness({ navigation = null, markActions = [] } = {}) {
|
||||
viewer,
|
||||
annotations,
|
||||
navigation,
|
||||
markActions,
|
||||
publish: (view) => (shown.view = view),
|
||||
connect: (actions) => {
|
||||
controls.actions = actions;
|
||||
@@ -577,285 +576,6 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
|
||||
}
|
||||
});
|
||||
|
||||
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
|
||||
const ran = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'tag',
|
||||
icon: 'sell',
|
||||
label: (name) => `Tag ${name}`,
|
||||
shapes: ['area', 'line'],
|
||||
run: (shape) => ran.push(['tag', shape]),
|
||||
},
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
accepts: (geometry) => geometry.type === 'Polygon',
|
||||
run: (shape) => ran.push(['measure', shape]),
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const board = h.annotations;
|
||||
board.marks.push(
|
||||
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
|
||||
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
|
||||
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
|
||||
);
|
||||
board.changed();
|
||||
assert.deepEqual(
|
||||
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
|
||||
[
|
||||
['r1', [
|
||||
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
|
||||
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
|
||||
]],
|
||||
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
|
||||
['p1', []],
|
||||
],
|
||||
);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
|
||||
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
|
||||
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual(ran, [
|
||||
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
|
||||
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
|
||||
]);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an action that throws is reported and leaves the row alone', async () => {
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
const warned = [];
|
||||
console.warn = (...args) => warned.push(args[0]);
|
||||
try {
|
||||
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
h.annotations.changed();
|
||||
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
|
||||
assert.deepEqual(warned, ['[draw] boom failed']);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
|
||||
const pending = [];
|
||||
const h = harness({
|
||||
markActions: [
|
||||
{
|
||||
id: 'measure',
|
||||
icon: 'straighten',
|
||||
label: (name) => `Measure ${name}`,
|
||||
shapes: ['area'],
|
||||
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
|
||||
},
|
||||
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
|
||||
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
|
||||
],
|
||||
});
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const board = h.annotations;
|
||||
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
|
||||
board.marks.push(area('r1'), area('r2'));
|
||||
board.changed();
|
||||
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
|
||||
h.act('runDrawMarkAction', 'r1', 'long');
|
||||
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
|
||||
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
|
||||
pending[0].resolve('412 km², 38 km of edge');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
|
||||
h.act('runDrawMarkAction', 'r1', 'measure');
|
||||
pending[1].reject(new Error('refused'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
|
||||
h.act('runDrawMarkAction', 'r2', 'measure');
|
||||
board.marks.splice(1, 1);
|
||||
board.changed();
|
||||
pending[2].resolve('late');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(row('r2'), undefined);
|
||||
board.marks.push(area('r2'));
|
||||
board.changed();
|
||||
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('drawShape hands back the drawn area as a polygon and places no mark', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.act('setDrawShape', 'line');
|
||||
const drawn = h.tool.drawShape({ shape: 'area' });
|
||||
assert.equal(h.tool.active, true, 'it opens draw mode');
|
||||
assert.equal(h.tool.shape, 'area');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.27);
|
||||
h.documentDouble.emit('keydown', {
|
||||
key: 'Enter',
|
||||
target: h.documentDouble.body,
|
||||
});
|
||||
const geometry = await drawn;
|
||||
assert.deepEqual(geometry, {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.75, 30.26],
|
||||
[-97.74, 30.26],
|
||||
[-97.74, 30.27],
|
||||
[-97.75, 30.26],
|
||||
],
|
||||
],
|
||||
});
|
||||
assert.equal(h.annotations.calls.annotate.length, 0, 'no mark is placed');
|
||||
assert.equal(h.tool.active, false, 'draw mode ends');
|
||||
assert.equal(isPointerFree(), true);
|
||||
assert.equal(h.tool.shape, 'line', 'the shape chosen before comes back');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('drawShape hands back a line as a LineString', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const drawn = h.tool.drawShape({ shape: 'line' });
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.documentDouble.emit('keydown', {
|
||||
key: 'Enter',
|
||||
target: h.documentDouble.body,
|
||||
});
|
||||
assert.deepEqual(await drawn, {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.75, 30.26],
|
||||
[-97.74, 30.26],
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('drawShape resolves null on Escape, on abort, on a newer request and when the pointer is taken', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
// Escape with a shape started clears it; a second Escape leaves.
|
||||
const escaped = h.tool.drawShape({ shape: 'area' });
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.documentDouble.emit('keydown', {
|
||||
key: 'Escape',
|
||||
target: h.documentDouble.body,
|
||||
});
|
||||
assert.equal(h.tool.active, true, 'the first Escape only clears the shape');
|
||||
h.documentDouble.emit('keydown', {
|
||||
key: 'Escape',
|
||||
target: h.documentDouble.body,
|
||||
});
|
||||
assert.equal(await escaped, null);
|
||||
assert.equal(h.tool.active, false);
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted = h.tool.drawShape({
|
||||
shape: 'area',
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
assert.equal(await aborted, null);
|
||||
assert.equal(isPointerFree(), true, 'an abort gives the pointer back');
|
||||
|
||||
const first = h.tool.drawShape({ shape: 'area' });
|
||||
const second = h.tool.drawShape({ shape: 'line' });
|
||||
assert.equal(await first, null, 'a newer request ends the older one');
|
||||
h.act('toggleDraw');
|
||||
assert.equal(await second, null, 'leaving draw mode ends it');
|
||||
|
||||
const done = new AbortController();
|
||||
done.abort();
|
||||
assert.equal(await h.tool.drawShape({ signal: done.signal }), null);
|
||||
|
||||
const other = claimPointer('directions');
|
||||
assert.equal(
|
||||
await h.tool.drawShape({ shape: 'area' }),
|
||||
null,
|
||||
'a held pointer refuses it',
|
||||
);
|
||||
releasePointer(other);
|
||||
|
||||
// Drawing by hand afterwards places marks as before.
|
||||
h.act('toggleDraw');
|
||||
h.tool.addVertex(-97.75, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.26);
|
||||
h.tool.addVertex(-97.74, 30.27);
|
||||
h.documentDouble.emit('keydown', {
|
||||
key: 'Enter',
|
||||
target: h.documentDouble.body,
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(h.annotations.calls.annotate.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('drawShape keeps its shape, and a shape across the antimeridian is drawn again', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const drawn = h.tool.drawShape({ shape: 'line' });
|
||||
h.act('setDrawShape', 'pin');
|
||||
assert.equal(h.tool.shape, 'line', 'the controls cannot change the asked-for shape');
|
||||
h.tool.addVertex(179.5, 10);
|
||||
h.tool.addVertex(-179.5, 10);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
||||
assert.equal(h.tool.active, true, 'still drawing');
|
||||
assert.equal(h.tool.session.vertices.length, 0, 'drawn again from the start');
|
||||
assert.equal(h.shown.view.hint, 'That shape could not be placed.');
|
||||
h.tool.addVertex(29, 41);
|
||||
h.tool.addVertex(29.1, 41);
|
||||
h.documentDouble.emit('keydown', { key: 'Enter', target: h.documentDouble.body });
|
||||
assert.equal((await drawn).type, 'LineString');
|
||||
h.act('setDrawShape', 'pin');
|
||||
assert.equal(h.tool.shape, 'pin', 'after it, the controls choose again');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('destroy ends a drawShape request with null', async () => {
|
||||
const h = harness();
|
||||
try {
|
||||
const drawn = h.tool.drawShape({ shape: 'area' });
|
||||
h.tool.destroy();
|
||||
assert.equal(await drawn, null);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Backspace on the page removes the last vertex', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -79,7 +78,7 @@ export function createApplicationCatalog({
|
||||
const sourceComposition = resolveCatalogSources(sources);
|
||||
sources = sourceComposition.sources;
|
||||
const militaryRegistry = createMilitaryRegistry();
|
||||
const weatherClock = createWeatherClock({ world: worldClock });
|
||||
const weatherClock = createWeatherClock();
|
||||
const dispose = () => {
|
||||
signal.removeEventListener('abort', dispose);
|
||||
militaryRegistry.dispose();
|
||||
@@ -183,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
return createGnssIntegrityLayer({
|
||||
worldNow: worldTimeFor('gnss-integrity'),
|
||||
...options,
|
||||
});
|
||||
return createGnssIntegrityLayer(options);
|
||||
}
|
||||
@@ -2,9 +2,6 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
const imageryTime = worldTimeFor('recent-imagery');
|
||||
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
@@ -18,6 +15,5 @@ export function createApplicationRecentImagery({ source }) {
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
now: () => new Date(imageryTime()),
|
||||
});
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import { describeError } from './errors.js';
|
||||
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||
import { followWorldTime } from '../time/viewerClock.js';
|
||||
import { onLocaleChange, t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the application globe using the caller's local configuration. */
|
||||
@@ -61,12 +59,6 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(
|
||||
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||
subscribe: worldClock.subscribe,
|
||||
requestRender: () => governorRequestRender('world-time'),
|
||||
}),
|
||||
);
|
||||
defer(installFreeLookHeightLimit(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
|
||||
+1
-18
@@ -1,7 +1,6 @@
|
||||
import { SceneDirector } from '../scenes/director.js';
|
||||
import { initAnnotations } from '../annotations/index.js';
|
||||
import { initDrawTool } from '../annotations/drawTool.js';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||
import { useDisplayStore } from '../ui/stores/display.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
@@ -36,7 +35,6 @@ export function createApplicationTools({
|
||||
startChrome,
|
||||
onSceneDirector,
|
||||
sceneDataPacks,
|
||||
drawMarkActions = [],
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -82,15 +80,12 @@ export function createApplicationTools({
|
||||
// while a session is open, so its teardown belongs to the application
|
||||
// lifetime rather than to whoever last pressed the button. A row's fly-to
|
||||
// goes through the shell's navigation policy, as Import's does.
|
||||
// `drawMarkActions` are an application's own buttons on a drawn area's or
|
||||
// line's row (annotations/drawMarks.js); the standalone shell adds none.
|
||||
const drawTool = initDrawTool({
|
||||
viewer,
|
||||
annotations,
|
||||
navigation: styleManager,
|
||||
publish: (view) => displayStore.publishDraw(view),
|
||||
connect: connectDisplay,
|
||||
markActions: drawMarkActions,
|
||||
});
|
||||
defer(() => drawTool?.destroy());
|
||||
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||
@@ -246,17 +241,5 @@ export function createApplicationTools({
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
// Draw one area or line and get its geometry back, without placing a mark
|
||||
// (annotations/drawTool.js `drawShape`); null without the Draw controls.
|
||||
const drawShape = (options) =>
|
||||
drawTool ? drawTool.drawShape(options) : Promise.resolve(null);
|
||||
// An application's own click handlers yield while a tool holds the pointer,
|
||||
// as the layers' do (data/inputOwnership.js).
|
||||
return {
|
||||
sceneDirector,
|
||||
annotations,
|
||||
voiceCommands,
|
||||
drawShape,
|
||||
pointerFree: isPointerFree,
|
||||
};
|
||||
return { sceneDirector, annotations, voiceCommands };
|
||||
}
|
||||
@@ -7,48 +7,6 @@ import {
|
||||
isFreeGlobeCamera,
|
||||
} from '../globeInView.js';
|
||||
|
||||
/**
|
||||
* At the farthest a hand can zoom out, the globe is this share of the
|
||||
* screen's height across. Cesium sets no limit, so zooming on left the globe
|
||||
* a speck among the stars.
|
||||
*/
|
||||
const MIN_GLOBE_SHARE = 0.3;
|
||||
|
||||
/**
|
||||
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
|
||||
* vertical field of view. It follows the screen's shape, as the app's own
|
||||
* whole-globe view does, so that view always lies within it.
|
||||
* @param {number} fovy Vertical field of view, radians.
|
||||
*/
|
||||
export function zoomOutLimit(fovy) {
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
|
||||
return radius / Math.sin(angular) - radius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
|
||||
* field of view follows the screen's shape. The limit never sits below the
|
||||
* camera: one already past it (a flight, a screen turned) stops there rather
|
||||
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
|
||||
* @returns {() => void} Stop limiting.
|
||||
*/
|
||||
export function limitZoomOut(viewer) {
|
||||
const { scene, camera } = viewer;
|
||||
return scene.preRender.addEventListener(() => {
|
||||
const controller = scene.screenSpaceCameraController;
|
||||
const fovy = camera.frustum.fovy;
|
||||
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
|
||||
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
|
||||
return;
|
||||
}
|
||||
controller.maximumZoomDistance = Math.max(
|
||||
zoomOutLimit(fovy),
|
||||
camera.positionCartographic?.height ?? 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
|
||||
@@ -414,7 +372,6 @@ export function createApplicationViewer({ container, creditContainer }) {
|
||||
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
||||
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
|
||||
limitZoomOut(viewer);
|
||||
// Before any tile builds a draw command: Cesium's per-vertex model
|
||||
// atmosphere fails to LINK on Apple's Metal backend and kills the
|
||||
// render loop. See app/atmosphereCompat.js.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// A hand zooming out stops while the globe is still a fair share of the
|
||||
// screen, on any screen's shape, rather than leaving it a speck.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { limitZoomOut, zoomOutLimit } from './viewer.js';
|
||||
|
||||
// The share of the screen's height the globe spans from `altitude`.
|
||||
function globeShare(altitude, fovy) {
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const angular = Math.asin(radius / (radius + altitude));
|
||||
return Math.tan(angular) / Math.tan(fovy / 2);
|
||||
}
|
||||
|
||||
test("zooming out stops with the globe 30% of the screen's height across", () => {
|
||||
for (const fovy of [Math.PI / 3, 0.4, 0.2]) {
|
||||
const limit = zoomOutLimit(fovy);
|
||||
assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a wide screen, whose view is narrower, may zoom out farther', () => {
|
||||
assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3));
|
||||
assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens');
|
||||
});
|
||||
|
||||
function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) {
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
preRender: new Cesium.Event(),
|
||||
screenSpaceCameraController: { maximumZoomDistance: Infinity },
|
||||
};
|
||||
const camera = {
|
||||
frustum: { fovy },
|
||||
positionCartographic: { height },
|
||||
};
|
||||
return { scene, camera };
|
||||
}
|
||||
|
||||
test('the limit follows the screen, and holds a camera already past it', () => {
|
||||
const viewer = fakeViewer();
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
const stop = limitZoomOut(viewer);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3));
|
||||
|
||||
// A wider screen narrows the view, and the globe may go farther.
|
||||
viewer.camera.frustum.fovy = 0.4;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4));
|
||||
|
||||
// Flown past it, the next zoom out stops there instead of jumping in.
|
||||
viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
|
||||
|
||||
stop();
|
||||
viewer.camera.positionCartographic.height = 0;
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
|
||||
});
|
||||
|
||||
test('flat maps keep zooming out unlimited', () => {
|
||||
const viewer = fakeViewer();
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
limitZoomOut(viewer);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.ok(Number.isFinite(controller.maximumZoomDistance));
|
||||
viewer.scene.mode = Cesium.SceneMode.SCENE2D;
|
||||
viewer.camera.frustum = {};
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(controller.maximumZoomDistance, Infinity);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ function ordered(source, needles, label) {
|
||||
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
||||
const enter = cockpitEnter.toString();
|
||||
ordered(enter, [
|
||||
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
@@ -106,8 +106,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
);
|
||||
ordered(transaction, [
|
||||
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
|
||||
// Aircraft-only: a selected vessel must not stand in for a voice target.
|
||||
"if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));",
|
||||
'if (!entryError) entered = Boolean(cockpitView.enter());',
|
||||
], 'Cockpit entry transaction');
|
||||
// No direct camera control: every mutation goes through a layer tracker or
|
||||
// the cockpit controller, both of which stamp.
|
||||
|
||||
+1
-25
@@ -1,9 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { worldClock, worldTimeFor } from './time/worldClock.js';
|
||||
|
||||
// The ring shows the sun and moon at the time the sky is drawn.
|
||||
const skyTime = worldTimeFor('sky');
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -32,9 +28,6 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
|
||||
*/
|
||||
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
|
||||
|
||||
// How far the world time moves before the sun and moon move with it: a
|
||||
// minute moves the sun a quarter of a degree.
|
||||
const EPHEMERIS_WORLD_STEP_MS = 60_000;
|
||||
const RING_INSET_PX = 11;
|
||||
const MARKER_INSET_PX = 36;
|
||||
const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
|
||||
@@ -452,20 +445,6 @@ export class CelestialRing {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
governorRequestRender('celestial-ephemeris');
|
||||
}, 60_000);
|
||||
// A jump in the world time moves the sun and moon at once.
|
||||
this._skyDate = new Date();
|
||||
this._skyTime = new Cesium.JulianDate();
|
||||
// Played through, the sky moves on each minute of world time it passes.
|
||||
this._ephemerisWorldMs = null;
|
||||
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
|
||||
if (
|
||||
this._ephemerisWorldMs !== null &&
|
||||
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
|
||||
)
|
||||
return;
|
||||
this._ephemerisWorldMs = timeMs;
|
||||
this._ephemerisDirty = true;
|
||||
});
|
||||
this.setEnabled(this.enabled);
|
||||
}
|
||||
|
||||
@@ -822,8 +801,7 @@ export class CelestialRing {
|
||||
return;
|
||||
}
|
||||
|
||||
this._skyDate.setTime(skyTime());
|
||||
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
|
||||
const time = Cesium.JulianDate.now();
|
||||
if (!this._updateEphemeris(time)) return;
|
||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||
const camera = this.viewer.camera;
|
||||
@@ -912,8 +890,6 @@ export class CelestialRing {
|
||||
clearInterval(this._ephemerisTimer);
|
||||
this._ephemerisTimer = null;
|
||||
}
|
||||
this._unsubscribeWorldTime?.();
|
||||
this._unsubscribeWorldTime = null;
|
||||
if (this._removePostRender) this._removePostRender();
|
||||
this._removePostRender = null;
|
||||
if (this._shown()) {
|
||||
|
||||
@@ -170,18 +170,6 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
|
||||
return value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit:
|
||||
* the pass fades out above 24 km (weatherAltitudeFactors), so a satellite
|
||||
* would pay for it and see nothing. The saved preference is left as it is
|
||||
* for the next aircraft or bridge; cloud cover from orbit is the Weather
|
||||
* layer's.
|
||||
* @param {string|null|undefined} layerId
|
||||
*/
|
||||
export function cockpitCloudsShowFor(layerId) {
|
||||
return layerId !== 'satellites';
|
||||
}
|
||||
|
||||
/**
|
||||
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
|
||||
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
|
||||
@@ -219,12 +207,10 @@ class CockpitCloudEffectsController {
|
||||
this.enabled = this.readEnabledPreference();
|
||||
|
||||
this.onResize = () => this.resize();
|
||||
this.orbit = false;
|
||||
this.onCockpitMode = (event) => {
|
||||
const active =
|
||||
event?.detail?.active ??
|
||||
document.body.classList.contains('cockpit-mode');
|
||||
this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId);
|
||||
if (active && this.enabled) this.start();
|
||||
else this.stop();
|
||||
};
|
||||
@@ -358,8 +344,7 @@ class CockpitCloudEffectsController {
|
||||
this.destroyed ||
|
||||
!this.enabled ||
|
||||
this.frame !== null ||
|
||||
this.suspended ||
|
||||
this.orbit
|
||||
this.suspended
|
||||
)
|
||||
return;
|
||||
if (!this.gl) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
cockpitCloudRenderSize,
|
||||
cockpitCloudsShowFor,
|
||||
cockpitWeatherRefreshDue,
|
||||
cockpitWeatherEnabledFromStoredValue,
|
||||
} from './cockpitCloudEffects.js';
|
||||
@@ -24,13 +23,6 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i
|
||||
assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true);
|
||||
});
|
||||
|
||||
test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => {
|
||||
assert.equal(cockpitCloudsShowFor('satellites'), false);
|
||||
for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) {
|
||||
assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId));
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit weather refreshes after time or meaningful movement', () => {
|
||||
const anchor = { latitude: 30, longitude: -97 };
|
||||
assert.equal(cockpitWeatherRefreshDue({
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Bridge look-around policy: how the arrow keys turn the bridge camera away
|
||||
* from the bow and back, and how a turned view is described. Yaw is relative
|
||||
* to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no
|
||||
* Cesium and no translated text; the Cockpit words the readout.
|
||||
*/
|
||||
|
||||
/** One arrow press turns the view this far. */
|
||||
export const LOOK_STEP_DEG = 5;
|
||||
|
||||
/** One Shift+arrow press turns the view this far: two reach either beam. */
|
||||
export const LOOK_COARSE_STEP_DEG = 45;
|
||||
|
||||
/** The view pitches no further than straight up or straight down. */
|
||||
export const LOOK_PITCH_LIMIT_DEG = 90;
|
||||
|
||||
/**
|
||||
* The eased view's time constant. Three of them (about 180 ms) close 95% of
|
||||
* a step, so a press glides rather than snaps and a held key turns smoothly.
|
||||
*/
|
||||
export const LOOK_EASE_TAU_MS = 60;
|
||||
|
||||
/** Closer than this the eased view lands on its target. */
|
||||
const LOOK_SETTLE_DEG = 0.05;
|
||||
|
||||
/** Arrow key → the axis it turns and the direction it turns it. */
|
||||
const LOOK_ARROWS = Object.freeze({
|
||||
ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }),
|
||||
ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }),
|
||||
ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }),
|
||||
ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap a yaw to (-180, 180]: negative to port, positive to starboard.
|
||||
* @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function wrapLookYaw(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const wrapped = (((deg % 360) + 360) % 360) - 360;
|
||||
const yaw = wrapped <= -180 ? wrapped + 360 : wrapped;
|
||||
return yaw === 0 ? 0 : yaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a view pitch to [-90, 90].
|
||||
* @param {number} deg Pitch in degrees; a non-finite pitch reads as level.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampLookPitch(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const pitch = Math.min(
|
||||
LOOK_PITCH_LIMIT_DEG,
|
||||
Math.max(-LOOK_PITCH_LIMIT_DEG, deg),
|
||||
);
|
||||
return pitch === 0 ? 0 : pitch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step an arrow key asks for, or null for any other key.
|
||||
* @param {string} key `KeyboardEvent.key`.
|
||||
* @param {boolean} [coarse] Shift is held: step 45° instead of 5°.
|
||||
* @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null}
|
||||
*/
|
||||
export function lookKeyStep(key, coarse = false) {
|
||||
const arrow = LOOK_ARROWS[key];
|
||||
if (!arrow) return null;
|
||||
return {
|
||||
...arrow,
|
||||
stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one value to the next multiple of `stepDeg` in the direction `sign`.
|
||||
* A value off the grid (the bridge's -2° rest pitch) lands on it first, so
|
||||
* the readout shows round angles.
|
||||
*/
|
||||
function nextOnGrid(value, sign, stepDeg) {
|
||||
const cells = value / stepDeg;
|
||||
const next =
|
||||
sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1;
|
||||
return next * stepDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one key step to a look target.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
|
||||
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
|
||||
* {@link lookKeyStep}.
|
||||
* @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped
|
||||
* to (-180, 180], pitch clamped to [-90, 90].
|
||||
*/
|
||||
export function stepLook(target, step) {
|
||||
const yawDeg = wrapLookYaw(target?.yawDeg ?? 0);
|
||||
const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0);
|
||||
if (!step) return { yawDeg, pitchDeg };
|
||||
if (step.axis === 'yaw')
|
||||
return {
|
||||
yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)),
|
||||
pitchDeg,
|
||||
};
|
||||
return {
|
||||
yawDeg,
|
||||
pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the eased view toward its target. Yaw takes the shorter way
|
||||
* round; with `immediate` (reduced motion) the view jumps to the target.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} current Eased view now.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} target Look target.
|
||||
* @param {number} dtMs Milliseconds since the previous step.
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function easeLook(current, target, dtMs, { immediate = false } = {}) {
|
||||
const goalYaw = wrapLookYaw(target?.yawDeg ?? 0);
|
||||
const goalPitch = clampLookPitch(target?.pitchDeg ?? 0);
|
||||
if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch };
|
||||
const fraction =
|
||||
Number.isFinite(dtMs) && dtMs > 0
|
||||
? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS)
|
||||
: 0;
|
||||
const fromYaw = wrapLookYaw(current.yawDeg);
|
||||
const yawError = wrapLookYaw(goalYaw - fromYaw);
|
||||
const pitchError = goalPitch - clampLookPitch(current.pitchDeg);
|
||||
const yawDeg =
|
||||
Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG
|
||||
? goalYaw
|
||||
: wrapLookYaw(fromYaw + yawError * fraction);
|
||||
const pitchDeg =
|
||||
Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG
|
||||
? goalPitch
|
||||
: clampLookPitch(current.pitchDeg + pitchError * fraction);
|
||||
return { yawDeg, pitchDeg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a look differs from the rest view (ahead at `restPitchDeg`).
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look
|
||||
* @param {number} restPitchDeg The camera profile's pitch.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function lookIsOffset(look, restPitchDeg) {
|
||||
return (
|
||||
wrapLookYaw(look?.yawDeg ?? 0) !== 0 ||
|
||||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
|
||||
clampLookPitch(restPitchDeg)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a look readout, for the Cockpit to word. A part is null when
|
||||
* that axis rests: yaw ahead, or pitch at the rest pitch.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look Look target.
|
||||
* @param {number} restPitchDeg The camera profile's pitch.
|
||||
* @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number,
|
||||
* elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is
|
||||
* the angle off the bow (0–180) and `angleDeg` the angle off level (0–90),
|
||||
* both rounded to whole degrees.
|
||||
*/
|
||||
export function lookReadoutParts(look, restPitchDeg) {
|
||||
const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0));
|
||||
const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg));
|
||||
const pitchOffset =
|
||||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
|
||||
clampLookPitch(restPitchDeg);
|
||||
let side = null;
|
||||
if (Math.abs(yaw) >= 180) side = 'astern';
|
||||
else if (yaw > 0) side = 'starboard';
|
||||
else if (yaw < 0) side = 'port';
|
||||
let elevation = null;
|
||||
if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level';
|
||||
return {
|
||||
side,
|
||||
bearingDeg: Math.abs(yaw),
|
||||
elevation,
|
||||
angleDeg: Math.abs(pitch),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The orbital view's off-nadir limit on each axis. The orbital look reuses
|
||||
* the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts
|
||||
* the view east (positive) or west, pitch tilts it north (positive) or
|
||||
* south. Single 5° steps only; the bridge keeps its own limits.
|
||||
*/
|
||||
export const ORBITAL_LOOK_LIMIT_DEG = 45;
|
||||
|
||||
/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */
|
||||
function clampOrbitalAxis(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const value = Math.min(
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg),
|
||||
);
|
||||
return value === 0 ? 0 : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp an orbital look to the off-nadir limit on both axes.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look East and north tilt.
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function clampOrbitalLook(look) {
|
||||
return {
|
||||
yawDeg: clampOrbitalAxis(look?.yawDeg),
|
||||
pitchDeg: clampOrbitalAxis(look?.pitchDeg),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one arrow step to an orbital look: the bridge's stepping on the
|
||||
* same 5° grid, then the off-nadir clamp.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
|
||||
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
|
||||
* {@link lookKeyStep}.
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function stepOrbitalLook(target, step) {
|
||||
return clampOrbitalLook(stepLook(clampOrbitalLook(target), step));
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of an off-nadir readout, for the Cockpit to word: the north or
|
||||
* south tilt and the east or west tilt, each null at rest, in whole degrees.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look Orbital look target.
|
||||
* @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number,
|
||||
* eastWest: 'E'|'W'|null, eastWestDeg: number}}
|
||||
*/
|
||||
export function orbitalLookReadoutParts(look) {
|
||||
const { yawDeg, pitchDeg } = clampOrbitalLook(look);
|
||||
const north = Math.round(pitchDeg);
|
||||
const east = Math.round(yawDeg);
|
||||
return {
|
||||
northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null,
|
||||
northSouthDeg: Math.abs(north),
|
||||
eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null,
|
||||
eastWestDeg: Math.abs(east),
|
||||
};
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps,
|
||||
// yaw wrapping, pitch limits, easing and the readout's parts.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LOOK_COARSE_STEP_DEG,
|
||||
LOOK_STEP_DEG,
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
clampLookPitch,
|
||||
clampOrbitalLook,
|
||||
easeLook,
|
||||
lookIsOffset,
|
||||
lookKeyStep,
|
||||
lookReadoutParts,
|
||||
orbitalLookReadoutParts,
|
||||
stepLook,
|
||||
stepOrbitalLook,
|
||||
wrapLookYaw,
|
||||
} from './cockpitLook.js';
|
||||
|
||||
const REST = { yawDeg: 0, pitchDeg: -2 };
|
||||
|
||||
/** Press one key `count` times from `start`. */
|
||||
function press(start, key, count = 1, coarse = false) {
|
||||
let look = start;
|
||||
for (let index = 0; index < count; index += 1)
|
||||
look = stepLook(look, lookKeyStep(key, coarse));
|
||||
return look;
|
||||
}
|
||||
|
||||
test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => {
|
||||
assert.equal(wrapLookYaw(0), 0);
|
||||
assert.equal(wrapLookYaw(-0), 0);
|
||||
assert.equal(wrapLookYaw(180), 180);
|
||||
assert.equal(wrapLookYaw(-180), 180);
|
||||
assert.equal(wrapLookYaw(185), -175);
|
||||
assert.equal(wrapLookYaw(-185), 175);
|
||||
assert.equal(wrapLookYaw(720 + 45), 45);
|
||||
assert.equal(wrapLookYaw(NaN), 0);
|
||||
assert.equal(clampLookPitch(120), 90);
|
||||
assert.equal(clampLookPitch(-120), -90);
|
||||
assert.equal(clampLookPitch(-0), 0);
|
||||
assert.equal(clampLookPitch(Infinity), 0);
|
||||
});
|
||||
|
||||
test('only the arrows step, 5° plain and 45° with Shift', () => {
|
||||
assert.deepEqual(lookKeyStep('ArrowLeft'), {
|
||||
axis: 'yaw',
|
||||
sign: -1,
|
||||
stepDeg: LOOK_STEP_DEG,
|
||||
});
|
||||
assert.deepEqual(lookKeyStep('ArrowUp', true), {
|
||||
axis: 'pitch',
|
||||
sign: 1,
|
||||
stepDeg: LOOK_COARSE_STEP_DEG,
|
||||
});
|
||||
assert.equal(lookKeyStep('ArrowDown').sign, -1);
|
||||
assert.equal(lookKeyStep('ArrowRight').sign, 1);
|
||||
for (const key of ['Home', '0', 'a', 'Escape', undefined])
|
||||
assert.equal(lookKeyStep(key), null);
|
||||
});
|
||||
|
||||
test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => {
|
||||
assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 });
|
||||
assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 });
|
||||
assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90);
|
||||
// A coarse step from off the 45° grid lands on it.
|
||||
assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45);
|
||||
// Past astern the yaw wraps to the other side.
|
||||
assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180);
|
||||
assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135);
|
||||
assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175);
|
||||
});
|
||||
|
||||
test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => {
|
||||
assert.equal(press(REST, 'ArrowUp').pitchDeg, 0);
|
||||
assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10);
|
||||
assert.equal(press(REST, 'ArrowDown').pitchDeg, -5);
|
||||
assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90);
|
||||
assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90);
|
||||
assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw');
|
||||
});
|
||||
|
||||
test('the eased view closes most of a step in about 180 ms', () => {
|
||||
const target = { yawDeg: 90, pitchDeg: -2 };
|
||||
let view = { ...REST };
|
||||
view = easeLook(view, target, 180);
|
||||
assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`);
|
||||
for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50);
|
||||
assert.deepEqual(view, target, 'it lands exactly on the target');
|
||||
assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion');
|
||||
});
|
||||
|
||||
test('the eased yaw takes the short way across astern', () => {
|
||||
const view = easeLook(
|
||||
{ yawDeg: 170, pitchDeg: 0 },
|
||||
{ yawDeg: -170, pitchDeg: 0 },
|
||||
30,
|
||||
);
|
||||
assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`);
|
||||
});
|
||||
|
||||
test('reduced motion jumps straight to the target', () => {
|
||||
const target = { yawDeg: -45, pitchDeg: 30 };
|
||||
assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target);
|
||||
});
|
||||
|
||||
test('the readout names the side, the bearing and the elevation', () => {
|
||||
assert.equal(lookIsOffset(REST, -2), false);
|
||||
assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true);
|
||||
assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true);
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), {
|
||||
side: 'starboard',
|
||||
bearingDeg: 45,
|
||||
elevation: 'up',
|
||||
angleDeg: 10,
|
||||
});
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), {
|
||||
side: 'port',
|
||||
bearingDeg: 90,
|
||||
elevation: null,
|
||||
angleDeg: 2,
|
||||
});
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), {
|
||||
side: null,
|
||||
bearingDeg: 0,
|
||||
elevation: 'down',
|
||||
angleDeg: 30,
|
||||
});
|
||||
assert.equal(
|
||||
lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation,
|
||||
'level',
|
||||
);
|
||||
assert.equal(
|
||||
lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side,
|
||||
'astern',
|
||||
);
|
||||
});
|
||||
|
||||
test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => {
|
||||
let look = { yawDeg: 0, pitchDeg: 0 };
|
||||
for (let index = 0; index < 3; index += 1)
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowUp'));
|
||||
assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 });
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowRight'));
|
||||
assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 });
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowDown'));
|
||||
look = stepOrbitalLook(look, lookKeyStep('ArrowLeft'));
|
||||
}
|
||||
assert.deepEqual(look, {
|
||||
yawDeg: -ORBITAL_LOOK_LIMIT_DEG,
|
||||
pitchDeg: -ORBITAL_LOOK_LIMIT_DEG,
|
||||
});
|
||||
assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), {
|
||||
yawDeg: 45,
|
||||
pitchDeg: 0,
|
||||
});
|
||||
assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), {
|
||||
yawDeg: 45,
|
||||
pitchDeg: -45,
|
||||
});
|
||||
});
|
||||
|
||||
test('the orbital readout names north or south and east or west', () => {
|
||||
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), {
|
||||
northSouth: 'N',
|
||||
northSouthDeg: 10,
|
||||
eastWest: 'E',
|
||||
eastWestDeg: 15,
|
||||
});
|
||||
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), {
|
||||
northSouth: null,
|
||||
northSouthDeg: 0,
|
||||
eastWest: 'W',
|
||||
eastWestDeg: 5,
|
||||
});
|
||||
assert.equal(
|
||||
orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth,
|
||||
'S',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bridge look keeps its own limits beside the orbital clamp', () => {
|
||||
const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp'));
|
||||
assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 });
|
||||
assert.equal(
|
||||
stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true))
|
||||
.yawDeg,
|
||||
90,
|
||||
);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
|
||||
assert.doesNotMatch(html, /id="cockpit-exit"/);
|
||||
assert.match(
|
||||
html,
|
||||
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
|
||||
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
|
||||
);
|
||||
const topCenterActions = html.match(
|
||||
/<nav id="top-center-actions"[\s\S]*?<\/nav>/,
|
||||
@@ -163,8 +163,7 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
|
||||
assert.match(
|
||||
cockpitEnter.toString(),
|
||||
// Orbit alone opens optical (src/ui/cockpitOrbitalView.test.mjs).
|
||||
/this\.setVisionMode\(\s*satellite \? 'optical' : this\.getInitialVisionMode\(\)\s*,?\s*\)/,
|
||||
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
|
||||
'Cockpit must enter on the mode matching the active map preset',
|
||||
);
|
||||
assert.match(
|
||||
@@ -233,12 +232,14 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
|
||||
);
|
||||
});
|
||||
|
||||
test('open utilities own the first Escape and Cockpit leaves C alone', () => {
|
||||
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
|
||||
const keydown = cockpitKeyDown.toString();
|
||||
assert.ok(keydown, 'Cockpit keyboard handler is missing');
|
||||
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
|
||||
// C is the app-wide CCTV shortcut; Cockpit never claims it.
|
||||
assert.doesNotMatch(keydown, /=== 'c'/);
|
||||
assert.match(
|
||||
keydown,
|
||||
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the Contact panel never hides itself out from under its own NEXT button', () => {
|
||||
|
||||
@@ -176,46 +176,6 @@ export function compassDivisions(heading) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A heading tape that scrolls continuously: the 30-degree divisions within
|
||||
* `reach` divisions of `heading`, each placed by its offset from the exact
|
||||
* heading in division slots, so the centre caret reads the heading itself
|
||||
* rather than the nearest division. `nearest` marks the division closest to
|
||||
* the caret.
|
||||
* @param {number} heading Degrees true.
|
||||
* @param {number} [reach] Divisions each side of the caret.
|
||||
* @returns {Array<{heading: number, slot: number, nearest: boolean}>}
|
||||
*/
|
||||
export function scrollingCompassDivisions(heading, reach = 4) {
|
||||
if (!Number.isFinite(heading)) return [];
|
||||
const exact = normalizeHeading(heading);
|
||||
const centre = Math.round(exact / 30) * 30;
|
||||
const divisions = [];
|
||||
for (let step = -reach; step <= reach; step += 1) {
|
||||
const division = normalizeHeading(centre + step * 30);
|
||||
divisions.push({
|
||||
heading: division,
|
||||
slot: Number((relativeBearing(division, exact) / 30).toFixed(4)),
|
||||
nearest: step === 0,
|
||||
});
|
||||
}
|
||||
return divisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape:
|
||||
* its offset from the caret (the tape's exact heading) in division slots of
|
||||
* 30 degrees, or null when it lies more than three divisions out.
|
||||
* @param {number} heading Degrees true.
|
||||
* @param {number} tapeHeading The heading under the tape's caret.
|
||||
* @returns {?number}
|
||||
*/
|
||||
export function compassMarkerSlot(heading, tapeHeading) {
|
||||
if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null;
|
||||
const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4));
|
||||
return Math.abs(slot) <= 3 ? slot : null;
|
||||
}
|
||||
|
||||
/** Format a compass division as a cardinal/intercardinal label or degrees. */
|
||||
export function formatCompassDivision(heading) {
|
||||
const normalized = normalizeHeading(heading);
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
compassDivisions,
|
||||
compassMarkerSlot,
|
||||
scrollingCompassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
@@ -153,46 +151,6 @@ test('compass divisions remain ordered across north', () => {
|
||||
assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]);
|
||||
});
|
||||
|
||||
test('compass marks sit in division slots off the caret, or off the tape', () => {
|
||||
// Measured from the exact heading under the caret, not the nearest division.
|
||||
assert.equal(compassMarkerSlot(350, 350), 0);
|
||||
assert.equal(compassMarkerSlot(35, 350), 1.5);
|
||||
assert.equal(compassMarkerSlot(260, 350), -3);
|
||||
assert.equal(compassMarkerSlot(80, 350), 3);
|
||||
assert.equal(compassMarkerSlot(90, 350), null);
|
||||
assert.equal(compassMarkerSlot(180, 350), null);
|
||||
assert.equal(compassMarkerSlot(Number.NaN, 0), null);
|
||||
});
|
||||
|
||||
test('a scrolling tape puts the exact heading under the caret', () => {
|
||||
// At 100° the divisions sit a third of a slot off their snapped places,
|
||||
// so the bow mark (also 100°) lines up with the caret, not 10° beside it.
|
||||
const tape = scrollingCompassDivisions(100);
|
||||
assert.deepEqual(
|
||||
tape.map(({ heading }) => heading),
|
||||
[330, 0, 30, 60, 90, 120, 150, 180, 210],
|
||||
);
|
||||
const nearest = tape.find((division) => division.nearest);
|
||||
assert.equal(nearest.heading, 90);
|
||||
assert.equal(nearest.slot, -0.3333);
|
||||
assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667);
|
||||
assert.equal(compassMarkerSlot(100, 100), 0);
|
||||
assert.equal(compassMarkerSlot(90, 100), nearest.slot);
|
||||
// Across north the slots stay continuous.
|
||||
assert.deepEqual(
|
||||
scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [
|
||||
heading,
|
||||
slot,
|
||||
]),
|
||||
[
|
||||
[330, -0.8333],
|
||||
[0, 0.1667],
|
||||
[30, 1.1667],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(scrollingCompassDivisions(Number.NaN), []);
|
||||
});
|
||||
|
||||
test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
||||
assert.equal(formatCompassDivision(270), 'W');
|
||||
assert.equal(formatCompassDivision(30), '030');
|
||||
|
||||
@@ -1,808 +0,0 @@
|
||||
/**
|
||||
* Camera policy for the orbital (satellite) view: the eye rides the tracked
|
||||
* satellite and looks straight down at the Earth's centre. Satellites publish
|
||||
* no attitude, so the picture is turned to the ground track (track-up; see
|
||||
* nadirOrientation's `headingDeg`), or north-up for a satellite with none
|
||||
* (geostationary). This module holds the zoom steps,
|
||||
* the field of view, the nadir orientation, the ground footprint and the zoom
|
||||
* transition curves. No DOM, Cesium or i18n dependencies: vectors are plain
|
||||
* `{x, y, z}` objects in Earth-fixed (ECEF) metres, so the policy is
|
||||
* unit-testable and the Cockpit camera copies them into its own scratch.
|
||||
*/
|
||||
|
||||
/** Mean Earth radius used for footprint and framing geometry, metres. */
|
||||
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
|
||||
|
||||
/**
|
||||
* The whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
|
||||
* imaging satellites with a known sensor, each up to the step its sensor's
|
||||
* swath allows (`orbitalMaxZoom`).
|
||||
*/
|
||||
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
|
||||
|
||||
/** The tightest step for a satellite without sensor data. */
|
||||
export const ORBITAL_BASE_MAX_ZOOM = 8;
|
||||
|
||||
/** The zoom an orbital view opens at. */
|
||||
export const ORBITAL_DEFAULT_ZOOM = 1;
|
||||
|
||||
/**
|
||||
* Vertical field of view at 1x for low orbits. From the ISS (~420 km) a 60°
|
||||
* frame spans roughly 490 km of ground top to bottom: a regional picture
|
||||
* with the curvature just out of frame, which 8x narrows to ~60 km.
|
||||
*/
|
||||
export const ORBITAL_BASE_FOV_LEO_RAD = (60 * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* How much wider than the Earth's disk a high orbit's 1x frame is. A fixed
|
||||
* 60° frame from GEO (~35,786 km) would show a 17° Earth floating in black;
|
||||
* above about 8,000 km the base frame instead fits the whole disk with this
|
||||
* margin, so 1x is "the whole Earth" and the steps zoom into it.
|
||||
*/
|
||||
export const ORBITAL_DISK_MARGIN = 1.15;
|
||||
|
||||
/** Duration of one zoom step's field-of-view change, ms. */
|
||||
export const ORBITAL_ZOOM_TRANSITION_MS = 400;
|
||||
|
||||
/** How long the reticle shows its lock tick after a step settles, ms. */
|
||||
export const ORBITAL_LOCK_TICK_MS = 220;
|
||||
|
||||
/** Peak focus-pull blur at the middle of a zoom step, CSS pixels. */
|
||||
export const ORBITAL_MAX_BLUR_PX = 6;
|
||||
|
||||
/**
|
||||
* Longest a zoom step holds its focus pull for the new field of view's
|
||||
* imagery, ms. The camera moves at orbital speed, so the tiles may never
|
||||
* all arrive; the hold gives them a start, then the reticle locks anyway.
|
||||
*/
|
||||
export const ORBITAL_SETTLE_MAX_MS = 2500;
|
||||
|
||||
/** The soft blur held while a step waits for its imagery, CSS pixels. */
|
||||
export const ORBITAL_SETTLE_BLUR_PX = 2.5;
|
||||
|
||||
/** Relative slack so a zoom computed in floating point still meets its step. */
|
||||
const ZOOM_EPSILON = 1e-6;
|
||||
|
||||
/**
|
||||
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
|
||||
* A zoom between steps (the SENSOR step) moves to the step above it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @param {number} [maxZoom] The satellite's tightest step.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
|
||||
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
const next = ORBITAL_ZOOM_LADDER.find(
|
||||
(step) => step > current * (1 + ZOOM_EPSILON) && step <= maxZoom,
|
||||
);
|
||||
return next ?? current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next wider ladder step; 1x stays where it is. A zoom between steps
|
||||
* moves to the step below it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function previousOrbitalZoom(zoom) {
|
||||
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
const wider = ORBITAL_ZOOM_LADDER.filter(
|
||||
(step) => step < current * (1 - ZOOM_EPSILON),
|
||||
);
|
||||
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground width the view spans at a zoom: what the SWATH readout shows.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} zoom Magnification.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number|null} Kilometres, or null for an unusable altitude.
|
||||
*/
|
||||
export function viewWidthKm(altitudeM, zoom, aspect) {
|
||||
return footprintWidthKm(
|
||||
altitudeM,
|
||||
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
|
||||
aspect,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A satellite's tightest zoom step at its live altitude: the tightest ladder
|
||||
* step whose view is still at least as wide as its sensor's narrowest
|
||||
* standard swath, so the view never shows finer ground than the sensor
|
||||
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} One of ORBITAL_ZOOM_LADDER.
|
||||
*/
|
||||
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
|
||||
return ORBITAL_BASE_MAX_ZOOM;
|
||||
let max = ORBITAL_BASE_MAX_ZOOM;
|
||||
for (const step of ORBITAL_ZOOM_LADDER) {
|
||||
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
|
||||
const width = viewWidthKm(altitudeM, step, aspect);
|
||||
if (!Number.isFinite(width)) break;
|
||||
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
|
||||
max = step;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom at which the view is exactly as wide as the sensor's swath, for
|
||||
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
|
||||
* than the 1x view, or narrower than the tightest allowed step).
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @param {number} maxZoom The satellite's tightest step.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
const widest = viewWidthKm(altitudeM, 1, aspect);
|
||||
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
|
||||
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
|
||||
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
|
||||
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
|
||||
// The width falls monotonically with zoom: bisect in log space.
|
||||
let low = 0;
|
||||
let high = Math.log2(maxZoom);
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the view's width a sensor swath covers, for the strip drawn
|
||||
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number|null|undefined} viewKm The view's current ground width.
|
||||
* @returns {number|null} Null without both widths.
|
||||
*/
|
||||
export function sensorStripFraction(sensorSwathKm, viewKm) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
|
||||
return Math.min(1, sensorSwathKm / viewKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular radius of the Earth's disk seen from an altitude, radians.
|
||||
* @param {number} altitudeM Height above the mean sphere, metres.
|
||||
* @returns {number} Half-angle from nadir to the limb (π/2 at the surface).
|
||||
*/
|
||||
export function earthAngularRadiusRad(altitudeM) {
|
||||
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
|
||||
return Math.asin(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical field of view at 1x for an altitude, radians.
|
||||
*
|
||||
* Low orbits get the fixed 60° regional frame. Higher orbits, where 60° would
|
||||
* leave the Earth small in a black frame, get the Earth's disk plus
|
||||
* ORBITAL_DISK_MARGIN. The two meet near 8,100 km, so the base never jumps
|
||||
* as an eccentric orbit climbs: GPS (~20,200 km) opens at ~32°, GEO at ~20°.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @returns {number} Vertical field of view, radians.
|
||||
*/
|
||||
export function orbitalBaseFovRad(altitudeM) {
|
||||
const diskFov = 2 * earthAngularRadiusRad(altitudeM) * ORBITAL_DISK_MARGIN;
|
||||
return Math.min(ORBITAL_BASE_FOV_LEO_RAD, diskFov);
|
||||
}
|
||||
|
||||
/**
|
||||
* Field of view at an optical zoom: magnification divides the tangent of
|
||||
* the half-angle, as a lens's focal length does.
|
||||
* @param {number} baseFovRad Field of view at 1x, radians.
|
||||
* @param {number} zoom Magnification (1 leaves the base unchanged).
|
||||
* @returns {number} Field of view, radians.
|
||||
*/
|
||||
export function fovForZoom(baseFovRad, zoom) {
|
||||
const magnification = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
||||
return 2 * Math.atan(Math.tan(baseFovRad / 2) / magnification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cesium's `PerspectiveFrustum.fov` is horizontal on a landscape canvas and
|
||||
* vertical on a portrait one; convert a vertical field of view to it.
|
||||
* @param {number} verticalFovRad Vertical field of view, radians.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} The value to assign to `frustum.fov`, radians.
|
||||
*/
|
||||
export function cesiumFrustumFovRad(verticalFovRad, aspect) {
|
||||
if (!(aspect > 1)) return verticalFovRad;
|
||||
return 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground width covered across the frame, along a great circle through the
|
||||
* sub-satellite point, km. A ray past the limb ends at the horizon, so a
|
||||
* frame wider than the disk reports the visible cap's width.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} fovRad Vertical field of view, radians.
|
||||
* @param {number} [aspect=1] Frame width / height; the width is measured
|
||||
* across the wider horizontal extent.
|
||||
* @returns {number|null} Width in kilometres, or null without a usable input.
|
||||
*/
|
||||
export function footprintWidthKm(altitudeM, fovRad, aspect = 1) {
|
||||
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return null;
|
||||
if (!Number.isFinite(fovRad) || fovRad <= 0) return null;
|
||||
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
const halfAngle = Math.atan(Math.tan(fovRad / 2) * ratio);
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const sinGround = ((R + altitudeM) / R) * Math.sin(halfAngle);
|
||||
// Central angle from nadir to where the ray meets the sphere, or the limb.
|
||||
const central =
|
||||
sinGround >= 1
|
||||
? Math.acos(R / (R + altitudeM))
|
||||
: Math.asin(sinGround) - halfAngle;
|
||||
return (2 * R * central) / 1000;
|
||||
}
|
||||
|
||||
function length(v) {
|
||||
return Math.hypot(v.x, v.y, v.z);
|
||||
}
|
||||
|
||||
function scale(v, s) {
|
||||
return { x: v.x * s, y: v.y * s, z: v.z * s };
|
||||
}
|
||||
|
||||
function dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function cross(a, b) {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
};
|
||||
}
|
||||
|
||||
/** `v` with its component along unit `d` removed, or null when degenerate. */
|
||||
function perpendicularUnit(v, d) {
|
||||
const along = dot(v, d);
|
||||
const p = {
|
||||
x: v.x - along * d.x,
|
||||
y: v.y - along * d.y,
|
||||
z: v.z - along * d.z,
|
||||
};
|
||||
const n = length(p);
|
||||
return n > 1e-6 ? scale(p, 1 / n) : null;
|
||||
}
|
||||
|
||||
const NORTH_AXIS = Object.freeze({ x: 0, y: 0, z: 1 });
|
||||
|
||||
/**
|
||||
* Camera orientation looking from an Earth-fixed position at the Earth's
|
||||
* centre, with north at the top of the frame.
|
||||
*
|
||||
* `up` is the Earth's rotation axis projected perpendicular to the view, which
|
||||
* is local north at the sub-satellite point. Directly over a pole north is
|
||||
* undefined; the previous frame's `up` is kept when given (so a polar pass
|
||||
* does not spin the picture), otherwise the limit along the prime meridian
|
||||
* is used: over the north pole the top of the frame faces 180°E, over the
|
||||
* south pole 0°E.
|
||||
*
|
||||
* With `headingDeg` the frame turns so that bearing (degrees clockwise from
|
||||
* north) is at the top: the ground track, for a view that scrolls along it.
|
||||
* @param {{x: number, y: number, z: number}} position ECEF position, metres.
|
||||
* @param {{previousUp?: {x: number, y: number, z: number}|null,
|
||||
* headingDeg?: number|null}} [options] `previousUp` is a previous frame's
|
||||
* `north`, not its turned `up`.
|
||||
* @returns {{direction: {x: number, y: number, z: number},
|
||||
* up: {x: number, y: number, z: number},
|
||||
* right: {x: number, y: number, z: number},
|
||||
* north: {x: number, y: number, z: number}}|null} Unit vectors (right =
|
||||
* direction × up, as Cesium's camera keeps them), or null at the centre.
|
||||
*/
|
||||
export function nadirOrientation(
|
||||
position,
|
||||
{ previousUp = null, headingDeg = null } = {},
|
||||
) {
|
||||
if (!position) return null;
|
||||
const r = length(position);
|
||||
if (!Number.isFinite(r) || r < 1) return null;
|
||||
const direction = scale(position, -1 / r);
|
||||
let up = perpendicularUnit(NORTH_AXIS, direction);
|
||||
if (!up && previousUp && Number.isFinite(length(previousUp)))
|
||||
up = perpendicularUnit(previousUp, direction);
|
||||
if (!up) {
|
||||
// Looking down -z (north pole): north at longitude 0 points along -x.
|
||||
up = perpendicularUnit(
|
||||
{ x: direction.z < 0 ? -1 : 1, y: 0, z: 0 },
|
||||
direction,
|
||||
);
|
||||
}
|
||||
const north = up;
|
||||
if (Number.isFinite(headingDeg) && headingDeg % 360 !== 0) {
|
||||
// Looking down, right = direction × north is east: the bearing turns
|
||||
// north toward it.
|
||||
const east = cross(direction, north);
|
||||
const θ = (headingDeg * Math.PI) / 180;
|
||||
up = {
|
||||
x: north.x * Math.cos(θ) + east.x * Math.sin(θ),
|
||||
y: north.y * Math.cos(θ) + east.y * Math.sin(θ),
|
||||
z: north.z * Math.cos(θ) + east.z * Math.sin(θ),
|
||||
};
|
||||
}
|
||||
return { direction, up, right: cross(direction, up), north };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease-in-out progress of a zoom step (cubic), clamped to 0..1.
|
||||
* @param {number} elapsedMs Time since the step began.
|
||||
* @param {{durationMs?: number, reducedMotion?: boolean}} [options]
|
||||
* Reduced motion cuts straight to the end.
|
||||
* @returns {number} Eased progress, 0..1.
|
||||
*/
|
||||
export function zoomTransitionProgress(
|
||||
elapsedMs,
|
||||
{ durationMs = ORBITAL_ZOOM_TRANSITION_MS, reducedMotion = false } = {},
|
||||
) {
|
||||
if (reducedMotion || !(durationMs > 0)) return 1;
|
||||
const linear = Math.min(1, Math.max(0, (elapsedMs || 0) / durationMs));
|
||||
return linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus-pull blur for a zoom step: zero at both ends, peaking mid-step.
|
||||
* @param {number} progress Step progress, 0..1 (linear or eased).
|
||||
* @param {{maxBlurPx?: number, reducedMotion?: boolean}} [options]
|
||||
* Reduced motion never blurs.
|
||||
* @returns {number} Blur radius, CSS pixels.
|
||||
*/
|
||||
export function zoomTransitionBlurPx(
|
||||
progress,
|
||||
{ maxBlurPx = ORBITAL_MAX_BLUR_PX, reducedMotion = false } = {},
|
||||
) {
|
||||
if (reducedMotion || !Number.isFinite(progress)) return 0;
|
||||
const p = Math.min(1, Math.max(0, progress));
|
||||
return Math.max(0, maxBlurPx) * Math.sin(Math.PI * p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Field of view part-way through a zoom step. Interpolates the tangent of
|
||||
* the half-angle geometrically, so each moment of the step magnifies by the
|
||||
* same ratio and 1x→2x feels like 4x→8x.
|
||||
* @param {number} fromFovRad Field of view the step starts at.
|
||||
* @param {number} toFovRad Field of view the step ends at.
|
||||
* @param {number} progress Eased progress, 0..1.
|
||||
* @returns {number} Field of view, radians.
|
||||
*/
|
||||
export function interpolateZoomFov(fromFovRad, toFovRad, progress) {
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
|
||||
const a = Math.log(Math.tan(fromFovRad / 2));
|
||||
const b = Math.log(Math.tan(toFovRad / 2));
|
||||
return 2 * Math.atan(Math.exp(a + (b - a) * p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The HUD's transition state at a moment of a zoom step, in the store shape
|
||||
* `{ active, blurPx, lockTick }`: blur while the field of view moves, then a
|
||||
* brief reticle lock tick once it settles. Reduced motion skips the blur and
|
||||
* the tick (the cut is instant).
|
||||
* @param {number} elapsedMs Time since the step began.
|
||||
* @param {{durationMs?: number, lockTickMs?: number, maxBlurPx?: number,
|
||||
* reducedMotion?: boolean}} [options]
|
||||
* @returns {{active: boolean, progress: number, blurPx: number,
|
||||
* lockTick: boolean}}
|
||||
*/
|
||||
export function zoomTransitionState(
|
||||
elapsedMs,
|
||||
{
|
||||
durationMs = ORBITAL_ZOOM_TRANSITION_MS,
|
||||
lockTickMs = ORBITAL_LOCK_TICK_MS,
|
||||
maxBlurPx = ORBITAL_MAX_BLUR_PX,
|
||||
reducedMotion = false,
|
||||
} = {},
|
||||
) {
|
||||
const progress = zoomTransitionProgress(elapsedMs, {
|
||||
durationMs,
|
||||
reducedMotion,
|
||||
});
|
||||
if (reducedMotion)
|
||||
return { active: false, progress: 1, blurPx: 0, lockTick: false };
|
||||
const elapsed = Math.max(0, elapsedMs || 0);
|
||||
const linear = durationMs > 0 ? Math.min(1, elapsed / durationMs) : 1;
|
||||
return {
|
||||
active: elapsed < durationMs,
|
||||
progress,
|
||||
blurPx: zoomTransitionBlurPx(linear, { maxBlurPx }),
|
||||
lockTick: elapsed >= durationMs && elapsed < durationMs + lockTickMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a zoom step whose field of view has landed still holds its focus
|
||||
* pull: until the imagery has loaded, at most `maxHoldMs`.
|
||||
* @param {number} sinceLandedMs Time since the field of view stopped moving.
|
||||
* @param {boolean} tilesLoaded Whether the scene's imagery has loaded.
|
||||
* @param {{maxHoldMs?: number}} [options]
|
||||
*/
|
||||
export function zoomSettleHolding(
|
||||
sinceLandedMs,
|
||||
tilesLoaded,
|
||||
{ maxHoldMs = ORBITAL_SETTLE_MAX_MS } = {},
|
||||
) {
|
||||
return !tilesLoaded && Math.max(0, sinceLandedMs || 0) < maxHoldMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total angle between a tilted view and nadir, degrees:
|
||||
* acos(cos north · cos east), as `offNadirOrientation` tilts.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
const cosine = Math.min(1, Math.max(-1, Math.cos(n) * Math.cos(e)));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the view's centre, tilted off nadir, still meets the Earth: its
|
||||
* angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius
|
||||
* from the satellite, asin(R / (R + altitude)).
|
||||
* @param {number} altitudeM
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function offNadirCentreMeetsEarth(altitudeM, offset = {}) {
|
||||
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false;
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI;
|
||||
return offNadirAngleDeg(offset) < limbDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly how much wider the ground across the frame is at the centre of a
|
||||
* tilted view than at nadir, on a flat Earth: tilting across the frame (east,
|
||||
* the frame's right) stretches its width by 1/cos², tilting along it (north,
|
||||
* the frame's top) by 1/cos. 1 at nadir; an approximation, so the HUD marks
|
||||
* the width it scales with ≈.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
|
||||
* @returns {number} At least 1.
|
||||
*/
|
||||
export function offNadirSwathScale({ northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
const along = Math.abs(Math.cos(n));
|
||||
const across = Math.cos(e) ** 2;
|
||||
if (!(along > 1e-6) || !(across > 1e-6)) return 1;
|
||||
return Math.max(1, 1 / (along * across));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilt a nadir orientation off nadir: first toward north (a rotation about
|
||||
* the nadir frame's right axis), then toward east (about the tilted up
|
||||
* axis). The base frame is always the nadir one, so a tilt never drifts with
|
||||
* the orbit; the angle from nadir is acos(cos north · cos east).
|
||||
* @param {{direction: object, up: object, right: object}} nadir From
|
||||
* {@link nadirOrientation}.
|
||||
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees;
|
||||
* negative is south or west.
|
||||
* @returns {{direction: object, up: object, right: object}} Unit vectors,
|
||||
* right = direction × up.
|
||||
*/
|
||||
export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
|
||||
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
|
||||
if (n === 0 && e === 0) return nadir;
|
||||
const { direction: d, up: u, right: r } = nadir;
|
||||
const combine = (a, ka, b, kb) => ({
|
||||
x: a.x * ka + b.x * kb,
|
||||
y: a.y * ka + b.y * kb,
|
||||
z: a.z * ka + b.z * kb,
|
||||
});
|
||||
// Toward north: the view swings onto the up axis, up swings off it.
|
||||
const d1 = combine(d, Math.cos(n), u, Math.sin(n));
|
||||
const u1 = combine(u, Math.cos(n), d, -Math.sin(n));
|
||||
// Toward east: the view swings onto the right axis.
|
||||
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
|
||||
return { direction, up: u1, right: cross(direction, u1) };
|
||||
}
|
||||
|
||||
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
|
||||
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
|
||||
|
||||
/**
|
||||
* After a step, the rest of that gesture is absorbed for this long, so one
|
||||
* physical notch (which some browsers deliver as several events) is one step.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
|
||||
|
||||
/**
|
||||
* A pause this long ends a scroll gesture: a partial sum left from an earlier
|
||||
* gesture never adds to a later one.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
|
||||
|
||||
/** `WheelEvent.deltaMode` line and page units, in pixels. */
|
||||
const WHEEL_LINE_PX = 16;
|
||||
const WHEEL_PAGE_PX = 800;
|
||||
|
||||
/**
|
||||
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
|
||||
* so they are summed until they reach a step; a change of direction, or a
|
||||
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
|
||||
* viewer (negative deltaY) zooms in.
|
||||
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
|
||||
* accumulator Previous state.
|
||||
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
|
||||
* @param {number} nowMs
|
||||
* @returns {{accumulator: {sum: number, lastStepMs: number,
|
||||
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
|
||||
* (1 = zoom in).
|
||||
*/
|
||||
export function wheelZoomStep(accumulator, wheel, nowMs) {
|
||||
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
|
||||
const previous =
|
||||
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
|
||||
? { ...held, sum: 0 }
|
||||
: held;
|
||||
const unit =
|
||||
wheel?.deltaMode === 1
|
||||
? WHEEL_LINE_PX
|
||||
: wheel?.deltaMode === 2
|
||||
? WHEEL_PAGE_PX
|
||||
: 1;
|
||||
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
|
||||
if (!delta) return { accumulator: held, step: 0 };
|
||||
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
|
||||
return {
|
||||
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
|
||||
step: 0,
|
||||
};
|
||||
const sum =
|
||||
Math.sign(previous.sum) === -Math.sign(delta)
|
||||
? delta
|
||||
: previous.sum + delta;
|
||||
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
|
||||
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
|
||||
return {
|
||||
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
|
||||
step: sum < 0 ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** From this zoom the view pre-loads imagery ahead along the ground track. */
|
||||
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
|
||||
|
||||
/** How often the pre-load looks ahead, ms. */
|
||||
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
|
||||
|
||||
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
|
||||
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
|
||||
|
||||
/**
|
||||
* Where across the track it looks, as fractions of the view's width: the
|
||||
* centre line, a third out and the frame's edges each side, so a row of
|
||||
* tiles a fifth of the view wide or more spans the frame.
|
||||
*/
|
||||
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
|
||||
0,
|
||||
-1 / 3,
|
||||
1 / 3,
|
||||
-1 / 2,
|
||||
1 / 2,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The points ahead, regrouped by their place across the track: every lead's
|
||||
* centre point first, then each lead's next pair out, so the tile cap trims
|
||||
* the frame's edges before it trims the far leads.
|
||||
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
|
||||
* lead by lead.
|
||||
* @param {number} [across] Points per lead.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function centreLinesFirst(
|
||||
points,
|
||||
across = ORBITAL_PREFETCH_ACROSS.length,
|
||||
) {
|
||||
const out = [];
|
||||
for (let k = 0; k < across; k += 1)
|
||||
for (let i = k; i < points.length; i += across) out.push(points[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** At most this many tile requests per look-ahead. */
|
||||
export const ORBITAL_PREFETCH_MAX_TILES = 12;
|
||||
|
||||
const toRadians = (deg) => (deg * Math.PI) / 180;
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* The point `distanceM` from a point along an initial bearing, on a sphere.
|
||||
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
|
||||
*/
|
||||
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
|
||||
const lat1 = toRadians(latDeg);
|
||||
const theta = toRadians(bearingDeg);
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(delta) +
|
||||
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
|
||||
);
|
||||
const lon2 =
|
||||
toRadians(lonDeg) +
|
||||
Math.atan2(
|
||||
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
|
||||
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
|
||||
);
|
||||
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
|
||||
return { lat: toDegrees(lat2), lon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the view will pass over: for each lead time, the sub-satellite
|
||||
* point that far along the ground track (orbital speed scaled down to the
|
||||
* ground), and points across the track at `across` fractions of the view's
|
||||
* width. Nearest lead first. Empty without a track, speed or width.
|
||||
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
|
||||
* velocityMps: number, altitudeM: number}} info Satellite subject.
|
||||
* @param {number} viewKm The view's ground width, km.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function groundTrackAhead(
|
||||
info,
|
||||
viewKm,
|
||||
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
|
||||
) {
|
||||
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
|
||||
info || {};
|
||||
if (
|
||||
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
velocityMps <= 0 ||
|
||||
altitudeM <= 0 ||
|
||||
!(viewKm > 0)
|
||||
)
|
||||
return [];
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const groundMps = (velocityMps * R) / (R + altitudeM);
|
||||
const points = [];
|
||||
for (const seconds of leadS) {
|
||||
const centre = destinationPoint(
|
||||
latitude,
|
||||
longitude,
|
||||
groundTrackDeg,
|
||||
groundMps * seconds,
|
||||
);
|
||||
for (const fraction of across) {
|
||||
points.push(
|
||||
fraction === 0
|
||||
? centre
|
||||
: destinationPoint(
|
||||
centre.lat,
|
||||
centre.lon,
|
||||
groundTrackDeg + (fraction > 0 ? 90 : -90),
|
||||
Math.abs(fraction) * viewKm * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
|
||||
const WGS84_MAXIMUM_RADIUS_M = 6378137;
|
||||
|
||||
/**
|
||||
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
|
||||
* its level-zero estimate (TerrainProvider's
|
||||
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
|
||||
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
|
||||
* level.
|
||||
* @param {number} level
|
||||
* @returns {number}
|
||||
*/
|
||||
export function ellipsoidTerrainLevelErrorM(level) {
|
||||
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
|
||||
}
|
||||
|
||||
/** The deepest terrain level considered. */
|
||||
const MAX_TERRAIN_LEVEL = 30;
|
||||
|
||||
/**
|
||||
* The imagery level Cesium draws under the view, and so the one to pre-load.
|
||||
* Cesium refines the terrain tile under the view while its geometric error
|
||||
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
|
||||
* screenSpaceError: error over the ground a pixel covers), then drapes the
|
||||
* imagery level whose texel spacing is nearest that error (ImageryLayer's
|
||||
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
|
||||
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
|
||||
* is left out. Clamped to the provider's levels; null without a usable input.
|
||||
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
|
||||
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
|
||||
* @param {{metresPerPixel: number, latitudeDeg: number,
|
||||
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
|
||||
* maximumLevel?: number, maximumScreenSpaceError?: number,
|
||||
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
|
||||
* is the ground one CSS pixel covers at the view's centre; `geographic`
|
||||
* marks a scheme in the geographic projection, whose texels do not narrow
|
||||
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
|
||||
* provider's getLevelMaximumGeometricError.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function imageryLevelForView(
|
||||
tilingScheme,
|
||||
{
|
||||
metresPerPixel,
|
||||
latitudeDeg,
|
||||
geographic = false,
|
||||
tileWidthPx = 256,
|
||||
minimumLevel = 0,
|
||||
maximumLevel = 30,
|
||||
maximumScreenSpaceError = 2,
|
||||
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
|
||||
},
|
||||
) {
|
||||
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
|
||||
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
|
||||
let terrainLevel = 0;
|
||||
while (
|
||||
terrainLevel < MAX_TERRAIN_LEVEL &&
|
||||
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
|
||||
)
|
||||
terrainLevel += 1;
|
||||
const errorM = terrainLevelErrorM(terrainLevel);
|
||||
if (!(errorM > 0)) return null;
|
||||
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
|
||||
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
|
||||
const latitudeFactor = geographic
|
||||
? 1
|
||||
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
|
||||
const levelZeroTexelM =
|
||||
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
|
||||
((tileWidthPx || 256) * rootTiles);
|
||||
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
|
||||
if (!Number.isFinite(level)) return null;
|
||||
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
|
||||
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
|
||||
return Math.min(top, Math.max(bottom, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles at `level` holding `points`, in order, without repeats or tiles
|
||||
* `skip` reports as already asked for, at most `maxTiles`.
|
||||
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
|
||||
* @param {Array<{lat: number, lon: number}>} points Degrees.
|
||||
* @param {number} level
|
||||
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
|
||||
* @returns {Array<{x: number, y: number, level: number, key: string}>}
|
||||
*/
|
||||
export function tilesForPoints(
|
||||
tilingScheme,
|
||||
points,
|
||||
level,
|
||||
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
|
||||
) {
|
||||
const tiles = [];
|
||||
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
|
||||
if (!Number.isInteger(level) || level < 0) return tiles;
|
||||
const seen = new Set();
|
||||
for (const point of points) {
|
||||
if (tiles.length >= maxTiles) break;
|
||||
const xy = tilingScheme.positionToTileXY(
|
||||
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
|
||||
level,
|
||||
);
|
||||
if (!xy) continue;
|
||||
const key = `${level}/${xy.x}/${xy.y}`;
|
||||
if (seen.has(key) || skip(key)) continue;
|
||||
seen.add(key);
|
||||
tiles.push({ x: xy.x, y: xy.y, level, key });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ORBITAL_ZOOM_LADDER,
|
||||
ORBITAL_BASE_FOV_LEO_RAD,
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
ORBITAL_SETTLE_MAX_MS,
|
||||
ORBITAL_WHEEL_COOLDOWN_MS,
|
||||
cesiumFrustumFovRad,
|
||||
earthAngularRadiusRad,
|
||||
footprintWidthKm,
|
||||
fovForZoom,
|
||||
interpolateZoomFov,
|
||||
nadirOrientation,
|
||||
nextOrbitalZoom,
|
||||
offNadirAngleDeg,
|
||||
offNadirCentreMeetsEarth,
|
||||
centreLinesFirst,
|
||||
offNadirSwathScale,
|
||||
orbitalMaxZoom,
|
||||
sensorStripFraction,
|
||||
sensorSwathZoom,
|
||||
viewWidthKm,
|
||||
wheelZoomStep,
|
||||
ORBITAL_WHEEL_GESTURE_GAP_MS,
|
||||
offNadirOrientation,
|
||||
orbitalBaseFovRad,
|
||||
previousOrbitalZoom,
|
||||
zoomSettleHolding,
|
||||
zoomTransitionBlurPx,
|
||||
zoomTransitionProgress,
|
||||
zoomTransitionState,
|
||||
ORBITAL_PREFETCH_ACROSS,
|
||||
ORBITAL_PREFETCH_LEAD_S,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
destinationPoint,
|
||||
groundTrackAhead,
|
||||
imageryLevelForView,
|
||||
tilesForPoints,
|
||||
} from './cockpitOrbital.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
const norm = (v) => Math.hypot(v.x, v.y, v.z);
|
||||
|
||||
/** ECEF position on the mean sphere at a latitude, longitude and altitude. */
|
||||
function ecef(latDeg, lonDeg, altitudeM) {
|
||||
const r = ORBITAL_EARTH_RADIUS_M + altitudeM;
|
||||
const lat = latDeg * RAD;
|
||||
const lon = lonDeg * RAD;
|
||||
return {
|
||||
x: r * Math.cos(lat) * Math.cos(lon),
|
||||
y: r * Math.cos(lat) * Math.sin(lon),
|
||||
z: r * Math.sin(lat),
|
||||
};
|
||||
}
|
||||
|
||||
/** Local north unit vector at a latitude and longitude. */
|
||||
function localNorth(latDeg, lonDeg) {
|
||||
const lat = latDeg * RAD;
|
||||
const lon = lonDeg * RAD;
|
||||
return {
|
||||
x: -Math.sin(lat) * Math.cos(lon),
|
||||
y: -Math.sin(lat) * Math.sin(lon),
|
||||
z: Math.cos(lat),
|
||||
};
|
||||
}
|
||||
|
||||
function assertOrthonormal(orientation) {
|
||||
const { direction, up, right } = orientation;
|
||||
for (const v of [direction, up, right])
|
||||
assert.ok(Math.abs(norm(v) - 1) < 1e-9);
|
||||
assert.ok(Math.abs(dot(direction, up)) < 1e-9);
|
||||
assert.ok(Math.abs(dot(direction, right)) < 1e-9);
|
||||
assert.ok(Math.abs(dot(up, right)) < 1e-9);
|
||||
}
|
||||
|
||||
test('the nadir view points at the Earth centre with north up', () => {
|
||||
for (const [lat, lon, alt] of [
|
||||
[0, 0, 420e3],
|
||||
[51.6, -97.3, 420e3],
|
||||
[-33.9, 151.2, 20.2e6],
|
||||
[0.05, 75, 35.786e6],
|
||||
]) {
|
||||
const position = ecef(lat, lon, alt);
|
||||
const orientation = nadirOrientation(position);
|
||||
const toCentre = {
|
||||
x: -position.x / norm(position),
|
||||
y: -position.y / norm(position),
|
||||
z: -position.z / norm(position),
|
||||
};
|
||||
assert.ok(dot(orientation.direction, toCentre) > 1 - 1e-12);
|
||||
assert.ok(dot(orientation.up, localNorth(lat, lon)) > 1 - 1e-9);
|
||||
assertOrthonormal(orientation);
|
||||
// North up puts east on the right of a picture looking down.
|
||||
const east = { x: -Math.sin(lon * RAD), y: Math.cos(lon * RAD), z: 0 };
|
||||
assert.ok(dot(orientation.right, east) > 1 - 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('over a pole the orientation stays valid and keeps the previous up', () => {
|
||||
for (const z of [1, -1]) {
|
||||
const position = { x: 0, y: 0, z: z * (ORBITAL_EARTH_RADIUS_M + 800e3) };
|
||||
const fallback = nadirOrientation(position);
|
||||
assertOrthonormal(fallback);
|
||||
assert.equal(fallback.direction.z, -z);
|
||||
// The prime-meridian limit of north.
|
||||
assert.ok(Math.abs(fallback.up.x - -z) < 1e-9);
|
||||
const kept = nadirOrientation(position, {
|
||||
previousUp: { x: 0, y: 1, z: 0 },
|
||||
});
|
||||
assertOrthonormal(kept);
|
||||
assert.ok(kept.up.y > 1 - 1e-9);
|
||||
}
|
||||
// Just off the pole, north still points over it.
|
||||
const nearPole = nadirOrientation(ecef(89.9999, 30, 500e3));
|
||||
assertOrthonormal(nearPole);
|
||||
assert.ok(dot(nearPole.up, localNorth(89.9999, 30)) > 1 - 1e-6);
|
||||
});
|
||||
|
||||
test('a degenerate position has no orientation', () => {
|
||||
assert.equal(nadirOrientation(null), null);
|
||||
assert.equal(nadirOrientation({ x: 0, y: 0, z: 0 }), null);
|
||||
assert.equal(nadirOrientation({ x: NaN, y: 0, z: 0 }), null);
|
||||
});
|
||||
|
||||
test('each zoom step divides the tangent of the half-angle', () => {
|
||||
const base = ORBITAL_BASE_FOV_LEO_RAD;
|
||||
for (const zoom of ORBITAL_ZOOM_LADDER) {
|
||||
const fov = fovForZoom(base, zoom);
|
||||
assert.ok(Math.abs(Math.tan(fov / 2) - Math.tan(base / 2) / zoom) < 1e-12);
|
||||
}
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Math.tan(fovForZoom(base, 4) / 2) / Math.tan(fovForZoom(base, 2) / 2) -
|
||||
0.5,
|
||||
) < 1e-12,
|
||||
);
|
||||
assert.equal(fovForZoom(base, 1), base);
|
||||
assert.equal(fovForZoom(base, 0), base);
|
||||
});
|
||||
|
||||
test('zoom steps clamp at both ends without wrapping', () => {
|
||||
assert.equal(nextOrbitalZoom(1), 2);
|
||||
assert.equal(nextOrbitalZoom(4), 8);
|
||||
assert.equal(nextOrbitalZoom(8), 8);
|
||||
assert.equal(previousOrbitalZoom(8), 4);
|
||||
assert.equal(previousOrbitalZoom(2), 1);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
assert.equal(nextOrbitalZoom(undefined), 2);
|
||||
});
|
||||
|
||||
test('the base frame is regional in low orbit and fits the disk from GEO', () => {
|
||||
assert.equal(orbitalBaseFovRad(420e3), ORBITAL_BASE_FOV_LEO_RAD);
|
||||
const geo = orbitalBaseFovRad(35.786e6);
|
||||
const disk = 2 * earthAngularRadiusRad(35.786e6);
|
||||
assert.ok(geo > disk && geo < ORBITAL_BASE_FOV_LEO_RAD);
|
||||
// Continuous across the crossover: no jump as an orbit climbs.
|
||||
let previous = orbitalBaseFovRad(1e6);
|
||||
for (let alt = 1e6; alt <= 40e6; alt += 50e3) {
|
||||
const fov = orbitalBaseFovRad(alt);
|
||||
assert.ok(fov <= previous + 1e-12);
|
||||
assert.ok(previous - fov < 0.01);
|
||||
previous = fov;
|
||||
}
|
||||
});
|
||||
|
||||
test('the footprint shrinks with zoom and stops at the limb', () => {
|
||||
const alt = 420e3;
|
||||
const widths = [1, 2, 4, 8].map((zoom) =>
|
||||
footprintWidthKm(alt, fovForZoom(orbitalBaseFovRad(alt), zoom)),
|
||||
);
|
||||
for (let i = 1; i < widths.length; i++) assert.ok(widths[i] < widths[i - 1]);
|
||||
assert.ok(widths[0] > 400 && widths[0] < 600, `1x ${widths[0]} km`);
|
||||
assert.ok(widths[3] > 40 && widths[3] < 80, `8x ${widths[3]} km`);
|
||||
// A wider frame covers more ground across.
|
||||
assert.ok(
|
||||
footprintWidthKm(alt, ORBITAL_BASE_FOV_LEO_RAD, 16 / 9) > widths[0],
|
||||
);
|
||||
// From GEO a frame wider than the disk spans the visible cap only.
|
||||
const cap =
|
||||
(2 *
|
||||
ORBITAL_EARTH_RADIUS_M *
|
||||
Math.acos(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + 35.786e6))) /
|
||||
1000;
|
||||
assert.ok(Math.abs(footprintWidthKm(35.786e6, 40 * RAD) - cap) < 1e-6);
|
||||
assert.equal(footprintWidthKm(0, 1), null);
|
||||
assert.equal(footprintWidthKm(420e3, NaN), null);
|
||||
});
|
||||
|
||||
test('Cesium frustum fov widens to the horizontal on a landscape canvas', () => {
|
||||
const vertical = 30 * RAD;
|
||||
assert.equal(cesiumFrustumFovRad(vertical, 0.75), vertical);
|
||||
const horizontal = cesiumFrustumFovRad(vertical, 2);
|
||||
assert.ok(
|
||||
Math.abs(Math.tan(horizontal / 2) - 2 * Math.tan(vertical / 2)) < 1e-12,
|
||||
);
|
||||
});
|
||||
|
||||
test('zoom transition curves stay bounded and settle', () => {
|
||||
assert.equal(zoomTransitionProgress(0), 0);
|
||||
assert.equal(zoomTransitionProgress(400), 1);
|
||||
assert.equal(zoomTransitionProgress(9999), 1);
|
||||
assert.equal(zoomTransitionProgress(-50), 0);
|
||||
assert.equal(zoomTransitionProgress(0, { reducedMotion: true }), 1);
|
||||
let previous = 0;
|
||||
for (let ms = 0; ms <= 500; ms += 10) {
|
||||
const p = zoomTransitionProgress(ms);
|
||||
assert.ok(p >= previous && p >= 0 && p <= 1);
|
||||
previous = p;
|
||||
const blur = zoomTransitionBlurPx(ms / 400);
|
||||
assert.ok(blur >= 0 && blur <= 6);
|
||||
}
|
||||
assert.equal(zoomTransitionBlurPx(0), 0);
|
||||
assert.ok(zoomTransitionBlurPx(1) < 1e-9);
|
||||
assert.ok(Math.abs(zoomTransitionBlurPx(0.5) - 6) < 1e-9);
|
||||
assert.equal(zoomTransitionBlurPx(0.5, { reducedMotion: true }), 0);
|
||||
|
||||
const from = fovForZoom(1, 1);
|
||||
const to = fovForZoom(1, 2);
|
||||
assert.equal(interpolateZoomFov(from, to, 0).toFixed(12), from.toFixed(12));
|
||||
assert.equal(interpolateZoomFov(from, to, 1).toFixed(12), to.toFixed(12));
|
||||
const mid = interpolateZoomFov(from, to, 0.5);
|
||||
assert.ok(mid < from && mid > to);
|
||||
});
|
||||
|
||||
test('the transition state blurs, then ticks the lock, then rests', () => {
|
||||
const moving = zoomTransitionState(200);
|
||||
assert.equal(moving.active, true);
|
||||
assert.ok(moving.blurPx > 5);
|
||||
assert.equal(moving.lockTick, false);
|
||||
const settled = zoomTransitionState(450);
|
||||
assert.equal(settled.active, false);
|
||||
assert.equal(settled.lockTick, true);
|
||||
assert.equal(settled.progress, 1);
|
||||
assert.ok(settled.blurPx < 1e-9);
|
||||
const rest = zoomTransitionState(2000);
|
||||
assert.equal(rest.lockTick, false);
|
||||
assert.deepEqual(zoomTransitionState(100, { reducedMotion: true }), {
|
||||
active: false,
|
||||
progress: 1,
|
||||
blurPx: 0,
|
||||
lockTick: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('an off-nadir tilt turns the view by its angle from the nadir frame', () => {
|
||||
const nadir = nadirOrientation(ecef(30, -97, 420_000));
|
||||
assert.equal(offNadirOrientation(nadir), nadir, 'no tilt is nadir itself');
|
||||
const angle = (v) => Math.acos(Math.min(1, dot(v, nadir.direction))) / RAD;
|
||||
const north = offNadirOrientation(nadir, { northDeg: 10 });
|
||||
assert.ok(Math.abs(angle(north.direction) - 10) < 1e-9);
|
||||
assert.ok(dot(north.direction, nadir.up) > 0, 'toward north');
|
||||
assert.ok(Math.abs(dot(north.direction, nadir.right)) < 1e-12);
|
||||
const west = offNadirOrientation(nadir, { eastDeg: -20 });
|
||||
assert.ok(Math.abs(angle(west.direction) - 20) < 1e-9);
|
||||
assert.ok(dot(west.direction, nadir.right) < 0, 'toward west');
|
||||
const both = offNadirOrientation(nadir, { northDeg: 45, eastDeg: 45 });
|
||||
const expected = Math.acos(Math.cos(45 * RAD) ** 2) / RAD;
|
||||
assert.ok(Math.abs(angle(both.direction) - expected) < 1e-9);
|
||||
for (const view of [north, west, both]) {
|
||||
assert.ok(Math.abs(norm(view.direction) - 1) < 1e-12);
|
||||
assert.ok(Math.abs(norm(view.up) - 1) < 1e-12);
|
||||
assert.ok(Math.abs(dot(view.direction, view.up)) < 1e-12);
|
||||
}
|
||||
});
|
||||
|
||||
test('a landed zoom step holds for its imagery, but never past the cap', () => {
|
||||
assert.equal(zoomSettleHolding(0, false), true, 'still loading');
|
||||
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS - 1, false), true);
|
||||
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS, false), false, 'cap');
|
||||
assert.equal(zoomSettleHolding(0, true), false, 'loaded: lock at once');
|
||||
assert.equal(zoomSettleHolding(Number.NaN, false), true);
|
||||
});
|
||||
|
||||
const WIDE = 16 / 9;
|
||||
|
||||
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
|
||||
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
|
||||
assert.equal(nextOrbitalZoom(8, 64), 16);
|
||||
assert.equal(nextOrbitalZoom(32, 64), 64);
|
||||
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
|
||||
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
|
||||
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
|
||||
assert.equal(previousOrbitalZoom(128), 64);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
});
|
||||
|
||||
test('a sensor caps the zoom at its swath for the live altitude', () => {
|
||||
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
|
||||
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
|
||||
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
|
||||
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
|
||||
// ICEYE spotlight, 5 km at 570 km.
|
||||
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
|
||||
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
|
||||
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
|
||||
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
|
||||
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
|
||||
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
|
||||
// No sensor, or an unusable swath.
|
||||
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
|
||||
// A swath exactly as wide as the 16x view allows 16x.
|
||||
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
|
||||
});
|
||||
|
||||
test('the SENSOR step frames the swath when it lies in range', () => {
|
||||
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
|
||||
assert.ok(landsat > 4 && landsat < 8, String(landsat));
|
||||
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
|
||||
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
|
||||
assert.equal(worldview, null, 'narrower than the 64x cap allows');
|
||||
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
|
||||
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
|
||||
});
|
||||
|
||||
test('the sensor strip spans its share of the view', () => {
|
||||
assert.equal(sensorStripFraction(13, 130), 0.1);
|
||||
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
|
||||
assert.equal(sensorStripFraction(null, 131), null);
|
||||
assert.equal(sensorStripFraction(13.1, null), null);
|
||||
});
|
||||
|
||||
test('the wheel steps once per notch and sums a trackpad swipe', () => {
|
||||
// A mouse notch away from the viewer zooms in.
|
||||
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 1);
|
||||
// The rest of that notch, inside the cooldown, is absorbed.
|
||||
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
|
||||
assert.equal(r.step, 0);
|
||||
// A notch toward the viewer after the cooldown zooms out.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: 120, deltaMode: 0 },
|
||||
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
|
||||
);
|
||||
assert.equal(r.step, -1);
|
||||
// A trackpad's small deltas add up before they step.
|
||||
let pad = null;
|
||||
const steps = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const out = wheelZoomStep(
|
||||
pad,
|
||||
{ deltaY: -30, deltaMode: 0 },
|
||||
5000 + i * 16,
|
||||
);
|
||||
pad = out.accumulator;
|
||||
steps.push(out.step);
|
||||
}
|
||||
assert.deepEqual(steps, [0, 0, 0, 1]);
|
||||
// Turning back starts the sum over.
|
||||
const turned = wheelZoomStep(
|
||||
{ sum: -90, lastStepMs: -Infinity },
|
||||
{ deltaY: 30, deltaMode: 0 },
|
||||
9000,
|
||||
);
|
||||
assert.equal(turned.step, 0);
|
||||
assert.equal(turned.accumulator.sum, 30);
|
||||
// Line-mode wheels (Firefox): three lines make a notch.
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
|
||||
});
|
||||
|
||||
test('a heading turns the nadir frame, keeping north to fall back on', () => {
|
||||
// Over the equator at 0°E: north is +z, east is +y.
|
||||
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
|
||||
const northUp = nadirOrientation(position);
|
||||
const east = nadirOrientation(position, { headingDeg: 90 });
|
||||
const close = (a, b) =>
|
||||
Math.abs(a.x - b.x) < 1e-9 &&
|
||||
Math.abs(a.y - b.y) < 1e-9 &&
|
||||
Math.abs(a.z - b.z) < 1e-9;
|
||||
assert.ok(close(northUp.up, { x: 0, y: 0, z: 1 }));
|
||||
assert.ok(close(east.up, { x: 0, y: 1, z: 0 }), 'east at the top');
|
||||
assert.ok(close(east.north, northUp.up), 'north kept for the next frame');
|
||||
assert.ok(
|
||||
close(nadirOrientation(position, { headingDeg: 0 }).up, northUp.up),
|
||||
);
|
||||
});
|
||||
|
||||
test('a partial scroll does not carry over into a later gesture', () => {
|
||||
// 90 px: short of a step.
|
||||
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 0);
|
||||
// Still the same gesture: 10 px more completes the step.
|
||||
const same = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1100,
|
||||
);
|
||||
assert.equal(same.step, 1);
|
||||
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
|
||||
);
|
||||
assert.equal(r.step, 0);
|
||||
});
|
||||
|
||||
test('the total off-nadir angle combines both tilts', () => {
|
||||
assert.equal(offNadirAngleDeg(), 0);
|
||||
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
|
||||
// FWD 15° and RIGHT 10° read OFF-NADIR 18.0°.
|
||||
const total = offNadirAngleDeg({ northDeg: 15, eastDeg: 10 });
|
||||
assert.equal(total.toFixed(1), '18.0');
|
||||
assert.equal(offNadirAngleDeg({ northDeg: -15, eastDeg: -10 }), total);
|
||||
});
|
||||
|
||||
test("a tilted view's width grows faster across the frame than along it", () => {
|
||||
assert.equal(offNadirSwathScale(), 1, 'nadir');
|
||||
const cos = (deg) => Math.cos((deg * Math.PI) / 180);
|
||||
assert.ok(
|
||||
Math.abs(offNadirSwathScale({ northDeg: 30 }) - 1 / cos(30)) < 1e-9,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(offNadirSwathScale({ eastDeg: 30 }) - 1 / cos(30) ** 2) < 1e-9,
|
||||
);
|
||||
let last = 1;
|
||||
for (const deg of [5, 15, 25, 35, 45]) {
|
||||
const scale = offNadirSwathScale({ northDeg: deg, eastDeg: deg });
|
||||
assert.ok(scale > last, `${deg}°`);
|
||||
last = scale;
|
||||
}
|
||||
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
|
||||
});
|
||||
|
||||
// ── imagery pre-load ahead of the ground track ─────────────────────────
|
||||
|
||||
const PASS = Object.freeze({
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
groundTrackDeg: 45,
|
||||
velocityMps: 7_600,
|
||||
altitudeM: 500_000,
|
||||
});
|
||||
|
||||
test('destinationPoint walks a great circle along a bearing', () => {
|
||||
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
|
||||
assert.ok(Math.abs(east.lat) < 1e-9);
|
||||
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
|
||||
const north = destinationPoint(10, 20, 0, 50_000);
|
||||
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
|
||||
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
|
||||
});
|
||||
|
||||
test('the points ahead run along the ground track at ground speed', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
|
||||
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
|
||||
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
|
||||
const start = { lat: PASS.latitude, lon: PASS.longitude };
|
||||
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
|
||||
const centre = points[i * across];
|
||||
const km = greatCircleKm(start, centre);
|
||||
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
|
||||
// North-east of the start, on a 45° track.
|
||||
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
|
||||
// The side points sit their fraction of the view off the centre line.
|
||||
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
|
||||
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
|
||||
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
|
||||
assert.deepEqual(groundTrackAhead(PASS, null), []);
|
||||
});
|
||||
|
||||
test('the tile level is the one Cesium draws for the view', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
|
||||
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
|
||||
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
|
||||
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
|
||||
// one-pixel texel would have asked for level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
|
||||
13,
|
||||
);
|
||||
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
|
||||
// texel spans 1.4 px.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// At 60° a Mercator texel covers half the ground: one level coarser.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
|
||||
12,
|
||||
);
|
||||
// A geographic scheme starts from two root tiles and keeps its texels at
|
||||
// every latitude.
|
||||
const geographic = new Cesium.GeographicTilingScheme();
|
||||
for (const latitudeDeg of [0, 60])
|
||||
assert.equal(
|
||||
imageryLevelForView(geographic, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg,
|
||||
geographic: true,
|
||||
}),
|
||||
12,
|
||||
`${latitudeDeg}°`,
|
||||
);
|
||||
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
|
||||
// and a looser screen-space error are honoured.
|
||||
const worldTerrain = (level) =>
|
||||
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
terrainLevelErrorM: worldTerrain,
|
||||
}),
|
||||
13,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
maximumScreenSpaceError: 4,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
// Clamped to the provider's levels.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 5,
|
||||
latitudeDeg: 0,
|
||||
maximumLevel: 12,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const tiles = tilesForPoints(mercator, points, 13);
|
||||
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
|
||||
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
|
||||
// Each tile holds its point: the first is the centre line 5 s ahead.
|
||||
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.contains(
|
||||
first,
|
||||
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
|
||||
),
|
||||
);
|
||||
// Ahead along a north-east track: rows north (smaller y), columns east.
|
||||
const here = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
|
||||
13,
|
||||
);
|
||||
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
|
||||
// Bounded, and a tile already asked for is skipped.
|
||||
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
|
||||
const asked = new Set(
|
||||
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
|
||||
(tile) => tile.key,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(tilesForPoints({}, points, 13), []);
|
||||
});
|
||||
|
||||
test('a view tilted past the limb no longer meets the Earth', () => {
|
||||
// GEO: the Earth spans about 8.7 degrees either side of nadir.
|
||||
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true);
|
||||
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false);
|
||||
// LEO: a 30 degree tilt still lands on the ground.
|
||||
assert.equal(
|
||||
offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
|
||||
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
|
||||
});
|
||||
|
||||
test('the centre line of every lead comes before the frame edges', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
const ordered = centreLinesFirst(points);
|
||||
assert.equal(ordered.length, points.length);
|
||||
const leads = ORBITAL_PREFETCH_LEAD_S.length;
|
||||
assert.deepEqual(
|
||||
ordered.slice(0, leads),
|
||||
points.filter((_, i) => i % across === 0),
|
||||
);
|
||||
// The 12-tile cap at a level of small tiles still reaches the far lead.
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const tiles = tilesForPoints(mercator, ordered, 17);
|
||||
const far = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(
|
||||
ordered[leads - 1].lon,
|
||||
ordered[leads - 1].lat,
|
||||
),
|
||||
17,
|
||||
);
|
||||
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Ground focus for the orbital view: the look that keeps a point on the
|
||||
* ground at the centre of the picture as the satellite moves, whether that
|
||||
* look is within the off-nadir reach, and which mapped points the view can
|
||||
* show or be turned to. The look is the orbital view's own
|
||||
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
|
||||
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
import {
|
||||
LOOK_EASE_TAU_MS,
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
easeLook,
|
||||
} from './cockpitLook.js';
|
||||
|
||||
/**
|
||||
* How long a focus takes to swing the view onto its target, or back to
|
||||
* nadir when it ends: the look's own easing curve, on a slower clock. Three
|
||||
* of them (about 0.75 s) close 95% of the turn.
|
||||
*/
|
||||
export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
|
||||
|
||||
/** How often the orbital Live Signals rows are rebuilt, ms. */
|
||||
export const ORBITAL_REACH_REFRESH_MS = 1000;
|
||||
|
||||
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
|
||||
const WGS84_A2 = 6378137 ** 2;
|
||||
const WGS84_B2 = 6356752.314245179 ** 2;
|
||||
|
||||
/** Slack on the reach limit so a target held exactly at 45° stays. */
|
||||
const REACH_EPSILON_DEG = 1e-6;
|
||||
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
function dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The look that centres `target` in the picture, in the nadir frame's tilt
|
||||
* (north first, then east; see `offNadirOrientation`): east is the angle off
|
||||
* the frame's top-bottom plane, north the angle along it. Also the single
|
||||
* angle between nadir and the line of sight, the image's off-nadir angle,
|
||||
* and whether the target faces the satellite (above its horizon).
|
||||
* @param {{direction: object, up: object, right: object}} nadir From
|
||||
* `nadirOrientation`.
|
||||
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
|
||||
* @param {{x: number, y: number, z: number}} target ECEF, metres.
|
||||
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
|
||||
* visible: boolean}|null} Null without usable positions.
|
||||
*/
|
||||
export function focusLook(nadir, satellite, target) {
|
||||
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
|
||||
const line = {
|
||||
x: target.x - satellite.x,
|
||||
y: target.y - satellite.y,
|
||||
z: target.z - satellite.z,
|
||||
};
|
||||
const range = Math.hypot(line.x, line.y, line.z);
|
||||
if (!(range > 1)) return null;
|
||||
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
|
||||
const along = dot(v, nadir.direction);
|
||||
const up = dot(v, nadir.up);
|
||||
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
|
||||
// The target's own zenith, the WGS84 surface normal there: the satellite
|
||||
// is above its horizon when the line from the target to it points up.
|
||||
const normal = {
|
||||
x: target.x / WGS84_A2,
|
||||
y: target.y / WGS84_A2,
|
||||
z: target.z / WGS84_B2,
|
||||
};
|
||||
const visible =
|
||||
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
|
||||
const yawDeg = toDegrees(Math.asin(right));
|
||||
const pitchDeg = toDegrees(Math.atan2(up, along));
|
||||
return {
|
||||
yawDeg: yawDeg === 0 ? 0 : yawDeg,
|
||||
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
|
||||
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a focus look is within the off-nadir reach: the target faces the
|
||||
* satellite and each axis is within the arrows' ±45° clamp.
|
||||
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusInReach(look) {
|
||||
if (!look || look.visible === false) return false;
|
||||
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
|
||||
return (
|
||||
Number.isFinite(look.yawDeg) &&
|
||||
Number.isFinite(look.pitchDeg) &&
|
||||
Math.abs(look.yawDeg) <= limit &&
|
||||
Math.abs(look.pitchDeg) <= limit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease what is left of the turn onto a focus target toward nothing: the
|
||||
* look's easing (`easeLook`) on the focus's slower clock. The view is the
|
||||
* live target's look plus this remainder, so once it lands the view holds
|
||||
* the target exactly, without the lag of chasing it. Reduced motion cuts.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
|
||||
* @param {number} dtMs Milliseconds since the previous frame.
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function easeFocusRemainder(
|
||||
remainder,
|
||||
dtMs,
|
||||
{ immediate = false } = {},
|
||||
) {
|
||||
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
|
||||
immediate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame's time on the focus's clock, for `easeLook`: its fixed time
|
||||
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
|
||||
* @param {number} dtMs
|
||||
* @returns {number}
|
||||
*/
|
||||
export function focusEaseDtMs(dtMs) {
|
||||
return Number.isFinite(dtMs) && dtMs > 0
|
||||
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped points the view can be turned to: those facing the satellite
|
||||
* and within the off-nadir reach, nearest to nadir first.
|
||||
* @param {Iterable<{key: string, position: {x: number, y: number,
|
||||
* z: number}}>} candidates
|
||||
* @param {{nadir: object, satellite: object}} frame
|
||||
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
|
||||
*/
|
||||
export function pointsInReach(candidates, { nadir, satellite }) {
|
||||
const entries = [];
|
||||
if (!nadir || !finiteVector(satellite)) return entries;
|
||||
for (const candidate of candidates) {
|
||||
const look = focusLook(nadir, satellite, candidate?.position);
|
||||
if (!focusInReach(look)) continue;
|
||||
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
|
||||
}
|
||||
return entries.sort(
|
||||
(a, b) =>
|
||||
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
|
||||
// orbital view: the look that centres a target, its off-nadir angle, the
|
||||
// reach, the easing onto and off a target, and the reach list.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ORBITAL_FOCUS_EASE_TAU_MS,
|
||||
easeFocusRemainder,
|
||||
focusEaseDtMs,
|
||||
focusInReach,
|
||||
focusLook,
|
||||
pointsInReach,
|
||||
} from './cockpitOrbitalFocus.js';
|
||||
import {
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
nadirOrientation,
|
||||
offNadirAngleDeg,
|
||||
offNadirOrientation,
|
||||
} from './cockpitOrbital.js';
|
||||
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
|
||||
|
||||
const near = (actual, expected, label, tolerance = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
|
||||
const unit = (v) => {
|
||||
const n = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / n, y: v.y / n, z: v.z / n };
|
||||
};
|
||||
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
|
||||
const angleBetweenDeg = (a, b) => {
|
||||
const u = unit(a);
|
||||
const v = unit(b);
|
||||
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
};
|
||||
|
||||
/** A point on a sphere of radius `radiusM`, ECEF. */
|
||||
const onSphere = (latDeg, lonDeg, radiusM) => {
|
||||
const lat = (latDeg * Math.PI) / 180;
|
||||
const lon = (lonDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: radiusM * Math.cos(lat) * Math.cos(lon),
|
||||
y: radiusM * Math.cos(lat) * Math.sin(lon),
|
||||
z: radiusM * Math.sin(lat),
|
||||
};
|
||||
};
|
||||
|
||||
/** A point on the WGS84 ellipsoid, as the globe draws it. */
|
||||
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
|
||||
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
|
||||
|
||||
const ISS_ALT_M = 420_000;
|
||||
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
|
||||
geodeticToEcef(latDeg, lonDeg, altitudeM);
|
||||
|
||||
test('the focus look turns the nadir frame onto the target exactly', () => {
|
||||
const satellite = satelliteAt(30, -97);
|
||||
for (const heading of [null, 0, 51.6, 200]) {
|
||||
const nadir = nadirOrientation(satellite, { headingDeg: heading });
|
||||
for (const [lat, lon] of [
|
||||
[31.5, -96],
|
||||
[28.2, -99.4],
|
||||
[30, -97],
|
||||
[32.9, -97.1],
|
||||
]) {
|
||||
const target = geodeticToEcef(lat, lon, 300);
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
const view = offNadirOrientation(nadir, {
|
||||
northDeg: look.pitchDeg,
|
||||
eastDeg: look.yawDeg,
|
||||
});
|
||||
near(
|
||||
angleBetweenDeg(view.direction, sub(target, satellite)),
|
||||
0,
|
||||
`centred at ${lat},${lon} heading ${heading}`,
|
||||
1e-5,
|
||||
);
|
||||
// The single angle the readout gives is the tilt's total angle.
|
||||
near(
|
||||
look.offNadirDeg,
|
||||
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
|
||||
'one off-nadir angle',
|
||||
1e-9,
|
||||
);
|
||||
assert.equal(look.visible, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
|
||||
// On a sphere: a target `central` radians from the sub-satellite point is
|
||||
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const satellite = onSphere(10, 20, R + ISS_ALT_M);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
|
||||
const target = onSphere(10 + centralDeg, 20, R);
|
||||
const γ = (centralDeg * Math.PI) / 180;
|
||||
const expected =
|
||||
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
|
||||
Math.PI;
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
|
||||
}
|
||||
// Straight below reads zero, a real angle and not a missing one.
|
||||
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
|
||||
near(below.offNadirDeg, 0, 'straight below', 1e-9);
|
||||
near(below.yawDeg, 0, 'no east', 1e-9);
|
||||
near(below.pitchDeg, 0, 'no north', 1e-9);
|
||||
assert.equal(focusInReach(below), true);
|
||||
});
|
||||
|
||||
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
|
||||
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
|
||||
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
|
||||
assert.equal(focusInReach(null), false);
|
||||
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
|
||||
|
||||
// From geostationary height the far side of the Earth lies within a few
|
||||
// degrees of nadir, but it faces away.
|
||||
const satellite = satelliteAt(0, 0, 35_786_000);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const farSide = geodeticToEcef(0, 100, 0);
|
||||
const look = focusLook(nadir, satellite, farSide);
|
||||
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
|
||||
assert.equal(look.visible, false);
|
||||
assert.equal(focusInReach(look), false);
|
||||
// A point near the sub-satellite point faces it.
|
||||
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
|
||||
});
|
||||
|
||||
test('a target slides out of reach as the satellite passes it', () => {
|
||||
const target = geodeticToEcef(0, 10, 0);
|
||||
const inReach = [];
|
||||
for (let lon = 10; lon >= -5; lon -= 1) {
|
||||
const satellite = satelliteAt(0, lon);
|
||||
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
|
||||
}
|
||||
assert.equal(inReach[0], true, 'overhead');
|
||||
assert.equal(inReach.at(-1), false, '15° of longitude behind');
|
||||
// Once out, it stays out: the reach ends once, not on and off.
|
||||
const firstOut = inReach.indexOf(false);
|
||||
assert.ok(firstOut > 0);
|
||||
assert.ok(inReach.slice(firstOut).every((value) => value === false));
|
||||
});
|
||||
|
||||
test('the turn onto a target eases on the slower focus clock and lands', () => {
|
||||
assert.equal(
|
||||
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
|
||||
LOOK_EASE_TAU_MS,
|
||||
'one focus time constant is one look time constant',
|
||||
);
|
||||
assert.equal(focusEaseDtMs(Number.NaN), 0);
|
||||
assert.equal(focusEaseDtMs(-5), 0);
|
||||
let remainder = { yawDeg: -30, pitchDeg: 20 };
|
||||
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
let frames = 0;
|
||||
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
|
||||
const before = remainder;
|
||||
remainder = easeFocusRemainder(remainder, 16);
|
||||
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
assert.ok(size < previous, 'always closer');
|
||||
// No axis moves more than a tenth of the way in a frame, but for the
|
||||
// last few hundredths of a degree it lands on: a turn, not a cut.
|
||||
for (const axis of ['yawDeg', 'pitchDeg']) {
|
||||
const step = Math.abs(before[axis] - remainder[axis]);
|
||||
assert.ok(
|
||||
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
|
||||
`frame ${frames} ${axis}`,
|
||||
);
|
||||
}
|
||||
previous = size;
|
||||
frames += 1;
|
||||
}
|
||||
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
|
||||
// About three time constants close 95%; landing takes a little longer.
|
||||
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.deepEqual(
|
||||
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
|
||||
{ yawDeg: 0, pitchDeg: 0 },
|
||||
'reduced motion cuts',
|
||||
);
|
||||
});
|
||||
|
||||
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
|
||||
const satellite = satelliteAt(0, 0);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const candidates = [];
|
||||
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
|
||||
// 45° tilt, the rest beyond it.
|
||||
for (let i = 0; i < 80; i += 1)
|
||||
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
|
||||
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
|
||||
const entries = pointsInReach(candidates, { nadir, satellite });
|
||||
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
|
||||
const angles = entries.map((entry) => entry.offNadirDeg);
|
||||
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
|
||||
assert.equal(entries[0].key, 'east-0');
|
||||
assert.equal(entries[0].offNadirDeg, 0);
|
||||
// Only those within the 45° tilt are listed.
|
||||
assert.ok(entries.length > 0 && entries.length < 80);
|
||||
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
|
||||
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
|
||||
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
|
||||
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
|
||||
});
|
||||
|
||||
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
|
||||
const target = geodeticToEcef(45, 0);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
|
||||
const satelliteAtElevation = (elevationDeg) => {
|
||||
const e = Cesium.Math.toRadians(elevationDeg);
|
||||
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
|
||||
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
|
||||
};
|
||||
const below = satelliteAtElevation(-0.1);
|
||||
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
|
||||
const above = satelliteAtElevation(0.1);
|
||||
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* The camera's hop from the satellite the orbital view rides to another.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
|
||||
/** The most nearby satellites asked for the orbital rows, nearest first. */
|
||||
export const ORBITAL_SATELLITES_MAX = 8;
|
||||
|
||||
/** How long the camera takes to hop to another satellite, ms. */
|
||||
export const ORBITAL_HOP_MS = 1200;
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera is part-way through a hop: along the great circle
|
||||
* between the two positions (never through the Earth), its distance from the
|
||||
* centre interpolated linearly. The circle is the one through both ends, so
|
||||
* the path moves smoothly as the destination does, however far round it
|
||||
* lies; only exactly opposite ends, which no single circle joins, turn about
|
||||
* a fixed axis square to them.
|
||||
* @param {{x: number, y: number, z: number}} from
|
||||
* @param {{x: number, y: number, z: number}} to
|
||||
* @param {number} progress Eased, 0..1.
|
||||
* @returns {{x: number, y: number, z: number}|null}
|
||||
*/
|
||||
export function hopPosition(from, to, progress) {
|
||||
if (!finiteVector(from) || !finiteVector(to)) return null;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
|
||||
const ra = Math.hypot(from.x, from.y, from.z);
|
||||
const rb = Math.hypot(to.x, to.y, to.z);
|
||||
if (!(ra > 0) || !(rb > 0)) return null;
|
||||
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
|
||||
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
|
||||
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
// The direction the circle leaves `a` in: `b` less its part along `a`.
|
||||
let c = {
|
||||
x: b.x - cosine * a.x,
|
||||
y: b.y - cosine * a.y,
|
||||
z: b.z - cosine * a.z,
|
||||
};
|
||||
let rc = Math.hypot(c.x, c.y, c.z);
|
||||
const r = ra + (rb - ra) * p;
|
||||
const omega = Math.atan2(rc, cosine);
|
||||
if (!(rc > 1e-12)) {
|
||||
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
|
||||
// Opposite ends: set off square to `a`, northward (or, near a pole,
|
||||
// toward the prime meridian).
|
||||
const pole =
|
||||
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
|
||||
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
|
||||
c = {
|
||||
x: pole.x - along * a.x,
|
||||
y: pole.y - along * a.y,
|
||||
z: pole.z - along * a.z,
|
||||
};
|
||||
rc = Math.hypot(c.x, c.y, c.z);
|
||||
}
|
||||
const angle = p * omega;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle) / rc;
|
||||
return {
|
||||
x: (a.x * cos + c.x * sin) * r,
|
||||
y: (a.y * cos + c.y * sin) * r,
|
||||
z: (a.z * cos + c.z * sin) * r,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
|
||||
// satellite to another.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { hopPosition } from './cockpitOrbitalSatellites.js';
|
||||
|
||||
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
|
||||
|
||||
test('the hop follows the great circle and lands on the satellite', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 90, 800_000);
|
||||
const start = hopPosition(from, to, 0);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
|
||||
// Half-way it is above the Earth, between the two heights, not on the
|
||||
// chord through it.
|
||||
const mid = hopPosition(from, to, 0.5);
|
||||
const radius = Math.hypot(mid.x, mid.y, mid.z);
|
||||
const ra = Cesium.Cartesian3.magnitude(from);
|
||||
const rb = Cesium.Cartesian3.magnitude(to);
|
||||
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
|
||||
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
|
||||
assert.equal(hopPosition(null, to, 0.5), null);
|
||||
});
|
||||
|
||||
test('a hop to the opposite side goes round the Earth, not through it', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 180, 400_000);
|
||||
const radius = Cesium.Cartesian3.magnitude(from);
|
||||
for (const p of [0.25, 0.5, 0.75]) {
|
||||
const point = hopPosition(from, to, p);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3,
|
||||
`p=${p}`,
|
||||
);
|
||||
}
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
|
||||
});
|
||||
|
||||
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
|
||||
const r = 6_778_137;
|
||||
const from = { x: r, y: 0, z: 0 };
|
||||
const near = (delta) => ({
|
||||
x: -r * Math.cos(delta),
|
||||
y: r * Math.sin(delta),
|
||||
z: 0,
|
||||
});
|
||||
const mid = (delta) => hopPosition(from, near(delta), 0.5);
|
||||
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
|
||||
assert.ok(
|
||||
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
|
||||
'no jump between neighbours',
|
||||
);
|
||||
for (const delta of [1e-3, 1e-6, 1e-9]) {
|
||||
const to = near(delta);
|
||||
const end = hopPosition(from, to, 1);
|
||||
assert.ok(
|
||||
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
|
||||
`lands at ${delta}`,
|
||||
);
|
||||
const half = mid(delta);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
|
||||
`round at ${delta}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Bridge radar policy: the range steps, the range rings and the heading-up
|
||||
* projection of nearby AIS vessels onto the radar's range circle. Pure: no
|
||||
* DOM, no Cesium and no translated text; the Cockpit words the labels.
|
||||
*/
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
} from './cockpitMath.js';
|
||||
import { greatCircleMeters } from './geoDistance.js';
|
||||
|
||||
/** Metres in one international nautical mile. */
|
||||
export const METRES_PER_NM = 1852;
|
||||
|
||||
/** The radar's range steps in nautical miles, shortest first. */
|
||||
export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]);
|
||||
|
||||
/** The range the radar opens on. */
|
||||
export const DEFAULT_RADAR_RANGE_NM = 3;
|
||||
|
||||
/** At most this many contacts are drawn, nearest first. */
|
||||
export const RADAR_MAX_CONTACTS = 60;
|
||||
|
||||
/** Range rings divide the range into this many equal steps. */
|
||||
export const RADAR_RING_COUNT = 4;
|
||||
|
||||
/**
|
||||
* Normalize a range to one of the radar's steps.
|
||||
* @param {number} rangeNm Requested range in nautical miles.
|
||||
* @returns {number} The range when it is a step, else the default range.
|
||||
*/
|
||||
export function radarRangeNm(rangeNm) {
|
||||
return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The range after this one; the longest range wraps to the shortest.
|
||||
* @param {number} rangeNm Current range in nautical miles.
|
||||
* @returns {number} The next range step.
|
||||
*/
|
||||
export function nextRadarRange(rangeNm) {
|
||||
const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm));
|
||||
return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Range ring distances at each quarter of the range, innermost first.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {number[]} Ring distances in nautical miles; the last is the range.
|
||||
*/
|
||||
export function radarRingsNm(rangeNm) {
|
||||
const range = radarRangeNm(rangeNm);
|
||||
return Array.from(
|
||||
{ length: RADAR_RING_COUNT },
|
||||
(_, index) => (range * (index + 1)) / RADAR_RING_COUNT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The search radius for a range, in metres.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function radarRangeM(rangeNm) {
|
||||
return radarRangeNm(rangeNm) * METRES_PER_NM;
|
||||
}
|
||||
|
||||
const round4 = (value) => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/**
|
||||
* Project vessel contacts onto a heading-up radar.
|
||||
*
|
||||
* Positions are fractions of the range circle: x to the right (starboard),
|
||||
* y up (ahead), both in [-1, 1]. Contacts outside the range, or without a
|
||||
* position, are left out; the rest come nearest first, at most `maxCount`.
|
||||
* @param {{latitude: number, longitude: number, headingDeg: number}} own
|
||||
* Own ship position and the heading the radar is drawn up from.
|
||||
* @param {Array<{id: string, name?: string, latitude: number,
|
||||
* longitude: number, course?: number|null, color?: string,
|
||||
* stale?: boolean}>} contacts Nearby vessels.
|
||||
* @param {{rangeNm?: number, maxCount?: number}} [options]
|
||||
* @returns {Array<{id: string, name: string, x: number, y: number,
|
||||
* distanceNm: number, relativeBearingDeg: number,
|
||||
* relativeCourseDeg: number|null, color: string, stale: boolean}>}
|
||||
* relativeBearingDeg and relativeCourseDeg are clockwise from the bow in
|
||||
* [-180, 180).
|
||||
*/
|
||||
export function projectRadarContacts(own, contacts, options = {}) {
|
||||
const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM);
|
||||
const cap =
|
||||
Number.isFinite(options.maxCount) && options.maxCount > 0
|
||||
? Math.floor(options.maxCount)
|
||||
: RADAR_MAX_CONTACTS;
|
||||
const lat = Number(own?.latitude);
|
||||
const lon = Number(own?.longitude);
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
!Array.isArray(contacts)
|
||||
)
|
||||
return [];
|
||||
const heading = normalizeHeading(own.headingDeg ?? 0);
|
||||
const projected = [];
|
||||
for (const contact of contacts) {
|
||||
const toLat = Number(contact?.latitude);
|
||||
const toLon = Number(contact?.longitude);
|
||||
const distanceNm =
|
||||
greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) /
|
||||
METRES_PER_NM;
|
||||
if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue;
|
||||
const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon);
|
||||
const relativeBearingDeg =
|
||||
bearing === null ? 0 : relativeBearing(bearing, heading);
|
||||
const radians = (relativeBearingDeg * Math.PI) / 180;
|
||||
const fraction = distanceNm / rangeNm;
|
||||
const course =
|
||||
contact.course === null || contact.course === undefined
|
||||
? NaN
|
||||
: Number(contact.course);
|
||||
projected.push({
|
||||
id: String(contact.id),
|
||||
name: String(contact.name || contact.id),
|
||||
x: round4(Math.sin(radians) * fraction),
|
||||
y: round4(Math.cos(radians) * fraction),
|
||||
distanceNm,
|
||||
relativeBearingDeg,
|
||||
relativeCourseDeg: Number.isFinite(course)
|
||||
? relativeBearing(course, heading)
|
||||
: null,
|
||||
color: contact.color || '',
|
||||
stale: contact.stale === true,
|
||||
});
|
||||
}
|
||||
projected.sort((a, b) => a.distanceNm - b.distanceNm);
|
||||
return projected.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The radar presentation without its worded labels.
|
||||
* @param {{latitude: number, longitude: number, headingDeg: number,
|
||||
* courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground.
|
||||
* @param {Array<object>} contacts See {@link projectRadarContacts}.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {{rangeNm: number, ringsNm: number[], headingDeg: number,
|
||||
* ownRelativeCourseDeg: number|null, contacts: Array<object>}}
|
||||
*/
|
||||
export function radarPicture(own, contacts, rangeNm) {
|
||||
const range = radarRangeNm(rangeNm);
|
||||
const headingDeg = normalizeHeading(own?.headingDeg ?? 0);
|
||||
const course =
|
||||
own?.courseDeg === null || own?.courseDeg === undefined
|
||||
? NaN
|
||||
: Number(own.courseDeg);
|
||||
return {
|
||||
rangeNm: range,
|
||||
ringsNm: radarRingsNm(range),
|
||||
headingDeg,
|
||||
ownRelativeCourseDeg: Number.isFinite(course)
|
||||
? relativeBearing(course, headingDeg)
|
||||
: null,
|
||||
contacts: projectRadarContacts({ ...own, headingDeg }, contacts, {
|
||||
rangeNm: range,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and
|
||||
// heading-up contact projection.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_RADAR_RANGE_NM,
|
||||
METRES_PER_NM,
|
||||
RADAR_MAX_CONTACTS,
|
||||
RADAR_RANGES_NM,
|
||||
nextRadarRange,
|
||||
projectRadarContacts,
|
||||
radarPicture,
|
||||
radarRangeM,
|
||||
radarRingsNm,
|
||||
} from './cockpitRadar.js';
|
||||
|
||||
const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 });
|
||||
/** One nautical mile of latitude, in degrees. */
|
||||
const NM_LAT = 1 / 60;
|
||||
/** One nautical mile of longitude at 54.5°N, in degrees. */
|
||||
const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180));
|
||||
|
||||
function contact(id, northNm, eastNm, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
latitude: OWN.latitude + northNm * NM_LAT,
|
||||
longitude: OWN.longitude + eastNm * NM_LON,
|
||||
course: null,
|
||||
color: '#fff',
|
||||
stale: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => {
|
||||
assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]);
|
||||
assert.equal(DEFAULT_RADAR_RANGE_NM, 3);
|
||||
assert.equal(nextRadarRange(3), 6);
|
||||
assert.equal(nextRadarRange(6), 12);
|
||||
assert.equal(nextRadarRange(12), 24);
|
||||
assert.equal(nextRadarRange(24), 3);
|
||||
assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default');
|
||||
assert.equal(radarRangeM(6), 6 * METRES_PER_NM);
|
||||
});
|
||||
|
||||
test('range rings sit at each quarter of the range', () => {
|
||||
assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]);
|
||||
assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]);
|
||||
});
|
||||
|
||||
test('a contact dead ahead is straight up the scope', () => {
|
||||
const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], {
|
||||
rangeNm: 6,
|
||||
});
|
||||
assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`);
|
||||
assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`);
|
||||
assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02);
|
||||
assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5);
|
||||
});
|
||||
|
||||
test('heading-up turns the picture with the bow', () => {
|
||||
// Heading east: a contact to the north is on the port beam, one to the
|
||||
// south on the starboard beam, one to the east dead ahead.
|
||||
const own = { ...OWN, headingDeg: 90 };
|
||||
const byId = Object.fromEntries(
|
||||
projectRadarContacts(own, [
|
||||
contact('N', 2, 0),
|
||||
contact('S', -2, 0),
|
||||
contact('E', 0, 2),
|
||||
]).map((entry) => [entry.id, entry]),
|
||||
);
|
||||
assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port');
|
||||
assert.ok(
|
||||
byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02,
|
||||
'south is to starboard',
|
||||
);
|
||||
assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead');
|
||||
assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1);
|
||||
assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1);
|
||||
});
|
||||
|
||||
test('a contact to starboard is right of the centre', () => {
|
||||
const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]);
|
||||
assert.ok(starboard.x > 0.2, `x ${starboard.x}`);
|
||||
assert.ok(Math.abs(starboard.y) < 0.01);
|
||||
});
|
||||
|
||||
test('course is drawn relative to the bow and stays null when unknown', () => {
|
||||
const own = { ...OWN, headingDeg: 90 };
|
||||
const [crossing, drifting] = projectRadarContacts(own, [
|
||||
contact('A', 1, 0, { course: 180 }),
|
||||
contact('B', 2, 0, { course: null }),
|
||||
]);
|
||||
assert.equal(crossing.relativeCourseDeg, 90);
|
||||
assert.equal(drifting.relativeCourseDeg, null);
|
||||
});
|
||||
|
||||
test('contacts beyond the range are left out', () => {
|
||||
const contacts = projectRadarContacts(
|
||||
OWN,
|
||||
[contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)],
|
||||
{ rangeNm: 6 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
contacts.map((entry) => entry.id),
|
||||
['in'],
|
||||
);
|
||||
assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1));
|
||||
});
|
||||
|
||||
test('contacts come nearest first, capped', () => {
|
||||
const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) =>
|
||||
contact(`V${index}`, 0, 5.5 - index * 0.07),
|
||||
);
|
||||
const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 });
|
||||
assert.equal(contacts.length, RADAR_MAX_CONTACTS);
|
||||
for (let index = 1; index < contacts.length; index += 1)
|
||||
assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm);
|
||||
assert.equal(
|
||||
projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length,
|
||||
3,
|
||||
'a smaller cap is honoured',
|
||||
);
|
||||
});
|
||||
|
||||
test('a picture carries the range, rings, heading and own course over ground', () => {
|
||||
const picture = radarPicture(
|
||||
{ ...OWN, headingDeg: 350, courseDeg: 10 },
|
||||
[contact('A', 1, 0)],
|
||||
12,
|
||||
);
|
||||
assert.equal(picture.rangeNm, 12);
|
||||
assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]);
|
||||
assert.equal(picture.headingDeg, 350);
|
||||
assert.equal(picture.ownRelativeCourseDeg, 20);
|
||||
assert.equal(picture.contacts.length, 1);
|
||||
assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°');
|
||||
assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null);
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Camera policy for first-person subjects other than aircraft: where a
|
||||
* vessel's bridge eye sits and how its reported fix advances between feed
|
||||
* refreshes. No DOM or i18n dependencies, so the policy is unit-testable.
|
||||
*/
|
||||
import { projectGroundArcLatLon } from './data/motionModel.js';
|
||||
|
||||
/** Bridge cameras look along the horizon; a ship's eye is already high. */
|
||||
export const VESSEL_BRIDGE_PITCH_DEG = -2;
|
||||
|
||||
/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */
|
||||
export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0;
|
||||
|
||||
/** Clearance over the shared ground floor for a camera that floats on water. */
|
||||
export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2;
|
||||
|
||||
/** Eye height used when the vessel layer knows no height for the type. */
|
||||
export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12;
|
||||
|
||||
/**
|
||||
* A reported fix older than this stops advancing: the vessel holds where the
|
||||
* last projection left it instead of sailing on through a feed outage.
|
||||
*/
|
||||
export const VESSEL_DEAD_RECKON_MAX_S = 180;
|
||||
|
||||
/**
|
||||
* The vessel layer floats every ship on the geoid (mean sea level), but a
|
||||
* river or lake can sit well above it: the Seine through Paris is ~27 m up,
|
||||
* which put a bridge eye under the water. The bridge reads the rendered
|
||||
* surface around its ship instead: probes on a ring this far out, plus one
|
||||
* at the ship itself.
|
||||
*/
|
||||
export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30;
|
||||
|
||||
/** Probes on the ring around the ship. */
|
||||
export const VESSEL_WATERLINE_PROBE_COUNT = 8;
|
||||
|
||||
/** How often the bridge reads the water under its ship again, ms. */
|
||||
export const VESSEL_WATERLINE_REFRESH_MS = 5000;
|
||||
|
||||
/** A surface less than this above sea level is the sea itself, metres. */
|
||||
export const VESSEL_WATERLINE_MIN_RISE_M = 2;
|
||||
|
||||
/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */
|
||||
export const VESSEL_WATERLINE_MAX_RISE_M = 4000;
|
||||
|
||||
/**
|
||||
* Where to read the surface around a ship: the ship's own position, then a
|
||||
* ring of probes around it.
|
||||
* @param {number} latitude
|
||||
* @param {number} longitude
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function waterlineProbePoints(
|
||||
latitude,
|
||||
longitude,
|
||||
radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M,
|
||||
count = VESSEL_WATERLINE_PROBE_COUNT,
|
||||
) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
||||
const points = [{ lat: latitude, lon: longitude }];
|
||||
const metresPerDegLat = 111_320;
|
||||
const metresPerDegLon =
|
||||
metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180));
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const bearing = (2 * Math.PI * i) / count;
|
||||
points.push({
|
||||
lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat,
|
||||
lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* The water surface a bridge floats on, ellipsoidal metres. Quays, bridges,
|
||||
* moored boats and the ship's own model all stand above the water, so the
|
||||
* lowest probe is the water. A lowest probe barely above sea level is the
|
||||
* sea; one implausibly high is a bad sample. Either way the geoid stands.
|
||||
* @param {number} seaSurfaceM The geoid sea surface the layer floats ships on.
|
||||
* @param {Array<number|null|undefined>} sampleHeightsM Rendered-surface probes.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) {
|
||||
const heights = (sampleHeightsM || []).filter(Number.isFinite);
|
||||
if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM;
|
||||
const lowest = Math.min(...heights);
|
||||
const rise = lowest - seaSurfaceM;
|
||||
return rise >= VESSEL_WATERLINE_MIN_RISE_M &&
|
||||
rise <= VESSEL_WATERLINE_MAX_RISE_M
|
||||
? lowest
|
||||
: seaSurfaceM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera placement for a vessel subject, in the shape the Cockpit camera
|
||||
* reads for every subject kind.
|
||||
* @param {{eyeHeightM?: number|null}} info Vessel subject info.
|
||||
* @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}}
|
||||
*/
|
||||
export function vesselCameraProfile(info) {
|
||||
return {
|
||||
forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M,
|
||||
upM: Number.isFinite(info?.eyeHeightM)
|
||||
? info.eyeHeightM
|
||||
: VESSEL_DEFAULT_EYE_HEIGHT_M,
|
||||
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
|
||||
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a vessel's fix is now, seconds, or null without a fix time.
|
||||
*
|
||||
* The fix time is on the AIS feed's clock and `nowMs` on this client's, so
|
||||
* comparing them directly would turn a client clock running minutes fast
|
||||
* into minutes of age (every bridge projecting its full limit and reading
|
||||
* STALE FEED). With the time this client first received the fix (client
|
||||
* clock) and the newest fix in that refresh (feed clock), the age is the
|
||||
* time since the fix arrived plus how far it trailed that newest fix; each
|
||||
* part is read on one clock. A fix the feed keeps re-serving keeps its first
|
||||
* receipt, so it ages into STALE FEED. The age leaves out the newest fix's
|
||||
* own delay before the refresh, typically seconds: a feed that was already
|
||||
* stalled when the bridge first saw it ages from then. Without them it falls
|
||||
* back to the clock difference, never negative.
|
||||
* @param {{fixEpochMs: number|null, receivedAtMs?: number|null,
|
||||
* feedEpochMs?: number|null}} fix
|
||||
* @param {number} nowMs Wall-clock time, ms.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function vesselFixAgeSec(fix, nowMs) {
|
||||
const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {};
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null;
|
||||
if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs))
|
||||
return (
|
||||
Math.max(0, (nowMs - receivedAtMs) / 1000) +
|
||||
Math.max(0, (feedEpochMs - fixEpochMs) / 1000)
|
||||
);
|
||||
return Math.max(0, (nowMs - fixEpochMs) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a vessel's last reported fix along its course over ground.
|
||||
*
|
||||
* The vessel layer redraws a ship only when the feed refreshes, so its icon
|
||||
* stands still between fixes. A camera converging on that still point would
|
||||
* sail backwards against its own motion. Projecting the fix forward gives the
|
||||
* Cockpit anchor a moving target, and the bounded age keeps a silent feed from
|
||||
* carrying the ship away indefinitely.
|
||||
* @param {{latitude: number, longitude: number, courseDeg: number|null,
|
||||
* speedMps: number|null, fixEpochMs: number|null,
|
||||
* receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last
|
||||
* report; its age is `vesselFixAgeSec`.
|
||||
* @param {number} nowMs Wall-clock time, ms.
|
||||
* @returns {{latitude: number, longitude: number, ageSec: number|null}}
|
||||
*/
|
||||
export function deadReckonVesselFix(fix, nowMs) {
|
||||
const { latitude, longitude, courseDeg, speedMps } = fix || {};
|
||||
const ageSec = vesselFixAgeSec(fix, nowMs);
|
||||
if (ageSec === null) return { latitude, longitude, ageSec: null };
|
||||
const { lat, lon } = projectGroundArcLatLon(
|
||||
latitude,
|
||||
longitude,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
0,
|
||||
Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S),
|
||||
);
|
||||
return { latitude: lat, longitude: lon, ageSec };
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// src/cockpitSubjects.test.mjs — the vessel camera profile and dead
|
||||
// reckoning of AIS fixes between feed refreshes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
VESSEL_BRIDGE_PITCH_DEG,
|
||||
VESSEL_DEAD_RECKON_MAX_S,
|
||||
VESSEL_DEFAULT_EYE_HEIGHT_M,
|
||||
VESSEL_WATERLINE_PROBE_RADIUS_M,
|
||||
deadReckonVesselFix,
|
||||
vesselFixAgeSec,
|
||||
vesselCameraProfile,
|
||||
vesselWaterlineM,
|
||||
waterlineProbePoints,
|
||||
} from './cockpitSubjects.js';
|
||||
|
||||
const EARTH_M_PER_DEG_LAT = 111_195;
|
||||
|
||||
test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => {
|
||||
assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), {
|
||||
forwardM: 0,
|
||||
upM: 30,
|
||||
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
|
||||
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
});
|
||||
});
|
||||
|
||||
test('a vessel with no known eye height uses the default', () => {
|
||||
for (const info of [{}, { eyeHeightM: null }, null]) {
|
||||
assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M);
|
||||
}
|
||||
});
|
||||
|
||||
test('a fix advances along course over ground for its age', () => {
|
||||
const fixEpochMs = 1_000_000;
|
||||
const advanced = deadReckonVesselFix(
|
||||
{
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
courseDeg: 0,
|
||||
speedMps: 10,
|
||||
fixEpochMs,
|
||||
},
|
||||
fixEpochMs + 60_000,
|
||||
);
|
||||
assert.equal(advanced.ageSec, 60);
|
||||
// Sixty seconds north at 10 m/s.
|
||||
assert.ok(
|
||||
Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2,
|
||||
`expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`,
|
||||
);
|
||||
assert.ok(Math.abs(advanced.longitude) < 1e-9);
|
||||
});
|
||||
|
||||
test('projection stops at the dead-reckoning limit', () => {
|
||||
const fixEpochMs = 0;
|
||||
const at = (ageSec) =>
|
||||
deadReckonVesselFix(
|
||||
{ latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs },
|
||||
fixEpochMs + ageSec * 1000,
|
||||
);
|
||||
const limit = at(VESSEL_DEAD_RECKON_MAX_S);
|
||||
const later = at(VESSEL_DEAD_RECKON_MAX_S * 4);
|
||||
assert.equal(later.longitude, limit.longitude);
|
||||
assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4);
|
||||
});
|
||||
|
||||
test('a fix without time, course or speed stays where it was reported', () => {
|
||||
const fix = {
|
||||
latitude: 54.3,
|
||||
longitude: 10.1,
|
||||
courseDeg: null,
|
||||
speedMps: 6,
|
||||
fixEpochMs: 0,
|
||||
};
|
||||
assert.deepEqual(deadReckonVesselFix(fix, 30_000), {
|
||||
latitude: 54.3,
|
||||
longitude: 10.1,
|
||||
ageSec: 30,
|
||||
});
|
||||
assert.deepEqual(
|
||||
deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000),
|
||||
{ latitude: 54.3, longitude: 10.1, ageSec: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('waterline probes ring the ship at the probe radius', () => {
|
||||
const points = waterlineProbePoints(48.859, 2.293);
|
||||
assert.equal(points.length, 9);
|
||||
assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 });
|
||||
for (const point of points.slice(1)) {
|
||||
const northM = (point.lat - 48.859) * 111_320;
|
||||
const eastM =
|
||||
(point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) <
|
||||
0.01,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []);
|
||||
});
|
||||
|
||||
test('the waterline is the lowest probe when it stands clear of the sea', () => {
|
||||
// The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m.
|
||||
assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2);
|
||||
// Swell and tile noise are the sea itself.
|
||||
assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6);
|
||||
// Under the sea surface (a dock, a bad tile) never sinks the ship.
|
||||
assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6);
|
||||
// Nothing sampled, or implausibly high.
|
||||
assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6);
|
||||
assert.equal(vesselWaterlineM(44.6, [9000]), 44.6);
|
||||
});
|
||||
|
||||
test("a fix's age is read on each clock, so a fast client clock adds none", () => {
|
||||
const fixEpochMs = 1_800_000_000_000;
|
||||
// The refresh's newest fix is 10 s newer than this one; the client, its
|
||||
// clock five minutes fast, received the refresh 5 s ago.
|
||||
const receivedAtMs = fixEpochMs + 12_000 + 300_000;
|
||||
const fix = {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
courseDeg: 90,
|
||||
speedMps: 5,
|
||||
fixEpochMs,
|
||||
receivedAtMs,
|
||||
feedEpochMs: fixEpochMs + 10_000,
|
||||
};
|
||||
assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15);
|
||||
assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15);
|
||||
// Without receive data the clocks are compared, never below zero.
|
||||
const bare = { fixEpochMs };
|
||||
assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60);
|
||||
assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0);
|
||||
assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null);
|
||||
});
|
||||
@@ -65,8 +65,7 @@ export function enterCockpitWithTracking({
|
||||
);
|
||||
}
|
||||
}
|
||||
// This transaction selects aircraft; a selected vessel must not stand in.
|
||||
if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));
|
||||
if (!entryError) entered = Boolean(cockpitView.enter());
|
||||
} catch (error) {
|
||||
entryThrew = true;
|
||||
entryError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -445,42 +445,6 @@ export function cockpitEntryAllowed({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selected vessel's bridge view can be entered. Like aircraft
|
||||
* Cockpit it needs only its own layer, Live AIS Vessels, with a vessel
|
||||
* selected. A Context mode change in flight still owns the camera, so entry
|
||||
* waits it out.
|
||||
* @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
|
||||
return Boolean(vesselsEnabled) && !contextModeChanging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tracked satellite's orbital view can be entered. Like the
|
||||
* bridge it needs only its own layer, Satellites, with a satellite tracked.
|
||||
* A Context mode change in flight still owns the camera, so entry waits it
|
||||
* out. A subject the satellites layer marks as not enterable (debris:
|
||||
* rocket bodies, fragments, dead satellites; `satelliteClassEnterable` in
|
||||
* src/layers/satellites/classes.js) can be tracked but is never ridden: it is
|
||||
* no mission and has no view to offer.
|
||||
* @param {{satellitesEnabled: boolean, contextModeChanging: boolean,
|
||||
* subjectEnterable?: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function orbitEntryAllowed({
|
||||
satellitesEnabled,
|
||||
contextModeChanging,
|
||||
subjectEnterable = true,
|
||||
}) {
|
||||
return (
|
||||
Boolean(satellitesEnabled) &&
|
||||
!contextModeChanging &&
|
||||
subjectEnterable !== false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context-mode id → the word the voice tools accept for it.
|
||||
*
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bridgeEntryAllowed,
|
||||
orbitEntryAllowed,
|
||||
cockpitEntryAllowed,
|
||||
contextAllowedLayerIds,
|
||||
contextLayerEnableBlockReason,
|
||||
@@ -428,30 +426,6 @@ test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
}), false, 'there is no aircraft to ride');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'ais-live-vessels',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), false, "a vessel is the bridge gate's subject, not Cockpit's");
|
||||
});
|
||||
|
||||
test('bridge entry needs only Live AIS Vessels', () => {
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true);
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false);
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false);
|
||||
});
|
||||
|
||||
test('orbital entry needs only Satellites', () => {
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: false }), true);
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: false, contextModeChanging: false }), false);
|
||||
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: true }), false);
|
||||
});
|
||||
|
||||
test('debris can be tracked but never ridden in orbit', () => {
|
||||
const on = { satellitesEnabled: true, contextModeChanging: false };
|
||||
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: false }), false);
|
||||
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: true }), true);
|
||||
assert.equal(orbitEntryAllowed(on), true, 'no subject read yet');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
Loaded 100 of 359 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user