From 2dd96e1f25f37a1464cf84ddbd162c677e546f87 Mon Sep 17 00:00:00 2001 From: MiguelGFerreira Date: Sat, 12 Sep 2026 14:24:33 -0300 Subject: [PATCH 001/166] =?UTF-8?q?feat(data):=20add=20seven=20Brazilian?= =?UTF-8?q?=20Bike=20Ita=C3=BA=20cities=20to=20the=20GBFS=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba run on the same PBSC platform as the existing Austin, Honolulu and Chattanooga entries, so they need no new feed handling and no proxy allowlist change — isAllowedGbfsHost already accepts *.publicbikesystem.net. These are the layer's first stations outside the United States: 1,017 stations across the seven systems. --- CHANGELOG.md | 4 ++ DATA_SOURCES.md | 2 +- src/data/dataCredits.js | 2 +- src/layers/bikeshare/registry.js | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..3c451b4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: + São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and + Curitiba. These are the layer's first stations outside the United States. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 650d3fe6b..2dc8ba0d7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,7 +35,7 @@ How to read this: | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | | **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **GBFS (Lyft / BCycle / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle, Bike Itaú/Tembici) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 009ff6d01..817261608 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -137,7 +137,7 @@ export const DATA_CREDITS = [ }, { key: 'gbfs', - html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', + html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle, Bike Itaú/Tembici)', }, { key: 'radio-browser', diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..a5a1ee32c 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -167,6 +167,90 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Chattanooga', }, + { + id: 'sao-paulo-bike-itau', + city: 'São Paulo, SP', + centerLat: -23.5505, + centerLon: -46.6333, + loadRadiusKm: 100, + stationInformationUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'rio-de-janeiro-bike-itau', + city: 'Rio de Janeiro, RJ', + centerLat: -22.9068, + centerLon: -43.1729, + loadRadiusKm: 100, + stationInformationUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'salvador-bike-itau', + city: 'Salvador, BA', + centerLat: -12.9777, + centerLon: -38.5016, + loadRadiusKm: 90, + stationInformationUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'recife-bike-itau', + city: 'Recife, PE', + centerLat: -8.0476, + centerLon: -34.877, + loadRadiusKm: 90, + stationInformationUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'porto-alegre-bike-itau', + city: 'Porto Alegre, RS', + centerLat: -30.0346, + centerLon: -51.2177, + loadRadiusKm: 90, + stationInformationUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'brasilia-bike-itau', + city: 'Brasília, DF', + centerLat: -15.7939, + centerLon: -47.8828, + loadRadiusKm: 90, + stationInformationUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'curitiba-bike-itau', + city: 'Curitiba, PR', + centerLat: -25.4284, + centerLon: -49.2733, + loadRadiusKm: 90, + stationInformationUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, bcycleEntry({ id: 'boulder-bcycle', city: 'Boulder, CO', From ae661db7ab61a7ab913856c654a3731ee354d9e3 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:38:00 +0200 Subject: [PATCH 002/166] =?UTF-8?q?feat(cctv):=20add=20Catalonia=20(Servei?= =?UTF-8?q?=20Catal=C3=A0=20de=20Tr=C3=A0nsit)=20as=20a=20keyless=20CCTV?= =?UTF-8?q?=20source=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One WFS/GML XML feed (~135 cameras) aggregating three publishers under a `font` field: SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras. Frame URLs are checked against a small host allowlist rather than one pinned origin, 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), and ground elevation is primed per publisher rather than one flat prior. SCT's own frame link needed a workaround: RenderService 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, by design (stops an upstream steering a host-pinned request into a plaintext downgrade). 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 builds the TransitCamera URL itself, over plain http, from RenderService's sctidcam id, instead of registering the broken upstream link — the only transport this host actually serves to this runtime. Verified against the live feed and the production fetch path (fetchCctvImageFromUpstream): all 135 cameras return real image bytes, 0 false positives. Ambient-card refresh cadence added to src/data/cctvLod.js (3 minutes), matching the upstream update rate measured empirically (no change inside 64s, changed within ~90-150s) — the same cadence already used for Caltrans/TfL/Ontario/TxDOT. CCTV_CATALONIA_MAX_SOURCES sets the cap and CCTV_CATALONIA_ENABLED=0 turns the pack off. No overlap with any existing pack (checked catalog.js and the git history for Spain/Catalonia/Barcelona coverage: none exists). Verified before this commit: format, check:boundaries, unit suite (3692 tests), and build all green. Co-Authored-By: Claude Sonnet 5 --- .env.example | 2 + CHANGELOG.md | 7 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 41 ++++ server/providers/cctv/normalize.js | 30 +++ server/providers/cctv/sources.js | 203 +++++++++++++++++ src/data/cctvCataloniaSource.test.mjs | 317 ++++++++++++++++++++++++++ src/data/cctvDriveBcSource.test.mjs | 2 + src/data/cctvLod.js | 4 + src/data/cctvLod.test.mjs | 4 + src/data/dataCredits.js | 8 + 13 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/data/cctvCataloniaSource.test.mjs diff --git a/.env.example b/.env.example index 5bc02df9c..5d2a268df 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # 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 (~135 exist today) # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3d01413..0cfff159d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack: ~135 + cameras from a WFS/GML feed that aggregates SCT's own highway cameras with + hotlinked Barcelona and Terrassa municipal cameras, each partner named as a + credit beside the shared provider. Ambient-card refresh paced to 3 minutes, + matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES` + sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. + - Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports. - Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 850d27e39..02e7e4272 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -45,6 +45,7 @@ How to read this: | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | | **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 | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -73,6 +74,7 @@ How to read this: - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. +- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `gencat.cat/transit/opendata/cameres.xml` WFS/GML feed. 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 (~135 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, 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. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_CATALONIA_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 40e441b25..42ab3f07a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2433,7 +2433,7 @@ its criteria cannot be silently ignored. | 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `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 | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..c681d321c 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -17,6 +17,7 @@ import { loadWarendorfSourcesFromCatalog, loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, + loadCataloniaSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -86,6 +87,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CALGARY_ENABLED'), load: loadCalgarySourcesFromOpenData, }, + { + name: 'catalonia', + enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), + load: loadCataloniaSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index d66806e45..4dfcbbd15 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -236,6 +236,47 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 }; * body cannot be buffered without limit. */ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** + * Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML + * XML list (verified 2026-09-15, hourly catalog refresh). The feed carries a + * `font` field naming the actual publisher per camera — SCT's own highway + * cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are + * kept, each confirmed to return a real frame. Frame URLs are checked + * against a host allowlist rather than one pinned origin, since each + * publisher serves from its own host. + */ +export const CATALONIA_CAMERAS_URL = + 'http://www.gencat.cat/transit/opendata/cameres.xml'; +/** Allowed image hosts, one per kept `font` publisher in the feed. */ +export const CATALONIA_IMAGE_HOSTS = Object.freeze( + new Set([ + 'mct.gencat.cat', // SCT + 'www.bcn.cat', // IMI (Ajuntament de Barcelona) + 'emap.terrassa.cat', // Ajuntament de Terrassa + ]), +); +/** Per-`font` partner credit, shown beside the provider in the CCTV panel + * (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's + * own publisher and carries no separate credit. */ +export const CATALONIA_FONT_CREDIT = Object.freeze({ + IMI: 'Ajuntament de Barcelona', + Terrassa: 'Ajuntament de Terrassa', +}); +/** Ground-elevation priors in metres, by `font`: the feed carries no + * elevation, and this pack spans sea level (Barcelona) to Terrassa's plateau + * (~270 m). The client's ground snap corrects these on 3D-tile stacks. */ +export const CATALONIA_FONT_ELEVATION_M = Object.freeze({ + SCT: 80, + IMI: 20, + Terrassa: 270, +}); +export const DEFAULT_CATALONIA_MAX_SOURCES = 150; +/** Prioritization anchors: Barcelona and Terrassa. */ +export const CATALONIA_ANCHORS = [ + { lat: 41.3874, lon: 2.1686 }, // Barcelona + { lat: 41.5636, lon: 2.0111 }, // Terrassa +]; + /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..2469bee76 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -372,6 +372,36 @@ export function isLikelyFinlandCoordinate(lat, lon) { return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; } +/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */ +export function isLikelyCataloniaCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.3 && + lat <= 42.9 && + lon >= 0.0 && + lon <= 3.4 + ); +} + +/** + * Decode decimal numeric HTML character references ("í" -> "í"), the + * entity form the Catalonia feed uses for accented place names, plus the + * handful of named entities XML/HTML text commonly carries. + * + * @param {string} text + * @returns {string} + */ +export function decodeNumericEntities(text) { + return String(text || '') + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .trim(); +} + /** * Human label for one Fintraffic preset (camera view). * diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 41471f5ba..3f73e70bc 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -55,6 +55,12 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + CATALONIA_CAMERAS_URL, + CATALONIA_IMAGE_HOSTS, + CATALONIA_FONT_CREDIT, + CATALONIA_FONT_ELEVATION_M, + DEFAULT_CATALONIA_MAX_SOURCES, + CATALONIA_ANCHORS, CCTV_SOURCE_FETCH_TIMEOUT_MS, } from './constants.js'; import { @@ -73,6 +79,8 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyCataloniaCoordinate, + decodeNumericEntities, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1594,3 +1602,198 @@ export async function loadCalgarySourcesFromOpenData() { return []; } } + +/** + * Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records. + * Regex-based rather than a full XML parser — the feed is small (~160 + * features) and this avoids a new dependency for one field set per feature. + * + * @param {string} xml + * @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>} + */ +export function parseCataloniaXml(xml) { + const out = []; + const blockRe = /([\s\S]*?)<\/gml:featureMember>/g; + const tag = (block, name) => { + const match = new RegExp(`([^<]*)<\\/cite:${name}>`).exec( + block, + ); + return match ? decodeNumericEntities(match[1]) : ''; + }; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const block = match[1]; + const fidMatch = /]*>([^<]+)<\/gml:coordinates>/.exec( + block, + ); + if (!fidMatch || !coordsMatch) continue; + const [lonRaw, latRaw] = coordsMatch[1].split(','); + out.push({ + fid: fidMatch[1], + lon: toFiniteNumber(lonRaw), + lat: toFiniteNumber(latRaw), + carretera: tag(block, 'carretera'), + municipi: tag(block, 'municipi'), + pk: tag(block, 'pk'), + link: tag(block, 'link'), + font: tag(block, 'font'), + }); + } + return out; +} + +/** + * Validate and normalize one Catalonia feed `link` against the publisher + * host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects + * anything else, including a host that merely resembles an allowed one. + * + * SCT's own `RenderService` frame link is a special case, for two stacked + * reasons (both verified 2026-09-15): + * 1. It always 302s to `TransitCamera`, on a Location the upstream + * hardcodes as plain http even when RenderService itself was requested + * over https. fetchWithinHost (media.js) only follows same-origin + * redirects — same scheme included, by design, to stop an upstream + * steering a host-pinned request into a plaintext downgrade — so that + * hop is refused and the camera would render as a placeholder. + * 2. mct.gencat.cat's TLS setup itself does not complete a handshake with + * Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it + * is reachable over https from other clients (e.g. curl) — an upstream + * server misconfiguration this proxy cannot route around. + * TransitCamera answers the frame directly over **http**, no redirect, and + * that is the only transport this host actually serves to this runtime — so + * this builds that URL itself from RenderService's `sctidcam` id instead of + * registering the (https, then-redirecting) RenderService link. + * + * @param {string} rawUrl + * @returns {string} Normalized URL, or '' if not accepted. + */ +export function normalizeCataloniaImageUrl(rawUrl) { + let parsed; + try { + parsed = new URL(String(rawUrl || '').trim()); + } catch { + return ''; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return ''; + + if (parsed.hostname === 'mct.gencat.cat') { + const camId = parsed.searchParams.get('sctidcam'); + if (!camId) return ''; + const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera'); + direct.searchParams.set('nom', camId); + direct.searchParams.set('visualitzacio', 'imatge'); + return direct.toString(); + } + + parsed.protocol = 'https:'; + return parsed.toString(); +} + +/** + * Human label for one Catalonia camera: " ()", + * skipping the parenthetical when the municipality is already named in the + * road/place text or when one half is missing. + * + * @param {string} carretera + * @param {string} municipi + * @returns {string} + */ +export function cataloniaCameraName(carretera, municipi) { + const road = String(carretera || '').trim(); + const city = String(municipi || '').trim(); + if (road && city) { + return road.toLowerCase().includes(city.toLowerCase()) + ? road + : `${road} (${city})`; + } + return road || city || 'Càmera de trànsit'; +} + +/** + * Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one + * WFS/GML XML list. The feed carries a `font` field naming the real + * publisher per camera; only SCT's own highway cameras and the hotlinked + * Barcelona (IMI) and Terrassa municipal cameras are kept, each confirmed to + * return a real frame — frame URLs are checked against a host allowlist + * (CATALONIA_IMAGE_HOSTS), so a row from any other publisher is dropped. + * Each non-SCT camera carries its publisher as a `credit` alongside the + * shared `provider`. The feed carries no heading, so every camera takes the + * id-hash fallback, low-confidence pose (same as TfL and Fintraffic). + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadCataloniaSourcesFromOpenData() { + try { + const resp = await fetch(CATALONIA_CAMERAS_URL, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] Catalonia camera download failed:', resp.status); + return []; + } + const xml = await resp.text(); + const rows = parseCataloniaXml(xml); + if (!rows.length) return []; + + const cameras = []; + for (const row of rows) { + if (!row.fid) continue; + if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue; + const imageUrl = normalizeCataloniaImageUrl(row.link); + if (!imageUrl) continue; + + const font = row.font.trim(); + const cameraId = `cat-${row.fid + .replace(/^cameres\.fid-/, '') + .replace(/[^a-zA-Z0-9]/g, '')}`; + + cameras.push({ + id: cameraId, + name: cataloniaCameraName(row.carretera, row.municipi), + city: row.municipi.trim() || 'Catalunya', + cityId: 'catalunya', + provider: 'Servei Català de Trànsit', + lat: row.lat, + lon: row.lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'catalonia-open-data', + license: + "Generalitat de Catalunya — Llicència oberta d'ús d'informació", + credit: CATALONIA_FONT_CREDIT[font] || '', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_CATALONIA_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS); + console.log( + `[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Catalonia camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvCataloniaSource.test.mjs b/src/data/cctvCataloniaSource.test.mjs new file mode 100644 index 000000000..d9b9f3456 --- /dev/null +++ b/src/data/cctvCataloniaSource.test.mjs @@ -0,0 +1,317 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { CATALONIA_CAMERAS_URL } from '../../server/providers/cctv/constants.js'; +import { + parseCataloniaXml, + normalizeCataloniaImageUrl, + cataloniaCameraName, + loadCataloniaSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** One `` block matching the real feed's shape. */ +function cataloniaFeature({ + fid, + lon, + lat, + carretera = '', + municipi = '', + pk = '', + link = '', + font = '', +}) { + const pkTag = pk ? `${pk}` : ''; + return ( + `` + + `` + + `${lon},${lat}` + + `` + + `${carretera}` + + `${municipi}${pkTag}` + + `${link}` + + `${font}` + + `` + ); +} + +function cataloniaFeed(features) { + return ( + '' + + features.join('') + + '' + ); +} + +test('parseCataloniaXml extracts every field from a feature block', () => { + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', + lon: '2.1849528', + lat: '41.45989301', + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + ]); + const rows = parseCataloniaXml(xml); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', + lon: 2.1849528, + lat: 41.45989301, + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }); +}); + +test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => { + // RenderService always 302s to a hardcoded http:// Location (which + // fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS + // setup fails the handshake with Node's fetch regardless — TransitCamera + // over plain http is the only transport that actually works here, so we + // build that URL from RenderService's sctidcam id instead of registering + // the redirector. + assert.equal( + normalizeCataloniaImageUrl( + 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + ), + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', + ); + assert.equal( + normalizeCataloniaImageUrl( + 'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif', + ), + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge', + 'an already-https RenderService link is rewritten the same way', + ); + assert.equal( + normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'), + '', + 'a RenderService link with no sctidcam id is rejected', + ); +}); + +test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => { + assert.equal( + normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'), + 'https://www.bcn.cat/transit/imatges/a.gif', + ); + assert.equal( + normalizeCataloniaImageUrl( + 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', + ), + 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', + ); + assert.equal( + normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'), + '', + 'a look-alike host is rejected', + ); + assert.equal( + normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'), + '', + 'any host outside the allowlist is rejected', + ); + assert.equal(normalizeCataloniaImageUrl(''), ''); + assert.equal(normalizeCataloniaImageUrl('not a url'), ''); +}); + +test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => { + assert.equal( + cataloniaCameraName('C-58', 'Nus Trinitat'), + 'C-58 (Nus Trinitat)', + ); + assert.equal( + cataloniaCameraName('Plaça Urquinaona', 'Barcelona'), + 'Plaça Urquinaona (Barcelona)', + ); + assert.equal( + cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'), + 'Barcelona Ronda Litoral', + ); + assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit'); + assert.equal(cataloniaCameraName('C-58', ''), 'C-58'); +}); + +test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => { + quiet(t); + withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-1', + lon: '2.1849528', + lat: '41.45989301', + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + cataloniaFeature({ + fid: 'cameres.fid-2', + lon: '2.17228212', + lat: '41.38890733', + carretera: 'Plaça Urquinaona', + municipi: 'Barcelona', + link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1', + font: 'IMI', + }), + cataloniaFeature({ + fid: 'cameres.fid-3', + lon: '2.0111', + lat: '41.5636', + carretera: 'Cam02', + municipi: 'Terrassa', + link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1', + font: 'Terrassa', + }), + // Rejected: link host not in the allowlist. + cataloniaFeature({ + fid: 'cameres.fid-4', + lon: '2.0', + lat: '41.5', + link: 'https://example.com/fake.gif', + font: 'SCT', + }), + // Rejected: outside the Catalonia bounding box. + cataloniaFeature({ + fid: 'cameres.fid-5', + lon: '-3.7', + lat: '40.4', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif', + font: 'SCT', + }), + ]); + return new Response(xml, { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }); + }); + + const cameras = await loadCataloniaSourcesFromOpenData(); + + assert.deepEqual(requested, [CATALONIA_CAMERAS_URL]); + assert.deepEqual( + cameras.map((camera) => camera.id).sort(), + ['cat-1', 'cat-2', 'cat-3'], + 'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded', + ); + + const sct = cameras.find((c) => c.id === 'cat-1'); + assert.equal(sct.name, 'C-58 (Nus Trinitat)'); + assert.equal(sct.provider, 'Servei Català de Trànsit'); + assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit'); + assert.equal( + sct.url, + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', + 'registers the direct, non-redirecting http frame URL, not the RenderService redirector', + ); + assert.equal(sct.groundElevationM, 80); + assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading'); + + const bcn = cameras.find((c) => c.id === 'cat-2'); + assert.equal(bcn.credit, 'Ajuntament de Barcelona'); + assert.equal(bcn.city, 'Barcelona'); + assert.equal(bcn.groundElevationM, 20); + + const terrassa = cameras.find((c) => c.id === 'cat-3'); + assert.equal(terrassa.credit, 'Ajuntament de Terrassa'); + assert.equal(terrassa.groundElevationM, 270); +}); + +test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => { + quiet(t); + let respond; + t.mock.method(globalThis, 'fetch', (...args) => respond(...args)); + for (respond of [ + async () => new Response('unavailable', { status: 503 }), + async () => new Response(''), + async () => { + throw new TypeError('fetch failed'); + }, + ]) { + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); + } +}); + +test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => { + quiet(t); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'), + ); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_CALTRANS_DISTRICTS: undefined, + CCTV_TFL_ENABLED: undefined, + CCTV_CATALONIA_ENABLED: undefined, + CCTV_CATALONIA_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + if (String(url) !== CATALONIA_CAMERAS_URL) { + return new Response('unavailable', { status: 503 }); + } + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-1', + lon: '2.1849528', + lat: '41.45989301', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + ]); + return new Response(xml, { status: 200 }); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['cat-1'], + ); + + process.env.CCTV_CATALONIA_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.includes(CATALONIA_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +}); diff --git a/src/data/cctvDriveBcSource.test.mjs b/src/data/cctvDriveBcSource.test.mjs index 617bb1350..a4934af3c 100644 --- a/src/data/cctvDriveBcSource.test.mjs +++ b/src/data/cctvDriveBcSource.test.mjs @@ -17,6 +17,7 @@ import { DEFAULT_TALLINN_MAX_SOURCES, DEFAULT_TARKTEE_MAX_SOURCES, DEFAULT_NSW_MAX_SOURCES, + DEFAULT_CATALONIA_MAX_SOURCES, DRIVEBC_WEBCAMS_URL, } from '../../server/providers/cctv/constants.js'; import { @@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => { DEFAULT_TALLINN_MAX_SOURCES + DEFAULT_TARKTEE_MAX_SOURCES + DEFAULT_NSW_MAX_SOURCES + + DEFAULT_CATALONIA_MAX_SOURCES + 1; assert.ok( packs <= DEFAULT_CCTV_MAX_SOURCES, diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 65ddc2e12..44b2e8ba3 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({ // collectionInterval, 600 s for every station sampled; polling faster only // re-fetches the same JPEG. fintraffic: 10 * 60 * 1000, + // Measured empirically (2026-09-15): polling the same SCT/Terrassa frame + // every 20s showed no change inside 64s but a new frame by ~90-150s later — + // matches the other highway packs' 3-minute cadence. + 'servei català de trànsit': 3 * 60 * 1000, }); /** diff --git a/src/data/cctvLod.test.mjs b/src/data/cctvLod.test.mjs index c6f6e4923..4f5982496 100644 --- a/src/data/cctvLod.test.mjs +++ b/src/data/cctvLod.test.mjs @@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000); // Digitraffic publishes one weathercam frame per 600 s collection interval. assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000); + assert.equal( + staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }), + 180_000, + ); assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000); assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000); assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..c44ac3ca6 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -169,6 +169,14 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'catalonia-cctv', + html: + 'CCTV cameras & frames (Catalonia): ' + + 'Servei Català de Trànsit ' + + '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + + '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From ffbe98b4ac9e269fea098f26eadd01d28a0b4333 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:38:04 +0200 Subject: [PATCH 003/166] test(format-scope): register cctvCataloniaSource.test.mjs for format:check Mechanical addition per CONTRIBUTING.md, following the existing per-pack test-file entries (cctvCalgary.test.mjs, cctvNswSource.test.mjs, etc.). Co-Authored-By: Claude Sonnet 5 --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b86632108..05fe8327a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -489,6 +489,7 @@ "src/data/cctvEstonia.test.mjs", "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", + "src/data/cctvCataloniaSource.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", From 976efed6dc2fa207524def3c90e5cf9683a2323b Mon Sep 17 00:00:00 2001 From: Reloded <37674025+Reloded@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:14:46 +0300 Subject: [PATCH 004/166] feat(cctv): add Lithuania Eismoinfo road cameras --- .env.example | 2 + CHANGELOG.md | 2 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 14 +++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + server/providers/cctv/catalog.js | 6 ++ server/providers/cctv/lithuania.js | 135 ++++++++++++++++++++++++++++ src/data/cctvLithuania.test.mjs | 138 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 ++ 10 files changed, 308 insertions(+) create mode 100644 server/providers/cctv/lithuania.js create mode 100644 src/data/cctvLithuania.test.mjs diff --git a/.env.example b/.env.example index 5bc02df9c..02fdd78b4 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras +# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ab7807e..a9a9e8687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. + - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 850d27e39..a2610887b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -59,6 +59,8 @@ How to read this: ### Notes on the live sources +- **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. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `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. Disable with `CCTV_LITHUANIA_ENABLED=0`; `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. + - **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)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b524a961..9d66da384 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -391,6 +391,20 @@ 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. +## Lithuania CCTV source pack + +The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default. +Two keyless public endpoints provide current snapshot URLs and WGS84 camera +locations; records are joined by ID, with invalid coordinates and off-origin +image URLs rejected. Catalog requests refuse redirects, have a 15-second +deadline and a 4 MiB response cap, and use the existing shared 15-minute cache +and fair catalog allocation. An unavailable source contributes an empty pack. +Frames use the registered-image proxy and retain Via Lietuva attribution. +The images are periodically updated road-condition photographs, not continuous +video; camera poses use the existing low-confidence heading prior. +`CCTV_LITHUANIA_ENABLED=0` disables the pack, and +`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f26e1e71..1bdb1c157 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -85,6 +85,8 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", + "src/data/cctvLithuania.test.mjs", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a5806db67..f40976374 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -156,6 +156,7 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..ca8a849ab 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -4,6 +4,7 @@ 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 { normalizeSourceItem } from './normalize.js'; +import { loadLithuaniaSources } from './lithuania.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -30,6 +31,11 @@ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; * kill switch. */ const LIVE_PACKS = [ + { + name: 'lithuania', + enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'), + load: loadLithuaniaSources, + }, { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, { name: 'caltrans', diff --git a/server/providers/cctv/lithuania.js b/server/providers/cctv/lithuania.js new file mode 100644 index 000000000..df4ee154d --- /dev/null +++ b/server/providers/cctv/lithuania.js @@ -0,0 +1,135 @@ +import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; +import { + cameraDisplayCode, + fallbackHeadingFromId, + prioritizeSources, +} from './normalize.js'; +import { readResponseJsonCapped } from '../common/http.js'; + +const BASE = 'https://eismoinfo.lt/eismoinfo-backend'; +export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`; +// Without lks=true this endpoint returns [latitude, longitude]. The camera +// table's x/y fields use LKS-94 metres and must never be treated as degrees. +export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`; +export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last'; +const ANCHORS = [ + { lat: 54.6872, lon: 25.2797 }, // Vilnius + { lat: 54.8985, lon: 23.9036 }, // Kaunas + { lat: 55.7033, lon: 21.1443 }, // Klaipeda + { lat: 55.9349, lon: 23.3137 }, // Siauliai + { lat: 55.7348, lon: 24.3575 }, // Panevezys +]; + +/** Join public catalogs by ID; register only the official still endpoint. */ +export function lithuaniaCameraToSource(row, feature) { + const id = String(row?.id ?? ''); + if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null; + const point = feature?.points?.[0]?.point; + if (!Array.isArray(point) || point.length !== 2) return null; + const [lat, lon] = point; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < 53.8 || + lat > 56.5 || + lon < 20.8 || + lon > 26.9 + ) + return null; + try { + const url = new URL(row.image); + if ( + url.origin !== 'https://eismoinfo.lt' || + url.username || + url.password || + url.pathname !== FRAME_PATH || + url.searchParams.get('id') !== id + ) + return null; + } catch { + return null; + } + // Reconstruct so extra query parameters cannot alter the request. + const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`; + const cameraId = `lithuania-${id}`; + const name = String(row.name || feature.name || `Lietuva ${id}`) + .trim() + .slice(0, 160); + return { + id: cameraId, + name, + city: 'Lithuania / Lietuva', + cityId: 'lithuania', + provider: 'Via Lietuva / Eismoinfo', + lat, + lon, + // No calibrated pose is published; use the app's low-confidence prior. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'eismoinfo-public', + credit: + 'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +async function readCatalog(url) { + const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Catalog HTTP ${response.status}`); + } + return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal); +} + +/** Two bounded requests per shared catalog refresh, no API key required. */ +export async function loadLithuaniaSources() { + try { + const [rows, layers] = await Promise.all([ + readCatalog(LITHUANIA_CAMERAS_URL), + readCatalog(LITHUANIA_LOCATIONS_URL), + ]); + if (!Array.isArray(rows) || !Array.isArray(layers)) return []; + const features = layers.find((layer) => layer?.layer === 'VKR')?.features; + if (!Array.isArray(features)) return []; + const locations = new Map( + features.filter(Boolean).map((feature) => [String(feature.id), feature]), + ); + const cameras = new Map(); + for (const row of rows) { + const camera = lithuaniaCameraToSource( + row, + locations.get(String(row?.id)), + ); + if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); + } + const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400); + const cap = Number.isFinite(rawCap) + ? Math.max(1, Math.min(1000, Math.floor(rawCap))) + : 400; + const sources = prioritizeSources([...cameras.values()], cap, ANCHORS); + console.log( + `[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`, + ); + return sources; + } catch (error) { + console.warn( + '[CCTV] Lithuania camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvLithuania.test.mjs b/src/data/cctvLithuania.test.mjs new file mode 100644 index 000000000..675e8d251 --- /dev/null +++ b/src/data/cctvLithuania.test.mjs @@ -0,0 +1,138 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lithuaniaCameraToSource, + loadLithuaniaSources, + LITHUANIA_CAMERAS_URL, + LITHUANIA_LOCATIONS_URL, + LITHUANIA_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/lithuania.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +const row = { + id: 72, + name: 'Vilnius A1 10,04', + x: 576154, + y: 6056867, + image: + 'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72', +}; +const feature = { + id: '72', + points: [{ point: [54.642550940833566, 25.17981587877114] }], +}; +const layers = [{ layer: 'VKR', features: [feature] }]; + +test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => { + const camera = lithuaniaCameraToSource(row, feature); + assert.equal(camera.id, 'lithuania-72'); + assert.equal(camera.lat, 54.642550940833566); + assert.equal(camera.lon, 25.17981587877114); + assert.equal(camera.name, 'Vilnius A1 10,04'); + assert.equal(camera.feedType, 'image'); + assert.equal(camera.headingConfidence, 'low'); + assert.equal(camera.url, row.image); + assert.match(camera.credit, /Via Lietuva/); +}); + +test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => { + for (const invalid of [ + null, + { ...feature, id: '8' }, + { ...feature, points: [] }, + { ...feature, points: [{ point: [25.18, 54.64] }] }, + { ...feature, points: [{ point: [576154, 6056867] }] }, + { ...feature, points: [{ point: [null, 25] }] }, + ]) { + assert.equal(lithuaniaCameraToSource(row, invalid), null); + } + for (const image of [ + 'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72', + row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'), + row.image.replace('https://', 'https://user:pass@'), + row.image.replace('id=72', 'id=8'), + 'http://127.0.0.1/private', + '', + null, + ]) { + assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null); + } + assert.equal( + lithuaniaCameraToSource( + { ...row, image: row.image + '&extra=ignored' }, + feature, + ).url, + row.image, + ); + assert.equal(lithuaniaCameraToSource(null, feature), null); +}); + +function mockCatalogs(t) { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url, options) => { + requested.push(String(url)); + if (url === LITHUANIA_CAMERAS_URL) { + assert.equal(options.redirect, 'manual'); + return Response.json([row, row, { ...row, id: 999 }]); + } + if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers); + return Response.json([]); + }); + return requested; +} + +test('loader deduplicates and omits cameras without matching published coordinates', async (t) => { + const requested = mockCatalogs(t); + assert.deepEqual( + (await loadLithuaniaSources()).map((c) => c.id), + ['lithuania-72'], + ); + assert.deepEqual( + requested.sort(), + [LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(), + ); +}); + +test('catalog includes Lithuania by default and respects the disable switch', async (t) => { + const requested = mockCatalogs(t); + const saved = process.env.CCTV_LITHUANIA_ENABLED; + try { + delete process.env.CCTV_LITHUANIA_ENABLED; + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.equal( + sources.find((c) => c.id === 'lithuania-72')?.cityId, + 'lithuania', + ); + requested.length = 0; + process.env.CCTV_LITHUANIA_ENABLED = '0'; + const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.ok(!disabled.some((c) => c.cityId === 'lithuania')); + assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL)); + } finally { + if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED; + else process.env.CCTV_LITHUANIA_ENABLED = saved; + } +}); + +test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => { + t.mock.method(console, 'warn', () => {}); + for (const makeResponse of [ + () => new Response('', { status: 503 }), + () => + new Response('', { + status: 302, + headers: { Location: 'http://127.0.0.1/' }, + }), + () => new Response('invalid json'), + () => Response.json({ unexpected: true }), + () => + new Response('[]', { + headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) }, + }), + ]) { + t.mock.method(globalThis, 'fetch', async () => makeResponse()); + assert.deepEqual(await loadLithuaniaSources(), []); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..d5ee02c54 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -163,6 +163,12 @@ export const DATA_CREDITS = [ 'digitraffic.fi, ' + 'license CC BY 4.0', }, + { + key: 'lithuania-cctv', + html: + 'Road-camera stills (Lithuania): ' + + 'Via Lietuva / Eismoinfo; periodically updated images', + }, { key: 'calgary-cctv', html: From ee3e3c7a414158387d6334528f56d1cb1af92b0b Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 10:36:49 +0200 Subject: [PATCH 005/166] feat(cctv): WSDOT highway camera source pack (Washington State) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Washington State's highway cameras as a keyless source pack, from the Esri JSON layer WSDOT's own Travel Center map consumes (data.wsdot.wa.gov/travelcenter/Cameras.json, ~1,700 point features). Closes #624. - Geometry arrives in EPSG:3857; converted to WGS84 on load and bounded to a Washington bounding box. - 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; those are skipped rather than proxied. 1,630 cameras survive the filters against the live feed. - CompassDirection letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows carry no single facing and use the shared id-hash fallback at low confidence, with the usual two pose personalities. - The feed body carries Windows-1252 bytes (en-dashes in titles) with no charset declaration, so it is decoded explicitly before parsing. - WSDOT's layer metadata marks the feed "low volume": the 15-minute catalog cache and per-request frame fetches respect that, frames are never stored or redistributed, and the WSDOT credit is registered in the attribution popover. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required. - Defaults: 250 cameras nearest Seattle and Spokane; CCTV_WSDOT_MAX_SOURCES changes the cap, CCTV_WSDOT_ENABLED=0 turns the pack off. - Tests: Mercator conversion against a real layer feature, official-host and malformed-row filtering, compass mapping and both pose personalities, cp1252 decoding, env cap, upstream-failure fallback. --- DATA_SOURCES.md | 2 + server/providers/cctv/catalog.js | 6 ++ server/providers/cctv/constants.js | 18 ++++ server/providers/cctv/sources.js | 134 +++++++++++++++++++++++ src/data/cctvWsdotSource.test.mjs | 167 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 10 ++ 6 files changed, 337 insertions(+) create mode 100644 src/data/cctvWsdotSource.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b1439230e..73eaf8a1f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | | **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" | +| **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) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -89,6 +90,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. +- **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, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows 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 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. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..3241cacd5 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -18,6 +18,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, + loadWsdotSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -92,6 +93,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), load: loadDelDOTSourcesFromOpenData, }, + { + name: 'wsdot', + enabled: () => envEnabled('CCTV_WSDOT_ENABLED'), + load: loadWsdotSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index da0f2fcda..3ab8ce9cb 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -244,6 +244,24 @@ export const DELDOT_ANCHORS = [ { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) ]; + +/** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the + * official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator), + * ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use, + * which the 15-minute catalog cache respects. */ +export const WSDOT_CAMERAS_URL = + 'https://data.wsdot.wa.gov/travelcenter/Cameras.json'; +/** Only frames on WSDOT's own image host are registered; the catalog also + * lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) + * whose own terms apply. */ +export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/'; +export const DEFAULT_WSDOT_MAX_SOURCES = 250; +/** Prioritization anchors: downtown Seattle and Spokane. */ +export const WSDOT_ANCHORS = [ + { lat: 47.6062, lon: -122.3321 }, // Seattle + { lat: 47.6588, lon: -117.426 }, // Spokane +]; + /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index ad8a883da..1124b187f 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -59,6 +59,10 @@ import { DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, DELDOT_ANCHORS, + WSDOT_CAMERAS_URL, + WSDOT_IMAGE_ORIGIN, + DEFAULT_WSDOT_MAX_SOURCES, + WSDOT_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -1701,3 +1705,133 @@ export async function loadDelDOTSourcesFromOpenData() { return []; } } + +/** WSDOT compass letters as headings in degrees. `B` (both directions), `O` + * (other) and null carry no single facing and fall through to the id hash. */ +const WSDOT_COMPASS_HEADINGS = Object.freeze({ + N: 0, + E: 90, + S: 180, + W: 270, +}); + +/** + * Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees. + * The WSDOT camera layer publishes point geometry in wkid 102100. + * @param {number} x - Web Mercator metres east. + * @param {number} y - Web Mercator metres north. + * @returns {{lat: number, lon: number}} WGS84 degrees. + */ +export function webMercatorToWgs84(x, y) { + const R = 6378137; + const lon = (x / R) * (180 / Math.PI); + const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI); + return { lat, lon }; +} + +/** Rough Washington State bounding box (with the Columbia River border cams). */ +function isLikelyWashingtonCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 45.3 && + lat <= 49.3 && + lon >= -125 && + lon <= -116.5 + ); +} + +/** + * Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON + * layer on WSDOT's official open-data host (the same layer the WSDOT Travel + * Center map consumes; a keyed Traveler Information API serves the identical + * population for higher-volume use). Only cameras with a positive integer id, + * finite in-state coordinates, and a frame on WSDOT's own image host are + * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, + * lodges, airports) are skipped rather than proxied under someone else's + * terms. `CompassDirection` letters give a high-confidence heading; `B` + * (both directions, the majority) and `O`/null fall back to the id hash. + * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset + * declaration, so the body is decoded explicitly before parsing. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadWsdotSourcesFromOpenData() { + try { + const resp = await fetch(WSDOT_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] WSDOT camera download failed:', resp.status); + return []; + } + const payload = JSON.parse( + new TextDecoder('windows-1252').decode(await resp.arrayBuffer()), + ); + const rows = Array.isArray(payload?.features) ? payload.features : []; + + const cameras = []; + for (const row of rows) { + const attrs = row?.attributes; + const rawId = attrs?.CameraID; + if (!Number.isSafeInteger(rawId) || rawId <= 0) continue; + const imageUrl = String(attrs.ImageURL || '').trim(); + if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue; + const x = row?.geometry?.x; + const y = row?.geometry?.y; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const { lat, lon } = webMercatorToWgs84(x, y); + if (!isLikelyWashingtonCoordinate(lat, lon)) continue; + + const cameraId = `wsdot-${rawId}`; + const heading = + WSDOT_COMPASS_HEADINGS[ + String(attrs.CompassDirection || '') + .trim() + .toUpperCase() + ]; + const hasHeading = Number.isFinite(heading); + cameras.push({ + id: cameraId, + name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`, + city: 'Washington', + cityId: 'washington-state', + provider: 'WSDOT', + lat, + lon, + headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + // Same two pose personalities as the other packs: raw priors that the + // client's ground snap and manual calibration refine. + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'wsdot-open-data', + license: 'WSDOT Travel Center camera data (public low-volume feed)', + }); + } + + const maxRaw = Number( + process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_WSDOT_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS); + console.log( + `[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] WSDOT camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvWsdotSource.test.mjs b/src/data/cctvWsdotSource.test.mjs new file mode 100644 index 000000000..0459f3c72 --- /dev/null +++ b/src/data/cctvWsdotSource.test.mjs @@ -0,0 +1,167 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_WSDOT_MAX_SOURCES, + WSDOT_CAMERAS_URL, +} from '../../server/providers/cctv/constants.js'; +import { + loadWsdotSourcesFromOpenData, + webMercatorToWgs84, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */ +function mercator(lat, lon) { + const R = 6378137; + return { + x: (lon * Math.PI * R) / 180, + y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)), + }; +} + +/** A `Cameras.json` point feature carrying the fields the loader reads. */ +function wsdotFeature(id, lat, lon, overrides = {}) { + const { attributes = {}, geometry } = overrides; + return { + attributes: { + CameraID: id, + CameraTitle: `Camera ${id}`, + CompassDirection: 'N', + ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`, + ...attributes, + }, + geometry: geometry === undefined ? mercator(lat, lon) : geometry, + }; +} + +/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */ +function cp1252Response(payload) { + const text = JSON.stringify(payload); + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + // U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships. + bytes[i] = code === 0x2013 ? 0x96 : code; + } + return new Response(bytes, { + headers: { 'Content-Type': 'application/json' }, + }); +} + +test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => { + // Real feature from the live layer: I-5 at the Interstate Bridge. + const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545); + assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`); + assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`); +}); + +test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return cp1252Response({ + features: [ + wsdotFeature(1001, 47.6097, -122.3331, { + attributes: { + // The 0x2013 below is serialized as the feed's 0x96 byte. + CameraTitle: 'I-5 – Seattle, looking south', + CompassDirection: 'S', + }, + }), + wsdotFeature(9059, 47.66, -117.43, { + attributes: { CompassDirection: 'B' }, + }), + // Partner-hosted frame (Oregon DOT): skipped, never proxied. + wsdotFeature(1002, 45.62, -122.675, { + attributes: { + ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg', + }, + }), + // Bad rows: non-integer id, missing geometry, out-of-state point. + wsdotFeature('1003', 47.6, -122.3), + wsdotFeature(1004, 47.6, -122.3, { geometry: null }), + wsdotFeature(1005, 34.05, -118.24), + ], + }); + }); + + const cameras = await loadWsdotSourcesFromOpenData(); + + assert.deepEqual(requested, [WSDOT_CAMERAS_URL]); + // Exactly the two valid official-host cameras survive; the partner-hosted + // frame and the malformed rows are dropped. + assert.deepEqual(cameras.map((camera) => camera.id).sort(), [ + 'wsdot-1001', + 'wsdot-9059', + ]); + + const seattle = cameras.find((camera) => camera.id === 'wsdot-1001'); + assert.equal(seattle.name, 'I-5 – Seattle, looking south'); + assert.equal(seattle.provider, 'WSDOT'); + assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg'); + assert.equal(seattle.snapshotUrl, seattle.url); + assert.equal(seattle.feedType, 'image'); + assert.equal(seattle.headingDeg, 180); + assert.equal(seattle.headingConfidence, 'high'); + assert.equal(seattle.fovDeg, 56); + assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001); + assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001); + + // "Both directions" carries no single facing: id-hash fallback at low + // confidence, with the low-confidence pose personality. + const spokane = cameras.find((camera) => camera.id === 'wsdot-9059'); + assert.equal(spokane.headingConfidence, 'low'); + assert.equal(spokane.fovDeg, 44); + assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360); +}); + +test('WSDOT loader honors the max-sources cap', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' }); + const features = []; + for (let i = 0; i < 24; i += 1) { + features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05)); + } + t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features })); + + const cameras = await loadWsdotSourcesFromOpenData(); + assert.equal(cameras.length, 8); +}); + +test('WSDOT loader defaults its cap and survives upstream failure', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250); + + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 503 }), + ); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); + + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadWsdotSourcesFromOpenData(), []); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index ab9213c26..ea7adeb78 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -293,6 +293,16 @@ export const DATA_CREDITS = [ 'Open Government Licence – British Columbia. ' + 'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.', }, + { + key: 'wsdot-cctv', + html: + 'CCTV cameras & frames (Washington State): ' + + 'Washington State Department of Transportation ' + + '(Travel Center), ' + + 'used per the ' + + 'WSDOT travel information disclaimer ' + + '(low-volume public feed; catalog cached, frames proxied on demand)', + }, { key: 'tallinn-cctv', html: From 5b00dd0980480c5815c6fea346f0d892f8834d16 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 10:45:43 +0200 Subject: [PATCH 006/166] feat(cctv): King County road camera source pack (Washington State) Adds King County DOT Road Services' cameras as a keyless source pack, from the county's public ArcGIS FeatureServer layer ("King County and WSDOT Traffic Cameras"). 59 live cameras against the current feed. - The `outFields` list is explicit on purpose: the layer also carries asset-administration columns (including staff names) that must never be fetched. - Only live, active cameras with frames on the county's own info.kingcounty.gov image handler are registered; the feed writes http:// and the handler serves HTTPS, so URLs are upgraded and pinned before registration. WSDOT-owned rows are skipped so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry their owner as the per-camera credit, DriveBC-style. - The layer publishes no facing: headings use the shared id-hash fallback at low confidence, correctable with the calibration gizmo. - Uniquely among the packs, the county publishes each camera's hardware Manufacturer/Model (Cohu 3950/3965, Bosch AUTODOME IP 5000i, Axis Q6135-LE, Siqura PD910). The model string is whitespace-collapsed (the feed carries stray newlines) and carried on the source as `model`, with an additive passthrough in normalizeSourceItem (same pattern as poseSource) so identified-hardware consumers can use it. All 59 live cameras carry a model value. - Attribution per the KCGIS Center terms, registered in the attribution popover and documented in DATA_SOURCES.md. - Defaults: the whole catalog loads (125 cap); CCTV_KINGCOUNTY_MAX_SOURCES changes the cap, CCTV_KINGCOUNTY_ENABLED=0 turns the pack off. - Tests: filtering (owner/live/status/host/geometry/bbox), HTTPS upgrade, model whitespace-collapse and passthrough, partner-city credit, env cap, upstream-failure fallback, and normalizeSourceItem model semantics. --- DATA_SOURCES.md | 2 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 19 +++ server/providers/cctv/normalize.js | 8 ++ server/providers/cctv/sources.js | 126 ++++++++++++++++++ src/data/cctvKingCountySource.test.mjs | 171 +++++++++++++++++++++++++ src/data/dataCredits.js | 9 ++ 7 files changed, 341 insertions(+) create mode 100644 src/data/cctvKingCountySource.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index b1439230e..c43ae7728 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | | **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" | +| **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) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -89,6 +90,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. +- **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 so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry 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); the model string is carried on the source as `model` for identified-hardware consumers. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index ae0ab9afa..ec5cf05b5 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -18,6 +18,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, + loadKingCountySourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -92,6 +93,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_DELDOT_ENABLED'), load: loadDelDOTSourcesFromOpenData, }, + { + name: 'kingcounty', + enabled: () => envEnabled('CCTV_KINGCOUNTY_ENABLED'), + load: loadKingCountySourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index da0f2fcda..a25dc887f 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -244,6 +244,25 @@ export const DELDOT_ANCHORS = [ { lat: 39.1582, lon: -75.5244 }, // Dover (Kent) { lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex) ]; + +/** King County, WA road cameras: keyless public ArcGIS FeatureServer layer + * ("King County and WSDOT Traffic Cameras", King County DOT Road Services). + * GeoJSON with WGS84 point geometry. `outFields` is an explicit list on + * purpose: the layer also carries asset-administration columns (including + * staff names in `ModifiedBy`) that must never be fetched. Uniquely among the + * packs, the county publishes each camera's hardware `Manufacturer`/`Model` + * (Cohu 3950/3965, Bosch AUTODOME, Axis Q6135-LE, …), carried through as the + * source's `model`. */ +export const KING_COUNTY_CAMERAS_URL = + 'https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,Description,ImageURL,CamRegion,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson'; +/** Frames come only from the county's own image handler host. */ +export const KING_COUNTY_IMAGE_ORIGIN = 'https://info.kingcounty.gov/'; +/** The whole catalog is ~125 rows (~59 live county cameras); no thinning by + * default. */ +export const DEFAULT_KING_COUNTY_MAX_SOURCES = 125; +/** Prioritization anchor: Renton, roughly central to the county road network. */ +export const KING_COUNTY_ANCHORS = [{ lat: 47.4829, lon: -122.2171 }]; + /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..17903335d 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -516,5 +516,13 @@ export function normalizeSourceItem(item) { // badge can distinguish them from raw automated priors (e.g. Austin Open // Data, which never sets this field). Passed through as-is to the client. poseSource: item.poseSource === 'curated' ? 'curated' : undefined, + // Optional hardware-model passthrough (additive, like poseSource): packs + // whose catalog publishes the camera's hardware model — King County's + // layer carries Manufacturer/Model per camera — pass it through so + // identified-hardware consumers can use it. Absent everywhere else. + model: + typeof item.model === 'string' && item.model.trim() + ? item.model.trim() + : undefined, }; } diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index ad8a883da..e92a8a265 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -59,6 +59,10 @@ import { DELDOT_CCTV_URL, DEFAULT_DELDOT_MAX_SOURCES, DELDOT_ANCHORS, + KING_COUNTY_CAMERAS_URL, + KING_COUNTY_IMAGE_ORIGIN, + DEFAULT_KING_COUNTY_MAX_SOURCES, + KING_COUNTY_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -1701,3 +1705,125 @@ export async function loadDelDOTSourcesFromOpenData() { return []; } } + +/** Rough King County, WA bounding box. */ +function isLikelyKingCountyCoordinate(lat, lon) { + return ( + Number.isFinite(lat) && + Number.isFinite(lon) && + lat >= 47.0 && + lat <= 48.0 && + lon >= -123.1 && + lon <= -121.0 + ); +} + +/** + * Fetch King County road cameras (Washington State). Keyless: one public + * ArcGIS FeatureServer GeoJSON query (King County DOT Road Services), with an + * explicit `outFields` list so the layer's asset-administration columns are + * never fetched. Only cameras that are live and active (`Live`, + * `CurrentStatus`) with a positive integer id, finite in-county coordinates, + * and a frame on the county's own image handler are kept. WSDOT-owned rows in + * the layer are skipped — the statewide WSDOT pack is their home — while the + * handful of city-owned cameras the county hosts (Redmond, Sammamish, + * Newcastle, Snoqualmie) keep their owner as the per-camera credit, + * DriveBC-style. The layer publishes no facing, so every heading is the id + * hash at low confidence. + * + * Uniquely among the packs, the county publishes each camera's hardware + * `Manufacturer`/`Model`; the model string (whitespace-collapsed — the feed + * carries stray newlines) rides along as the source's `model` for + * identified-hardware consumers. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadKingCountySourcesFromOpenData() { + try { + const resp = await fetch(KING_COUNTY_CAMERAS_URL, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] King County camera download failed:', resp.status); + return []; + } + const payload = await resp.json(); + const rows = Array.isArray(payload?.features) ? payload.features : []; + + const cameras = []; + for (const row of rows) { + const attrs = row?.properties; + const rawId = attrs?.AssetID; + if (!Number.isSafeInteger(rawId) || rawId <= 0) continue; + if (attrs.Live !== 1) continue; + if (String(attrs.CurrentStatus || '').trim() !== 'Active') continue; + const owner = String(attrs.Owner || '').trim(); + // WSDOT-owned rows belong to the statewide WSDOT pack; skipping them + // here keeps one camera from appearing twice on the globe. + if (owner === 'WSDOT') continue; + // The handler is served over HTTPS; the feed still writes http://. + const imageUrl = String(attrs.ImageURL || '') + .trim() + .replace(/^http:\/\/info\.kingcounty\.gov\//, KING_COUNTY_IMAGE_ORIGIN); + if (!imageUrl.startsWith(KING_COUNTY_IMAGE_ORIGIN)) continue; + const [lon, lat] = Array.isArray(row?.geometry?.coordinates) + ? row.geometry.coordinates + : []; + if (!isLikelyKingCountyCoordinate(lat, lon)) continue; + + const cameraId = `kingcounty-${rawId}`; + const model = String(attrs.Model || '') + .replace(/\s+/g, ' ') + .trim(); + const camera = { + id: cameraId, + name: + String(attrs.Location || '').trim() || `King County camera ${rawId}`, + city: 'King County', + cityId: 'king-county', + provider: 'King County Road Services', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + // The low-confidence pose personality shared by the other packs. + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'kingcounty-open-data', + license: 'King County GIS open data (KCGIS Center terms of use)', + credit: owner && owner !== 'King County' ? owner : '', + }; + if (model) camera.model = model; + cameras.push(camera); + } + + const maxRaw = Number( + process.env.CCTV_KINGCOUNTY_MAX_SOURCES || + DEFAULT_KING_COUNTY_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) + : DEFAULT_KING_COUNTY_MAX_SOURCES; + const prioritized = prioritizeSources( + cameras, + maxCount, + KING_COUNTY_ANCHORS, + ); + console.log( + `[CCTV] Loaded King County camera sources: ${cameras.length} published (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] King County camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvKingCountySource.test.mjs b/src/data/cctvKingCountySource.test.mjs new file mode 100644 index 000000000..544b4f9ac --- /dev/null +++ b/src/data/cctvKingCountySource.test.mjs @@ -0,0 +1,171 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_KING_COUNTY_MAX_SOURCES, + KING_COUNTY_CAMERAS_URL, +} from '../../server/providers/cctv/constants.js'; +import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js'; +import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** A FeatureServer GeoJSON feature carrying the fields the loader reads. */ +function kingCountyFeature(id, lat, lon, overrides = {}) { + const { properties = {}, geometry } = overrides; + return { + type: 'Feature', + geometry: + geometry === undefined + ? { type: 'Point', coordinates: [lon, lat] } + : geometry, + properties: { + AssetID: id, + Location: `Road ${id} at Test Ave`, + ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`, + Live: 1, + Model: '3950', + Manufacturer: 'Cohu', + Owner: 'King County', + CurrentStatus: 'Active', + ...properties, + }, + }; +} + +test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => { + quiet(t); + withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + return Response.json({ + type: 'FeatureCollection', + features: [ + kingCountyFeature(56, 47.70059, -122.02443, { + properties: { + Location: 'NE Novelty Hill Road at Trilogy Parkway NE', + // The live layer carries stray whitespace in model strings. + Model: 'Q6135-LE\n', + Manufacturer: 'Axis', + }, + }), + // City-owned camera the county hosts: kept, owner becomes the credit. + kingCountyFeature(90, 47.674, -122.119, { + properties: { Owner: 'City of Redmond', Model: null }, + }), + // WSDOT-owned row: the statewide WSDOT pack is its home. + kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }), + // Off-host frame, not live, retired, bad id, bad geometry, out of county. + kingCountyFeature(71, 47.6, -122.3, { + properties: { ImageURL: 'https://example.com/cam71.jpg' }, + }), + kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }), + kingCountyFeature(73, 47.6, -122.3, { + properties: { CurrentStatus: 'Retired' }, + }), + kingCountyFeature('74', 47.6, -122.3), + kingCountyFeature(75, 47.6, -122.3, { geometry: null }), + kingCountyFeature(76, 34.05, -118.24), + ], + }); + }); + + const cameras = await loadKingCountySourcesFromOpenData(); + + assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]); + assert.deepEqual(cameras.map((camera) => camera.id).sort(), [ + 'kingcounty-56', + 'kingcounty-90', + ]); + + const novelty = cameras.find((camera) => camera.id === 'kingcounty-56'); + assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE'); + assert.equal(novelty.provider, 'King County Road Services'); + assert.equal( + novelty.url, + 'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg', + ); + assert.equal(novelty.snapshotUrl, novelty.url); + assert.equal(novelty.feedType, 'image'); + // Published hardware model rides along, whitespace-collapsed. + assert.equal(novelty.model, 'Q6135-LE'); + assert.equal(novelty.credit, ''); + // No facing in the layer: id-hash fallback at low confidence. + assert.equal(novelty.headingConfidence, 'low'); + assert.equal(novelty.fovDeg, 44); + assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360); + + const redmond = cameras.find((camera) => camera.id === 'kingcounty-90'); + assert.equal(redmond.credit, 'City of Redmond'); + assert.equal(redmond.model, undefined); +}); + +test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => { + const withModel = normalizeSourceItem({ + id: 'kingcounty-56', + lat: 47.7, + lon: -122.0, + model: ' Q6135-LE ', + }); + assert.equal(withModel.model, 'Q6135-LE'); + + const withoutModel = normalizeSourceItem({ + id: 'austin-1', + lat: 30.3, + lon: -97.7, + }); + assert.equal(withoutModel.model, undefined); + assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined); +}); + +test('King County loader honors the max-sources cap', async (t) => { + quiet(t); + withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' }); + const features = []; + for (let i = 0; i < 24; i += 1) { + features.push( + kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02), + ); + } + t.mock.method(globalThis, 'fetch', async () => + Response.json({ type: 'FeatureCollection', features }), + ); + + const cameras = await loadKingCountySourcesFromOpenData(); + assert.equal(cameras.length, 8); +}); + +test('King County loader defaults its cap and survives upstream failure', async (t) => { + quiet(t); + withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined }); + assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125); + + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 503 }), + ); + assert.deepEqual(await loadKingCountySourcesFromOpenData(), []); + + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadKingCountySourcesFromOpenData(), []); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index ab9213c26..6c3a2507e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -201,6 +201,15 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'kingcounty-cctv', + html: + 'CCTV cameras & frames (King County, WA): King County Road Services — ' + + 'kingcounty.gov, ' + + 'per the ' + + 'KCGIS Center terms. ' + + 'A few cameras are supplied by partner cities (Redmond, Sammamish, Newcastle, Snoqualmie); each names its owner in the CCTV panel.', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From ccdf8f0dc287129bf0e45158807cec26bf478bff Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Mon, 14 Sep 2026 13:45:20 +0200 Subject: [PATCH 007/166] feat(cctv): enrich identified cameras with datasheet FOV + specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CCTV source pack (or a seed) names its hardware `model`, resolve it against a camera-model spec table: the coverage frustum then uses the manufacturer's horizontal FOV instead of the pose estimate, and the HUD meta line gains the model's resolution, night-vision, and PTZ specs. Cameras with no identified model are unchanged — unknown never implies anything, matching the layer's existing honesty about RAW PRIOR poses. Spec records are vendored verbatim from the CCTV Camera Database (https://github.com/ch-bas/cctv-camera-database, CC0-1.0), an open dataset of 12,000+ camera models transcribed from manufacturer datasheets. This lands a small starter table plus the join + parsing helpers; source packs that publish their hardware can grow it. Records describe camera products (optics, resolution, night vision) only — no endpoints or access data. - src/layers/cctv/modelSpecs.js: spec table, case-insensitive/alias lookup, wide-end FOV parser, HUD summary formatter - src/layers/cctv/modelSpecs.test.mjs: lookup/alias/unknown, FOV parsing across the real fov string shapes, summary rendering - src/layers/cctv/catalog.js: apply enrichment in both catalog builders - src/layers/cctv/presentation.js: label a datasheet FOV, append the spec summary, and pass fovSource + spec through the public camera state --- src/layers/cctv/catalog.js | 27 ++++++ src/layers/cctv/modelSpecs.js | 129 ++++++++++++++++++++++++++++ src/layers/cctv/modelSpecs.test.mjs | 63 ++++++++++++++ src/layers/cctv/presentation.js | 11 ++- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/layers/cctv/modelSpecs.js create mode 100644 src/layers/cctv/modelSpecs.test.mjs diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..be6dc7e90 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,25 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; + +/** + * Applies datasheet enrichment to a camera in place: when it names a known + * hardware `model`, the manufacturer's horizontal FOV replaces the pose + * estimate and the spec record is attached for the HUD. Unknown models — the + * common case — change nothing. + * @param {Object} camera - Camera being built (mutated). + * @param {string} [modelName] - Hardware model from a source or seed. + * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. + */ +function applyModelEnrichment(camera, modelName, clamp) { + const spec = modelName ? lookupModelSpec(modelName) : null; + if (!spec) return; + const fov = horizontalFovDeg(spec); + if (fov) { + camera.fovDeg = clamp(fov, 20, 125); + camera.fovSource = 'datasheet'; + } + camera.spec = spec; +} export function createCatalog({ state: layerState, services, parts, source }) { const { CITY_POIS } = services.locations; @@ -45,6 +66,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; + applyModelEnrichment(camera, seed.model, parts.model.clamp); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -205,6 +227,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { : null, poseSource, }; + applyModelEnrichment( + camera, + source.model || seed?.model, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js new file mode 100644 index 000000000..e9bb48856 --- /dev/null +++ b/src/layers/cctv/modelSpecs.js @@ -0,0 +1,129 @@ +/** + * @module cctvModelSpecs + * + * Hardware-model spec enrichment for the CCTV layer. + * + * When a catalog camera names its hardware model (source packs may set + * `model`; hand-authored seeds may too), the model resolves to a datasheet + * record: the camera's frustum then uses the manufacturer's horizontal FOV + * instead of an estimate, and the HUD meta line gains the model's real specs. + * Cameras without an identified model are untouched — unknown never implies + * anything. + * + * Spec records are vendored verbatim from the CCTV Camera Database + * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset + * of 12,000+ camera models transcribed from manufacturer datasheets (84% with + * FOV). This module ships a small starter table; city packs that publish + * their hardware models can grow it, or vendor the full extract. + * + * Scope guardrail: records describe camera PRODUCTS (optics, resolution, + * night vision) — never endpoints, stream paths, or access/config data. + */ + +/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +const RECORDS = [ + { + id: 'axis-q6135-le', + brand: 'Axis', + model: 'Q6135-LE', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6135-le/', + aliases: ['01959-004'], + fov_deg: '58.3-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 250 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hanwha-xnp-6400rw', + brand: 'Hanwha', + model: 'XNP-6400RW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/', + aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'], + fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 200 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hikvision-ds-2cd2087g3-li2uy', + brand: 'Hikvision', + model: 'DS-2CD2087G3-LI2UY', + type: 'bullet', + url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', + fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', + resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, + night_vision: { type: 'color', range_m: 40 }, + environment: ['outdoor'], + ndaa_compliant: false, + }, + { + id: 'axis-f4105-lre', + brand: 'Axis', + model: 'F4105-LRE', + type: 'dome', + url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', + fov_deg: '110 horizontal, 60 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + night_vision: { type: 'ir', range_m: 10 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, +]; + +const BY_MODEL = new Map(); +for (const record of RECORDS) { + BY_MODEL.set(record.model.toLowerCase(), record); + for (const alias of record.aliases || []) + BY_MODEL.set(alias.toLowerCase(), record); +} + +/** + * Looks up a spec record by model string (case-insensitive; aliases match too). + * @param {string} model - Hardware model as named by a source pack or seed. + * @returns {Object|null} The verbatim spec record, or null when unknown. + */ +export function lookupModelSpec(model) { + if (!model) return null; + return BY_MODEL.get(String(model).trim().toLowerCase()) || null; +} + +/** + * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` + * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide + * end — the honest default for a coverage cone. + * @param {Object} spec - A record from this module. + * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + */ +export function horizontalFovDeg(spec) { + const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); + const value = match ? Number(match[0]) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; +} + +/** + * One-line spec summary for HUD meta text. + * @param {Object} spec - A record from this module. + * @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null). + */ +export function specSummary(spec) { + if (!spec) return ''; + const parts = [`${spec.brand} ${spec.model}`]; + if (spec.resolution?.label) parts.push(spec.resolution.label); + if (spec.night_vision) { + parts.push( + `${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`, + ); + } + if (spec.ptz) parts.push('PTZ'); + return parts.join(' · '); +} diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs new file mode 100644 index 000000000..4d6f80370 --- /dev/null +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -0,0 +1,63 @@ +// src/layers/cctv/modelSpecs.test.mjs +// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias +// matching, unknown → null — never a guess), datasheet-FOV parsing across the +// real fov_deg string shapes, and the HUD spec-summary line. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lookupModelSpec, + horizontalFovDeg, + specSummary, +} from './modelSpecs.js'; + +test('lookupModelSpec matches case-insensitively and trims', () => { + const spec = lookupModelSpec(' q6135-le '); + assert.ok(spec); + assert.equal(spec.brand, 'Axis'); + assert.equal(lookupModelSpec('Q6135-LE'), spec); +}); + +test('lookupModelSpec matches aliases', () => { + const byAlias = lookupModelSpec('01959-004'); + assert.ok(byAlias); + assert.equal(byAlias.model, 'Q6135-LE'); +}); + +test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { + assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); + assert.equal(lookupModelSpec(''), null); + assert.equal(lookupModelSpec(null), null); + assert.equal(lookupModelSpec(undefined), null); +}); + +test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { + // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). + assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +}); + +test('horizontalFovDeg reads multi-variant and plain fov strings', () => { + // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. + assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); + // '110 horizontal, 60 vertical' → horizontal figure. + assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +}); + +test('horizontalFovDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovDeg({}), null); + assert.equal(horizontalFovDeg(null), null); +}); + +test('specSummary renders the HUD line and degrades by omission', () => { + assert.equal( + specSummary(lookupModelSpec('Q6135-LE')), + 'Axis Q6135-LE · 1080p · IR 250m · PTZ', + ); + // No night vision / no PTZ → those segments are simply absent. + assert.equal( + specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }), + 'Axis X · 4K', + ); + assert.equal(specSummary(null), ''); +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index f8f83fcd1..9fa4a7cef 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,6 +1,7 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; +import { specSummary } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -39,7 +40,8 @@ export function createPresentation({ // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), - `FOV ${Math.round(active.camera.fovDeg)}°`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js). + `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -49,6 +51,10 @@ export function createPresentation({ ? `SRC ${String(health.sourceKind).toUpperCase()}` : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, `${viewBand.toUpperCase()} CONTEXT`, + // Identified hardware: append the model's datasheet specs + attribution. + active.camera.spec + ? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM` + : null, ] .filter(Boolean) .join(' · '); @@ -86,6 +92,9 @@ export function createPresentation({ headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, + // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). + fovSource: camera.fovSource || 'estimated', + spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM, From 97118f478100ca4cebdba60b8eaf7f56f4e4de4e Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:36:50 +0200 Subject: [PATCH 008/166] feat(cctv): vendor the model specs the integrated catalogs publish Grow the enrichment table from the 4-model starter to the hardware the currently integrated public catalogs actually publish, so identified cameras render datasheet FOV cones and spec cards instead of estimates: - King County (WA) road cameras: Cohu 3950 / 3960 (3965) / 3920 iDome (3925), Bosch AUTODOME IP 5000i. - Sarasota County (FL) + Sioux Falls (SD) open data: Bosch AUTODOME IP starlight 7000i / starlight 7000 HD / dynamic 7000 HD, the VG4 AUTODOME (500i), Bosch AUTODOME 800 (VG5-836), and Axis Q6155-E, Q6054-E, Q6000-E Mk II, and P3707-PE. All records are vendored verbatim from the CCTV Camera Database (CC0), each transcribed from a manufacturer datasheet. Analog Cohu positioners carry no fov_deg (their datasheets publish none), so they enrich the HUD specs without a datasheet cone; every other model carries a real FOV. Adds a test asserting the vendored table covers each integrated catalog's published hardware strings. --- src/layers/cctv/modelSpecs.js | 221 +++++++++++++++++++++++++++- src/layers/cctv/modelSpecs.test.mjs | 13 ++ 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index e9bb48856..463e750ad 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -13,8 +13,11 @@ * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module ships a small starter table; city packs that publish - * their hardware models can grow it, or vendor the full extract. + * FOV). This module vendors the records that the currently integrated public + * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis + * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that + * appear in Sarasota County FL and Sioux Falls SD open data; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. @@ -78,6 +81,220 @@ const RECORDS = [ environment: ['outdoor'], ndaa_compliant: true, }, + { + id: 'axis-p3707-pe', + brand: 'Axis', + model: 'P3707-PE', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-p3707-pe/', + aliases: ['AXIS P3707-PE', 'P3707-PE'], + fov_deg: '108-54 horizontal per sensor (4x 1080p)', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6000-e-mk-ii', + brand: 'Axis', + model: 'Q6000-E Mk II', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/', + aliases: [ + 'AXIS Q6000-E Mk II', + 'Q6000-E Mk II 50 Hz', + 'Q6000-E Mk II 60 Hz', + ], + fov_deg: 'up to 360 combined; 113-152 horizontal per sensor', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + environment: ['outdoor'], + }, + { + id: 'axis-q6054-e', + brand: 'Axis', + model: 'Q6054-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6054-e/', + aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], + fov_deg: '62.9-2.2 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6155-e', + brand: 'Axis', + model: 'Q6155-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6155-e/', + aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], + fov_deg: '66.7-2.36 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-500i', + brand: 'Bosch', + model: 'AutoDome 500i Series (VG4)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/', + aliases: [ + 'VG4 AUTODOME', + 'VG4 AUTODOME H.264', + 'AutoDome 500i', + 'AutoDome 500i Series', + 'VG4-500i', + 'VG4-500', + ], + fov_deg: '57.8-1.7 horizontal', + resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-dynamic-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP dynamic 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', + aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], + fov_deg: '65-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-starlight-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/', + aliases: [ + 'AUTODOME IP starlight 7000 HD', + 'Starlight 7000HD', + 'VG5-7130-CPT4', + 'VG5-7130', + ], + fov_deg: '59-2.1 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-5502-30', + brand: 'Bosch', + model: 'AUTODOME IP 5000i (NDP-5502-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/', + aliases: [ + 'AUTODOME IP 5000i', + 'NDP-5502-Z30', + 'NDP-5502-Z30C', + 'NDP-5502-30', + ], + fov_deg: '60.9-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-7512-z30', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/', + aliases: [ + 'AUTODOME IP starlight 7000i', + 'NDP-7512-Z30', + 'NDP-7512-Z30C', + 'NDP-7512-Z30K', + ], + fov_deg: '64.7-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-vg5-836-ecev', + brand: 'Bosch', + model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/', + aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'], + resolution: { mp: 2, label: '1080p' }, + ptz: true, + autotracking: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3920-idome', + brand: 'Cohu', + model: '3920 iDome', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3920-idome/', + aliases: [ + '3920', + '3920 Series', + '3923', + '3925', + '3925 Dome', + 'iDome 3925', + 'Cohu iDome 3920 Series', + ], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3950-iview', + brand: 'Cohu', + model: '3950 iVIEW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', + aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3960', + brand: 'Cohu', + model: '3960', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3960/', + aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], + resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, ]; const BY_MODEL = new Map(); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 4d6f80370..b31f2d714 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -23,6 +23,19 @@ test('lookupModelSpec matches aliases', () => { assert.equal(byAlias.model, 'Q6135-LE'); }); +test('vendored table covers the integrated catalogs published hardware', () => { + // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), + // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). + const cohu = lookupModelSpec('3950'); // King County publishes "3950" + assert.equal(cohu?.brand, 'Cohu'); + const autodome = lookupModelSpec('AUTODOME IP 5000i'); + assert.ok(autodome && horizontalFovDeg(autodome) > 0); + const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string + assert.ok(vg4); + const axis = lookupModelSpec('Q6155-E'); // Sioux Falls + assert.equal(horizontalFovDeg(axis), 66.7); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From 0533f78be433f28fe9085d856caf000630691670 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:56:15 +0200 Subject: [PATCH 009/166] feat(cctv): resolve verbose registry model strings via token fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence. --- scripts/package-boundaries.json | 3 ++ src/layers/cctv/modelSpecs.js | 69 +++++++++++++++++++++++++++-- src/layers/cctv/modelSpecs.test.mjs | 37 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..a987623a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1079,6 +1079,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1496,6 +1497,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -2136,6 +2138,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 463e750ad..1252ce013 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -297,21 +297,82 @@ const RECORDS = [ }, ]; +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + const BY_MODEL = new Map(); for (const record of RECORDS) { - BY_MODEL.set(record.model.toLowerCase(), record); + BY_MODEL.set(normalize(record.model), record); for (const alias of record.aliases || []) - BY_MODEL.set(alias.toLowerCase(), record); + BY_MODEL.set(normalize(alias), record); } /** - * Looks up a spec record by model string (case-insensitive; aliases match too). + * DISTINCTIVE tokens of a record — the whitespace words of its model and + * aliases that identify hardware rather than describe it. A token qualifies + * only when it is >= 4 chars AND contains a digit or hyphen, so generic words + * ("dome", "network", "camera", "series", "ptz") are never distinctive and a + * verbose registry string cannot match on them alone. Surrounding punctuation + * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". + * @param {Object} record + * @returns {string[]} Unique distinctive tokens, lowercased. + */ +function distinctiveTokens(record) { + const out = new Set(); + for (const name of [record.model, ...(record.aliases || [])]) { + for (const raw of normalize(name).split(' ')) { + const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); + if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); + } + } + return [...out]; +} + +/** + * Token index for the substring fallback: one precompiled whole-word matcher + * per distinctive token. Word boundaries are any non-alphanumeric char (or the + * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as + * whole words while "700" never matches inside "7000". + */ +const TOKEN_INDEX = RECORDS.flatMap((record) => + distinctiveTokens(record).map((token) => ({ + token, + record, + re: new RegExp( + `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, + ), + })), +); + +/** + * Looks up a spec record for a hardware-model string. + * + * Exact match first (case-insensitive, whitespace-collapsed) on the model and + * every alias. When that misses, a token fallback lets a VERBOSE registry + * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the + * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) + * matched token wins; if two different records tie on the longest matched + * token the result is ambiguous and returns null (never guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @returns {Object|null} The verbatim spec record, or null when unknown. */ export function lookupModelSpec(model) { if (!model) return null; - return BY_MODEL.get(String(model).trim().toLowerCase()) || null; + const query = normalize(model); + if (!query) return null; + const exact = BY_MODEL.get(query); + if (exact) return exact; + const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); + if (!matches.length) return null; + const maxLen = Math.max(...matches.map((m) => m.token.length)); + const top = matches.filter((m) => m.token.length === maxLen); + const records = new Set(top.map((m) => m.record)); + return records.size === 1 ? top[0].record : null; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index b31f2d714..c504e14ad 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => { assert.equal(horizontalFovDeg(axis), 66.7); }); +test('verbose registry strings resolve via distinctive-token fallback', () => { + // Sioux Falls publishes the full product title, not the bare model. + assert.equal( + lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model, + 'Q6155-E', + ); + assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II'); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id, + 'bosch-ndp-7512-z30', + ); + // Punctuation around the model token doesn't defeat the match. + assert.equal( + lookupModelSpec('camera (P3707-PE), outdoor')?.model, + 'P3707-PE', + ); +}); + +test('token fallback never matches on generic words or wrong models', () => { + // Only generic descriptors — no distinctive hardware token — stays null. + assert.equal(lookupModelSpec('PTZ Dome Camera'), null); + assert.equal(lookupModelSpec('HD Network Camera'), null); + assert.equal(lookupModelSpec('Outdoor Dome Camera'), null); + // A real-looking but unknown model resolves to nothing, not a near neighbour. + assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null); + // A generation token alone must not collide with a similar one: "7000" + // belongs to two records (dynamic / starlight 7000 HD), so a bare + // "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick. + assert.equal(lookupModelSpec('AUTODOME 7000'), null); +}); + +test('exact match still wins over the token fallback', () => { + // "3965" is an alias of the 3960; exact alias match, not a token scan. + assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From 5f8a275487338d3fc08d8e3b91dbc8d856148676 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 15 Sep 2026 22:08:53 -0400 Subject: [PATCH 010/166] fix(providers): bound the remaining upstream body reads Route the six proxied fetches that still buffered upstream bodies without a limit through the shared capped readers in src/sources/httpBody.js: FIRMS CSV, CelesTrak TLE groups, adsb.lol /v2/mil (both the OK and relayed-status paths), TomTom flow tiles, OpenSky states/all and adsbdb enrichment lookups. Each cap is a bound sized well above the measured payload, not a budget. readResponseBytesCapped gains the same optional abort signal as the text reader, with the same cancel-on-abort plumbing and reader release, so the TomTom tile path can share the route deadline instead of reading arrayBuffer() unbounded. Where a route already had a deadline, the same signal now covers the body read as well as the headers. Two polls had no deadline at all: the adsb.lol military poll gains a 12-second one and the OpenSky states/all exchange, including its Basic-auth retry, a 20-second one; both miss into their existing STALE paths. FIRMS map-key status, the OpenSky token exchange and CCTV media are left as they are. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 20 ++++ scripts/package-boundaries.json | 14 ++- server/providers/aircraft/adsb-lol.js | 20 +++- server/providers/aircraft/enrichment.js | 16 ++- server/providers/aircraft/opensky.js | 21 +++- server/providers/firms.js | 14 ++- server/providers/space/celestrak.js | 13 ++- server/providers/traffic.js | 15 ++- src/sources/httpBody.js | 44 +++++--- src/sources/httpBody.test.mjs | 133 ++++++++++++++++++++++++ 11 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 src/sources/httpBody.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f5433b87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Bound the remaining upstream body reads (FIRMS, CelesTrak, adsb.lol, TomTom, OpenSky, adsbdb) with streaming size caps and shared deadlines; the adsb.lol military and OpenSky `states/all` polls gain deadlines of their own. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..7853d590e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -827,6 +827,26 @@ feed address is read from the path, and a query-string form is refused with a 40 streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +## Upstream body bounds + +Every proxied fetch outside CCTV media now reads its body through the shared +streaming readers in `src/sources/httpBody.js`, so a declared or observed body +over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than +buffered. `readResponseBytesCapped` accepts the same optional abort signal as +the text reader and cancels a body still streaming when it fires; TomTom flow +tiles use it alongside GTFS-Realtime. Caps are bounds, not budgets, sized well +above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB +per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles +8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS +60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s) that deadline now covers the +body read as well as the headers. Two polls that had no deadline gain one: the +adsb.lol military poll waits at most 12 seconds, and the OpenSky `states/all` +exchange, including its Basic-auth retry, at most 20. A rejected or timed-out +body is an upstream miss and follows each route's existing error and +cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV +media keep their current handling. + + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..e47e5bb16 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -113,7 +113,12 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "server/providers/common/http.js", + "src/sources/httpBody.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { @@ -129,7 +134,12 @@ "firms-provider": { "runtime": "node", "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "modules": [ + "server/providers/firms.js", + "server/providers/common/http.js", + "src/sources/httpBody.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..606b598cd 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -1,3 +1,5 @@ +import { readResponseTextCapped } from '../common/http.js'; + /** * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. * @@ -25,6 +27,10 @@ export function adsbLolProxy() { let _cooldownStatus = 0; /** Response cache TTL (ms). */ const CACHE_MS = 12000; + /** Upstream deadline (ms); the fetch previously had none and could hang a poll. */ + const UPSTREAM_TIMEOUT_MS = 12000; + /** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */ + const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */ const RATE_LIMIT_COOLDOWN_MS = 30000; /** Cooldown after a 5xx (ms). */ @@ -94,11 +100,17 @@ export function adsbLolProxy() { ); return; } + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); const upstream = await fetch('https://api.adsb.lol/v2/mil', { headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + signal, }); if (upstream.ok) { - const body = await upstream.text(); + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); _cache = body; _cacheAt = Date.now(); _cooldownUntil = 0; @@ -125,7 +137,11 @@ export function adsbLolProxy() { return; } } - const body = await upstream.text(); + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); serve( res, upstream.status, diff --git a/server/providers/aircraft/enrichment.js b/server/providers/aircraft/enrichment.js index 400050151..9b7c1b1bb 100644 --- a/server/providers/aircraft/enrichment.js +++ b/server/providers/aircraft/enrichment.js @@ -1,5 +1,9 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseJsonCapped } from '../common/http.js'; + +/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */ +const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024; /** * adsbdb.com enrichment proxy: callsign → route (airline + origin/destination * airports) and hex → aircraft type/registration. Free community API — cached @@ -80,12 +84,16 @@ export function adsbdbProxy() { kind === 'route' ? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}` : `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + const signal = AbortSignal.timeout(8000); + const res = await fetch(url, { signal }); if (res.ok) { + const payload = await readResponseJsonCapped( + res, + ADSBDB_MAX_RESPONSE_BYTES, + signal, + ); const data = - kind === 'route' - ? parseRoute(await res.json()) - : parseAircraft(await res.json()); + kind === 'route' ? parseRoute(payload) : parseAircraft(payload); store[key] = { at: Date.now(), data }; // data may be null — negative cache dirty = true; return data; diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..f1ded6401 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -2,6 +2,7 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback import { coalesceProxyRequest, readResponseJsonCapped, + readResponseTextCapped, } from '../common/http.js'; import { requiredFiniteQueryNumber } from '../common/query.js'; // --------------------------------------------------------------------------- @@ -72,6 +73,15 @@ const ADSBLOL_POINT_CACHE_MS = 12000; const ADSBLOL_POINT_CACHE_MAX = 80; const ADSBLOL_POINT_RADIUS_NM = 250; const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */ +const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** + * Deadline for the whole states/all exchange: headers, the Basic-auth retry + * and the body. The fetch previously had none, so a hung upstream connection + * stalled the worldwide poll indefinitely; a miss here serves STALE like any + * other upstream failure. + */ +const OPENSKY_STATES_TIMEOUT_MS = 20000; // A 200 response can still contain an old OpenSky snapshot. Past this point // the viewport-scoped adsb.lol source is more honest and keeps local motion // current instead of coasting a stale worldwide frame indefinitely. @@ -470,9 +480,10 @@ export function openSkyProxy() { } } + const signal = AbortSignal.timeout(OPENSKY_STATES_TIMEOUT_MS); let upstream = await fetch( 'https://opensky-network.org/api/states/all?extended=1', - { headers }, + { headers, signal }, ); // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials if ( @@ -487,13 +498,17 @@ export function openSkyProxy() { }; upstream = await fetch( 'https://opensky-network.org/api/states/all?extended=1', - { headers: retryHeaders }, + { headers: retryHeaders, signal }, ); usedMode = 'basic'; reason = 'oauth_rejected_fallback_basic'; } - let body = await upstream.text(); + let body = await readResponseTextCapped( + upstream, + OPENSKY_STATES_MAX_RESPONSE_BYTES, + signal, + ); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; if ( upstream.ok && diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..c73ca10ce 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js'; +import { readResponseTextCapped } from './common/http.js'; /** * NASA FIRMS live active-fire proxy with a memory + disk cache. @@ -35,6 +36,12 @@ export function firmsProxy() { 'VIIRS_SNPP_NRT', 'MODIS_NRT', ]; + /** + * Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about + * 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a + * bound, not a budget. + */ + const FIRMS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); const CACHE_PATH = path.join(CACHE_DIR, 'firms.json'); @@ -83,9 +90,12 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const signal = AbortSignal.timeout(60_000); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const records = parseFirmsCsv(await res.text()); + const records = parseFirmsCsv( + await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal), + ); if (records === null) throw new Error('non-CSV upstream response'); return records; } diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index a76cc590d..8baf59ba7 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { readResponseTextCapped } from '../common/http.js'; /** * Vite plugin: CelesTrak TLE proxy. @@ -48,10 +49,14 @@ export function celestrakProxy() { } } + /** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */ + const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + async function fetchUpstream(group) { const url = celestrakTleUrl(group); + const signal = AbortSignal.timeout(20000); const res = await fetch(url.toString(), { - signal: AbortSignal.timeout(20000), + signal, // CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a // descriptive User-Agent with a contact point. headers: { @@ -60,7 +65,11 @@ export function celestrakProxy() { }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = await res.text(); + 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 }; diff --git a/server/providers/traffic.js b/server/providers/traffic.js index 2a3c2e60a..d53db6530 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -1,6 +1,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; +import { readResponseBytesCapped } from './common/http.js'; import { isValidTileCoord as isValidTomTomTile, utcDayKey as tomtomUtcDayKey, @@ -48,6 +49,8 @@ export function tomtomProxy() { const DEFAULT_DAILY_BUDGET = 6000; const MEM_MAX_ENTRIES = 256; const UPSTREAM_TIMEOUT_MS = 15000; + /** Flow tiles are well under 1 MB; this bounds a runaway body, not a budget. */ + const TOMTOM_TILE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** @type {Map} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */ const mem = new Map(); @@ -143,11 +146,15 @@ export function tomtomProxy() { 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; recordUpstreamFetch(); // attempts count — upstream bills the request either way - const res = await fetch(url, { - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = Buffer.from(await res.arrayBuffer()); + const bytes = await readResponseBytesCapped( + res, + TOMTOM_TILE_MAX_RESPONSE_BYTES, + signal, + ); + const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (buf.length === 0) throw new Error('empty tile body'); return buf; } diff --git a/src/sources/httpBody.js b/src/sources/httpBody.js index 6f176ca52..481dd5b7f 100644 --- a/src/sources/httpBody.js +++ b/src/sources/httpBody.js @@ -55,38 +55,52 @@ export async function readResponseJsonCapped(response, maxBytes, signal) { /** * Read a fetch() Response body as bytes with the same hard cap as - * readResponseTextCapped — for protobuf upstreams (GTFS-Realtime). + * readResponseTextCapped — for protobuf and tile upstreams (GTFS-Realtime, + * TomTom flow). Same early Content-Length rejection, running cap while + * streaming and cancellation on abort, so callers can treat the two alike. * Throws { code:'RESPONSE_TOO_LARGE' }. */ -export async function readResponseBytesCapped(response, maxBytes) { +export async function readResponseBytesCapped(response, maxBytes, signal) { const tooLarge = () => { const err = new Error('Upstream response too large'); err.code = 'RESPONSE_TOO_LARGE'; return err; }; const declared = Number(response.headers.get('content-length')); - if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge(); + if (Number.isFinite(declared) && declared > maxBytes) { + void response.body?.cancel().catch(() => {}); + throw tooLarge(); + } + signal?.throwIfAborted(); const reader = response.body?.getReader?.(); if (!reader) { const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); if (bytes.byteLength > maxBytes) throw tooLarge(); return bytes; } + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener('abort', cancel, { once: true }); const chunks = []; let total = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - try { - await reader.cancel(); - } catch { - /* no-op */ - } - throw tooLarge(); + try { + for (;;) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw tooLarge(); + chunks.push(value); } - chunks.push(value); + } catch (error) { + cancel(); + throw error; + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); } const out = new Uint8Array(total); let offset = 0; diff --git a/src/sources/httpBody.test.mjs b/src/sources/httpBody.test.mjs new file mode 100644 index 000000000..aa8e77d1a --- /dev/null +++ b/src/sources/httpBody.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readResponseBytesCapped } from './httpBody.js'; + +/** + * A Response that streams `chunk` forever until the reader cancels it. The + * zero high-water mark keeps the stream from pulling eagerly at construction, + * so `pulls()` counts only what a reader actually asked for. + */ +function endlessResponse(chunk, onCancel, headers = {}) { + let pulls = 0; + const stream = new ReadableStream( + { + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel: onCancel, + }, + { highWaterMark: 0 }, + ); + return { response: new Response(stream, { headers }), pulls: () => pulls }; +} + +function chunkedResponse(chunks, headers = {}) { + const stream = new ReadableStream( + { + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }, + { highWaterMark: 0 }, + ); + return new Response(stream, { headers }); +} + +const bytes = (n, fill) => new Uint8Array(n).fill(fill); + +test('returns the exact bytes of a body that fits, untouched by text decoding', async () => { + // 0xFF and 0xFE are invalid UTF-8 lead bytes; a text path would mangle them. + const payload = Uint8Array.from([0xff, 0xfe, 0x00, 0x7f, 0x80, 0x01]); + const buf = await readResponseBytesCapped(chunkedResponse([payload]), 1024); + assert.ok(buf instanceof Uint8Array); + assert.deepEqual([...buf], [...payload]); +}); + +test('joins chunks in order and reports the total length', async () => { + const buf = await readResponseBytesCapped( + chunkedResponse([bytes(3, 1), bytes(2, 2), bytes(1, 3)]), + 1024, + ); + assert.deepEqual([...buf], [1, 1, 1, 2, 2, 3]); +}); + +test('refuses on a declared Content-Length without pulling the body', async () => { + let cancelled = false; + const { response, pulls } = endlessResponse( + bytes(512, 0), + () => { + cancelled = true; + }, + { 'content-length': '4096' }, + ); + await assert.rejects(readResponseBytesCapped(response, 1024), { + code: 'RESPONSE_TOO_LARGE', + }); + assert.equal(pulls(), 0); + assert.ok(cancelled); +}); + +test('refuses a chunked body the moment it passes the cap and stops pulling', async () => { + let cancelled = false; + const { response, pulls } = endlessResponse(bytes(512, 0), () => { + cancelled = true; + }); + await assert.rejects(readResponseBytesCapped(response, 1024), { + code: 'RESPONSE_TOO_LARGE', + }); + // Three 512-byte chunks is the first total over 1024; an uncapped read of + // this never-ending stream would not terminate at all. + assert.ok(pulls() <= 3, `pulled ${pulls()} chunks`); + assert.ok(cancelled); +}); + +test('accepts a body exactly at the cap', async () => { + const buf = await readResponseBytesCapped( + chunkedResponse([bytes(512, 7), bytes(512, 7)]), + 1024, + ); + assert.equal(buf.byteLength, 1024); +}); + +test('cancels a body still streaming when the signal aborts, and never returns a partial one', async () => { + const controller = new AbortController(); + let cancelled = false; + let pulls = 0; + const stream = new ReadableStream( + { + pull(ctrl) { + pulls += 1; + ctrl.enqueue(bytes(64, 0)); + if (pulls === 2) controller.abort(); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); + await assert.rejects( + readResponseBytesCapped(new Response(stream), 1 << 20, controller.signal), + ); + assert.ok(cancelled); + assert.ok(pulls <= 3, `pulled ${pulls} chunks after abort`); +}); + +test('refuses immediately when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const { response, pulls } = endlessResponse(bytes(8, 0), () => {}); + await assert.rejects( + readResponseBytesCapped(response, 1024, controller.signal), + ); + assert.equal(pulls(), 0); +}); + +test('falls back to arrayBuffer() for a body with no stream reader', async () => { + const response = new Response(bytes(16, 9)); + const buf = await readResponseBytesCapped(response, 1024); + assert.equal(buf.byteLength, 16); + assert.equal(buf[0], 9); +}); From d585d25b19c0f41746268e07ea840f11365bd0e3 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Tue, 15 Sep 2026 22:08:54 -0400 Subject: [PATCH 011/166] chore(format): adopt the httpBody reader test Co-Authored-By: Claude Opus 5 --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..d661d7a6b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -699,6 +699,7 @@ "src/search/nominatim.js", "src/search/nominatim.test.mjs", "src/sources/httpBody.js", + "src/sources/httpBody.test.mjs", "src/sources/overpass.js", "src/sources/nominatim.js", "server/providers/transit.js", From 320a1d6bfd65b9cd96cad39f90e7aa48c93d2a67 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:01:17 +0200 Subject: [PATCH 012/166] fix(cctv): harden the Catalonia catalog fetch and frame-link allowlist Address review feedback on #595: - Reject frame links carrying userinfo (user:pass@) in normalizeCataloniaImageUrl, so catalog data cannot inject credentials into the server-side frame request on an allowlisted host. - Fetch cameres.xml over https (verified: same 200 body, no redirect), refuse redirects with the unread body released, and read the body through readResponseTextCapped with a 1 MiB CATALONIA_MAX_CATALOG_BYTES cap instead of an unbounded resp.text(). - Add regression tests for userinfo links on every allowed host, the refused redirect, and declared and streamed oversize bodies. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- DATA_SOURCES.md | 2 +- server/providers/cctv/constants.js | 8 +- server/providers/cctv/sources.js | 38 +++++++- src/data/cctvCataloniaSource.test.mjs | 125 ++++++++++++++++++++++++-- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d48a26e..1ad4538e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -295,7 +295,9 @@ Analyst records for loaded satellites, datacenters and dams, with explicit bound hotlinked Barcelona and Terrassa municipal cameras, each partner named as a credit beside the shared provider. Ambient-card refresh paced to 3 minutes, matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES` - sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. + sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. The catalog + is fetched over https with redirects refused and a 1 MiB body cap, and frame + links carrying userinfo are rejected even on an allowlisted host. - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 824cb95be..5cc0be53d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -90,7 +90,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. -- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `gencat.cat/transit/opendata/cameres.xml` WFS/GML feed. 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 (~135 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, 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. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_CATALONIA_ENABLED=0`. +- **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 (~135 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 is rejected even on an allowed host), 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. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_CATALONIA_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index a64b06cd0..391c35230 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -238,7 +238,8 @@ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; /** * Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML - * XML list (verified 2026-09-15, hourly catalog refresh). The feed carries a + * XML list (verified 2026-09-15, hourly catalog refresh), fetched over https + * (verified 2026-09-27: same 200 body, no redirect). The feed carries a * `font` field naming the actual publisher per camera — SCT's own highway * cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are * kept, each confirmed to return a real frame. Frame URLs are checked @@ -246,7 +247,10 @@ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; * publisher serves from its own host. */ export const CATALONIA_CAMERAS_URL = - 'http://www.gencat.cat/transit/opendata/cameres.xml'; + 'https://www.gencat.cat/transit/opendata/cameres.xml'; +/** Byte ceiling for the Catalonia catalog read (~90 KB observed 2026-09-27), + * so a runaway body cannot be buffered without limit. */ +export const CATALONIA_MAX_CATALOG_BYTES = 1024 * 1024; /** Allowed image hosts, one per kept `font` publisher in the feed. */ export const CATALONIA_IMAGE_HOSTS = Object.freeze( new Set([ diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index f32b928a2..ee83f284e 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -56,6 +56,7 @@ import { CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, CATALONIA_CAMERAS_URL, + CATALONIA_MAX_CATALOG_BYTES, CATALONIA_IMAGE_HOSTS, CATALONIA_FONT_CREDIT, CATALONIA_FONT_ELEVATION_M, @@ -89,7 +90,10 @@ import { prioritizeSources, } from './normalize.js'; import { directionToHeading } from '../../../src/data/directionText.js'; -import { readResponseJsonCapped } from '../common/http.js'; +import { + readResponseJsonCapped, + readResponseTextCapped, +} from '../common/http.js'; /** * Fetch and parse Austin traffic camera records from the city Open Data portal. * @@ -1649,7 +1653,10 @@ export function parseCataloniaXml(xml) { /** * Validate and normalize one Catalonia feed `link` against the publisher * host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects - * anything else, including a host that merely resembles an allowed one. + * anything else, including a host that merely resembles an allowed one, and + * any URL carrying userinfo (`https://user:pass@www.bcn.cat/...`): the + * hostname check alone would pass it, letting catalog data inject + * credentials into the server-side frame request. * * SCT's own `RenderService` frame link is a special case, for two stacked * reasons (both verified 2026-09-15): @@ -1679,6 +1686,7 @@ export function normalizeCataloniaImageUrl(rawUrl) { return ''; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (parsed.username || parsed.password) return ''; if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return ''; if (parsed.hostname === 'mct.gencat.cat') { @@ -1725,19 +1733,41 @@ export function cataloniaCameraName(carretera, municipi) { * shared `provider`. The feed carries no heading, so every camera takes the * id-hash fallback, low-confidence pose (same as TfL and Fintraffic). * + * The list decides ids, coordinates, labels and which allowlisted frame path + * is proxied, so it is fetched over https, redirects are refused (the list + * host cannot be steered or downgraded) and the body read is capped at + * CATALONIA_MAX_CATALOG_BYTES. + * * @returns {Promise>} Normalized camera source objects. */ export async function loadCataloniaSourcesFromOpenData() { try { const resp = await fetch(CATALONIA_CAMERAS_URL, { headers: { Accept: 'application/xml,text/xml,*/*' }, + 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] Catalonia catalog redirected; redirects are not followed', + ); + return discard(); + } if (!resp.ok) { console.warn('[CCTV] Catalonia camera download failed:', resp.status); - return []; + return discard(); } - const xml = await resp.text(); + const xml = await readResponseTextCapped(resp, CATALONIA_MAX_CATALOG_BYTES); const rows = parseCataloniaXml(xml); if (!rows.length) return []; diff --git a/src/data/cctvCataloniaSource.test.mjs b/src/data/cctvCataloniaSource.test.mjs index d9b9f3456..a2b1ed673 100644 --- a/src/data/cctvCataloniaSource.test.mjs +++ b/src/data/cctvCataloniaSource.test.mjs @@ -4,7 +4,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; -import { CATALONIA_CAMERAS_URL } from '../../server/providers/cctv/constants.js'; +import { + CATALONIA_CAMERAS_URL, + CATALONIA_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; import { parseCataloniaXml, normalizeCataloniaImageUrl, @@ -31,6 +34,24 @@ function quiet(t) { t.mock.method(console, 'warn', () => {}); } +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('` block matching the real feed's shape. */ function cataloniaFeature({ fid, @@ -143,6 +164,19 @@ test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to assert.equal(normalizeCataloniaImageUrl('not a url'), ''); }); +test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => { + // The hostname matches the allowlist, so only an explicit userinfo check + // stops catalog data injecting credentials into the frame request. + for (const link of [ + 'https://user:pass@www.bcn.cat/transit/imatges/a.gif', + 'http://user@www.bcn.cat/transit/imatges/a.gif', + 'https://:pass@emap.terrassa.cat/it_terrassa/cam02.jpeg', + 'http://user:pass@mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + ]) { + assert.equal(normalizeCataloniaImageUrl(link), '', link); + } +}); + test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => { assert.equal( cataloniaCameraName('C-58', 'Nus Trinitat'), @@ -164,8 +198,8 @@ test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publis quiet(t); withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); const requested = []; - t.mock.method(globalThis, 'fetch', async (url) => { - requested.push(String(url)); + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); const xml = cataloniaFeed([ cataloniaFeature({ fid: 'cameres.fid-1', @@ -220,7 +254,11 @@ test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publis const cameras = await loadCataloniaSourcesFromOpenData(); - assert.deepEqual(requested, [CATALONIA_CAMERAS_URL]); + assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]); + assert.ok( + CATALONIA_CAMERAS_URL.startsWith('https://'), + 'the catalog is fetched over https', + ); assert.deepEqual( cameras.map((camera) => camera.id).sort(), ['cat-1', 'cat-2', 'cat-3'], @@ -264,6 +302,81 @@ test('Catalonia loader fails soft on HTTP errors, empty payloads and network err } }); +test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + // A redirect is never followed: the list host cannot be steered or + // downgraded. Its body is a live stream, so the test also proves the + // refusal releases the transport. + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); + assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + // An HTTP error body is released too. + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the error body is cancelled'); + + const validFeed = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-1', + lon: '2.1849528', + lat: '41.45989301', + carretera: 'C-58', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + ]); + + // A body that declares a size over the cap is refused rather than buffered, + // even though its actual content would parse. + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + return new Response(validFeed, { + headers: { + 'Content-Type': 'application/xml', + 'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); + + // So is a body with no declared size that only turns out too long while + // streaming — a valid feed padded past the cap. + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = + validFeed + ' '.repeat(CATALONIA_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/xml' } }, + ); + }); + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); +}); + test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => { quiet(t); const sourceRoot = fs.mkdtempSync( @@ -282,8 +395,8 @@ test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips t CCTV_CATALONIA_MAX_SOURCES: undefined, }); const requested = []; - t.mock.method(globalThis, 'fetch', async (url) => { - requested.push(String(url)); + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); if (String(url) !== CATALONIA_CAMERAS_URL) { return new Response('unavailable', { status: 503 }); } From 481d0c327a36c1c8f2d6c60f44f5de98c79881ea Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 013/166] fix(cctv): map WSDOT diagonal and BW compass codes; document WSDOT env WSDOT's layer metadata defines BW (Bothways) plus the diagonals NE/SE/SW/NW alongside N/E/S/W. Diagonal cameras were being demoted to the low-confidence id-hash fallback; map them to 45/135/225/315 degrees and treat BW (not B) as the no-single-facing code. Add CCTV_WSDOT_ENABLED and CCTV_WSDOT_MAX_SOURCES to .env.example with the other pack controls. --- .env.example | 2 ++ DATA_SOURCES.md | 2 +- server/providers/cctv/sources.js | 15 +++++++--- src/data/cctvWsdotSource.test.mjs | 46 +++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index e16e85a27..45ae52099 100644 --- a/.env.example +++ b/.env.example @@ -180,6 +180,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras +# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first # 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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 73eaf8a1f..760ac8429 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -90,7 +90,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. -- **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, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows 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 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. +- **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, which the 15-minute catalog cache and per-request frame fetches respect; frames are never stored or redistributed. `CompassDirection` codes with a single facing (N/E/S/W and the diagonals NE/SE/SW/NW, per WSDOT's layer metadata) give a high-confidence heading; `BW` ("Bothways") rows 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 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. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1124b187f..b7fc197b0 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1706,13 +1706,19 @@ export async function loadDelDOTSourcesFromOpenData() { } } -/** WSDOT compass letters as headings in degrees. `B` (both directions), `O` - * (other) and null carry no single facing and fall through to the id hash. */ +/** WSDOT `CompassDirection` codes as headings in degrees, per the layer's own + * metadata (N/E/S/W, NE/NW/SE/SW, BW = Bothways). `BW` and anything else + * (`O`, null, unknown codes) carry no single facing and fall through to the + * id hash. */ const WSDOT_COMPASS_HEADINGS = Object.freeze({ N: 0, + NE: 45, E: 90, + SE: 135, S: 180, + SW: 225, W: 270, + NW: 315, }); /** @@ -1749,8 +1755,9 @@ function isLikelyWashingtonCoordinate(lat, lon) { * finite in-state coordinates, and a frame on WSDOT's own image host are * kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle, * lodges, airports) are skipped rather than proxied under someone else's - * terms. `CompassDirection` letters give a high-confidence heading; `B` - * (both directions, the majority) and `O`/null fall back to the id hash. + * terms. A single-facing `CompassDirection` (cardinal or diagonal) gives a + * high-confidence heading; `BW` (Bothways) and `O`/null fall back to the id + * hash. * The feed carries Windows-1252 bytes (en-dashes in titles) with no charset * declaration, so the body is decoded explicitly before parsing. * diff --git a/src/data/cctvWsdotSource.test.mjs b/src/data/cctvWsdotSource.test.mjs index 0459f3c72..438df7e58 100644 --- a/src/data/cctvWsdotSource.test.mjs +++ b/src/data/cctvWsdotSource.test.mjs @@ -89,7 +89,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy }, }), wsdotFeature(9059, 47.66, -117.43, { - attributes: { CompassDirection: 'B' }, + attributes: { CompassDirection: 'BW' }, }), // Partner-hosted frame (Oregon DOT): skipped, never proxied. wsdotFeature(1002, 45.62, -122.675, { @@ -127,7 +127,7 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001); assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001); - // "Both directions" carries no single facing: id-hash fallback at low + // `BW` (Bothways) carries no single facing: id-hash fallback at low // confidence, with the low-confidence pose personality. const spokane = cameras.find((camera) => camera.id === 'wsdot-9059'); assert.equal(spokane.headingConfidence, 'low'); @@ -135,6 +135,48 @@ test('WSDOT loader keeps official-host cameras and maps compass directions', asy assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360); }); +test('WSDOT loader maps every documented CompassDirection code', async (t) => { + quiet(t); + withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined }); + const codes = { + N: 0, + NE: 45, + E: 90, + SE: 135, + S: 180, + SW: 225, + W: 270, + NW: 315, + // Lower-case and padded codes are the same code. + ' nw ': 315, + }; + const noFacing = ['BW', 'O', null]; + const entries = [...Object.keys(codes), ...noFacing]; + t.mock.method(globalThis, 'fetch', async () => + cp1252Response({ + features: entries.map((code, i) => + wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, { + attributes: { CompassDirection: code }, + }), + ), + }), + ); + + const cameras = await loadWsdotSourcesFromOpenData(); + const byId = new Map(cameras.map((camera) => [camera.id, camera])); + entries.forEach((code, i) => { + const camera = byId.get(`wsdot-${3000 + i}`); + if (code in codes) { + assert.equal(camera.headingDeg, codes[code], `heading for ${code}`); + assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`); + assert.equal(camera.fovDeg, 56, `pose personality for ${code}`); + } else { + assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`); + assert.equal(camera.fovDeg, 44, `pose personality for ${code}`); + } + }); +}); + test('WSDOT loader honors the max-sources cap', async (t) => { quiet(t); withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' }); From c3f197823e2b13a1538b4a1c4cd0dada29650752 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 014/166] fix(cctv): serve King County model/manufacturer through /api/cctv/sources The loader and normalizer carried the hardware model, but the /sources response projection and the browser catalog both whitelist fields and dropped it, so no client consumer ever saw it. Project model and manufacturer (manufacturer disambiguates bare model strings like "3950") at both boundaries, and add a test that drives the real middleware and browser catalog end to end. --- DATA_SOURCES.md | 2 +- server/providers/cctv.js | 2 + server/providers/cctv/normalize.js | 6 ++ server/providers/cctv/sources.js | 4 + src/data/cctvKingCountySource.test.mjs | 106 +++++++++++++++++++++++++ src/layers/cctv/catalog.js | 2 + 6 files changed, 121 insertions(+), 1 deletion(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index c43ae7728..8e806208d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -90,7 +90,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **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`. -- **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 so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry 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); the model string is carried on the source as `model` for identified-hardware consumers. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_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 so a camera never appears twice when a statewide WSDOT pack is present; the few city-owned cameras the county hosts (Redmond, Sammamish, Newcastle, Snoqualmie) carry 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. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/server/providers/cctv.js b/server/providers/cctv.js index a71e2af4d..5fb15be03 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -162,6 +162,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { sourceKind: source.sourceKind || (source.url ? 'configured' : 'fallback'), poseSource: source.poseSource, + model: source.model || '', + manufacturer: source.manufacturer || '', license: source.license, credit: source.credit || '', code: source.code || '', diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 17903335d..c4020da34 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -524,5 +524,11 @@ export function normalizeSourceItem(item) { typeof item.model === 'string' && item.model.trim() ? item.model.trim() : undefined, + // Bare model strings collide across vendors (e.g. "3950"), so the + // publishing pack's manufacturer rides along for disambiguation. + manufacturer: + typeof item.manufacturer === 'string' && item.manufacturer.trim() + ? item.manufacturer.trim() + : undefined, }; } diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index e92a8a265..3996f37f1 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1776,6 +1776,9 @@ export async function loadKingCountySourcesFromOpenData() { const model = String(attrs.Model || '') .replace(/\s+/g, ' ') .trim(); + const manufacturer = String(attrs.Manufacturer || '') + .replace(/\s+/g, ' ') + .trim(); const camera = { id: cameraId, name: @@ -1800,6 +1803,7 @@ export async function loadKingCountySourcesFromOpenData() { credit: owner && owner !== 'King County' ? owner : '', }; if (model) camera.model = model; + if (model && manufacturer) camera.manufacturer = manufacturer; cameras.push(camera); } diff --git a/src/data/cctvKingCountySource.test.mjs b/src/data/cctvKingCountySource.test.mjs index 544b4f9ac..a0f765446 100644 --- a/src/data/cctvKingCountySource.test.mjs +++ b/src/data/cctvKingCountySource.test.mjs @@ -1,11 +1,16 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { DEFAULT_KING_COUNTY_MAX_SOURCES, KING_COUNTY_CAMERAS_URL, } from '../../server/providers/cctv/constants.js'; import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js'; import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js'; +import { cctvProxy } from '../../server/providers/cctv.js'; +import { createCatalog } from '../layers/cctv/catalog.js'; /** Set (or, for `undefined`, delete) environment variables for one test. */ function withEnv(t, env) { @@ -106,6 +111,7 @@ test('King County loader keeps live county cameras and upgrades frame URLs to HT assert.equal(novelty.feedType, 'image'); // Published hardware model rides along, whitespace-collapsed. assert.equal(novelty.model, 'Q6135-LE'); + assert.equal(novelty.manufacturer, 'Axis'); assert.equal(novelty.credit, ''); // No facing in the layer: id-hash fallback at low confidence. assert.equal(novelty.headingConfidence, 'low'); @@ -115,6 +121,8 @@ test('King County loader keeps live county cameras and upgrades frame URLs to HT const redmond = cameras.find((camera) => camera.id === 'kingcounty-90'); assert.equal(redmond.credit, 'City of Redmond'); assert.equal(redmond.model, undefined); + // A manufacturer without a model identifies nothing, so it is dropped too. + assert.equal(redmond.manufacturer, undefined); }); test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => { @@ -123,8 +131,10 @@ test('normalizeSourceItem passes a hardware model through and omits it otherwise lat: 47.7, lon: -122.0, model: ' Q6135-LE ', + manufacturer: ' Axis ', }); assert.equal(withModel.model, 'Q6135-LE'); + assert.equal(withModel.manufacturer, 'Axis'); const withoutModel = normalizeSourceItem({ id: 'austin-1', @@ -132,9 +142,105 @@ test('normalizeSourceItem passes a hardware model through and omits it otherwise lon: -97.7, }); assert.equal(withoutModel.model, undefined); + assert.equal(withoutModel.manufacturer, undefined); assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined); }); +/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */ +async function requestSources(plugin) { + let handler; + plugin.configureServer({ + middlewares: { + use(_route, fn) { + handler = fn; + }, + }, + }); + const res = { + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, + }; + await handler({ url: '/sources', method: 'GET' }, res); + assert.equal(res.status, 200); + return JSON.parse(res.body); +} + +/** The browser catalog with the pose math stubbed out: only field + * passthrough is under test here. */ +function browserCatalog() { + const model = { + safeNumber: (value, fallback = NaN) => + Number.isFinite(Number(value)) ? Number(value) : fallback, + normalizeHeading: (deg) => ((deg % 360) + 360) % 360, + clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)), + headingFromId: () => 0, + normalizeFeedType: (type) => type, + ensureCameraPose: () => {}, + }; + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: {} } }, + parts: { model }, + source: {}, + }); +} + +test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => { + quiet(t); + const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + // No file/env catalog, so the live packs load; every pack but King County + // sees an upstream failure and contributes nothing. + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_KINGCOUNTY_ENABLED: undefined, + CCTV_KINGCOUNTY_MAX_SOURCES: undefined, + CCTV_MAX_SOURCES: undefined, + }); + t.mock.method(globalThis, 'fetch', async (url) => { + if (String(url) !== KING_COUNTY_CAMERAS_URL) + return new Response('', { status: 503 }); + return Response.json({ + type: 'FeatureCollection', + features: [ + kingCountyFeature(56, 47.70059, -122.02443, { + properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' }, + }), + kingCountyFeature(90, 47.674, -122.119, { + properties: { Model: null, Manufacturer: 'Cohu' }, + }), + ], + }); + }); + + const body = await requestSources(cctvProxy({ sourceRoot: root })); + const served = new Map(body.sources.map((source) => [source.id, source])); + assert.deepEqual([...served.keys()].sort(), [ + 'kingcounty-56', + 'kingcounty-90', + ]); + assert.equal(served.get('kingcounty-56').model, 'Q6135-LE'); + assert.equal(served.get('kingcounty-56').manufacturer, 'Axis'); + // Absent hardware is an explicit empty string on the wire, like + // headingConfidence, so consumers never have to probe for the key. + assert.equal(served.get('kingcounty-90').model, ''); + assert.equal(served.get('kingcounty-90').manufacturer, ''); + + const cameras = browserCatalog().buildCatalogFromSources(body.sources); + const byId = new Map(cameras.map((camera) => [camera.id, camera])); + assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE'); + assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis'); + assert.equal(byId.get('kingcounty-90').model, ''); + assert.equal(byId.get('kingcounty-90').manufacturer, ''); +}); + test('King County loader honors the max-sources cap', async (t) => { quiet(t); withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' }); diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..76d530b11 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -204,6 +204,8 @@ export function createCatalog({ state: layerState, services, parts, source }) { ? source.groundHeights : null, poseSource, + model: String(source.model || ''), + manufacturer: String(source.manufacturer || ''), }; parts.model.ensureCameraPose(camera); catalog.push(camera); From ffa1891b01b6d39def2d2854560d6e927d4c9cfb Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 015/166] fix(cctv): keep estimated FOV for PTZ/varifocal hardware; carry datasheet range as capability A PTZ/varifocal datasheet FOV is a capability range, not the lens's current zoom, so replacing the frustum's estimate with its wide end presented unknown optical state as known. Parse the full horizontal range (ignoring vertical, "combined" panoramic and lens-note figures), carry it as specFovMinDeg/specFovMaxDeg, and substitute the datasheet FOV only for fixed single-lens hardware. The HUD labels the range as capability beside the estimate. This also stops the Q6000-E Mk II's "360 combined" figure from being read as a 360-degree lens. --- src/layers/cctv/catalog.js | 25 ++++-- src/layers/cctv/modelSpecs.js | 64 +++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 124 ++++++++++++++++++++++++---- src/layers/cctv/presentation.js | 7 +- 4 files changed, 186 insertions(+), 34 deletions(-) diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index be6dc7e90..499e715a0 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,11 +1,17 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; -import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; +import { + lookupModelSpec, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, +} from './modelSpecs.js'; /** * Applies datasheet enrichment to a camera in place: when it names a known - * hardware `model`, the manufacturer's horizontal FOV replaces the pose - * estimate and the spec record is attached for the HUD. Unknown models — the - * common case — change nothing. + * hardware `model`, the spec record is attached for the HUD and the + * datasheet's horizontal FOV range rides along as `specFovMinDeg`/ + * `specFovMaxDeg`. Only fixed-lens hardware replaces the pose estimate: a + * PTZ/varifocal range says what the lens CAN do, not where it is zoomed now. + * Unknown models — the common case — change nothing. * @param {Object} camera - Camera being built (mutated). * @param {string} [modelName] - Hardware model from a source or seed. * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. @@ -13,9 +19,14 @@ import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; function applyModelEnrichment(camera, modelName, clamp) { const spec = modelName ? lookupModelSpec(modelName) : null; if (!spec) return; - const fov = horizontalFovDeg(spec); - if (fov) { - camera.fovDeg = clamp(fov, 20, 125); + const range = horizontalFovRangeDeg(spec); + if (range) { + camera.specFovMinDeg = range.minDeg; + camera.specFovMaxDeg = range.maxDeg; + } + const fixedFov = fixedHorizontalFovDeg(spec); + if (fixedFov) { + camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } camera.spec = spec; diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 1252ce013..a9d12e23d 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -5,8 +5,10 @@ * * When a catalog camera names its hardware model (source packs may set * `model`; hand-authored seeds may too), the model resolves to a datasheet - * record: the camera's frustum then uses the manufacturer's horizontal FOV - * instead of an estimate, and the HUD meta line gains the model's real specs. + * record and the HUD meta line gains the model's real specs. A fixed-lens + * model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or + * multi-sensor range is carried as capability metadata beside the estimate, + * because the lens's current zoom is unknown. * Cameras without an identified model are untouched — unknown never implies * anything. * @@ -376,16 +378,58 @@ export function lookupModelSpec(model) { } /** - * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` - * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide - * end — the honest default for a coverage cone. + * Datasheet horizontal FOV as the range the hardware can cover. Only + * segments stating a horizontal figure count — vertical and "combined" + * panoramic figures never do — and parenthesized lens/sensor notes are + * ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list + * ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range. * @param {Object} spec - A record from this module. - * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + * @returns {{minDeg:number, maxDeg:number}|null} Null when unstated. */ -export function horizontalFovDeg(spec) { - const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); - const value = match ? Number(match[0]) : NaN; - return Number.isFinite(value) && value > 0 ? value : null; +export function horizontalFovRangeDeg(spec) { + const values = []; + for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) { + if (!/horizontal/i.test(segment)) continue; + const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0]; + for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) { + const value = Number(match[0]); + if (Number.isFinite(value) && value > 0) values.push(value); + } + } + if (!values.length) return null; + return { minDeg: Math.min(...values), maxDeg: Math.max(...values) }; +} + +/** + * The datasheet horizontal FOV as the camera's CURRENT optical state, which + * holds only for fixed single-lens hardware stating one figure. PTZ, + * varifocal and multi-sensor models publish a capability range whose in-use + * value is unknown — known hardware capability is not known pose state — so + * they return null and keep their estimated FOV. + * @param {Object} spec - A record from this module. + * @returns {number|null} Fixed horizontal FOV in degrees, or null. + */ +export function fixedHorizontalFovDeg(spec) { + if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1) + return null; + const range = horizontalFovRangeDeg(spec); + return range && range.minDeg === range.maxDeg ? range.minDeg : null; +} + +/** + * HUD token for a datasheet FOV that describes capability rather than the + * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {string|null} Null when the datasheet FOV already IS the camera's + * FOV (fixed lens) or no datasheet range is known. + */ +export function specFovCapabilityToken(camera) { + if (!camera || camera.fovSource === 'datasheet') return null; + const { specFovMinDeg: min, specFovMaxDeg: max } = camera; + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg))); + const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`; + return `SPEC FOV ${span}° (CAPABILITY)`; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index c504e14ad..06ffe3a64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -6,9 +6,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { lookupModelSpec, - horizontalFovDeg, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, + specFovCapabilityToken, specSummary, } from './modelSpecs.js'; +import { createCatalog } from './catalog.js'; test('lookupModelSpec matches case-insensitively and trims', () => { const spec = lookupModelSpec(' q6135-le '); @@ -29,11 +32,11 @@ test('vendored table covers the integrated catalogs published hardware', () => { const cohu = lookupModelSpec('3950'); // King County publishes "3950" assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); - assert.ok(autodome && horizontalFovDeg(autodome) > 0); + assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string assert.ok(vg4); const axis = lookupModelSpec('Q6155-E'); // Sioux Falls - assert.equal(horizontalFovDeg(axis), 66.7); + assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 }); }); test('verbose registry strings resolve via distinctive-token fallback', () => { @@ -80,23 +83,112 @@ test('lookupModelSpec returns null for unknown or empty input — never guesses' assert.equal(lookupModelSpec(undefined), null); }); -test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { - // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). - assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => { + const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model)); + // PTZ zoom: both ends, not just the wide one. + assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 }); + // Vertical figures never count. + assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 }); + // Lens options: parenthesized focal lengths are not FOV figures. + assert.deepEqual(range('DS-2CD2087G3-LI2UY'), { + minDeg: 93.3, + maxDeg: 108.8, + }); + assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 }); + // The "360 combined" panoramic figure is not a horizontal lens FOV. + assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 }); + assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 }); }); -test('horizontalFovDeg reads multi-variant and plain fov strings', () => { - // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. - assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); - // '110 horizontal, 60 vertical' → horizontal figure. - assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null); + assert.equal(horizontalFovRangeDeg({}), null); + assert.equal(horizontalFovRangeDeg(null), null); }); -test('horizontalFovDeg returns null when no numeric FOV is stated', () => { - assert.equal(horizontalFovDeg({ fov_deg: '' }), null); - assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); - assert.equal(horizontalFovDeg({}), null); - assert.equal(horizontalFovDeg(null), null); +test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => { + // Fixed dome, one horizontal figure: the datasheet IS the optical state. + assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); + // Known hardware capability != known pose state: PTZ, multi-sensor, and a + // model sold in several lens options all stay estimated. + for (const model of [ + 'Q6135-LE', + 'XNP-6400RW', + 'AUTODOME IP 5000i', + 'P3707-PE', + 'Q6000-E Mk II', + 'DS-2CD2087G3-LI2UY', + ]) { + assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model); + } + assert.equal(fixedHorizontalFovDeg(null), null); +}); + +test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => { + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }), + 'SPEC FOV 2.4–58° (CAPABILITY)', + ); + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }), + 'SPEC FOV 113° (CAPABILITY)', + ); + // Already the camera's FOV (fixed lens) or nothing known: no token. + assert.equal( + specFovCapabilityToken({ + fovSource: 'datasheet', + specFovMinDeg: 110, + specFovMaxDeg: 110, + }), + null, + ); + assert.equal(specFovCapabilityToken({}), null); + assert.equal(specFovCapabilityToken(null), null); +}); + +/** Browser catalog with the pose math stubbed: only enrichment is under test. */ +function browserCatalog() { + const model = { + safeNumber: (value, fallback = NaN) => + Number.isFinite(Number(value)) ? Number(value) : fallback, + normalizeHeading: (deg) => ((deg % 360) + 360) % 360, + clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)), + headingFromId: () => 0, + normalizeFeedType: (type) => type, + ensureCameraPose: () => {}, + }; + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: {} } }, + parts: { model }, + source: {}, + }); +} + +test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => { + const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([ + { id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' }, + { id: 'fixed', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, + ]); + + // PTZ: the frustum keeps its estimate; the datasheet range is metadata. + assert.equal(ptz.fovDeg, 44); + assert.equal(ptz.fovSource, undefined); + assert.equal(ptz.specFovMinDeg, 2.4); + assert.equal(ptz.specFovMaxDeg, 58.3); + assert.equal(ptz.spec.model, 'Q6135-LE'); + + // Fixed lens: the datasheet figure is the optical state. + assert.equal(fixed.fovDeg, 110); + assert.equal(fixed.fovSource, 'datasheet'); + + // Unknown hardware changes nothing. + assert.equal(unknown.fovDeg, 44); + assert.equal(unknown.spec, undefined); + assert.equal(unknown.specFovMaxDeg, undefined); }); test('specSummary renders the HUD line and degrades by omission', () => { diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 9fa4a7cef..efa18e462 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,7 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; -import { specSummary } from './modelSpecs.js'; +import { specSummary, specFovCapabilityToken } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -42,6 +42,9 @@ export function createPresentation({ headingHudToken(active.camera), // Identified hardware labels its FOV as datasheet-sourced (catalog.js). `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, + // A PTZ/varifocal datasheet range is what the lens can do, not its + // current zoom, so it is shown beside the estimate, never as it. + specFovCapabilityToken(active.camera), `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -94,6 +97,8 @@ export function createPresentation({ fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). fovSource: camera.fovSource || 'estimated', + specFovMinDeg: camera.specFovMinDeg ?? null, + specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, From 5b2e160e54c8259847c34a4915cfd501f6c1bc45 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:15:13 +0000 Subject: [PATCH 016/166] docs(cctv): add the WSDOT pack to CHANGELOG, CURRENT-STATE and README CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. The README and CURRENT-STATE table diffs are column realignment from the widened source cell. --- CHANGELOG.md | 10 ++++++++++ README.md | 42 +++++++++++++++++++++--------------------- docs/CURRENT-STATE.md | 42 +++++++++++++++++++++--------------------- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..912e64ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: + WSDOT's official Travel Center camera layer, with frames registered only on + WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of + Seattle are skipped). Cameras with a single `CompassDirection`, cardinal or + diagonal, get a high-confidence heading; `BW` (Bothways) cameras use the + shared id-hash fallback at low confidence. By default the 250 cameras nearest + Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the cap and + `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, #644). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +244,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/README.md b/README.md index 88690d52f..39daf9e2d 100644 --- a/README.md +++ b/README.md @@ -279,27 +279,27 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) -| Layer | What you get | Source | Auth | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | -| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | -| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **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 | 🟢 | -| 🚗 **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** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. 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) | 🟢 | -| 📻 **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 | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | -| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | -| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | -| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | -| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | -| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | -| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | +| Layer | What you get | Source | Auth | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | +| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **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 | 🟢 | +| 🚗 **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** | ~3,850 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Washington (WSDOT). 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) | 🟢 | +| 📻 **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 | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | +| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | +| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | **Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..edbd44565 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2747,27 +2747,27 @@ 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/opensky` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| 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) | -| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| 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) | +| Layer | Source | File | Proxy | Update Interval | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary + WSDOT (WA) Open Data + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| 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) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| 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) | Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. From c4f18169ebeac81272badaa9c833a4d68be98fd2 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:20:45 +0000 Subject: [PATCH 017/166] fix(cctv): raise the default catalog cap to 4,500 and enforce it over every pack The per-pack defaults already summed to 4,054 before WSDOT (4,304 with it), past the documented 4,000 cap, so a default install was trimming. The existing sum check in the DriveBC test listed packs by hand and missed Calgary and DelDOT. Replace it with a check in cctvCatalogCap.test.mjs that sums every exported DEFAULT_*_MAX_SOURCES, and drop the Calgary test that pinned the cap at exactly 4,000. --- .env.example | 2 +- CHANGELOG.md | 6 ++++- docs/CURRENT-STATE.md | 2 +- server/providers/cctv/constants.js | 7 +++-- src/data/cctvCalgary.test.mjs | 5 ---- src/data/cctvCatalogCap.test.mjs | 28 +++++++++++++++++--- src/data/cctvDriveBcSource.test.mjs | 40 +---------------------------- 7 files changed, 37 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 45ae52099..5f153ca40 100644 --- a/.env.example +++ b/.env.example @@ -182,6 +182,6 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras # CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first -# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs +# CCTV_MAX_SOURCES=4500 # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 912e64ff4..1b559c1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ diagonal, get a high-confidence heading; `BW` (Bothways) cameras use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the cap and - `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra, #644). + `CCTV_WSDOT_ENABLED=0` turns the pack off. The default catalog cap + (`CCTV_MAX_SOURCES`) rises from 4,000 to 4,500: the per-pack defaults already + summed to 4,054 before WSDOT, so a default install was being trimmed. A test + now sums every pack's default and fails if they exceed the cap + (bassem chagra, #644). - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index edbd44565..892c85976 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3422,7 +3422,7 @@ 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` is a 4,000 catalog-wide ceiling shared round-robin + on the main road spine. `CCTV_MAX_SOURCES` is a 4,500 catalog-wide ceiling (4,000 until the WSDOT pack) 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. - **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 3ab8ce9cb..91e684be0 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -9,9 +9,12 @@ export const DEFAULT_AUSTIN_MAX_SOURCES = 250; * 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. Sized above the sum of the - * default per-pack caps so a default install never trims. + * default per-pack caps so a default install never trims (enforced by + * cctvCatalogCap.test.mjs). The per-pack defaults reached 4,304 with WSDOT, + * so this is 4,500: room for one more small pack while staying under + * CCTV_MAX_SOURCES_CEILING, which also sizes the health map. */ -export const DEFAULT_CCTV_MAX_SOURCES = 4000; +export const DEFAULT_CCTV_MAX_SOURCES = 4500; /** Hard upper bound for CCTV_MAX_SOURCES; also sizes the health map. */ export const CCTV_MAX_SOURCES_CEILING = 5000; /** Reference point for Austin camera prioritization (Congress & 6th). */ diff --git a/src/data/cctvCalgary.test.mjs b/src/data/cctvCalgary.test.mjs index 1295c536b..4e7702059 100644 --- a/src/data/cctvCalgary.test.mjs +++ b/src/data/cctvCalgary.test.mjs @@ -12,7 +12,6 @@ import { CALGARY_IMAGE_ORIGIN, CALGARY_MAX_CATALOG_BYTES, DEFAULT_CALGARY_ROWS_URL, - DEFAULT_CCTV_MAX_SOURCES, } from '../../server/providers/cctv/constants.js'; import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js'; import { allocateSourceCap } from '../../server/providers/cctv/cap.js'; @@ -383,7 +382,3 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) Object.assign(process.env, saved); } }); - -test('the shipped catalog ceiling is not raised to make room for this pack', () => { - assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); -}); diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 6f1ff6c59..b0a603b4a 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -4,10 +4,9 @@ import { allocateSourceCap, resolveCatalogCap, } from '../../server/providers/cctv/cap.js'; -import { - CCTV_MAX_SOURCES_CEILING, - DEFAULT_CCTV_MAX_SOURCES, -} from '../../server/providers/cctv/constants.js'; +import * as constants from '../../server/providers/cctv/constants.js'; + +const { CCTV_MAX_SOURCES_CEILING, DEFAULT_CCTV_MAX_SOURCES } = constants; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; const pack = (name, count, prefix = name) => ({ @@ -135,3 +134,24 @@ test('the catalog applies the fair cap to configured packs end to end', async () Object.assign(process.env, saved); } }); + +test('default per-pack camera caps fit inside the default catalog cap', () => { + // Every exported DEFAULT__MAX_SOURCES counts, so a new pack cannot + // slip past this check by not being listed (Warendorf ships 1 curated + // camera and has no cap constant). + const packCaps = Object.entries(constants).filter( + ([name]) => + /^DEFAULT_[A-Z_]+_MAX_SOURCES$/.test(name) && + name !== 'DEFAULT_CCTV_MAX_SOURCES', + ); + assert.ok(packCaps.length >= 13, 'every pack cap constant is found'); + const total = packCaps.reduce((sum, [, cap]) => sum + cap, 0) + 1; + assert.ok( + total <= DEFAULT_CCTV_MAX_SOURCES, + `default packs (${total}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`, + ); + assert.ok( + DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING, + 'the default cap must sit inside the CCTV_MAX_SOURCES ceiling', + ); +}); diff --git a/src/data/cctvDriveBcSource.test.mjs b/src/data/cctvDriveBcSource.test.mjs index 617bb1350..080b1edf0 100644 --- a/src/data/cctvDriveBcSource.test.mjs +++ b/src/data/cctvDriveBcSource.test.mjs @@ -4,21 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; -import { - DEFAULT_AUSTIN_MAX_SOURCES, - DEFAULT_CALTRANS_MAX_SOURCES, - DEFAULT_CCTV_MAX_SOURCES, - CCTV_MAX_SOURCES_CEILING, - DEFAULT_DRIVEBC_MAX_SOURCES, - DEFAULT_TFL_MAX_SOURCES, - DEFAULT_ONTARIO_MAX_SOURCES, - DEFAULT_FINTRAFFIC_MAX_SOURCES, - DEFAULT_TXDOT_MAX_SOURCES, - DEFAULT_TALLINN_MAX_SOURCES, - DEFAULT_TARKTEE_MAX_SOURCES, - DEFAULT_NSW_MAX_SOURCES, - DRIVEBC_WEBCAMS_URL, -} from '../../server/providers/cctv/constants.js'; +import { DRIVEBC_WEBCAMS_URL } from '../../server/providers/cctv/constants.js'; import { driveBcImageCredit, loadDriveBcSourcesFromOpenData, @@ -221,30 +207,6 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r ); }); -test('default per-pack camera caps fit inside the default catalog cap', () => { - // Every default pack at its own cap (Warendorf ships 1 curated camera). - const packs = - DEFAULT_AUSTIN_MAX_SOURCES + - DEFAULT_CALTRANS_MAX_SOURCES + - DEFAULT_TFL_MAX_SOURCES + - DEFAULT_ONTARIO_MAX_SOURCES + - DEFAULT_FINTRAFFIC_MAX_SOURCES + - DEFAULT_DRIVEBC_MAX_SOURCES + - DEFAULT_TXDOT_MAX_SOURCES + - DEFAULT_TALLINN_MAX_SOURCES + - DEFAULT_TARKTEE_MAX_SOURCES + - DEFAULT_NSW_MAX_SOURCES + - 1; - assert.ok( - packs <= DEFAULT_CCTV_MAX_SOURCES, - `default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`, - ); - assert.ok( - DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING, - 'the default cap must sit inside the CCTV_MAX_SOURCES ceiling', - ); -}); - test('DriveBC credit keeps partner attribution and drops operational notes', () => { assert.equal( driveBcImageCredit( From 5dfe3f0525f1c26541829d9da699236df65634df Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:23:23 +0000 Subject: [PATCH 018/166] docs(cctv): add the King County pack to CHANGELOG, CURRENT-STATE, README and .env.example CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR, and new env controls belong in .env.example with the other CCTV pack controls. The README and CURRENT-STATE table diffs are column realignment from the widened source cell. --- .env.example | 2 ++ CHANGELOG.md | 12 ++++++++++++ README.md | 42 +++++++++++++++++++++--------------------- docs/CURRENT-STATE.md | 42 +++++++++++++++++++++--------------------- 4 files changed, 56 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index e16e85a27..f1ff1898f 100644 --- a/.env.example +++ b/.env.example @@ -180,6 +180,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras +# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..810391db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Add King County road cameras (Washington State) as a keyless CCTV source + pack: one public King County DOT ArcGIS layer, fetched with an explicit field + list so its staff-name columns are never requested, and frames pinned to the + county's own image host and upgraded to HTTPS. WSDOT-owned rows are skipped, + and city-owned cameras the county hosts credit their owner. The layer + publishes no facing, so headings use the shared id-hash fallback at low + confidence. It does publish each camera's hardware, which is served on + `/api/cctv/sources` as `model` and `manufacturer`. `CCTV_KINGCOUNTY_MAX_SOURCES` + sets the cap and `CCTV_KINGCOUNTY_ENABLED=0` turns the pack off + (bassem chagra, #645). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +246,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/README.md b/README.md index 88690d52f..50a86253d 100644 --- a/README.md +++ b/README.md @@ -279,27 +279,27 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) -| Layer | What you get | Source | Auth | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | -| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | -| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **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 | 🟢 | -| 🚗 **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** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary. 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) | 🟢 | -| 📻 **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 | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | -| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | -| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | -| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | -| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | -| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | -| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | +| Layer | What you get | Source | Auth | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | +| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **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 | 🟢 | +| 🚗 **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** | ~3,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · King County (WA). 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) | 🟢 | +| 📻 **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 | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | +| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | +| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | **Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..2946a26e8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2747,27 +2747,27 @@ 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/opensky` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| 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) | -| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| 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) | +| Layer | Source | File | Proxy | Update Interval | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/opensky` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/adsblol/mil` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| 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 | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary + King County (WA) Open Data + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| 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) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| 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) | Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. From cce159c93d6c7023e65589c1024bf83de45a1499 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:24:13 +0000 Subject: [PATCH 019/166] docs(cctv): document identified-hardware specs and datasheet FOV in CHANGELOG and CURRENT-STATE CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. --- CHANGELOG.md | 9 +++++++++ docs/CURRENT-STATE.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..21a8cb743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- CCTV cameras with identified hardware show the model's datasheet specs. When a + source names a camera model found in the vendored CCTV Camera Database records + (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. + A fixed single-lens model's horizontal FOV replaces the estimate + (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not + its current zoom, so those cameras keep the estimate and show + `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +243,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..5e1ccd447 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3390,6 +3390,13 @@ silently demoting every later lookup for the session. (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). +- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that + resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera + Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens + hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + multi-sensor datasheets publish a capability range, not the lens's current zoom, so those + cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public + camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's From 7c7563d91b63be493312e3b89e088510274bbe97 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:48:00 +0700 Subject: [PATCH 020/166] fix(gbfs): rate-limit /api/gbfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy already restricted WHERE it would go — https, an allowlisted host, a station_information/station_status path, no redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted HOW OFTEN, so the dev server would relay an unbounded stream of requests at a third-party bikeshare operator on any caller's behalf, one outbound fetch and one timeout timer each. Admit 120 requests/minute per client address, global backstop 3x, refused with 429 and `Retry-After` before the target is even parsed. The ceiling comes from the layer's own demand: `src/layers/bikeshare/registry.js` catalogues 15 systems, station information is cached per city after its first fetch, and station status refreshes once per city every STATUS_POLL_MS (60s) with in-flight requests deduplicated — about 15 requests a minute with everything in range, so 120 is eight times a maximal session. The method, host, path, redirect, deadline and size guards are unchanged. Refs #264 --- CHANGELOG.md | 9 ++ docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/providers/gbfs.js | 34 +++++- src/data/gbfsProxyRateLimit.test.mjs | 152 +++++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/data/gbfsProxyRateLimit.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f1c6ffb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..f55945224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -827,6 +827,15 @@ feed address is read from the path, and a query-string form is refused with a 40 streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +The route also bounds request frequency: 120 requests/minute per client +address, with a global backstop of three times that. A refused request answers +429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound +fetch. The bikeshare layer's own demand is about 15 requests a minute with +every catalogued system in range, so an ordinary session never meets the limit; +the method, host and path guards run unchanged. + +Updated: September 21, 2026 + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..e7b7a92d0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..1f1db490d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -138,6 +138,8 @@ "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js", "src/data/gbfsSource.js", "src/sources/httpBody.js" ], diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..001e94a7d 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,4 +1,5 @@ import { readResponseTextCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { isAllowedGbfsHost, isAllowedGbfsPath, @@ -13,6 +14,19 @@ const GBFS_PROXY_TIMEOUT_MS = 12000; export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB +/** + * Requests/minute/IP admitted by the proxy. + * + * The bikeshare layer's own demand is small and known: `src/layers/bikeshare/ + * registry.js` catalogues 15 systems, station information is cached per city + * after its first fetch, and station status refreshes once per city every + * `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every + * catalogued system in camera range at once that is about 15 requests a minute, + * so 120 is eight times a maximal real session while still capping a caller + * using this proxy as an open relay against a third-party GBFS host. + */ +export const GBFS_RATE_PER_MIN = 120; + function gbfsRedirectHost(location, requestUrl) { if (!location) return ''; try { @@ -113,7 +127,12 @@ export async function fetchGbfsUpstream( * * @returns {import('vite').Plugin} */ -export function gbfsProxy() { +export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: ratePerMinute, + globalMax: ratePerMinute * 3, + }); const installMiddleware = (server) => { server.middlewares.use('/api/gbfs', async (req, res) => { try { @@ -126,6 +145,19 @@ export function gbfsProxy() { return; } + // Frequency, checked before the target is even parsed: the allowlist + // already says WHERE this proxy may go, nothing said HOW OFTEN. A + // refused request performs no outbound fetch and starts no timer. + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { diff --git a/src/data/gbfsProxyRateLimit.test.mjs b/src/data/gbfsProxyRateLimit.test.mjs new file mode 100644 index 000000000..72ee38152 --- /dev/null +++ b/src/data/gbfsProxyRateLimit.test.mjs @@ -0,0 +1,152 @@ +// GBFS PROXY — the frequency the allowlist never bounded. +// +// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a +// station_information/station_status path, a 12 s timeout, a 5 MB body cap and +// no redirects. Nothing said HOW OFTEN, so the dev server would relay an +// unbounded stream of requests at a third-party bikeshare operator on any +// caller's behalf — one outbound fetch and one timeout timer each. +// +// These cases drive the real mounted middleware. `fetch` is stubbed per test +// and restored afterwards, so a case that reaches the network fails loudly, +// and the stub's call count is what proves a refused request costs nothing. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js'; + +const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json'; +const STATION_BODY = '{"data":{"stations":[]}}'; + +/** Mount the proxy the way the dev server does; returns its route handler. */ +function mount(options) { + const routes = new Map(); + gbfsProxy(options).configureServer({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); + return routes.get('/api/gbfs'); +} + +function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) { + return new Promise((resolve, reject) => { + const req = { + method: method || 'GET', + // connect strips the mount prefix before the handler sees the URL. + url: '/' + encodeURIComponent(target), + headers: {}, + socket: remoteAddress ? { remoteAddress } : undefined, + }; + let status = 200; + let headers = {}; + const res = { + writeHead(code, sent) { + status = code; + headers = sent || {}; + return res; + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ status, headers, body: parsed }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +/** Answer every allowlisted upstream, counting the calls that got through. */ +function stubUpstream(t) { + const asked = []; + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + asked.push(String(url)); + return new Response(STATION_BODY, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + t.after(() => { + globalThis.fetch = original; + }); + return asked; +} + +test('a maximal real session is never throttled', async (t) => { + const asked = stubUpstream(t); + const handler = mount(); + + // The bikeshare layer catalogues 15 systems and refreshes each one's status + // once every 60 s, so a minute of the busiest possible session is ~15 + // requests. Run eight times that and expect every one of them served. + let served = 0; + for (let i = 0; i < 120; i += 1) { + const res = await request(handler); + if (res.status === 200) served += 1; + } + assert.equal(served, 120, 'the shipped ceiling leaves a real session alone'); + assert.equal(asked.length, 120); + assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on'); +}); + +test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 5 }); + + const responses = []; + for (let i = 0; i < 12; i += 1) responses.push(await request(handler)); + + const served = responses.filter((r) => r.status === 200); + const refused = responses.filter((r) => r.status === 429); + assert.equal(served.length, 5, 'exactly the admitted budget is served'); + assert.equal(refused.length, 7); + assert.equal(refused[0].body.error, 'Rate limit exceeded'); + assert.equal(refused[0].headers['Retry-After'], '10'); + assert.equal(refused[0].headers['Cache-Control'], 'no-store'); + assert.equal( + asked.length, + 5, + 'a refused request never reaches the GBFS operator', + ); +}); + +test('a different client keeps its own budget', async (t) => { + stubUpstream(t); + const handler = mount({ ratePerMinute: 3 }); + + for (let i = 0; i < 6; i += 1) + await request(handler, { remoteAddress: '10.0.0.1' }); + const noisy = await request(handler, { remoteAddress: '10.0.0.1' }); + assert.equal(noisy.status, 429, 'the noisy client spent its own budget'); + + const quiet = await request(handler, { remoteAddress: '10.0.0.2' }); + assert.equal(quiet.status, 200, 'a quiet client is not collateral'); +}); + +test('the limiter does not displace the existing guards', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 10 }); + + const wrongMethod = await request(handler, { method: 'POST' }); + assert.equal(wrongMethod.status, 405); + + const offAllowlist = await request(handler, { + target: 'https://example.com/gbfs/station_status.json', + }); + assert.equal(offAllowlist.status, 403); + assert.equal(offAllowlist.body.error, 'GBFS host not allowed'); + + const wrongPath = await request(handler, { + target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json', + }); + assert.equal(wrongPath.status, 400); + + assert.equal(asked.length, 0, 'none of those reached upstream'); +}); From 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH 021/166] feat(flights): publish verticalRateMps on the tracked-aircraft descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapAnalystRecord` has always published `verticalRateMps`, but the tracked seam — `getTrackedInfo()` / `getTrackedSubject()`, built by `_describeFlight` — did not, so anything needing climb/descent had to reach past the public descriptor into the raw poll record. Both layers now map it, which matters because they store it under different keys: the civil records write `verticalRate` (records.js), the military ones write `verticalRateMps`. Mapping only one would leave `resolveTrackedAircraftInfo()` handing back two different shapes, and a consumer reading the field off a civil contact would silently read every military contact as level flight. Reported, never derived. `stickyNumber` seeds the field with null, so an aircraft that has not transmitted a rate publishes null rather than a level-flight 0 — absent is not level, and a flight-phase classifier that cannot tell them apart calls a climbing aircraft cruise. Tests: both layers, both halves (a reported rate arrives with its sign; an absent one stays null). Mutation-verified — removing either field or defaulting it to 0 turns them red. `docs/CURRENT-STATE.md` records the descriptor field, the two record keys it reconciles, and the reported-never-derived rule. --- CHANGELOG.md | 5 ++++ docs/CURRENT-STATE.md | 7 +++++ src/data/flights.test.mjs | 49 ++++++++++++++++++++++++++++++- src/data/militaryFlights.test.mjs | 45 ++++++++++++++++++++++++++++ src/layers/flights/queries.js | 13 ++++++-- src/layers/military/queries.js | 11 +++++-- 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..ae79c5dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,11 @@ Native point, billboard and label highlighting uses GPU draw commands; there is no scene-dimming effect selector. Unsupported shaders retain native contact rendering, and leaving Cyber restores the standard shell and contact treatment. +- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the + civil and military layers, so a consumer reading `getTrackedInfo()` no longer + has to reach past it into the raw poll record for climb/descent. Reported, + never derived: an aircraft that has not transmitted a rate reads as `null` + rather than a level-flight `0`. - Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). Select a box (drag, the current view, or around a pin; up to 1,000 km a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..87db7a7ac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -567,6 +567,13 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +The tracked-aircraft descriptor both flight layers hand to the cockpit carries +`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as +`verticalRate` and military records as `verticalRateMps`; the query seam maps both to +one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The +rate is reported, never derived: a blank poll keeps the last transmitted value and an +absent rate stays null rather than reading as level flight. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 2834e8833..48fb05737 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -487,7 +487,15 @@ const LABEL_ICAO = 'ae1fa4'; const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); /** Seed one civil contact through the production refresh seam. */ -function seedLabelContact({ callsign, registration, tracked = false }) { +function seedLabelContact({ + callsign, + registration, + tracked = false, + // Per-test metadata overrides, spread last so a test can set a field to + // `undefined` to model "the feed never sent it" — a default parameter + // could not express that, it would just re-supply the default. + meta: metaOverrides = {}, +}) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, @@ -509,6 +517,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { turnRateDps: 0, rawLat: 30.21, rawLon: -97.71, + ...metaOverrides, }, }); } @@ -574,6 +583,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration', assert.equal(flightsLayer.getTrackedInfo()?.registration, null); }); +test('the tracked descriptor publishes the reported vertical rate', () => { + // `mapAnalystRecord` has always published `verticalRateMps`; the tracked + // seam did not, so a consumer that needs climb/descent (a flight-phase + // classifier, the cockpit) had to reach past getTrackedInfo() into the + // raw record. Both seams now answer with the same field name and unit. + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: -6.4 }, + }); + assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4); + assert.equal( + mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps, + -6.4, + 'the analyst seam and the tracked seam must agree', + ); +}); + +test('an unreported vertical rate stays null, never a level-flight zero', () => { + // Absent is not level. `stickyNumber` seeds this field with null, so a + // contact that has never transmitted a rate must not read as 0 m/s — a + // phase classifier would call that cruise while the aircraft climbs. + for (const missing of [undefined, null, Number.NaN]) { + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: missing }, + }); + assert.equal( + flightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); + test('civil label chain: identity stays icao24 while the label moves', () => { for (const fixture of [ { callsign: 'SWA696', registration: 'N123AB' }, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 122b6b132..faf9722fc 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -603,3 +603,48 @@ test('two contacts matching at the same strength resolve deterministically', () assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); assert.equal(second, first, 'and the same query resolves the same way every time'); }); + +test('the military tracked descriptor publishes the reported vertical rate', () => { + // Shape parity with the civil layer's getTrackedInfo(). The two layers store + // the rate under different record keys (`verticalRateMps` here, + // `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one + // shape if BOTH descriptors map it — a consumer that reads the field off the + // civil seam and finds it missing on the military one would silently treat + // every military contact as level flight. + const seedRate = (verticalRateMps) => + _setTrackedMilitaryRefreshStateForTest({ + icao24: 'ae7f01', + entity: { gevLabelModel: { title: 'TEST', details: [] } }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), + color: Cesium.Color.WHITE, + show: true, + }, + billboardCollection: { show: true, remove() {} }, + viewer: { camera: { positionCartographic: null }, scene: {} }, + meta: { + callsign: 'RCH512', + rawLat: 31, + rawLon: -97, + altitudeFt: 26_000, + speedMps: 220, + track: 95, + onGround: false, + verticalRateMps, + }, + }); + + seedRate(12.7); + assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7); + + // Absent is not level: a contact that has never transmitted a rate must read + // as null, not as a 0 m/s a phase classifier would call cruise. + for (const missing of [undefined, null, Number.NaN]) { + seedRate(missing); + assert.equal( + militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..423f43788 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -113,7 +113,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -163,6 +163,15 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Climb/descent rate, the one kinematic field `mapAnalystRecord` already + // published (as `verticalRateMps`) but this descriptor did not, so a + // consumer reading the tracked seam had to reach back into `_flightData` + // for it. Reported, never derived: `stickyNumber` in records.js keeps the + // last value across a blank poll, and an absent rate stays null rather + // than becoming a level-flight 0 the aircraft never transmitted. + verticalRateMps: Number.isFinite(info?.verticalRate) + ? info.verticalRate + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -834,7 +843,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..9553cf181 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -86,7 +86,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -129,6 +129,13 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Same field, same name, same null-when-absent rule as the civil + // descriptor — the two layers store the record under different keys + // (`verticalRateMps` here, `verticalRate` there), so only the mapping + // makes `resolveTrackedAircraftInfo` hand back one shape for both. + verticalRateMps: Number.isFinite(info?.verticalRateMps) + ? info.verticalRateMps + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -755,7 +762,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { From 114b0eeaba1d3cb9cb142e4521c79b396d3e439d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:14:11 +0700 Subject: [PATCH 022/166] feat(tooling): one changelog file per pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CONTRIBUTING.md` asks every runtime change to add an entry at the top of `CHANGELOG.md`, so two pull requests in flight write into the same few lines and git has nothing to merge on. Against `main` at `81eb443`, `git merge-tree` over the 85 open pull requests GitHub reports as conflicting that aren't mine puts `CHANGELOG.md` in the conflict set of 41 — more than any other path — and for 6 it is the only one. `docs/CURRENT-STATE.md`, the other file rule 3 names, conflicts for 23, and merges cleanly for 23 of the 41: edits land in the section for the area you touched, so they only collide when the areas do. A pull request now adds `changelog.d/-.md` instead. Distinct filenames never conflict, and the pull request number makes them distinct for free. `npm run changelog` splices the directory into `CHANGELOG.md` and empties it; `npm run changelog:check` reports the batch and changes nothing. Fragment text is copied through unchanged apart from trailing whitespace. `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a fixed window after a heading, so an assembler that reflowed an entry could move text across that window. Fragments are deliberately outside `scripts/format-scope.json` for the same reason. The assembler refuses a batch before writing anything when a filename is not `-.md` or two fragments claim one pull request number, and a unit test runs that check over the real `changelog.d/`, so a fragment the release could not assemble fails `npm test` in the pull request that adds it rather than at release time. Refs #781. --- CONTRIBUTING.md | 2 +- changelog.d/README.md | 45 ++++++ docs/MAINTAINER_WORKFLOW.md | 11 +- package.json | 2 + scripts/assemble-changelog.mjs | 120 ++++++++++++++++ scripts/format-scope.json | 4 +- src/tooling/changelogFragments.test.mjs | 178 ++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 scripts/assemble-changelog.mjs create mode 100644 src/tooling/changelogFragments.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdfbb6547..56775e824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. -3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..26a7c2e07 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,45 @@ +# Changelog fragments + +One file per entry. A pull request adds a file here instead of editing +`CHANGELOG.md`, and a release concatenates them into it. + +``` +changelog.d/-.md +``` + +`723-atc-decision-layer.md`, for example. The slug is lowercase, words separated +by `-`. Two pull requests never pick the same filename, because the pull request +number is already unique — which is the point: new `CHANGELOG.md` entries go at +the top of the file, so two pull requests in flight write into the same few lines +and git has nothing to merge on. + +Write the file exactly as the entry should read in `CHANGELOG.md`: a `-` bullet, +wrapped the way the surrounding entries are, crediting yourself and the pull +request. + +```markdown +- Report which upstream declined a Street Traffic road load. The layer row now + reads `Overpass rate-limited` instead of a general "Road data temporarily + unavailable" (yourname, #665). +``` + +The text is spliced in unchanged apart from trailing whitespace, so what you +write here is what ships. Fragments are deliberately outside +`scripts/format-scope.json`, so `npm run format` never reflows one. + +At release time: + +``` +npm run changelog:check # list what would move, change nothing +npm run changelog # move every fragment into CHANGELOG.md +``` + +The assembler refuses a batch rather than guessing: a filename that is not +`-.md`, or two fragments claiming one pull request number, +stops the run before anything is written and names the files. + +A documentation claim that needs a regression test — the way +`src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should +assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request +still edits those directly, so the text is in the file while the change is under +review; a fragment does not reach `CHANGELOG.md` until release. diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index 39c87be4b..632400c9c 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -57,7 +57,8 @@ with a reason; usefulness can also be `decline` or `discuss`. - Inspect removed tests, skipped tests, relaxed assertions, snapshot updates, mocks, and test-runner changes. A green result from weakened tests is not acceptance. - Check applicable documentation updates: runtime changes need `CURRENT-STATE.md` - and `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. + and a `changelog.d/-.md` fragment rather than an edit to + `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. ### 3. Security and supply chain @@ -173,6 +174,14 @@ Before merging: a stale merge. Verify the merged result matches the validated candidate tree and record the resulting commit and PR URL. Stop and report any mismatch. +Pending release notes live in `changelog.d/` as one file per pull request, so an +integration never has to resolve the top of `CHANGELOG.md`. When cutting a release, +run `npm run changelog:check` to see the batch and the order it would take, then +`npm run changelog` to splice it into `CHANGELOG.md` and empty the directory. The +assembler refuses a malformed filename or two fragments claiming one pull request +number before it writes anything, and copies the text through unchanged, so the +contributor's own wording and credit are what ship. + Repository rules enforce only their configured requirements; this document and the skill do not configure branch protection or CI. Maintainers should require CI and review on `main` and dismiss stale approvals when changes are pushed. diff --git a/package.json b/package.json index e48881c6d..4fd753168 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "format:check": "node scripts/format.mjs --check", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "changelog": "node scripts/assemble-changelog.mjs --write", + "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs new file mode 100644 index 000000000..8f3e854ef --- /dev/null +++ b/scripts/assemble-changelog.mjs @@ -0,0 +1,120 @@ +import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Release-time assembly of changelog fragments. + * + * A pull request adds `changelog.d/-.md` instead of editing + * `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in + * flight no longer write into the same few lines at the top of one file. A + * release splices the fragments in and empties the directory. + * + * Fragment text is copied through unchanged apart from trailing whitespace: + * `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a + * fixed window after a heading, so reflowing an entry here could move text + * across that window. + */ +export const FRAGMENT_DIR = 'changelog.d'; +const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; +const HEADING = '# Changelog\n'; + +/** Fragment file names a release may consume, highest pull request first. */ +export function orderFragmentNames(names) { + const claimed = new Map(); + for (const name of names) { + if (name === 'README.md') continue; + const match = FRAGMENT_NAME.exec(name); + if (!match) { + throw new Error( + `Unexpected file in ${FRAGMENT_DIR}/: ${name}` + + ' — expected -.md with a lowercase slug', + ); + } + const number = Number(match[1]); + const prior = claimed.get(number); + if (prior) { + throw new Error( + `Two fragments claim pull request ${number}: ${prior} and ${name}`, + ); + } + claimed.set(number, name); + } + return [...claimed.entries()] + .sort(([a], [b]) => b - a) + .map(([, name]) => name); +} + +/** Splice fragment bodies above the existing entries, newest batch on top. */ +export function assembleChangelog(changelog, fragments) { + if (!fragments.length) return changelog; + if (!changelog.startsWith(HEADING)) { + throw new Error('CHANGELOG.md must start with "# Changelog"'); + } + const entries = changelog.slice(HEADING.length).replace(/^\n+/, ''); + const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n'); + return `${HEADING}\n${batch}\n\n${entries}`; +} + +/** + * Report or apply the pending fragments in `/changelog.d`. + * + * The batch is validated in full before anything is written, so a malformed + * filename or a duplicate pull request number leaves the directory untouched. + * + * @param {string} root repository root + * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @returns {Promise<{ consumed: string[], changelog: string }>} + */ +export async function assembleFragments(root, mode) { + if (!['--check', '--write'].includes(mode)) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const directory = path.join(root, FRAGMENT_DIR); + const changelogFile = path.join(root, 'CHANGELOG.md'); + const consumed = orderFragmentNames(await readdir(directory)); + const bodies = []; + for (const name of consumed) { + bodies.push(await readFile(path.join(directory, name), 'utf8')); + } + const changelog = assembleChangelog( + await readFile(changelogFile, 'utf8'), + bodies, + ); + if (mode === '--write' && consumed.length) { + await writeFile(changelogFile, changelog, 'utf8'); + for (const name of consumed) await rm(path.join(directory, name)); + } + return { consumed, changelog }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const mode = process.argv[2]; + const { consumed } = await assembleFragments( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (!consumed.length) { + console.log(`No fragments in ${FRAGMENT_DIR}/.`); + } else { + console.log( + `${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` + + `\n ${consumed.join('\n ')}`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..974275366 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -844,5 +844,7 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "scripts/assemble-changelog.mjs", + "src/tooling/changelogFragments.test.mjs" ] diff --git a/src/tooling/changelogFragments.test.mjs b/src/tooling/changelogFragments.test.mjs new file mode 100644 index 000000000..880e3318d --- /dev/null +++ b/src/tooling/changelogFragments.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + FRAGMENT_DIR, + assembleChangelog, + assembleFragments, + orderFragmentNames, +} from '../../scripts/assemble-changelog.mjs'; + +const EXISTING = + '# Changelog\n\n- An entry that was already released (someone, #1).\n'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); + +async function fixture(t, fragments = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-changelog-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, FRAGMENT_DIR)); + await writeFile( + path.join(root, FRAGMENT_DIR, 'README.md'), + '# How to add one\n', + ); + await writeFile(path.join(root, 'CHANGELOG.md'), EXISTING); + for (const [name, body] of Object.entries(fragments)) { + await writeFile(path.join(root, FRAGMENT_DIR, name), body); + } + return root; +} + +test('the batch reads newest pull request first', () => { + assert.deepEqual(orderFragmentNames(['7-b.md', '112-c.md', '19-a.md']), [ + '112-c.md', + '19-a.md', + '7-b.md', + ]); +}); + +test('the directory README is documentation, never an entry', () => { + assert.deepEqual(orderFragmentNames(['README.md', '5-a.md']), ['5-a.md']); +}); + +test('two fragments claiming one pull request fail loudly, not silently', () => { + assert.throws( + () => orderFragmentNames(['5-first.md', '5-second.md']), + /Two fragments claim pull request 5/, + ); +}); + +test('a name that is not -.md is refused', () => { + for (const name of ['notes.md', '5_underscore.md', '5-Caps.md', '5-a.txt']) { + assert.throws(() => orderFragmentNames([name]), /Unexpected file/, name); + } +}); + +test('fragment text is spliced in unchanged, above the existing entries', () => { + // Wrapped, indented and punctuated the way a real entry is: the continuation + // indent and the blank line inside the entry are the parts a reflow would eat. + const entry = [ + '- A new thing, with `code` and a — dash. The second line is indented the', + ' way the surrounding entries wrap, and it keeps its two spaces.', + '', + ' A second paragraph, because some entries have one (someone, #9).', + ].join('\n'); + const result = assembleChangelog(EXISTING, [`${entry}\n\n\n`]); + assert.equal( + result, + `# Changelog\n\n${entry}\n\n- An entry that was already released (someone, #1).\n`, + ); + // The entry survives byte for byte: drawTool.test.mjs reads this file as + // prose and slices a fixed window, so reflowing would move text across it. + assert.ok(result.includes(entry)); +}); + +test('a CHANGELOG without the heading is refused rather than prepended to', () => { + assert.throws( + () => assembleChangelog('- orphan entry\n', ['- new\n']), + /must start with "# Changelog"/, + ); +}); + +test('assembling consumes the fragments and leaves the README', async (t) => { + const root = await fixture(t, { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + }); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, ['100-first.md', '20-second.md']); + const changelog = await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'); + assert.match( + changelog, + /# Changelog\n\n- First \(someone, #100\)\.\n\n- Second \(someone, #20\)\.\n\n- An entry that was already released/, + ); + assert.deepEqual(await readdir(path.join(root, FRAGMENT_DIR)), ['README.md']); +}); + +test('a rejected batch leaves the directory and the file untouched', async (t) => { + const root = await fixture(t, { + '8-first.md': '- First (someone, #8).', + '8-again.md': '- Again (someone, #8).', + }); + await assert.rejects( + assembleFragments(root, '--write'), + /Two fragments claim pull request 8/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '8-again.md', + '8-first.md', + 'README.md', + ]); +}); + +test('check mode changes nothing on disk', async (t) => { + const root = await fixture(t, { '3-a.md': '- Something (someone, #3).' }); + const { consumed } = await assembleFragments(root, '--check'); + assert.deepEqual(consumed, ['3-a.md']); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '3-a.md', + 'README.md', + ]); +}); + +test('an empty directory is a no-op, so a release can always run it', async (t) => { + const root = await fixture(t); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, []); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + +test('a mode that is neither check nor write is refused', async (t) => { + const root = await fixture(t); + await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/); +}); + +// The two below run against the repository itself, so a fragment that the +// release could not assemble fails `npm test` in the pull request that adds it +// rather than at release time, when its author has moved on. + +test('every pending fragment in the repository can be assembled', async () => { + const names = await readdir(path.join(repositoryRoot, FRAGMENT_DIR)); + assert.ok( + names.includes('README.md'), + `${FRAGMENT_DIR}/README.md documents the convention and keeps the directory tracked`, + ); + // Throws on a name that is not -.md, and on two fragments + // claiming one pull request number. Every other file is an entry. + assert.equal(orderFragmentNames(names).length, names.length - 1); +}); + +test('CHANGELOG.md still carries the heading a release splices under', async () => { + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ); + assert.ok(changelog.startsWith('# Changelog\n')); +}); From d29ff40d04a1ce7de8872488159e38e39f570339 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:17:18 +0700 Subject: [PATCH 023/166] docs: add this pull request's own changelog fragment (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfoods the convention rather than describing it: the entry for #787 arrives as `changelog.d/787-changelog-fragments.md` and `CHANGELOG.md` is untouched. A fragment needs the pull request number, which only exists once the pull request is open, so it lands in a second commit. `changelog.d/README.md` says to push first and add the fragment after, for the same reason. Checked against the one consumer that constrains the design: assembling this fragment moves the `DISPLAY ▸ Draw` marker in `CHANGELOG.md` by exactly the length of the spliced entry, and the 1200-character window that `src/annotations/drawTool.test.mjs` slices after it is byte-identical. --- changelog.d/787-changelog-fragments.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/787-changelog-fragments.md diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md new file mode 100644 index 000000000..41509c046 --- /dev/null +++ b/changelog.d/787-changelog-fragments.md @@ -0,0 +1,7 @@ +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). From 9c15f093a1705b52807d44ab02a57f40249ee977 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Wed, 23 Sep 2026 21:54:43 -0500 Subject: [PATCH 024/166] fix(cyclones): accept advisories NHC publishes before their nominal time NHC stamps an advisory with its nominal time and writes it about half an hour early, so the 5-minute future limit rejected the whole feed each advisory cycle and the layer showed no storms. Allow 90 minutes ahead. --- CHANGELOG.md | 5 +++++ server/providers/cyclones.js | 5 ++++- src/data/cyclonesProxy.test.mjs | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..431d282f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Cyclone advisories no longer go dark while NHC publishes an advisory ahead + of its nominal time: advisory and position times up to 90 minutes ahead of + the server clock are accepted (previously 5 minutes), so the whole feed is + not rejected for about half an hour each advisory cycle. + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index fede1aade..d580357ce 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -41,6 +41,9 @@ function number(value, min, max) { const n = Number(value); return Number.isFinite(n) && n >= min && n <= max ? n : null; } +// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it +// up to about half an hour before; allow that, but not a clock far ahead. +const EARLY_ADVISORY_MS = 90 * 60_000; function iso(value, now) { if ( typeof value !== 'string' || @@ -50,7 +53,7 @@ function iso(value, now) { const n = Date.parse(value); if ( !Number.isFinite(n) || - n > now + 5 * 60_000 || + n > now + EARLY_ADVISORY_MS || now - n > 12 * HOUR || new Date(n).toISOString().replace('.000Z', 'Z') !== value.replace('.000Z', 'Z') diff --git a/src/data/cyclonesProxy.test.mjs b/src/data/cyclonesProxy.test.mjs index 04729d33a..31433b326 100644 --- a/src/data/cyclonesProxy.test.mjs +++ b/src/data/cyclonesProxy.test.mjs @@ -162,6 +162,15 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []); }); +test('advisories published before their nominal time are accepted, far-future ones are not', () => { + // NHC wrote the 03:00Z advisory at 02:34Z. + const early = Date.UTC(2026, 8, 16, 2, 34); + assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME); + assert.throws(() => + parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)), + ); +}); + test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => { const storms = parseCycloneStatus(status(), NOW); const parts = collections(); From bde1e430efdee543f5a9ff88e341843d8569c2a9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:23 +0200 Subject: [PATCH 025/166] feat(i18n): locale core + English catalogs + parity gates Add the i18n foundation: src/i18n/locale.js (locale resolution with a configurable GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, guarded preference storage, one-shot ?lang= override, / metadata) and src/i18n/index.js (catalog registry, t(), Intl formatters, applyDocumentTranslations, persistLocaleAndReload). English is the only shipped locale in this PR: the four en catalogs (shell/cockpit/layers/setup) are the reference and the always-shipped fallback, the built-in pair degenerates to en-only, and English output is byte-identical to the strings these replace. Parity gates land with the core (catalog key/placeholder/plural-shape parity, markup coverage of every data-i18n* attribute, repair-pass anchors pinning en byte-identity); additional locales ship as stacked follow-up PRs through the recipe in docs/TRANSLATORS.md. Rebased onto current main: the client defines live in build/vite.js / server/standalone/vite.config.js as before. --- .env.example | 14 + build/vite.js | 10 + server/standalone/vite.config.js | 2 + src/i18n/catalog.test.mjs | 198 +++++++++++ src/i18n/i18n.test.mjs | 457 +++++++++++++++++++++++++ src/i18n/index.js | 348 +++++++++++++++++++ src/i18n/locale.js | 314 +++++++++++++++++ src/i18n/locales/en/cockpit.js | 560 +++++++++++++++++++++++++++++++ src/i18n/locales/en/layers.js | 501 +++++++++++++++++++++++++++ src/i18n/locales/en/setup.js | 211 ++++++++++++ src/i18n/locales/en/shell.js | 82 +++++ src/tooling/viteBuild.test.mjs | 5 + 12 files changed, 2702 insertions(+) create mode 100644 src/i18n/catalog.test.mjs create mode 100644 src/i18n/i18n.test.mjs create mode 100644 src/i18n/index.js create mode 100644 src/i18n/locale.js create mode 100644 src/i18n/locales/en/cockpit.js create mode 100644 src/i18n/locales/en/layers.js create mode 100644 src/i18n/locales/en/setup.js create mode 100644 src/i18n/locales/en/shell.js diff --git a/.env.example b/.env.example index 67a1e74a7..a0f089772 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,20 @@ LL2_API_TOKEN= # Optional: OpenSky credentials JSON file path (alternative to above) # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json +# Optional: interface language pair (i18n). GEV_DEFAULT_LOCALE is the default +# UI locale; GEV_SECONDARY_LOCALE is the second locale offered in the dock +# language switch. English is ALWAYS shipped as the fallback catalog and is +# resolvable no matter the pair. Only 'en' ships in this PR: both values are +# validated against the shipped catalogs, so a secondary without a shipped +# catalog degenerates the switch to a single English button (and an invalid, +# unshipped, or degenerate pair falls back to that en-only shape with a +# dev-server-only console.warn). Later locale PRs re-open the pair — see +# docs/TRANSLATORS.md. CLIENT-EXPOSED (non-secret): both values reach the +# browser bundle via import.meta.env defines — do not put anything sensitive +# here. +# GEV_DEFAULT_LOCALE=en +# GEV_SECONDARY_LOCALE=es + # Optional: dev server config PORT=4173 # Network binding. Default is localhost — the server is reachable ONLY from diff --git a/build/vite.js b/build/vite.js index cdff4a27c..bf2ea9f98 100644 --- a/build/vite.js +++ b/build/vite.js @@ -7,6 +7,8 @@ export function createBrowserViteConfig({ publicDir, googleApiKey, cesiumToken, + defaultLocale = '', + secondaryLocale = '', host = 'localhost', port = 4173, command, @@ -47,6 +49,14 @@ export function createBrowserViteConfig({ define: { 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), + // Locale pair (i18n): the client's default + secondary UI locale. These + // are NOT secrets and are read by src/i18n/locale.js, which validates + // them against the shipped catalogs and falls back to the built-in + // en-only pair (while English is the only shipped catalog) for + // empty/invalid/unshipped values. Explicit define entries are required + // because Vite only auto-exposes VITE_-prefixed vars on import.meta.env. + 'import.meta.env.GEV_DEFAULT_LOCALE': JSON.stringify(defaultLocale), + 'import.meta.env.GEV_SECONDARY_LOCALE': JSON.stringify(secondaryLocale), }, build: { chunkSizeWarningLimit: 1500 }, }; diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..ae4a62043 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -16,6 +16,8 @@ export default defineConfig(({ command, mode }) => { plugins: [...localProviderPlugins(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, + defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '', + secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '', host: process.env.HOST, port: process.env.PORT, command, diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs new file mode 100644 index 000000000..9b8997f8d --- /dev/null +++ b/src/i18n/catalog.test.mjs @@ -0,0 +1,198 @@ +// Catalog parity gates: every shipped non-English locale (the es follow-up, +// then one PR per further locale) is checked against en — it may never LEAD en +// (no extra keys), every shared key must keep identical placeholder names, +// and plural entries must carry every en variant plus only categories +// Intl.PluralRules reports valid for that locale (see assertVariantSuperset +// below); the strict gate demands exact key-set equality so a forgotten +// translation cannot ship silently behind the English fallback. While English +// is the only shipped locale these loops are vacuous — the gate machinery +// stays so each locale PR meets it from day one. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCatalog, mergeNamespace } from './index.js'; +import { CATALOG_LOCALES } from './locale.js'; + +/* + * PARITY FLIP — how the strict gate is previewed or relaxed. + * ───────────────────────────────────────────────────────────────────────────── + * REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the + * exact en key set. While a NEW catalog is still an untranslated seed that + * lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to + * the subset rules (a locale may be a subset of en, never a superset). The + * pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as + * an alias. Record every flip in docs/TRANSLATORS.md. + */ +const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY + ?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY; +const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0'; + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Placeholder names used by one catalog entry (string or plural variants). */ +function placeholdersOf(entry) { + const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {}); + const names = new Set(); + for (const pattern of patterns) { + if (typeof pattern !== 'string') continue; + for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]); + } + return names; +} + +/* + * PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES + * ───────────────────────────────────────────────────────────────────────────── + * en plural entries carry { one, other } — the only cardinal categories + * Intl.PluralRules reports for English. Slavic locales such as ru/uk select + * one/few/many/other, so exact-shape equality with en would forbid their + * grammars. The rule is now, per locale and shared key: + * (a) the entry must carry EVERY variant en has (they are mandatory — the + * runtime reads entry. ?? entry.other, so dropping an en + * variant strands the fallback), and + * (b) every variant added beyond en's must be a cardinal category + * Intl.PluralRules reports VALID for that locale + * (resolvedOptions().validCategories, spelled pluralCategories on + * older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en). + * en itself can never grow a variant (English's valid set is exactly + * one/other), and a typo'd variant like 'xother' fails loudly for any + * locale. The runtime degrades gracefully the other way: an entry lacking a + * category its locale would select renders entry.other (selectPluralPattern + * in src/i18n/index.js; pinned in i18n.test.mjs). + */ + +/** Cardinal plural categories Intl may select for a locale. */ +function validPluralCategories(locale) { + const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions(); + // The property was renamed pluralCategories → validCategories in newer + // ECMAScript drafts; read whichever the running ICU exposes. + const categories = validCategories ?? pluralCategories; + assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`); + return new Set(categories); +} + +/** Assert the plural-shape rule for one locale entry against its en twin. */ +function assertVariantSuperset(localeEntry, enEntry, locale, key) { + assert.equal( + typeof localeEntry, + typeof enEntry, + `variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`, + ); + if (typeof enEntry === 'string') return; + const enVariants = Object.keys(enEntry); + const variants = Object.keys(localeEntry); + const missing = enVariants.filter((variant) => !variants.includes(variant)); + assert.deepEqual( + missing, + [], + `${locale}:${key} must keep every en variant (${enVariants.join(',')})`, + ); + const valid = validPluralCategories(locale); + const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant)); + assert.deepEqual( + invalid, + [], + `${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`, + ); +} + +const enKeys = Object.keys(getCatalog('en')).sort(); +// Every shipped non-en locale with its sorted key list. +const localeKeys = new Map( + CATALOG_LOCALES + .filter((locale) => locale !== 'en') + .map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]), +); + +test('every shipped locale ships a merged catalog', () => { + // English only in the foundation state; a locale PR appends its code here + // alongside its catalog files (docs/TRANSLATORS.md). + assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']); + for (const locale of CATALOG_LOCALES) { + assert.ok(getCatalog(locale), `merged catalog for ${locale}`); + } +}); + +test('no locale carries keys that en does not have', () => { + for (const [locale, keys] of localeKeys) { + const extras = keys.filter((key) => !enKeys.includes(key)); + assert.deepEqual( + extras, + [], + `extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`, + ); + } +}); + +test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => { + const enCatalog = getCatalog('en'); + for (const [locale, keys] of localeKeys) { + const catalog = getCatalog(locale); + for (const key of keys) { + assert.deepEqual( + [...placeholdersOf(catalog[key])].sort(), + [...placeholdersOf(enCatalog[key])].sort(), + `placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`, + ); + assertVariantSuperset(catalog[key], enCatalog[key], locale, key); + } + } +}); + +test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => { + const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' }; + // ru selects one/few/many/other, so the full four-variant translation of + // an en { one, other } twin is a legal superset (adds only valid ru + // categories) — exactly what stage-B translators must produce… + assert.doesNotThrow(() => assertVariantSuperset( + { one: '…', few: '…', many: '…', other: '…' }, + enEntry, + 'ru', + 'layers.clear.toast.cleared', + )); + // …while a variant Intl never selects for ru (a typo like 'xother') fails. + assert.throws( + () => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'), + /xother/, + ); + // Dropping a mandatory en variant fails even when all additions are valid. + assert.throws( + () => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'), + /every en variant/, + ); + // String ↔ plural-object drift still fails exactly as before. + assert.throws( + () => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'), + /string ↔ plural-object/, + ); + // The gate stays honest about what the runtime can select: validCategories + // is read from Intl, not hardcoded per locale. + assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']); + assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']); +}); + +test('exact key parity for every shipped locale once translation is complete (parity flip)', () => { + if (!REQUIRE_FULL_PARITY) { + // Subset mode: every locale key resolves; en-only keys fall back silently. + for (const [locale, keys] of localeKeys) { + for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`); + } + return; + } + for (const [locale, keys] of localeKeys) { + assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`); + } +}); + +test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => { + const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } }); + assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' }); + assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/); + assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {}); +}); + +test('registered namespaces produce the documented dot prefixes', () => { + const prefixes = new Set(enKeys.map((key) => key.split('.')[0])); + assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']); +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs new file mode 100644 index 000000000..0b9ca8a23 --- /dev/null +++ b/src/i18n/i18n.test.mjs @@ -0,0 +1,457 @@ +// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded +// storage, English fallback, interpolation, plural selection, document +// application on all four attributes, and hash-exact reload handling. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CATALOG_LOCALES, + DEFAULT_LOCALE, + LOCALE_METADATA, + LOCALE_STORAGE_KEY, + applyDocumentLanguage, + availableLocales, + normalizeLocale, + resolveLocale, + resolveLocalePair, + writeStoredLocale, +} from './locale.js'; +import { + applyDocumentTranslations, + formatDate, + formatNumber, + formatRelativeTime, + getCatalog, + getLocale, + hasMessage, + persistLocaleAndReload, + resolveMessage, + selectPluralPattern, + setLocale, + t, +} from './index.js'; + +// Tests mutate module locale state; each dependent test re-pins it. +function pinLocale(locale) { + assert.equal(setLocale(locale), locale); +} + +test('normalizeLocale folds regional variants and rejects unsupported tags', () => { + assert.equal(normalizeLocale('es'), 'es'); + assert.equal(normalizeLocale('ES'), 'es'); + assert.equal(normalizeLocale('es-MX'), 'es'); + assert.equal(normalizeLocale('es_419'), 'es'); + assert.equal(normalizeLocale('en'), 'en'); + assert.equal(normalizeLocale('en-GB'), 'en'); + assert.equal(normalizeLocale('fr'), 'fr'); + assert.equal(normalizeLocale('FR'), 'fr'); + assert.equal(normalizeLocale('fr-CA'), 'fr'); + assert.equal(normalizeLocale('fr_FR'), 'fr'); + assert.equal(normalizeLocale('ru'), 'ru'); + assert.equal(normalizeLocale('RU'), 'ru'); + assert.equal(normalizeLocale('ru-RU'), 'ru'); + assert.equal(normalizeLocale('ru_KZ'), 'ru'); + assert.equal(normalizeLocale('uk'), 'uk'); + assert.equal(normalizeLocale('UK'), 'uk', 'uppercase country spelling still folds to the language tag'); + assert.equal(normalizeLocale('uk-UA'), 'uk'); + assert.equal(normalizeLocale('uk_UA'), 'uk'); + assert.equal(normalizeLocale('de'), null, 'no shipped de catalog'); + assert.equal(normalizeLocale('english'), null); + assert.equal(normalizeLocale(''), null); + assert.equal(normalizeLocale(null), null); + assert.equal(normalizeLocale(42), null); +}); + +test('resolution precedence: ?lang= is one-shot, pair-scoped, and never persisted', () => { + // English is the only shipped catalog in the foundation state, so the + // offered pair is en-only: an override naming any other locale — a + // planned-but-unshipped code ('es') or garbage ('de') — is ignored while + // the rest of the chain keeps looking. The override-beats-everything pin + // comes back with the first secondary-locale PR (docs/TRANSLATORS.md). + const storage = { getItem: () => 'en', setItem() { throw new Error('must not be written'); } }; + assert.equal(resolveLocale({ + location: { search: '?lang=es' }, + storage, + languages: ['en-US'], + }), 'en'); + // A garbage override is ignored — the chain keeps looking. + assert.equal(resolveLocale({ + location: { search: '?lang=de' }, + storage, + languages: ['en-US'], + }), 'en'); + // A locale code that normalizes but has no shipped catalog is equally + // ignored: fr cannot be offered before its locale PR lands. + assert.equal(resolveLocale({ + location: { search: '?lang=fr' }, + storage, + languages: ['en-US'], + }), 'en'); +}); + +test('resolution precedence: a stored preference is re-checked against the offered pair', () => { + // 'es' may have been stored under a pair that once offered it; while + // English is the only shipped catalog the stored value is out-of-pair and + // reads as absent, so the navigator list decides — the stored preference + // degrades gracefully instead of rendering English under an es . + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'es' }, + languages: ['en-US', 'en'], + }), 'en'); +}); + +test('resolution precedence: navigator languages resolve before the English default', () => { + // Regional variants normalize to the primary tag and only then face the + // pair check: 'en-GB' resolves through the offered pair, unshipped codes + // ('fr-CA', 'es-MX') defer to the next step. + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['en-GB'], + }), 'en'); + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['fr-CA', 'es-MX'], + }), 'en', 'no shipped fr/es catalog: neither navigator entry resolves'); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: ['fr-CA'] }), 'en'); + assert.equal(resolveLocale({ location: null, storage: null, languages: null }), DEFAULT_LOCALE); +}); + +test('guarded storage: a throwing store (private mode / quota) never escapes', () => { + const hostile = { + getItem() { throw new Error('SecurityError'); }, + setItem() { throw new Error('SecurityError'); }, + }; + assert.equal(resolveLocale({ location: { search: '' }, storage: hostile, languages: ['es'] }), 'en', + 'an unshipped navigator locale defers to the en-only default'); + assert.equal(writeStoredLocale('es', hostile), false); + assert.equal(writeStoredLocale('es', { setItem() { throw new Error('quota'); } }), false); + assert.equal(writeStoredLocale('es', {}), false, 'no setItem function means no write'); + assert.equal(writeStoredLocale('es-AR', { setItem() {} }), true, 'stored value is normalized'); +}); + +test('applyDocumentLanguage reflects lang and dir from locale metadata', () => { + const makeDoc = () => ({ documentElement: { lang: '', dir: '' } }); + const enDoc = makeDoc(); + assert.equal(applyDocumentLanguage(enDoc, 'en'), true); + assert.deepEqual({ lang: enDoc.documentElement.lang, dir: enDoc.documentElement.dir }, + { lang: 'en', dir: LOCALE_METADATA.en.dir }); + assert.equal(LOCALE_METADATA.en.dir, 'ltr', 'English is LTR by metadata'); + const esDoc = makeDoc(); + assert.equal(applyDocumentLanguage(esDoc, 'es-MX'), true); + assert.deepEqual({ lang: esDoc.documentElement.lang, dir: esDoc.documentElement.dir }, + { lang: 'es', dir: LOCALE_METADATA.es.dir }); + assert.equal(LOCALE_METADATA.es.dir, 'ltr', 'Spanish is LTR by metadata'); + const frDoc = makeDoc(); + assert.equal(applyDocumentLanguage(frDoc, 'fr-CA'), true); + assert.deepEqual({ lang: frDoc.documentElement.lang, dir: frDoc.documentElement.dir }, + { lang: 'fr', dir: LOCALE_METADATA.fr.dir }); + assert.equal(LOCALE_METADATA.fr.dir, 'ltr', 'French is LTR by metadata'); + const ruDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ruDoc, 'ru-RU'), true); + assert.deepEqual({ lang: ruDoc.documentElement.lang, dir: ruDoc.documentElement.dir }, + { lang: 'ru', dir: LOCALE_METADATA.ru.dir }); + assert.equal(LOCALE_METADATA.ru.dir, 'ltr', 'Russian is LTR by metadata'); + const ukDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ukDoc, 'uk_UA'), true); + assert.deepEqual({ lang: ukDoc.documentElement.lang, dir: ukDoc.documentElement.dir }, + { lang: 'uk', dir: LOCALE_METADATA.uk.dir }); + assert.equal(LOCALE_METADATA.uk.dir, 'ltr', 'Ukrainian is LTR by metadata'); + assert.equal(applyDocumentLanguage(null, 'es'), false); + assert.equal(applyDocumentLanguage({ documentElement: null }, 'es'), false); + // An unsupported locale still yields valid metadata, never an empty dir. + const fallbackDoc = makeDoc(); + applyDocumentLanguage(fallbackDoc, 'de'); + assert.equal(fallbackDoc.documentElement.lang, DEFAULT_LOCALE); + assert.equal(fallbackDoc.documentElement.dir, 'ltr'); +}); + +test('t() resolves seed keys, interpolates named placeholders, and keeps unknown ones literal', () => { + pinLocale('en'); + assert.equal(t('shell.title.subtitle'), 'NO PLACE LEFT BEHIND'); + assert.equal( + t('shell.loading.status.tilesUnavailable', { detail: '403 from ion' }), + 'Google 3D Tiles unavailable (403 from ion). Loading the keyless globe...', + ); + assert.equal( + t('shell.loading.status.tilesUnavailable', {}), + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'an unprovided name stays visible instead of silently vanishing', + ); +}); + +test('t() selects one/other plural variants through Intl.PluralRules', () => { + pinLocale('en'); + assert.equal(t('layers.clear.toast.cleared', { count: 1 }), 'Cleared 1 data layer'); + assert.equal(t('layers.clear.toast.cleared', { count: 3 }), 'Cleared 3 data layers'); + assert.equal(t('layers.clear.toast.notCleared', { count: 1 }), + '1 data layer could not be cleared'); + // Non-en plural-category selection is pinned per locale in that locale's + // PR (es: one/other; ru/uk: one/few/many/other) — with real catalog values, + // not synthetic ones. The selection CONTRACT itself stays pinned below + // through the synthetic missing-category fallback under 'ru'. +}); + +test('a missing key returns the key itself and never warns outside dev builds', () => { + pinLocale('en'); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + assert.equal(t('shell.definitely.not.here'), 'shell.definitely.not.here'); + assert.equal(hasMessage('shell.definitely.not.here'), false); + assert.equal(hasMessage('shell.title.subtitle'), true); + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn path itself only runs on the dev server. + assert.deepEqual(warnings, []); +}); + +test('a key missing in es resolves through the English fallback (pure lookup)', () => { + // The shipped es seed mirrors en key-for-key, so the fallback path is + // exercised through the pure resolver with synthetic catalogs — exactly the + // shape stage-3 leaves behind while es translation lags. + const catalogs = { + es: Object.freeze({}), + en: Object.freeze({ 'a.b': 'EN ONLY' }), + }; + assert.equal(resolveMessage(catalogs, 'es', 'a.b'), 'EN ONLY'); + assert.equal(resolveMessage(catalogs, 'fr', 'a.b'), 'EN ONLY', 'unsupported locale reads en'); + assert.equal(resolveMessage(catalogs, 'es', 'nope'), undefined); + assert.equal(resolveMessage(null, 'es', 'a.b'), undefined, 'no catalogs means no message'); +}); + +test('Intl formatters follow the active locale', () => { + pinLocale('en'); + // Five significant digits: Spanish (CLDR minimumGroupingDigits=2) only + // groups from five digits up, so a 4-digit value would hide the difference. + assert.equal(formatNumber(12345.6), '12,345.6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + 'January 15, 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), '5 minutes ago'); + pinLocale('es'); + assert.equal(formatNumber(12345.6), '12.345,6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + '15 de enero de 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), 'hace 5 minutos'); + // Formatter cache is keyed per locale: switching back must not leak es forms. + pinLocale('en'); + assert.equal(formatNumber(12345.6), '12,345.6'); +}); + +test('persistLocaleAndReload stores the choice, keeps the hash exact, strips ?lang', () => { + const writes = []; + const navigations = []; + const storage = { setItem(key, value) { writes.push([key, value]); } }; + const location = { + href: 'http://localhost:4173/?lang=es&welcome=1#cam=30.2672,-97.7431,alt=2500', + assign(url) { navigations.push(url); }, + }; + assert.equal(persistLocaleAndReload('es-MX', { location, storage }), true); + assert.deepEqual(writes, [[LOCALE_STORAGE_KEY, 'es']], 'normalized value under the versioned key'); + assert.equal(navigations.length, 1); + assert.equal(navigations[0], 'http://localhost:4173/?welcome=1#cam=30.2672,-97.7431,alt=2500'); + + // No ?lang present: the URL round-trips unchanged, hash included. + navigations.length = 0; + persistLocaleAndReload('en', { location: { href: 'http://localhost:4173/#scene=coast', assign: (u) => navigations.push(u) } }); + assert.deepEqual(navigations, ['http://localhost:4173/#scene=coast']); + + // A blocked store must not block the reload. + const blocked = { setItem() { throw new Error('SecurityError'); } }; + navigations.length = 0; + assert.equal(persistLocaleAndReload('es', { location: { href: 'http://x/?lang=es#a', assign: (u) => navigations.push(u) }, storage: blocked }), true); + assert.deepEqual(navigations, ['http://x/#a']); + + // Nothing navigable injected: no throw, no reload claim. + assert.equal(persistLocaleAndReload('es', { location: null }), false); + assert.equal(persistLocaleAndReload('es', { location: {} }), false); + + // Real-browser shape: a reloadable location MUST force the reload — assign() + // to a byte-identical URL never navigates, which stranded stored-locale + // switches (no ?lang present, hash unchanged). + const reloaded = []; + const assigned = []; + const replaced = []; + assert.equal(persistLocaleAndReload('es', { + location: { + href: 'http://localhost:4173/#scene=coast', + assign: (u) => assigned.push(u), + reload: () => reloaded.push(true), + }, + history: { replaceState: (_s, _t, u) => replaced.push(u) }, + }), true); + assert.deepEqual(replaced, ['http://localhost:4173/#scene=coast'], '?lang-free URL passes through replaceState'); + assert.equal(reloaded.length, 1, 'reload is forced'); + assert.equal(assigned.length, 0, 'assign is not used when reload exists'); +}); + +test('a plural entry lacking a category the active locale selects degrades to other (pure selection)', () => { + // Graceful degradation stays pinned after the ru/uk translation landed: + // full-parity catalogs can no longer carry a partial entry, so the + // runtime contract — entry[category] ?? entry.other in selectPluralPattern + // (src/i18n/index.js) — is exercised with a synthetic { one, other } twin + // under ru, which selects few/many for 2–4/5–20. This is the runtime half + // of the plural-superset gate in catalog.test.mjs. + const twin = Object.freeze({ one: 'ONE {count}', other: 'OTHER {count}' }); + pinLocale('ru'); + assert.equal(selectPluralPattern(twin, { count: 2 }, 'demo.key'), 'OTHER {count}', + "ru selects 'few' for 2 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 5 }, 'demo.key'), 'OTHER {count}', + "ru selects 'many' for 5 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 21 }, 'demo.key'), 'ONE {count}', + "21 selects 'one', which the entry carries directly"); + assert.equal(selectPluralPattern(twin, { count: 1.5 }, 'demo.key'), 'OTHER {count}', + 'fractional counts select other'); + assert.equal(selectPluralPattern(twin, {}, 'demo.key'), 'OTHER {count}', + 'a non-finite count degrades to entry.other ?? entry.one'); + pinLocale('en'); +}); + +class FakeElement { + constructor(attributes) { + this.attributes = new Map(Object.entries(attributes)); + this.textContent = ''; + } + getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } +} + +function fakeDocumentFor(elements) { + return { + querySelectorAll(selector) { + const match = /^\[([a-z0-9-]+)\]$/.exec(selector); + if (!match) return []; + return elements.filter((element) => element.getAttribute(match[1]) !== null); + }, + }; +} + +test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => { + pinLocale('en'); + const elements = [ + new FakeElement({ 'data-i18n': 'shell.title.subtitle' }), + new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }), + new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }), + new FakeElement({ 'data-i18n-placeholder': 'setup.keySetup.title' }), + new FakeElement({ 'data-i18n': 'shell.missing.key' }), + new FakeElement({ 'data-i18n': '' }), + ]; + const applied = applyDocumentTranslations(fakeDocumentFor(elements)); + assert.equal(applied, 4); + assert.equal(elements[0].textContent, 'NO PLACE LEFT BEHIND'); + assert.equal(elements[1].getAttribute('title'), 'Turn off all selected data layers'); + assert.equal(elements[2].getAttribute('aria-label'), 'Copy share link'); + assert.equal(elements[3].getAttribute('placeholder'), 'Power up the globe'); + // A key that resolves nowhere leaves the element untouched. + assert.equal(elements[4].textContent, ''); + assert.equal(elements[5].textContent, ''); + assert.equal(applyDocumentTranslations(null), 0); + assert.equal(applyDocumentTranslations({}), 0); +}); + +test('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => { + assert.deepEqual([...CATALOG_LOCALES], ['en'], + 'the foundation ships English only; each locale PR appends its code here'); + for (const locale of CATALOG_LOCALES) { + const catalog = getCatalog(locale); + assert.ok(catalog, `catalog for ${locale}`); + assert.ok(Object.keys(catalog).length > 0); + for (const key of Object.keys(catalog)) { + assert.match(key, /^(shell|cockpit|layers|setup)\./, `${locale} key ${key}`); + } + } + // Codes without a shipped catalog stay null even though they normalize + // (locale-code hygiene): shipping is catalog-driven. An unknown locale + // stays null too. + assert.equal(getCatalog('es'), null); + assert.equal(getCatalog('fr'), null); + assert.equal(getCatalog('ru'), null); + assert.equal(getCatalog('uk'), null); + assert.equal(getCatalog('de'), null); + assert.equal(getLocale(), 'en', 'state survived the whole file'); +}); + +test('locale pair: built-in and configured pairs degrade to en-only while only English ships', () => { + // English is the only shipped catalog, so the offered set is ['en'] under + // any configuration — a pair naming anything else names an unshipped + // locale. The dedup-ordering pin (default, secondary, then en as the + // always-shipped fallback) returns with the first secondary-locale PR. + assert.deepEqual([...availableLocales()], ['en'], 'no config = built-in en-only pair'); + assert.deepEqual([...availableLocales({ defaultLocale: 'fr', secondaryLocale: 'en' })], ['en'], + 'fr has no shipped catalog: the pair degenerates'); + // Regional spellings still normalize before validation. + assert.deepEqual([...availableLocales({ defaultLocale: 'FR-fr', secondaryLocale: 'EN' })], ['en']); + // A blank field means "unset" (empty .env line) and takes its built-in default. + assert.deepEqual([...availableLocales({ defaultLocale: ' ' })], ['en']); + const pair = resolveLocalePair(); + assert.equal(pair.defaultLocale, 'en'); + assert.equal(pair.secondaryLocale, 'en'); + assert.ok(Object.isFrozen(pair) && Object.isFrozen(pair.availableLocales)); +}); + +test('locale pair: a configured pair naming an unshipped locale degrades to English resolution', () => { + const config = { defaultLocale: 'fr', secondaryLocale: 'en' }; + // fr normalizes but has no shipped catalog: the pair is unusable, the + // built-in en-only pair applies, and every resolution step yields English. + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'fr' }, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['fr-FR'], config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=en' }, storage: { getItem: () => 'en' }, languages: null, config }), 'en'); +}); + +test('locale pair: ?lang= cannot offer a locale without a shipped catalog', () => { + assert.equal( + resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }), + 'en', + 'fr has no catalog: the override falls through to the stored preference', + ); + assert.equal( + resolveLocale({ + location: { search: '?lang=es' }, + storage: { getItem: () => 'en' }, + languages: null, + config: { defaultLocale: 'es', secondaryLocale: 'fr' }, + }), + 'en', + 'a configured pair cannot smuggle an unshipped locale into the offered set', + ); +}); + +test('locale pair: a stored preference outside the offered pair falls through', () => { + // A stored 'fr'/'es' reads as absent while those locales are unshipped; + // resolution defers to the navigator list and finally the built-in default. + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en', + '…down to the default'); +}); + +test('locale pair: invalid, unshipped, or degenerate values fall back to the built-in en-only pair (dev-warn only)', () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const badPairs = [ + { defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code + { defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice + { defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only + { defaultLocale: 'english', secondaryLocale: 'ES' }, + { defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog + ]; + for (const bad of badPairs) { + const pair = resolveLocalePair(bad); + assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad)); + assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad)); + assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad)); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en'); + } + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn fires only on the dev server. + assert.deepEqual(warnings, []); +}); diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 000000000..3d38b3f93 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,348 @@ +// God's Eye View i18n core (phase 1): catalog registry, translation, and +// Intl-based formatting. English is the source/fallback catalog; a missing key +// in another locale falls back to English with a development-only warning. +// English is also the ONLY shipped catalog in this foundation change — each +// additional locale lands as a stacked follow-up PR following the recipe in +// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry). +// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial +// and the registry stays declarative — but only locales in the configured pair +// are offered/accepted (see locale.js resolveLocalePair). +// +// APPEND-ONLY NAMESPACE REGISTRATION +// ───────────────────────────────────────────────────────────────────────────── +// A worker adding a namespace creates locales//.js (exporting +// NAMESPACE plus a default message map of namespace-relative keys), then +// appends ONE import line per locale and pushes the module into that locale's +// array below. Nothing else changes, so concurrent workers merge trivially. +// Namespace-relative keys are prefixed at registration time: shell.*, +// cockpit.*, layers.*, setup.*. +import { + DEFAULT_LOCALE, + normalizeLocale, + writeStoredLocale, +} from './locale.js'; + +import * as enShell from './locales/en/shell.js'; +import * as enCockpit from './locales/en/cockpit.js'; +import * as enLayers from './locales/en/layers.js'; +import * as enSetup from './locales/en/setup.js'; + +// Append new namespace modules here (one import + one entry per locale). +const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup]; + +/** + * Prefix one namespace module's flat, namespace-relative keys with its + * declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes + * 'shell.title.subtitle'. + * @param {{NAMESPACE: string, default?: object}} namespaceModule + * @returns {Record} Frozen prefixed message map. + */ +export function mergeNamespace(namespaceModule) { + const prefix = + typeof namespaceModule?.NAMESPACE === 'string' + ? namespaceModule.NAMESPACE.trim() + : ''; + if (!/^[a-z][a-z0-9-]*$/.test(prefix)) { + throw new Error( + `i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`, + ); + } + const merged = {}; + for (const [key, value] of Object.entries(namespaceModule.default || {})) { + merged[`${prefix}.${key}`] = value; + } + return Object.freeze(merged); +} + +/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */ +function buildCatalog(namespaceModules) { + const catalog = {}; + for (const namespaceModule of namespaceModules) { + for (const [key, value] of Object.entries( + mergeNamespace(namespaceModule), + )) { + if (Object.hasOwn(catalog, key)) { + throw new Error(`i18n duplicate catalog key: ${key}`); + } + catalog[key] = value; + } + } + return Object.freeze(catalog); +} + +const CATALOGS = Object.freeze({ + [DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES), +}); + +/** + * The merged catalog for a locale (frozen; for coverage/parity tests and + * tooling — runtime lookups go through t()). + * @param {string} locale + * @returns {Record|null} + */ +export function getCatalog(locale) { + const normalized = normalizeLocale(locale); + return (normalized && CATALOGS[normalized]) || null; +} + +let currentLocale = DEFAULT_LOCALE; + +/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */ +export function getLocale() { + return currentLocale; +} + +/** + * Switch the active locale. Pure state change: call applyDocumentLanguage / + * applyDocumentTranslations to reflect it in the DOM. + * @returns {string} The locale actually applied (normalized). + */ +export function setLocale(locale) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + if (normalized !== currentLocale) { + currentLocale = normalized; + formatterCache.clear(); + } + return currentLocale; +} + +/** Development-only missing-key warning. Off under node:test and prod builds. */ +function warnMissingKey(key, detail) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`); +} + +/** + * Pure lookup shared by t() and the parity tests: the requested locale first, + * then the English fallback catalog. + * @param {Record} catalogs Catalog map to read from. + * @param {string} locale Locale to resolve within that map. + * @param {string} key Dot-prefixed message key. + * @returns {string|object|undefined} Raw entry, or undefined when nowhere. + */ +export function resolveMessage(catalogs, locale, key) { + const source = catalogs || CATALOGS; + const normalized = normalizeLocale(locale); + if ( + normalized && + source[normalized] && + Object.hasOwn(source[normalized], key) + ) { + return source[normalized][key]; + } + const fallback = source[DEFAULT_LOCALE]; + return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined; +} + +/** Whether the key resolves in the active locale or the English fallback. */ +export function hasMessage(key) { + return resolveMessage(CATALOGS, currentLocale, key) !== undefined; +} + +const pluralRulesCache = new Map(); + +function pluralRulesFor(locale) { + let rules = pluralRulesCache.get(locale); + if (!rules) { + rules = new Intl.PluralRules(locale); + pluralRulesCache.set(locale, rules); + } + return rules; +} + +/** Select a plural variant via Intl.PluralRules for the ACTIVE locale; + * an entry lacking the selected category degrades to entry.other. Exported + * for tests: full-parity catalogs can no longer stage a partial entry, so + * the fallback is pinned synthetically (i18n.test.mjs). */ +export function selectPluralPattern(entry, values, key) { + const count = values?.count; + if (typeof count !== 'number' || !Number.isFinite(count)) { + warnMissingKey(key, 'plural entry requires a finite {count} value'); + return entry.other ?? entry.one; + } + const category = pluralRulesFor(currentLocale).select(count); + return entry[category] ?? entry.other; +} + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Named {placeholder} interpolation. Unprovided names stay literal so a gap + * stays visible during development instead of silently vanishing. */ +function interpolate(pattern, values) { + if (!values || typeof pattern !== 'string') return pattern; + return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => + Object.hasOwn(values, name) ? String(values[name]) : literal, + ); +} + +/** + * Translate one dot-prefixed key in the active locale. Missing keys in a + * non-English locale fall back to English; keys missing everywhere return the + * key itself. Plural entries ({ one, other }) require a numeric `count` value. + * @param {string} key e.g. 'layers.clear.toast.cleared' + * @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' } + * @returns {string} + */ +export function t(key, values) { + const entry = resolveMessage(CATALOGS, currentLocale, key); + if (entry === undefined) { + warnMissingKey(key, `not in ${currentLocale} or en catalog`); + return key; + } + if ( + currentLocale !== DEFAULT_LOCALE && + !Object.hasOwn(CATALOGS[currentLocale] || {}, key) + ) { + warnMissingKey(key, `missing in ${currentLocale}, using en fallback`); + } + const pattern = + typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key); + return interpolate(pattern, values); +} + +/* + * Intl formatters are memoized per (kind, locale, options): the constructors + * are expensive and stage-3 extraction will format numbers and dates on panel + * refresh paths. The cache is cleared whenever the active locale changes. + */ +const formatterCache = new Map(); + +function cachedFormatter(kind, Constructor, options) { + const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`; + let formatter = formatterCache.get(cacheKey); + if (!formatter) { + formatter = new Constructor(currentLocale, options); + formatterCache.set(cacheKey, formatter); + } + return formatter; +} + +/** Locale-aware number formatting (Intl.NumberFormat). */ +export function formatNumber(value, options) { + return cachedFormatter('number', Intl.NumberFormat, options).format(value); +} + +/** Locale-aware date/time formatting (Intl.DateTimeFormat). */ +export function formatDate(value, options) { + return cachedFormatter('date', Intl.DateTimeFormat, options).format(value); +} + +/** + * Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed + * distance in `unit`, e.g. formatRelativeTime(-5, 'minute') → + * '5 minutes ago' (en) / 'hace 5 minutos' (es). + * @param {number} value Signed distance in `unit`. + * @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …). + * @param {object} [options] Intl.RelativeTimeFormat options (numeric, style). + */ +export function formatRelativeTime(value, unit = 'second', options) { + return cachedFormatter('relative', Intl.RelativeTimeFormat, { + numeric: 'auto', + ...options, + }).format(value, unit); +} + +/** DOM write targets for applyDocumentTranslations: textContent plus the + * three presentation attributes vision.md requires to agree with visible + * labels. Never innerHTML — translation functions return text. */ +const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ + Object.freeze({ + attribute: 'data-i18n', + apply(element, text) { + element.textContent = text; + }, + }), + Object.freeze({ + attribute: 'data-i18n-title', + apply(element, text) { + element.setAttribute('title', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-aria-label', + apply(element, text) { + element.setAttribute('aria-label', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-placeholder', + apply(element, text) { + element.setAttribute('placeholder', text); + }, + }), +]); + +/** + * Apply catalog text to every element carrying an i18n attribute in `doc`. + * Elements whose key resolves nowhere are left untouched (warned in dev). + * @param {Document} doc + * @returns {number} Count of element/attribute writes performed. + */ +export function applyDocumentTranslations(doc) { + if (typeof doc?.querySelectorAll !== 'function') return 0; + let applied = 0; + for (const target of DOCUMENT_TRANSLATION_TARGETS) { + for (const element of doc.querySelectorAll(`[${target.attribute}]`)) { + const key = element.getAttribute?.(target.attribute); + if (!key) continue; + if (!hasMessage(key)) { + warnMissingKey(key, `attribute ${target.attribute}`); + continue; + } + target.apply(element, t(key)); + applied += 1; + } + } + return applied; +} + +/** + * Persist a locale choice, then reload preserving window.location.hash exactly + * and stripping ?lang from the search string — the override is one-shot and + * must never graduate into the stored URL. Storage is best-effort: where + * storage is blocked (private mode) the preference simply will not survive. + * @param {string} locale Locale the user chose (normalized here). + * @param {object} [deps] + * @param {{href: string, assign: Function}} [deps.location] + * @param {object} [deps.storage] Injected localStorage-like store. + * @returns {boolean} true when a reload was triggered. + */ +export function persistLocaleAndReload( + locale, + { + location = globalThis.location, + history = globalThis.history, + storage, + } = {}, +) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + writeStoredLocale(normalized, storage); + if (!location || typeof location.assign !== 'function') return false; + const stripLang = () => { + try { + const url = new URL(location.href); + url.searchParams.delete('lang'); + return url.href; + } catch { + return location.href; + } + }; + // location.assign() to the byte-identical URL performs NO navigation in + // real browsers (the common case: no ?lang present, hash unchanged), so a + // stored locale change would never reload. Strip ?lang via replaceState + // (no extra history entry) and force a real reload instead. + const target = stripLang(); + try { + history?.replaceState?.(null, '', target); + } catch { + // replaceState is best-effort; the reload below still applies. + } + if (typeof location.reload === 'function') { + location.reload(); + } else { + location.assign(target); + } + return true; +} diff --git a/src/i18n/locale.js b/src/i18n/locale.js new file mode 100644 index 000000000..ea5968fea --- /dev/null +++ b/src/i18n/locale.js @@ -0,0 +1,314 @@ +// Locale identity for God's Eye View (i18n phase 1). +// +// English is the default-without-configuration, the ALWAYS-shipped fallback +// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the +// only locale with a shipped catalog in this foundation change: additional +// locales land as stacked follow-up PRs, one per locale, each following the +// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is +// configured at build/dev time through GEV_DEFAULT_LOCALE / +// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here — +// pair members are validated against the shipped catalogs, so while only +// English ships the pair degenerates to en-only (a single EN button). +// This module owns ONLY locale resolution and document language metadata — +// catalogs and translation helpers live in src/i18n/index.js. + +/** Durable preference key. Matches the repo convention gev::v1. */ +export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; + +/** + * Locales with a shipped catalog. 'en' is the fallback catalog locale + * (DEFAULT_LOCALE); every entry here can be normalized and looked up, though + * only locales in the configured PAIR (see resolveLocalePair) are offered in + * the UI and accepted during resolution. A locale PR appends its code here + * alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping. + */ +export const CATALOG_LOCALES = Object.freeze(['en']); + +/** + * Locale codes normalizeLocale folds regional variants for. Kept ahead of + * shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'), + * while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR + * edits only CATALOG_LOCALES plus its catalog files, and a code listed here + * without a catalog normalizes but is never offerable or resolvable. + */ +const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']); + +/** Fallback catalog locale and no-config default: always English. */ +export const DEFAULT_LOCALE = 'en'; + +/** + * Built-in pair used when no env/config shapes one, or a configured one is + * unusable. English-only while English is the only shipped catalog: the + * selector degenerates to a single EN button until a secondary locale PR + * restores the built-in pair. + */ +const FALLBACK_PAIR = Object.freeze({ + defaultLocale: 'en', + secondaryLocale: 'en', +}); + +/** Document metadata per normalizable locale code. All of them are LTR. */ +export const LOCALE_METADATA = Object.freeze({ + en: Object.freeze({ dir: 'ltr' }), + es: Object.freeze({ dir: 'ltr' }), + fr: Object.freeze({ dir: 'ltr' }), + ru: Object.freeze({ dir: 'ltr' }), + uk: Object.freeze({ dir: 'ltr' }), +}); + +/** + * Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk' + * and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the + * primary tag; anything unknown (or not a string) resolves to null so the + * caller's precedence chain can keep looking. Knowing a code is hygiene, not + * shipping: offering/resolving a locale additionally requires a catalog + * (CATALOG_LOCALES) — see resolveLocalePair. + * @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry). + * @returns {'en'|'es'|'fr'|'ru'|'uk'|null} + */ +export function normalizeLocale(candidate) { + if (typeof candidate !== 'string') return null; + const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; + return NORMALIZABLE_LOCALES.includes(primary) ? primary : null; +} + +/** + * Read the configured pair from the two client defines. Both spellings are + * replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay + * verbatim member expressions. Under node:test `import.meta.env` is absent + * and the first access throws; the catch turns that into "use the defaults". + * @returns {{defaultLocale?: string, secondaryLocale?: string}} + */ +function readEnvLocalePair() { + try { + return { + defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE, + secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE, + }; + } catch { + return {}; + } +} + +/** A blank string means "unset" (an empty .env line), not an invalid value. */ +function orBuiltIn(value, builtIn) { + return typeof value === 'string' && value.trim() ? value : builtIn; +} + +/** Development-only diagnostics, same gate as warnMissingKey in index.js. */ +function warnInvalidPair(rawDefault, rawSecondary) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn( + `[i18n] unusable locale pair ${JSON.stringify(String(rawDefault))}/` + + `${JSON.stringify(String(rawSecondary))} — offering English only`, + ); +} + +/** + * Resolve the offered locale pair. With no argument the two client defines are + * read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in + * value); an explicit { defaultLocale, secondaryLocale } injects a pair for + * tests and embedders — injected fields never fall through to the env. + * + * SHIPPING IS CATALOG-DRIVEN: both members must name a locale in + * CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet + * merged), or a degenerate pair (the same locale twice) returns the built-in + * pair instead, and a dev-only console.warn explains why — except when the + * raw values already ARE the built-in pair, which is the normal unconfigured + * state and must stay silent. While English is the only shipped catalog the + * built-in pair is en-only, so the selector renders a single EN button. + * + * availableLocales is the deduped [default, secondary, 'en'] triple — English + * is always shippable because its catalog is the unconditional fallback. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] + * @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen. + */ +export function resolveLocalePair(config) { + const injected = + typeof config === 'object' && + config !== null && + (config.defaultLocale !== undefined || + config.secondaryLocale !== undefined); + const raw = injected ? config : readEnvLocalePair(); + const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale); + const rawSecondary = orBuiltIn( + raw?.secondaryLocale, + FALLBACK_PAIR.secondaryLocale, + ); + const defaultLocale = normalizeLocale(rawDefault); + const secondaryLocale = normalizeLocale(rawSecondary); + const offerable = Boolean( + defaultLocale && + secondaryLocale && + CATALOG_LOCALES.includes(defaultLocale) && + CATALOG_LOCALES.includes(secondaryLocale), + ); + if (!offerable || defaultLocale === secondaryLocale) { + // The built-in pair itself is never a misconfiguration: only a raw value + // that had to be corrected warns on the dev server. + if ( + rawDefault !== FALLBACK_PAIR.defaultLocale || + rawSecondary !== FALLBACK_PAIR.secondaryLocale + ) { + warnInvalidPair(rawDefault, rawSecondary); + } + return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale); + } + return pairOf(defaultLocale, secondaryLocale); +} + +/** Freeze the deduped pair + always-shipped English. */ +function pairOf(defaultLocale, secondaryLocale) { + const availableLocales = [ + ...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]), + ]; + return Object.freeze({ + defaultLocale, + secondaryLocale, + availableLocales: Object.freeze(availableLocales), + }); +} + +/** + * Locales the app offers and accepts, in selector order: the configured + * default, the secondary locale, then English if not already present. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders). + * @returns {readonly string[]} + */ +export function availableLocales(config) { + return resolveLocalePair(config).availableLocales; +} + +/** + * One-shot `?lang=` override, read from the SEARCH string only. + * + * The override is never persisted, and it must never leak into the URL hash or + * share links: it dies with the URL it arrived in (persistLocaleAndReload in + * src/i18n/index.js strips it when a choice is stored). An unsupported value — + * or a shipped locale outside the configured pair — is ignored rather than + * forced to English; the rest of the precedence chain still gets to speak. + * @param {{search?: string}|null} location + * @param {readonly string[]} availableLocales Resolved pair locales. + * @returns {string|null} + */ +function queryLocaleOverride(location, availableLocales) { + const search = location?.search; + if (!search) return null; + try { + return normalizeIfAvailable( + new URLSearchParams(search).get('lang'), + availableLocales, + ); + } catch { + return null; + } +} + +/** Normalize a candidate, keeping it only when the pair offers that locale. */ +function normalizeIfAvailable(candidate, availableLocales) { + const normalized = normalizeLocale(candidate); + return normalized && availableLocales.includes(normalized) + ? normalized + : null; +} + +/* + * STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js): + * the `globalThis.localStorage` getter itself can throw SecurityError in Safari + * private mode or under enterprise policies, so it is only touched inside a + * try/catch at the moment of use — never in a default parameter. An injected + * `storage` (tests, embedders) short-circuits the global entirely; `undefined` + * means "use the global". + */ + +/** Read the stored preference, treating every failure as "nothing stored". + * A stored locale outside the current pair reads as absent. */ +function readStoredLocale(storage, availableLocales) { + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + return normalizeIfAvailable( + store?.getItem?.(LOCALE_STORAGE_KEY), + availableLocales, + ); + } catch { + return null; + } +} + +/** + * Write the stored preference, best-effort. Never throws. The stored value is + * any known locale code (pair membership is re-checked at resolution time, so + * a choice made under one configuration — or for a locale whose catalog has + * not shipped yet — degrades gracefully to the pair's default). + * @returns {boolean} true only if the value actually landed. + */ +export function writeStoredLocale(locale, storage) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + if (typeof store?.setItem !== 'function') return false; + store.setItem(LOCALE_STORAGE_KEY, normalized); + return true; + } catch { + // Privacy-restricted or quota-exhausted storage must never break the app. + return false; + } +} + +/** + * Resolve the active locale. Precedence (docs/TRANSLATORS.md): + * + * ?lang= → stored preference → navigator.languages → configured default + * + * Every step only accepts locales in the configured pair (which always + * includes English); each step that yields nothing (absent or out-of-pair) + * defers to the next. Without env configuration the built-in pair is en-only + * (English is the only shipped catalog), so every step resolves English — + * byte-identical to the pre-configuration resolution chain. + * @param {object} [input] + * @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from. + * @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global. + * @param {readonly string[]|null} [input.languages] Browser language list. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines. + * @returns {string} + */ +export function resolveLocale({ + location = globalThis.location, + storage, + languages = globalThis.navigator?.languages, + config, +} = {}) { + const { availableLocales: offered, defaultLocale } = + resolveLocalePair(config); + const override = queryLocaleOverride(location, offered); + if (override) return override; + const stored = readStoredLocale(storage, offered); + if (stored) return stored; + if (Array.isArray(languages)) { + for (const candidate of languages) { + const normalized = normalizeIfAvailable(candidate, offered); + if (normalized) return normalized; + } + } + return defaultLocale; +} + +/** + * Reflect the active locale on the document: `` and `` + * must always agree with the language actually being rendered. + * @param {Document} doc Document whose documentElement carries the metadata. + * @param {string} locale Locale to apply (normalized here). + * @returns {boolean} true when the metadata was written. + */ +export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) { + const root = doc?.documentElement; + if (!root) return false; + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + root.lang = normalized; + root.dir = ( + LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE] + ).dir; + return true; +} diff --git a/src/i18n/locales/en/cockpit.js b/src/i18n/locales/en/cockpit.js new file mode 100644 index 000000000..91a551268 --- /dev/null +++ b/src/i18n/locales/en/cockpit.js @@ -0,0 +1,560 @@ +// English catalog — cockpit namespace (phase-1 seed). +// +// Owns the first-person cockpit HUD, its Contact summary, and the briefing +// carousel. Keys are namespace-relative; the registry prefixes them with +// `cockpit.`. +export const NAMESPACE = 'cockpit'; + +export default { + // index.html #cockpit-hud section aria-label + 'hud.sectionLabel': 'Aircraft cockpit view', + // index.html #map-view-switch button label + 'exit.label': 'EXIT COCKPIT', + // index.html .cockpit-readout-label + 'readout.groundSpeed': 'GROUND SPEED', + 'readout.altitude': 'ALTITUDE', + // index.html .cockpit-context-kicker / #cockpit-context-subject (initial) + 'context.kicker': 'CONTACT', + 'context.subjectWindow': 'CONTACTS · 250 KM', + // index.html #cockpit-brief-kicker / #cockpit-brief-subtitle (initial) + 'brief.kicker': 'LIVE SIGNALS', + 'brief.subtitle': 'OBSERVED / MAPPED PINGS', + // index.html #cockpit-vision-current small label + 'vision.current': 'CURRENT', + // index.html #cockpit-radio-station (initial) + 'radio.station.ready': 'READY', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys land here because + // src/ui.js and src/hud.js own the surfaces' runtime half in phase 3. + + // index.html #style-indicator / #clean-view-exit (visual-style chrome) + 'presets.activeStyleLabel': 'ACTIVE STYLE', + 'display.cleanViewExitTitle': 'Return UI controls', + 'display.cleanViewExitLabel': 'EXIT CLEAN VIEW', + + // index.html control panel — Visual Presets dock tray + 'presets.toggleAriaLabel': 'Expand Visual Presets', + // Key-only: the .panel-title node shares its element with the dock-label-icon + // span, so a data-i18n write would wipe the icon; phase 3 wires this via t(). + 'presets.title': 'VISUAL PRESETS', + 'presets.pinAriaLabel': 'Pin visual presets', + 'presets.pinTitle': 'Keep visual presets open', + 'presets.styleNormalTitle': 'Show the globe without a visual filter.', + 'presets.styleNormalLabel': 'Normal', + 'presets.styleCrtTitle': + 'Emulate a green phosphor CRT with scanlines and screen curvature.', + 'presets.styleCrtLabel': 'CRT', + 'presets.styleNvgTitle': + 'Simulate night-vision goggles with green intensification and a tube vignette.', + 'presets.styleNvgLabel': 'NVG', + 'presets.styleFlirTitle': + 'Simulate FLIR-style thermal contrast. Turn up Ironbow for color.', + 'presets.styleFlirLabel': 'FLIR', + 'presets.styleAnimeTitle': + 'Apply bright cel-shaded color and illustrated outlines.', + 'presets.styleAnimeLabel': 'Anime', + 'presets.styleNoirTitle': 'Apply high-contrast monochrome film-noir grading.', + 'presets.styleNoirLabel': 'Noir', + 'presets.styleSnowTitle': + 'Add a cold, snowy whiteout treatment to the scene.', + 'presets.styleSnowLabel': 'Snow', + // Key-only: the static span text is pinned verbatim by mapStackChips.test.mjs + // (id="map-source-label">MAP SOURCE<), so no attribute can be added without + // touching that test; phase 3 owns the swap. + 'presets.mapSourceLabel': 'MAP SOURCE', + // Key-only: the chip-row tag is pinned literally by mapStackChips.test.mjs. + 'presets.mapSourceChipsAriaLabel': 'Map source', + 'presets.miniStyleLabel': 'Style', + + // index.html #pp-toggles DISPLAY rail + // Key-only: .pp-header-label text is pinned verbatim by panelStackLayout.test.mjs. + 'display.title': 'DISPLAY', + 'display.collapseTitle': 'Collapse panel', + 'display.hudToggleTitle': 'Intelligence HUD (H)', + 'display.hudLayoutLabel': 'Layout', + 'display.hudLayoutAriaLabel': 'HUD layout', + 'display.hudLayoutTactical': 'Tactical', + 'display.hudLayoutOperator': 'Operator', + 'display.hudLayoutMinimal': 'Minimal', + 'display.detectionToggleTitle': 'Detection Overlay (D)', + 'display.detectionAriaLabel': 'Detection overlay', + 'display.detectionLabel': 'DETECT', + 'display.densityLabel': 'Density', + 'display.densityAriaLabel': 'Detection label density', + 'display.allocationLabel': 'Allocation', + 'display.allocationAriaLabel': 'Detection label allocation', + 'display.allocationElastic': 'Elastic', + 'display.allocationWeighted': 'Weighted', + 'display.fadeLabel': 'Fade', + 'display.fadeAriaLabel': 'Detection fade distance', + 'display.fadeTitle': + 'World-overlay fade distance outside the keyhole as a percentage of its radius', + 'display.outsideLabel': 'Outside', + 'display.outsideAriaLabel': 'Detection opacity outside the keyhole', + 'display.outsideTitle': + 'World-overlay label and card opacity beyond the fade distance', + 'display.parametersTitle': 'PARAMETERS', + 'display.parametersCollapseTitle': 'Collapse panel', + 'display.modelsToggleTitle': + '3D aircraft — flat icons zoomed out, 3D models up close', + 'display.modelsLabel': 'Models', + 'display.modelsCoverageAriaLabel': '3D model coverage', + 'display.modelsModeProximity': 'Proximity', + 'display.modelsModeAll': 'All', + 'display.scopeToggleTitle': 'Scope — the circular viewport mask', + 'display.scopeLabel': 'Scope', + 'display.featherLabel': 'Feather', + 'display.featherTitle': + 'Scope edge feather as a percentage of the keyhole radius', + 'display.celestialToggleTitle': 'Celestial ring — reveal the full globe', + 'display.celestialLabel': 'Celestial', + 'display.cleanViewToggleTitle': 'Hide UI chrome', + 'display.cleanViewLabel': 'Clean UI', + 'display.bloomToggleTitle': 'Bloom / Glow', + 'display.bloomLabel': 'Bloom', + 'display.sharpenToggleTitle': 'Sharpening', + 'display.sharpenLabel': 'Sharpen', + + // index.html location bar (dock locations tray; ui.js owns the runtime half) + // Key-only: .location-toolbar-label shares its element with dock-label-icon. + 'location.toolbarLabel': 'LOCATION', + 'location.collapseTitle': 'Collapse panel', + 'location.pinAriaLabel': 'Pin location tray', + 'location.pinTitle': 'Keep location tray open', + // Initial mini-status values; ui.js rewrites both once a place resolves. + 'location.miniCityInitial': '📍 Location: --', + 'location.miniPoiInitial': 'Landmark: --', + 'location.searchToggleTitle': 'Search any location', + 'location.searchPlaceholder': 'Search any location...', + + // index.html cockpit HUD statics + 'hud.level': 'LEVEL', + 'hud.routeDirectionAriaLabel': 'Estimated destination direction', + // Initial idle readout; ui.js rewrites it with a live bearing. + 'hud.routeDirectionIdle': 'DEST ---°', + 'hud.visorPlane': 'OPTICAL PLANE · 01', + 'hud.visorLock': 'VISOR LOCK · ACTIVE', + 'hud.firstPerson': 'FIRST PERSON', + // Initial meta line; ui.js rewrites it from live track state. + 'hud.aircraftMetaInitial': 'LIVE TRACK · COURSE ALIGNED', + 'hud.visionGroupAriaLabel': 'Cockpit vision style', + 'hud.compassAriaLabel': 'Current aircraft heading', + 'readout.rimGroundSpeed': 'GROUND SPEED · KTS', + 'readout.rimAltitude': 'ALTITUDE · FT', + + // index.html cockpit vision controls + 'vision.previousLabel': 'PREV', + 'vision.previousAriaLabel': 'Previous cockpit vision style', + 'vision.previousTitle': 'Previous vision style', + // The NORMAL token inside these two is the style name; phase 3 splits the + // dynamic token out when the runtime half is extracted. + 'vision.currentAriaLabel': + 'Current cockpit vision style: NORMAL. Activate for next style.', + 'vision.currentTitle': 'Current style: NORMAL — click for next', + 'vision.nextLabel': 'NEXT', + 'vision.nextAriaLabel': 'Next cockpit vision style', + 'vision.nextTitle': 'Next vision style', + + // index.html view switcher + 'exit.navAriaLabel': 'View switcher', + 'exit.resetAriaLabel': 'Reset cockpit to full globe view', + 'exit.resetTitle': 'Exit cockpit and return to full globe view', + 'exit.resetLabel': 'RESET', + 'exit.ariaLabel': 'Exit cockpit view', + 'exit.title': 'Exit cockpit view', + + // index.html cockpit route card + 'route.cardAriaLabel': 'Estimated flight plan', + 'route.kicker': 'ESTIMATED FLIGHT PLAN', + 'route.statusUnavailable': 'ROUTE DATA UNAVAILABLE', + 'route.fromLabel': 'FROM', + 'route.toLabel': 'TO', + 'route.unknownEndpoint': 'UNKNOWN', + + // index.html cockpit briefing carousel + // (brief.kicker 'LIVE SIGNALS' is seeded above but is KEY-ONLY: #cockpit-brief-kicker + // shares its element with the live-dot , so no data-i18n attribute can target it.) + 'brief.carouselAriaLabel': 'Cockpit briefing carousel', + 'brief.actionsAriaLabel': 'Cockpit briefing controls', + 'brief.previousAriaLabel': 'Previous briefing page', + 'brief.previousTitle': 'Previous briefing page', + 'brief.nextAriaLabel': 'Next briefing page', + 'brief.nextTitle': 'Next briefing page', + // Title is static prose; the visible label + aria-label are CYCLE ON/OFF + // states that belong to the phase-3 runtime extraction. + 'brief.autoTitle': + 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.', + 'brief.collapseAriaLabel': 'Collapse cockpit briefing panel', + 'brief.collapseTitle': 'Collapse briefing panel', + 'brief.signalsAriaLabel': 'Live signals', + 'brief.newsAriaLabel': 'Latest regional news', + 'brief.localAriaLabel': 'Location-based information', + 'brief.newsAcquiring': 'ACQUIRING REGIONAL NEWS', + 'brief.localResolving': 'RESOLVING REGION', + 'brief.labelTemp': 'TEMP', + 'brief.labelWind': 'WIND', + 'brief.labelSky': 'SKY', + 'brief.labelPrecip': 'PRECIP', + 'brief.sourceNote': 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', + 'brief.tabSignalsAriaLabel': 'Show Live Signals', + 'brief.tabNewsAriaLabel': 'Show Regional News', + 'brief.tabLocalAriaLabel': 'Show Local Info', + + // index.html cockpit context panel (right-rail Global Context included) + 'context.panelAriaLabel': 'Contact cockpit summary', + 'context.navAriaLabel': 'Contact navigation', + 'context.previousAriaLabel': + 'Previous — prior visited contact in the 250 km window', + 'context.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'context.nextAriaLabel': + 'Next — nearest unvisited contact in the 250 km window', + 'context.nextTitle': 'Next — nearest unvisited contact in the 250 km window', + 'context.toggleAriaLabel': 'Collapse Contact panel', + 'context.toggleTitle': 'Collapse contact panel', + 'context.qualifier': 'CONTEXT ONLY', + 'context.cohortsAriaLabel': 'Nearby cohort counts', + 'context.nearestLabel': 'NEAREST OBSERVED / MAPPED', + 'context.nearestEmpty': 'NO AVAILABLE EXAMPLE', + 'context.uncertaintyNote': 'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR', + 'context.weatherEnableAriaLabel': 'Enable cockpit weather effects', + 'context.weatherEnableTitle': 'Enable cockpit weather effects', + 'context.panelTitle': 'CONTEXT', + 'context.collapseTitle': 'Expand panel', + 'context.modesAriaLabel': 'Context mode', + 'context.contactsTabAriaLabel': 'CONTACTS', + 'context.contactsTabTitle': + 'Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.', + 'context.contactsTabLabel': 'CONTACTS', + 'context.missionsTabLabel': 'SPACE MISSIONS', + 'context.standbyTitle': 'SELECT CONTEXT', + 'context.actionsAriaLabel': 'Contact Context actions', + 'context.cockpitEntryLabel': 'COCKPIT', + 'context.searchNearbyLabel': 'SEARCH NEARBY SITES', + 'context.tr3bAriaLabel': 'Reclassify tracked contact as TR-3B', + 'context.tr3bTitle': 'Reclassify as TR-3B', + 'context.awarenessOffTitle': 'CONTACTS CONTEXT OFF', + 'context.awarenessOffHint': + 'SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY', + 'context.rosterAriaLabel': 'Available Space Missions', + 'context.rosterTitle': 'AVAILABLE MISSIONS', + 'context.rosterHint': 'SELECT A MISSION TO INSPECT', + 'context.rosterLoading': 'LOADING 30-DAY MISSION INDEX', + 'context.radioToggleAriaLabel': 'Open compact Radio controls', + 'context.radioToggleTitle': 'Open compact Radio controls', + 'context.radioMiniGroupAriaLabel': 'Compact Radio controls', + 'context.radioMiniTitle': 'RADIO', + 'context.radioMiniReady': 'RADIO READY', + 'context.radioDetailsAriaLabel': 'Open detailed Radio controls', + 'context.radioDetailsTitle': 'Open detailed Radio controls', + 'context.radioCloseAriaLabel': 'Close compact Radio controls', + 'context.radioCloseTitle': 'Close compact Radio controls', + 'context.radioMiniEnable': 'ENABLE', + 'context.radioMiniPrevAriaLabel': 'Previous filtered radio station', + 'context.radioMiniPrevTitle': 'Previous station', + 'context.radioMiniPlayAriaLabel': 'Play selected radio station', + 'context.radioMiniPlayTitle': 'Play', + 'context.radioMiniNextAriaLabel': 'Next filtered radio station', + 'context.radioMiniNextTitle': 'Next station', + 'context.radioMiniVolumeLabel': 'VOLUME', + 'context.radioMiniVolumeAriaLabel': 'Compact Radio volume', + + // index.html cockpit utility controls + compact radio popover + 'utility.controlsAriaLabel': 'Cockpit display and Radio controls', + 'utility.displayLabel': 'DISPLAY', + 'utility.radioLabel': 'RADIO', + 'utility.displayToggleAriaLabel': 'Expand Cockpit display options', + 'utility.displayToggleTitle': 'Expand Cockpit display options', + 'utility.displayPanelAriaLabel': 'Cockpit display options', + 'utility.radioToggleAriaLabel': 'Expand Cockpit Radio controls', + 'utility.radioToggleTitle': 'Expand Cockpit Radio controls', + 'utility.radioPanelAriaLabel': 'Cockpit compact Radio controls', + 'radio.enable': 'ENABLE', + 'radio.prevAriaLabel': 'Previous filtered radio station', + 'radio.playAriaLabel': 'Play selected radio station', + 'radio.nextAriaLabel': 'Next filtered radio station', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Cockpit Radio volume', + + // ── Phase-3 runtime extraction (src/ui.js), appended ───────────────────── + // Values stay byte-identical to the previous English literals; existing + // tests pin many of them under the default locale. + + // _syncPanelCollapseButton(): composed panel collapse titles. {name} is the + // panel's own (already localized) .panel-title text. + 'panel.expandTitle': 'Expand {name}', + 'panel.collapseTitle': 'Collapse {name}', + // Fallback when a panel ships no .panel-title/.pp-header-label node. + 'panel.fallbackName': 'panel', + // Radio panel collapse state; the expand state reuses the layers.radio.* + // keys seeded on the static button in phase 2. + 'panel.radioCollapseTitle': 'Collapse Radio', + 'panel.radioCollapseAria': 'Collapse Radio section', + // _updateLocationMiniStatus(): runtime rewrite of the collapsed LOCATION + // readout's single-segment search fallback (initial-state keys + // location.miniCityInitial/miniPoiInitial were seeded in phase 2). + 'location.miniSearchedPlaceholder': 'Searched location', + + // ── Phase-3 runtime extraction batch 2: cockpit + Context renderers ─────── + + // syncWeatherToggle(): state-sibling keys for the runtime ON/OFF rewrite + // (context.weatherEnableAriaLabel was seeded on the static button). + 'context.weatherDisableAriaLabel': 'Disable cockpit weather effects', + 'context.weatherStateOn': 'ON', + 'context.weatherStateOff': 'OFF', + // syncTr3bToggle(): converted-state title sibling of context.tr3bTitle. + 'context.tr3bRestoreTitle': 'Restore real aircraft', + // setVisionMode(): the style token is dynamic, so the sentence is split out + // of the seeded vision.currentAriaLabel/currentTitle (whose values pin the + // NORMAL default) into {style} templates. + 'vision.styleNameNightVision': 'Night vision', + 'vision.styleNameThermal': 'Thermal', + 'vision.styleNameNoir': 'Noir', + 'vision.currentAriaTemplate': + 'Current cockpit vision style: {style}. Activate for next style.', + 'vision.currentTitleTemplate': 'Current style: {style} — click for next', + + // Cockpit signal stream (pushCockpitSignal / renderCockpitSignals). + 'signal.trackAcquired': 'TRACK ACQUIRED', + 'signal.trackDetail': '{label} · COURSE {heading}°', + 'signal.selectFlightAria': 'Select flight {title}', + 'signal.contextStandby': 'CONTEXT STANDBY', + 'signal.contextStandbyHint': 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', + 'signal.contactLostTitle': 'CONTACT LOST · {subject}', + 'signal.contactLostDetail': + 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', + 'signal.classMilitary': 'MILITARY FLIGHT', + 'signal.classCommercial': 'COMMERCIAL FLIGHT', + 'signal.contactCurrent': '{aircraftClass} · CURRENT', + 'signal.contactRange': '{aircraftClass} · {distance}', + 'signal.distanceUnknown': 'DISTANCE UNKNOWN', + 'signal.inputsUnknown': { + one: '{count} INPUT UNKNOWN', + other: '{count} INPUTS UNKNOWN', + }, + 'signal.sourceStatusUnavailable': 'SOURCE STATUS UNAVAILABLE', + + // updateHud(): callsign fallback + the composed aircraft meta line (each + // feed state is its own key so no state can leak English later). + 'hud.fallbackCallsign': 'AIRCRAFT', + 'hud.metaClassMilitary': 'MILITARY', + 'hud.metaClassCommercial': 'COMMERCIAL', + 'hud.metaFeedAcquiringSurface': 'ACQUIRING SURFACE', + 'hud.metaFeedSurfaceFallback': 'SURFACE FALLBACK', + 'hud.metaFeedStale': 'STALE FEED', + 'hud.metaFeedLive': 'LIVE TRACK', + 'hud.aircraftMetaTemplate': '{aircraftClass} · {feedState} · COURSE ALIGNED', + + // updateRoute(): runtime state siblings of the seeded route keys. + 'route.statusArrowEstimated': 'ARROW · ESTIMATED DIRECTION', + 'route.directionLabel': 'DEST {bearing}', + + // updateContext(): uncertainty/nearest/bearing readout states. + 'context.uncertaintyContactLost': + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', + 'context.uncertaintyInputsUnknown': { + one: '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + other: '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + }, + 'context.uncertaintyInputsCurrent': + 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR', + 'context.nearestTemplate': '{cohort} · {contact}', + 'context.nearestUnavailableAria': '{cohort}, Unavailable', + 'context.bearingNone': 'BRG —', + 'context.bearingAhead': 'AHEAD', + 'context.bearingSide': '{side} {angle}', + 'context.sideLeft': 'L', + 'context.sideRight': 'R', + + // setContextCollapsed()/setSignalCollapsed(): expand-state siblings. + 'context.toggleExpandAriaLabel': 'Expand Contact panel', + 'context.toggleExpandTitle': 'Expand contact panel', + 'brief.expandAriaLabel': 'Expand cockpit briefing panel', + 'brief.expandTitle': 'Expand briefing panel', + + // setBriefAutoRotate(): cycle toggle states + the ON-state help text (the + // OFF-state help is the seeded brief.autoTitle). + 'brief.autoOn': 'CYCLE ON', + 'brief.autoOff': 'CYCLE OFF', + 'brief.autoTitleOn': + 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.', + + // Briefing carousel pages (kicker/subtitle/source per page; the news/local + // source lines are provider attribution and stay English). + 'brief.kickerNews': 'REGIONAL NEWS', + 'brief.kickerLocal': 'LOCAL INFO', + 'brief.subtitleNews': 'LATEST LOCATION-MATCHED REPORTING', + 'brief.subtitleLocal': 'PLACE / CONDITIONS / POSITION', + + // Local position + regional brief status states. + 'brief.positionUnavailable': 'POSITION UNAVAILABLE', + 'brief.newsUnavailable': 'REGIONAL NEWS UNAVAILABLE', + 'brief.regionUnavailable': 'REGION UNAVAILABLE', + 'brief.newsEmpty': 'NO RECENT LOCATION MATCHES', + + // renderRegionalBrief(): article metadata, cloud readout, and age chips. + 'brief.metadataSourceFallback': 'SOURCE', + 'brief.articleMetaTemplate': '{domain} · {age}', + 'brief.newsSourceLine': '{source} · LOCATION QUERY', + 'brief.age.timeUnknown': 'TIME UNKNOWN', + 'brief.age.minutes': '{count}M AGO', + 'brief.age.hours': '{count}H AGO', + 'brief.age.days': '{count}D AGO', + 'brief.wind.dirUnknown': 'DIR UNKNOWN', + 'brief.cloudTemplate': 'CLOUD {pct}%', + 'brief.cloudUnknown': 'CLOUD UNKNOWN', + + // ── Phase-3 runtime extraction batch 3: toasts, loading helpers, map tray ─ + + // Panel-chrome + share/location toasts. + 'panel.layoutResetToast': + 'Panel layout updated — positions reset to new defaults', + 'share.toastCopied': 'Link copied!', + 'share.toastCopyFailed': 'Copy failed', + 'location.toastNotFound': 'Location not found', + 'location.toastSearchFailed': 'Search failed', + 'location.toastFlyToPoiFirst': 'Fly to a POI first', + 'actions.clearLayersBusyAria': 'Clearing selected data layers', + 'actions.clearLayersFailedToast': 'Selected data layers could not be cleared', + + // Global status chip notices (_handleShareTrackingRestoreStatus). + 'status.acquiring': 'ACQUIRING', + 'status.subjectFallback': 'entity', + 'status.sharedSubjectDetail': 'SHARED {subject}', + 'status.sharedFollowExpired': 'Shared {subject} follow expired', + 'status.sharedRestoreFailed': + 'Shared {subject} could not be restored — feed unavailable', + 'status.sharedUnavailable': 'Shared {subject} is unavailable', + + // Context mode user-facing action failures. + 'context.modeContext': 'Context', + 'context.modeSpaceMissions': 'Space Missions', + 'context.toastStartBlocked': + '{mode} could not start because another layer did not stop cleanly', + 'context.toastTransitionFailedContacts': + 'Contacts could not complete the requested transition; try again', + 'context.toastTransitionFailedMissions': + 'Space Missions could not complete the requested transition; try again', + 'context.toastInstallationsRefreshFailed': + 'Nearby installations could not be refreshed; try again', + 'context.toastRestoreFailed': + 'Context could not restore every layer; try again', + 'context.toastZoomToSearch': 'Zoom in to search mapped installations', + 'context.toastInstallationsRefreshed': 'Nearby installations refreshed', + 'context.toastLayerUnavailable': + 'That layer is unavailable in the current Context mode', + 'context.actionStart': 'start', + 'context.actionStop': 'stop', + 'context.toastLayerLifecycleFailed': '{layerId} could not {action} cleanly', + 'radio.toastLifecycleFailed': 'Radio could not {action} cleanly', + + // CCTV sync chip captions + calibration/availability toasts. + 'cctv.syncLoadingFrames': 'loading frames', + 'cctv.syncGridReady': 'camera grid ready', + 'cctv.toastCalibrationSaved': 'CCTV calibration saved', + 'cctv.toastCalibrationReset': 'CCTV calibration reset', + 'cctv.toastLayerUnavailable': 'CCTV layer unavailable', + + // _renderMapStackState(): status chip fallback when no stack label resolves + // (stack names themselves are keep-English provider/stack ids). + 'presets.mapStackFallback': 'MAP', + + // ── Phase-3 runtime extraction batch 4: awareness / CCTV / Radio panels ─── + // (radio.* and cctv.* state values that layers.js already seeds are reused + // cross-namespace; these are the runtime-only siblings and compositions.) + + // _applyRuntimeStaticHeaderText(): the key-only standby description span + // (both mode descriptions in one span split by a literal
in phase 2). + 'context.standbyContactsDesc': 'CONTACTS — nearest planes · vessels · sites', + 'context.standbyMissionsDesc': 'SPACE MISSIONS — launches & orbital assets', + // _syncContextRadioLauncherState(): close-state sibling of the seeded + // context.radioToggleAriaLabel ('Open compact Radio controls'). + 'context.radioToggleCloseAriaLabel': 'Close compact Radio controls', + 'context.toastMissionsCancelRestoreFailed': + 'Space Missions cancellation could not restore the previous layer state', + // Cockpit utility disclosures: collapse-state siblings of the seeded + // utility.*ToggleAriaLabel expand keys. + 'utility.displayToggleCollapseAriaLabel': 'Collapse Cockpit display options', + 'utility.radioToggleCollapseAriaLabel': 'Collapse Cockpit Radio controls', + + // Radio panel runtime states (state-sibling keys so no state can leak + // English later; lifecycle enum labels reuse layers.status.*). + 'radio.stateSync': 'SYNC', + 'radio.tunerCategoryBand': '{category} BAND', + 'radio.tunerNoStations': 'NO STATIONS', + 'radio.tunerStationAria': '{name}, station {index} of {total}', + 'radio.tunerNoStationAria': 'No station available', + 'radio.tunerOffAir': 'OFF AIR', + 'radio.tunerStationUnavailable': 'STATION UNAVAILABLE', + 'radio.actionPlay': 'Play', + 'radio.actionPause': 'Pause', + 'radio.actionResume': 'Resume', + 'radio.targetSelected': 'selected', + 'radio.targetNearest': 'nearest', + 'radio.playStateAria': '{action} {target} radio station', + 'radio.miniStateUncertain': 'RADIO STATE UNCERTAIN', + 'radio.miniSyncingDirectory': 'SYNCING DIRECTORY', + 'radio.stationSyncing': 'SYNCING', + 'radio.stationFallback': 'station', + 'radio.playbackReadyFallback': 'Ready', + + // CCTV panel runtime states (layers.cctv.* seeded keys reused where they + // match; these are the runtime-only compositions and flipped states). + 'cctv.coverageViewshedOn': 'VIEWSHED ON', + 'cctv.frameLoading': 'FRAME · LOADING', + 'cctv.frameUnavailable': 'FRAME · UNAVAILABLE', + 'cctv.calChipEdited': 'CAL · EDITED (UNSAVED)', + 'cctv.calChipTemplate': 'CAL · {badge}', + 'cctv.calBadgeCalibrated': 'CALIBRATED', + 'cctv.calBadgeCurated': 'CURATED', + 'cctv.calBadgeRawPrior': 'RAW PRIOR', + 'cctv.metaProjectionMonitor': 'MONITOR', + 'cctv.metaProjectionOff': 'OFF', + 'cctv.metaTemplate': + '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', + 'cctv.metaCamerasClick': { + one: '{count} camera loaded · click a camera to activate', + other: '{count} cameras loaded · click a camera to activate', + }, + 'cctv.metaCamerasEnable': { + one: '{count} camera loaded · enable CCTV to activate', + other: '{count} cameras loaded · enable CCTV to activate', + }, + 'cctv.summaryNoneAvailable': 'No summary available.', + + // ── Phase-3 runtime extraction batch 5: remainder + hud.js ──────────────── + + // _setCelestialRingEnabled(): unsupported-style title sibling of the seeded + // display.celestialToggleTitle. + 'display.celestialUnavailableTitle': + 'Celestial ring — available in Normal style', + // _updateDetectionButton(): density-profile labels (display.detectionLabel + // 'DETECT' was seeded on the static button) + composed aria. + 'display.detectionAriaTemplate': 'Detection overlay: {mode}', + 'display.detectionAriaOff': 'Detection overlay: off', + 'display.detectionLabelSparse': 'SPARSE', + 'display.detectionLabelBalanced': 'BALANCED', + 'display.detectionLabelDense': 'DENSE', + // Globe-reset buttons: idle/working aria states (cockpit variant sibling). + 'hud.resetGlobeAria': 'Reset to full globe view', + 'hud.resetGlobeCockpitAria': 'Reset cockpit to full globe view', + 'hud.resettingGlobeAria': 'Resetting to full globe view', + 'hud.resettingGlobeCockpitAria': 'Resetting cockpit to full globe view', + // _initOrbit(): indicator caption after the orbit glyph. + 'location.orbitLabel': 'ORBIT', + // Intel HUD (src/hud.js): summary caption, idle placeholder, REC indicator. + // Classification banners, terse instrument readout codes (MGRS/GSD/NIIRS/ + // ALT/COLL/ONA/BAND/BITS/LVL), and the AI summary line stay keep-English. + 'hud.summaryLabel': 'SUMMARY', + 'hud.summaryAwaiting': 'Awaiting telemetry...', + 'hud.recLabel': 'REC', + + // Upstream a11y additions (post-merge follow-up): slider/toggle accessible names. + 'display.featherAria': 'Scope edge feather', + 'display.bloomAria': 'Bloom intensity', + 'display.sharpenAria': 'Sharpen intensity', + 'location.searchAria': 'Search location by name or coordinates', + 'location.toggleAria': 'Expand LOCATION', +}; diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js new file mode 100644 index 000000000..458a648f7 --- /dev/null +++ b/src/i18n/locales/en/layers.js @@ -0,0 +1,501 @@ +// English catalog — layers namespace (phase-1 seed). +// +// Owns data-layer presentation copy: display names, feed-state labels, and +// layer batch toasts. Machine-readable values (layer ids, status enum keys, +// provider names) are intentionally NOT here — see docs/TRANSLATORS.md +// for the keep-English boundary. Keys are namespace-relative; the registry +// prefixes them with `layers.`. +export const NAMESPACE = 'layers'; + +export default { + // src/data/manager.js FEED_STATE_LABELS value + 'status.unavailable': 'UNAVAILABLE', + // src/data/flights.js / src/data/earthquakes.js layer display names + 'name.liveFlights': 'Live Flights', + 'name.earthquakes': 'Earthquakes (24h)', + // src/ui.js clearSelectedLayers() result toasts (genuine plural copy) + 'clear.toast.noneSelected': 'No selected data layers', + 'clear.toast.cleared': { + one: 'Cleared {count} data layer', + other: 'Cleared {count} data layers', + }, + 'clear.toast.notCleared': { + one: '{count} data layer could not be cleared', + other: '{count} data layers could not be cleared', + }, + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // layers-worker surfaces (world overlay, CCTV panel, Radio panel) whose + // runtime presentation paths land here in phase 3. + + // index.html #world-overlay-actions region (src/overlays/worldOverlay.js) + 'overlay.regionAriaLabel': 'Visible map targets', + + // index.html CCTV panel chrome. Button texts marked as states (toggleOff, + // coverageOff, autoHopOff, projectionOn) are the shipped initial values; the + // phase-3 layer worker adds the flipped-state siblings. + 'cctv.panelTitle': 'CCTV', + 'cctv.collapseTitle': 'Collapse panel', + 'cctv.sourceUnknown': 'SOURCE · UNKNOWN', + 'cctv.metaIdle': 'Enable CCTV to load camera intersections', + 'cctv.toggleOff': 'CCTV OFF', + 'cctv.nearest': 'NEAREST', + 'cctv.prev': 'PREV', + 'cctv.cameraAriaLabel': 'CCTV camera', + 'cctv.next': 'NEXT', + 'cctv.focus': 'FOCUS', + 'cctv.coverageOff': 'COVERAGE OFF', + 'cctv.autoHopOff': 'AUTO HOP OFF', + 'cctv.projectionOn': 'PROJECTION ON', + 'cctv.calibrationLabel': 'CALIBRATION', + 'cctv.adjustLabel': 'ADJUST', + 'cctv.adjustTitle': + 'Drag the camera in the world: rings rotate, arrows move, handles set range/FOV', + 'cctv.calPoseAriaLabel': 'Camera pose — click a value to type', + 'cctv.calHeadingTitle': 'Heading (compass °) — click to type', + 'cctv.calPitchTitle': 'Pitch (° up/down) — click to type', + 'cctv.calFovTitle': 'Horizontal FOV (°) — click to type', + 'cctv.calRangeTitle': 'Range / monitor-plane distance (m) — click to type', + 'cctv.calHeightTitle': 'Mount height above ground (m) — click to type', + 'cctv.calNorthTitle': + 'North offset from catalog position (m) — click to type', + 'cctv.calEastTitle': 'East offset from catalog position (m) — click to type', + 'cctv.saveCal': 'SAVE CAL', + 'cctv.resetCal': 'RESET CAL', + 'cctv.summaryLabel': 'SCENE SUMMARY', + 'cctv.summaryIdle': + 'Enable CCTV to start camera-linked intelligence summaries.', + + // index.html Radio panel chrome (right rail #radio-panel) + 'radio.panelAriaLabel': 'Internet radio and local SDR', + 'radio.panelTitle': 'RADIO', + 'radio.expandTitle': 'Expand Radio', + 'radio.expandAriaLabel': 'Expand Radio section', + 'radio.enable': 'ENABLE', + 'radio.stationTagLabel': 'STATION TAG', + 'radio.filterAriaLabel': 'Filter stations by station tag', + 'radio.filterAll': 'All', + 'radio.noStation': 'NO STATION SELECTED', + 'radio.stationHint': 'Enable Radio, then choose a globe marker or use next.', + // Key-only: the #radio-tuner-band-label span text is pinned verbatim by + // radioMarkup.test.mjs, so the attribute must be wired by a later phase. + 'radio.bandLabel': 'DIRECTORY BAND', + 'radio.dragToTune': 'DRAG TO TUNE', + 'radio.tunerIdle': 'ALL · DRAG THE NEEDLE', + 'radio.snapsNote': 'SNAPS TO AVAILABLE STATIONS', + 'radio.transportAriaLabel': 'Radio playback', + 'radio.prev': 'PREV', + 'radio.prevAriaLabel': 'Previous filtered station', + 'radio.play': 'PLAY', + 'radio.playAriaLabel': 'Play selected station', + 'radio.next': 'NEXT', + 'radio.nextAriaLabel': 'Next filtered station', + 'radio.stop': 'STOP', + 'radio.stopAriaLabel': 'Stop radio playback', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Radio volume', + // Initial idle announcement; the runtime states land with the layers worker. + 'radio.playbackOff': 'Radio off', + 'radio.stationSite': 'STATION SITE', + 'radio.privacyNote': + 'Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.', + + // ── Phase-3 runtime extraction (src/data/*), appended ──────────────────── + // Layer display names are translated only at the presentation boundary + // (manager.js _renderToggles/_syncToggleButton map layer.id → key here). + // layer.id values, FEED_STATE_LABELS enum KEYS, and lifecycle state keys + // stay English — see docs/TRANSLATORS.md keep-English boundary. + + // src/data/manager.js FEED_STATE_LABELS values + toggle-button states. + 'status.on': 'ON', + 'status.loading': 'LOADING', + 'status.degraded': 'DEGRADED', + 'status.stale': 'STALE', + 'status.fallback': 'FALLBACK', + 'status.off': 'OFF', + 'status.partial': 'PARTIAL', + 'status.uncertain': 'UNCERTAIN', + 'status.enabling': 'ENABLING', + 'status.disabling': 'DISABLING', + + // Layer display names (toggle panel rows + toggle-button aria-label). + 'name.militaryFlights': 'Military Flights', + 'name.satellites': 'Satellites', + 'name.rocketLaunches': 'Space Missions (30d)', + 'name.traffic': 'Street Traffic', + 'name.cctv': 'CCTV', + 'name.radio': 'Radio', + 'name.bikeshare': 'Bikeshare', + 'name.aisVessels': 'Live AIS Vessels', + 'name.installations': 'Mapped Installations', + 'name.globalContext': 'Global Context', + 'name.datacenters': 'Datacenters', + 'name.dams': 'Dams', + 'name.submarineCables': 'Submarine Cables', + 'name.fires': 'FIRMS Active Fires', + + // src/data/manager.js data-panel row meta line (_buildMetaText) + time ago. + 'meta.justNow': 'just now', + 'meta.secondsAgo': '{count}s ago', + 'meta.minutesAgo': '{count}m ago', + 'meta.hoursAgo': '{count}h ago', + 'meta.never': 'never', + 'meta.loading': 'loading...', + 'meta.uncertainLifecycle': + 'UNCERTAIN · {source} · lifecycle state requires reconciliation', + 'meta.transitioning': '{state} · {source}', + 'meta.stateSourceRetry': '{state} · {source} · {detail} · retry {seconds}s', + 'meta.stateSourceDetail': '{state} · {source} · {detail}', + 'meta.stateSourceAgo': '{state} · {source} · {ago}', + 'meta.stateSourceAgoRetry': + '{state} · {source} · {ago} · retrying in {seconds}s', + 'meta.sourceLoading': '{source} · {loadingLabel}', + 'meta.sourceAgo': '{source} · {ago}', + 'meta.partialCounts': '{accepted} of {raw} records accepted', + 'meta.partialIncomplete': 'incomplete snapshot', + 'meta.partialDetail': '{state} · {source} · {detail} · {ago}', + 'meta.toggleAriaLabel': '{name}: {state}', + + // src/data/militaryFlights.js tracked readout fallback captions. + 'readout.typeUnknown': 'Type unknown', + 'readout.regUnknown': 'Reg unknown', + 'readout.operatorUnknown': 'Operator unknown', + 'readout.altUnknown': 'Alt unknown', + + // src/data/vesselLabels.js AIS type names (display only — the styling + // matcher keeps the English tokens as machine values). + 'vessel.type.fishing': 'FISHING', + 'vessel.type.towing': 'TOWING', + 'vessel.type.dredger': 'DREDGER', + 'vessel.type.diveOps': 'DIVE OPS', + 'vessel.type.military': 'MILITARY', + 'vessel.type.sailing': 'SAILING', + 'vessel.type.pleasure': 'PLEASURE', + 'vessel.type.pilot': 'PILOT', + 'vessel.type.sar': 'SAR', + 'vessel.type.tug': 'TUG', + 'vessel.type.portTender': 'PORT TENDER', + 'vessel.type.antiPollution': 'ANTI-POLLUTION', + 'vessel.type.lawEnforce': 'LAW ENFORCE', + 'vessel.type.medical': 'MEDICAL', + 'vessel.type.highSpeed': 'HIGH-SPEED', + 'vessel.type.passenger': 'PASSENGER', + 'vessel.type.cargo': 'CARGO', + 'vessel.type.tanker': 'TANKER', + 'vessel.type.other': 'OTHER', + + // src/data/aisLiveVessels.js vessel cards + selected-vessel HUD. + 'vessel.fallback': 'VESSEL', + 'vessel.posLive': 'POS: LIVE', + 'vessel.posAt': 'POS: {time}Z', + 'vessel.hudIdle': 'AIS: --', + 'vessel.hudName': 'AIS: {name}', + 'vessel.hudTypeLine': '{type} SPD: {speed} HDG: {heading}', + 'vessel.awaitingFirstPosition': 'awaiting first AIS position…', + + // index.html CCTV panel flipped-state siblings (runtime writers live in + // src/ui.js, owned by core-ui — these keys are the layers-surface half of + // the cross-surface rule in docs/TRANSLATORS.md). + 'cctv.toggleOn': 'CCTV ON', + 'cctv.coverageOn': 'COVERAGE ON', + 'cctv.autoHopOn': 'AUTO HOP ON', + 'cctv.projectionOff': 'PROJECTION OFF', + 'cctv.adjustOn': 'ADJUST ON', + + // src/data/cctv.js buildSummaryText() — the SCENE SUMMARY line. + 'cctv.summary.standingBy': + '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE', + 'cctv.summary.empty': 'No cameras available in catalog.', + 'cctv.summary.city': '{city} CCTV', + 'cctv.summary.hdg': 'HDG {value}°', + 'cctv.summary.fov': 'FOV {value}°', + 'cctv.summary.coverage': 'COVERAGE {value}km²', + 'cctv.summary.overlap': 'OVERLAP {count} cams', + 'cctv.summary.isolated': 'ISOLATED VIEW', + 'cctv.summary.projMonitor': 'PROJ MONITOR', + 'cctv.summary.projOff': 'PROJ OFF', + 'cctv.summary.viewshed': 'VIEWSHED', + 'cctv.summary.cal': 'CAL {value}', + 'cctv.summary.src': 'SRC {value}', + 'cctv.summary.context': '{value} CONTEXT', + + // src/data/radio.js station-tag categories, genre labels, cluster badges. + 'radio.category.news': 'News', + 'radio.category.talk': 'Talk', + 'radio.category.weather': 'Weather / Emergency', + 'radio.category.publicSafety': 'Public Safety', + 'radio.category.aviationMarine': 'Aviation / Marine', + 'radio.category.trafficTransit': 'Traffic / Transit', + 'radio.category.music': 'Music', + 'radio.category.other': 'Other', + 'radio.genre.alternative': 'Alternative', + 'radio.genre.ambient': 'Ambient', + 'radio.genre.blues': 'Blues', + 'radio.genre.classical': 'Classical', + 'radio.genre.country': 'Country', + 'radio.genre.dance': 'Dance', + 'radio.genre.electronic': 'Electronic', + 'radio.genre.folk': 'Folk', + 'radio.genre.funk': 'Funk', + 'radio.genre.hipHop': 'Hip-Hop', + 'radio.genre.house': 'House', + 'radio.genre.indie': 'Indie', + 'radio.genre.jazz': 'Jazz', + 'radio.genre.latin': 'Latin', + 'radio.genre.metal': 'Metal', + 'radio.genre.oldies': 'Oldies', + 'radio.genre.pop': 'Pop', + 'radio.genre.punk': 'Punk', + 'radio.genre.rAndB': 'R&B', + 'radio.genre.reggae': 'Reggae', + 'radio.genre.rock': 'Rock', + 'radio.genre.soul': 'Soul', + 'radio.genre.techno': 'Techno', + 'radio.genre.trance': 'Trance', + 'radio.genre.world': 'World', + 'radio.cluster.news': 'NEWS', + 'radio.cluster.talk': 'TALK', + 'radio.cluster.weather': 'WEATHER', + 'radio.cluster.publicSafety': 'SAFETY', + 'radio.cluster.aviationMarine': 'AIR / SEA', + 'radio.cluster.trafficTransit': 'TRANSIT', + 'radio.cluster.music': 'MUSIC', + 'radio.cluster.other': 'OTHER', + + // Radio right-rail panel runtime states (seeded here per the ownership + // contract; src/ui.js wires the writes — core-ui). English values mirror + // the literals ui.js writes today so wiring is byte-identical under 'en'. + 'radio.noStationAvailable': 'NO STATION AVAILABLE', + 'radio.disable': 'DISABLE', + 'radio.reconcile': 'RECONCILE', + 'radio.enableAria': 'Enable Radio', + 'radio.disableAria': 'Disable Radio', + 'radio.reconcileAria': 'Reconcile Radio — lifecycle uncertain', + 'radio.meta.loading': 'Loading station directory…', + 'radio.meta.metadataOnly': 'Directory metadata only', + 'radio.meta.chooseHint': 'Choose a globe marker or use next.', + 'radio.tags': 'TAGS · {tags}', + 'radio.state.ready': 'Ready — playback starts only from your action', + 'radio.state.loading': 'Connecting directly to broadcaster…', + 'radio.state.buffering': 'Buffering broadcaster stream…', + 'radio.state.playing': 'Playing {station}', + 'radio.state.paused': 'Paused {station}', + 'radio.state.error': 'Broadcaster stream unavailable', + 'radio.state.degradedDirectory': ' · degraded directory', + 'radio.state.staleDirectory': ' · stale directory', + 'radio.state.staleDegradedDirectory': ' · stale/degraded directory', + 'radio.state.outsideFilter': ' · outside current filter', + 'radio.state.voiceMuted': ' · muted during voice interaction', + 'radio.state.voiceRestoring': ' · restoring volume after voice', + 'radio.state.staticNoAudio': ' · static indicates no broadcaster audio', + 'radio.state.tuningStatic': ' · tuning static until broadcaster starts', + 'radio.state.stationUnavailable': + 'Station unavailable after directory refresh — choose another channel', + 'radio.state.enabling': 'Radio is enabling…', + 'radio.state.disabling': 'Radio is disabling…', + 'radio.state.uncertain': + 'Radio lifecycle is uncertain — use Enable or Disable to reconcile', + + // src/data/rocketLaunches.js space-mission surface (runtime-built panel, + // roster, replay overlay, and map cards). + 'missions.launchSite': 'LAUNCH SITE', + 'missions.launchSiteValue': 'LAUNCH SITE · {site}', + 'missions.panel.selectedHeader': 'SELECTED SPACE MISSION', + 'missions.panel.showAllTitle': 'Show all missions', + 'missions.panel.deselectAria': 'Deselect mission', + 'missions.panel.mission': 'MISSION', + 'missions.panel.status': 'STATUS · ', + 'missions.panel.launchSite': 'LAUNCH SITE · ', + 'missions.panel.launchTime': 'LAUNCH TIME · ', + 'missions.panel.orbit': 'ORBIT · ', + 'missions.panel.ascentPath': 'ASCENT PATH · ', + 'missions.panel.distance': 'CURRENT DISTANCE FROM EARTH · ', + 'missions.panel.speed': 'SATELLITE SPEED · ', + 'missions.panel.payload': 'PAYLOAD', + 'missions.panel.colName': 'NAME', + 'missions.panel.colType': 'TYPE', + 'missions.panel.colDestination': 'DESTINATION', + 'missions.panel.stagesSection': 'STAGE / RE-ENTRY / RECOVERY', + 'missions.panel.colStage': 'STAGE', + 'missions.panel.colStatus': 'STATUS', + 'missions.panel.colFinalPosition': 'FINAL POSITION', + 'missions.panel.focus': 'FOCUS', + 'missions.panel.prev': 'PREV', + 'missions.panel.next': 'NEXT', + 'missions.panel.prevTitle': 'Previous mission', + 'missions.panel.nextTitle': 'Next mission', + 'missions.panel.showAll': 'SHOW ALL / DESELECT', + 'missions.roster.empty': 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + 'missions.roster.count': '{count} / 30D', + 'missions.roster.dateUnavailable': 'DATE UNAVAILABLE', + 'missions.roster.unspecifiedOperator': 'UNSPECIFIED OPERATOR', + 'missions.roster.selectAria': 'Select {mission}', + 'missions.rows.additionalPayloads': '+{count} additional payload records', + 'missions.rows.classified': 'PAYLOAD DATA UNAVAILABLE', + 'missions.rows.noStageData': 'NO STAGE RE-ENTRY / RECOVERY DATA', + 'missions.rows.unspecified': 'UNSPECIFIED', + 'missions.rows.unavailable': 'UNAVAILABLE', + 'missions.rows.flightNumber': 'FLIGHT {count}', + 'missions.rows.reused': 'REUSED', + 'missions.rows.positionUnavailable': 'POSITION UNAVAILABLE', + 'missions.rows.kmDownrange': '{value} KM DOWNRANGE', + 'missions.rows.plannedPrefix': 'PLANNED · ', + 'missions.rows.suppliedTrajectory': 'SUPPLIED TRAJECTORY POINTS', + 'missions.rows.reconstructedEstimate': 'RECONSTRUCTED ESTIMATE', + 'missions.telemetry.km': '{value} KM', + 'missions.telemetry.kmPerSecond': '{value} KM/S', + 'missions.replay.start': 'REPLAY ASCENT', + 'missions.replay.startTitle': + 'Replay the estimated ascent with a following camera', + 'missions.replay.pause': 'Pause replay', + 'missions.replay.resume': 'Resume replay', + 'missions.replay.cancel': 'Cancel replay', + 'missions.replay.speedLabel': 'REPLAY SPEED', + 'missions.replay.speedAria': 'Replay speed multiplier', + 'missions.replay.countdown': 'T−{time} · {mission}', + 'missions.replay.standbyDetail': 'LAUNCH STANDBY', + 'missions.replay.liftoff': 'LIFTOFF · {mission}', + 'missions.replay.ascentReplay': 'ASCENT REPLAY · {mission}', + 'missions.replay.ascentEstimate': 'ASCENT ESTIMATE · {mission}', + 'missions.replay.recovery': 'STAGE RE-ENTRY / RECOVERY · {mission}', + 'missions.replay.orbit': 'ORBIT REPLAY · {mission}', + 'missions.replay.pausedPrefix': 'PAUSED · {title}', + 'missions.replay.phaseCountdown': 'T minus {seconds}', + 'missions.replay.phasePreparing': 'Preparing launch site', + 'missions.replay.phaseLiftoff': 'Liftoff', + 'missions.replay.phaseAscent': 'Ascent replay', + 'missions.replay.phaseOrbit': 'Orbit replay', + 'missions.replay.pausedSuffix': ', paused', + + // src/data/satellites.js dense-shell chip + src/data/satelliteClass.js + // class labels and legend blurbs. + 'satellites.denseChip': 'DENSE', + 'satellites.denseChipLoading': 'DENSE ···', + 'satellites.denseChipFailed': 'DENSE ✕', + 'satellites.denseTitleAdd': + 'Add the full Starlink broadband shell (thousands of extra points)', + 'satellites.denseTitleLoading': 'Loading the Starlink shell…', + 'satellites.denseTitleFailed': 'Starlink {detail} — click to retry', + 'satellites.loadFailed': 'load failed', + 'satellites.denseTitleActive': + 'Showing the full Starlink shell — click for the core catalog only', + 'satellites.class.station': 'STATION', + 'satellites.class.stationBlurb': + 'Crewed stations and their visiting vehicles', + 'satellites.class.nav': 'NAV', + 'satellites.class.navBlurb': 'GNSS navigation — GPS, GLONASS, Galileo', + 'satellites.class.geo': 'GEO', + 'satellites.class.geoBlurb': + 'Geostationary belt — comms and weather, fixed over the equator', + 'satellites.class.visual': 'VISUAL', + 'satellites.class.visualBlurb': + 'Brightest naked-eye objects — CelesTrak visual group', + 'satellites.class.comms': 'COMMS', + 'satellites.class.commsBlurb': + 'Broadband constellation shell — shown only in DENSE mode', + + // src/data/firmsHeatmap.js stats labels + fire/cell cards. + 'meta.refreshing': 'refreshing...', + 'firms.keyRequired': 'KEY REQUIRED', + 'firms.staleCached': 'STALE · cached {age}', + 'firms.liveUpdated': 'LIVE · updated {age}', + 'firms.underMinuteAgo': '<1m ago', + 'firms.card.fire': 'FIRE · {frp} MW', + 'firms.card.ambient': '▲ {frp} MW', + 'firms.card.fireNoun': 'FIRE', + 'firms.card.firesNoun': 'FIRES', + 'firms.card.confSuffix': '{value} conf', + 'firms.card.ageSuffix': '{value} ago', + 'firms.card.maxFrp': 'max {value} MW', + 'firms.card.newAge': 'new {value}', + 'firms.card.night': 'NIGHT', + 'firms.card.sensorUnavailable': 'sensor n/a', + 'firms.card.focusAria': 'Focus fire detection {title}, {details}', + + // src/data/traffic.js feed presentation (manager meta loadingLabel values). + 'traffic.loadingSyncing': 'syncing LIVE traffic flow', + 'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov', + 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', + 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', + + // src/data/bikeshare.js loading labels + selected-station card. + 'bike.loadingSyncing': 'syncing {count} city feeds...', + 'bike.loadingScanning': 'scanning nearby systems...', + 'bike.card.capacity': '🚲 {bikes} avail · {docks} docks · {capacity} cap', + 'bike.warning.notInstalled': '⚠️ Not installed', + 'bike.warning.notRenting': '⚠️ Not renting', + 'bike.warning.notReturning': '⚠️ Not returning', + 'bike.stationFallback': 'Station', + 'bike.stationWithId': 'Station {id}', + + // src/data/militaryInstallations.js + installationFeedback.js. + 'installations.fallbackTitle': 'MAPPED INSTALLATION', + 'installations.loading': 'loading mapped installation context', + 'installations.feedback.reason.rateLimited': 'Overpass rate-limited', + 'installations.feedback.reason.timeout': 'Overpass timed out', + 'installations.feedback.reason.queryFailed': + 'Overpass could not complete the query', + 'installations.feedback.reason.unavailable': + 'Overpass temporarily unavailable', + 'installations.feedback.retrying': 'Retrying mapped sites…', + 'installations.feedback.fetching': 'Fetching mapped sites…', + 'installations.feedback.retryIn': '{reason} — retrying in {seconds}s', + 'installations.feedback.retryPending': '{reason} — retry pending', + 'installations.feedback.zoomIn': 'Zoom in to search mapped installations', + 'installations.feedback.cached': 'Showing cached mapped sites', + 'installations.feedback.notLoaded': 'Mapped sites not loaded', + 'installations.feedback.loaded': 'Mapped sites loaded', + + // src/data/militaryAwareness.js panel + militaryAwarenessEngine.js reasons. + 'awareness.standbyReady': 'CONTEXT READY', + 'awareness.standbyOff': 'GLOBAL CONTEXT OFF', + 'awareness.standbySelect': 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION', + 'awareness.standbyEnable': 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY', + 'awareness.controlsAria': 'Global Context navigation', + 'awareness.previous': 'PREVIOUS', + 'awareness.focus': 'FOCUS', + 'awareness.next': 'NEXT', + 'awareness.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'awareness.nextTitle': + 'Next — nearest unvisited contact in the 250 km window', + 'awareness.note': + 'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.', + 'awareness.unavailableAria': 'Unavailable', + 'awareness.focusAria': 'Focus {label}', + 'awareness.cohort.flights': 'Flights', + 'awareness.cohort.military': 'Military flights', + 'awareness.cohort.vessels': 'AIS vessels', + 'awareness.cohort.installations': 'Mapped installations', + 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', + 'awareness.hdg': 'HDG {value}°', + 'awareness.brg': 'BRG {value}°', + 'awareness.brgUnknown': 'BRG —', + 'awareness.crs': 'CRS {value}°', + 'awareness.reason.feedUnavailable': 'feed unavailable', + 'awareness.reason.feedStale': 'feed stale', + 'awareness.reason.nearby': 'observed or mapped nearby context', + 'awareness.reason.none': 'no observed or mapped objects in current feeds', + + // src/data/regionalBrief.js WMO weather-code labels (cockpit copy). + 'weather.conditionsUnknown': 'CONDITIONS UNKNOWN', + 'weather.clear': 'CLEAR', + 'weather.partlyCloudy': 'PARTLY CLOUDY', + 'weather.overcast': 'OVERCAST', + 'weather.fog': 'FOG', + 'weather.drizzle': 'DRIZZLE', + 'weather.rain': 'RAIN', + 'weather.snow': 'SNOW', + 'weather.rainShowers': 'RAIN SHOWERS', + 'weather.snowShowers': 'SNOW SHOWERS', + 'weather.thunderstorm': 'THUNDERSTORM', + 'weather.mixed': 'MIXED CONDITIONS', + + 'missions.roster.keyboardHint': 'TAB PREVIEWS · ENTER / SPACE SELECTS', + + 'vessel.awaitingPositions': 'awaiting usable AIS positions…', + 'vessel.awaitingFirstMessage': 'awaiting first AIS message…', + 'missions.estDownrange': 'EST. DOWNRANGE', +}; diff --git a/src/i18n/locales/en/setup.js b/src/i18n/locales/en/setup.js new file mode 100644 index 000000000..1c52248dc --- /dev/null +++ b/src/i18n/locales/en/setup.js @@ -0,0 +1,211 @@ +// English catalog — setup namespace (phase-1 seed). +// +// Owns first-launch onboarding, in-app key setup (Provider Settings), scene +// recipes, and voice-control chrome. Keys are namespace-relative; the registry +// prefixes them with `setup.`. +export const NAMESPACE = 'setup'; + +export default { + // index.html #first-run-launcher header/title/choices + 'firstRun.kicker': 'MISSION CONTROL · FIRST LAUNCH', + 'firstRun.title': 'Choose your first view', + 'firstRun.choice.contacts': 'LIVE CONTACTS', + 'firstRun.suppress': "Don't show this again", + // index.html #key-setup-chip / #key-setup dialog + 'keySetup.chip': 'POWER UP', + 'keySetup.kicker': 'GROUND STATION · PROVIDER SETTINGS', + 'keySetup.title': 'Power up the globe', + 'keySetup.apply': 'SAVE KEYS', + // src/keySetup.js submitUpdates() status announcement + 'keySetup.status.saving': 'Saving…', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // support-worker surfaces (first-run, key setup, scene director) whose + // runtime paths land here in phase 3. + + // index.html #first-run-launcher + // Owner-authored persuasive line, pinned verbatim by firstRunExperience.test.mjs + // (unspaced em dash included) — keep the exact string when translating. + 'firstRun.description': + 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', + 'firstRun.choice.contactsSub': 'Aircraft, vessels and nearby intelligence', + 'firstRun.choice.spaceMissions': 'SPACE MISSIONS', + 'firstRun.choice.spaceMissionsSub': + 'Launches, spacecraft and orbital context', + 'firstRun.choice.explore': 'EXPLORE MANUALLY', + 'firstRun.choice.exploreSub': 'Begin with a clean globe', + 'firstRun.dismissHint': 'ESC to dismiss', + // Initial status tip; firstRunExperience.js swaps it for progress/errors. + 'firstRun.note': + 'Tip: the GEV MIC button in the dock lets you talk to the map.', + // NOT seeded: the environmental tile's / are pinned verbatim + // by firstRunExperience.test.mjs (and the title is painted from + // ENVIRONMENTAL_LABEL_CHOICE at init) — the support worker wires them in + // phase 3 together with the test update. + + // index.html #key-setup + 'keySetup.closeAriaLabel': 'Close key setup', + 'keySetup.description': + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.", + 'keySetup.hint': 'ESC to close', + 'keySetup.note': + 'The Google Maps key buys the photorealistic planet — everything else stacks on top.', + + // index.html scene director panel chrome (src/scenes/director.js surface) + 'scenes.panelTitle': 'SCENES', + 'scenes.collapseTitle': 'Collapse panel', + 'scenes.recipeAriaLabel': 'Scene recipe', + 'scenes.new': 'NEW', + 'scenes.delete': 'DEL', + 'scenes.capture': 'CAPTURE SHOT', + 'scenes.updateShot': 'UPDATE SHOT', + 'scenes.start': 'START', + 'scenes.stop': 'STOP', + 'scenes.next': 'NEXT', + 'scenes.exportPresets': 'EXPORT PRESETS', + 'scenes.import': 'IMPORT', + 'scenes.runLog': 'RUN LOG', + 'scenes.statusReady': 'Ready', + + // ── Phase-3 runtime extraction (support surfaces), appended ─────────────── + + // src/firstRunExperience.js — mission busy lines and status/error copy. + // {detail} is the failed layer-id list (machine values, kept raw). + 'firstRun.busy.contacts': 'Starting live contacts…', + 'firstRun.busy.spaceMissions': 'Opening space missions…', + 'firstRun.busy.environmental': 'Scanning active events…', + 'firstRun.busy.working': 'Working…', + 'firstRun.status.failed': + 'Could not open that mission{detail}. Retry or explore manually.', + 'firstRun.status.storageBlocked': + 'This browser is blocking storage, so that could not be saved.', + // The environmental tile is painted at init from ENVIRONMENTAL_LABEL_CHOICE; + // the subcopy names BOTH feeds (pinned verbatim by firstRunExperience.test.mjs). + 'firstRun.choice.environmentalSub': + 'Live earthquakes and active fires, from USGS and NASA', + 'firstRun.environmentalTitle.environmental': 'ENVIRONMENTAL', + 'firstRun.environmentalTitle.earthWatch': 'EARTH WATCH', + 'firstRun.environmentalTitle.activeEvents': 'ACTIVE EVENTS', + + // src/keySetup.js — chip counter, status line, and remove confirm. + 'keySetup.chipWaiting': { + one: 'POWER UP · {count} KEY WAITING', + other: 'POWER UP · {count} KEYS WAITING', + }, + 'keySetup.chipReady': 'POWERED UP', + 'keySetup.status.saveFailed': 'Save failed ({status}).', + 'keySetup.status.saveFailedDetail': 'Save failed: {detail}', + 'keySetup.status.pasteFirst': 'Paste at least one key first.', + 'keySetup.status.saved': + 'Saved to {store}. Restarting — this page reloads itself.', + 'keySetup.status.removed': + 'Removed from {store}. Restarting — this page reloads itself.', + 'keySetup.store.pinokio': 'your app configuration', + 'keySetup.store.env': 'your local .env', + 'keySetup.confirm.remove': 'Remove this key from your saved configuration?', + + // src/mapStackChips.js — unavailable-chip tooltip and aria templates. + 'mapStack.fallbackName': 'This map stack', + 'mapStack.unavailableReason': '{label} is unavailable', + 'mapStack.unavailableAriaLabel': '{label} unavailable: {hint}', + + // src/scenes/director.js — built-in recipe DISPLAY names only. Recipe ids + // and the URL 'scene' param stay English; stored/renamed project titles are + // user data and render verbatim. + 'scenes.recipe.flightsRadar': 'Global Flights Radar', + 'scenes.recipe.orbitalWatch': 'Orbital Watch', + 'scenes.recipe.thermalThreats': 'Thermal Threat Board', + 'scenes.recipe.cityOverload': 'City Overload', + 'scenes.recipe.omnisciencePullback': 'Omniscience Pullback', + + // src/voice/gevRealtime.js — mic chrome and connection/execution STATUS + // text. Status enum keys (idle/connecting/…) are machine values; tool + // names/schemas and model-facing results stay English (keep-English + // boundary). Tier badges STD/MINI and the MIC/ON-OFF mic label stay machine + // identifiers this phase. + 'voice.status.idle': 'OFF', + 'voice.status.connecting': 'CONNECTING', + 'voice.status.listening': 'LISTENING', + 'voice.status.executing': 'EXECUTING', + 'voice.status.error': 'ERROR', + 'voice.status.sessionCostCap': 'Session ended — cost cap {cost}', + 'voice.detail.standby': 'VOICE STANDBY', + 'voice.detail.active': 'VOICE ACTIVE', + 'voice.detail.unavailable': 'VOICE UNAVAILABLE', + 'voice.detail.microphoneUnavailable': 'WebRTC microphone support unavailable', + 'voice.detail.requestingMicrophone': 'Requesting microphone', + 'voice.detail.holdSpaceTalk': 'Hold Space to talk', + 'voice.detail.releaseSpaceSend': 'Release Space to send', + 'voice.detail.askOrCommand': 'Ask or command', + 'voice.detail.voiceOff': 'Voice off', + 'voice.detail.runningCommand': 'Running command', + 'voice.detail.radioDidNotStart': 'Radio did not start', + 'voice.hint.default': + 'Hold Space to speak · tap Space to activate focused controls', + 'voice.error.sessionStart': 'Voice session could not be started.', + 'voice.error.trayTitle': 'VOICE SYSTEM ERROR', + 'voice.error.dismiss': 'DISMISS', + 'voice.error.hint': + 'Check microphone permission and network access, then try again.', + 'voice.kicker.agent': 'AI AGENT', + 'voice.kicker.control': 'VOICE CONTROL', + 'voice.tier.appliesNextSession': '{tier} applies next session', + 'voice.tier.buttonTitle': 'Voice model tier — applies next session', + 'voice.cost.buttonTitle': 'Estimated session cost', + 'voice.button.ariaLabel': + 'Voice control — activate to toggle voice; hold Space to speak', + + // ── Stage-4 repair pass: src/scenes/director.js status corpus, appended ─── + // Status/confirm lines and the default shot title. {scene}/{shot} carry + // stored project titles (user data, rendered verbatim); {mode} is the raw + // context-mode identifier (machine value, keep-English boundary). + 'scenes.status.captureCameraNotReady': + 'Cannot capture shot: camera not ready', + 'scenes.status.shotTitleDefault': 'Shot {n}', + 'scenes.status.captured': 'Captured: {scene} / {shot}', + 'scenes.status.selectShotFirst': 'Select a shot first', + 'scenes.status.updated': 'Updated: {scene} / {shot}', + 'scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?', + 'scenes.status.loaded': 'Loaded: {scene} / {shot}', + 'scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first', + 'scenes.status.noShotsToRun': 'No shots to run', + 'scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}', + 'scenes.status.runComplete': 'Scene run complete', + 'scenes.status.runError': 'Error: {message}', + 'scenes.status.contextExitFailed': + 'Could not exit {mode} — scene layers may be refused', + + // ── Stage-4 repair pass: keySetup dev-surface copy, appended ────────────── + // src/keySetup.js buildRow() remove chrome (dev-server-only surface). + 'keySetup.row.remove': 'REMOVE', + 'keySetup.row.removeTitle': "Remove {title} from this app's saved keys", + // src/keySetupCore.mjs keySetupRequirement(): availability sentence naming + // the registry env vars (machine values stay raw). + 'keySetup.requirement': 'Needs {envVars} — add it in Provider Settings', + // src/keySetupCore.mjs KEY_SETUP_KEYS `unlocks` copy, keyed by registry id; + // en values must stay byte-identical to the registry strings. + 'keySetup.unlocks.google-maps': 'The photorealistic 3D planet + place search', + 'keySetup.unlocks.google-maps-server': + 'Places context + Street View fallback; optional separate key', + 'keySetup.unlocks.openai': 'Voice control — talk to the planet', + 'keySetup.unlocks.aisstream': 'Live ships, worldwide', + 'keySetup.unlocks.firms': 'Live active-fire detections', + 'keySetup.unlocks.tomtom': 'Real live traffic (keyless runs a simulation)', + 'keySetup.unlocks.cesium-ion': 'Bing imagery map stacks + world terrain', + 'keySetup.unlocks.opensky': + 'More flight-polling credits (anonymous works without)', + 'keySetup.unlocks.launch-library': 'Higher space-missions request allowance', + + 'scenes.status.actionFailed': 'Scene action failed', + + 'scenes.status.projectExported': 'Project exported', + + 'voice.tierNextSession': + 'Next session: {pendingId} — this session stays on {modelId}', + 'voice.tierSwitchHint': + 'Voice model: {pendingId} — click to switch to {target}; applies next session', + 'voice.costTooltip': + 'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.', +}; diff --git a/src/i18n/locales/en/shell.js b/src/i18n/locales/en/shell.js new file mode 100644 index 000000000..838fa0bd5 --- /dev/null +++ b/src/i18n/locales/en/shell.js @@ -0,0 +1,82 @@ +// English catalog — shell namespace (phase-1 seed). +// +// Owns the global application chrome: title bar, loader status, top-center +// actions, panel titles, and the shared global status surface. Keys are +// namespace-relative; the registry in src/i18n/index.js prefixes them with +// `shell.`. Values are plain strings or { one, other } plural variant objects +// with named {placeholder} interpolation. +export const NAMESPACE = 'shell'; + +export default { + // index.html #title-bar .subtitle + 'title.subtitle': 'NO PLACE LEFT BEHIND', + // index.html .loader-status (initial paint) + 'loading.initialStatus': 'Initializing photorealistic world...', + // src/main.js init() loaderStatus writes + 'loading.status.configuring': 'Configuring viewer...', + 'loading.status.tilesUnavailable': + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'loading.status.flying': 'Flying to Austin, TX...', + 'loading.status.restoring': 'Restoring shared view...', + // index.html #global-loading-label (initial) and + // src/loadingFeedback.js presentLoadingFeedback() labels + 'status.loadingLiveData': 'LOADING LIVE DATA', + 'status.loadComplete': 'LOAD COMPLETE', + 'status.loadFailed': 'LOAD FAILED', + 'status.loadCancelled': 'LOAD CANCELLED', + // index.html #traffic-sync-label (initial) and + // src/loadingFeedback.js reduceTrafficSyncFeedback() neutral fallback + 'status.trafficSyncing': 'syncing road network', + 'status.retryingIn': 'retrying in {seconds}s', + 'status.retryPending': 'retry pending', + // index.html #top-center-actions buttons + 'actions.clearLayers.ariaLabel': 'Clear selected data layers', + 'actions.clearLayers.title': 'Turn off all selected data layers', + 'actions.share.ariaLabel': 'Copy share link', + 'actions.resetView.ariaLabel': 'Reset to full globe view', + // index.html #data-toggles panel header + 'panels.dataLayers': 'DATA LAYERS', + // index.html .panel-collapse-btn on #data-panel + 'panels.collapseTitle': 'Collapse panel', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // index.html #command-dock nav region + 'dock.ariaLabel': 'Navigation, voice, and visual preset controls', + // index.html #top-center-actions nav + 'actions.navAriaLabel': 'Globe actions', + 'actions.share.title': 'Copy share link', + 'actions.resetView.title': 'Reset camera and return to full globe view', + // index.html #cctv-sync-label (initial); src/ui.js setSplitFlapText() fallback + 'status.framesLoading': 'loading frames', + + // Locale selector (index.html #control-panel tray): the group container is + // static; its buttons are runtime-rendered by ui.js _initLocaleSelector() + // from the configured locale pair (keys at the end of this file). + 'locale.groupAriaLabel': 'Language', + + // ── Phase-3 runtime extraction (support worker), appended ───────────────── + // src/main.js init() loaderStatus write sites without a phase-1/2 key. + 'loading.status.tilesGoogle': 'Loading Google 3D Tiles...', + 'loading.status.tilesKeyless': 'Loading the keyless globe...', + 'loading.status.systems': 'Initializing systems...', + // src/loadingFeedback.js presentLoadingFeedback() labels beyond the phase-1 + // seeds. 'OpenStreetMap · Overpass' detail strings are provider names and + // stay verbatim at the call site. + 'status.liveDataOff': 'LIVE DATA OFF', + 'status.mappedSitesLoaded': 'MAPPED SITES LOADED', + 'status.retryingMappedSites': 'RETRYING MAPPED SITES', + 'status.fetchingMappedSites': 'FETCHING MAPPED SITES', + 'status.turningOffLiveData': 'TURNING OFF LIVE DATA', + 'status.refreshingLiveData': 'REFRESHING LIVE DATA', + + // ── Configurable locale pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE, + // vite.config.js client defines), appended ──────────────────────────── + // ui.js _initLocaleSelector() renders one runtime button per offered + // locale and reads shell.locale..ariaLabel for its aria-label. + // Only SHIPPED locales get a key: each locale PR appends its label to every + // shipped catalog (docs/TRANSLATORS.md), so an unshipped locale can never + // render a selector button. + 'locale.en.ariaLabel': 'Switch to English', + + 'credits.closeAria': 'Close data attribution', +}; diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..d4018c0f6 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -11,6 +11,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop plugins: [plugin], googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', + defaultLocale: 'fr', + secondaryLocale: 'en', }); assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); @@ -30,6 +32,9 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.define, { 'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"', 'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"', + // Locale pair (i18n): threaded like the other explicit browser inputs. + 'import.meta.env.GEV_DEFAULT_LOCALE': '"fr"', + 'import.meta.env.GEV_SECONDARY_LOCALE': '"en"', }); assert.equal( createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server From 03929f1a009e1a5a590dd790bcb2e38c4725adae Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 1 Oct 2026 12:34:07 +0200 Subject: [PATCH 026/166] docs(cctv): refresh the CCTV Camera Database size in the model-specs module The dataset has grown from 12,000+ to 28,000+ camera models (v2.26.0, 75% with a field of view). The 17 vendored records are unchanged in v2.26.0. --- src/layers/cctv/modelSpecs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index a9d12e23d..cee3e5044 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,8 +14,8 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module vendors the records that the currently integrated public + * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that * appear in Sarasota County FL and Sioux Falls SD open data; packs that From 284c4d46019c022a4811381c6fbc567f8cf5b229 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:36 +0200 Subject: [PATCH 027/166] =?UTF-8?q?feat(i18n):=20presentation=20extraction?= =?UTF-8?q?=20=E2=80=94=20t()=20call=20sites,=20data-i18n=20templates,=20l?= =?UTF-8?q?ocale=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every application-owned presentation string through the i18n core: t() call sites across the app/layers/ui/data/voice surfaces, data-i18n* attributes in the src/ui/templates/*.html markup (applied at boot by applyDocumentTranslations from src/standalone/application.js), and the dock locale selector (runtime-rendered from the offered pair — a single EN button while only English ships). Rebased onto current main, where the extraction follows the module splits: the shell wiring now lands in the owner modules (panelChrome, visualSettings, locationNavigation, shareRestoration, shellFeedback, applicationShell) and the voice status corpus in the realtime* modules; scene status lives in director.js + playbackAdapter.js. Modules shared with the node runtime (loadingFeedback, installationFeedback, regionalModel, keySetupCore, vessels ingestion) stay environment-neutral English and localize at their browser render sites (chip maps in shellFeedback/installations controls, the weather table in cockpitBriefing, keySetupRequirementEnvVars compositions), so the package-boundary graph stays clean; the browser boundary groups now own the i18n modules they reach (scripts/package-boundaries.json). --- scripts/package-boundaries.json | 2024 +++++++++++++++-------- src/app/controls.js | 5 +- src/app/scene.js | 13 +- src/creditKeyboard.js | 4 +- src/data/militaryAwareness.test.mjs | 2 +- src/data/militaryAwarenessEngine.js | 10 +- src/data/satelliteClass.js | 35 +- src/data/vesselLabels.js | 39 + src/firstRunExperience.js | 44 +- src/hud.js | 9 +- src/hudA11y.test.mjs | 4 +- src/i18n/locales/en/layers.js | 19 +- src/i18n/markupCoverage.test.mjs | 128 ++ src/i18n/repairPass.test.mjs | 83 + src/keySetup.js | 57 +- src/keySetupCore.mjs | 13 + src/layers/awareness/panel.js | 22 +- src/layers/awareness/rendering.js | 15 +- src/layers/awareness/subject.js | 27 +- src/layers/bikeshare/controls.js | 7 +- src/layers/bikeshare/selection.js | 16 +- src/layers/cctv/headingConfidence.js | 8 +- src/layers/cctv/presentation.js | 43 +- src/layers/firms/cards.js | 6 +- src/layers/firms/model.js | 40 +- src/layers/firms/queries.js | 20 +- src/layers/flights/tracking.js | 3 +- src/layers/installations/controls.js | 61 +- src/layers/installations/rendering.js | 3 +- src/layers/launches/overlays.js | 36 +- src/layers/launches/panel.js | 65 +- src/layers/launches/paths.js | 3 +- src/layers/launches/policyHelpers.js | 12 +- src/layers/launches/replay.js | 27 +- src/layers/military/queries.js | 3 +- src/layers/military/tracking.js | 11 +- src/layers/radio/categories.js | 25 +- src/layers/satellites/controls.js | 20 +- src/layers/submarineCables/ingestion.js | 3 +- src/layers/traffic/model.js | 39 +- src/layers/vessels/cards.js | 25 +- src/layers/vessels/lifecycle.js | 13 +- src/layers/vessels/queries.js | 28 +- src/mapSourceFocus.test.mjs | 5 +- src/mapStackChips.js | 18 +- src/maps/availability.js | 9 +- src/maps/defaultSources.js | 7 +- src/scenes/director.js | 78 +- src/scenes/playbackAdapter.js | 11 +- src/standalone/application.js | 10 + src/ui/applicationShell.js | 128 +- src/ui/cctvBindings.js | 4 +- src/ui/cctvCalibration.js | 8 +- src/ui/cctvFrames.js | 8 +- src/ui/cctvPresentation.js | 72 +- src/ui/clearLayersControl.js | 6 +- src/ui/cockpitBriefing.js | 85 +- src/ui/cockpitContext.js | 47 +- src/ui/cockpitInstruments.js | 63 +- src/ui/cockpitLayout.js | 28 +- src/ui/cockpitPresentation.js | 28 +- src/ui/cockpitSignals.js | 26 +- src/ui/cockpitTrackingController.js | 17 +- src/ui/contextActions.js | 19 +- src/ui/contextBindings.js | 11 +- src/ui/contextLayerChanges.js | 33 +- src/ui/contextSubscriptions.js | 8 +- src/ui/layerPanel.js | 153 +- src/ui/locationControls.js | 35 +- src/ui/locationNavigation.js | 17 +- src/ui/mapSourceControls.js | 5 +- src/ui/panelChrome.js | 39 +- src/ui/panelPositionControls.js | 5 +- src/ui/radioBindings.js | 56 +- src/ui/radioControls.js | 12 +- src/ui/radioPresentation.js | 193 ++- src/ui/sceneControls.js | 12 +- src/ui/shareRestoration.js | 17 +- src/ui/shellElements.js | 1 + src/ui/shellFeedback.js | 63 +- src/ui/templates/cockpit.html | 154 +- src/ui/templates/command-dock.html | 57 +- src/ui/templates/context.html | 103 +- src/ui/templates/display-controls.html | 82 +- src/ui/templates/hud-loading.html | 2 +- src/ui/templates/layer-panels.html | 86 +- src/ui/templates/provider-settings.html | 16 +- src/ui/templates/scene-chrome.html | 20 +- src/ui/templates/welcome.html | 16 +- src/ui/visualSettings.js | 27 +- src/voice/control.js | 24 +- src/voice/realtimeConnection.js | 12 +- src/voice/realtimeController.js | 36 +- src/voice/realtimeCost.js | 31 +- src/voice/realtimeInput.js | 8 +- src/voice/realtimeInputPolicy.js | 6 +- src/voice/realtimeRadio.js | 3 +- src/voice/realtimeTurns.js | 9 +- 98 files changed, 3474 insertions(+), 1625 deletions(-) create mode 100644 src/i18n/markupCoverage.test.mjs create mode 100644 src/i18n/repairPass.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..dfb42419f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,27 +12,50 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js", + "src/app/atmosphereCompat.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite", + "./build/html" + ], + "modules": [ + "build/vite.js", + "build/application-html.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -48,16 +71,24 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -75,13 +106,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -93,13 +130,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -107,35 +150,59 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-csv": { - "exports": ["./sources/firms-csv"], - "modules": ["src/data/firmsCsv.js"], + "exports": [ + "./sources/firms-csv" + ], + "modules": [ + "src/data/firmsCsv.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -145,13 +212,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -176,7 +249,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -192,7 +267,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -215,7 +292,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -234,7 +313,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -261,7 +342,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -289,7 +372,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -300,7 +385,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -321,17 +408,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -344,7 +441,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -353,12 +452,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -371,76 +477,130 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ - "src/ui/mapSource.js", - "src/ui/mapSourceControls.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", "src/mapStackChips.js", - "src/keySetupCore.mjs" + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js" ], "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ - "src/ui/layers.js", - "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", - "src/ui/clearLayersControl.js", - "src/loadingFeedback.js", - "src/data/installationFeedback.js", - "src/keySetupCore.mjs", "src/data/feedState.js", - "src/data/layerSnapshot.js" + "src/data/installationFeedback.js", + "src/data/layerSnapshot.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", + "src/loadingFeedback.js", + "src/ui/chipGroup.js", + "src/ui/clearLayersControl.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", + "src/ui/rowList.js", + "src/ui/weatherPanel.js" ], "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ + "src/app/stateChannel.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/locationStatus.js", "src/ui/location.js", "src/ui/locationControls.js", - "src/ui/locationSearch.js", - "src/locationStatus.js", - "src/app/stateChannel.js" + "src/ui/locationSearch.js" ], "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/radio.js", - "src/ui/radioControls.js", "src/ui/radioBindings.js", + "src/ui/radioControls.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js" ], "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", - "src/ui/cctvPresentation.js" + "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js" ], "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ + "src/contextModePolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/rightRailPolicy.js", "src/ui/context.js", "src/ui/contextActions.js", "src/ui/contextBindings.js", @@ -450,8 +610,6 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", - "src/contextModePolicy.js", - "src/rightRailPolicy.js", "src/ui/panelRailGeometry.js" ], "external": [] @@ -464,42 +622,56 @@ "./ui/cockpit/vision" ], "modules": [ + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", "src/ui/cockpitTrackingController.js", - "src/cockpitMath.js", - "src/cockpitUtilityLayout.js", - "src/cockpitVisionPolicy.js", + "src/ui/displayPanelScroll.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ - "src/ui/scenes.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/sceneControls.js", - "src/ui/scenePresentation.js" + "src/ui/scenePresentation.js", + "src/ui/scenes.js" ], "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/cyberSonar.js", - "src/ui/radioPanelPlacement.js", "src/app/stateChannel.js", "src/bloom.js", "src/cctvFocusPolicy.js", @@ -510,13 +682,27 @@ "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", + "src/cyberSonar.js", + "src/data/cameraArrival.js", "src/data/detectionPolicy.js", + "src/data/feedState.js", "src/data/installationFeedback.js", + "src/data/layerSnapshot.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", "src/data/scenePick.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keySetupCore.mjs", + "src/layers/localAdsb/feedNames.js", + "src/layers/weather/imageryHost.js", "src/loadingFeedback.js", "src/locationStatus.js", "src/mapStackChips.js", @@ -530,27 +716,26 @@ "src/styles/snow.js", "src/styles/surveillance.js", "src/styles/thermal.js", + "src/ui/aircraftDisplay.js", "src/ui/applicationShell.js", - "src/ui/cyberSonarControls.js", - "src/ui/locationNavigation.js", - "src/ui/cockpitCoordinator.js", - "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", + "src/ui/cameraOrientationControls.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js", + "src/ui/chipGroup.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", + "src/ui/cockpitCoordinator.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -566,20 +751,21 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", + "src/ui/cyberSonarControls.js", + "src/ui/displayBindings.js", "src/ui/displayControls.js", + "src/ui/displayPanelScroll.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/layerBindings.js", "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/ui/location.js", "src/ui/locationControls.js", + "src/ui/locationNavigation.js", "src/ui/locationSearch.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -594,15 +780,21 @@ "src/ui/radio.js", "src/ui/radioBindings.js", "src/ui/radioControls.js", + "src/ui/radioPanelPlacement.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rowList.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", + "src/ui/shellFacade.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", "src/ui/surfaceKeyboard.js", @@ -611,23 +803,17 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", - "src/worldFocus.js", - "src/data/feedState.js", - "src/data/layerSnapshot.js", - "src/ui/aircraftDisplay.js", - "src/layers/weather/imageryHost.js", - "src/ui/layerBindings.js", - "src/ui/displayBindings.js", - "src/ui/shellFacade.js", - "src/ui/localSdrControls.js", - "src/layers/localAdsb/feedNames.js", - "src/ui/localSdrPresentation.js", - "src/data/cameraArrival.js" + "src/ui/weatherPanel.js", + "src/worldFocus.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +843,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,59 +868,71 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ - "src/scenes/nepalEvidencePack.js", + "src/app/stateChannel.js", + "src/bloom.js", + "src/contextModePolicy.js", + "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiShotPaths.js", - "src/data/bhoteKoshiFloodPath.js", - "src/maps/catalog.js", - "src/scenes/director.js", - "src/scenes/playbackAdapter.js", - "src/director/playback.js", - "src/scenes/recipes.js", - "src/scenes/scenePolicy.js", - "src/bloom.js", - "src/app/stateChannel.js", - "src/ui/scenes.js", - "src/ui/sceneControls.js", - "src/ui/scenePresentation.js", - "src/contextModePolicy.js", - "src/director/clock.js", - "src/director/timeline.js", - "src/scenes/shotPresentation.js", - "src/scenes/packs/registry.js", - "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js", - "src/director/document.js", - "src/scenes/project.js", - "src/director/documentFields.js", - "src/director/cameraDocument.js", + "src/data/cameraArrival.js", + "src/data/inputOwnership.js", + "src/director/authoring.js", "src/director/camera.js", - "src/scenes/cameraMotion.js", - "src/director/packs/source.js", - "src/director/packs/session.js", - "src/director/packs/geojson.js", - "src/director/packs/manifest.js", - "src/scenes/dataPacks/controller.js", - "src/scenes/dataPacks/presentation.js", - "src/scenes/dataPacks/geometry.js", + "src/director/cameraDocument.js", + "src/director/clock.js", + "src/director/document.js", + "src/director/documentFields.js", "src/director/interactions/document.js", "src/director/interactions/session.js", - "src/scenes/interactions.js", - "src/data/inputOwnership.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/director/packs/session.js", + "src/director/packs/source.js", + "src/director/playback.js", "src/director/sharing/bundle.js", - "src/director/sharing/preview.js", "src/director/sharing/lifetime.js", - "src/director/authoring.js", + "src/director/sharing/preview.js", + "src/director/timeline.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/maps/catalog.js", + "src/scenes/cameraMotion.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/geometry.js", + "src/scenes/dataPacks/presentation.js", + "src/scenes/director.js", + "src/scenes/interactions.js", + "src/scenes/nepalEvidencePack.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/scenes/packs/registry.js", + "src/scenes/playbackAdapter.js", + "src/scenes/project.js", + "src/scenes/recipes.js", + "src/scenes/scenePolicy.js", "src/scenes/sharing.js", + "src/scenes/shotPresentation.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", "src/ui/sceneSharing.js", - "src/data/cameraArrival.js" + "src/ui/scenes.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -743,7 +943,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -754,38 +956,48 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/flights/controller.js", "src/layers/flights/enrichment.js", "src/layers/flights/evidence.js", "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", - "src/layers/flights/tracking.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/layers/flights/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -796,13 +1008,21 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -810,20 +1030,22 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", - "src/layers/military/tracking.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/layers/military/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,11 +1067,24 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ + "src/data/iconOrientation.js", + "src/data/inputOwnership.js", + "src/data/vesselLabels.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -857,32 +1092,37 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", - "src/layers/vessels/tracking.js", - "src/data/iconOrientation.js", - "src/data/vesselLabels.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/layers/vessels/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "recent-imagery": { - "exports": ["./layers/recent-imagery"], + "exports": [ + "./layers/recent-imagery" + ], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -891,14 +1131,25 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", "src/layers/firms/index.js", @@ -911,17 +1162,27 @@ "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", - "src/layers/firms/viewport.js", - "src/data/inputOwnership.js" + "src/layers/firms/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ + "src/data/inputOwnership.js", "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -930,21 +1191,32 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", "src/layers/satellites/testing.js", - "src/layers/satellites/tracking.js", - "src/data/inputOwnership.js" + "src/layers/satellites/tracking.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/launches/camera.js", "src/layers/launches/controls.js", "src/layers/launches/index.js", @@ -963,53 +1235,74 @@ "src/layers/launches/selection.js", "src/layers/launches/source.js", "src/layers/launches/state.js", - "src/layers/launches/testing.js", - "src/data/inputOwnership.js" + "src/layers/launches/testing.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ + "src/data/bundledJson.js", "src/data/cameraSensitivity.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", + "src/data/local_data/osm_military_names/names.json", "src/data/militaryInstallationData.js", + "src/data/militaryNames.js", + "src/data/retryableLoad.js", + "src/data/tomtomTiles.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/viewport.js", - "src/data/inputOwnership.js", - "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", - "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", + "src/sources/openFreeMap.js", "src/sources/phaseTiming.js", - "src/data/militaryNames.js", "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/data/bundledJson.js", - "src/data/retryableLoad.js", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/sources/vectorTiles.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ - "src/cyberSonar.js", "src/cockpitMath.js", + "src/cyberSonar.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1028,10 +1321,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1040,6 +1337,12 @@ "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/traffic/animation.js", "src/layers/traffic/controls.js", "src/layers/traffic/flow.js", @@ -1048,32 +1351,46 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", - "src/sources/overpassRoads.js", "src/sources/capability.js", "src/sources/httpBody.js", - "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", + "src/sources/openFreeMap.js", + "src/sources/overpassRoads.js", "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js" + "src/sources/requestDeadline.js", + "src/sources/vectorTiles.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ + "src/data/cameraSensitivity.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", "src/layers/bikeshare/ingestion.js", @@ -1087,21 +1404,31 @@ "src/layers/bikeshare/source.js", "src/layers/bikeshare/state.js", "src/layers/bikeshare/testing.js", - "src/layers/bikeshare/viewport.js", - "src/data/inputOwnership.js", - "src/data/cameraSensitivity.js" + "src/layers/bikeshare/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", + "src/data/cctvFootprint.js", "src/data/cctvGizmo.js", "src/data/cctvLod.js", "src/data/cctvViewshed.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/cctv/calibration.js", "src/layers/cctv/cards.js", "src/layers/cctv/catalog.js", @@ -1121,21 +1448,24 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", - "src/overlays/worldOverlayTokens.js", - "src/data/cctvFootprint.js", - "src/data/inputOwnership.js", - "src/layers/cctv/sourcePolicy.js" + "src/layers/cctv/videoPlayback.js", + "src/overlays/worldOverlayTokens.js" ], - "external": ["hls.js", "cesium"] + "external": [ + "hls.js", + "cesium" + ] }, "alpr-cameras": { - "exports": ["./layers/alpr"], + "exports": [ + "./layers/alpr" + ], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1154,15 +1484,28 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -1185,14 +1528,24 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", - "src/ui/radioTunerModel.js", - "src/data/inputOwnership.js" + "src/ui/radioTunerModel.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/ingestion.js", @@ -1202,13 +1555,16 @@ "src/layers/submarineCables/policy.js", "src/layers/submarineCables/rendering.js", "src/layers/submarineCables/state.js", - "src/layers/submarineCables/surface.js", - "src/data/inputOwnership.js" + "src/layers/submarineCables/surface.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1217,7 +1573,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1239,21 +1598,29 @@ "./maps/defaults" ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", - "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/maps/registry.js", "src/maps/terrain.js", - "src/maps/terrainRetry.js", - "src/keySetupCore.mjs", - "src/maps/imageryHost.js" + "src/maps/terrainRetry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -1265,27 +1632,33 @@ "./voice/realtime-session" ], "modules": [ - "src/voice/realtimeController.js", - "src/voice/realtimeBackend.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/voice/commands.js", "src/voice/control.js", "src/voice/layerSummary.js", - "src/voice/voiceCost.js", - "src/voice/commands.js", - "src/voice/session.js", - "src/voice/sessionCommands.js", - "src/voice/realtimeSession.js", + "src/voice/realtimeBackend.js", + "src/voice/realtimeConnection.js", + "src/voice/realtimeController.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeDiagnostics.js", "src/voice/realtimeEvents.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeInputPolicy.js", "src/voice/realtimePreferences.js", "src/voice/realtimeProtocol.js", - "src/voice/realtimeDiagnostics.js", - "src/voice/realtimeViewport.js", - "src/voice/realtimeInputPolicy.js", - "src/voice/realtimeInput.js", - "src/voice/realtimeCost.js", - "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", + "src/voice/realtimeSession.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js" + "src/voice/realtimeViewport.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/voiceCost.js" ], "external": [] }, @@ -1309,27 +1682,16 @@ "./standalone/catalog" ], "modules": [ - "src/cyberSonarScene.js", - "src/cyberSonar.js", - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/ui/radioPanelPlacement.js", - "src/cyberSonarGpu.js", - "src/scenes/nepalEvidencePack.js", - "src/data/bhoteKoshiEvent.js", - "src/ui/imagerySplit.js", - "src/data/bhoteKoshiEmbeddedMedia.js", - "src/data/bhoteKoshiLocator.js", - "src/data/bhoteKoshiIncidentPlaces.js", - "src/data/bhoteKoshiFloodPath.js", - "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", "src/annotations/annotationResolver.js", + "src/annotations/drawMode.js", + "src/annotations/drawTool.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", "src/annotations/resolver.js", "src/annotations/screenAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js", + "src/app/atmosphereCompat.js", "src/app/catalog.js", "src/app/constructCatalog.js", "src/app/controls.js", @@ -1340,28 +1702,32 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/directions.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", + "src/app/layers/localAdsb.js", "src/app/layers/militaryAwareness.js", "src/app/layers/militaryFlights.js", "src/app/layers/militaryInstallations.js", "src/app/layers/overlayHost.js", + "src/app/layers/perimeters.js", "src/app/layers/radio.js", + "src/app/layers/recentImagery.js", "src/app/layers/rocketLaunches.js", "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", + "src/app/layers/transit.js", + "src/app/localGeojsonServices.js", "src/app/operations.js", "src/app/scene.js", - "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", "src/app/stateChannel.js", "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", - "src/app/atmosphereCompat.js", "src/bloom.js", "src/camera.js", "src/cameraGroundGuard.js", @@ -1377,6 +1743,10 @@ "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", + "src/cyberSonar.js", + "src/cyberSonarGpu.js", + "src/cyberSonarScene.js", + "src/data/adminBoundaries.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -1385,7 +1755,16 @@ "src/data/aisLiveVessels.js", "src/data/alprCameras.js", "src/data/analystEngine.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiShotPaths.js", "src/data/bikeshare.js", + "src/data/bundledJson.js", + "src/data/cameraArrival.js", + "src/data/cameraSensitivity.js", "src/data/cctv.js", "src/data/cctvCards.js", "src/data/cctvFootprint.js", @@ -1395,28 +1774,19 @@ "src/data/cockpitAirLod.js", "src/data/cockpitContactDot.js", "src/data/contactMatch.js", + "src/data/contactPlayback.js", + "src/data/contactTrailRenderer.js", "src/data/contextStore.js", "src/data/dataCredits.js", - "src/data/wind.js", - "src/layers/wind/index.js", - "src/layers/wind/model.js", - "src/layers/wind/fields.js", - "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", - "src/layers/wind/inspection.js", - "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", - "src/layers/wind/source.js", - "src/sources/windBody.js", "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/detectionPresentation.js", "src/data/detectionRenderDemand.js", + "src/data/directions.js", "src/data/earthquakes.js", + "src/data/feedState.js", "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", @@ -1427,11 +1797,15 @@ "src/data/geoid.js", "src/data/groundFloor.js", "src/data/groundSnap.js", + "src/data/gtfsRealtime.js", "src/data/iconOrientation.js", "src/data/infrastructure.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", + "src/data/layerSnapshot.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", @@ -1439,9 +1813,13 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/localLayers.js", + "src/data/local_data/natural_earth/countries.json", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/states_provinces.json", "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/osm_military_names/names.json", + "src/data/local_data/us_census_counties/counties.json", "src/data/manager.js", "src/data/meshFloorSampler.js", "src/data/militaryAwareness.js", @@ -1449,14 +1827,12 @@ "src/data/militaryFlights.js", "src/data/militaryInstallationData.js", "src/data/militaryInstallations.js", + "src/data/militaryNames.js", "src/data/militaryRegistry.js", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/naturalEarthRegions.js", - "src/data/adminBoundaries.js", - "src/data/local_data/natural_earth/states_provinces.json", - "src/data/local_data/natural_earth/countries.json", - "src/data/local_data/us_census_counties/counties.json", "src/data/neighborhoodPolygons.js", "src/data/pickRegistry.js", "src/data/radio.js", @@ -1464,10 +1840,10 @@ "src/data/regionalBrief.js", "src/data/regionalModel.js", "src/data/renderAltitude.js", - "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/rocketLaunches.js", "src/data/routePlausible.js", + "src/data/routeSteps.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", "src/data/satellites.js", @@ -1488,22 +1864,56 @@ "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", "src/data/trailRenderer.js", + "src/data/transit.js", + "src/data/transitFeeds.js", + "src/data/transitIcons.js", + "src/data/transitPresetStyle.js", + "src/data/transitProxy.js", "src/data/vesselLabels.js", + "src/data/wind.js", + "src/director/authoring.js", + "src/director/camera.js", + "src/director/cameraDocument.js", + "src/director/clock.js", + "src/director/document.js", + "src/director/documentFields.js", + "src/director/interactions/document.js", + "src/director/interactions/session.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/director/packs/session.js", + "src/director/packs/source.js", + "src/director/playback.js", + "src/director/sharing/bundle.js", + "src/director/sharing/lifetime.js", + "src/director/sharing/preview.js", + "src/director/timeline.js", "src/firstRunExperience.js", "src/hud.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", "src/hudLocality.js", "src/hudSummaryResponse.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keySetup.js", "src/keySetupCore.mjs", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", + "src/layers/alpr/floors.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", - "src/layers/alpr/visuals.js", - "src/layers/alpr/overlay.js", + "src/layers/alpr/records.js", "src/layers/alpr/source.js", + "src/layers/alpr/tileRecords.js", + "src/layers/alpr/visuals.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1553,14 +1963,21 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", + "src/layers/cctv/videoPlayback.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/source.js", + "src/layers/directions/index.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", + "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", @@ -1581,10 +1998,14 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -1593,6 +2014,7 @@ "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", @@ -1618,6 +2040,15 @@ "src/layers/launches/source.js", "src/layers/launches/state.js", "src/layers/launches/testing.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/enrichment.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/status.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -1625,10 +2056,18 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", "src/layers/military/tracking.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -1651,6 +2090,11 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -1659,8 +2103,8 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", @@ -1685,18 +2129,34 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", + "src/layers/transit/controls.js", + "src/layers/transit/height.js", + "src/layers/transit/index.js", + "src/layers/transit/ingestion.js", + "src/layers/transit/lifecycle.js", + "src/layers/transit/movement.js", + "src/layers/transit/policy.js", + "src/layers/transit/qaMetrics.js", + "src/layers/transit/queries.js", + "src/layers/transit/rendering.js", + "src/layers/transit/selection.js", + "src/layers/transit/source.js", + "src/layers/transit/state.js", + "src/layers/transit/testing.js", + "src/layers/transit/trails.js", + "src/layers/transit/viewport.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -1704,11 +2164,34 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", + "src/layers/weather/clock.js", + "src/layers/weather/imageryHost.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/index.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/rasterTiles.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/source.js", + "src/layers/wind/fields.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/index.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/presentation.js", + "src/layers/wind/relief.js", + "src/layers/wind/rendering.js", + "src/layers/wind/source.js", + "src/layers/wind/streamlines.js", "src/loadingFeedback.js", "src/locationStatus.js", "src/locations.js", @@ -1719,15 +2202,17 @@ "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", - "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/maps/registry.js", "src/maps/terrain.js", "src/maps/terrainRetry.js", "src/navigationPolicy.js", + "src/nominatimGeocode.js", "src/orbit.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", @@ -1735,17 +2220,36 @@ "src/panelStackLayout.js", "src/renderGovernor.js", "src/rightRailPolicy.js", + "src/scenes/cameraMotion.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/geometry.js", + "src/scenes/dataPacks/presentation.js", "src/scenes/director.js", + "src/scenes/interactions.js", + "src/scenes/nepalEvidencePack.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/scenes/packs/registry.js", "src/scenes/playbackAdapter.js", - "src/director/playback.js", + "src/scenes/project.js", "src/scenes/recipes.js", "src/scenes/scenePolicy.js", + "src/scenes/sharing.js", + "src/scenes/shotPresentation.js", "src/scopeMask.js", + "src/sdr/controller.js", + "src/sdr/gain.js", + "src/sdr/spectrum.js", + "src/sdr/usbDevices.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", "src/search/defaults.js", "src/search/geospatial.js", "src/search/google.js", "src/search/http.js", + "src/search/nominatim.js", "src/search/placeSearch.js", + "src/search/presetGeocoder.js", "src/services/application.js", "src/services/groundFloor.js", "src/services/groundSnap.js", @@ -1753,10 +2257,28 @@ "src/services/requests.js", "src/services/terrainHeights.js", "src/sharelink.js", + "src/sources/adsbRecords.js", + "src/sources/capability.js", + "src/sources/featureGeometry.js", + "src/sources/featureSource.js", + "src/sources/httpBody.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", + "src/sources/militaryTileGeometry.js", + "src/sources/nominatim.js", + "src/sources/openFreeMap.js", + "src/sources/overpassFeatures.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassRoads.js", + "src/sources/phaseTiming.js", + "src/sources/reference.js", + "src/sources/requestDeadline.js", + "src/sources/sourceSlot.js", + "src/sources/transitHistory.js", + "src/sources/vectorTiles.js", + "src/sources/windBody.js", "src/splitFlap.js", "src/standalone/catalog.js", "src/standalone/layerSources.js", @@ -1766,27 +2288,26 @@ "src/styles/snow.js", "src/styles/surveillance.js", "src/styles/thermal.js", + "src/ui/aircraftDisplay.js", "src/ui/applicationShell.js", - "src/ui/cyberSonarControls.js", - "src/ui/locationNavigation.js", - "src/ui/cockpitCoordinator.js", - "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", + "src/ui/cameraOrientationControls.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js", + "src/ui/chipGroup.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", + "src/ui/cockpitCoordinator.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -1803,20 +2324,23 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", + "src/ui/cyberSonarControls.js", + "src/ui/displayBindings.js", "src/ui/displayControls.js", + "src/ui/displayPanelScroll.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/imageryBoxTool.js", + "src/ui/imagerySplit.js", + "src/ui/layerBindings.js", "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/ui/location.js", "src/ui/locationControls.js", + "src/ui/locationNavigation.js", "src/ui/locationSearch.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -1831,15 +2355,23 @@ "src/ui/radio.js", "src/ui/radioBindings.js", "src/ui/radioControls.js", + "src/ui/radioPanelPlacement.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", + "src/ui/recentImagery.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rowList.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/sceneSharing.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", + "src/ui/shellFacade.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", "src/ui/surfaceKeyboard.js", @@ -1848,186 +2380,32 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", + "src/ui/weatherPanel.js", "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", + "src/voice/realtimeConnection.js", "src/voice/realtimeController.js", - "src/voice/voiceCost.js", - "src/weatherEffectsMath.js", - "src/worldFocus.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/annotations/drawMode.js", - "src/annotations/drawTool.js", - "src/data/routeSteps.js", - "src/layers/directions/index.js", - "src/data/directions.js", - "src/app/layers/directions.js", - "src/search/coordinateGeocoder.js", - "src/search/coordinateParser.js", - "src/search/presetGeocoder.js", - "src/voice/session.js", - "src/voice/sessionCommands.js", - "src/voice/realtimeSession.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeDiagnostics.js", "src/voice/realtimeEvents.js", - "src/app/localGeojsonServices.js", - "src/data/feedState.js", - "src/data/layerSnapshot.js", - "src/layers/alpr/records.js", - "src/layers/earthquakes/records.js", - "src/app/layers/perimeters.js", - "src/layers/perimeters/index.js", - "src/layers/perimeters/source.js", - "src/layers/perimeters/records.js", - "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js", - "src/layers/cctv/sourcePolicy.js", - "src/sources/reference.js", - "src/search/nominatim.js", - "src/nominatimGeocode.js", - "src/sources/httpBody.js", - "src/sources/nominatim.js", - "src/app/layers/transit.js", - "src/data/transit.js", - "src/data/transitFeeds.js", - "src/data/transitIcons.js", - "src/data/transitPresetStyle.js", - "src/layers/transit/controls.js", - "src/layers/transit/height.js", - "src/layers/transit/index.js", - "src/layers/transit/ingestion.js", - "src/layers/transit/lifecycle.js", - "src/layers/transit/policy.js", - "src/layers/transit/queries.js", - "src/layers/transit/rendering.js", - "src/layers/transit/selection.js", - "src/layers/transit/state.js", - "src/layers/transit/testing.js", - "src/layers/transit/viewport.js", - "src/data/cameraSensitivity.js", - "src/data/contactPlayback.js", - "src/data/contactTrailRenderer.js", - "src/data/transitProxy.js", - "src/data/gtfsRealtime.js", - "src/data/modelEligibility.js", - "src/layers/transit/trails.js", - "src/layers/transit/movement.js", - "src/layers/transit/qaMetrics.js", - "src/layers/transit/source.js", - "src/sources/transitHistory.js", - "src/sources/featureGeometry.js", - "src/sources/overpassFeaturesRecords.js", - "src/sources/overpassFeatures.js", - "src/sources/featureSource.js", - "src/sources/overpassRoads.js", - "src/ui/aircraftDisplay.js", - "src/ui/layerBindings.js", - "src/ui/displayBindings.js", - "src/ui/shellFacade.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeInputPolicy.js", "src/voice/realtimePreferences.js", "src/voice/realtimeProtocol.js", - "src/voice/realtimeDiagnostics.js", - "src/voice/realtimeViewport.js", - "src/voice/realtimeInputPolicy.js", - "src/voice/realtimeInput.js", - "src/voice/realtimeCost.js", - "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", + "src/voice/realtimeSession.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js", - "src/layers/weather/index.js", - "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", - "src/layers/weather/shellRendering.js", - "src/layers/weather/infraredAlpha.js", - "src/layers/weather/infraredImage.js", - "src/layers/weather/source.js", - "src/director/clock.js", - "src/director/timeline.js", - "src/scenes/shotPresentation.js", - "src/scenes/packs/registry.js", - "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js", - "src/director/document.js", - "src/scenes/project.js", - "src/director/documentFields.js", - "src/director/cameraDocument.js", - "src/director/camera.js", - "src/scenes/cameraMotion.js", - "src/director/packs/source.js", - "src/director/packs/session.js", - "src/director/packs/geojson.js", - "src/director/packs/manifest.js", - "src/scenes/dataPacks/controller.js", - "src/scenes/dataPacks/presentation.js", - "src/scenes/dataPacks/geometry.js", - "src/director/interactions/document.js", - "src/director/interactions/session.js", - "src/scenes/interactions.js", - "src/director/sharing/bundle.js", - "src/director/sharing/preview.js", - "src/director/sharing/lifetime.js", - "src/director/authoring.js", - "src/scenes/sharing.js", - "src/ui/sceneSharing.js", - "src/layers/weather/imageryOrder.js", - "src/layers/weather/imageryHost.js", - "src/layers/cyclones/index.js", - "src/layers/cyclones/rendering.js", - "src/layers/cyclones/labels.js", - "src/layers/cyclones/source.js", - "src/app/layers/localAdsb.js", - "src/layers/localAdsb/index.js", - "src/layers/localAdsb/policy.js", - "src/layers/localAdsb/card.js", - "src/layers/localAdsb/feeds.js", - "src/layers/localAdsb/status.js", - "src/layers/localAdsb/feedNames.js", - "src/layers/localAdsb/motion.js", - "src/layers/localAdsb/models.js", - "src/layers/localAdsb/enrichment.js", - "src/sources/adsbRecords.js", - "src/sdr/controller.js", - "src/sdr/spectrum.js", - "src/sdr/gain.js", - "src/sdr/usbDevices.js", - "src/ui/localSdrControls.js", - "src/ui/localSdrPresentation.js", - "src/app/layers/recentImagery.js", - "src/layers/recentImagery/index.js", - "src/layers/recentImagery/model.js", - "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", - "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", - "src/ui/recentImagery.js", - "src/ui/imageryBoxTool.js", - "src/layers/alpr/tileRecords.js", - "src/sources/capability.js", - "src/sources/openFreeMap.js", - "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", - "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js", - "src/data/cameraArrival.js", - "src/layers/alpr/floors.js", - "src/data/militaryNames.js", - "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/voice/realtimeViewport.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/voiceCost.js", + "src/weatherEffectsMath.js", + "src/worldFocus.js" ], "external": [ "hls.js", @@ -2041,61 +2419,67 @@ ] }, "application-catalog": { - "exports": ["./application/catalog"], - "modules": ["src/app/catalog.js"], + "exports": [ + "./application/catalog" + ], + "modules": [ + "src/app/catalog.js" + ], "external": [] }, "application-layer-construction": { - "exports": ["./application/layers"], + "exports": [ + "./application/layers" + ], "modules": [ - "src/cyberSonar.js", - "src/data/bhoteKoshiEvent.js", - "src/ui/imagerySplit.js", - "src/maps/imageryComparison.js", - "src/data/bhoteKoshiEmbeddedMedia.js", - "src/data/bhoteKoshiLocator.js", - "src/data/bhoteKoshiIncidentPlaces.js", - "src/data/bhoteKoshiFloodPath.js", - "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", + "src/annotations/worldAnnotationRenderer.js", "src/app/catalog.js", "src/app/constructCatalog.js", - "src/layers/wind/index.js", - "src/layers/wind/model.js", - "src/layers/wind/fields.js", - "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", - "src/layers/wind/inspection.js", - "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/directions.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", + "src/app/layers/localAdsb.js", "src/app/layers/militaryAwareness.js", "src/app/layers/militaryFlights.js", "src/app/layers/militaryInstallations.js", "src/app/layers/overlayHost.js", + "src/app/layers/perimeters.js", "src/app/layers/radio.js", + "src/app/layers/recentImagery.js", "src/app/layers/rocketLaunches.js", "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", - "src/sources/sourceSlot.js", + "src/app/layers/transit.js", + "src/app/localGeojsonServices.js", + "src/cameraGroundGuard.js", + "src/cameraVerbs.js", "src/cctvFocusRequest.js", "src/celestialRing.js", "src/cockpitMath.js", + "src/cyberSonar.js", + "src/data/adminBoundaries.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", "src/data/aircraftRecession.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiShotPaths.js", + "src/data/bundledJson.js", + "src/data/cameraArrival.js", + "src/data/cameraSensitivity.js", "src/data/cctvCards.js", "src/data/cctvFootprint.js", "src/data/cctvGizmo.js", @@ -2104,49 +2488,63 @@ "src/data/cockpitAirLod.js", "src/data/cockpitContactDot.js", "src/data/contactMatch.js", + "src/data/contactPlayback.js", + "src/data/contactTrailRenderer.js", "src/data/contextStore.js", "src/data/dataCredits.js", + "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", + "src/data/detectionPresentation.js", + "src/data/detectionRenderDemand.js", + "src/data/directions.js", "src/data/firmsAdapt.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", "src/data/geoid.js", + "src/data/groundFloor.js", + "src/data/gtfsRealtime.js", "src/data/iconOrientation.js", "src/data/infrastructure.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", "src/data/issPass.js", - "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", + "src/data/local_data/natural_earth/countries.json", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/states_provinces.json", "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/osm_military_names/names.json", + "src/data/local_data/us_census_counties/counties.json", "src/data/militaryAwarenessEngine.js", "src/data/militaryInstallationData.js", + "src/data/militaryNames.js", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/naturalEarthRegions.js", - "src/data/adminBoundaries.js", - "src/data/local_data/natural_earth/states_provinces.json", - "src/data/local_data/natural_earth/countries.json", - "src/data/local_data/us_census_counties/counties.json", "src/data/neighborhoodPolygons.js", "src/data/pickRegistry.js", "src/data/radioCountry.js", + "src/data/regionalModel.js", "src/data/renderAltitude.js", - "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/routePlausible.js", + "src/data/routeSteps.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/data/scenePick.js", "src/data/spriteOrder.js", + "src/data/terrainHeights.js", "src/data/tomtomTiles.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", @@ -2158,16 +2556,29 @@ "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", "src/data/trailRenderer.js", + "src/data/transitFeeds.js", + "src/data/transitIcons.js", + "src/data/transitPresetStyle.js", + "src/data/transitProxy.js", "src/data/vesselLabels.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", + "src/layers/alpr/floors.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", - "src/layers/alpr/visuals.js", - "src/layers/alpr/overlay.js", + "src/layers/alpr/records.js", "src/layers/alpr/source.js", + "src/layers/alpr/tileRecords.js", + "src/layers/alpr/visuals.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -2217,14 +2628,21 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", + "src/layers/cctv/videoPlayback.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/source.js", + "src/layers/directions/index.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", + "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", @@ -2245,10 +2663,14 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -2257,6 +2679,7 @@ "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", @@ -2282,6 +2705,15 @@ "src/layers/launches/source.js", "src/layers/launches/state.js", "src/layers/launches/testing.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/enrichment.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/status.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -2289,10 +2721,18 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", "src/layers/military/tracking.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -2315,6 +2755,11 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -2323,8 +2768,8 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", @@ -2348,18 +2793,34 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", + "src/layers/transit/controls.js", + "src/layers/transit/height.js", + "src/layers/transit/index.js", + "src/layers/transit/ingestion.js", + "src/layers/transit/lifecycle.js", + "src/layers/transit/movement.js", + "src/layers/transit/policy.js", + "src/layers/transit/qaMetrics.js", + "src/layers/transit/queries.js", + "src/layers/transit/rendering.js", + "src/layers/transit/selection.js", + "src/layers/transit/source.js", + "src/layers/transit/state.js", + "src/layers/transit/testing.js", + "src/layers/transit/trails.js", + "src/layers/transit/viewport.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -2367,151 +2828,79 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", + "src/layers/weather/clock.js", + "src/layers/weather/imageryHost.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/index.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/rasterTiles.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/source.js", + "src/layers/wind/fields.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/index.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/presentation.js", + "src/layers/wind/relief.js", + "src/layers/wind/rendering.js", + "src/layers/wind/streamlines.js", "src/locations.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/navigationPolicy.js", + "src/nominatimGeocode.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", "src/renderGovernor.js", + "src/sdr/controller.js", + "src/sdr/gain.js", + "src/sdr/spectrum.js", + "src/sdr/usbDevices.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", "src/search/defaults.js", "src/search/geospatial.js", "src/search/google.js", "src/search/http.js", + "src/search/nominatim.js", "src/search/placeSearch.js", + "src/search/presetGeocoder.js", "src/services/application.js", + "src/services/groundFloor.js", "src/services/requests.js", + "src/services/terrainHeights.js", + "src/sources/adsbRecords.js", + "src/sources/capability.js", + "src/sources/featureGeometry.js", + "src/sources/featureSource.js", + "src/sources/httpBody.js", + "src/sources/militaryTileGeometry.js", + "src/sources/nominatim.js", + "src/sources/openFreeMap.js", + "src/sources/overpassFeatures.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassRoads.js", + "src/sources/phaseTiming.js", + "src/sources/requestDeadline.js", + "src/sources/sourceSlot.js", + "src/sources/transitHistory.js", + "src/sources/vectorTiles.js", + "src/ui/imagerySplit.js", "src/ui/panelRailGeometry.js", "src/ui/radioTunerModel.js", - "src/worldFocus.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/annotations/worldAnnotationRenderer.js", - "src/cameraGroundGuard.js", - "src/cameraVerbs.js", - "src/services/groundFloor.js", - "src/data/routeSteps.js", - "src/layers/directions/index.js", - "src/services/terrainHeights.js", - "src/data/terrainHeights.js", - "src/data/groundFloor.js", - "src/data/directions.js", - "src/app/layers/directions.js", - "src/search/coordinateGeocoder.js", - "src/search/coordinateParser.js", - "src/search/presetGeocoder.js", - "src/app/localGeojsonServices.js", - "src/layers/alpr/records.js", - "src/layers/earthquakes/records.js", - "src/app/layers/perimeters.js", - "src/layers/perimeters/index.js", - "src/layers/perimeters/source.js", - "src/layers/perimeters/records.js", - "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js", - "src/layers/cctv/sourcePolicy.js", - "src/search/nominatim.js", - "src/nominatimGeocode.js", - "src/data/regionalModel.js", - "src/sources/httpBody.js", - "src/sources/nominatim.js", - "src/app/layers/transit.js", - "src/data/transitFeeds.js", - "src/data/transitIcons.js", - "src/data/transitPresetStyle.js", - "src/layers/transit/controls.js", - "src/layers/transit/height.js", - "src/layers/transit/index.js", - "src/layers/transit/ingestion.js", - "src/layers/transit/lifecycle.js", - "src/layers/transit/policy.js", - "src/layers/transit/queries.js", - "src/layers/transit/rendering.js", - "src/layers/transit/selection.js", - "src/layers/transit/state.js", - "src/layers/transit/testing.js", - "src/layers/transit/viewport.js", - "src/data/cameraSensitivity.js", - "src/data/contactPlayback.js", - "src/data/contactTrailRenderer.js", - "src/data/transitProxy.js", - "src/data/gtfsRealtime.js", - "src/layers/transit/trails.js", - "src/layers/transit/movement.js", - "src/layers/transit/qaMetrics.js", - "src/layers/transit/source.js", - "src/sources/transitHistory.js", - "src/sources/featureGeometry.js", - "src/sources/overpassFeaturesRecords.js", - "src/sources/overpassFeatures.js", - "src/sources/featureSource.js", - "src/sources/overpassRoads.js", - "src/layers/weather/index.js", - "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", - "src/layers/weather/shellRendering.js", - "src/layers/weather/infraredAlpha.js", - "src/layers/weather/infraredImage.js", - "src/layers/weather/source.js", - "src/layers/weather/imageryOrder.js", - "src/layers/weather/imageryHost.js", - "src/layers/cyclones/index.js", - "src/layers/cyclones/rendering.js", - "src/layers/cyclones/labels.js", - "src/layers/cyclones/source.js", - "src/app/layers/localAdsb.js", - "src/layers/localAdsb/index.js", - "src/layers/localAdsb/policy.js", - "src/layers/localAdsb/card.js", - "src/layers/localAdsb/feeds.js", - "src/layers/localAdsb/status.js", - "src/layers/localAdsb/feedNames.js", - "src/layers/localAdsb/motion.js", - "src/layers/localAdsb/models.js", - "src/layers/localAdsb/enrichment.js", - "src/sources/adsbRecords.js", - "src/sdr/controller.js", - "src/sdr/spectrum.js", - "src/sdr/gain.js", - "src/sdr/usbDevices.js", - "src/data/detection.js", - "src/data/detectionRenderDemand.js", - "src/data/detectionPresentation.js", - "src/data/modelEligibility.js", - "src/app/layers/recentImagery.js", - "src/layers/recentImagery/index.js", - "src/layers/recentImagery/model.js", - "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", - "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", - "src/layers/alpr/tileRecords.js", - "src/sources/capability.js", - "src/sources/openFreeMap.js", - "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", - "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js", - "src/data/cameraArrival.js", - "src/layers/alpr/floors.js", - "src/data/militaryNames.js", - "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/worldFocus.js" ], "external": [ "hls.js", @@ -2525,7 +2914,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2586,26 +2977,43 @@ ] }, "layer-lifecycle": { - "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "exports": [ + "./data/lifecycle" + ], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js" + ], "external": [] }, "ui-navigation": { - "exports": ["./ui/navigation"], + "exports": [ + "./ui/navigation" + ], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-share-restoration": { - "exports": ["./ui/share-restoration"], + "exports": [ + "./ui/share-restoration" + ], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/loadingFeedback.js", "src/navigationPolicy.js", "src/ui/shareRestoration.js", @@ -2614,16 +3022,23 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ - "src/cyberSonar.js", - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", + "src/cyberSonar.js", "src/data/detectionPolicy.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/retro.js", @@ -2632,6 +3047,7 @@ "src/styles/thermal.js", "src/ui/applicationShortcuts.js", "src/ui/effects.js", + "src/ui/radioPanelPlacement.js", "src/ui/styleParameters.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", @@ -2639,14 +3055,23 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-panel-chrome": { - "exports": ["./ui/panel-chrome"], + "exports": [ + "./ui/panel-chrome" + ], "modules": [ - "src/ui/displayPanelScroll.js", - "src/ui/radioPanelPlacement.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/panelStackLayout.js", + "src/ui/displayPanelScroll.js", "src/ui/leftPanelRail.js", "src/ui/panelChrome.js", "src/ui/panelDisclosure.js", @@ -2655,13 +3080,16 @@ "src/ui/panelPositionControls.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", + "src/ui/radioPanelPlacement.js", "src/ui/rightPanelRail.js", "src/ui/uiLifetime.js" ], "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2672,7 +3100,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2680,7 +3110,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2691,7 +3123,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2699,7 +3133,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2707,7 +3143,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2715,46 +3153,85 @@ "external": [] }, "directions-layer": { - "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], - "external": ["cesium"] + "exports": [ + "./layers/directions" + ], + "modules": [ + "src/layers/directions/index.js", + "src/data/routeSteps.js" + ], + "external": [ + "cesium" + ] }, "radio-browser-source": { - "exports": ["./sources/radio-browser"], - "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "exports": [ + "./sources/radio-browser" + ], + "modules": [ + "src/sources/radioBrowser.js", + "src/data/radioCountry.js" + ], "external": [] }, "cctv-types-source": { - "exports": ["./sources/cctv-types"], - "modules": ["src/sources/cctvTypes.js"], + "exports": [ + "./sources/cctv-types" + ], + "modules": [ + "src/sources/cctvTypes.js" + ], "external": [] }, "regional-source": { - "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "exports": [ + "./sources/regional" + ], + "modules": [ + "src/data/regionalModel.js" + ], "external": [] }, "voice-action-schemas": { - "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], + "exports": [ + "./voice/action-schemas" + ], + "modules": [ + "src/voice/actionSchemas.js", + "src/hudLayoutPolicy.js" + ], "external": [] }, "voice-session": { - "exports": ["./voice/session"], - "modules": ["src/voice/session.js"], + "exports": [ + "./voice/session" + ], + "modules": [ + "src/voice/session.js" + ], "external": [] }, "voice-session-controls": { - "exports": ["./voice/session-controls"], + "exports": [ + "./voice/session-controls" + ], "modules": [ - "src/voice/sessionCommands.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/voice/control.js", "src/voice/session.js", - "src/voice/control.js" + "src/voice/sessionCommands.js" ], "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2765,20 +3242,34 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "bikeshare-source": { - "exports": ["./layers/bikeshare/source"], - "modules": ["src/layers/bikeshare/source.js"], + "exports": [ + "./layers/bikeshare/source" + ], + "modules": [ + "src/layers/bikeshare/source.js" + ], "external": [] }, "cctv-source": { - "exports": ["./layers/cctv/source"], - "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "exports": [ + "./layers/cctv/source" + ], + "modules": [ + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js" + ], "external": [] }, "earthquakes-source": { - "exports": ["./layers/earthquakes/source"], + "exports": [ + "./layers/earthquakes/source" + ], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -2786,12 +3277,18 @@ "external": [] }, "firms-source": { - "exports": ["./layers/firms/source"], - "modules": ["src/layers/firms/source.js"], + "exports": [ + "./layers/firms/source" + ], + "modules": [ + "src/layers/firms/source.js" + ], "external": [] }, "installations-source": { - "exports": ["./layers/installations/source"], + "exports": [ + "./layers/installations/source" + ], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -2808,25 +3305,43 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "launches-source": { - "exports": ["./layers/launches/source"], - "modules": ["src/layers/launches/source.js"], + "exports": [ + "./layers/launches/source" + ], + "modules": [ + "src/layers/launches/source.js" + ], "external": [] }, "radio-source": { - "exports": ["./layers/radio/source"], - "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "exports": [ + "./layers/radio/source" + ], + "modules": [ + "src/layers/radio/policy.js", + "src/layers/radio/source.js" + ], "external": [] }, "satellites-source": { - "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "exports": [ + "./layers/satellites/source" + ], + "modules": [ + "src/layers/satellites/source.js" + ], "external": [] }, "traffic-source-factory": { - "exports": ["./layers/traffic/source"], + "exports": [ + "./layers/traffic/source" + ], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -2842,10 +3357,16 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "reference-sources": { - "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "exports": [ + "./sources/reference", + "./layers/submarine-cables/source" + ], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -2858,7 +3379,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2871,17 +3394,27 @@ "external": [] }, "source-http-body": { - "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "exports": [ + "./sources/http-body" + ], + "modules": [ + "src/sources/httpBody.js" + ], "external": [] }, "overpass-lexer": { - "exports": ["./sources/overpass"], - "modules": ["src/sources/overpass.js"], + "exports": [ + "./sources/overpass" + ], + "modules": [ + "src/sources/overpass.js" + ], "external": [] }, "nominatim-source": { - "exports": ["./sources/nominatim"], + "exports": [ + "./sources/nominatim" + ], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -2893,7 +3426,9 @@ }, "transit-provider": { "runtime": "node", - "exports": ["./server/providers/transit"], + "exports": [ + "./server/providers/transit" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -2908,20 +3443,28 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-source": { - "exports": ["./sources/transit"], + "exports": [ + "./sources/transit" + ], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-standalone": { - "exports": ["./layers/transit/standalone"], + "exports": [ + "./layers/transit/standalone" + ], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -2980,10 +3523,16 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": ["cesium", "egm96-universal", "pbf"] + "external": [ + "cesium", + "egm96-universal", + "pbf" + ] }, "transit-layer": { - "exports": ["./layers/transit"], + "exports": [ + "./layers/transit" + ], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3017,10 +3566,15 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": ["cesium", "pbf"] + "external": [ + "cesium", + "pbf" + ] }, "transit-source-factory": { - "exports": ["./layers/transit/source"], + "exports": [ + "./layers/transit/source" + ], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3028,7 +3582,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3039,10 +3595,14 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "map-feature-source": { - "exports": ["./sources/map-features"], + "exports": [ + "./sources/map-features" + ], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3053,7 +3613,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3062,10 +3624,14 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "perimeters-source": { - "exports": ["./layers/perimeters/source"], + "exports": [ + "./layers/perimeters/source" + ], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", diff --git a/src/app/controls.js b/src/app/controls.js index 8d9b988b5..9b9bc0b98 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; +import { t } from '../i18n/index.js'; /** Construct the existing controls and camera presentation. */ export function createApplicationControls({ @@ -41,10 +42,10 @@ export function createApplicationControls({ // If no share link state, do default fly-to Austin if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; + loaderStatus.textContent = t('shell.loading.status.flying'); defer(flyToAustin(viewer)); } else { - loaderStatus.textContent = 'Restoring shared view...'; + loaderStatus.textContent = t('shell.loading.status.restoring'); } return { styleManager, weatherEffects, cockpitCloudEffects }; diff --git a/src/app/scene.js b/src/app/scene.js index 65e40cd7e..986ad7bc7 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,7 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ @@ -41,7 +42,7 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } - loaderStatus.textContent = 'Configuring viewer...'; + loaderStatus.textContent = t('shell.loading.status.configuring'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); creditContainer.id = 'cesium-credits'; @@ -60,8 +61,8 @@ export async function createApplicationScene({ configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; + ? t('shell.loading.status.tilesGoogle') + : t('shell.loading.status.tilesKeyless'); const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, cesiumToken, @@ -88,12 +89,14 @@ export async function createApplicationScene({ tileError, ); const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', { + detail: tileErrorDetail, + }); } viewer.scene.globe.show = true; } - loaderStatus.textContent = 'Initializing systems...'; + loaderStatus.textContent = t('shell.loading.status.systems'); const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js index 5e1c3dea2..7d7c19cdb 100644 --- a/src/creditKeyboard.js +++ b/src/creditKeyboard.js @@ -2,6 +2,8 @@ * Adds the keyboard semantics omitted by Cesium's href-less attribution links. * Cesium retains ownership of showing and hiding its required credit lightbox. */ +import { t } from './i18n/index.js'; + export function configureCreditKeyboardAccess(root = document) { const expand = root?.querySelector?.( '#cesium-credits .cesium-credit-expand-link', @@ -19,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) { expand.setAttribute('aria-expanded', 'false'); close.setAttribute('role', 'button'); close.setAttribute('tabindex', '0'); - close.setAttribute('aria-label', 'Close data attribution'); + close.setAttribute('aria-label', t('shell.credits.closeAria')); if (expand.dataset.gevKeyboardReady === 'true') return true; expand.dataset.gevKeyboardReady = 'true'; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index e4deae82e..e1f5e0232 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -301,7 +301,7 @@ test('Contacts continuation target is the visible explanatory note', () => { const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url)); assert.match( source, - /

Open-source mapped\/observed context\./, + /

\$\{t\('layers\.awareness\.note'\)\}<\/p>/, ); assert.doesNotMatch(source, /]*data-awareness-focus-continuation/); }); diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 996ad5b81..17886e8fa 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,5 +1,7 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ +import { t } from '../i18n/index.js'; + export const AWARENESS_RADIUS_M = 250000; export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000; export const AWARENESS_RELATIONSHIP = Object.freeze({ @@ -82,14 +84,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: 'feed unavailable', + reason: t('layers.awareness.reason.feedUnavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: 'feed stale', + reason: t('layers.awareness.reason.feedStale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -106,8 +108,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? 'observed or mapped nearby context' - : 'no observed or mapped objects in current feeds', + ? t('layers.awareness.reason.nearby') + : t('layers.awareness.reason.none'), }; } diff --git a/src/data/satelliteClass.js b/src/data/satelliteClass.js index 29a7df3b1..83ea0e846 100644 --- a/src/data/satelliteClass.js +++ b/src/data/satelliteClass.js @@ -17,6 +17,8 @@ * for), then the constellations users can actually name, then the grab-bag. */ +import { t } from '../i18n/index.js'; + /** * Class registry. `label` is the card/legend token, `color` the point color, * `blurb` the plain-language gloss for the legend tooltip. @@ -77,6 +79,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([ 'comms', ]); +// Display keys for the class table above: `label`/`blurb` stay the English +// source-of-truth tokens (tests read them), while the card and legend accessors +// translate through these keys at the presentation boundary. +const CLASS_DISPLAY_KEYS = Object.freeze({ + station: Object.freeze({ + label: 'layers.satellites.class.station', + blurb: 'layers.satellites.class.stationBlurb', + }), + nav: Object.freeze({ + label: 'layers.satellites.class.nav', + blurb: 'layers.satellites.class.navBlurb', + }), + geo: Object.freeze({ + label: 'layers.satellites.class.geo', + blurb: 'layers.satellites.class.geoBlurb', + }), + visual: Object.freeze({ + label: 'layers.satellites.class.visual', + blurb: 'layers.satellites.class.visualBlurb', + }), + comms: Object.freeze({ + label: 'layers.satellites.class.comms', + blurb: 'layers.satellites.class.commsBlurb', + }), +}); + /** * CelesTrak group tag → { class, subtype }. Subtype names the specific * constellation so the card can read "NAV · GPS" rather than a bare "NAV". @@ -139,7 +167,7 @@ export function satelliteClassColor(group) { */ export function satelliteClassLabel(group, { isIss = false } = {}) { const { klass, subtype } = satelliteClassOf(group, { isIss }); - const base = SATELLITE_CLASSES[klass].label; + const base = t(CLASS_DISPLAY_KEYS[klass].label); return subtype ? `${base} · ${subtype}` : base; } @@ -180,11 +208,12 @@ export function satelliteClassLegend(counts) { const count = counts?.[klass]; if (!(count > 0)) continue; const spec = SATELLITE_CLASSES[klass]; + const display = CLASS_DISPLAY_KEYS[klass]; result.push({ klass, - label: spec.label, + label: t(display.label), color: spec.color, - blurb: spec.blurb, + blurb: t(display.blurb), count, }); } diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 240e22828..6db97e93d 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -4,6 +4,8 @@ * after the vessel cards moved out of their dedicated canvas renderer. */ +import { t } from '../i18n/index.js'; + export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; /** Existing selector grid size; one ambient winner is retained per cell. */ export const VESSEL_LABEL_GRID_PX = 118; @@ -64,6 +66,43 @@ const NUMERIC_TYPE_FAMILIES = { 9: 'OTHER', }; +// Display keys for the normalized English type tokens above. The tokens stay +// machine values — TYPE_STYLES matches on them and tests pin them — so card +// text translates only through this map at the presentation boundary. +const TYPE_DISPLAY_KEYS = Object.freeze({ + FISHING: 'layers.vessel.type.fishing', + TOWING: 'layers.vessel.type.towing', + DREDGER: 'layers.vessel.type.dredger', + 'DIVE OPS': 'layers.vessel.type.diveOps', + MILITARY: 'layers.vessel.type.military', + SAILING: 'layers.vessel.type.sailing', + PLEASURE: 'layers.vessel.type.pleasure', + PILOT: 'layers.vessel.type.pilot', + SAR: 'layers.vessel.type.sar', + TUG: 'layers.vessel.type.tug', + 'PORT TENDER': 'layers.vessel.type.portTender', + 'ANTI-POLLUTION': 'layers.vessel.type.antiPollution', + 'LAW ENFORCE': 'layers.vessel.type.lawEnforce', + MEDICAL: 'layers.vessel.type.medical', + 'HIGH-SPEED': 'layers.vessel.type.highSpeed', + PASSENGER: 'layers.vessel.type.passenger', + CARGO: 'layers.vessel.type.cargo', + TANKER: 'layers.vessel.type.tanker', + OTHER: 'layers.vessel.type.other', +}); + +/** + * Card-display form of a normalized AIS type: known tokens translate through + * the catalog; free-text types and unknown codes render unchanged. + * @param {string} type Raw AIS type. + * @returns {string} + */ +export function displayVesselType(type) { + const normalized = normalizeVesselType(type); + const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()]; + return key ? t(key) : normalized; +} + /** * Resolve an AIS type to display text: bare numeric ship-type codes map to * family names ("71" → "CARGO"); text types pass through unchanged. diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 6dffb7c5e..c26a18a7b 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // Choosing a mission is deliberately NOT durable suppression: picking a mission // is enthusiasm, not "never show me this again". +import { t } from './i18n/index.js'; + /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; /** Per-session dismissal. Written by every close path; scoped to sessionStorage. */ @@ -34,10 +36,12 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1'; */ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; -const ENVIRONMENTAL_LABELS = Object.freeze({ - ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }), - EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }), - ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }), +// Catalog keys per label choice. Resolved through t() at CALL time (init), +// never at module load — the locale is not resolved until main.js's body runs. +const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ + ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental', + EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch', + ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents', }); /** @@ -45,7 +49,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({ * @returns {{title: string}} The label set the constant above selects. */ export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) { - return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL; + const key = + ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL; + return { title: t(key) }; } /* @@ -92,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ contacts: Object.freeze({ kind: 'context', contextMode: 'contacts', - busyText: 'Starting live contacts…', + busyKey: 'setup.firstRun.busy.contacts', }), 'space-missions': Object.freeze({ kind: 'context', contextMode: 'space-missions', - busyText: 'Opening space missions…', + busyKey: 'setup.firstRun.busy.spaceMissions', }), environmental: Object.freeze({ kind: 'globe', @@ -115,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ // before a launch. LEDGERED post-launch. Until it lands, keyless visitors // are judged on the layer row, which tells them the truth. layerIds: Object.freeze(['earthquakes', 'local-firms']), - busyText: 'Scanning active events…', + busyKey: 'setup.firstRun.busy.environmental', }), explore: Object.freeze({ kind: 'none' }), }); @@ -352,12 +358,23 @@ export function initFirstRunExperience({ } // The tile name is owner-switchable from one constant, so paint it from the - // module rather than trusting the markup to have been edited to match. + // module rather than trusting the markup to have been edited to match. The + // persuasive description line and the tile's feed subcopy are the two + // phase-2 exceptions whose static sites are pinned verbatim by + // firstRunExperience.test.mjs — the runtime write is their only legal + // localization point (docs/TRANSLATORS.md, runtime-only static sites). const environmentalTitle = root.querySelector( '[data-first-run-environmental-title]', ); if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title; + const environmentalSub = root.querySelector( + '[data-first-run-environmental-title] + small', + ); + if (environmentalSub) + environmentalSub.textContent = t('setup.firstRun.choice.environmentalSub'); + const description = root.querySelector('#first-run-description'); + if (description) description.textContent = t('setup.firstRun.description'); const status = root.querySelector('[data-first-run-status]'); const suppressBox = root.querySelector('[data-first-run-suppress]'); @@ -440,7 +457,9 @@ export function initFirstRunExperience({ button.setAttribute('aria-disabled', String(next)); if (!status) return; if (next) - status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…'; + status.textContent = t( + FIRST_RUN_MISSIONS[choice]?.busyKey || 'setup.firstRun.busy.working', + ); else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus; }; @@ -490,7 +509,7 @@ export function initFirstRunExperience({ Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; if (status) { status.dataset.sticky = 'true'; - status.textContent = `Could not open that mission${detail}. Retry or explore manually.`; + status.textContent = t('setup.firstRun.status.failed', { detail }); } setBusy(false); }; @@ -506,8 +525,7 @@ export function initFirstRunExperience({ if (box) box.checked = !wanted; if (!status) return; status.dataset.sticky = 'true'; - status.textContent = - 'This browser is blocking storage, so that could not be saved.'; + status.textContent = t('setup.firstRun.status.storageBlocked'); }; const keyboard = createSurfaceKeyboard({ diff --git a/src/hud.js b/src/hud.js index eb8c0ab34..f251c0193 100644 --- a/src/hud.js +++ b/src/hud.js @@ -35,6 +35,7 @@ import { DEFAULT_HUD_LAYOUT, normalizeHudLayout, } from './hudLayouts.js'; +import { t } from './i18n/index.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -201,15 +202,15 @@ export class IntelHUD {

${this._missionId} ${this._sensorId}
NORMAL
-
SUMMARY
-
Awaiting telemetry...
+
${t('cockpit.hud.summaryLabel')}
+
${t('cockpit.hud.summaryAwaiting')}
-
● REC 2026-01-01 00:00:00Z
+
● ${t('cockpit.hud.recLabel')} 2026-01-01 00:00:00Z
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -634,7 +635,7 @@ export class IntelHUD { */ _composeSummary() { const m = this._latestMetrics; - if (!m) return 'Awaiting telemetry...'; + if (!m) return t('cockpit.hud.summaryAwaiting'); const modeEl = document.getElementById('hud-mode'); const modeLabel = modeEl?.textContent || 'NORMAL'; diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index 3d11ebf3e..920933852 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -22,7 +22,9 @@ test('HUD sliders and location search have descriptive explicit names', () => { }); test('the first-run checkbox keeps its native visible label', () => { - assert.match(html, /]*class="first-run-suppress"[^>]*>\s*\s*Don't show this again<\/span>\s*<\/label>/); + // The span may carry a data-i18n extraction attribute; the native wrapping + // label contract is what this guards. + assert.match(html, /]*class="first-run-suppress"[^>]*>\s*\s*]*>Don't show this again<\/span>\s*<\/label>/); }); test('generated style sliders use the visible parameter label as their name', () => { diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js index 458a648f7..3b66325d6 100644 --- a/src/i18n/locales/en/layers.js +++ b/src/i18n/locales/en/layers.js @@ -210,6 +210,7 @@ export default { 'cctv.summary.empty': 'No cameras available in catalog.', 'cctv.summary.city': '{city} CCTV', 'cctv.summary.hdg': 'HDG {value}°', + 'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)', 'cctv.summary.fov': 'FOV {value}°', 'cctv.summary.coverage': 'COVERAGE {value}km²', 'cctv.summary.overlap': 'OVERLAP {count} cams', @@ -415,8 +416,21 @@ export default { 'firms.card.focusAria': 'Focus fire detection {title}, {details}', // src/data/traffic.js feed presentation (manager meta loadingLabel values). - 'traffic.loadingSyncing': 'syncing LIVE traffic flow', - 'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov', + 'traffic.loadingSyncing': + 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated', + 'traffic.liveCoverage': + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov', + 'traffic.liveCoverageUnmatched': + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated', + 'traffic.simulatedNoMatches': + 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)', + 'traffic.syncingRoads': 'Syncing flow · Roads: {source}', + 'traffic.liveRoadsFlowHidden': + 'LIVE · Roads: {source} · Roads without flow hidden', + 'traffic.liveRoadsNoFlowInView': + 'LIVE · Roads: {source} · No flow roads in view', + 'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%', + 'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%', 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', @@ -470,6 +484,7 @@ export default { 'awareness.cohort.vessels': 'AIS vessels', 'awareness.cohort.installations': 'Mapped installations', 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', + 'awareness.coverage.within': 'WITHIN {km} KM', 'awareness.hdg': 'HDG {value}°', 'awareness.brg': 'BRG {value}°', 'awareness.brgUnknown': 'BRG —', diff --git a/src/i18n/markupCoverage.test.mjs b/src/i18n/markupCoverage.test.mjs new file mode 100644 index 000000000..45c8743eb --- /dev/null +++ b/src/i18n/markupCoverage.test.mjs @@ -0,0 +1,128 @@ +// Markup coverage gate (phase 2): every data-i18n* attribute in the static +// application markup must name a key that resolves in EVERY shipped catalog +// via the real catalog builder — a renamed or dropped catalog key can no +// longer strand a static extraction silently behind the key-itself fallback. +// index.html is now assembled from src/ui/templates/*.html by +// build/application-html.js before Vite processes it, so the gate expands the +// same templates the build serves and scans that assembled markup. Regex +// parsing is deliberate: no DOM dependency in unit tests. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getCatalog } from './index.js'; +import { CATALOG_LOCALES, availableLocales } from './locale.js'; +import { expandApplicationHtml } from '../../build/application-html.js'; + +const INDEX_HTML = new URL('../../index.html', import.meta.url); +const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url); + +// The four spellings applyDocumentTranslations() knows about. Anything else +// (a typo like data-i18n-lable) must fail here, not silently never apply. +const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g; +const KNOWN_ATTRIBUTE_NAMES = new Set([ + 'data-i18n', + 'data-i18n-title', + 'data-i18n-aria-label', + 'data-i18n-placeholder', +]); + +const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8')); + +const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]); + +test('index.html carries static i18n references', () => { + assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?'); +}); + +test('index.html sources its body from the application templates', () => { + const raw = readFileSync(INDEX_HTML, 'utf8'); + assert.ok(raw.includes(' -
- LEVEL + LEVEL
-
- GROUND SPEED · KTS + GROUND SPEED · KTS
@@ -92,7 +92,7 @@
- ALTITUDE · FT + ALTITUDE · FT
@@ -112,36 +112,36 @@
- FIRST PERSON + FIRST PERSON AIRCRAFT - LIVE TRACK · COURSE ALIGNED + LIVE TRACK · COURSE ALIGNED
-
- - -
-
+
- GROUND SPEED + GROUND SPEED --- KTS
---°
- ALTITUDE + ALTITUDE ----- FT
@@ -153,26 +153,26 @@ --:--:--Z --° · --°
-