diff --git a/.gitignore b/.gitignore index 14e8d320b..d2f0107c0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ qa-artifacts/ 3d-models/ pinokio/ENVIRONMENT pinokio/.installed + +# Transient: a release interrupted mid-assembly (npm run changelog recovers it) +changelog.d/.assembling/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f26a481..05e3cff23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,53 @@ a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 or on the zero meridian. +- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both + band edges, skips the starting station and rejects spectra from an earlier + tune (Aditya, @Rohilalala, #1174). +- Local RTL-SDR keeps USB reads queued in order and bounds pending decoder + work. A stalled or overloaded decoder stops with an explicit error; stopping + terminates its worker and rejects late audio. FM playback buffers short + scheduling gaps, caps its audio queue at 256 ms, and reduces display updates + and spectrum calculations while listening. + +- Add Bogotá (Tembici, 250 stations) and Santiago de Chile (Bike Itaú, 200 + stations) to the GBFS bikeshare registry, the layer's first stations in + Colombia and Chile. Both are PBSC feeds like the Brazilian Bike Itaú cities, + so no proxy change is needed. Both feeds also list depot stations on other + continents (São Paulo, Madrid) as in service; stations outside a city's load + radius are now dropped instead of drawn as live docks. + +- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the + city's PBSC system) and AMBici (237 e-bike stations across the metropolitan + area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in + MobilityData's systems catalogue; AMBici's is CC0. `gbfs.nextbike.net` joins + the proxy allowlist. These are the layer's first stations in Spain. + +- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region, + YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own + licence quote and attribution. A feed can now name its own modes, so Toronto's + trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids + are internal keys can translate them to the number a rider would say. TTC + vehicles take their mode from the fleet number first, so replacement buses on + streetcar routes read as buses and out-of-service streetcars stay streetcars. + +- 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 fills in when the source states no + FOV and the pose is not curated (`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). + +- Ship a web app manifest and raster home-screen icons, so adding the app to a + phone home screen or installing it on desktop uses the God's Eye View mark + instead of a blank tile. The icons are generated from `public/logo.svg` by + `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs + no image toolchain. The existing SVG favicon still serves the browser tab. + Manifest paths are relative to the manifest, so a build served under a path + prefix installs and launches inside that prefix. + - 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 @@ -88,6 +135,11 @@ have wound down — CoGo's feed reports no stations and last moved a bike in March 2025. The three repointed feeds were verified live. +- 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 (about 1,000 stations in total). These are the layer's first stations + in South America. + - Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga entries, so it needs no new plumbing; a registry test now holds every city's @@ -132,6 +184,18 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Spanish interface (`es`), translated by @meirm (#602) and reviewed against the + current English. + +- Interface localization, starting with Russian. UI text is marked with + `t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed + by the English text itself; each language is one flat JSON catalog + (`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language + picker (the globe button in the bottom-right corner), then the browser + language; English stays the default and the fallback. `npm run i18n`, also + run by `npm test`, fails on missing, unused or broken translations. Built on + the i18n foundation by @meirm (#601), with the Russian catalog started + from #312. It also fails on UI text that is not marked at all. - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. @@ -144,6 +208,9 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Keep unknown GBFS availability counts neutral instead of turning null or empty + values into zero bikes. + - Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records. - Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cde8582f9..6d6b968db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,7 @@ The highest-leverage places to jump in: - ES modules, **2-space indent, single quotes, semicolons.** - JSDoc on exported/public functions. - Match the surrounding code — comment density, naming, and idiom. +- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md). - Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required. ## Formatting and reusable components @@ -149,7 +150,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/DATA_SOURCES.md b/DATA_SOURCES.md index 8f3b55dbf..0e04fb39b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,8 +65,11 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | -| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | +| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | +| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" | +| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | | **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | @@ -77,6 +80,15 @@ How to read this: | **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) | | **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) | | **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) | +| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto | +| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use | +| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence | +| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" | +| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence | +| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence | +| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | +| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | +| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **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)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -155,7 +167,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_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. +- **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 sixteen 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. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **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. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. @@ -185,6 +197,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | +| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -210,6 +223,17 @@ commercial use with attribution and applicable database share-alike/access obligations; it does not license the application code. Trimmed test-only tile fixtures retain their provenance in `src/data/fixtures/README.md`. +### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) + +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of +camera models transcribed from manufacturer datasheets, dedicated to the public domain under +**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King +County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +fetched at runtime. The records describe camera products (optics, resolution, night vision) +only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. + ### Bundled OpenStreetMap data Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. diff --git a/README.md b/README.md index c8b9eafec..c5aeb4710 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **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 | 🟢 | +| 🚌 **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, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | 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 | 🟡 | diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md new file mode 100644 index 000000000..adc1793c4 --- /dev/null +++ b/changelog.d/1096-military-hold-track-speed.md @@ -0,0 +1,3 @@ +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md new file mode 100644 index 000000000..4543710da --- /dev/null +++ b/changelog.d/1097-overpass-remark-failure-phrases.md @@ -0,0 +1,5 @@ +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md new file mode 100644 index 000000000..a6b69472d --- /dev/null +++ b/changelog.d/1115-aircraft-altitude-band-unknown.md @@ -0,0 +1,2 @@ +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). 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). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..df17e32f2 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,52 @@ +# 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. + +If `npm run changelog` fails part-way (a full disk, a killed process), run it +again. The batch is staged in `changelog.d/.assembling/` and only lands when the +assembled file replaces `CHANGELOG.md` in a single rename, so the rerun either +finishes that batch or puts its fragments back first. It never adds an entry +twice or drops one. `npm run changelog:check` names a staged batch, if one is +left over, instead of reporting past it. + +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/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1060f8c60..38f9249b2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Aircraft altitude-band queries require a reported barometric or geometric altitude +for each match, so a grounded contact that reports none is left out of a band too. +Queries without an altitude band retain unknown-altitude contacts. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts @@ -525,7 +528,7 @@ are protected from fallback writes. Version 6 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); see [Director](DIRECTOR.md) for camera, pack, action and sharing support. -Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. @@ -584,7 +587,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang Military flights also separate portable records and source acquisition from Cesium snapshot application. The metadata owner retains aviation feet, source -fix times, sticky fields and partial-feed retention. Rendering retains ground +fix times, sticky fields and partial-feed retention. A row without track or +speed holds the last known value, as civil records do; a grounded row without +speed reads 0. Rendering retains ground model ownership, motion history, military registration and tracking cleanup. Civil flights separate source acquisition, portable record reconciliation and @@ -1262,6 +1267,15 @@ cancels the body unread. 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. +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 @@ -1285,6 +1299,28 @@ services, local voice and standalone key setup. Importing them starts no network acquisition. Browser layer lifecycle, rendering and voice execution stay in their existing modules. +## Web app install icons + +`public/manifest.webmanifest` declares the installable app: standalone display, +`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant. +`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG +favicon; the existing SVG favicon still serves the browser tab. Safari ignores an +SVG apple-touch-icon and substitutes a page screenshot, so the raster is what +makes an installed icon appear at all. + +Manifest `start_url`, `scope` and icon paths are relative to the manifest, and +Vite rewrites the `index.html` links under `base`, so a build served from a path +prefix installs and launches inside that prefix. + +`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and +writes `public/icons/`. Output is committed, so building needs no image +toolchain; re-run the script after changing the logo. Ordinary icons inset the +mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the +circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links, +the manifest fields and that each committed PNG is square at its declared size. +It also builds the install links under `base: '/example/'` and asserts every +emitted link and manifest URL stays inside that base. + ## Local build preview After `npm run build`, `npm run preview` serves the built app with the same data @@ -1334,6 +1370,9 @@ and stale/error responses remain unchanged. Each has a Node-only package entry under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile math and GBFS source rules are available under `gods-eye-view/sources/`. The browser layers and their rendering remain in their existing modules. +GBFS null or empty availability counts retain an unknown value through station +parsing and rendering; they use the neutral station color rather than reporting +an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. ## Terrain height cache bound @@ -3205,6 +3244,39 @@ test:track` 43 tracking invariants · headless QA harnesses under > attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`. > The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history. +### Interface localization (October 2026) + +Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in +`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: +[`docs/TRANSLATORS.md`](./TRANSLATORS.md). + +- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera', + '{count} cameras')`, and `msg('Live Flights')` for text stored in data and + translated later. Markup opts in with `data-i18n` (element text) and + `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English + value). English has no catalog; every other locale is + `src/i18n/locales/.json`, and a missing entry falls back to English. +- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → + `navigator.languages` → English. `?lang=` is not stored, and share links drop + it, so a recipient keeps their own language. The globe picker in the + bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops + `?lang=` (keeps it only when storage is blocked) and reloads. It asks first + when the reload would lose work registered with `registerUnsavedWork`: + unsaved keys or CCTV calibration, Draw marks, imported map files, the open + cockpit, a voice session. +- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`) + extracts every message and fails on missing, empty, unused, + placeholder-mismatched, still-English (plural forms included) or + wrongly-shaped plural entries. +- **Not translated by design:** layer ids, share-link and hash parameters, API + fields, voice tool schemas, provider names, feed data (callsigns, vessel and + station names) and attribution. +- **Unmarked text.** The same check lists UI text that is not marked at all + (markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`) + and fails on it. Text kept on purpose says why: `translate="no"`, + `data-i18n-runtime`, or `// i18n-ignore: `. `src/tools/` is skipped: + it writes for AI clients, not the page. + ## Canonical Docs Order Use docs in this order when details conflict: @@ -3254,7 +3326,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | 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 | @@ -3670,8 +3742,11 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes +gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, and nine in +Ontario — Toronto (TTC surface vehicles; the subway publishes no +positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, +Burlington, Barrie and Kingston. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and @@ -4204,6 +4279,14 @@ 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 fills the FOV, and only when the source or seed states none and the pose is not + curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. 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 @@ -5362,6 +5445,22 @@ Local FM and internet radio are one listening surface: starting local FM stops internet-radio playback, and starting internet radio stops local FM. ADS-B reception does not interrupt internet radio. +- **FM seek.** Seeking wraps across 87.5–108 MHz, covers both band edges and + skips the starting station. Spectra carry a decoder epoch, so a retune or mode + change rejects earlier output. A failed scan retune stops seeking. This + integrates Aditya's (@Rohilalala) contribution in #1174; it does not change + the peak-selection algorithm or guarantee that a final station retune succeeds. +- **Streaming and playback.** Four ordered USB reads keep input available while + the page handles other work; control changes drain those reads before touching + the receiver. The worker must finish loading before reads start. Decoder work + is limited to eight unacknowledged blocks / 512 KiB, with a one-second oldest + block watchdog. Overload stops the receiver with an explicit error. Stop + terminates the worker immediately and disables the audio session; stale decoder + output and audio from retired sessions are refused. FM audio starts after an + 80 ms prebuffer and retains at most 256 ms, dropping the oldest frames on + overflow. FM signal display updates are coalesced to at most four per second; + spectra are computed only while seeking. Optional `decoderDiagnostics` adds + bounded timing samples and overload snapshots without recording audio or IQ. - **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz. With a receiver open, selecting ADS-B enables the Local ADS-B layer; selecting FM disables it. diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 000000000..9b5220917 --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,92 @@ +# UI text and translation + +Every piece of text a user can see goes through `t()`, `tn()` or `msg()` in code, or a `data-i18n*` attribute in markup. The English text itself is the key. English needs no catalog; other languages live in `src/i18n/locales/.json`. Translators work from [TRANSLATORS.md](./TRANSLATORS.md). + +## Which one to use + +| | Translates | Use for | +| ------------------------------- | --------------------------------- | --------------------------------------------- | +| `t(text, values)` | now | any text shown right where it is written | +| `tn(count, one, other, values)` | now, picking the form for `count` | text that changes with a number | +| `msg(text)` | never: returns `text` unchanged | text stored in data and passed to `t()` later | + +```js +import { t, tn, msg } from '../i18n/index.js'; + +status.textContent = t('Save keys'); +status.textContent = t('Saved to {store}', { store: storeLabel() }); + +chip.textContent = tn(missing, '{count} KEY WAITING', '{count} KEYS WAITING'); +// en: 1 KEY / 3 KEYS ru: 1 КЛЮЧ / 3 КЛЮЧА / 5 КЛЮЧЕЙ + +const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') }; +label.textContent = t(FEED_STATE_LABELS[state]); +``` + +A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.) + +`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English. + +## Markup + +```html +Normal + + +``` + +`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading. + +## Rules + +- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`. +- **Use placeholders, not concatenation.** `t('Flying to {place}…', { place })`, not `t('Flying to') + ' ' + place`. Word order differs between languages. +- **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms. +- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them. +- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering. +- **Numbers and dates** go through `formatNumber`, `formatDate`, `formatRelativeTime` and `formatUtcDayMonth` from `src/i18n/index.js`, which follow the active language. +- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same. +- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped. + +## After you add or change text + +```sh +npm run i18n # what each language lacks or no longer uses, and unmarked UI text +npm run i18n -- --missing ru # the missing ru entries as JSON +npm run i18n -- --prune # drop entries whose English no longer exists +``` + +Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete. + +To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner). + +An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`. + +## Text the check reports as unmarked + +`npm run i18n` and `npm test` fail on UI text that is not marked: + +- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`. + +Fix it by marking the text. When the text must stay as it is, say so where it lives: + +| Case | How | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML | +| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element | +| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: ` on the line or the line above | + +Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page. + +## Where things live + +| File | What it holds | +| ------------------------------- | ------------------------------------------------------------------------------ | +| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | +| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) | +| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | +| `src/i18n/locales/.json` | one catalog per language, keyed by English text | +| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | +| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text | +| `src/i18n/catalog.test.mjs` | runs that check under `npm test` | diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index e80e4bb99..ec6cd026d 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -49,7 +49,8 @@ failure elsewhere. These questions guide review, not a mandatory questionnaire. regressions? Prefer fixes whose tests fail on the base. Test-only PRs should close a real coverage gap with deterministic fixtures and useful assertions. Inspect removed/skipped tests, weakened assertions, mocks, snapshots, and runner - changes. Update `CURRENT-STATE.md` and `CHANGELOG.md` for runtime changes, and + changes. Update `CURRENT-STATE.md` and add a `changelog.d/-.md` + fragment (not an edit to `CHANGELOG.md`) for runtime changes, and `DATA_SOURCES.md` for source changes. ## 3. Choose execution proportionate to risk @@ -124,6 +125,15 @@ Condition the merge on the reviewed head where supported; if revisions change, return to validation. Verify the merged tree matches the validated candidate and record the merge commit/PR URL; stop on mismatch. +Pending release notes live in `changelog.d/`, one file per PR, so integration never +resolves the top of `CHANGELOG.md`. At release, `npm run changelog:check` previews +the batch and its order; `npm run changelog` splices it into `CHANGELOG.md` and +empties the directory. The assembler refuses a malformed filename or two fragments +claiming one PR number before writing, and copies text through unchanged so the +contributor's wording and credit ship. The write is staged in +`changelog.d/.assembling/`, so if it fails part-way, rerunning `npm run changelog` +finishes or rolls back the batch. It never duplicates or drops an entry. + Return this concise record privately unless posting is authorized. Keep sensitive security evidence in the private report: diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md new file mode 100644 index 000000000..2131ab7a3 --- /dev/null +++ b/docs/TRANSLATORS.md @@ -0,0 +1,66 @@ +# Translating God's Eye View + +Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`). + +## How it works + +The English text is the key: code calls `t('Save keys')`, and markup marks elements with `data-i18n`. How developers mark text is in [I18N.md](./I18N.md). + +Every other language is one flat JSON file, `src/i18n/locales/.json`, keyed by the English text: + +```json +{ + "Save keys": "Сохранить ключи", + "{count} cameras": { + "one": "{count} камера", + "few": "{count} камеры", + "many": "{count} камер", + "other": "{count} камеры" + } +} +``` + +A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English. + +The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page. + +## The check + +```sh +npm run i18n # what is missing, unused or broken, per language +npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate +npm run i18n -- --prune # drop entries the code no longer uses +``` + +`npm test` runs the same check. It fails on a missing, empty or unused entry, on placeholders that differ from the English, on a missing plural form, and on text or plural forms still in English. The `--missing` template fails until every entry is translated. If an entry is right in English (a code such as CRT, a product name), add it under your language code in `src/i18n/same-as-english.json`. + +Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune. + +## Fixing a translation + +Edit `src/i18n/locales/.json` and run `npm run i18n`. To see your change, open the app with `?lang=`. + +## Adding a language + +1. Create `src/i18n/locales/.json`, fill it from `npm run i18n -- --missing ` (start from `{}`), and translate every entry. +2. Import it in `src/i18n/index.js` and add it to `CATALOGS`. +3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`. +4. Add `src/i18n/locales/.json` next to `ru.json` in `scripts/package-boundaries.json`. +5. Write `docs/translations/.md`: glossary, plural forms, units and style (copy the structure of `ru.md`). +6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths. + +## Rules + +- Keep every `{placeholder}` name exactly as in the English: do not rename, drop or add one. Its position inside the sentence follows the grammar of your language, e.g. `"{accepted} of {raw} records accepted"` → `"принято записей: {accepted} из {raw}"`. +- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line. +- Keep the separators `·`, `—`, `→`, `↗`, `…`. +- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9). +- Units follow the language's convention; see the language notes. + +## Never translated + +These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content. + +## Language notes + +Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/es.md b/docs/translations/es.md new file mode 100644 index 000000000..22a827a64 --- /dev/null +++ b/docs/translations/es.md @@ -0,0 +1,75 @@ +# Spanish (`es`) + +Catalog: `src/i18n/locales/es.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +## Style + +- Neutral Spanish that reads naturally in Spain and Latin America; avoid regionalisms. +- Address the user with informal "tú" imperatives: «Añade», «Activa», «Elige», «Pega», «Inténtalo de nuevo». Tooltips that name an action may use the infinitive: «Expandir el panel», «Restablecer la cámara». +- Sentence case, not Title Case: "Live Flights" → «Vuelos en vivo». UPPERCASE English stays UPPERCASE: «EN VIVO», «SIN DATOS». +- Quotes inside Spanish text are straight double quotes: «¿Eliminar el plano "{shot}"?». Questions and exclamations open with ¿ and ¡. +- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., SIG., VEL., DIST., SINCR., cám., cob. +- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE and ALT stay in English everywhere, as in cockpit avionics. Words used in a sentence are translated: heading → «rumbo», range → «alcance». +- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, H, 1–9 («Mantén pulsado Space para hablar»). +- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KN, FT, MIN, H. "m" is always metres, so minutes are «min» («hace 5 min»). MW stays MW. `°` and `%` stay. +- "ago" goes before the time: «hace {count} min». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «hace» again. +- Spelling: «costo» (not «coste»). + +## Plurals + +A `tn()` entry needs three forms. `Intl.PluralRules('es')` picks: + +| Form | Counts | Example | +| ------- | -------------------------------------- | -------------------- | +| `one` | 1 | 1 cámara | +| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de cámaras | +| `other` | everything else: 0, 2–999 999, 1.5… | 3 cámaras | + +The `many` form is the `other` text with «de» after the number: «{count} de cámaras cargadas», «{count} DE CLAVES EN ESPERA». + +## Glossary + +| English | Español | +| --------------------------------------------- | ----------------------------------------------------------------- | +| layer / data layer | capa / capa de datos | +| feed / source / data source | fuente / fuente / fuente de datos | +| LIVE (status) / live data | EN VIVO / datos en vivo | +| SIMULATED | SIMULADO | +| stale / degraded / unavailable | desactualizado / degradado / no disponible | +| retry / retrying | reintento / reintentando | +| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO | +| Provider Settings | Ajustes de proveedores | +| ON / OFF (state chips) | ACTIVO / INACTIVO (short enough for the layer pill) | +| enable / disable | activar / desactivar | +| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas | +| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares | +| vessel, ship | buque | +| satellite / shell (Starlink) / DENSE | satélite / capa / DENSO | +| launch (rocket) / launch site / space mission | lanzamiento / lugar de lanzamiento / misión espacial | +| ascent / replay / payload / stage | ascenso / repetición / carga útil / etapa | +| contact / tracking | contacto / rastreo | +| installation / site | instalación / sitio | +| mapped / observed | mapeado / observado | +| camera / CCTV camera | cámara / cámara CCTV | +| earthquake / active fire | terremoto / incendio activo | +| radio station | emisora | +| road / flow (traffic) | vía / flujo | +| scene / shot / scene recipe | escena / plano / receta de escena | +| briefing / panel / dock | información / panel / dock | +| cockpit | cabina | +| preset / visual preset | preajuste / preajuste visual | +| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura | +| feather / fade | difuminado / desvanecimiento | +| detection overlay | superposición de detección | +| pin (Draw tool) | marcador | +| Bloom / Glow (visual effect) | resplandor | +| Cyber (HUD layout, effect) | Ciber | +| heading, course / altitude / ground speed | rumbo / altitud / velocidad respecto al suelo (short: VEL. SUELO) | +| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) | +| PREV / NEXT | ANT. / SIG. | +| Previous / Next (button text) | Anterior / Siguiente | +| L / R (relative bearing) | I / D | +| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTICIAS / LOCAL | +| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. | +| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO | +| click / click to … | haz clic / clic para … | diff --git a/docs/translations/ru.md b/docs/translations/ru.md new file mode 100644 index 000000000..a44935b71 --- /dev/null +++ b/docs/translations/ru.md @@ -0,0 +1,65 @@ +# Russian (`ru`) + +Catalog: `src/i18n/locales/ru.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +## Style + +- Address the user with polite imperatives: «Нажмите», «Включите», «Попробуйте снова». +- Sentence case, not Title Case: "Live Flights" → «Рейсы в реальном времени». +- Quotes inside Russian text are «ёлочки». Write ё where it belongs: её, ещё, всё. +- In short labels, a standard abbreviation with a period beats a wrapped line: НЕОПРЕД., СЛЕД. +- Units: км, м, км/ч, м/с, уз (knots), фт (feet), ч, мин, с; in UPPERCASE text КМ, М, КМ/С, УЗ, ФТ. MW is МВт in any case. `°` and `%` stay. + +## Plurals + +A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks: + +| Form | Counts | Example | +| ------- | ------------------------ | ---------- | +| `one` | 1, 21, 31, 101… | 1 камера | +| `few` | 2–4, 22–24, 32–34… | 3 камеры | +| `many` | 0, 5–20, 25–30, 111–114… | 5 камер | +| `other` | fractions: 1.5, 2.25… | 1,5 камеры | + +When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}». + +## Glossary + +| English | Русский | +| ------------------------------------------- | ------------------------------------------------------------------ | +| layer / data layer | слой / слой данных | +| feed | поток данных (short: поток) | +| source / data source | источник / источник данных | +| LIVE (status) / live data | ОНЛАЙН / в реальном времени | +| SIMULATED | СИМУЛЯЦИЯ | +| stale / degraded / unavailable | устарел / с перебоями / недоступен | +| retry | повтор | +| key (API key) / POWER UP | ключ / ЗАПУСК | +| Provider Settings | Настройки провайдеров | +| globe / map / map source | глобус / карта / источник карты | +| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы | +| vessel, ship / vessels | судно / суда | +| satellite | спутник | +| launch (rocket) / space mission | пуск / космическая миссия | +| contact / tracking | контакт / отслеживание | +| installation, site | объект | +| camera / CCTV camera | камера / камера CCTV | +| earthquake / active fire | землетрясение / активный пожар | +| scene / shot / scene recipe | сцена / кадр / рецепт сцены | +| briefing / panel / dock | брифинг / панель / док | +| preset / visual preset | пресет / визуальный пресет | +| Scope (circular viewport mask) | прицел | +| heading / altitude / ground speed | курс / высота / путевая скорость | +| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. | +| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке | +| Street View (Google) | Просмотр улиц (как в русских Google Картах) | +| CONUS (NOAA region code) | CONUS, без перевода | +| WHOT/BHOT, Ironbow (thermal modes, palette) | без перевода | +| PREV / NEXT | ПРЕД. / СЛЕД. | +| Previous / Next (button text) | Назад / Далее | +| anchor / data pack / bundle (scene) | якорь / пакет данных / архив | +| basemap / box (Recent Imagery area) | подложка / область | +| camera move | перелёт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. | +| compass points (N, NNE, …) | С, ССВ, … | +| L / R (relative bearing) | Л / П | diff --git a/index.html b/index.html index 78d6e34ec..8a01415b6 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,20 @@ God's Eye View + + + + + + + + + + diff --git a/package.json b/package.json index c46fa6670..99e35c0c5 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,11 @@ "doctor": "node scripts/setup-doctor.mjs", "format": "node scripts/format.mjs --write", "format:check": "node scripts/format.mjs --check", + "i18n": "node scripts/i18n.mjs", "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/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 000000000..5655a9e70 Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 000000000..7aed57138 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-32.png b/public/icons/icon-32.png new file mode 100644 index 000000000..a19279f06 Binary files /dev/null and b/public/icons/icon-32.png differ diff --git a/public/icons/icon-512-maskable.png b/public/icons/icon-512-maskable.png new file mode 100644 index 000000000..0b3064f39 Binary files /dev/null and b/public/icons/icon-512-maskable.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 000000000..644a5c8ea Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 000000000..478cd9019 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "id": ".", + "name": "God's Eye View", + "short_name": "God's Eye", + "description": "Live spatial intelligence in the browser.", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0a0f", + "theme_color": "#0a0a0f", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs new file mode 100644 index 000000000..430fca77a --- /dev/null +++ b/scripts/assemble-changelog.mjs @@ -0,0 +1,204 @@ +import * as fs 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. + * + * A write is staged so that a failure at any point leaves a rerun unable to + * duplicate or lose an entry. The assembled file is written into + * `changelog.d/.assembling/` first, the fragments are renamed in beside it, and + * renaming the assembled file over `CHANGELOG.md` is the single commit point. + * While the assembled file is still staged the batch has not landed, so + * recovery moves the fragments back; once it is gone the batch has landed, and + * the staged fragments are leftovers to delete. Either way they are out of the + * input directory, so they are never assembled twice. + */ +export const FRAGMENT_DIR = 'changelog.d'; +export const STAGING_DIR = '.assembling'; +const STAGED_CHANGELOG = 'CHANGELOG.md'; +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}`; +} + +/** + * Finish or roll back a batch an earlier `--write` left in the staging + * directory. A no-op when there is none. + * + * @returns {Promise<'none'|'rolled-back'|'cleaned-up'>} + */ +export async function recoverInterruptedBatch(root, io = fs) { + const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); + let staged; + try { + staged = await io.readdir(staging); + } catch (error) { + if (error.code === 'ENOENT') return 'none'; + throw error; + } + if (!staged.includes(STAGED_CHANGELOG)) { + // The assembled file was renamed over CHANGELOG.md: the batch landed. + await io.rm(staging, { recursive: true, force: true }); + return 'cleaned-up'; + } + // The batch never reached CHANGELOG.md, so every fragment goes back. + const pending = new Set(await io.readdir(directory)); + const fragments = staged.filter((name) => name !== STAGED_CHANGELOG); + const clash = fragments.filter((name) => pending.has(name)); + if (clash.length) { + throw new Error( + `Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` + + ` also exist in ${FRAGMENT_DIR}/ — resolve by hand`, + ); + } + for (const name of fragments) { + await io.rename(path.join(staging, name), path.join(directory, name)); + } + await io.rm(staging, { recursive: true, force: true }); + return 'rolled-back'; +} + +/** + * 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. + * `--write` first recovers any batch an interrupted run left staged. + * + * @param {string} root repository root + * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @param {typeof fs} [io] filesystem calls, replaceable for failure injection + * @returns {Promise<{ consumed: string[], changelog: string }>} + */ +export async function assembleFragments(root, mode, io = fs) { + if (!['--check', '--write'].includes(mode)) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); + const changelogFile = path.join(root, 'CHANGELOG.md'); + if (mode === '--write') { + await recoverInterruptedBatch(root, io); + } + const names = await io.readdir(directory); + if (names.includes(STAGING_DIR)) { + throw new Error( + `${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` + + ' left behind — `npm run changelog` finishes or rolls it back', + ); + } + const consumed = orderFragmentNames(names); + const bodies = []; + for (const name of consumed) { + bodies.push(await io.readFile(path.join(directory, name), 'utf8')); + } + const changelog = assembleChangelog( + await io.readFile(changelogFile, 'utf8'), + bodies, + ); + if (mode === '--write' && consumed.length) { + try { + await io.mkdir(staging); + await io.writeFile( + path.join(staging, STAGED_CHANGELOG), + changelog, + 'utf8', + ); + for (const name of consumed) { + await io.rename(path.join(directory, name), path.join(staging, name)); + } + await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile); + await io.rm(staging, { recursive: true, force: true }); + } catch (error) { + try { + await recoverInterruptedBatch(root, io); + } catch { + throw new Error( + `${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` + + ' and the next `npm run changelog` finishes or rolls it back', + { cause: error }, + ); + } + throw error; + } + } + 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 9e7f7da80..8bcc4c04d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -873,9 +873,13 @@ "src/layers/weather/imageryHost.test.mjs", "src/sdr/adsbDecoder.test.mjs", "src/sdr/controller.test.mjs", + "src/sdr/decoderDiagnostics.test.mjs", "src/sdr/gain.test.mjs", + "src/sdr/readPipeline.diag.test.mjs", + "src/sdr/sdrAudioWorklet.test.mjs", "src/sdr/spectrum.test.mjs", "src/sdr/usbDevices.test.mjs", + "src/sdr/workerAdmission.test.mjs", "src/sources/adsbRecords.test.mjs", "src/layers/localAdsb/index.test.mjs", "src/ui/localSdrControls.test.mjs", @@ -1051,6 +1055,11 @@ "src/costEndpointThrottle.test.mjs", "src/sources/gbfsStations.test.mjs", "scripts/qa-gbfs-names.mjs", + "scripts/generate-icons.mjs", + "src/tooling/webAppIcons.test.mjs", "src/ui/flyToDataSource.js", - "src/ui/flyToDataSource.test.mjs" + "src/ui/flyToDataSource.test.mjs", + "scripts/assemble-changelog.mjs", + "src/tooling/changelogFragments.test.mjs", + "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 000000000..3c16eb46e --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Renders the home-screen and favicon rasters from `public/logo.svg`. +// +// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output +// is committed, because the install icons have to exist in a plain `vite build` +// and contributors should not need an image toolchain to ship the app. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const REPO_ROOT = path.resolve( + fileURLToPath(new URL('.', import.meta.url)), + '..', +); +const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg'); +const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons'); + +/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */ +const BACKGROUND = '#0a0a0f'; + +/** + * Fraction of the canvas the mark is allowed to occupy. + * + * A maskable icon may be cropped to a circle inscribed in the middle 80% of the + * square, so its mark stays well inside that circle; the other icons are shown + * as drawn and only need optical breathing room. + */ +const INSET = { normal: 0.78, maskable: 0.54 }; + +const TARGETS = [ + { file: 'icon-32.png', size: 32, inset: INSET.normal }, + { file: 'icon-180.png', size: 180, inset: INSET.normal }, + { file: 'icon-192.png', size: 192, inset: INSET.normal }, + { file: 'icon-512.png', size: 512, inset: INSET.normal }, + { file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable }, +]; + +/** Fit the logo inside `size * inset` and centre it on an opaque square. */ +async function render(svg, { size, inset }) { + const box = Math.round(size * inset); + // The logo's viewBox is wider than it is tall, so `contain` bounds it by + // width and leaves the vertical padding uneven — centring is done by the + // composite below rather than by the resize. + const mark = await sharp(svg, { density: 384 }) + .resize(box, box, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toBuffer(); + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: BACKGROUND, + }, + }) + .composite([{ input: mark, gravity: 'centre' }]) + .png({ compressionLevel: 9 }) + .toBuffer(); +} + +const svg = await readFile(LOGO); +await mkdir(OUT_DIR, { recursive: true }); +for (const target of TARGETS) { + await writeFile(path.join(OUT_DIR, target.file), await render(svg, target)); + console.log(`icons: wrote ${target.file} (${target.size}px)`); +} diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs new file mode 100644 index 000000000..0ee5d60ec --- /dev/null +++ b/scripts/i18n.mjs @@ -0,0 +1,623 @@ +// Keeps the locale catalogs in step with the code. +// +// npm run i18n check every catalog and find unmarked UI text +// npm run i18n -- --prune also drop entries the code no longer uses +// npm run i18n -- --missing ru print what ru lacks, as JSON to translate +// +// A translation left as the English fails unless src/i18n/same-as-english.json +// lists it. +// +// Messages are the English literals passed to t(), tn() and msg() in src/, +// plus the markup opted in with data-i18n* in src/ui/templates and index.html. +// It also lists UI text that is not marked at all (see findUnmarked). +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales'); +const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json'); + +const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``; +const CALL = new RegExp( + String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`, + 'g', +); + +function unquote(literal) { + const body = literal.slice(1, -1); + return body.replace( + /\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs, + (_, esc) => { + if (esc[0] === 'u' || esc[0] === 'x') { + return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16)); + } + return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc; + }, + ); +} + +const ENTITIES = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}; +function decodeHtml(text) { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => { + if (name[0] === '#') { + const hex = name[1] === 'x' || name[1] === 'X'; + return String.fromCodePoint( + parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10), + ); + } + return ENTITIES[name.toLowerCase()] ?? all; + }); +} + +const normalize = (text) => text.replace(/\s+/g, ' ').trim(); + +function trackedFiles(...pathspecs) { + return ( + execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + ...pathspecs, + ], + { + cwd: ROOT, + encoding: 'utf8', + }, + ) + .split('\n') + .filter(Boolean) + // The index still lists a file moved or deleted without git mv / git rm. + .filter((file) => existsSync(path.join(ROOT, file))) + ); +} + +/** + * Every message the code can ask for. + * @returns {Map} + */ +export function extractMessages() { + const messages = new Map(); + const add = (text, where, plural = null) => { + if (!text) return; + const known = messages.get(text); + if (known && Boolean(known.plural) !== Boolean(plural)) { + throw new Error( + `${where}: "${text}" is used both as plural and plain text (also ${known.where})`, + ); + } + if (!known) + messages.set(text, { plural: Boolean(plural), one: plural?.one, where }); + }; + + const sources = sourceFiles(); + for (const file of sources) { + const source = readFileSync(path.join(ROOT, file), 'utf8'); + for (const match of source.matchAll(CALL)) { + const line = source.slice(0, match.index).split('\n').length; + const where = `${file}:${line}`; + if (match[1]) { + add(`${unquote(match[2])}|${unquote(match[1])}`, where); + } else if (match[4]) { + if (match[4][0] === '`' && match[4].includes('${')) continue; + add(unquote(match[4]), where); + } else { + add(unquote(match[6]), where, { one: unquote(match[5]) }); + } + } + } + + const markup = trackedFiles('index.html', 'src/ui/templates/*.html'); + const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs; + for (const file of markup) { + const html = readFileSync(path.join(ROOT, file), 'utf8'); + for (const [, tag, attrs, text] of html.matchAll(TAG)) { + const where = `${file} <${tag}>`; + if (/\sdata-i18n(\s|=|$)/.test(attrs)) + add(normalize(decodeHtml(text)), where); + for (const [, name] of attrs.matchAll( + /\sdata-i18n-(title|aria-label|placeholder)\b/g, + )) { + const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`)); + if (value) add(decodeHtml(value[1]), where); + else + throw new Error( + `${where}: data-i18n-${name} without a ${name} attribute`, + ); + } + } + } + return messages; +} + +const hasWords = (text) => /[A-Za-z]{2,}/.test(text); +const lineAt = (source, index) => source.slice(0, index).split('\n').length; + +const VOID_TAGS = new Set( + 'area base br col embed hr img input link meta source track wbr'.split(' '), +); +const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']); + +/** + * User-visible markup text without data-i18n*. Skipped, with everything + * inside: translate="no" (text that stays as is, as in HTML), + * data-i18n-runtime (code sets this text through t()), and icons + * (aria-hidden, Material Symbols). + */ +export function unmarkedMarkup(file, html) { + const found = []; + const source = html.replace(/|]*>/g, (c) => + c.replace(/[^\n]/g, ' '), + ); + const TOKEN = + /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g; + const stack = []; + const skipping = () => stack.at(-1)?.skip === true; + for (const match of source.matchAll(TOKEN)) { + const [, closing, rawTag, attrs = '', selfClosing, text] = match; + if (text !== undefined) { + const top = stack.at(-1); + const value = normalize(decodeHtml(text)); + if (!skipping() && hasWords(value) && !top?.marked) + found.push({ + where: `${file}:${lineAt(source, match.index)}`, + text: value, + }); + continue; + } + const tag = rawTag.toLowerCase(); + if (closing) { + const at = stack.map((entry) => entry.tag).lastIndexOf(tag); + if (at >= 0) stack.length = at; + continue; + } + const skip = + skipping() || + SKIP_TAGS.has(tag) || + /\stranslate="no"/.test(attrs) || + /\sdata-i18n-runtime\b/.test(attrs) || + /\saria-hidden="true"/.test(attrs) || + /class="[^"]*material-symbols/.test(attrs); + if (!skip) { + for (const [, name, value] of attrs.matchAll( + /\s(title|aria-label|placeholder)="([^"]*)"/g, + )) { + if ( + hasWords(value) && + !new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs) + ) + found.push({ + where: `${file}:${lineAt(source, match.index)}`, + text: `${name}="${decodeHtml(value)}"`, + }); + } + } + if (!VOID_TAGS.has(tag) && !selfClosing) + stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) }); + } + return found; +} + +// Where a string becomes UI text. Each pattern ends where the value starts. +const JS_SINKS = [ + /\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g, + /\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g, + /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, + /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, + // Also showToast?.(…) and this._showToast(…). + /\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g, + // `status` values are often codes ('ok', 'auth-failed'); visibleText drops + // those, so only readable text is reported. A name right after `?` is a + // ternary branch (`ok ? status : 'Fallback'`), not a property. + /(?:^|[{,\s])(?])\s*/g; +JS_SINKS.push(ASSIGNMENT_SINK); + +/** + * Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`, + * `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its + * value ends at `,` or a closing bracket. An assignment statement ends at `;`. + */ +function defaultValue(source, nameStart, valueStart) { + const before = source.slice(0, nameStart).trimEnd().at(-1); + if (!before || !'([{,'.includes(before)) return false; + const end = valueStart + expressionAt(source, valueStart).length; + const after = source.slice(end).trimStart()[0]; + return Boolean(after) && ',)]}'.includes(after); +} +const MARKERS = /\b(?:t|tn|msg)\(/y; + +/** The expression starting at `start`, up to its end at bracket depth 0. */ +function expressionAt(source, start) { + let depth = 0; + let i = start; + for (; i < source.length; i++) { + const c = source[i]; + if (c === "'" || c === '"' || c === '`') { + i = skipString(source, i); + continue; + } + if ('([{'.includes(c)) depth++; + else if (')]}'.includes(c)) { + if (depth === 0) break; + depth--; + } else if (depth === 0 && (c === ';' || c === ',')) break; + else if (depth === 0 && c === '\n') { + const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? ''; + const before = source.slice(start, i).trimEnd().at(-1) ?? ''; + if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before)) + break; + } + } + return source.slice(start, i); +} + +function skipString(source, i) { + const quote = source[i]; + for (let j = i + 1; j < source.length; j++) { + if (source[j] === '\\') j++; + else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') { + let depth = 1; + for (j += 2; j < source.length && depth; j++) { + if (source[j] === '{') depth++; + else if (source[j] === '}') depth--; + else if ('\'"`'.includes(source[j])) j = skipString(source, j); + } + j--; + } else if (source[j] === quote) return j; + else if (quote !== '`' && source[j] === '\n') return j; + } + return source.length; +} + +/** String literals that sit in value position: not inside any call or index, + * not compared, and not already passed to t/tn/msg. */ +function valueLiterals(expression) { + const literals = []; + let callDepth = 0; + const stack = []; + for (let i = 0; i < expression.length; i++) { + const c = expression[i]; + if (c === '(' || c === '[') { + const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i)); + stack.push(isCall); + if (isCall) callDepth++; + continue; + } + if (c === ')' || c === ']') { + if (stack.pop()) callDepth--; + continue; + } + MARKERS.lastIndex = i; + if (MARKERS.test(expression)) { + stack.push(true); + callDepth++; + i = MARKERS.lastIndex - 1; + continue; + } + if (c !== "'" && c !== '"' && c !== '`') continue; + const end = skipString(expression, i); + const literal = expression.slice(i, end + 1); + const before = expression.slice(0, i).trimEnd(); + const after = expression.slice(end + 1).trimStart(); + const compared = + /(===|!==|==|!=|\bcase|\bin)$/.test(before) || + /^(===|!==|==|!=|in\b)/.test(after); + if (callDepth === 0 && !compared) literals.push(literal); + i = end; + } + return literals; +} + +/** The text a literal shows: static template parts, HTML tags removed. */ +function visibleText(literal, html) { + let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' '); + if (html) { + const attrs = [ + ...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g), + ].map((m) => m[1]); + body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' ')); + } + const text = normalize(body); + if (!hasWords(text)) return null; + if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS + if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts + if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null; + return text; +} + +/** Blank out comments, keeping strings and line numbers intact. */ +function withoutComments(source) { + let out = ''; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + if (c === "'" || c === '"' || c === '`') { + const end = skipString(source, i); + out += source.slice(i, end + 1); + i = end; + } else if (c === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i); + const stop = end < 0 ? source.length : end; + out += ' '.repeat(stop - i); + i = stop - 1; + } else if (c === '/' && source[i + 1] === '*') { + const end = source.indexOf('*/', i + 2); + const stop = end < 0 ? source.length : end + 2; + out += source.slice(i, stop).replace(/[^\n]/g, ' '); + i = stop - 1; + } else out += c; + } + return out; +} + +export function unmarkedScript(file, original) { + const found = []; + const lines = original.split('\n'); + const source = withoutComments(original); + const ignored = (line) => + /i18n-ignore/.test(lines[line - 1] ?? '') || + /i18n-ignore/.test(lines[line - 2] ?? ''); + for (const sink of JS_SINKS) { + sink.lastIndex = 0; + for (const match of source.matchAll(sink)) { + const start = match.index + match[0].length; + const line = lineAt(source, start); + if (ignored(line)) continue; + if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start)) + continue; + const html = /HTML/.test(match[0]); + for (const literal of valueLiterals(expressionAt(source, start))) { + const text = visibleText(literal, html); + if (text) found.push({ where: `${file}:${line}`, text }); + } + } + } + return found; +} + +/** + * UI text that is not marked for translation: markup without data-i18n* and + * string literals that reach the page without t(), tn() or msg(). + * Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line. + * @returns {{where: string, text: string}[]} + */ +export function findUnmarked() { + const found = []; + for (const file of trackedFiles('index.html', 'src/ui/templates/*.html')) + found.push( + ...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')), + ); + for (const file of sourceFiles().filter( + (name) => !NOT_UI.some((pattern) => pattern.test(name)), + )) + found.push( + ...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')), + ); + const seen = new Set(); + return found.filter(({ where, text }) => { + const key = `${where}\0${text}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// Text that never reaches the page as UI: tool results and descriptions for +// AI clients (MCP and the voice model). +const NOT_UI = [/^src\/tools\//]; + +function sourceFiles() { + return trackedFiles( + 'src/*.js', + 'src/*.mjs', + 'src/**/*.js', + 'src/**/*.mjs', + ).filter( + (file) => + !file.startsWith('src/i18n/') && + !/\.test\.mjs$|\/testSupport\//.test(file), + ); +} + +const placeholders = (text) => + [...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)] + .map((m) => m[1]) + .sort() + .join(','); + +export function catalogPath(locale) { + return path.join(LOCALES_DIR, `${locale}.json`); +} + +export function readCatalog(locale) { + return JSON.parse(readFileSync(catalogPath(locale), 'utf8')); +} + +export function shippedLocales() { + return trackedFiles('src/i18n/locales/*.json').map((file) => + path.basename(file, '.json'), + ); +} + +/** Messages a locale keeps exactly as in English, on purpose. */ +export function readSameAsEnglish(locale) { + const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8')); + return new Set(lists[locale] || []); +} + +/** The English a message shows: its text without the "context|" prefix. */ +const englishOf = (text) => text.slice(text.indexOf('|') + 1); + +/** A translation with nothing to show: t() falls back to English for it. */ +const blank = (value) => value.trim() === ''; + +/** Words to read, beyond {placeholders}, codes and punctuation. */ +const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, '')); + +const STILL_ENGLISH = (text, locale) => + `"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`; + +/** + * Compare one catalog with the extracted messages. + * @param {Set} [sameAsEnglish] Messages allowed to read as English. + * @returns {{missing: string[], stale: string[], errors: string[]}} + */ +export function checkCatalog( + locale, + catalog, + messages, + sameAsEnglish = readSameAsEnglish(locale), +) { + const categories = new Intl.PluralRules(locale).resolvedOptions() + .pluralCategories; + const missing = []; + const errors = []; + for (const [text, info] of messages) { + if (!Object.hasOwn(catalog, text)) { + missing.push(text); + continue; + } + const entry = catalog[text]; + if (info.plural) { + if (!entry || typeof entry !== 'object') { + errors.push(`"${text}" needs plural forms ${categories.join('/')}`); + continue; + } + const english = new Set([info.one, englishOf(text)]); + const stillEnglish = []; + for (const category of categories) { + const form = entry[category]; + if (typeof form !== 'string') + errors.push(`"${text}" lacks the "${category}" form`); + else if (blank(form)) + errors.push(`"${text}" [${category}] is empty: translate it`); + else if (placeholders(form) !== placeholders(text)) + errors.push(`"${text}" [${category}] placeholders differ: ${form}`); + else if (english.has(form) && hasLetters(form)) + stillEnglish.push(category); + } + if (stillEnglish.length && !sameAsEnglish.has(text)) + errors.push( + `${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`, + ); + } else if (typeof entry !== 'string') { + errors.push(`"${text}" must be a string`); + } else if (blank(entry)) { + errors.push(`"${text}" is empty: translate it`); + } else if (placeholders(entry) !== placeholders(text)) { + errors.push(`"${text}" placeholders differ: ${entry}`); + } else if ( + entry === englishOf(text) && + hasLetters(entry) && + !sameAsEnglish.has(text) + ) { + errors.push(STILL_ENGLISH(text, locale)); + } + } + const stale = Object.keys(catalog).filter((text) => !messages.has(text)); + return { missing, stale, errors }; +} + +/** + * What `--missing ` prints: every missing entry, ready to translate. + * A template, not a translation: the check rejects a string left empty and a + * plural form left as the English. + */ +export function missingTemplate(locale, catalog, messages) { + const categories = new Intl.PluralRules(locale).resolvedOptions() + .pluralCategories; + const out = {}; + for (const text of checkCatalog(locale, catalog, messages, new Set()) + .missing) { + const info = messages.get(text); + out[text] = info.plural + ? Object.fromEntries( + categories.map((category) => [ + category, + category === 'one' ? info.one : text, + ]), + ) + : ''; + } + return out; +} + +function main(args) { + const messages = extractMessages(); + const missingFor = args.includes('--missing') + ? args[args.indexOf('--missing') + 1] + : null; + if (missingFor) { + const out = missingTemplate(missingFor, readCatalog(missingFor), messages); + console.log(JSON.stringify(out, null, 2)); + return 0; + } + let failed = false; + for (const locale of shippedLocales()) { + let catalog = readCatalog(locale); + const { missing, stale, errors } = checkCatalog(locale, catalog, messages); + if (args.includes('--prune') && stale.length) { + catalog = Object.fromEntries( + Object.entries(catalog).filter(([text]) => messages.has(text)), + ); + writeFileSync( + catalogPath(locale), + `${JSON.stringify(catalog, null, 2)}\n`, + ); + console.log(`${locale}: pruned ${stale.length} unused entries`); + } else if (stale.length) { + failed = true; + console.log( + `${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`, + ); + for (const text of stale) console.log(` ${JSON.stringify(text)}`); + } + if (missing.length) { + failed = true; + console.log( + `${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`, + ); + for (const text of missing) + console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`); + } + for (const error of errors) { + failed = true; + console.log(`${locale}: ${error}`); + } + if (!missing.length && !stale.length && !errors.length) { + console.log(`${locale}: ${messages.size} messages, complete`); + } + } + const unmarked = findUnmarked(); + if (unmarked.length) { + failed = true; + console.log( + `${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`, + ); + for (const { where, text } of unmarked) + console.log(` ${where} ${JSON.stringify(text)}`); + } + return failed ? 1 : 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d0bc8b3f4..a9c49f6c3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -53,7 +53,8 @@ "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": ["ws"] }, @@ -79,6 +80,7 @@ "src/localRequestGate.mjs", "src/data/routeSteps.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js" ], "external": [] @@ -97,7 +99,8 @@ "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -127,6 +130,7 @@ "server/providers/traffic.js", "server/providers/common/http.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/data/tomtomTiles.js" ], "external": [] @@ -148,6 +152,7 @@ "server/providers/firms.js", "server/providers/common/http.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/data/firmsCsv.js" ], "external": [] @@ -158,8 +163,11 @@ "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" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -190,7 +198,8 @@ "scripts/google-server-key.mjs", "src/data/cctvFootprint.js", "src/sources/cctvTypes.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -208,7 +217,8 @@ "server/providers/common/rate-limit.js", "src/sources/rateLimit.js", "server/providers/common/http.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": ["pbf"] }, @@ -224,7 +234,8 @@ "src/data/radioCountry.js", "server/providers/common/http.js", "src/sources/radioBrowser.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -246,6 +257,7 @@ "src/data/placeProviderPayloads.js", "src/data/routeSteps.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/overpass.js", "src/sources/rateLimit.js" ], @@ -266,6 +278,7 @@ "server/providers/overpass/geometry.js", "server/providers/overpass/transport.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js" ], "external": [] @@ -288,6 +301,7 @@ "src/data/regionalModel.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/rateLimit.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", @@ -301,6 +315,7 @@ "runtime": "node", "exports": ["./server/providers/openai"], "modules": [ + "src/i18n/marker.js", "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -333,6 +348,7 @@ "runtime": "node", "exports": ["./server/standalone/key-setup"], "modules": [ + "src/i18n/marker.js", "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", "server/standalone/key-setup.js", @@ -365,6 +381,7 @@ "src/data/local_data/natural_earth/regions.json", "src/data/local_data/natural_earth/marine.json", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js" ], "external": [] @@ -397,7 +414,11 @@ "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", - "src/ui/styleParameters.js" + "src/ui/styleParameters.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, @@ -418,26 +439,37 @@ "src/styles/thermal.js", "src/styles/anime.js", "src/styles/noir.js", - "src/styles/snow.js" + "src/styles/snow.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, "map-source-controls": { "exports": ["./ui/maps"], "modules": [ + "src/i18n/marker.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", "src/keySetupCore.mjs", - "src/localRequestGate.mjs" + "src/localRequestGate.mjs", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, "layer-panel": { "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ + "src/i18n/marker.js", "src/ui/layers.js", "src/ui/layerPanel.js", + "src/ui/layerNames.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -450,7 +482,14 @@ "src/keySetupCore.mjs", "src/localRequestGate.mjs", "src/data/feedState.js", - "src/data/layerSnapshot.js" + "src/data/layerSnapshot.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js", + "src/ui/keyGuidance.js", + "src/ui/statusText.js" ], "external": [] }, @@ -460,8 +499,13 @@ "src/ui/location.js", "src/ui/locationControls.js", "src/ui/locationSearch.js", + "src/ui/placeNames.js", "src/locationStatus.js", - "src/app/stateChannel.js" + "src/app/stateChannel.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, @@ -472,7 +516,11 @@ "src/ui/radioControls.js", "src/ui/radioBindings.js", "src/ui/radioPresentation.js", - "src/ui/radioTunerModel.js" + "src/ui/radioTunerModel.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, @@ -485,13 +533,19 @@ "src/ui/cctvControls.js", "src/ui/cctvFrames.js", "src/ui/cctvVideo.js", - "src/ui/cctvPresentation.js" + "src/ui/cctvPresentation.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": [] }, "context-controls": { "exports": ["./ui/context", "./ui/context/policy"], "modules": [ + "src/i18n/marker.js", "src/ui/context.js", "src/ui/contextActions.js", "src/ui/contextBindings.js", @@ -503,7 +557,12 @@ "src/ui/contextTransactions.js", "src/contextModePolicy.js", "src/rightRailPolicy.js", - "src/ui/panelRailGeometry.js" + "src/ui/panelRailGeometry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/layerNames.js" ], "external": [] }, @@ -531,7 +590,12 @@ "src/cockpitMath.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", - "src/ui/panelRailGeometry.js" + "src/ui/panelRailGeometry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, @@ -540,13 +604,20 @@ "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", - "src/ui/scenePresentation.js" + "src/ui/scenePresentation.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": [] }, "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/i18n/marker.js", + "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", "src/cyberSonar.js", @@ -568,6 +639,7 @@ "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/scenePick.js", "src/keySetupCore.mjs", "src/localRequestGate.mjs", @@ -585,6 +657,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", @@ -679,7 +752,15 @@ "src/ui/localSdrControls.js", "src/layers/localAdsb/feedNames.js", "src/ui/localSdrPresentation.js", - "src/data/cameraArrival.js" + "src/data/cameraArrival.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js", + "src/ui/keyGuidance.js", + "src/ui/layerNames.js", + "src/ui/statusText.js" ], "external": ["cesium"] }, @@ -732,6 +813,7 @@ "src/director/interactions/session.js", "src/director/sharing/bundle.js", "src/director/sharing/preview.js", + "src/i18n/marker.js", "src/director/sharing/lifetime.js", "src/director/authoring.js" ], @@ -746,6 +828,7 @@ "src/data/bhoteKoshiFloodPath.js", "src/maps/catalog.js", "src/scenes/director.js", + "src/scenes/sceneSaveError.js", "src/scenes/playbackAdapter.js", "src/director/playback.js", "src/scenes/recipes.js", @@ -781,17 +864,24 @@ "src/data/inputOwnership.js", "src/director/sharing/bundle.js", "src/director/sharing/preview.js", + "src/i18n/marker.js", "src/director/sharing/lifetime.js", "src/director/authoring.js", "src/scenes/sharing.js", "src/ui/sceneSharing.js", - "src/data/cameraArrival.js" + "src/data/cameraArrival.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/i18n/marker.js", "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -804,6 +894,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", @@ -841,13 +932,19 @@ "src/layers/flights/snapshotRenderer.js", "src/layers/flights/modelSpec.js", "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/data/modelEligibility.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", @@ -881,13 +978,18 @@ "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/data/modelEligibility.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, "aircraft-mechanics": { "exports": ["./aircraft"], "modules": [ + "src/i18n/marker.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -934,7 +1036,11 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -951,13 +1057,20 @@ "recent-imagery": { "exports": ["./layers/recent-imagery"], "modules": [ + "src/i18n/marker.js", "src/layers/recentImagery/index.js", "src/layers/recentImagery/source.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/maps/imageryHost.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js", + "src/layers/recentImagery/display.js" ], "external": ["cesium"] }, @@ -980,7 +1093,11 @@ "src/layers/firms/source.js", "src/layers/firms/state.js", "src/layers/firms/viewport.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1006,7 +1123,12 @@ "src/layers/satellites/state.js", "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] }, @@ -1033,7 +1155,11 @@ "src/layers/launches/source.js", "src/layers/launches/state.js", "src/layers/launches/testing.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1043,6 +1169,7 @@ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", + "src/data/installationNames.js", "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", @@ -1058,6 +1185,7 @@ "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", @@ -1068,17 +1196,24 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/data/infrastructureOverlayEntry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/i18n/marker.js", "src/cyberSonar.js", "src/cockpitMath.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1095,7 +1230,12 @@ "src/layers/awareness/state.js", "src/layers/awareness/subject.js", "src/layers/awareness/testing.js", - "src/ui/panelRailGeometry.js" + "src/ui/panelRailGeometry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, @@ -1132,11 +1272,17 @@ "src/sources/overpassRoads.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js" + "src/layers/traffic/roadModes.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1159,7 +1305,11 @@ "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js", "src/data/inputOwnership.js", - "src/data/cameraSensitivity.js" + "src/data/cameraSensitivity.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1187,6 +1337,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", @@ -1200,7 +1351,11 @@ "src/overlays/worldOverlayTokens.js", "src/data/cctvFootprint.js", "src/data/inputOwnership.js", - "src/layers/cctv/sourcePolicy.js" + "src/layers/cctv/sourcePolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["hls.js", "cesium"] }, @@ -1261,7 +1416,11 @@ "src/layers/streetLevel/lineCoverage.js", "src/layers/streetLevel/sequenceCones.js", "src/layers/streetLevel/providers/mapillary/source.js", - "scripts/qa-street-level.mjs" + "scripts/qa-street-level.mjs", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -1281,9 +1440,14 @@ "src/layers/alpr/tileRecords.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", - "src/layers/alpr/floors.js" + "src/layers/alpr/floors.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1318,7 +1482,11 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/ui/radioTunerModel.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1335,7 +1503,11 @@ "src/layers/submarineCables/rendering.js", "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js", - "src/data/inputOwnership.js" + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1371,6 +1543,7 @@ "./maps/defaults" ], "modules": [ + "src/i18n/marker.js", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", @@ -1384,7 +1557,11 @@ "src/maps/terrainRetry.js", "src/keySetupCore.mjs", "src/localRequestGate.mjs", - "src/maps/imageryHost.js" + "src/maps/imageryHost.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1428,10 +1605,19 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", + "src/cityPois.js", + "src/ui/layerNames.js", + "src/ui/unitLabels.js", + "src/ui/placeNames.js", "src/voice/narration.js", "src/voice/earcon.js", "src/voice/pointerContext.js", - "src/voice/pointerReticle.js" + "src/voice/pointerReticle.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/statusText.js" ], "external": [] }, @@ -1455,6 +1641,7 @@ "./standalone/catalog" ], "modules": [ + "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", "src/hudLayouts.js", @@ -1597,6 +1784,7 @@ "src/data/meshFloorSampler.js", "src/data/militaryAwareness.js", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/militaryFlights.js", "src/data/militaryInstallationData.js", "src/data/militaryInstallations.js", @@ -1643,6 +1831,7 @@ "src/firstRunExperience.js", "src/hud.js", "src/hudLocality.js", + "src/hudLocalityFormat.js", "src/hudSummaryResponse.js", "src/keySetup.js", "src/keySetupCore.mjs", @@ -1702,6 +1891,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", @@ -1868,6 +2058,7 @@ "src/loadingFeedback.js", "src/locationStatus.js", "src/locations.js", + "src/cityPois.js", "src/logoGaze.js", "src/mapStackChips.js", "src/mapStackController.js", @@ -1892,6 +2083,7 @@ "src/renderGovernor.js", "src/rightRailPolicy.js", "src/scenes/director.js", + "src/scenes/sceneSaveError.js", "src/scenes/playbackAdapter.js", "src/director/playback.js", "src/scenes/recipes.js", @@ -1924,6 +2116,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", @@ -2010,6 +2203,7 @@ "src/voice/control.js", "src/voice/gevActions.js", "src/voice/speech.js", + "src/ui/layerNames.js", "src/voice/earcon.js", "src/voice/narration.js", "src/voice/turnMetrics.js", @@ -2043,6 +2237,7 @@ "src/ui/geoFileImport.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/data/directions.js", "src/app/layers/directions.js", @@ -2073,6 +2268,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transit.js", @@ -2266,13 +2462,22 @@ "src/voice/actionSchemas.js", "src/view/index.js", "src/app/embed.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js", + "src/layers/recentImagery/display.js", + "src/ui/keyGuidance.js", + "src/layers/installations/classLabel.js", "src/data/cameraFraming.js", "src/data/geoImportPins.js", "src/data/geoImportImages.js", "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/ui/statusText.js" ], "external": [ "hls.js", @@ -2378,6 +2583,7 @@ "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", "src/data/militaryAwarenessEngine.js", + "src/data/installationNames.js", "src/data/militaryInstallationData.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", @@ -2463,6 +2669,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", @@ -2626,6 +2833,7 @@ "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", "src/locations.js", + "src/cityPois.js", "src/navigationPolicy.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", @@ -2659,6 +2867,7 @@ "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/services/terrainHeights.js", "src/data/terrainHeights.js", @@ -2686,6 +2895,7 @@ "src/nominatimGeocode.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transitFeeds.js", @@ -2822,11 +3032,20 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js", + "src/layers/recentImagery/display.js", + "src/layers/installations/classLabel.js", "src/data/cameraFraming.js", "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", - "src/layers/airQuality/source.js" + "src/layers/airQuality/source.js", + "src/ui/statusText.js", + "src/ui/layerNames.js" ], "external": [ "hls.js", @@ -2865,6 +3084,7 @@ "src/keylessGeocoder.js", "src/layers/firms/anchors.js", "src/locations.js", + "src/cityPois.js", "src/cameraGroundGuard.js", "src/search/alpsGeocoder.js", "src/search/defaults.js", @@ -2885,6 +3105,7 @@ "src/nominatimGeocode.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js", "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -2914,13 +3135,18 @@ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", - "src/data/cameraArrival.js" + "src/data/cameraArrival.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/index.js" ], "external": ["cesium"] }, "ui-share-restoration": { "exports": ["./ui/share-restoration"], "modules": [ + "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", "src/data/layerState.js", @@ -2929,7 +3155,11 @@ "src/loadingFeedback.js", "src/navigationPolicy.js", "src/ui/shareRestoration.js", - "src/ui/uiLifetime.js" + "src/ui/uiLifetime.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, @@ -2957,7 +3187,11 @@ "src/ui/visualEffects.js", "src/ui/visualInput.js", "src/ui/visualPresets.js", - "src/ui/visualSettings.js" + "src/ui/visualSettings.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -2977,13 +3211,18 @@ "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", - "src/ui/uiLifetime.js" + "src/ui/uiLifetime.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": [] }, "civil-flight-records": { "exports": ["./layers/flights/records"], "modules": [ + "src/i18n/marker.js", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", @@ -3004,6 +3243,7 @@ "military-flight-records": { "exports": ["./layers/military/records"], "modules": [ + "src/i18n/marker.js", "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/data/aircraftMeta.js", @@ -3055,9 +3295,16 @@ "directions-layer": { "exports": ["./layers/directions"], "modules": [ + "src/i18n/marker.js", "src/layers/directions/index.js", + "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", - "src/data/routeSteps.js" + "src/data/routeSteps.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, @@ -3093,6 +3340,7 @@ "voice-session-controls": { "exports": ["./voice/session-controls"], "modules": [ + "src/ui/placeNames.js", "src/voice/sessionCommands.js", "src/voice/session.js", "src/voice/control.js", @@ -3102,7 +3350,15 @@ "src/voice/referents.js", "src/voice/layerManifest.js", "src/voice/speech.js", - "src/voice/pointerReticle.js" + "src/cityPois.js", + "src/ui/layerNames.js", + "src/ui/unitLabels.js", + "src/voice/pointerReticle.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/statusText.js" ], "external": [] }, @@ -3115,6 +3371,7 @@ "src/data/tomtomTiles.js", "src/layers/alpr/tileRecords.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], @@ -3148,9 +3405,11 @@ "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", + "src/data/installationNames.js", "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", @@ -3188,7 +3447,9 @@ "src/layers/traffic/source.js", "src/sources/overpassRoads.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/militaryTileGeometry.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", @@ -3204,6 +3465,7 @@ "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/submarineCables/bundledSource.js", @@ -3222,13 +3484,14 @@ "src/search/geospatial.js", "src/data/regionalModel.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/nominatim.js" ], "external": [] }, "source-http-body": { "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "modules": ["src/sources/httpBody.js", "src/i18n/marker.js"], "external": [] }, "overpass-lexer": { @@ -3243,7 +3506,8 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] }, @@ -3259,6 +3523,7 @@ "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/transitHistory.js", "src/sources/transitService.js", "src/sources/transitHistoryStore.js", @@ -3334,7 +3599,11 @@ "src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeatures.js", "src/sources/featureSource.js", - "src/sources/capability.js" + "src/sources/capability.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3371,7 +3640,11 @@ "scripts/qa-transit-controls.mjs", "scripts/qa-transit-browser.mjs", "src/layers/transit/source.js", - "src/sources/transitHistory.js" + "src/sources/transitHistory.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "pbf"] }, @@ -3390,6 +3663,7 @@ "src/data/transitProxy.js", "src/data/gtfsRealtime.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/sources/transitHistory.js", "src/sources/transitService.js", "src/sources/transitHistoryStore.js", @@ -3413,10 +3687,15 @@ "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js" + "src/layers/perimeters/inciweb.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -3425,14 +3704,25 @@ "modules": [ "src/layers/gnss/index.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/gnss/source.js", - "src/layers/gnss/records.js" + "src/layers/gnss/records.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/statusText.js", + "src/ui/layerNames.js" ], "external": ["cesium"] }, "gnss-source": { "exports": ["./layers/gnss/source"], - "modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"], + "modules": [ + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/layers/gnss/source.js" + ], "external": [] }, "perimeters-source": { @@ -3440,6 +3730,7 @@ "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/perimeters/source.js" ], "external": [] @@ -3473,6 +3764,7 @@ "src/layers/wind/inspection.js", "src/layers/wind/model.js", "src/layers/wind/fields.js", + "src/i18n/marker.js", "src/tools/queries/imagery.js", "src/tools/queries/cables.js", "src/tools/queries/surveillance.js", @@ -3526,6 +3818,7 @@ "src/layers/cctv/sourcePolicy.js", "src/layers/cyclones/source.js", "src/sources/httpBody.js", + "src/i18n/marker.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", "src/layers/firms/source.js", @@ -3537,6 +3830,7 @@ "src/data/retryableLoad.js", "src/data/local_data/osm_military_names/names.json", "src/sources/openFreeMap.js", + "src/data/installationNames.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", "src/data/tomtomTiles.js", @@ -3571,6 +3865,8 @@ "src/layers/weather/source.js", "src/layers/wind/source.js", "src/sources/windBody.js", + "src/layers/wind/fields.js", + "src/layers/wind/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/model.js", "src/layers/submarineCables/bundledSource.js", @@ -3599,7 +3895,14 @@ "src/layers/airQuality/index.js", "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/ui/statusText.js", + "src/ui/layerNames.js" ], "external": ["cesium"] }, @@ -3608,7 +3911,8 @@ "modules": [ "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", - "src/sources/httpBody.js" + "src/sources/httpBody.js", + "src/i18n/marker.js" ], "external": [] } diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index 2a3d7328e..fdac4fd40 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) { for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 500)); - const status = document.getElementById('gev-voice-status')?.textContent?.trim() || ''; - if (status && status !== 'CONNECTING') break; + // The label is translated; the machine state is on the control. + const status = document.getElementById('gev-voice-control')?.dataset.status || ''; + if (status && status !== 'connecting') break; } let diag = null; try { diag = vc.getDiagnostics?.(); } catch { /* not started */ } diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index adbbfdf81..648075bd2 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -167,6 +167,13 @@ async function main() { page.on('request', (request) => { const url = new URL(request.url()); + // Radio QA uses keyless maps; an optional Google token is not offered. + if (request.method() === 'GET' && url.origin === APP_ORIGIN + && url.pathname === '/api/google/tiles-token') { + request.respond({ status: 200, contentType: 'application/json', body: '{}' }); + return; + } + // These scenarios exercise Context lifecycle and keyboard ownership, not // live orbit accuracy. Reuse the tracking suite's fixed element sets so // CelesTrak outages cannot invalidate an otherwise clean UI run. diff --git a/scripts/qa-scene-dialog-file-drop.mjs b/scripts/qa-scene-dialog-file-drop.mjs new file mode 100644 index 000000000..9b48555ad --- /dev/null +++ b/scripts/qa-scene-dialog-file-drop.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a + * file dropped from the desktop, proved in a real browser. + * + * The dialog draws a translated row over the native file input + * (src/ui/sceneSharing.js, fileChooser). The input must still cover the row, + * or drops miss it. Drops a file on the row's centre via CDP and expects + * `input.files` to hold it. + * + * Usage: + * node scripts/qa-scene-dialog-file-drop.mjs + * + * Needs the repository's puppeteer (devDependencies). Exit status 0 when the + * drop selects the file, 1 otherwise. + */ +import http from 'node:http'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TYPES = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.html': 'text/html', +}; + +const PAGE = ` +`; + +async function dropOn(client, x, y, file) { + const data = { items: [], files: [file], dragOperationsMask: 1 }; + for (const type of ['dragEnter', 'dragOver', 'drop']) { + await client.send('Input.dispatchDragEvent', { type, x, y, data }); + } +} + +async function main() { + const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-')); + const pack = path.join(dir, 'pack.json'); + await writeFile(pack, '{}'); + const server = http.createServer(async (req, res) => { + if (req.url === '/') { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(PAGE); + return; + } + try { + const body = await readFile(path.join(ROOT, req.url)); + res.writeHead(200, { + 'content-type': TYPES[path.extname(req.url)] || 'text/plain', + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/`; + const browser = await puppeteer.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1000, height: 800 }); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + await page.goto(url); + await page.waitForFunction(() => window.ready === true); + const point = await page.evaluate(() => window.openDialog()); + const client = await page.createCDPSession(); + await dropOn(client, point.x, point.y, pack); + const names = await page.evaluate(() => + [...window.filesInput.files].map((file) => file.name), + ); + console.log(JSON.stringify({ names, errors })); + if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`); + if (names.length !== 1 || names[0] !== 'pack.json') { + throw new Error( + `expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`, + ); + } + console.log('OK: a file dropped on the visible control is selected'); + } finally { + await browser.close(); + server.close(); + await rm(dir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 6f87762cf..b629c6c3c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -1687,6 +1687,13 @@ async function main() { await step( 'the header ON/OFF pill switches the layer off and on', async () => { + // Tiles requested while the credit painted can flip the pill back + // to LOADING, so let coverage settle before reading it. + await waitForCoverage(page); + await settle( + page, + () => document.getElementById('sl-status').textContent === 'ON', + ); assert.equal( await page.$eval('#sl-status', (node) => [ diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index d580357ce..3e5f2f6d1 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json'; const GIS = 'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer'; const HOUR = 3600_000; +// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in +// msg() and translates it when shown (likewise 'Cyclone data unavailable'). const COVERAGE = 'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.'; const LAYERS = [ diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 990642c8b..c6e3ba4ec 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, @@ -17,6 +18,19 @@ export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view'; 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 { @@ -121,7 +135,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 { @@ -134,6 +153,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/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 38c31bb08..e3776e291 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js'; * Checks for known rate-limit phrases in the body text regardless of * HTTP status code, since some mirrors return 200 with an error payload. * - * @param {string} bodyText - Upstream response body. + * @param {string} bodyText - The remark of a data reply, or a non-data body. * @returns {boolean} True if the body looks rate-limited. */ function overpassLooksRateLimited(bodyText) { @@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) { ); } +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** * True only for an upstream response that is actually Overpass data. * @@ -151,15 +159,22 @@ async function fetchOverpassPayload( signal: controller.signal, }); const responseBody = await readBody(upstream, maxResponseBytes); + const parsed = parseJson(responseBody); + // An Overpass data reply reports failure in its remark; its element + // tags are data and can contain any phrase. Any other body (an error + // page, or JSON without elements) is matched whole. + const failureText = Array.isArray(parsed?.elements) + ? String(parsed.remark || '') + : responseBody; const rateLimited = upstream.status === 429 || upstream.status === 406 || - overpassLooksRateLimited(responseBody); + overpassLooksRateLimited(failureText); if ( rateLimited || upstream.status < 200 || upstream.status >= 300 || - overpassLooksRuntimeError(responseBody) + overpassLooksRuntimeError(failureText) ) { const failures = (previous?.failures || 0) + 1; const delay = retryDelay( @@ -178,7 +193,6 @@ async function fetchOverpassPayload( failure = refusal(status, delay); continue; } - const parsed = JSON.parse(responseBody); if (!Array.isArray(parsed?.elements) || parsed.remark) throw new Error('Malformed Overpass response'); cooldowns.delete(endpoint); diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index cb06c182a..551fee7a7 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -179,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses assert.equal(recreated.fixes.length, 1); assert.equal(recreated.truncated, true); }); +test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => { + const h = history(t); + const ttc = { + id: 'ttc', + historyRetention: true, + defaultMode: 'bus', + routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'), + vehicleMode: (id) => + /^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown', + }; + h.ingest( + ttc, + [ + row(100000, '8515', { routeId: '506' }), + row(100000, '4507', { routeId: null }), + row(100000, 'x1', { routeId: '501' }), + ], + 100000, + ); + assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus'); + assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); + assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); +}); diff --git a/server/providers/wind.js b/server/providers/wind.js index e953ffca3..c94fe3979 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js'; +// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact +// English with msg() and translates it when shown, so keep the two in sync. +const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable'; + /** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */ export function windProxy({ fetchImpl = fetch, @@ -25,7 +29,7 @@ export function windProxy({ ...(overlay === 'none' ? {} : { overlay }), unavailable: true, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }, }); const sendJson = (res, value, status = 200) => { @@ -139,7 +143,7 @@ export function windProxy({ old.manifest = { ...old.manifest, stale: true, - reason: 'Wind upstream unavailable', + reason: UPSTREAM_UNAVAILABLE, }; return old; } diff --git a/server/standalone/key-setup.js b/server/standalone/key-setup.js index b8be4c036..4a36cc8a0 100644 --- a/server/standalone/key-setup.js +++ b/server/standalone/key-setup.js @@ -134,6 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }; // The gate itself is pure and unit-tested (admitKeySetupRequest in // src/keySetupCore.mjs) — this just feeds it the request. + // `code`/`params` pick the panel's sentence (src/keySetup.js); `error` is for logs. + const refusal = ({ error, code, params }) => ({ error, code, params }); const admit = (req) => admitKeySetupRequest({ method: req.method, @@ -247,18 +249,24 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { configureServer(server) { server.middlewares.use('/api/setup/status', (req, res) => { if (req.method !== 'GET') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); respond(res, 200, providerStatus()); }); server.middlewares.use('/api/setup/keys', (req, res) => { if (req.method !== 'POST') - return respond(res, 405, { error: 'Method not allowed' }); + return respond(res, 405, { + error: 'Method not allowed', + code: 'method', + }); const admission = admit(req); if (!admission.ok) - return respond(res, admission.status, { error: admission.error }); + return respond(res, admission.status, refusal(admission)); let body = ''; let overflowed = false; req.on('data', (chunk) => { @@ -270,15 +278,21 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { }); req.on('end', () => { if (overflowed) - return respond(res, 413, { error: 'Request too large' }); + return respond(res, 413, { + error: 'Request too large', + code: 'too-large', + }); let parsed; try { parsed = JSON.parse(body || '{}'); } catch { - return respond(res, 400, { error: 'Invalid JSON' }); + return respond(res, 400, { + error: 'Invalid JSON', + code: 'invalid-json', + }); } const verdict = validateKeySetupUpdates(parsed); - if (!verdict.ok) return respond(res, 400, { error: verdict.error }); + if (!verdict.ok) return respond(res, 400, refusal(verdict)); // Neither a replace NOR a removal may touch an externally-supplied // credential (shell env, Keychain, another workflow). This backs the // UI's read-only "configured externally" state with a real contract — @@ -289,6 +303,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { if (isExternallyManaged(name, inStore)) { return respond(res, 409, { error: `${name} is configured outside Provider Settings and can only be changed where it was set`, + code: 'external', + params: { name }, }); } } @@ -305,10 +321,16 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) { ) { return respond(res, 500, { error: `The key was not saved: ${error.message}`, + code: + error.code === 'GEV_HARDEN_FAILED' + ? 'harden-failed' + : 'store-unreadable', }); } return respond(res, 500, { error: `Could not write the ${storeName()} store`, + code: 'write-failed', + params: { store: storeName() }, }); } // Live for the server-side proxies immediately; the restart below is diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 27b049bb8..645aa3f3d 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1137,12 +1137,14 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio (former Army base)', color: 'green', footprint: true, @@ -1150,6 +1152,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'cyan', }, @@ -1157,6 +1160,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', }, ], @@ -1186,6 +1190,7 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Palace of Fine Arts', color: 'amber', }, @@ -1200,6 +1205,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'next to the Marina', color: 'cyan', }, @@ -1223,6 +1229,7 @@ export function createAnnotationEngine({ { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'The Presidio — a former Army base', color: 'green', footprint: true, @@ -1237,6 +1244,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'ILM / Lucasfilm', color: 'red', }, @@ -1250,6 +1258,7 @@ export function createAnnotationEngine({ { type: 'route', color: 'amber', + // i18n-ignore: dev demo of voice-agent labels (not translated) label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index f28a7ae6f..34f08e8f1 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -1,3 +1,5 @@ +import { t, tn } from '../i18n/index.js'; + /** * Manual whiteboard drawing: the pure half. * @@ -284,20 +286,26 @@ export function closeRing(pairs) { /** One line of guidance for the person drawing, by state. */ export function drawHint(session) { - if (!session) return 'Pick a shape, then click the map.'; + if (!session) return t('Pick a shape, then click the map.'); const n = session.vertices.length; if (session.shape === 'pin') return n - ? 'Enter to place the pin, Esc to cancel.' - : 'Click where the pin goes.'; + ? t('Enter to place the pin, Esc to cancel.') + : t('Click where the pin goes.'); const need = MIN_VERTICES[session.shape] - n; - if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`; + if (need > 0) + return tn(need, 'Click {count} more point.', 'Click {count} more points.'); if (finishReason(session) === 'degenerate') { return session.shape === 'area' - ? 'Those points are in a line — move one off it to enclose an area.' - : 'That line has no length — click somewhere further away.'; + ? t('Those points are in a line — move one off it to enclose an area.') + : t('That line has no length — click somewhere further away.'); } const full = - n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : ''; - return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`; + n >= MAX_VERTICES + ? t(' · {max}-point limit reached', { max: MAX_VERTICES }) + : ''; + return t( + '{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}', + { measure: formatMeasure(session), full }, + ); } diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 7f164b54f..91adb4d0d 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -36,6 +36,7 @@ import { pointerOwner, releasePointer, } from '../data/inputOwnership.js'; +import { pointerOwnerName } from '../data/pointerOwnerNames.js'; import { DRAW_SHAPES, MAX_VERTICES, @@ -48,6 +49,7 @@ import { removeLastVertex, } from './drawMode.js'; +import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ export const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; @@ -100,6 +102,17 @@ export function initDrawTool({ viewer, annotations }) { target.addEventListener(type, listener, options); domListeners.push([target, type, listener, options]); }; + // The board lives only in memory: switching language (a reload) asks first + // while it holds a lasting mark or a shape is half drawn. + const unregisterUnsavedWork = registerUnsavedWork(() => { + if (destroyed) return null; + const lasting = (annotations.list?.() || []).some( + (mark) => mark && mark.ttlMs == null && !mark.expiring, + ); + return lasting || session?.vertices?.length > 0 + ? msg('drawings on the map') + : null; + }); const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME); // `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would @@ -197,10 +210,13 @@ export function initDrawTool({ viewer, annotations }) { // a limit rather than the harmless tail of a double-click. if (reason === 'full') setHint( - `That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`, + t( + 'That shape already has {count} points — finish it or press Backspace.', + { count: MAX_VERTICES }, + ), ); else if (reason === 'invalid') - setHint('That point is off the globe — click on the world.'); + setHint(t('That point is off the globe — click on the world.')); }; const onMove = (event) => { if (!session || session.shape === 'pin' || destroyed) return; @@ -236,11 +252,15 @@ export function initDrawTool({ viewer, annotations }) { flyTo: false, }); if (destroyed || attempt !== generation) return result; - if (result?.drawn === 0) setHint('That shape could not be placed.'); + if (result?.drawn === 0) setHint(t('That shape could not be placed.')); return result; } catch (error) { if (destroyed || attempt !== generation) return null; - setHint(`Could not place the shape: ${error?.message || error}`); + setHint( + t('Could not place the shape: {error}', { + error: error?.message || error, + }), + ); return null; } }; @@ -258,7 +278,7 @@ export function initDrawTool({ viewer, annotations }) { if (session) session = createDrawSession(shape); annotations.clear(); syncPreview(); - if (!destroyed && !active) setHint('Board cleared.'); + if (!destroyed && !active) setHint(t('Board cleared.')); }; // ---- keys: only while drawing, never while typing in another field ---- @@ -324,7 +344,11 @@ export function initDrawTool({ viewer, annotations }) { // Somebody else is using the pointer — possibly an older instance of // this same tool that has not finished tearing down. Say so instead of // half-starting. - setHint(`${pointerOwner()} is using the pointer — close it first.`); + setHint( + t('{owner} is using the pointer — close it first.', { + owner: translateKnown(pointerOwnerName(pointerOwner())), + }), + ); return active; } } @@ -502,6 +526,7 @@ export function initDrawTool({ viewer, annotations }) { generation += 1; if (active) setActive(false); destroyed = true; + unregisterUnsavedWork(); releaseSceneHandler(); releasePointer(lease); lease = null; diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 6da4c91af..f8b9afcd1 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -6,6 +6,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { initDrawTool } from './drawTool.js'; +import { describeUnsavedWork } from '../i18n/index.js'; import { isPointerFree, pointerOwner, @@ -240,6 +241,29 @@ const LEFT_DOUBLE_CLICK = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; /* ── the tests ─────────────────────────────────────────────────────────── */ +// First in the file: tools left by later tests would answer the same registry. +test('a lasting mark or a half-drawn shape makes the language switch ask first', async () => { + const h = harness(); + try { + let marks = []; + h.annotations.list = () => marks; + assert.deepEqual(describeUnsavedWork(), [], 'an empty board loses nothing'); + marks = [{ id: 'a', ttlMs: 8000, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), [], 'a fading mark is not work'); + marks = [{ id: 'b', ttlMs: null, expiring: false }]; + assert.deepEqual(describeUnsavedWork(), ['drawings on the map']); + marks = []; + h.element('draw-toggle').emit('click'); + h.tool.addVertex(-97.75, 30.26); + assert.deepEqual(describeUnsavedWork(), ['drawings on the map'], 'a shape in progress'); + marks = [{ id: 'c', ttlMs: null, expiring: false }]; + await h.tool.destroy(); + assert.deepEqual(describeUnsavedWork(), [], 'a destroyed tool unregisters'); + } finally { + h.restore(); + } +}); + test('turning Draw on takes a lease and turning it off gives it back', async () => { const h = harness(); try { @@ -263,7 +287,11 @@ test('Draw refuses to start when something else holds the pointer', async () => h.element('draw-toggle').emit('click'); assert.equal(h.tool.active, false, 'it must not half-start'); assert.equal(pointerOwner(), 'directions', 'and must not steal the claim'); - assert.match(h.element('draw-hint').textContent, /directions/); + // Named by its tool, not by the internal owner id. + assert.equal( + h.element('draw-hint').textContent, + 'Directions is using the pointer — close it first.', + ); assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound'); releasePointer(other); diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index a2447ecce..c6c214fff 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -4,6 +4,12 @@ import { getActiveTrackedReadoutId, TRACKED_OVERLAY_SOURCE_ID, } from '../data/trackedReadout.js'; +import { t } from '../i18n/index.js'; + +/** A label whose detailed outline could not be resolved. */ +function outlineUnavailableLabel(label) { + return t('{label} · Detailed outline unavailable', { label: label || '' }); +} /** * Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic). @@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.poly); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.path); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer( }); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.leader); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer( } rec.anno = anno; if (anno.outlineUnavailable && rec.parts.label) { - rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`; + rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label); sizeCallout(rec.parts.label); } diff --git a/src/app/controls.js b/src/app/controls.js index 8d9b988b5..40b354d06 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('Flying to Austin, TX...'); defer(flyToAustin(viewer)); } else { - loaderStatus.textContent = 'Restoring shared view...'; + loaderStatus.textContent = t('Restoring shared view...'); } return { styleManager, weatherEffects, cockpitCloudEffects }; diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2b259d7b9..d10e5717d 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -541,6 +541,7 @@ export function panelRuntime(config) { const notice = document.createElement('div'); notice.id = 'status'; notice.textContent = + // i18n-ignore: host-panel script, no app catalogs "This panel lost its 3D graphics, likely because other God's Eye " + "View panels in this conversation hold them. Use Open in God's " + 'Eye View above, or show it in a new conversation.'; @@ -568,13 +569,16 @@ export function panelRuntime(config) { queued = view; if (started) return; started = true; + // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); startApp(url, view).catch((error) => + // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); setTimeout(() => { if (!ready) say( + // i18n-ignore: see above "God's Eye View did not load here" + (blocked ? ` (${blocked})` : '') + ". Use Open in God's Eye View above.", @@ -616,6 +620,7 @@ export function panelRuntime(config) { if (context?.displayMode) displayMode = context.displayMode; if (context?.availableDisplayModes) expand.hidden = !context.availableDisplayModes.includes('fullscreen'); + // i18n-ignore: host-panel script, no app catalogs expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; }; expand.addEventListener('click', () => { @@ -640,6 +645,7 @@ export function panelRuntime(config) { appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, appInfo: { name: 'gods-eye-view', + // i18n-ignore: product name sent to the MCP host title: "God's Eye View", version: '1.0.0', }, @@ -655,6 +661,7 @@ export function panelRuntime(config) { }, (error) => say( + // i18n-ignore: host-panel script, no app catalogs "This client did not accept the God's Eye View panel" + (error?.message ? `: ${error.message}` : '.'), ), diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 60cc64d0f..99bf3fa6f 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -3,7 +3,7 @@ import * as credits from '../../data/dataCredits.js'; import * as sprites from '../../data/spriteOrder.js'; import * as activation from '../../cctvFocusRequest.js'; import * as overlays from '../../overlays/worldOverlay.js'; -import * as locations from '../../locations.js'; +import { CITY_POIS } from '../../cityPois.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as render from '../../renderGovernor.js'; @@ -18,7 +18,7 @@ export function createApplicationCctv({ surface, source }) { sprites, activation, overlays, - locations, + locations: { CITY_POIS }, picking, terrain, ground, diff --git a/src/app/scene.js b/src/app/scene.js index b98a28914..62434613f 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({ @@ -42,7 +43,7 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } - loaderStatus.textContent = 'Configuring viewer...'; + loaderStatus.textContent = t('Configuring viewer...'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); creditContainer.id = 'cesium-credits'; @@ -61,8 +62,8 @@ export async function createApplicationScene({ configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || googleTokens || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; + ? t('Loading Google 3D Tiles...') + : t('Loading the keyless globe...'); const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, googleTokens, @@ -90,12 +91,17 @@ export async function createApplicationScene({ tileError, ); const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + loaderStatus.textContent = t( + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + { + detail: tileErrorDetail, + }, + ); } viewer.scene.globe.show = true; } - loaderStatus.textContent = 'Initializing systems...'; + loaderStatus.textContent = t('Initializing systems...'); const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, diff --git a/src/cityPois.js b/src/cityPois.js new file mode 100644 index 000000000..f16394030 --- /dev/null +++ b/src/cityPois.js @@ -0,0 +1,514 @@ +/** + * @module cityPois + * @description The preset cities and their landmarks — plain data with no + * Cesium, so modules that only need the names (voice cards) can import it. + */ + +/** + * Points of Interest per city. + * Each city has 5 POIs; the first is the default fly-to landmark. + * + * Field reference: + * alt — RANGE (distance from target in meters), NOT absolute altitude + * heading — optimal camera heading in degrees (0=N, 90=E, 180=S, 270=W) + * pitch — camera tilt in degrees (negative = looking down) + * buildingHeight — estimated height of landmark center above ground (meters) + */ +export const CITY_POIS = { + austin: { + name: 'Austin', + groundElevation: 150, // meters above WGS84 ellipsoid + viewBounds: { + southwest: { lat: 30.1, lng: -97.95 }, + northeast: { lat: 30.52, lng: -97.55 }, + }, + pois: [ + { + name: 'Texas State Capitol', + lat: 30.2747, + lon: -97.7403, + alt: 550, + pitch: -28, + heading: 180, + buildingHeight: 35, + }, + { + name: 'Frost Bank Tower', + lat: 30.2674, + lon: -97.7434, + alt: 550, + pitch: -22, + heading: 30, + buildingHeight: 80, + }, + { + name: 'Pennybacker Bridge', + lat: 30.3451, + lon: -97.7951, + alt: 500, + pitch: -25, + heading: 90, + buildingHeight: 40, + }, + { + name: 'The Jenga Tower', + lat: 30.2642, + lon: -97.75, + alt: 500, + pitch: -18, + heading: 45, + buildingHeight: 60, + }, + { + name: 'UT Tower', + lat: 30.2862, + lon: -97.7394, + alt: 500, + pitch: -22, + heading: 180, + buildingHeight: 50, + }, + ], + }, + sf: { + name: 'San Francisco', + groundElevation: 15, + viewBounds: { + southwest: { lat: 37.7, lng: -122.53 }, + northeast: { lat: 37.84, lng: -122.35 }, + }, + pois: [ + { + name: 'Golden Gate Bridge', + lat: 37.8199, + lon: -122.4783, + alt: 1400, + pitch: -20, + heading: 45, + buildingHeight: 100, + }, + { + name: 'Transamerica Pyramid', + lat: 37.7952, + lon: -122.4028, + alt: 500, + pitch: -25, + heading: 30, + buildingHeight: 85, + }, + { + name: 'Salesforce Tower', + lat: 37.7897, + lon: -122.3972, + alt: 680, + pitch: -25, + heading: 330, + buildingHeight: 100, + }, + { + name: 'Alcatraz Island', + lat: 37.8267, + lon: -122.423, + alt: 800, + pitch: -30, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Coit Tower', + lat: 37.8024, + lon: -122.4058, + alt: 420, + pitch: -30, + heading: 45, + buildingHeight: 30, + }, + ], + }, + nyc: { + name: 'New York', + groundElevation: 10, + viewBounds: { + southwest: { lat: 40.477, lng: -74.259 }, + northeast: { lat: 40.918, lng: -73.7 }, + }, + pois: [ + { + name: 'Statue of Liberty', + lat: 40.6892, + lon: -74.0445, + alt: 450, + pitch: -25, + heading: 315, + buildingHeight: 45, + }, + { + name: 'Empire State Building', + lat: 40.7484, + lon: -73.9857, + alt: 850, + pitch: -12, + heading: 30, + buildingHeight: 130, + buildingBounds: { height: 443, width: 130, depth: 75 }, + }, + { + name: 'One World Trade Center', + lat: 40.7127, + lon: -74.0134, + alt: 850, + pitch: -25, + heading: 0, + buildingHeight: 170, + }, + { + name: 'Brooklyn Bridge', + lat: 40.7061, + lon: -73.9969, + alt: 850, + pitch: -25, + heading: 45, + buildingHeight: 40, + }, + { + name: 'Chrysler Building', + lat: 40.7516, + lon: -73.9755, + alt: 700, + pitch: -20, + heading: 225, + buildingHeight: 100, + }, + ], + }, + tokyo: { + name: 'Tokyo', + groundElevation: 40, + viewBounds: { + southwest: { lat: 35.52, lng: 139.55 }, + northeast: { lat: 35.9, lng: 139.92 }, + }, + pois: [ + { + name: 'Tokyo Tower', + lat: 35.6586, + lon: 139.7454, + alt: 850, + pitch: -25, + heading: 0, + buildingHeight: 110, + }, + { + name: 'Tokyo Skytree', + lat: 35.7101, + lon: 139.8107, + alt: 900, + pitch: -25, + heading: 30, + buildingHeight: 200, + }, + { + name: 'Imperial Palace', + lat: 35.6852, + lon: 139.7528, + alt: 900, + pitch: -35, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Senso-ji Temple', + lat: 35.7148, + lon: 139.7967, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 25, + }, + { + name: 'Mode Gakuen Cocoon Tower', + lat: 35.6929, + lon: 139.6925, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 70, + }, + ], + }, + london: { + name: 'London', + groundElevation: 15, + viewBounds: { + southwest: { lat: 51.28, lng: -0.51 }, + northeast: { lat: 51.7, lng: 0.33 }, + }, + pois: [ + { + name: 'Tower Bridge', + lat: 51.5055, + lon: -0.0754, + alt: 400, + pitch: -25, + heading: 270, + buildingHeight: 65, + }, + { + name: 'The Shard', + lat: 51.5045, + lon: -0.0865, + alt: 850, + pitch: -20, + heading: 0, + buildingHeight: 100, + }, + { + name: 'Big Ben / Parliament', + lat: 51.5007, + lon: -0.1246, + alt: 600, + pitch: -25, + heading: 180, + buildingHeight: 50, + }, + { + name: "St. Paul's Cathedral", + lat: 51.5138, + lon: -0.0984, + alt: 400, + pitch: -30, + heading: 270, + buildingHeight: 55, + }, + { + name: 'The Gherkin', + lat: 51.5145, + lon: -0.0803, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 60, + }, + ], + }, + paris: { + name: 'Paris', + groundElevation: 35, + viewBounds: { + southwest: { lat: 48.815, lng: 2.224 }, + northeast: { lat: 48.902, lng: 2.47 }, + }, + pois: [ + { + name: 'Eiffel Tower', + lat: 48.8584, + lon: 2.2945, + alt: 750, + pitch: -25, + heading: 315, + buildingHeight: 150, + }, + { + name: 'Arc de Triomphe', + lat: 48.8738, + lon: 2.295, + alt: 400, + pitch: -28, + heading: 45, + buildingHeight: 25, + }, + { + name: 'Notre-Dame', + lat: 48.853, + lon: 2.3499, + alt: 400, + pitch: -25, + heading: 225, + buildingHeight: 35, + }, + { + name: 'Sacré-Cœur', + lat: 48.8867, + lon: 2.3431, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 40, + }, + { + name: 'Louvre Pyramid', + lat: 48.8606, + lon: 2.3376, + alt: 500, + pitch: -35, + heading: 0, + buildingHeight: 10, + }, + ], + }, + dubai: { + name: 'Dubai', + groundElevation: 5, + viewBounds: { + southwest: { lat: 24.95, lng: 54.9 }, + northeast: { lat: 25.35, lng: 55.55 }, + }, + pois: [ + { + name: 'Burj Khalifa', + lat: 25.1972, + lon: 55.2744, + alt: 600, + pitch: -20, + heading: 200, + buildingHeight: 270, + }, + { + name: 'Burj Al Arab', + lat: 25.1412, + lon: 55.1853, + alt: 500, + pitch: -25, + heading: 90, + buildingHeight: 100, + }, + { + name: 'Palm Jumeirah', + lat: 25.1124, + lon: 55.139, + alt: 1200, + pitch: -40, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Dubai Frame', + lat: 25.235, + lon: 55.3003, + alt: 400, + pitch: -25, + heading: 270, + buildingHeight: 75, + }, + { + name: 'Museum of the Future', + lat: 25.2197, + lon: 55.2806, + alt: 350, + pitch: -20, + heading: 30, + buildingHeight: 35, + }, + ], + }, + dc: { + name: 'Washington DC', + groundElevation: 10, + viewBounds: { + southwest: { lat: 38.79, lng: -77.12 }, + northeast: { lat: 38.995, lng: -76.91 }, + }, + pois: [ + { + name: 'US Capitol', + lat: 38.8897, + lon: -77.0091, + alt: 550, + pitch: -25, + heading: 270, + buildingHeight: 45, + }, + { + name: 'Washington Monument', + lat: 38.8895, + lon: -77.0353, + alt: 500, + pitch: -30, + heading: 0, + buildingHeight: 85, + }, + { + name: 'Lincoln Memorial', + lat: 38.8893, + lon: -77.0502, + alt: 400, + pitch: -25, + heading: 90, + buildingHeight: 20, + }, + { + name: 'Pentagon', + lat: 38.8711, + lon: -77.0559, + alt: 800, + pitch: -40, + heading: 0, + buildingHeight: 20, + }, + { + name: 'Jefferson Memorial', + lat: 38.8814, + lon: -77.0365, + alt: 400, + pitch: -30, + heading: 0, + buildingHeight: 25, + }, + ], + }, + tallinn: { + name: 'Tallinn', + groundElevation: 15, + viewBounds: { + southwest: { lat: 59.36, lng: 24.6 }, + northeast: { lat: 59.52, lng: 24.92 }, + }, + pois: [ + { + name: 'Viru Square', + lat: 59.4366, + lon: 24.7527, + alt: 450, + pitch: -28, + heading: 60, + buildingHeight: 25, + }, + { + name: 'Old Town / Raekoja plats', + lat: 59.4372, + lon: 24.7452, + alt: 400, + pitch: -30, + heading: 180, + buildingHeight: 20, + }, + { + name: 'Teatri väljak', + lat: 59.4344, + lon: 24.7514, + alt: 400, + pitch: -25, + heading: 220, + buildingHeight: 25, + }, + { + name: 'Port of Tallinn', + lat: 59.4445, + lon: 24.7675, + alt: 700, + pitch: -30, + heading: 90, + buildingHeight: 20, + }, + { + name: 'Ülemiste', + lat: 59.421, + lon: 24.792, + alt: 600, + pitch: -28, + heading: 45, + buildingHeight: 30, + }, + ], + }, +}; diff --git a/src/cityPresets.test.mjs b/src/cityPresets.test.mjs index dc9ef0145..2aaec446f 100644 --- a/src/cityPresets.test.mjs +++ b/src/cityPresets.test.mjs @@ -8,7 +8,8 @@ // say nothing about whether a destination is framed well. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { CITY_POIS, LOCATIONS } from './locations.js'; +import { CITY_POIS } from './cityPois.js'; +import { LOCATIONS } from './locations.js'; const DESTINATIONS = Object.entries(CITY_POIS); const REQUIRED_POI_KEYS = ['name', 'lat', 'lon', 'alt', 'pitch', 'heading']; diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 744423c90..0b376d9fc 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,4 +1,7 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; @@ -7,10 +10,19 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; -import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + exit as cockpitExit, + readAircraftInfo, + _adoptTrackedEntity, +} from './ui/cockpitTrackingController.js'; import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; import { updateContext as cockpitContext } from './ui/cockpitContext.js'; -import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { + syncSignalLayout, + setContextCollapsed, + setSignalCollapsed, +} from './ui/cockpitLayout.js'; import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; @@ -18,8 +30,14 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _restoreContextSession } from './ui/contextSession.js'; import { readFileSync as readRadioSource } from 'node:fs'; -const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioBindings = readRadioSource( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', +); +const radioPresentation = readRadioSource( + new URL('./ui/radioPresentation.js', import.meta.url), + 'utf8', +); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -28,13 +46,27 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8')); +const html = expandApplicationHtml( + fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'), +); const ui = readShellSource(); const css = readStylesheet(path.join(ROOT, 'style.css')); -const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); -const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8'); -const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); -const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); +const sceneDirector = fs.readFileSync( + path.join(ROOT, 'src', 'scenes', 'director.js'), + 'utf8', +); +const manager = fs.readFileSync( + path.join(ROOT, 'src', 'data', 'lifecycle.js'), + 'utf8', +); +const contextLayer = readLayerSource( + path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), + 'utf8', +); +const voiceActions = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-quick-entry"/); @@ -43,24 +75,48 @@ test('Cockpit has one reset action beside its bottom exit path', () => { html, /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); - const topCenterActions = html.match(/