Merge upstream main into pr/street-level-show-action

Re-pin the Realtime payload digests for main's control_cockpit wording.
This commit is contained in:
Said Turksever committed 2026-11-06 00:23:42 +00:00
commit 14f7326dbf
118 files changed
+5479 -353

No files matched your search

+11 -7
View File
@@ -98,6 +98,7 @@ How to read this:
| **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 |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **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)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
@@ -185,7 +186,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_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 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.
- **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 eighteen registered feeds, covering seventeen 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, YRT and Rio’s SMTR 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 country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/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; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. 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.
@@ -224,7 +225,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **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) |
| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 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
@@ -252,13 +253,16 @@ 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
The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC
records from v2.29.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
**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County,
Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in
[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the
module. Nothing is fetched at runtime. The MIC records keep their specification fields; their
titles are shortened to model codes and colour aliases are added. 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
+1 -1
View File
@@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **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 | 🟢 |
| 🚌 **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, Rio de Janeiro (buses and BRT), 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) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
+3
View File
@@ -0,0 +1,3 @@
- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera
register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour
wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319).
+12
View File
@@ -0,0 +1,12 @@
- Color every Active Fires detection by fire radiative power on a six-stop
ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at
0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a
log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ /
High confidence switch and an "N of M shown" counter to the layer row. Far
zoom shows a Snap Map–style heatmap in the same colors instead of squares,
one distinct spot per fire, empty where nothing burns, and cross-fades with
the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before
rendering, so markers, cards, the heatmap, context and the top-fires query
agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires`
takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All
(gekh, #1426).
+5
View File
@@ -0,0 +1,5 @@
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
American region. Rio's internal route keys are joined to SMTR's published
GTFS `routes.txt`, so a card reads "Route SN731" rather than
"Route O0731AAN0A" (IMBrandao, #1481).
+4
View File
@@ -0,0 +1,4 @@
- Cockpit Contact counts and the Contacts list name the provider that
answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from
the adsb.lol fallback no longer read OpenSky, and vessels no longer always
read AISStream (German, @gekh, #1521).
@@ -0,0 +1,2 @@
- Keep retained Active Fires heat imagery hidden when an empty filtered layer is
re-enabled while a new snapshot is pending (radianx, #1535).
+5
View File
@@ -0,0 +1,5 @@
- Keep typed chat's view when switching AI Provider: Settings opened from
chat stay chat's Settings for the new provider instead of falling back to
voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546).
@@ -0,0 +1,6 @@
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
required, and ending Contacts mid-session keeps the view and the layer it
rides; turning off the aircraft's own feed ends the view, a missing other
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
#1556).
@@ -0,0 +1,3 @@
- Chat autocomplete finds Arabic suggestions typed without short vowels or
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
(German, @gekh, #1561).
+85 -11
View File
@@ -766,9 +766,12 @@ each side, and the empty Voice Preview suggests things to say until voice
starts. Settings opened from chat keep chat's frame and show the shared AI
provider, the quick-commands option and then the voice preferences (Voice
Preview, inactivity timeout and model tier), so they stay in reach while
Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice
Preview is off, with Back to chat; voice Settings show Back to voice. Switching
AI Provider keeps that view: Settings opened from chat stay chat's Settings,
in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice
Preview, and does nothing in chat. Closing chat ends a typed session, and the
voice panel's close turns voice off. Asking the assistant from chat to open
voice panel's close turns voice off. Opening chat or closing the voice panel
also clears a voice error left on the dock. Asking the assistant from chat to open
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
voice panel; the typed conversation and its running reply carry on, and the
chat button brings them back.
@@ -2323,6 +2326,62 @@ Generated style sliders use the same name as their visible parameter label.
The first-run suppression checkbox keeps its native wrapping label, so its
accessible name remains "Don't show this again". Control behavior is unchanged.
## Active Fires intensity filter and color ramp
Every FIRMS detection is colored by fire radiative power on a six-stop ember →
white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from
50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in
`src/data/firmsIntensity.js`). Each stop is lighter than the one below and
none is a cold hue, so the brightest marks are the strongest fires; ~90% of
detections are under 20 MW and stay dim red. Glow sprites and fire-card
accents read that ramp; confidence and brightness do not affect the color.
At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style
heatmap instead of individual markers: the shown detections are linearly binned
(each detection's weight shared between the four nearest pixel centres, so spots
sit at the true position and stay round) on a
global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is
radiated power, and raising the FRP slider does not dim areas where strong fires
burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one
pixel so each fire stays a distinct spot about the size of its marker,
normalized on a log scale against the 99.5th percentile and mapped through the
same hues, from a bright red edge through orange and gold to a pale
white-hot core. The edge starts more than half opaque, so regions far below
the hottest ones still read over forest and savanna imagery. Nothing is painted where
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
on each data refresh or filter change (the first far-band paint at once,
later ones 150 ms after the last change). Heatmap and markers are both kept
built at every zoom and cross-fade by camera height between 3,600 and
5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by
imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe
tiles and re-mapped late when shown), the markers by sprite alpha, and the
marker collection is hidden once the heatmap is whole. The 750–4,500 km
markers band draws up to 40,000 detections (the strongest in the padded
view), so the weak fires the heatmap shows stay on screen as markers when it
fades out; it reads each marker's already-built anchor instead of the ground
floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest
areas; their accent is the strongest fire's FRP color.
Re-enabling Active Fires keeps the retained heat imagery hidden when the current
filtered set is empty, including while a new snapshot is pending.
The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale
whose track is painted with the ramp and ticked at the same six values (its
value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ /
High, and an "N of M shown" counter. The default is 0 MW and
All, because many of the strongest detections are rated low confidence. The
filter runs once in `ingestion.applyFilter()` before rendering, so markers,
cards, the heatmap, the shared context store, the top-fires query and the
analyst records all see one filtered set; a selected fire that no longer passes
is deselected. The layer state keeps both options under the `w` (`local-firms`)
share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`),
including the short share codec; a link without them restores the defaults.
The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence`
(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and
names the applied criteria in its notes. Stale-snapshot and missing-source
reporting are unchanged.
## FIRMS source status
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
@@ -4668,8 +4727,9 @@ 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. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), 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
@@ -4879,7 +4939,7 @@ cannot claim full acceptance.
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
prior state on exit; Space Missions refuses enablement while isolating replay
data. Cockpit enters through Contacts and does not independently enable Transit.
data. Cockpit does not need Contacts and does not independently enable Transit.
The proxy fetches registered URLs only, validates redirect origins, bounds
responses and decoding, caches successful snapshots, and backs off after failed
refreshes. A 304 confirms contact without appending history. Missing vehicles
@@ -5197,6 +5257,7 @@ silently demoting every later lookup for the session.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
@@ -5240,6 +5301,10 @@ silently demoting every later lookup for the session.
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).
The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam
register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names
no axis, so it neither gives a capability range nor replaces the estimate. The records
alone do not enable that source pack.
- **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. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW).
- **CCTV v2 foundation:** a pitched
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
@@ -6097,12 +6162,21 @@ apply to `public/` assets or built-preview serving.
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
- Cockpit entry is gated to the operational Contacts context bundle. The
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
be enabled, and a civilian or military aircraft must be tracked. The visible
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
flight-layer selection cannot enter a context-dependent cockpit.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
tracked: Live Flights for a civilian, Military Flights for a military aircraft
(the tracked info's `layerId`; `cockpitEntryAllowed` in
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
layer, Live AIS Vessels and every other layer may be turned on or off before
or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
+6
View File
@@ -420,6 +420,7 @@
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/heatVisibility.test.mjs",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
@@ -785,6 +786,7 @@
"server/providers/transit.js",
"server/providers/transitHistory.js",
"server/providers/transitHistory.test.mjs",
"src/data/rioRouteNames.js",
"src/data/transitFeeds.js",
"src/data/transitFeeds.test.mjs",
"src/data/transitProxy.js",
@@ -1210,6 +1212,10 @@
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/data/firmsIntensity.js",
"src/data/firmsIntensity.test.mjs",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/sources/phaseTiming.test.mjs",
"scripts/build-census-places.mjs",
"src/data/placeBoundaries.test.mjs",
+49 -2
View File
@@ -39,6 +39,9 @@
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/ui/views/panels/CockpitRollingValue.vue",
"src/ui/views/panels/CockpitSignalList.vue",
"src/ui/views/panels/CockpitUtilityControls.vue",
@@ -685,6 +688,8 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/clearLayersControl.js",
"src/loadingFeedback.js",
@@ -722,7 +727,8 @@
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiBadge.vue",
"src/ui/rail/fieldValue.js",
"src/ui/kit/UiSlider.js"
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSegmented.vue"
],
"external": ["vue"]
},
@@ -866,6 +872,7 @@
"src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
@@ -888,6 +895,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -946,6 +954,7 @@
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -992,6 +1001,7 @@
"src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
@@ -1018,6 +1028,8 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
@@ -1141,7 +1153,8 @@
"src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js"
"src/ui/i18n.js",
"src/ui/kit/UiSegmented.vue"
],
"external": ["cesium", "vue", "pinia", "vue-i18n"]
},
@@ -1310,6 +1323,7 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -1372,6 +1386,7 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -1425,6 +1440,7 @@
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1526,6 +1542,8 @@
"exports": ["./layers/firms"],
"modules": [
"src/data/firmsAdapt.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/iconOrientation.js",
"src/layers/firms/anchors.js",
@@ -1537,6 +1555,7 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -1731,6 +1750,10 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js"
],
"external": ["cesium"]
@@ -2000,6 +2023,7 @@
"modules": [
"src/data/iconOrientation.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -2372,6 +2396,8 @@
"src/data/fireAnchors.js",
"src/data/firmsAdapt.js",
"src/data/firmsHeatmap.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/flights.js",
"src/data/flowMatch.js",
@@ -2533,6 +2559,7 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -2794,6 +2821,8 @@
"src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
@@ -2935,6 +2964,7 @@
"src/app/layers/transit.js",
"src/data/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
@@ -3263,6 +3293,8 @@
"application-layer-construction": {
"exports": ["./application/layers"],
"modules": [
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
@@ -3333,6 +3365,8 @@
"src/data/detectionDraw.js",
"src/data/detectionPolicy.js",
"src/data/firmsAdapt.js",
"src/data/firmsIntensity.js",
"src/data/firmsHeatRaster.js",
"src/data/firmsLabels.js",
"src/data/flowMatch.js",
"src/data/focusDeemphasis.js",
@@ -3464,6 +3498,7 @@
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/heatImagery.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
@@ -3682,6 +3717,7 @@
"src/sources/nominatim.js",
"src/app/layers/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
@@ -3987,6 +4023,7 @@
"src/i18n/marker.js",
"src/data/installationFeedback.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -4442,6 +4479,7 @@
"server/providers/transitHistory.js",
"src/data/gtfsRealtime.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/sources/httpBody.js",
@@ -4458,6 +4496,7 @@
"modules": [
"src/data/gtfsRealtime.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/sources/transitHistory.js"
@@ -4491,6 +4530,7 @@
"src/data/terrainHeights.js",
"src/data/transit.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/data/transitProxy.js",
@@ -4549,6 +4589,7 @@
"src/data/iconOrientation.js",
"src/data/inputOwnership.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
@@ -4606,6 +4647,7 @@
"exports": ["./sources/transit-service"],
"modules": [
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js",
"src/data/transitProxy.js",
"src/data/gtfsRealtime.js",
@@ -4704,6 +4746,7 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/view/index.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/idSwitches.js",
@@ -4825,6 +4868,8 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/data/firmsIntensity.js",
"src/data/firmsAdapt.js",
"src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js",
@@ -4847,6 +4892,7 @@
"src/tools/surfaces.js",
"src/layers/transit/policy.js",
"src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/layers/transit/movement.js",
"src/data/contactPlayback.js",
"src/data/layerSnapshot.js",
@@ -4959,6 +5005,7 @@
"modules": [
"src/view/index.js",
"src/data/idSwitches.js",
"src/data/firmsIntensity.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/visualStylePolicy.js"
+19 -10
View File
@@ -2322,9 +2322,11 @@ async function runBehaviorLayer() {
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
);
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
// reason rather than produce the half-entered state the operator saw: a
// plane anchored under the camera with no HUD and no exit control.
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
// With Contacts OFF, status must explain a shut gate without blaming
// Contacts, and entry must either enter cleanly or refuse with the reason
// rather than produce the half-entered state the operator saw: a plane
// anchored under the camera with no HUD and no exit control.
r = await run('control_cockpit', { action: 'status' });
const gateReport = r?.state || {};
const cockpitBeforeGate = await page.evaluate(() =>
@@ -2332,10 +2334,13 @@ async function runBehaviorLayer() {
);
report(
gateReport.active === false &&
gateReport.entryAllowed === false &&
gateReport.entryBlockedReason === 'contacts-inactive' &&
(gateReport.entryAllowed === true
? gateReport.entryBlockedReason === null
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
gateReport.entryBlockedReason,
)) &&
!cockpitBeforeGate,
'behavior: cockpit status names why entry is blocked with Contacts off',
'behavior: cockpit status explains its gate without requiring Contacts',
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
);
@@ -2349,12 +2354,16 @@ async function runBehaviorLayer() {
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
);
report(
gated?.ok === false &&
/contacts/i.test(gated?.error || '') &&
!cockpitAfterGate,
'behavior: cockpit entry with Contacts off is refused, not half-entered',
gated?.ok === true
? cockpitAfterGate
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
);
if (cockpitAfterGate)
await page.evaluate(() =>
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
);
await runBundledOutlineScenarios({
page,
+1 -1
View File
@@ -157,7 +157,7 @@ export const ACTION_DESCRIPTIONS = {
},
control_cockpit: {
description:
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
$position: 1,
parameters: {
properties: {
+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"),
);
assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()');
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok(
gateIndex >= 0,
'entry consults the same gate the manual chip uses',
'entry refuses on the gate it can check before tracking',
);
assert.ok(
retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"),
);
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
assert.match(branch, /Contacts must be active to enter Cockpit/);
assert.match(branch, /Contacts is still starting up/);
// Contacts is not required: only a Context change in flight or no aircraft
// layer to ride shuts the gate before tracking, and the entry itself then
// applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
});
test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored;
}
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* (Live Flights for a civilian, Military Flights for a military aircraft) must
* be on. Contacts and the other feeds are not required. When a source the
* surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
*
* @param {object} input Current context and dependency visibility.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
* @param {object} input Current subject and Context state.
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
contextMode,
subjectLayerId,
subjectLayerEnabled,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
}
});
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), true);
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
}), false, 'Cockpit remains unavailable until a Context mode change settles');
assert.equal(cockpitEntryAllowed({
contextMode: null,
subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
}), false, "the ridden aircraft's own layer is required");
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, 'there is no aircraft to ride');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+22 -6
View File
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
assert.equal(card.details.length, 1);
assert.equal(card.details[0], 'high · 2h · N20');
assert.equal(card.selected, false);
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
});
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.notEqual(card.accent, accentForSeverity('red'));
assert.equal(card.accent, accentForSeverity('ember'));
});
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
const accentAt = (frp) =>
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
assert.equal(accentAt(4.9), accentForSeverity('ember'));
assert.equal(accentAt(5), accentForSeverity('red'));
assert.equal(accentAt(20), accentForSeverity('flame'));
assert.equal(accentAt(50), accentForSeverity('orange'));
assert.equal(accentAt(100), accentForSeverity('gold'));
assert.equal(accentAt(300), accentForSeverity('white-hot'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
accentForSeverity('white-hot'),
);
});
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
const candidate = {
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
position: { x: 0, y: 0, z: 0 },
accent: accentForSeverity('orange'),
accent: accentForSeverity('gold'),
};
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '14 FIRES');
assert.equal(card.details[0], 'max 210 MW · new 3h');
assert.equal(card.accent, accentForSeverity('orange'));
assert.equal(card.accent, accentForSeverity('gold'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
+209
View File
@@ -0,0 +1,209 @@
/**
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
* are linearly binned on an equirectangular grid (row 0 = north, column
* 0 = 180°W), blurred, normalized on a log scale and mapped through
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
* in node.
*/
/** Below this normalized heat nothing is painted: empty land stays empty. */
export const HEAT_FLOOR = 0.03;
/**
* The FRP marker ramp as a continuous glow, from a red edge through orange
* and gold to a pale white-hot core. The edge is a bright red, not the
* markers' dark ember, and starts more than half opaque: most of the world
* sits low on a scale set by the hottest regions, and a dark translucent red
* vanishes into forest and savanna imagery.
*/
export const HEAT_PALETTE = Object.freeze([
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
]);
export function heatColorAt(t) {
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
const v = Math.min(1, t);
let i = 1;
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
const a = HEAT_PALETTE[i - 1];
const b = HEAT_PALETTE[i];
const k = (v - a.at) / (b.at - a.at || 1);
const mix = (p, q) => Math.round(p + (q - p) * k);
return [
mix(a.rgb[0], b.rgb[0]),
mix(a.rgb[1], b.rgb[1]),
mix(a.rgb[2], b.rgb[2]),
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
];
}
/** Weight of a detection with no or zero FRP, in megawatts. */
export const HEAT_MIN_WEIGHT_MW = 0.5;
/**
* Heat is radiated power, not a head count: each detection adds its FRP in
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
* removes most detections without dimming the areas where strong fires burn
* (a count-based heat collapsed there and the hot spots faded).
*/
const defaultWeight = (fire) =>
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
/**
* Linear binning (Wand 1994): each detection's weight is shared between the
* four pixel centres around it in proportion to closeness, instead of
* dropping whole into one pixel. Spots then sit at the true position rather
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
* into a round spot instead of a square or a plus sign. Longitude wraps;
* rows beyond the poles fold back onto the edge row.
*/
export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height);
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
const col = (x) => ((x % width) + width) % width;
for (const fire of fires) {
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
const fx = ((fire.lon + 180) / 360) * width - 0.5;
const fy = ((90 - fire.lat) / 180) * height - 0.5;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const w = weight(fire);
const top = row(y0);
const bottom = row(y0 + 1);
const left = col(x0);
const right = col(x0 + 1);
grid[top + left] += w * (1 - tx) * (1 - ty);
grid[top + right] += w * tx * (1 - ty);
grid[bottom + left] += w * (1 - tx) * ty;
grid[bottom + right] += w * tx * ty;
}
return grid;
}
function kernel(sigma) {
const radius = Math.max(1, Math.ceil(sigma * 3));
const k = new Float32Array(radius * 2 + 1);
let sum = 0;
for (let i = -radius; i <= radius; i++) {
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
sum += k[i + radius];
}
for (let i = 0; i < k.length; i++) k[i] /= sum;
return { k, radius };
}
export function blurGaussian(grid, width, height, sigma) {
const { k, radius } = kernel(sigma);
const tmp = new Float32Array(grid.length);
const out = new Float32Array(grid.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const v = grid[row + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const xx = (((x + i) % width) + width) % width; // longitude wraps
tmp[row + xx] += v * k[i + radius];
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const v = tmp[y * width + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
out[yy * width + x] += v * k[i + radius];
}
}
}
return out;
}
/** Bins of the log-density histogram used to find the reference quantile. */
const QUANTILE_BINS = 4096;
/**
* The p-quantile of the non-zero values, from a histogram of log1p(value):
* one linear pass instead of sorting ~350k burning pixels (a sort cost
* 200 ms+ and dominated the build). Bin width on the log axis is
* max/4096, far finer than the 256-step palette it feeds.
*/
function percentileOfNonZero(values, p) {
let count = 0;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) {
count += 1;
if (v > max) max = v;
}
}
if (!count) return 0;
const logMax = Math.log1p(max);
const bins = new Uint32Array(QUANTILE_BINS);
const toBin = (QUANTILE_BINS - 1) / logMax;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
}
const target = Math.floor(p * (count - 1));
let seen = 0;
for (let b = 0; b < QUANTILE_BINS; b++) {
seen += bins[b];
if (seen > target) return Math.expm1((b + 1) / toBin);
}
return max;
}
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
const LUT_STEPS = 256;
let paletteLut = null;
function heatLut() {
if (!paletteLut) {
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
for (let i = 0; i < LUT_STEPS; i++) {
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
}
}
return paletteLut;
}
export function buildHeatRaster(
fires,
{ width, height, sigmaPx, percentile = 0.995 },
) {
const density = blurGaussian(
binFires(fires, { width, height }),
width,
height,
sigmaPx,
);
const reference = percentileOfNonZero(density, percentile);
const rgba = new Uint8ClampedArray(width * height * 4);
if (reference > 0) {
const lut = heatLut();
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
for (let i = 0; i < density.length; i++) {
const d = density[i];
if (d <= 0) continue;
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
const l = step * 4;
if (lut[l + 3] === 0) continue;
const o = i * 4;
rgba[o] = lut[l];
rgba[o + 1] = lut[l + 1];
rgba[o + 2] = lut[l + 2];
rgba[o + 3] = lut[l + 3];
}
}
return { width, height, rgba, reference };
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HEAT_MIN_WEIGHT_MW,
HEAT_PALETTE,
heatColorAt,
binFires,
blurGaussian,
buildHeatRaster,
} from './firmsHeatRaster.js';
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
test('palette runs ember to white-hot and starts fully transparent', () => {
assert.equal(HEAT_PALETTE[0].at, 0);
assert.equal(HEAT_PALETTE.at(-1).at, 1);
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
const low = heatColorAt(0.15);
const high = heatColorAt(1);
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
assert.ok(high[3] > low[3], 'hotter is more opaque');
for (let i = 1; i <= 100; i++) {
const [r, , b] = heatColorAt(i / 100);
assert.ok(r >= b, `no cold hue at ${i}%`);
}
});
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
width: 4,
height: 2,
});
assert.equal(grid[0], 1, 'north-west pixel');
assert.equal(grid[7], 1, 'south-east pixel');
assert.equal(
grid.reduce((a, b) => a + b, 0),
2,
);
});
test('binFires shares a detection between the pixels around it', () => {
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
assert.equal(between[1], 0.5, 'half to the pixel west of it');
assert.equal(between[2], 0.5, 'half to the pixel east of it');
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
assert.equal(quarter[0], 0.75, 'closer centre takes more');
assert.equal(quarter[1], 0.25);
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
assert.equal(wrapped[0], 0.5);
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
});
test('binFires weights each detection by its radiative power', () => {
const grid = binFires(
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
{ width: 4, height: 2 },
);
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
});
test('filtering out weak fires keeps a strong fire hot', () => {
const strong = fire(0.1, 0.1, 300);
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
const options = { width: 360, height: 180, sigmaPx: 2 };
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
const red = (r) => r.rgba[pixel * 4];
const alpha = (r) => r.rgba[pixel * 4 + 3];
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
const filtered = buildHeatRaster([strong, ...reference], options);
assert.ok(alpha(filtered) > 0);
assert.ok(
alpha(filtered) >= alpha(unfiltered) * 0.9,
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
);
assert.ok(
red(filtered) >= red(unfiltered) * 0.9,
`red ${red(unfiltered)} -> ${red(filtered)}`,
);
});
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
const w = 16;
const h = 8;
const grid = new Float32Array(w * h);
grid[3 * w + 0] = 1;
const out = blurGaussian(grid, w, h, 1.5);
const sum = out.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
});
test('buildHeatRaster is transparent where there are no fires', () => {
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
width: 360,
height: 180,
sigmaPx: 2,
});
assert.equal(r.rgba.length, 360 * 180 * 4);
const alphaAt = (lat, lon) => {
const x = Math.floor(((lon + 180) / 360) * 360);
const y = Math.floor(((90 - lat) / 180) * 180);
return r.rgba[(y * 360 + x) * 4 + 3];
};
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
});
test('buildHeatRaster with no fires is fully transparent', () => {
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
assert.ok(r.rgba.every((v) => v === 0));
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @module firmsIntensity
* @description Fire intensity policy shared by the Active Fires layer and the
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
* slider that doubles as its legend, and the one filter every consumer
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
* before anything is rendered or selected. Pure — no Cesium, no DOM.
*
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
* down: a dim ember for weak fires through red, orange and gold to a pale
* white-hot core, lightness rising with power, so the brightest marks are the
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
* blue start painted most of the map blue. The thresholds are GEV's own, set
* on a real week's percentiles so every color appears: Portolan's
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
*/
/**
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
*/
export const FRP_RAMP = Object.freeze([
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
]);
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
export const FRP_SLIDER_TICKS = Object.freeze(
FRP_RAMP.map((stop) => stop.minFrp),
);
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
export const MIN_FRP_MW_MIN = 0;
export const MIN_FRP_MW_MAX = 10_000;
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
/**
* Normalized-confidence floor for a switch position. The thresholds match
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
*/
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
export const DEFAULT_FIRE_FILTER = Object.freeze({
minFrp: 0,
confidence: 'all',
});
/**
* The ramp stop for a fire radiative power.
* @param {number} frp Megawatts; non-finite or negative reads as 0.
* @returns {{name: string, minFrp: number, hex: string}}
*/
export function frpRampStop(frp) {
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
let stop = FRP_RAMP[0];
for (const candidate of FRP_RAMP) {
if (value >= candidate.minFrp) stop = candidate;
else break;
}
return stop;
}
/** `#rrggbb` → `[r, g, b]` in 0..255. */
export function hexToRgb(hex) {
const text = String(hex).replace('#', '');
return [0, 2, 4].map((offset) =>
Number.parseInt(text.slice(offset, offset + 2), 16),
);
}
/**
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
* nothing worth a tick.
*/
export const SLIDER_MAX_MW = 1000;
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
/**
* Track position in 0..1 for a minimum FRP.
* @param {number} minFrp Megawatts.
* @returns {number}
*/
export function sliderPositionForFrp(minFrp) {
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
}
/**
* Minimum FRP for a track position, rounded to whole megawatts so a slider
* value and its share-link form are the same number.
* @param {number} position 0..1.
* @returns {number}
*/
export function frpForSliderPosition(position) {
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
if (p <= 0) return 0;
const frp = Math.pow(10, p * LOG_MAX);
return Math.max(1, Math.round(frp));
}
/**
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
* slider track IS the legend: the color under the thumb is the color of the
* weakest fire still shown.
* @returns {string}
*/
export function rampTrackGradient() {
const parts = [];
FRP_RAMP.forEach((stop, index) => {
const start = sliderPositionForFrp(stop.minFrp) * 100;
const next = FRP_RAMP[index + 1];
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
});
return `linear-gradient(90deg, ${parts.join(', ')})`;
}
/**
* Minimum FRP → whole megawatts inside the codec bounds, or null.
* @param {*} value
* @returns {?number}
*/
export function normalizeMinFrp(value) {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(number)) return null;
const rounded = Math.round(number);
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
? rounded
: null;
}
/**
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
* @param {*} value
* @returns {?string}
*/
export function normalizeConfidenceMode(value) {
const text = String(value ?? '')
.trim()
.toLowerCase();
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
}
/**
* Sanitize a partial filter against the current one; unknown or invalid
* fields keep their current value.
* @param {Object} [params]
* @param {{minFrp: number, confidence: string}} [current]
* @returns {{minFrp: number, confidence: string}}
*/
export function normalizeFireFilter(
params = {},
current = DEFAULT_FIRE_FILTER,
) {
const minFrp = normalizeMinFrp(params?.minFrp);
const confidence = normalizeConfidenceMode(params?.confidence);
return {
minFrp: minFrp === null ? current.minFrp : minFrp,
confidence: confidence === null ? current.confidence : confidence,
};
}
/**
* Whether one fire passes the filter. `confidence` is the normalized 0..1
* value firmsAdapt produces; `frp` is megawatts.
* @param {{frp: number, confidence: number}} fire
* @param {{minFrp: number, confidence: string}} filter
* @returns {boolean}
*/
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
if (frp < (filter.minFrp || 0)) return false;
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
if (floor <= 0) return true;
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
return confidence >= floor;
}
/**
* The fires that pass the filter, in input order. Returns the SAME array when
* the filter is the default, so callers can tell "nothing hidden" cheaply.
* @template T
* @param {Array<T>} fires Internal records (normalized confidence).
* @param {{minFrp: number, confidence: string}} filter
* @returns {Array<T>}
*/
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
if (!Array.isArray(fires)) return [];
if (isDefaultFireFilter(filter)) return fires;
return fires.filter((fire) => firePassesFilter(fire, filter));
}
/** True when the filter hides nothing. */
export function isDefaultFireFilter(filter) {
return (
!(filter?.minFrp > 0) &&
(filter?.confidence === 'all' || filter?.confidence == null)
);
}
+221
View File
@@ -0,0 +1,221 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_FIRE_FILTER,
FIRMS_CONFIDENCE_MODES,
FRP_RAMP,
FRP_SLIDER_TICKS,
filterFires,
firePassesFilter,
frpForSliderPosition,
frpRampStop,
hexToRgb,
isDefaultFireFilter,
normalizeConfidenceMode,
normalizeFireFilter,
normalizeMinFrp,
rampTrackGradient,
sliderPositionForFrp,
} from './firmsIntensity.js';
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
assert.deepEqual(
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
[
['ember', 0, '#9e2a1b'],
['red', 5, '#d2401f'],
['flame', 20, '#f06a28'],
['orange', 50, '#fb9a2e'],
['gold', 100, '#fdc84a'],
['white-hot', 300, '#fff3b0'],
],
);
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
assert.ok(Object.isFrozen(FRP_RAMP));
});
test('every stop is warm and each is lighter than the one below', () => {
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
let previous = -1;
for (const stop of FRP_RAMP) {
const rgb = hexToRgb(stop.hex);
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
assert.ok(
luma(rgb) > previous,
`${stop.name} is lighter than the stop below`,
);
previous = luma(rgb);
}
});
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
assert.equal(frpRampStop(0).name, 'ember');
assert.equal(frpRampStop(4.99).name, 'ember');
assert.equal(frpRampStop(5).name, 'red');
assert.equal(frpRampStop(19.9).name, 'red');
assert.equal(frpRampStop(20).name, 'flame');
assert.equal(frpRampStop(50).name, 'orange');
assert.equal(frpRampStop(100).name, 'gold');
assert.equal(frpRampStop(299.9).name, 'gold');
assert.equal(frpRampStop(300).name, 'white-hot');
assert.equal(frpRampStop(1246).name, 'white-hot');
assert.equal(frpRampStop(NaN).name, 'ember');
assert.equal(frpRampStop(-3).name, 'ember');
assert.equal(frpRampStop(undefined).name, 'ember');
});
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
});
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
assert.equal(sliderPositionForFrp(0), 0);
assert.equal(sliderPositionForFrp(0.5), 0);
assert.equal(sliderPositionForFrp(1), 0);
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
assert.equal(sliderPositionForFrp(1000), 1);
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
assert.equal(frpForSliderPosition(0), 0);
assert.equal(frpForSliderPosition(1), 1000);
assert.equal(frpForSliderPosition(-1), 0);
assert.equal(frpForSliderPosition(2), 1000);
for (const frp of FRP_SLIDER_TICKS) {
assert.equal(
frpForSliderPosition(sliderPositionForFrp(frp)),
frp,
`tick ${frp} MW round-trips through the axis`,
);
}
// Ticks spread along the track instead of bunching at one end.
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
for (let i = 1; i < positions.length; i += 1) {
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
}
});
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
const gradient = rampTrackGradient();
assert.match(gradient, /^linear-gradient\(90deg, /);
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
});
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
assert.equal(normalizeMinFrp(0), 0);
assert.equal(normalizeMinFrp(19.6), 20);
assert.equal(normalizeMinFrp('100'), 100);
assert.equal(normalizeMinFrp(10000), 10000);
assert.equal(normalizeMinFrp(10001), null);
assert.equal(normalizeMinFrp(-1), null);
assert.equal(normalizeMinFrp('abc'), null);
assert.equal(normalizeMinFrp(''), null);
assert.equal(normalizeMinFrp(null), null);
assert.equal(normalizeMinFrp(NaN), null);
});
test('confidence modes are exactly all / nominal / high', () => {
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(normalizeConfidenceMode(' High '), 'high');
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
assert.equal(normalizeConfidenceMode('low'), null);
assert.equal(normalizeConfidenceMode(0.9), null);
assert.equal(normalizeConfidenceMode(undefined), null);
});
test('a partial filter patches the current one and invalid fields keep their value', () => {
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
minFrp: 20,
confidence: 'all',
});
assert.deepEqual(
normalizeFireFilter(
{ confidence: 'high' },
{ minFrp: 50, confidence: 'all' },
),
{ minFrp: 50, confidence: 'high' },
);
assert.deepEqual(
normalizeFireFilter(
{ minFrp: 'nope', confidence: 'medium' },
{ minFrp: 50, confidence: 'nominal' },
),
{ minFrp: 50, confidence: 'nominal' },
);
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
});
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
const low = { frp: 864, confidence: 0.3 };
const nominal = { frp: 20, confidence: 0.6 };
const high = { frp: 2, confidence: 0.9 };
assert.equal(firePassesFilter(low), true, 'default shows everything');
assert.equal(
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
false,
);
assert.equal(
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
false,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
false,
);
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
assert.equal(
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
false,
);
// Missing numbers read as 0, never as a pass-through.
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
assert.equal(
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
false,
);
});
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
const fires = [
{ frp: 864, confidence: 0.3 },
{ frp: 20, confidence: 0.6 },
{ frp: 2, confidence: 0.9 },
{ frp: 120, confidence: 0.9 },
];
assert.equal(filterFires(fires), fires);
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
assert.deepEqual(
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
[864, 20, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
[2, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
(f) => f.frp,
),
[120],
);
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
});
+151
View File
@@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HEAT_REBUILD_MS } from '../layers/firms/policy.js';
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
@@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const priorDocument = globalThis.document;
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
});
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const added = [];
try {
h.layer.attachShellServices({
imageryHost: () => ({
kind: 'globe',
collection: {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove() {},
raiseToTop() {},
},
}),
});
h.layer._renderLodForTest('close');
assert.equal(added.length, 0, 'the markers paint first; the build trails');
t.mock.timers.tick(HEAT_REBUILD_MS);
assert.equal(added.length, 1, 'then the heatmap is draped');
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
} finally {
h.layer.destroy(h.viewer);
h.cleanup();
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
}
});
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
};
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
let markers = null;
// The fade reads the camera height; the cull reads scene.camera.
const setHeight = (height) => {
h.viewer.camera = { positionCartographic: { height } };
};
setHeight(12_000_000);
h.viewer.scene.primitives = {
add(primitive) {
markers = primitive;
},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove: (layer, destroy) => removed.push({ layer, destroy }),
raiseToTop() {},
};
h.layer.attachShellServices({
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(added[0].alpha, 1);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.ok(h.published.length > 0, 'the far band still paints cards');
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
setHeight(1_000_000);
h.layer._renderLodForTest('close');
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
assert.equal(added[0].alpha, 0, 'faded out under the markers');
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 1);
// Mid hand-off both are drawn, half each, in either band.
setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 0.5);
h.layer._renderLodForTest('regional');
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
);
} finally {
h.cleanup();
restoreDocument();
}
});
+14 -9
View File
@@ -4,6 +4,8 @@
* dedicated card canvas moved into the shared world-overlay host.
*/
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
return `firms:${lat}:${lon}:${acq}:${source}`;
}
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
const ACCENT_RGB = Object.freeze({
red: '224, 82, 82',
orange: '240, 178, 62',
yellow: '244, 227, 108',
});
/**
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
*/
const ACCENT_RGB = Object.freeze(
Object.fromEntries(
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
),
);
/**
* Severity stop name → "r, g, b" accent string (defaults to yellow).
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
* @returns {string}
*/
export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
}
/**
+10 -5
View File
@@ -9,11 +9,16 @@ import {
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('red'), '224, 82, 82');
assert.equal(accentForSeverity('orange'), '240, 178, 62');
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
assert.equal(accentForSeverity('ember'), '158, 42, 27');
assert.equal(accentForSeverity('red'), '210, 64, 31');
assert.equal(accentForSeverity('flame'), '240, 106, 40');
assert.equal(accentForSeverity('orange'), '251, 154, 46');
assert.equal(accentForSeverity('gold'), '253, 200, 74');
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
+20 -1
View File
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
}
const OPTION_GROUPS = Object.freeze({
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
// the codec side; the panel's log slider rounds to it. Both default to
// "show everything": many of the strongest fires are rated low confidence.
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
// firmsIntensity.js (pinned by layerState.test); spelled out here because
// this file runs in isolation in the token-ledger tests.
'local-firms': Object.freeze([
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
all: 'a',
nominal: 'n',
high: 'h',
}),
]),
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't',
@@ -626,7 +640,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'd',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-firms',
token: 'w',
disposition: 'enabled+options',
optionOwner: 'local-firms',
}),
Object.freeze({
id: 'military',
token: 'm',
+82 -1
View File
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
function deferred() {
let resolve;
@@ -3007,9 +3008,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
assert.equal(
results.find((result) => result.layerId === id).succeeded,
false,
);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
function firmsOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', 'w'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['local-firms'];
}
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
const state = createDefaultLayerState();
assert.deepEqual(state.options['local-firms'], {
minFrp: 0,
confidence: 'all',
});
state.enabledLayerIds = ['local-firms'];
state.options['local-firms'] = { minFrp: 20, confidence: 'high' };
const encoded = encode(state);
assert.equal(new URLSearchParams(encoded).get('l'), 'w');
// Other owners write their own explicit defaults into `lo` too (flights 3D,
// wind overlay); the fire assignments ride beside them.
const assignments = new URLSearchParams(encoded).get('lo').split('_');
assert.ok(assignments.includes('w.f.20'), assignments.join(' '));
assert.ok(assignments.includes('w.c.h'), assignments.join(' '));
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
assert.deepEqual(decoded.options['local-firms'], {
minFrp: 20,
confidence: 'high',
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'local-firms'
],
state.options['local-firms'],
);
// Defaults are omitted from the link: a plain `l=w` is "show everything".
const plain = createDefaultLayerState();
plain.enabledLayerIds = ['local-firms'];
assert.equal(
new URLSearchParams(encode(plain))
.get('lo')
.split('_')
.filter((assignment) => assignment.startsWith('w.')).length,
0,
);
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
});
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
// The codec spells its bounds out (it runs in isolation in the ledger
// tests); they must stay the layer's.
assert.equal(MIN_FRP_MW_MAX, 10000);
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(firmsOptions('w.f.300').minFrp, 300);
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
assert.equal(
firmsOptions('w.f.2.5').minFrp,
0,
'a third segment is rejected',
);
assert.equal(firmsOptions('w.c.a').confidence, 'all');
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
assert.equal(firmsOptions('w.c.h').confidence, 'high');
assert.equal(
firmsOptions('w.c.x').confidence,
'all',
'unknown code → default',
);
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
minFrp: 5,
confidence: 'nominal',
});
});
+50
View File
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
// status its module never emits guards nothing. See the settled-but-fetching
// tests below, which use militaryInstallations' genuine first-fetch readout.
layerStats = {},
subscribe = undefined,
} = {}) {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
setEnabled,
getLayerLifecycleState,
layers,
...(subscribe ? { subscribe } : {}),
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
}
});
test('a dependency the user adopts stays on when Contacts turns off', async () => {
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
// coordinator must give it up, or its OFF release tears the layer down —
// and the track or the Cockpit with it — before Context restores it.
const calls = [];
const subscribers = new Set();
const runtime = installAwarenessRuntime({
isEnabled: () => false,
isEffectivelyEnabled: () => false,
setEnabled: (layerId, enabled) => {
calls.push({ layerId, enabled });
return Promise.resolve(true);
},
subscribe: (listener) => {
subscribers.add(listener);
return () => subscribers.delete(listener);
},
});
try {
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
for (const listener of subscribers)
listener({
type: 'visibility',
layerId: 'flights',
enabled: true,
origin: 'user',
adopted: true,
adoptedFromSelection: true,
});
await militaryAwarenessLayer.disable();
const released = calls
.filter(({ enabled }) => !enabled)
.map(({ layerId }) => layerId)
.sort();
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
);
} finally {
runtime.restore();
}
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
});
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
let enabled = false;
let releaseAircraft;
+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/**
* @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_ROUTE_NAMES[routeId]
: routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]);
/**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
+3 -2
View File
@@ -1041,10 +1041,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint; voice geometry
// adds the analyst area scopes and the osm-places layer.
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
// control_cockpit states the own-layer entry rule (Contacts not required).
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+16
View File
@@ -2187,6 +2187,9 @@
"{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
@@ -3214,6 +3217,19 @@
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"fire confidence filter|All": "الكل",
"fire confidence filter|Nominal+": "متوسطة فأعلى",
"fire confidence filter|High": "عالية",
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "الشدة (FRP)",
"≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
"fire intensity filter|All": "الكل",
"Detection confidence": "ثقة الرصد",
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
"Name of {name}": "اسم {name}",
"Change the colour of {name}": "تغيير لون {name}",
"Colour of {name}": "لون {name}",
+16
View File
@@ -2087,6 +2087,9 @@
"{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
@@ -2946,6 +2949,19 @@
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS",
"fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch",
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
"Fire intensity (FRP)": "Intensität (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Konfidenz",
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
"fire intensity filter|All": "Alle",
"Detection confidence": "Konfidenz der Erkennung",
"{shown} of {total} shown": "{shown} von {total} angezeigt",
"Name of {name}": "Name von {name}",
"Change the colour of {name}": "Farbe von {name} ändern",
"Colour of {name}": "Farbe von {name}",
+16
View File
@@ -1920,6 +1920,9 @@
"N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft",
"kts": "kn",
@@ -2983,6 +2986,19 @@
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta",
"Fire intensity (FRP)": "Intensidad (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confianza",
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "{shown} de {total} mostrados",
"{lower} to {upper}": "{lower} a {upper}",
"Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}",
+16
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
@@ -3016,6 +3019,19 @@
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS",
"fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée",
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
"High confidence detections only": "Détections de confiance élevée uniquement",
"Fire intensity (FRP)": "Intensité (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confiance",
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
"fire intensity filter|All": "Toutes",
"Detection confidence": "Confiance de la détection",
"{shown} of {total} shown": "{shown} sur {total} affichées",
"Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}",
+16
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
@@ -3016,6 +3019,19 @@
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
"High confidence detections only": "Solo rilevamenti con confidenza alta",
"Fire intensity (FRP)": "Intensità (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confidenza",
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
"fire intensity filter|All": "Tutti",
"Detection confidence": "Confidenza del rilevamento",
"{shown} of {total} shown": "{shown} di {total} mostrati",
"Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}",
+17 -1
View File
@@ -1612,6 +1612,9 @@
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1684,7 +1687,7 @@
"Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
@@ -2876,6 +2879,19 @@
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS",
"fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
"Nominal and high confidence detections": "信頼度が中・高の検知",
"High confidence detections only": "信頼度が高の検知のみ",
"Fire intensity (FRP)": "強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "信頼度",
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
"fire intensity filter|All": "すべて",
"Detection confidence": "検知の信頼度",
"{shown} of {total} shown": "{total}件中{shown}件を表示",
"Name of {name}": "{name}の名前",
"Change the colour of {name}": "{name}の色を変更",
"Colour of {name}": "{name}の色",
+17 -1
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
@@ -1736,7 +1739,7 @@
"Restore real aircraft": "Restaurar a aeronave real",
"Reclassify as TR-3B": "Reclassificar como TR-3B",
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": {
@@ -3016,6 +3019,19 @@
"Right panels": "Painéis à direita",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas as detecções — muitos dos incêndios mais intensos têm confiança baixa",
"Nominal and high confidence detections": "Detecções de confiança nominal e alta",
"High confidence detections only": "Somente detecções de confiança alta",
"Fire intensity (FRP)": "Intensidade (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confiança",
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
"fire intensity filter|All": "Todas",
"Detection confidence": "Confiança da detecção",
"{shown} of {total} shown": "{shown} de {total} exibidas",
"Name of {name}": "Nome de {name}",
"Change the colour of {name}": "Mudar a cor de {name}",
"Colour of {name}": "Cor de {name}",
+17 -1
View File
@@ -458,7 +458,7 @@
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
"Select flight {title}": "Выбрать рейс {title}",
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных",
@@ -1953,6 +1953,9 @@
"N/A": "Н/Д",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"ft": "фт",
"kts": "уз",
@@ -3053,6 +3056,19 @@
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"fire confidence filter|All": "Все",
"fire confidence filter|Nominal+": "Средняя+",
"fire confidence filter|High": "Высокая",
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
"High confidence detections only": "Только детекции высокой достоверности",
"Fire intensity (FRP)": "Интенсивность (FRP)",
"≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достоверность",
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
"fire intensity filter|All": "Все",
"Detection confidence": "Достоверность детекции",
"{shown} of {total} shown": "показано {shown} из {total}",
"{lower} to {upper}": "от {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "От {lower}",
+17 -1
View File
@@ -1683,6 +1683,9 @@
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
@@ -1761,7 +1764,7 @@
"Restore real aircraft": "Повернути справжній літак",
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": {
@@ -3045,6 +3048,19 @@
"Don't show this again": "Більше не показувати",
"ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"fire confidence filter|All": "Усі",
"fire confidence filter|Nominal+": "Середня+",
"fire confidence filter|High": "Висока",
"Every detection — many of the strongest fires are rated low confidence": "Усі виявлення — багато найсильніших пожеж мають низьку достовірність",
"Nominal and high confidence detections": "Виявлення середньої та високої достовірності",
"High confidence detections only": "Лише виявлення високої достовірності",
"Fire intensity (FRP)": "Інтенсивність (FRP)",
"≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достовірність",
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
"fire intensity filter|All": "Усі",
"Detection confidence": "Достовірність виявлення",
"{shown} of {total} shown": "показано {shown} з {total}",
"{lower} to {upper}": "від {lower} до {upper}",
"Up to {upper}": "До {upper}",
"From {lower}": "Від {lower}",
+17 -1
View File
@@ -1588,6 +1588,9 @@
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
"CONTEXT STANDBY": "态势待机",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
@@ -1660,7 +1663,7 @@
"Restore real aircraft": "恢复真实飞机",
"Reclassify as TR-3B": "重新归类为 TR-3B",
"TRACK ACQUIRED": "已获取航迹",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": {
@@ -2843,6 +2846,19 @@
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "标称+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
"Nominal and high confidence detections": "标称和高置信度的检测",
"High confidence detections only": "仅高置信度的检测",
"Fire intensity (FRP)": "火势强度 (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "检测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起",
+17 -1
View File
@@ -1578,6 +1578,9 @@
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
"CONTEXT STANDBY": "態勢待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
@@ -1650,7 +1653,7 @@
"Restore real aircraft": "恢復真實飛機",
"Reclassify as TR-3B": "重新歸類為 TR-3B",
"TRACK ACQUIRED": "已獲取航跡",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": {
@@ -2882,6 +2885,19 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "標稱+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
"Nominal and high confidence detections": "標稱和高置信度的檢測",
"High confidence detections only": "僅高置信度的檢測",
"Fire intensity (FRP)": "火勢強度 (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "檢測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
+12 -2
View File
@@ -201,6 +201,7 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"es": [
@@ -307,6 +308,7 @@
"WX",
"audio {state}",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -329,6 +331,7 @@
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -515,6 +518,7 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"it": [
@@ -687,6 +691,7 @@
"{value} KM/H",
"{value} KM/S",
"{value} conf",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -738,6 +743,7 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"≥ {value} MW",
"▲ {frp} MW"
],
"pt": [
@@ -851,6 +857,7 @@
"WHOT/BHOT",
"WX",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -869,7 +876,8 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"≥ {value} MW"
],
"ru": [
"AIS: --",
@@ -928,9 +936,9 @@
"MM",
"NVG",
"REC",
"SRC {value}",
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
@@ -955,6 +963,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -1012,6 +1021,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
+1
View File
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
parts.dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
+20
View File
@@ -79,6 +79,25 @@ export function createDependencies({
});
}
/**
* Give up a dependency the user adopts. A direct selection, or the Cockpit
* riding one of its aircraft, promotes a layer Contacts turned on into the
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
* Contacts goes off would tear down that track or Cockpit before the
* Context restore could put the layer back.
*/
function relinquishAdoptedDependency(change) {
if (change?.type === 'visibility' && change.adopted && change.enabled)
layerState.ownedDependencies.delete(change.layerId);
}
/** Follow one manager's adoptions; null stops following. */
function watchAdoptions(dataManager) {
layerState.unwatchAdoptions?.();
layerState.unwatchAdoptions =
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
}
function activateOperationalContext() {
const activationId = ++layerState.activationId;
layerState.autoFocusRetryPending = false;
@@ -113,6 +132,7 @@ export function createDependencies({
refreshAfterDeferredDependency,
enableDependencies,
releaseOwnedDependencies,
watchAdoptions,
activateOperationalContext,
};
}
+1
View File
@@ -340,6 +340,7 @@ export function createLifecycle({
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
parts.dependencies.watchAdoptions(null);
layerState.dataManager = null;
},
};
+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+1
View File
@@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
stats.count ?? null,
stats.status || null,
stats.error || null,
stats.source || null,
];
})
.map((parts) => parts.join(':'))
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createModel } from './model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { layerFeedState } from '../../data/feedState.js';
import aisLiveVesselsLayer, {
_setVesselStateForTest,
_beginAisSessionForTest,
_applyAisFeedSnapshotForTest,
_setAisRuntimeForTest,
} from '../../data/aisLiveVessels.js';
test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => {
globalThis.document ??= { body: null };
const services = {
installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: { getNearby: () => [] },
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
let states;
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
const realQueries = createQueries({ state: layerState, services, parts });
parts.queries = { ...realQueries, collectSourceStates: () => states };
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const flights = (lastUpdate) => ({
available: true, enabled: true, stale: false,
stats: { source: 'OpenSky Network', count: 4, lastUpdate },
});
const vessels = (loading) => ({
available: true, enabled: true, stale: true,
stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading },
});
const rest = {
military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } },
'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
// 1. Stale AIS cache, nothing loading.
states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject(true);
assert.equal(ais().sourceStatus, 'stale');
// 2. AIS re-poll in flight while the flights poll lands (revision changes).
states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest };
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale');
// 3. AIS poll returns the same stale snapshot; only loading flips back.
states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too');
});
function fakeRuntime() {
return {
now: () => 1000,
setTimeout: () => ({}),
clearTimeout() {},
};
}
test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => {
globalThis.document ??= { body: null };
_setAisRuntimeForTest(fakeRuntime());
_setVesselStateForTest({ viewer: {}, records: [] });
try {
_beginAisSessionForTest();
// What server/providers/vessels/ais-live.js answers while its socket connects.
_applyAisFeedSnapshotForTest({}, {
rows: [], source: 'AISStream', status: 'connecting', refreshing: true,
});
const vesselStats = aisLiveVesselsLayer.getStats();
assert.equal(vesselStats.loading, true);
assert.equal(vesselStats.stale, true);
assert.equal(vesselStats.lastUpdate, null);
assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING');
const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 };
const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 };
const installStats = { coverage: { kind: 'subject', radiusM: 100000 } };
const statsById = {
flights: flightsStats,
military: militaryStats,
'ais-live-vessels': vesselStats,
'military-installations': installStats,
};
const services = {
installations: { getNearby: () => [], getStats: () => installStats },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: aisLiveVesselsLayer,
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
dataManager: {
isEnabled: () => true,
getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }),
layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])),
},
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
parts.queries = createQueries({ state: layerState, services, parts });
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels'];
assert.equal(vesselsSource.available, false, 'never-answered source is unavailable');
subject.refreshSelectedSubject(true);
const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING');
} finally {
_setVesselStateForTest({ enabled: false });
_setAisRuntimeForTest();
}
});
+2
View File
@@ -26,6 +26,8 @@ export function createState({ services }) {
// layers activate only after Contacts is explicitly selected.
passive: true,
ownedDependencies: new Set(),
/** Stops following the manager's adoptions (see watchAdoptions). */
unwatchAdoptions: null,
subject: null,
results: null,
visual: null,
+17
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,18 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// A poll in flight keeps FALLBACK or STALE: the revision key ignores loading.
// Before the first answer there is no snapshot, so loading stays.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(
sourceState.available
? { ...sourceState.stats, loading: false }
: sourceState.stats,
);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +107,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +127,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +147,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +165,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+75 -6
View File
@@ -12,20 +12,28 @@
* Cameras without an identified model are untouched — unknown never implies
* anything.
*
* Spec records are vendored verbatim from the CCTV Camera Database
* Specification fields are vendored from the CCTV Camera Database
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
* of 29,000+ camera models transcribed from manufacturer datasheets (76% with
* FOV, as of v2.29.0). This module vendors records that the integrated public
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
* appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC
* PTZs for the City of Amsterdam camera register proposed in #251; packs that
* publish other hardware models can grow it, or vendor the full extract.
*
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
* night vision) — never endpoints, stream paths, or access/config data.
*/
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
/**
* Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch
* MIC records from v2.29.0 with titles shortened to model codes.
* A Bosch MIC order code ends in a colour letter (B, G, W; registries write
* `x`) on one shared datasheet. The colours are aliases of one record whose
* `model` leaves the letter out, so the HUD never names a variant the source
* did not.
*/
const RECORDS = [
{
id: 'axis-q6135-le',
@@ -264,6 +272,67 @@ const RECORDS = [
autotracking: true,
environment: ['outdoor'],
},
{
id: 'bosch-mic-7130-pb4',
brand: 'Bosch',
model: 'MIC-7130-P_4',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/',
aliases: [
'MIC IP starlight 7000 HD',
'MIC-7130-PB4',
'MIC-7130-PG4',
'MIC-7130-PW4',
'MIC-7130-Px4',
],
fov_deg: '2.1-59',
resolution: { mp: 1, w: 1280, h: 720, label: '720p' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-mic-7502-z30b',
brand: 'Bosch',
model: 'MIC-7502-Z30',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/',
aliases: [
'MIC-7502-Z30B',
'MIC-7502-Z30G',
'MIC-7502-Z30W',
'MIC-7502-Z30x',
],
fov_deg: '2.3-63.7',
resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' },
ptz: true,
autotracking: true,
varifocal: true,
night_vision: { type: 'color' },
environment: ['outdoor'],
},
{
id: 'bosch-mic-7522-z30b',
brand: 'Bosch',
model: 'MIC-7522-Z30',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/',
aliases: [
'MIC-7522-Z30B',
'MIC-7522-Z30G',
'MIC-7522-Z30W',
'MIC-7522-Z30x',
],
fov_deg: '2.1-58.3',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
ptz: true,
varifocal: true,
night_vision: { type: 'color' },
environment: ['outdoor'],
ndaa_compliant: true,
},
{
id: 'cohu-3920-idome',
brand: 'Cohu',
@@ -431,7 +500,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => {
* guesses).
* @param {string} model - Hardware model as named by a source pack or seed.
* @param {string} [manufacturer] - The source's manufacturer, when published.
* @returns {Object|null} The verbatim spec record, or null when unknown.
* @returns {Object|null} The matched spec record, or null when unknown.
*/
export function lookupModelSpec(model, manufacturer) {
const query = words(model).map(stripVariantSuffix);
+89
View File
@@ -245,6 +245,95 @@ test('King County snapshot model strings still resolve', () => {
);
});
test('Amsterdam camera register model strings resolve', () => {
// The register writes the Bosch MIC colour letter as `x`.
assert.equal(
lookupModelSpec('Bosch MIC-7522-Z30x')?.id,
'bosch-mic-7522-z30b',
);
assert.equal(
lookupModelSpec('Bosch MIC-7502-Z30x')?.id,
'bosch-mic-7502-z30b',
);
assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4');
assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b');
// Its ANPR units are not in the table: unknown, never a guess.
assert.equal(lookupModelSpec('CSG-8800'), null);
// The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand
// is needed) stay apart.
assert.equal(
lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id,
'bosch-mic-7130-pb4',
);
assert.equal(
lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id,
'bosch-autodome-ip-starlight-7000-hd',
);
// The MIC datasheets state FOV without an axis, so no horizontal range.
assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null);
});
test('Bosch MIC colour aliases resolve without accepting unknown variants', () => {
for (const [pattern, id] of [
['MIC-7130-P?4', 'bosch-mic-7130-pb4'],
['MIC-7502-Z30?', 'bosch-mic-7502-z30b'],
['MIC-7522-Z30?', 'bosch-mic-7522-z30b'],
]) {
for (const colour of ['B', 'G', 'W', 'x']) {
const model = pattern.replace('?', colour);
assert.equal(lookupModelSpec(model)?.id, id, model);
assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id);
assert.equal(lookupModelSpec(model, 'Bosch')?.id, id);
assert.equal(lookupModelSpec(model, 'Axis'), null);
assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null);
}
assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null);
}
});
test('Bosch MIC HUD summary never names a colour the source did not', () => {
assert.equal(
specSummary(lookupModelSpec('MIC-7522-Z30W')),
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
);
assert.equal(
specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')),
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
);
for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) {
for (const colour of ['B', 'G', 'W', 'x']) {
const spec = lookupModelSpec(pattern.replace('?', colour));
const summary = specSummary(spec);
for (const other of ['B', 'G', 'W', 'x']) {
assert.ok(!summary.includes(pattern.replace('?', other)), summary);
}
}
}
});
test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => {
for (const model of [
'Bosch MIC-7130-Px4',
'Bosch MIC-7502-Z30x',
'Bosch MIC-7522-Z30x',
]) {
for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) {
const [camera] = browserCatalog().buildCatalogFromSources([
{ id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose },
]);
assert.ok(camera.spec, model);
assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model);
assert.equal(camera.fovSource, undefined);
assert.equal(camera.specFovMinDeg, undefined);
assert.equal(camera.specFovMaxDeg, undefined);
assert.equal(horizontalFovRangeDeg(camera.spec), null);
assert.equal(fixedHorizontalFovDeg(camera.spec), null);
assert.equal(specFovCapabilityToken(camera), null);
assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/);
}
}
});
test('catalog never overwrites an explicit FOV or a curated pose', () => {
const [explicit, curated, cohu, pelco] =
browserCatalog().buildCatalogFromSources([
+88
View File
@@ -0,0 +1,88 @@
/**
* Drapes the Active Fires heat raster over the globe as one full-extent
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
* in the fires layer touches imagery.
*
* `show` is only for the layer being on or off. The zoom hand-off fades
* `alpha` instead: Cesium unmaps a hidden imagery layer from the globe
* tiles and re-maps it when shown, so a show toggle at the band edge left
* a beat with neither markers nor heat. At alpha 0 the layer stays mapped
* and costs no draw.
*/
const defaultCanvas = (width, height) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
export function createHeatImagery({
cesium,
getHost,
createCanvas = defaultCanvas,
}) {
let raster = null;
let layer = null;
let collection = null;
let show = true;
let opacity = 1;
function detach() {
if (layer && collection) collection.remove(layer, true);
layer = null;
collection = null;
}
function attach() {
detach();
if (!raster) return;
const host = getHost?.() ?? { collection: null, kind: 'none' };
if (!host.collection) return;
const canvas = createCanvas(raster.width, raster.height);
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(raster.width, raster.height);
image.data.set(raster.rgba);
ctx.putImageData(image, 0, 0);
const provider = new cesium.SingleTileImageryProvider({
url: canvas.toDataURL('image/png'),
tileWidth: raster.width,
tileHeight: raster.height,
rectangle: cesium.Rectangle.MAX_VALUE,
});
collection = host.collection;
layer = collection.addImageryProvider(provider);
layer.show = show;
layer.alpha = opacity;
collection.raiseToTop?.(layer);
}
return {
setRaster(next) {
raster = next;
attach();
},
/** True once a raster was handed over (painted or waiting for a host). */
hasRaster() {
return raster !== null;
},
setShow(value) {
show = Boolean(value);
if (layer) layer.show = show;
},
/** Hand-off fade, 0..1; the layer stays mapped at 0. */
setOpacity(value) {
opacity = Math.min(1, Math.max(0, Number(value) || 0));
if (layer) layer.alpha = opacity;
},
rehome() {
const host = getHost?.();
if ((host?.collection ?? null) !== collection) attach();
},
destroy() {
detach();
raster = null;
},
};
}
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHeatImagery } from './heatImagery.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import { HEAT_RASTER } from './policy.js';
function harness() {
const layers = [];
const removed = [];
const collection = {
addImageryProvider(provider) {
const layer = { provider, show: true, alpha: 1 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
removed.push({ layer, destroy });
},
raiseToTop() {},
};
let host = { collection, kind: 'globe' };
const cesium = {
Rectangle: { MAX_VALUE: { max: true } },
SingleTileImageryProvider: class {
constructor(options) {
this.options = options;
}
},
};
const createCanvas = (w, h) => ({
width: w,
height: h,
getContext: () => ({
createImageData: (cw, ch) => ({
data: new Uint8ClampedArray(cw * ch * 4),
}),
putImageData() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
});
const imagery = createHeatImagery({
cesium,
getHost: () => host,
createCanvas,
});
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
}
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
const h = harness();
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1);
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
assert.equal(h.layers[0].provider.options.tileWidth, 4);
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1, 'never two heat layers');
assert.equal(h.removed[0].destroy, true);
});
test('setShow toggles without rebuilding', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setShow(false);
assert.equal(layer.show, false);
h.imagery.setShow(true);
assert.equal(h.layers[0], layer);
assert.equal(layer.show, true);
});
test('setOpacity fades the same layer and survives a rebuild', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setOpacity(0);
assert.equal(layer.alpha, 0);
assert.equal(layer.show, true, 'a faded layer stays mapped');
h.imagery.setOpacity(0.25);
assert.equal(h.layers[0], layer);
assert.equal(layer.alpha, 0.25);
h.imagery.setRaster(raster);
assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade');
h.imagery.setOpacity(7);
assert.equal(h.layers[0].alpha, 1);
});
test('no imagery host keeps nothing draped; rehome brings it back', () => {
const h = harness();
h.setHost({ collection: null, kind: 'none' });
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 0);
h.setHost({ collection: h.collection, kind: 'globe' });
h.imagery.rehome();
assert.equal(h.layers.length, 1);
});
test('destroy removes the layer', () => {
const h = harness();
h.imagery.setRaster(raster);
h.imagery.destroy();
assert.equal(h.layers.length, 0);
});
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
// A hot cluster elsewhere sets the scale, as Africa does on real data.
const fires = [{ lat: 0, lon: 0, frp: 5 }];
for (let i = 0; i < 40; i++) {
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
}
const r = buildHeatRaster(fires, HEAT_RASTER);
const y = Math.floor((90 / 180) * r.height);
const painted = [];
for (let x = 0; x < r.width; x++) {
const lon = ((x + 0.5) / r.width) * 360 - 180;
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
painted.push(x);
}
}
const kmPerPx = 40075 / r.width;
assert.ok(painted.length > 0, 'the weak fire still shows');
assert.ok(
painted.length * kmPerPx <= 100,
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
);
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLifecycle } from './lifecycle.js';
test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => {
let shown = false;
const noop = () => {};
const layerState = {
_enabled: false,
_viewer: {},
_dataSource: {},
_fires: [],
_loading: true,
_pickIndexById: new Map(),
_heatImagery: {
setShow(v) {
shown = v;
},
},
};
const lifecycle = createLifecycle({
layerState,
services: {
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
sprites: { restoreSpriteOrderOnEnable: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
config: { id: 'local-firms', overlayHost: { setVisible: noop } },
components: {
rendering: { applyHandoffFade: noop },
viewport: { installLodWatcher: noop, installMoveEndWatcher: noop },
selection: { installClickHandler: noop },
},
});
await lifecycle.methods.enable(layerState._viewer);
assert.equal(
shown,
false,
'empty current set must keep the old raster hidden while refresh is pending',
);
layerState._fires = [{ frp: null, confidence: null }];
await lifecycle.methods.enable(layerState._viewer);
assert.equal(shown, true, 'nonempty sets still resume their raster');
});
+28 -5
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
@@ -40,12 +41,13 @@ export function createIngestion({
layerState._stale = Boolean(payload?.stale);
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._fires = adaptFirmsRecords(payload?.fires);
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
layerState._firesByFrp = [...layerState._fires].sort(
layerState._allFires = adaptFirmsRecords(payload?.fires);
// Sorted once per payload; applyFilter only filters, so a slider drag
// over 200k detections never re-sorts.
layerState._allFiresByFrp = [...layerState._allFires].sort(
(a, b) => b.frp - a.frp,
);
layerState._count = layerState._fires.length;
applyFilter();
// Data age, not response age: a stale proxy payload truthfully reads old.
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
? payload.fetchedAt
@@ -65,6 +67,7 @@ export function createIngestion({
}
components.rendering.renderCurrentLod(true);
if (reselected) components.selection.selectFire(reselected, false);
layerState._rowControlsListener?.();
} catch (error) {
if (
request.signal.aborted ||
@@ -83,6 +86,26 @@ export function createIngestion({
}
}
/**
* Run the intensity filter over the loaded payload. This is the ONE place
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
* the heatmap, the context store, the top-fires query and the analyst
* records, so none of them can disagree about which fires exist. Cells and
* the heat raster are built per shown set, so their caches go with it.
*/
function applyFilter() {
layerState._fires = filterFires(layerState._allFires, layerState._filter);
layerState._cellCacheByGrid.clear();
layerState._heatRaster = null;
layerState._firesByFrp = filterFires(
layerState._allFiresByFrp,
layerState._filter,
);
layerState._count = layerState._fires.length;
layerState._totalCount = layerState._allFires.length;
}
/**
* Find the record in the freshly-loaded set matching a previous selection.
* Identity is the detection itself (lat/lon/acquisition time) — indices
@@ -109,5 +132,5 @@ export function createIngestion({
},
};
return { loadHeatmap, findMatchingFire, methods };
return { loadHeatmap, applyFilter, findMatchingFire, methods };
}
+30
View File
@@ -14,6 +14,20 @@ export function createLifecycle({
const { id, overlayHost } = config;
const methods = {
/**
* Shell services: `imageryHost()` resolves where draped imagery goes on
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
*/
attachShellServices(services) {
layerState._imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
layerState._heatImagery?.rehome();
},
init(viewer) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
@@ -40,6 +54,10 @@ export function createLifecycle({
layerState._billboards.show = true;
}
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
// A filter can leave an old raster cached while the current set is empty.
layerState._heatImagery?.setShow(layerState._fires.length > 0);
// The camera can have crossed the hand-off while the layer was off.
components.rendering.applyHandoffFade(true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
@@ -64,6 +82,7 @@ export function createLifecycle({
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
layerState._heatImagery?.setShow(false);
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
@@ -91,10 +110,21 @@ export function createLifecycle({
components.rendering.removeDetectionCollections(viewer);
components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id);
layerState._allFires = [];
layerState._allFiresByFrp = [];
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
layerState._heatImagery?.destroy();
layerState._heatImagery = null;
layerState._heatRaster = null;
layerState._handoffHeat = -1;
layerState._imageryHost = null;
layerState._count = 0;
layerState._totalCount = 0;
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._keyRequired = false;
+43 -27
View File
@@ -6,6 +6,7 @@ import {
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
import {
VIEW_PADDING,
DETECTION_COLOR_STOPS,
@@ -119,27 +120,30 @@ export function createModel({
);
}
function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha);
/**
* The ramp stop for a fire radiative power (stepped, weakest first).
* Intensity alone drives the color: confidence feeds the card text and the
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
* @param {number} frp - Megawatts.
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
*/
function frpColorStop(frp) {
const name = frpRampStop(frp).name;
return (
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
DETECTION_COLOR_STOPS[0]
);
}
/**
* Pick a sprite color stop from FRP + confidence, reusing the same
* yellow → orange → red thresholds as the aggregated heat cells.
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
* @param {Object} fire - Detection record.
* @returns {{name: string, color: Cesium.Color}}
*/
function detectionColorStop(fire) {
const heat = Math.min(
1,
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
);
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
return DETECTION_COLOR_STOPS[2];
return frpColorStop(fire?.frp);
}
/** FRP → core marker pixel size, clamped to 8..28px. */
@@ -227,6 +231,19 @@ export function createModel({
return fire.position;
}
/**
* The anchor already built for a detection, without the floor lookup;
* for the far marker band, where floors are never warmed and the height
* difference is invisible, so tens of thousands of markers skip a cache
* read each per rebuild. Builds one through {@link firePosition} once.
* @param {Object} fire - Detection record.
* @returns {Cesium.Cartesian3}
*/
function fireBuiltPosition(fire) {
return fire.position ?? firePosition(fire);
}
/**
* Occlusion-test anchor for one detection. Returns the render position
* outright for anchors comfortably above the ellipsoid (the common case) and
@@ -234,11 +251,11 @@ export function createModel({
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
* is what the sprite and the card are drawn at.
* @param {Object} fire - Detection record.
* @param {Cesium.Cartesian3} [position] Its render anchor, if at hand.
* @returns {Cesium.Cartesian3}
*/
function fireCullPosition(fire) {
const position = firePosition(fire);
function fireCullPosition(fire, position = firePosition(fire)) {
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
@@ -398,8 +415,8 @@ export function createModel({
/**
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
* score is only known at renderCells time). Exported for unit tests.
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
@@ -417,7 +434,7 @@ export function createModel({
position: candidate.position,
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
@@ -464,16 +481,14 @@ export function createModel({
}
/**
* Heat-normalized cell score → severity accent, mirroring heatColor's
* red/orange/yellow thresholds.
* @param {number} normalized - 0..1 normalized heat score.
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
* color its markers take once the camera is close enough to show them.
* @param {{maxFrp?: number}} cell - Aggregated cell.
* @returns {string} "r, g, b" accent string.
*/
function cellAccent(normalized) {
if (normalized > 0.72) return accentForSeverity('red');
if (normalized > 0.42) return accentForSeverity('orange');
return accentForSeverity('yellow');
function cellCardAccent(cell) {
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
}
/** Coordinate line like "30.512°N 75.831°E". */
@@ -579,12 +594,13 @@ export function createModel({
boundsContainPoint,
cellIntersectsBounds,
heatScore,
heatColor,
frpColorStop,
detectionColorStop,
frpPixelSize,
sizeBucket,
glowSprite,
firePosition,
fireBuiltPosition,
fireCullPosition,
cellCullPosition,
applyHorizonCull,
@@ -593,7 +609,7 @@ export function createModel({
buildFireCard,
buildCellCard,
applyFirmsOverlayPolicy,
cellAccent,
cellCardAccent,
formatLatLon,
formatFrp,
formatAge,
+57 -13
View File
@@ -1,4 +1,5 @@
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
import { FRP_RAMP } from '../../data/firmsIntensity.js';
import * as Cesium from 'cesium';
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
@@ -6,9 +7,10 @@ import * as Cesium from 'cesium';
export const REFRESH_INTERVAL_MS = 600_000;
/**
* LOD bands keyed by camera height. `cells` bands render aggregated
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
* local band) render individual fire detections as glow-sprite billboards.
* LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
* bands render individual fire detections as glow-sprite billboards.
* Labels in every band go through the shared screen-space greedy declutter
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
*/
@@ -17,24 +19,28 @@ export const LOD_LEVELS = [
{
id: 'global',
minHeight: 9000000,
mode: 'cells',
mode: 'heat',
gridDegrees: 2.0,
maxCells: 1800,
labelDistance: 12000000,
},
{
id: 'regional',
minHeight: 3000000,
mode: 'cells',
minHeight: 4500000,
mode: 'heat',
gridDegrees: 1.0,
maxCells: 3600,
labelDistance: 8500000,
},
{
// Up to 40,000 markers: at the 3,600 km end of the hand-off the padded
// view over Africa, the densest region, holds ~40,000 detections, and a
// tighter cap left the heatmap's weak spots dissolving into nothing.
// A rebuild of that many costs ~60 ms, only there.
id: 'local',
minHeight: 750000,
mode: 'detections',
maxDetections: 2500,
maxDetections: 40000,
labelDistance: 4500000,
},
{
@@ -46,6 +52,38 @@ export const LOD_LEVELS = [
},
];
/**
* Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a
* one-pixel Gaussian σ, so a lone fire is a spot about the size of its
* marker near the hand-off and the two cross-fade without a jump;
* neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared
* each fire over ~200 km). Built once per data refresh or filter change,
* never per camera move.
*/
export const HEAT_RASTER = Object.freeze({
width: 4096,
height: 2048,
sigmaPx: 1,
});
/**
* Camera heights, in metres, over which the heatmap and the markers
* cross-fade around the 4,500 km band edge: markers alone below `from`,
* heatmap alone above `to`. Wider than the ±10 % band hysteresis, so
* whichever band is active inside it, both layers are already built.
*/
export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 });
/**
* Trailing delay before a stale heatmap is rebuilt while one is already on
* screen: a slider drag fires many filter changes, and a build plus PNG
* encode costs a few hundred milliseconds. The first paint is immediate.
*/
export const HEAT_REBUILD_MS = 150;
export const LOD_CHECK_MS = 650;
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
@@ -88,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */
/**
* Color stops for detection glow sprites and card accents: the
* six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium
* colors. Stop names key the glow-sprite cache and the card accents.
*/
export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED },
{ name: 'orange', color: Cesium.Color.ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW },
];
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
Object.freeze({
name: stop.name,
minFrp: stop.minFrp,
color: Cesium.Color.fromCssColorString(stop.hex),
}),
);
+161 -2
View File
@@ -1,6 +1,18 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
import {
FIRMS_CONFIDENCE_MODES,
FRP_SLIDER_TICKS,
firePassesFilter,
frpForSliderPosition,
normalizeFireFilter,
rampTrackGradient,
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
const SLIDER_STEPS = 1000;
export function createQueries({
layerState,
@@ -11,6 +23,25 @@ export function createQueries({
}) {
const { id, name, icon, source } = config;
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
function frpLabel(frp) {
return formatNumber(frp, { maximumFractionDigits: 0 });
}
const confidenceLabels = () => ({
all: t('All', { context: 'fire confidence filter' }),
nominal: t('Nominal+', { context: 'fire confidence filter' }),
high: t('High', { context: 'fire confidence filter' }),
});
const confidenceTitles = () => ({
all: t(
'Every detection — many of the strongest fires are rated low confidence',
),
nominal: t('Nominal and high confidence detections'),
high: t('High confidence detections only'),
});
const methods = {
/** Return the selected detection as a plain record for application consumers. */
getSelectedInfo() {
@@ -51,6 +82,110 @@ export function createQueries({
// not this interval — is what protects the upstream FIRMS quota).
updateInterval: REFRESH_INTERVAL_MS,
/**
* The intensity filter as layer options (share token `w`, owner
* `local-firms` in layerState.js).
* @returns {{minFrp: number, confidence: string}}
*/
getParams() {
return { ...layerState._filter };
},
/**
* Apply a (partial) intensity filter. Invalid fields keep their current
* value. The filter runs in one place (ingestion.applyFilter) and the LOD
* rebuild re-derives markers, cells, cards and context registrations from
* the shown set; a selected fire that no longer passes is deselected.
* @param {{minFrp?: number, confidence?: string}} params
* @returns {boolean} True — the request is always accepted.
*/
setParams(params = {}) {
const next = normalizeFireFilter(params, layerState._filter);
if (
next.minFrp === layerState._filter.minFrp &&
next.confidence === layerState._filter.confidence
)
return true;
layerState._filter = next;
if (!layerState._allFires.length) return true;
components.ingestion.applyFilter();
if (
layerState._selectedFire &&
!firePassesFilter(layerState._selectedFire, next)
) {
components.selection.clearFireSelection();
}
if (layerState._enabled) components.rendering.renderCurrentLod(true);
layerState._rowControlsListener?.();
return true;
},
/**
* Row controls under the Active Fires row: the log-scale minimum-FRP
* slider whose track is painted with the intensity ramp (so it doubles as
* the legend), the All / Nominal+ / High confidence switch, and the
* "N of M shown" counter.
*/
getRowControls() {
const { minFrp, confidence } = layerState._filter;
const labels = confidenceLabels();
const titles = confidenceTitles();
const shown = layerState._count;
const total = layerState._totalCount;
return {
slider: {
id: 'min-frp',
label: t('Fire intensity (FRP)'),
ariaLabel: t('Minimum fire radiative power, megawatts'),
valueText:
minFrp > 0
? t('≥ {value} MW', { value: frpLabel(minFrp) })
: t('All', { context: 'fire intensity filter' }),
min: 0,
max: SLIDER_STEPS,
step: 1,
value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS),
track: rampTrackGradient(),
ticks: FRP_SLIDER_TICKS.map((frp) => ({
at: sliderPositionForFrp(frp),
label: frpLabel(frp),
})),
params: (value) => ({
minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS),
}),
},
segmented: {
id: 'confidence',
label: t('Confidence'),
ariaLabel: t('Detection confidence'),
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
id: `confidence-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === confidence,
params: { confidence: mode },
})),
},
info: total
? t('{shown} of {total} shown', {
shown: formatNumber(shown),
total: formatNumber(total),
})
: '',
};
},
/**
* Install the panel's "row controls changed" callback: a load or a filter
* change moves the counter, and nothing else repaints the row before the
* next scheduled refresh.
* @param {(() => void)|null} listener
*/
setRowControlsListener(listener) {
layerState._rowControlsListener =
typeof listener === 'function' ? listener : null;
},
/**
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
@@ -84,7 +219,9 @@ export function createQueries({
});
}
return {
// Shown (filtered) detections; `total` is the loaded payload.
count: layerState._count,
total: layerState._totalCount,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
@@ -172,12 +309,34 @@ export function createQueries({
return result;
},
/** Test seam: render one named LOD band against a near-global view. */
_renderLodForTest(lodId) {
components.rendering.renderBand(
LOD_LEVELS.find((level) => level.id === lodId),
null,
);
},
/** Test seam: one frame of the hand-off fade at the current camera. */
_applyHandoffFadeForTest() {
components.rendering.applyHandoffFade();
},
/** Test seam: entities drawn by the layer's data source. */
_entityCountForTest() {
return layerState._dataSource?.entities?.values?.length ?? 0;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
layerState._enabled = true;
layerState._allFires = fires;
layerState._allFiresByFrp = [...fires];
layerState._fires = fires;
layerState._firesByFrp = [...fires];
layerState._count = fires.length;
layerState._totalCount = fires.length;
layerState._pickIndexById.clear();
layerState._labelLodDistance = 1e7;
layerState._labelCandidates = fires.map((fire) => {
+191 -83
View File
@@ -1,7 +1,21 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import {
LOD_LEVELS,
CONTEXT_TOP_N,
HANDOFF_FADE,
HEAT_RASTER,
HEAT_REBUILD_MS,
} from './policy.js';
import { createHeatImagery } from './heatImagery.js';
/** The markers drawn under the far bands' heatmap for the hand-off fade. */
const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections');
/** Fade steps: a moving camera rewrites marker colors at most this often. */
const FADE_STEPS = 32;
export function createRendering({
layerState,
@@ -29,8 +43,11 @@ export function createRendering({
!layerState._dataSource ||
!layerState._fires.length ||
!layerState._viewer
)
) {
// A filter that leaves nothing shown must not leave the old heat behind.
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
return false;
}
const lodIndex = components.viewport.selectLodIndex(
components.viewport.cameraHeight(),
);
@@ -51,14 +68,54 @@ export function createRendering({
? components.model.paddedDegreeBounds(viewRect)
: null;
if (lod.mode === 'detections') {
renderDetections(lod, bounds);
} else {
renderCells(aggregateFires(lod, bounds), lod, bounds);
}
renderBand(lod, bounds);
return true;
}
/**
* Draw one band. Markers and heatmap stay built in every band; the
* hand-off only fades between them.
* @param {Object} lod - Band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderBand(lod, bounds) {
prepareHeat(lod.mode === 'heat');
if (lod.mode === 'detections') renderDetections(lod, bounds);
else renderHeat(lod, bounds);
applyHandoffFade(true);
}
const fadeColor = new Cesium.Color();
/**
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
* on every rendered frame, so a fast zoom never outruns the throttled band
* check. Cheap when nothing changed: one height read and a compare.
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
*/
function applyHandoffFade(force = false) {
const height = components.viewport.cameraHeight();
const t = Math.min(
1,
Math.max(
0,
(height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from),
),
);
const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS;
if (!force && heat === layerState._handoffHeat) return;
layerState._handoffHeat = heat;
layerState._heatImagery?.setOpacity(heat);
const billboards = layerState._billboards;
if (!billboards) return;
billboards.show = layerState._enabled && heat < 1;
const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor);
for (let i = 0; i < billboards.length; i += 1)
billboards.get(i).color = color;
}
/**
* Bin all detections into grid cells, then viewport-clip BEFORE the
* top-N cap so fires in view never lose their slot to higher-scoring
@@ -118,65 +175,96 @@ export function createRendering({
return clipped;
}
/** The heat imagery adapter, created on first use. */
function heatImagery() {
if (!layerState._heatImagery) {
layerState._heatImagery = createHeatImagery({
cesium: Cesium,
getHost: () =>
layerState._imageryHost?.() ?? {
collection: layerState._viewer?.imageryLayers ?? null,
kind: 'globe',
},
});
}
return layerState._heatImagery;
}
/** Build the heat raster from the shown set and drape it. */
function rebuildHeatRaster() {
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
if (layerState._destroyed) return;
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
heatImagery().setRaster(layerState._heatRaster);
}
/**
* Render aggregated heat cells (global/regional bands) as ground-clamped
* rectangles. Cell labels do NOT live on the entities anymore — they go
* through the same unclamped, screen-space-decluttered label pipeline as
* detections (see {@link rebuildAmbientLabels}).
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
* Make sure the draped heatmap matches the shown set. The first paint in a
* far band is immediate; other builds (filter drags, refreshes, the
* preload under the markers) trail by HEAT_REBUILD_MS and keep the
* previous heatmap on screen meanwhile.
* @param {boolean} now Build at once when nothing is draped yet.
*/
function ensureHeatRaster(now) {
if (layerState._heatRaster) return;
if (now && !heatImagery().hasRaster()) {
rebuildHeatRaster();
return;
}
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = setTimeout(
rebuildHeatRaster,
HEAT_REBUILD_MS,
);
}
/**
* Keep the heatmap draped on the current map stack and current with the
* shown set in every band; the hand-off fade decides how much of it shows.
* @param {boolean} now The camera is in a far band.
*/
function prepareHeat(now) {
const imagery = heatImagery();
// The map stack may have changed since the last render.
imagery.rehome();
ensureHeatRaster(now);
imagery.setShow(layerState._enabled);
}
/**
* Far bands (global/regional): a Snap Map–style heatmap draped over the
* globe, transparent where nothing burns, plus the decluttered "N FIRES"
* cards from the coarse aggregation. The nearest band's markers are kept
* built underneath for the hand-off fade and hidden once it completes;
* cards go through the same screen-space label pipeline as detections
* (see {@link rebuildAmbientLabels}).
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
layerState._cellCount = cells.length;
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
function renderHeat(lod, bounds) {
paintMarkers(HANDOFF_MARKER_LOD, bounds);
const cells = aggregateFires(lod, bounds);
layerState._cellCount = cells.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
// Cells arrive heat-sorted, so candidate order doubles as label priority.
for (const cell of cells) {
const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5;
const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._dataSource.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.lonCell,
cell.latCell,
cell.lonCell + lod.gridDegrees,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position,
properties: {
count: cell.count,
intensity: score,
maxFrp: cell.maxFrp,
night: cell.night,
},
});
// Cells arrive heat-sorted, so candidate order doubles as label priority.
// Accent is resolved here because `normalized` only exists in this walk.
layerState._labelCandidates.push({
position,
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellAccent(normalized),
accent: components.model.cellCardAccent(cell),
});
}
@@ -202,9 +290,48 @@ export function createRendering({
*/
function renderDetections(lod, bounds) {
const markers = paintMarkers(lod, bounds);
if (!markers) return;
const candidates = markers.map((marker) => marker.fire);
layerState._cellCount = candidates.length;
// FRP-sorted walk: marker order doubles as label priority.
layerState._labelCandidates = markers;
layerState._labelLodDistance = lod.labelDistance;
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
}
/**
* The glow-sprite markers for a detections band: the strongest fires in
* view, capped at the band's maxDetections, horizon-culled.
* @param {Object} lod - Detections band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
* @returns {?Array<Object>} Label candidates for the fires drawn,
* strongest first; null before the sprite collection exists.
*/
function paintMarkers(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (!layerState._billboards) return;
if (!layerState._billboards) return null;
layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
@@ -226,17 +353,20 @@ export function createRendering({
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
}
layerState._cellCount = candidates.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
const markers = [];
// Only the close band warms terrain floors (renderDetections).
const anchor =
lod.id === 'close'
? components.model.firePosition
: components.model.fireBuiltPosition;
for (const fire of candidates) {
const coreSize = components.model.frpPixelSize(fire.frp);
const position = components.model.firePosition(fire);
const cullPosition = components.model.fireCullPosition(fire);
const position = anchor(fire);
const cullPosition = components.model.fireCullPosition(fire, position);
const pickId = `firms-${fire.index}`;
layerState._pickIndexById.set(pickId, fire);
layerState._cullPositions.push(cullPosition);
markers.push({ position, cullPosition, fire, cell: null });
layerState._billboards.add({
id: pickId,
position,
@@ -248,37 +378,13 @@ export function createRendering({
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
// FRP-sorted walk: candidate order doubles as label priority.
layerState._labelCandidates.push({
position,
cullPosition,
fire,
cell: null,
});
}
// Freshly-added sprites default to show=true; cull the far side now so a
// rebuild never flashes through-the-planet detections while the camera
// sits still (the moveEnd/preRender hooks only fire on camera motion).
refreshHorizonCulling();
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
return markers;
}
function ensureDetectionCollections() {
@@ -344,9 +450,11 @@ export function createRendering({
}
return {
renderCurrentLod,
renderBand,
aggregateFires,
renderCells,
renderHeat,
renderDetections,
applyHandoffFade,
ensureDetectionCollections,
removeDetectionCollections,
fireHorizonOccluder,
+51
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
export function createFirmsState({ services, config }) {
const layerState = {};
@@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) {
layerState._error = null;
/** Every adapted detection in the current payload, before the filter. */
layerState._allFires = [];
/** `_allFires` sorted by FRP descending, once per payload. */
layerState._allFiresByFrp = [];
/**
* The detections that pass the intensity filter — the ONLY set rendering,
* selection, cells, the top-fires query and the analyst records ever walk.
*/
layerState._fires = [];
layerState._firesByFrp = [];
/** Shown (filtered) detection count. */
layerState._count = 0;
/** Loaded detection count before the filter ("N of M shown"). */
layerState._totalCount = 0;
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
layerState._filter = { ...DEFAULT_FIRE_FILTER };
/** Panel callback: repaint the row controls (counter) after a load. */
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
@@ -103,6 +131,29 @@ export function createFirmsState({ services, config }) {
layerState._cellCacheByGrid = new Map();
/**
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
* on the next far render. Cleared with the cell cache on every new shown set.
*/
layerState._heatRaster = null;
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
layerState._heatRebuildTimer = null;
/** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */
layerState._handoffHeat = -1;
/** Imagery adapter (heatImagery.js), created on the first far render. */
layerState._heatImagery = null;
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
layerState._imageryHost = null;
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
layerState._camSnapValid = false;
+3
View File
@@ -151,6 +151,9 @@ export function createViewport({
// on screen throughout it — so the horizon pass below must keep running
// while a fetch is in flight. Only the rebuild is gated on fresh data.
if (!layerState._enabled) return;
// Every frame, ahead of the throttle: a fast zoom crosses the whole
// hand-off between two band checks.
components.rendering.applyHandoffFade();
const now = performance.now();
if (now - layerState._lastLodCheck < LOD_CHECK_MS) return;
layerState._lastLodCheck = now;
+6 -1
View File
@@ -12,6 +12,11 @@ import {
} from '../../data/transitFeeds.js';
import { transitModeTier } from '../../data/transitPresetStyle.js';
/** Regions, not feeds: one region can carry several feeds (Rio has two). */
const TRANSIT_REGION_COUNT = new Set(
TRANSIT_ENABLED_FEEDS.map((feed) => feed.region),
).size;
/**
* The registry entry behind a vehicle. Live feeds answer first so a feed the
* session disabled stops supplying vocabulary; the registry is the fallback,
@@ -249,7 +254,7 @@ export function createQueries({ state, parts }) {
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createQueries } from './queries.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
test('the empty-coverage note counts regions, not feeds', () => {
const rio = TRANSIT_ENABLED_FEEDS.filter(
(feed) => feed.region === 'Rio de Janeiro, RJ',
);
assert.equal(rio.length, 2, 'Rio is one region with two feeds');
const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region));
assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length);
const stats = createQueries({
state: {
_activeFeeds: new Map(),
_vehicles: new Map(),
_enabled: true,
_altitudeGateOpen: true,
},
parts: {},
}).methods.getStats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.coverage,
`No feed here yet · ${regions.size} regions available`,
);
});
+12
View File
@@ -409,3 +409,15 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// Same field as flights and military, so Contacts names the provider.
source: layer.source,
};
},
};
+21 -1
View File
@@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
const SPAN_TEXT =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
const SPAN_MUSTACHE =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
@@ -64,7 +67,24 @@ function referencedGlyphs() {
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
// that computed returns.
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
if (!name) continue;
const body = new RegExp(
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
).exec(source)?.[1];
for (const literal of body?.matchAll(
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
) ?? [])
add(literal[1]);
}
const expressions = [
...source.matchAll(TEXT_ASSIGNMENT),
...source.matchAll(SPAN_MUSTACHE),
];
for (const match of expressions) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
? statement.slice(statement.indexOf('?') + 1)
+1
View File
@@ -252,6 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
* written explicitly.
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
+60 -4
View File
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import {
FIRMS_CONFIDENCE_MODES,
MIN_FRP_MW_MAX,
MIN_FRP_MW_MIN,
firePassesFilter,
normalizeFireFilter,
} from '../../data/firmsIntensity.js';
import { normalizeConfidence } from '../../data/firmsAdapt.js';
function acquiredMsUtc(acqDate, acqTime) {
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
title: 'Active fires',
description:
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
'area, most intense (fire radiative power) first.',
'area, most intense (fire radiative power) first. Optionally limited to a ' +
'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.",
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
properties: {
area: AREA_SCHEMA,
min_frp_mw: {
type: 'number',
minimum: MIN_FRP_MW_MIN,
maximum: MIN_FRP_MW_MAX,
description:
'Hide detections weaker than this fire radiative power (MW). ' +
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
},
confidence: {
type: 'string',
enum: [...FIRMS_CONFIDENCE_MODES],
description:
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
'strongest fires are rated low confidence, so prefer "all" unless asked.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
'unavailable',
'Fire detections need a NASA FIRMS key configured for this server',
);
const filter = normalizeFireFilter({
minFrp: args.min_frp_mw,
confidence: args.confidence,
});
const fires = snapshot.fires
.filter((fire) => areaContains(area, fire))
.filter(
(fire) =>
areaContains(area, fire) &&
firePassesFilter(
{
frp: Number(fire.frp),
confidence: normalizeConfidence(fire.confidence),
},
filter,
),
)
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
const filterNotes = [
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
...(filter.confidence !== 'all'
? [
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
]
: []),
];
const result = capRows(fires, args.limit);
// The proxy serves its last good snapshot when a refresh fails, and marks
// satellites whose download failed.
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
];
return {
summary:
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
`${countNoun(fires.length, 'fire detection')}` +
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
` in ${area.label} in the last 24 hours` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, { area, layers: ['local-firms'] }),
filter: {
min_frp_mw: filter.minFrp,
confidence: filter.confidence,
},
stale,
missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt),
+36
View File
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
assert.equal(result.data.rows[0].acquired, expected);
}
});
const WEEK = [
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
];
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
assert.match(result.summary, /^4 fire detections in /);
});
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
assert.match(nominal.summary, /\(nominal or high confidence\)/);
const both = await catalog.call('get_active_fires', {
...TAIWAN,
min_frp_mw: 19.6,
confidence: 'high',
});
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
});
+91
View File
@@ -0,0 +1,91 @@
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
// <body> must not also name an element's rule. A bare `.name { … }` written
// for a panel matches <body> the moment <body> carries `name`, and its
// pointer-events, position and overflow then apply to the whole page.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const UI_ROOT = fileURLToPath(new URL('.', import.meta.url));
const SRC_ROOT = path.resolve(UI_ROOT, '..');
const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js');
/** Every stylesheet the app ships. */
function stylesheets(directory = SRC_ROOT) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...stylesheets(absolute));
else if (entry.isFile() && entry.name.endsWith('.css'))
files.push(absolute);
}
return files;
}
/** A selector list split on its top-level commas, not those in `:not(a, b)`. */
function splitSelectorList(list) {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i < list.length; i += 1) {
if (list[i] === '(') depth += 1;
else if (list[i] === ')') depth -= 1;
else if (list[i] === ',' && depth === 0) {
parts.push(list.slice(start, i));
start = i + 1;
}
}
parts.push(list.slice(start));
return parts;
}
/** The classes the tracking controller adds to <body>. */
function cockpitBodyClasses() {
const source = readFileSync(CONTROLLER, 'utf8');
const names = [
...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g),
].map((match) => match[1]);
return [...new Set(names)];
}
test('the Cockpit adds its subject classes to <body>', () => {
const classes = cockpitBodyClasses();
for (const name of ['cockpit-mode']) {
assert.ok(classes.includes(name), `${name} is not added to <body>`);
}
});
test('no stylesheet rule matches a Cockpit body class on another element', () => {
const offenders = [];
for (const name of cockpitBodyClasses()) {
// `.name` as a whole class token, not as `.name-suffix`.
const token = new RegExp(`\\.${name}(?![\\w-])`);
for (const file of stylesheets()) {
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) {
for (const selector of splitSelectorList(selectors)) {
// Prettier wraps long `:not(\n .name\n)` arguments; unwrap them.
const compound = selector
.trim()
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')');
if (!token.test(compound)) continue;
// Allowed only on <body> itself: `body.name` or `body:not(.name)`.
const onBody = new RegExp(
`(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`,
);
if (onBody.test(compound)) continue;
offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`);
}
}
}
}
assert.deepEqual(
offenders,
[],
'Selectors that match a Cockpit body class without `body.`: ' +
offenders.join('; '),
);
});
+26 -5
View File
@@ -19,6 +19,14 @@ import {
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
} from './cockpitPresentation.js';
/** The aircraft eye: just ahead of and above the reported position. */
const AIRCRAFT_CAMERA_PROFILE = Object.freeze({
forwardM: COCKPIT_FORWARD_OFFSET_M,
upM: COCKPIT_UP_OFFSET_M,
pitchDeg: COCKPIT_VIEW_PITCH_DEG,
minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M,
});
export function update() {
if (this.destroyed) return false;
if (!this.active) {
@@ -75,7 +83,20 @@ export function update() {
COCKPIT_HEADING_SLEW_DPS * dtSec,
);
}
placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE);
}
/**
* Place the camera for one update: advance the inertial anchor, clamp it and
* the eye against the rendered surface, and look along the slewed heading.
* @param {Cesium.Cartesian3} target Authoritative subject position.
* @param {object} info Subject info.
* @param {number} nowMs Frame time.
* @param {number} dtSec Seconds since the previous camera update.
* @param {{forwardM: number, upM: number, pitchDeg: number,
* minClearanceM: number}} profile Eye offsets for the subject kind.
*/
function placeCamera(target, info, nowMs, dtSec, profile) {
if (!this.cockpitAnchorValid) {
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
this.cockpitAnchorValid = true;
@@ -89,7 +110,7 @@ export function update() {
// 15/30-second interpolation and per-frame cache without exposing its
// sample-boundary corrections to the camera.
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
const pitchRad = Cesium.Math.toRadians(profile.pitchDeg);
const speedMps = Number.isFinite(info.velocityMps)
? Math.max(0, info.velocityMps)
: 0;
@@ -248,7 +269,7 @@ export function update() {
const minimumAnchorHeightM = cockpitGroundSafeHeight(
anchorCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
profile.minClearanceM - profile.upM,
);
if (minimumAnchorHeightM !== anchorCartographic.height) {
anchorCartographic.height = minimumAnchorHeightM;
@@ -297,7 +318,7 @@ export function update() {
Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal,
COCKPIT_FORWARD_OFFSET_M,
profile.forwardM,
this.scratchCamera,
);
Cesium.Cartesian3.add(
@@ -309,7 +330,7 @@ export function update() {
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
Cesium.Cartesian3.multiplyByScalar(
this.scratchTarget,
COCKPIT_UP_OFFSET_M,
profile.upM,
this.scratchTarget,
);
Cesium.Cartesian3.add(
@@ -338,7 +359,7 @@ export function update() {
const safeHeightM = cockpitGroundSafeHeight(
cameraCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M,
profile.minClearanceM,
);
if (safeHeightM !== cameraCartographic.height) {
cameraCartographic.height = safeHeightM;
+47 -9
View File
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { sourceStatusLabel } from './layerNames.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -24,6 +25,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
});
/**
* Which air feed the aircraft Cockpit's surrounding picture is missing. Entry
* needs only the ridden aircraft's own layer, so the other one may be off;
* the panels then say the picture is partial rather than present it as
* complete. Null with both feeds on, or when the owner cannot tell.
* @returns {?{title: string, detail: string}}
*/
export function readPartialPicture() {
const isLayerEnabled = this.services.isLayerEnabled;
if (typeof isLayerEnabled !== 'function') return null;
const militaryOff = !isLayerEnabled('military');
const flightsOff = !isLayerEnabled('flights');
if (!militaryOff && !flightsOff) return null;
return {
title: militaryOff
? t('MILITARY FEED OFF · PARTIAL PICTURE')
: t('FLIGHTS FEED OFF · PARTIAL PICTURE'),
detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'),
};
}
/** Hand a fresh copy of the Contact card to the view. */
export function publishContextView() {
const view = this.contextView;
@@ -52,7 +74,11 @@ export function updateContext(info, heading) {
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info });
const partial = readPartialPicture.call(this);
if (!readout.visible) {
// Contacts just went off: the rows its snapshot built stop being
// measured, so they go before the standby rows replace them.
if (this.presence.context) this.dropContextSnapshotSignals?.();
this.showContextCard(false);
this.contextLayoutStamp = null;
this.pushCockpitSignal(
@@ -61,6 +87,14 @@ export function updateContext(info, heading) {
t('CONTEXT STANDBY'),
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
);
if (partial)
this.pushCockpitSignal(
'feed-status',
'warning',
partial.title,
partial.detail,
);
else this.dropCockpitSignal?.('feed-status');
return;
}
@@ -110,6 +144,7 @@ export function updateContext(info, heading) {
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus);
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
@@ -171,19 +206,22 @@ export function updateContext(info, heading) {
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
view.uncertainty = unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
// A feed that is off outranks unknown inputs: the picture is partial.
view.uncertainty = partial
? partial.title
: unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
view.updated = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
view.state = unknownCount ? 'uncertain' : 'current';
view.state = unknownCount || partial ? 'uncertain' : 'current';
this.publishContextView();
this.updateCockpitSignals(snapshot, unknownCount);
this.updateCockpitSignals(snapshot, unknownCount, partial);
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
this.contextLayoutStamp = snapshot.evaluatedAt;
this.scheduleContextLayout();
+164
View File
@@ -12,6 +12,13 @@ import {
updateContext,
} from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import {
dropCockpitSignal,
dropContextSnapshotSignals,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
import { syncWeatherToggle } from './cockpitInstruments.js';
import { setContextCollapsed } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
@@ -96,6 +103,13 @@ test('before the controller presents, the card shows the state it starts in', (t
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
@@ -159,6 +173,43 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) =>
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
@@ -204,6 +255,119 @@ test('without a snapshot the card stands by, hidden', async (t) => {
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
});
test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => {
// Every input known, so only the feed that is off can make it partial.
const { $, text, owner } = fixture(
t,
snapshot({
cohorts: [
{
id: 'flights',
label: 'Flights',
count: 12,
relationship: 'OBSERVED',
nearest: [{ id: 'N1', distanceM: 4200 }],
},
],
}),
);
const rebuilt = [];
owner.updateCockpitSignals = (...args) => rebuilt.push(args);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'current');
assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add');
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'uncertain');
assert.equal(
text('#cockpit-context-uncertainty'),
'MILITARY FEED OFF · PARTIAL PICTURE',
);
assert.deepEqual(rebuilt.at(-1)[2], {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
});
test('without Contacts, a feed that is off adds a signal until it is back', async (t) => {
const { owner, signals } = fixture(t, null);
const dropped = [];
owner.dropCockpitSignal = (key) => dropped.push(key);
// Riding a military aircraft with Live Flights off.
owner.services.isLayerEnabled = (layerId) => layerId === 'military';
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY');
assert.deepEqual(signals.at(-1), [
'feed-status',
'warning',
'FLIGHTS FEED OFF · PARTIAL PICTURE',
'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
]);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.deepEqual(dropped, ['feed-status']);
});
/** The fixture's owner with the real Live Signals presenters. */
function withSignals(owner) {
const published = [];
Object.assign(owner, {
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => published.push(rows),
publishCockpitSignals,
pushCockpitSignal,
dropCockpitSignal,
dropContextSnapshotSignals,
updateCockpitSignals,
});
return () => published.at(-1).map((row) => row.title);
}
test('Contacts going off clears its contact rows and leaves standby', async (t) => {
const { owner } = fixture(t, snapshot());
const rows = withSignals(owner);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, INFO, 0);
assert.ok(
owner.signalItems.some((item) => item.key.startsWith('flight:')),
'Contacts fills the rows',
);
assert.ok(owner.signalItems.some((item) => item.key === 'input-status'));
// Both feeds stay on; only Contacts goes.
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY']);
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY'], 'standby holds still');
});
test('Contacts going off with a feed off keeps the feed row beside standby', async (t) => {
const { owner } = fixture(t, snapshot());
const rows = withSignals(owner);
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
updateContext.call(owner, INFO, 0);
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), [
'MILITARY FEED OFF · PARTIAL PICTURE',
'CONTEXT STANDBY',
]);
});
test('standby without Contacts keeps the rows the session already shows', async (t) => {
const { owner } = fixture(t, null);
const rows = withSignals(owner);
owner.services.isLayerEnabled = () => true;
owner.pushCockpitSignal('track', 'track', 'TRACK ACQUIRED', 'UAL123');
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY', 'TRACK ACQUIRED']);
});
test('the weather toggle renders its state and the action it offers', async (t) => {
const { $, text, owner } = fixture(t);
syncWeatherToggle.call(owner, true);
+4 -1
View File
@@ -25,7 +25,10 @@ export function createContextState() {
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
COCKPIT_CONTEXT_COHORTS.map((id) => [
id,
{ count: '—', unknown: false, source: '—' },
]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
+40 -3
View File
@@ -1,6 +1,8 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
import {
readAircraftInfo,
readSubjectLayerId,
resolveEntryKind,
dispatchCockpitModeChanged,
toggleTrackedTr3b,
syncTr3bToggle,
@@ -48,6 +50,8 @@ import {
relocalizeBrief,
} from './cockpitBriefing.js';
import {
dropCockpitSignal,
dropContextSnapshotSignals,
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
@@ -60,7 +64,11 @@ import {
syncContextLayout,
syncSignalLayout,
} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import {
onKeyDown,
onSceneDoubleClick,
routeSceneDoubleClick,
} from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
@@ -75,6 +83,7 @@ export class CockpitViewController {
isUtilityDisclosureOpen = null,
onEntered = null,
onExited = null,
onRide = null,
restoreTrackingFrame = null,
publish = {},
} = {},
@@ -114,6 +123,7 @@ export class CockpitViewController {
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.onRide = onRide;
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
@@ -205,6 +215,13 @@ export class CockpitViewController {
}),
);
this._listen(this.entry, 'click', () => this.enter());
// Double-clicking the followed track is a third way in.
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
// The readout's entry renders inside the Intel HUD, which may mount after
// this controller, so its clicks are delegated.
this._listen(document, 'click', (event) => {
if (event.target?.closest?.('#cockpit-entry-readout')) this.enter();
});
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
this._listen(this.mapViewButton, 'click', () => this.exit());
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
@@ -272,6 +289,12 @@ export class CockpitViewController {
readAircraftInfo() {
return readAircraftInfo.call(this);
}
readSubjectLayerId() {
return readSubjectLayerId.call(this);
}
resolveEntryKind() {
return resolveEntryKind.call(this);
}
dispatchCockpitModeChanged(active, info) {
return dispatchCockpitModeChanged.call(this, active, info);
}
@@ -371,8 +394,19 @@ export class CockpitViewController {
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
}
updateCockpitSignals(snapshot, unknownCount) {
return updateCockpitSignals.call(this, snapshot, unknownCount);
dropCockpitSignal(key) {
return dropCockpitSignal.call(this, key);
}
dropContextSnapshotSignals() {
return dropContextSnapshotSignals.call(this);
}
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
return updateCockpitSignals.call(
this,
snapshot,
unknownCount,
partialPicture,
);
}
scheduleContextLayout() {
return scheduleContextLayout.call(this);
@@ -392,6 +426,9 @@ export class CockpitViewController {
onKeyDown(event) {
return onKeyDown.call(this, event);
}
onSceneDoubleClick(position) {
return onSceneDoubleClick.call(this, position);
}
handleSignalClick(event) {
return handleSignalClick.call(this, event);
+74 -21
View File
@@ -1,6 +1,9 @@
import { nextTick } from 'vue';
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import {
COCKPIT_AIRCRAFT_LAYER_IDS,
cockpitEntryAllowed,
} from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
@@ -72,23 +75,29 @@ export class CockpitCoordinator {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
// The Contact card and Live Signals say which feeds are off.
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
// Only the tracked aircraft's own layer gates entry (see the policy).
isEntryAllowed: () => {
const subjectLayerId =
this.cockpitView?.readAircraftInfo?.()?.layerId ?? null;
return cockpitEntryAllowed({
subjectLayerId,
subjectLayerEnabled: this._isLayerEnabled(subjectLayerId),
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
});
},
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
onRide: (layerId) => this._adoptRiddenLayer(layerId),
restoreTrackingFrame: (entity) => {
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
':',
@@ -115,6 +124,44 @@ export class CockpitCoordinator {
get activeStyle() {
return this.readActiveStyle();
}
/** Whether a data layer is enabled; false for no layer. */
_isLayerEnabled(layerId) {
return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId);
}
/**
* Make the ridden aircraft's layer the user's own while Contacts is on.
* Contacts may have turned that layer on, and it releases the layers it
* owns when it goes off; the Cockpit must outlive Contacts, so riding an
* aircraft adopts its layer as a direct selection does. Contacts then stops
* owning it and the Context restore keeps it (session `userAdded`).
* @param {string|null} layerId The ridden aircraft's layer.
*/
_adoptRiddenLayer(layerId) {
if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return;
if (!this._isLayerEnabled('military-awareness')) return;
this._dataManager?.adoptLayerVisibility?.(layerId, true, {
origin: 'user',
adoptedFromSelection: true,
});
}
/** Whether either aircraft layer is on, so there is an aircraft to ride. */
get _aircraftLayerEnabled() {
return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) =>
this._isLayerEnabled(layerId),
);
}
/**
* Why voice entry must refuse before it touches tracking, or null. The
* tracked aircraft's own layer is checked again by the entry itself.
* @returns {string|null}
*/
_cockpitEntryRefusal() {
if (this._contextModeChanging)
return 'Context is still changing — try Cockpit again in a moment';
if (!this._aircraftLayerEnabled)
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
return null;
}
getCockpitState() {
const { militaryAwarenessLayer } = this.services;
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
@@ -129,19 +176,24 @@ export class CockpitCoordinator {
const entryAllowed =
!active &&
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
// Entry needs only the ridden aircraft's own layer: with one tracked,
// that layer; with none, any aircraft layer to track one from.
const subjectLayerOff = info
? !this._isLayerEnabled(info.layerId)
: !this._aircraftLayerEnabled;
return {
active,
entryAllowed,
// Why entry is unavailable, so a refusal can be explained rather than
// guessed at.
// guessed at. Contacts is not required, so it is never the reason.
entryBlockedReason:
entryAllowed || active
? null
: !gateOpen
? this._contextModeChanging
? 'contacts-starting'
: 'contacts-inactive'
: 'no-tracked-aircraft',
: this._contextModeChanging
? 'contacts-starting'
: subjectLayerOff
? 'subject-layer-off'
: 'no-tracked-aircraft',
visionMode: this.cockpitView?.visionMode || null,
subject: info
? {
@@ -260,17 +312,18 @@ export class CockpitCoordinator {
};
}
if (normalized === 'enter') {
// Entry is gated exactly as the manual entry chip is. Attempting it while
// the gate is shut produced the half-entered look the operator reported
// (a plane anchored under the camera with no cockpit around it), so
// refuse with the reason instead of trying.
if (!this.cockpitView.isEntryAllowed?.()) {
// Attempting entry while the gate is shut produced the half-entered look
// the operator reported (a plane anchored under the camera with no
// cockpit around it), so refuse with the reason instead of trying. The
// aircraft may not be tracked yet (a selection or a requested layer is
// adopted below), so this checks what can be known now; the entry
// itself then applies the manual chip's gate to the aircraft it rides.
const refusal = this._cockpitEntryRefusal();
if (refusal) {
return {
ok: false,
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
error: refusal,
state: this.getCockpitState(),
};
}
+467
View File
@@ -0,0 +1,467 @@
// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of
// the vehicle it rides: Contacts and the other feeds may be off before or
// during a session, turning the ridden layer off ends it, Contacts ending
// keeps the ridden layer it had turned on, and the voice gate and state say
// so without claiming Contacts is required. Over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { DataLayerManager } from '../data/manager.js';
import { createDependencies } from '../layers/awareness/dependencies.js';
import { CockpitCoordinator } from './cockpitCoordinator.js';
import { ContextControls } from './contextControls.js';
import { _handleContextLayerChange } from './contextLayerChanges.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useHudStore } from './stores/hud.js';
import ContextView from './views/ContextView.vue';
import IntelHudView from './views/IntelHudView.vue';
const CIVIL = Object.freeze({
icao24: 'abc123',
callsign: 'UAL123',
track: 92,
latitude: 30,
longitude: -97,
});
const MILITARY = Object.freeze({
...CIVIL,
icao24: 'ae0001',
callsign: 'RCH1',
});
function fixture(
t,
{
enabled = [],
tracked = null,
contextMode = null,
changing = false,
dataManager: suppliedManager = null,
readContext = null,
} = {},
) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => [h(ContextView), h(IntelHudView)]),
).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const layers = new Set(enabled);
const dataManager = suppliedManager || {
isEnabled: (layerId) => layers.has(layerId),
};
// A layer that is off has no tracked aircraft, as the flight layers clear
// tracking on disable.
const trackedFrom = (layerId) => () =>
tracked?.layerId === layerId && dataManager.isEnabled(layerId)
? tracked.info
: null;
const entityFor = (subject) => ({
position: { getValue: () => null },
show: true,
gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '',
});
const entities = new Set();
const entity = entityFor(tracked);
entities.add(entity);
const listeners = () => ({ addEventListener: () => () => {} });
const viewer = {
trackedEntity: tracked ? entity : undefined,
clock: { currentTime: null },
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
entities: { contains: (candidate) => entities.has(candidate) },
scene: {
preUpdate: listeners(),
screenSpaceCameraController: { enableInputs: true },
},
trackedEntityChanged: listeners(),
};
const context = { mode: contextMode, changing };
const coordinator = new CockpitCoordinator({
viewer,
services: {
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
militaryAwarenessLayer: { getContextSnapshot: () => null },
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
},
elements: {},
operations: {
publishCockpit: { presence: (view) => store.publishPresence(view) },
_stampNavigation() {},
getAircraftTrackingTarget: () =>
tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null,
},
readDataManager: () => dataManager,
readContext: readContext || (() => context),
readActiveStyle: () => 'normal',
readUtilityDisclosureOpen: () => false,
enterPanels() {},
exitPanels() {},
});
const cockpit = coordinator.cockpitView;
// The HUD, briefing and signals have their own tests.
Object.assign(cockpit, {
updateHud() {},
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
pushCockpitSignal() {},
scheduleContextLayout() {},
clearPredictiveRoute() {},
});
t.after(() => {
coordinator.stop();
app.unmount();
document.body.classList.remove('cockpit-mode');
});
/** Turn a layer off and tell the Context owner, as the data manager does. */
const disable = (layerId) => {
layers.delete(layerId);
_handleContextLayerChange.call(
{
cockpitView: cockpit,
_dataManager: dataManager,
_contextMode: null,
_contextModeChanging: false,
_contextSessionSnapshot: null,
_contextRestoreState: null,
_syncContextModeButtons() {},
},
{ type: 'visibility', layerId, enabled: false, origin: 'user' },
);
};
/** Track another aircraft, as Contacts navigation does mid-session. */
const retrack = (subject) => {
tracked = subject;
viewer.trackedEntity = entityFor(subject);
entities.add(viewer.trackedEntity);
};
const $ = (selector) => document.querySelector(selector);
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
}
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, true);
assert.equal(state.entryBlockedReason, null);
assert.equal(cockpit.enter(), true);
});
test('a military aircraft enters with Military Flights alone', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['military'],
tracked: { layerId: 'military', info: MILITARY },
});
assert.equal(coordinator.getCockpitState().entryAllowed, true);
assert.equal(cockpit.enter(), true);
});
test('a Context change in flight still blocks entry, and says so', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights', 'military'],
tracked: { layerId: 'flights', info: CIVIL },
contextMode: 'flights',
changing: true,
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, false);
assert.equal(state.entryBlockedReason, 'contacts-starting');
assert.equal(cockpit.enter(), false);
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /Context is still changing/);
assert.equal(cockpit.active, false);
});
test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => {
const { coordinator, cockpit } = fixture(t);
const state = coordinator.getCockpitState();
assert.equal(state.entryBlockedReason, 'subject-layer-off');
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /No aircraft layer is on/);
assert.doesNotMatch(voice.error, /contacts/i);
assert.equal(voice.state.entryBlockedReason, 'subject-layer-off');
assert.equal(cockpit.active, false);
});
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
const { coordinator } = fixture(t, { enabled: ['military'] });
assert.equal(
coordinator.getCockpitState().entryBlockedReason,
'no-tracked-aircraft',
);
});
test('voice enters without Contacts when the ridden layer is on', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const result = coordinator.controlCockpit('enter');
assert.equal(result.ok, true, result.error);
assert.equal(cockpit.active, true);
});
test('other layers may be turned off during a session; the ridden layer ends it', (t) => {
const { cockpit, disable } = fixture(t, {
enabled: ['flights', 'military', 'ais-live-vessels'],
tracked: { layerId: 'flights', info: CIVIL },
});
assert.equal(cockpit.enter(), true);
disable('military');
disable('ais-live-vessels');
assert.equal(cockpit.active, true, 'other feeds are free to go off');
disable('flights');
assert.equal(cockpit.active, false, 'the ridden layer going off ends it');
});
test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => {
const { $, cockpit, pinia } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
useHudStore(pinia);
const readoutEntry = $('#cockpit-entry-readout');
assert.equal(readoutEntry.hidden, true, 'hidden until entry is available');
cockpit.syncEntry();
await nextTick();
assert.equal(readoutEntry.hidden, false);
assert.match(readoutEntry.textContent, /flight/);
assert.match(readoutEntry.textContent, /COCKPIT/);
assert.equal(
$('#cockpit-entry').hidden,
false,
'the Contacts entry follows the same presence',
);
readoutEntry.click();
await nextTick();
assert.equal(cockpit.active, true);
assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter');
});
const AIR_AND_SEA = [
'flights',
'military',
'ais-live-vessels',
'military-installations',
];
/** A source layer that settles at once, as the data manager drives it. */
function sourceLayer(id) {
return {
id,
name: id,
icon: '',
source: 'test',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
getStats: () => ({ count: 0, lastUpdate: null }),
};
}
/**
* Contacts as the manager sees it: a coordinator whose real dependency
* ownership (src/layers/awareness/dependencies.js) turns on the air and sea
* layers it finds off and releases them when it goes off.
*/
function contactsCoordinator() {
const state = {
dataManager: null,
enabled: false,
passive: true,
activationId: 0,
ownedDependencies: new Set(),
subject: null,
autoFocusRetryPending: false,
};
const dependencies = createDependencies({
state,
services: {},
parts: { subject: {}, focus: {} },
source: null,
});
return {
id: 'military-awareness',
name: 'Contacts',
icon: '',
source: 'test',
showInTogglePanel: false,
updateInterval: -1,
attachDataManager(dataManager) {
state.dataManager = dataManager;
dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
if (typeof params.passive === 'boolean') state.passive = params.passive;
},
getParams: () => ({ passive: state.passive }),
init() {},
enable() {
state.enabled = true;
if (!state.passive) return dependencies.enableDependencies();
return true;
},
disable() {
state.enabled = false;
state.passive = true;
return dependencies.releaseOwnedDependencies(++state.activationId);
},
update() {},
getStats: () => ({ count: 0, lastUpdate: null }),
};
}
/**
* The Cockpit beside the real Context owner and data manager, so a Contacts
* exit runs its whole restore: the coordinator's releases, then the snapshot.
*/
async function liveFixture(t, { enabled = [], tracked = null } = {}) {
const manager = new DataLayerManager({});
for (const layerId of AIR_AND_SEA) manager.register(sourceLayer(layerId));
const coordinatorModule = contactsCoordinator();
manager.register(coordinatorModule);
coordinatorModule.attachDataManager(manager);
for (const layerId of enabled) await manager.setEnabled(layerId, true);
let cockpit = null;
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => cockpit,
claimVisualAuthority() {},
showToast() {},
setClearBusy() {},
syncDetection() {},
scheduleLayout() {},
refreshRadio() {},
setPanelCollapsed() {},
},
});
controls.connect(manager);
t.after(() => {
controls.stop();
controls.disconnect();
});
const base = fixture(t, {
tracked,
dataManager: manager,
readContext: () => controls.getContextModeState(),
});
cockpit = base.cockpit;
/** Let every Context reaction the last change started settle. */
const settle = async () => {
for (let turn = 0; turn < 5; turn += 1) {
await controls._waitForContextLayerSettlement();
await new Promise((resolve) => setImmediate(resolve));
}
};
return { ...base, manager, controls, settle };
}
const on = (manager) =>
[...AIR_AND_SEA, 'military-awareness'].filter((layerId) =>
manager.isEnabled(layerId),
);
test('Contacts ending in a Cockpit session keeps the ridden layer it had turned on', async (t) => {
for (const [label, endContacts] of [
[
'Military Flights off',
({ manager }) =>
manager.setEnabled('military', false, { origin: 'user' }),
],
[
'Contacts off by its own layer',
({ manager }) =>
manager.setEnabled('military-awareness', false, { origin: 'user' }),
],
[
'Contacts off from its chooser',
({ controls }) => controls._selectContextMode(null),
],
]) {
await t.test(label, async (t) => {
// Live Flights is off on the map; Contacts turns it on as its own.
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.deepEqual(on(manager), [...AIR_AND_SEA, 'military-awareness']);
assert.equal(cockpit.enter(), true);
await endContacts(live);
await settle();
assert.equal(controls.getContextModeState().mode, null, 'Contacts ended');
assert.equal(cockpit.active, true, 'the Cockpit rides on');
assert.deepEqual(
on(manager),
['flights'],
'only the ridden layer outlives the pre-Contacts restore',
);
});
}
});
test('a ride handed to an aircraft on a layer Contacts turned on keeps that layer', async (t) => {
const live = await liveFixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, retrack, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.equal(cockpit.enter(), true);
// NEXT hands the view to a military aircraft; Contacts turned its layer on.
retrack({ layerId: 'military', info: MILITARY });
assert.equal(cockpit._adoptTrackedEntity(performance.now()), true);
assert.equal(cockpit.readSubjectLayerId(), 'military');
await manager.setEnabled('ais-live-vessels', false, { origin: 'user' });
await settle();
assert.equal(controls.getContextModeState().mode, null);
assert.equal(cockpit.active, true);
assert.deepEqual(on(manager), ['flights', 'military']);
});
test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => {
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.equal(cockpit.enter(), true);
await manager.setEnabled('flights', false, { origin: 'user' });
await settle();
assert.equal(cockpit.active, false);
assert.equal(manager.isEnabled('flights'), false);
});
test('without a Cockpit, ending Contacts restores the layers it found', async (t) => {
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
await manager.setEnabled('military', false, { origin: 'user' });
await settle();
assert.equal(controls.getContextModeState().mode, null);
assert.deepEqual(on(manager), [], 'Live Flights goes back off');
});
+94
View File
@@ -1,5 +1,99 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
/** How far from the followed track a double-click may land, CSS pixels. */
export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32;
/**
* Whether a double-click landed on the followed track's mark.
* @param {{x: number, y: number}|null} click Canvas position of the click.
* @param {{x: number, y: number}|null|undefined} subject Canvas position of
* the track, or nothing when it is off screen.
* @param {number} [radiusPx]
*/
export function doubleClickHitsSubject(
click,
subject,
radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX,
) {
if (!click || !subject) return false;
const distance = Math.hypot(click.x - subject.x, click.y - subject.y);
return Number.isFinite(distance) && distance <= radiusPx;
}
/** Where the track `kind` would ride is drawn now, as a world position. */
function subjectWorldPosition(kind) {
if (kind !== 'aircraft') return null;
return (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
}
/** The followed track's canvas position now, or null off screen. */
function subjectCanvasPosition() {
const kind = this.resolveEntryKind();
const world = kind ? subjectWorldPosition.call(this, kind) : null;
const scene = this.viewer.scene;
if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function')
return null;
return (
scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null
);
}
/**
* Enter first person when the followed track itself is double-clicked: the
* same entry, and the same gates, as C and the entry buttons. A
* double-click anywhere else, or one a tool owns (drawing ends a shape with
* one), is left alone.
* @param {{x: number, y: number}|null|undefined} position Canvas position.
* @returns {boolean} True when it landed on the followed track, entered or
* refused by the gate: the caller does nothing else with it.
*/
export function onSceneDoubleClick(position) {
if (this.active || this.destroyed || !isPointerFree()) return false;
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
return false;
if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this)))
return false;
this.enter();
return true;
}
/**
* Take the scene's double-click through the Cockpit first. Cesium's Viewer
* installs its own (pickAndTrackObject), which untracks whenever the pick is
* not an Entity: on a followed aircraft's model, a primitive, it would drop
* the very track the double-click means to ride. A double-click on the
* followed track is the Cockpit's; any other goes on to Cesium's.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
export function routeSceneDoubleClick() {
const handler = this.viewer.screenSpaceEventHandler;
if (typeof handler?.setInputAction !== 'function') return () => {};
const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
if (this.onSceneDoubleClick(event?.position)) return;
stock?.(event);
};
handler.setInputAction(routed, type);
return () => {
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
return;
if (stock) handler.setInputAction(stock, type);
else handler.removeInputAction(type);
};
}
/**
* The Cockpit's capture-phase keydown: Escape and C.
* @param {KeyboardEvent} event
*/
export function onKeyDown(event) {
if (this.destroyed) return false;
if (event.repeat || event.isComposing) return;
+18
View File
@@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) {
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
/**
* The Cockpit entry for each subject it can ride: an aircraft's cockpit.
* Both entry buttons (Contacts and the Intel HUD readout) show it.
*/
const COCKPIT_ENTRY_FACES = Object.freeze({
aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }),
});
/**
* The entry's glyph and localized label for the subject it would ride.
* @param {'aircraft'|null|undefined} kind
* @returns {{icon: string, label: string}}
*/
export function cockpitEntryFace(kind) {
const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
return { icon: face.icon, label: t(face.label) };
}
+75 -11
View File
@@ -62,7 +62,52 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
this.publishCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
/**
* Take a status row off the list, as when the feed it warned about is back.
* @param {string} key The row's key.
*/
export function dropCockpitSignal(key) {
this.signalSignatures.delete(key);
const kept = this.signalItems.filter((item) => item.key !== key);
if (kept.length === this.signalItems.length) return;
this.signalItems = kept;
this.publishCockpitSignals();
}
/** Whether a row is one a Contacts snapshot builds, with its status rows. */
const isContextSnapshotRow = (key) =>
key.startsWith('flight:') ||
['input-status', 'feed-status', 'context-status'].includes(key);
/**
* Take off every row a Contacts snapshot built, as when Contacts goes off:
* its contacts, distances and CURRENT would otherwise read as live. Rows the
* session made without Contacts, such as TRACK ACQUIRED, stay.
*/
export function dropContextSnapshotSignals() {
for (const key of [...this.signalSignatures.keys()])
if (isContextSnapshotRow(key)) this.signalSignatures.delete(key);
const kept = this.signalItems.filter(
(item) => !isContextSnapshotRow(item.key),
);
if (kept.length === this.signalItems.length) return;
this.signalItems = kept;
this.publishCockpitSignals();
}
/**
* Rebuild the rows from a Contacts snapshot: the nearest flights, a row for
* unknown inputs and, first, a row naming an air feed that is off.
* @param {object} snapshot `militaryAwareness.getContextSnapshot()` result.
* @param {number} unknownCount Cohorts whose count is unknown.
* @param {?{title: string, detail: string}} [partialPicture] The feed-off
* row (see `readPartialPicture`), or null with every air feed on.
*/
export function updateCockpitSignals(
snapshot,
unknownCount,
partialPicture = null,
) {
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
const contacts = [];
const subject = snapshot.subject;
@@ -110,7 +155,9 @@ export function updateCockpitSignals(snapshot, unknownCount) {
}
}
contacts.sort((a, b) => a.distanceM - b.distanceM);
const nextItems = contacts.slice(0, 5).map((item) => ({
// A feed-off row leads, so the list never reads as the whole picture.
const reserved = partialPicture ? 1 : 0;
const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({
...item,
timestamp:
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
@@ -120,18 +167,35 @@ export function updateCockpitSignals(snapshot, unknownCount) {
.filter((cohort) => cohort.count === null)
.map((cohort) => cohort.source)
.join(' · ');
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status',
nextItems.splice(
4 - reserved,
Math.max(0, nextItems.length - 4 + reserved),
{
key: 'input-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
},
);
}
if (partialPicture) {
nextItems.unshift({
key: 'feed-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
title: partialPicture.title,
detail: partialPicture.detail,
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
previous.get('feed-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
});
+31 -2
View File
@@ -17,6 +17,30 @@ export function readAircraftInfo() {
});
}
/**
* The layer the active view rides: the aircraft's own layer. It is read from
* the tracked entity's id once the layer has dropped its tracking, so a layer
* turned off still names itself.
* @returns {'flights'|'military'|null}
*/
export function readSubjectLayerId() {
if (!this.active) return null;
const layerId = this.readAircraftInfo()?.layerId;
if (layerId) return layerId;
const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':');
return ['flights', 'military'].includes(prefix) ? prefix : null;
}
/**
* The subject entry would take: a Cesium-tracked aircraft.
* @returns {'aircraft'|null}
*/
export function resolveEntryKind() {
return this.readAircraftInfo() && this.viewer.trackedEntity?.position
? 'aircraft'
: null;
}
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || '')
@@ -131,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
this.lastContextUpdateMs = 0;
this.lastCameraUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.onRide?.(info.layerId);
this.dispatchCockpitModeChanged(true, info);
return true;
}
@@ -206,6 +231,7 @@ export function enter() {
this.mapViewButton?.focus({ preventScroll: true });
});
this.onEntered?.();
this.onRide?.(info.layerId);
this.dispatchCockpitModeChanged(true, info);
return true;
}
@@ -247,9 +273,12 @@ export function exit({ restoreTracking = true } = {}) {
this.restoreTrackingFrame(entity);
}
this.syncEntry();
// Back to whichever entry was used: the Contacts button or the readout's.
const readoutEntry = document.getElementById('cockpit-entry-readout');
const restoreTarget =
this.entryFocusOrigin === this.entry
? this.entry
this.entryFocusOrigin === this.entry ||
(readoutEntry && this.entryFocusOrigin === readoutEntry)
? this.entryFocusOrigin
: this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null;
// The entry shows when Vue flushes the presence; read it after.
+6 -10
View File
@@ -1,7 +1,6 @@
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
@@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) {
);
}
}
if (
this.cockpitView?.active &&
!cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
})
) {
// Turning off the layer the view rides ends it; every other layer may be
// turned on or off freely during a session.
const riddenLayerId = this.cockpitView?.active
? this.cockpitView.readSubjectLayerId?.()
: null;
if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) {
this.cockpitView.exit({ restoreTracking: false });
}
this._syncContextModeButtons();
+6 -3
View File
@@ -18,7 +18,10 @@ export async function _selectContextMode(
this._contextModeChanging = true;
this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
// Cockpit does not need Contacts, so leaving it keeps the view; the
// restore below ends it only by turning off the layer it rides. Another
// mode takes the camera, so switching to one ends it.
if (mode && mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
}
if (!mode) {
@@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({
this._contextMode = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
if (this.cockpitView?.active)
this.cockpitView.exit({ restoreTracking: false });
// A layer turned off ends Contacts but not the Cockpit: the view ends only
// when the layer it rides goes off (see _handleContextLayerChange).
this._syncContextModeButtons();
try {
await this._restoreContextSession({ notificationToken });
+15
View File
@@ -117,3 +117,18 @@ test('a radio group moves from the focused option, an arrows group from the sele
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
arrows.unmount();
});
test("an option's own tip wins; an icon option without one is titled by its label", () => {
const wrapper = mountGroup({
options: [
{ value: 'a', label: 'Alpha', attrs: { title: 'Alpha tip' } },
{ value: 'b', label: 'Beta', icon: 'star' },
{ value: 'c', label: 'Gamma' },
],
});
const titles = wrapper
.findAll('button')
.map((b) => b.attributes('title') ?? null);
assert.deepEqual(titles, ['Alpha tip', 'Beta', null]);
wrapper.unmount();
});
+3 -2
View File
@@ -8,7 +8,8 @@ import { computed } from 'vue';
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
// selected option's `activeClass`. An option with an `icon` shows the glyph,
// and its label becomes the button's accessible name and tooltip.
// and its label becomes the button's accessible name and tooltip, unless its
// `attrs` carry a tip of their own.
const props = defineProps({
modelValue: { type: String, default: undefined },
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
@@ -119,7 +120,7 @@ function onKey(event) {
]"
:aria-checked="String(option.value === props.modelValue)"
:aria-label="option.icon ? option.label : undefined"
:title="option.icon ? option.label : undefined"
:title="option.attrs?.title ?? (option.icon ? option.label : undefined)"
:disabled="optionOff(option)"
:tabindex="tabindex(index)"
@click="choose(index)"
+6
View File
@@ -141,6 +141,12 @@ export class LayerBindings {
});
this._weatherShellModules.push(layer);
}
// Active Fires drapes its far-zoom heatmap on the same imagery host.
const fires = this._dataManager?.layers?.get('local-firms')?.module;
if (typeof fires?.attachShellServices === 'function') {
fires.attachShellServices({ imageryHost: this.services.imageryHost });
this._weatherShellModules.push(fires);
}
}
_persistAwarenessSelection(event, cleared = false) {
+14
View File
@@ -141,3 +141,17 @@ export function sourceLabel(source) {
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* A provider name with the feed state it answered in, so a fallback or stale
* snapshot does not read as the live primary feed.
* @param {string} source English source name.
* @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`.
* @returns {string}
*/
export function sourceStatusLabel(source, status) {
const name = sourceLabel(source);
if (status === 'fallback') return `${name} · ${t('FALLBACK')}`;
if (status === 'stale') return `${name} · ${t('STALE')}`;
return name;
}
+17
View File
@@ -7,6 +7,7 @@ import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
sourceStatusLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
@@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a provider name carries the fallback or stale state it answered in', () => {
assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder');
assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK');
assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE');
setLocale('ru');
try {
assert.equal(
sourceStatusLabel('adsb.lol', 'fallback'),
`adsb.lol · ${t('FALLBACK')}`,
);
assert.notEqual(t('FALLBACK'), 'FALLBACK');
} finally {
setLocale('en');
}
});
+35 -5
View File
@@ -442,6 +442,8 @@ export class LayerPanel {
this._selectChip(layerId, chipId, generation),
onListItem: (layerId, itemId) =>
this._selectListItem(layerId, itemId, generation),
onSlider: (layerId, sliderId, value) =>
this._moveSlider(layerId, sliderId, value, generation),
}),
id === null ? this._toggleContainer : this._groupContainers.get(id),
);
@@ -471,7 +473,8 @@ export class LayerPanel {
}
/**
* A layer's row chips, color legend, info line and ordered list. The block
* A layer's row chips, one-of-many switch, slider, color legend, info line
* and ordered list. The block
* is hidden while the layer is off (or while a dependency owner has
* surrendered it), so a quiet row stays quiet. Chips and list items are
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
@@ -495,11 +498,20 @@ export class LayerPanel {
: `${item.label} ${formatCount(item.count)}`,
}));
const infoText = controls?.info ? String(controls.info) : '';
const segmented = controls?.segmented || null;
const slider = controls?.slider || null;
// The info line, once shown, stays in place and hides when empty.
if (infoText) row.info = true;
return {
hidden: chips.length === 0 && legend.length === 0 && !infoText,
hidden:
chips.length === 0 &&
legend.length === 0 &&
!infoText &&
!segmented &&
!slider,
chips,
segmented,
slider,
legend,
info: row.info
? { text: infoText, title: controls?.infoTitle || '' }
@@ -537,15 +549,33 @@ export class LayerPanel {
// listeners, so a click from a released generation is dropped.
_selectChip(id, chipId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const chip = this._rowControlsFor(id)?.chips?.find(
(entry) => entry.id === chipId,
);
// A switch option carries the same fields as a chip.
const live = this._rowControlsFor(id);
const chip = [
...(live?.chips || []),
...(live?.segmented?.options || []),
].find((entry) => entry.id === chipId);
if (!chip || chip.disabled || !this.isEnabled(id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(id, chip.params, { origin: 'user' });
}
// A row slider (e.g. the Active Fires minimum-FRP filter) maps its position
// to params through the live descriptor on every tick.
_moveSlider(id, sliderId, value, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const slider = this._rowControlsFor(id)?.slider;
if (
!slider ||
slider.id !== sliderId ||
typeof slider.params !== 'function' ||
!this.isEnabled(id)
)
return;
this.setLayerParams(id, slider.params(value), { origin: 'user' });
}
_selectListItem(id, itemId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const item = this._rowControlsFor(id)?.list?.items?.find(
+13
View File
@@ -1,6 +1,8 @@
<script setup>
import RailChips from '../rail/RailChips.vue';
import RailRowList from '../rail/RailRowList.vue';
import RowSlider from './RowSlider.vue';
import RowSwitch from './RowSwitch.vue';
import RowText from './RowText.vue';
import { vClassToggle } from '../rail/classToggle.js';
@@ -12,6 +14,7 @@ defineProps({
onToggle: { type: Function, default: () => {} },
onChip: { type: Function, default: () => {} },
onListItem: { type: Function, default: () => {} },
onSlider: { type: Function, default: () => {} },
});
</script>
@@ -55,6 +58,16 @@ defineProps({
><RailChips
:chips="item.controls.chips"
:on-select="(chip) => onChip(item.id, chip.id)"
/><RowSwitch
v-if="item.controls.segmented"
:segmented="item.controls.segmented"
:on-select="(optionId) => onChip(item.id, optionId)"
/><RowSlider
v-if="item.controls.slider"
:slider="item.controls.slider"
:on-input="
(value) => onSlider(item.id, item.controls.slider.id, value)
"
/><span
v-for="(entry, index) in item.controls.legend"
:key="index"
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more