diff --git a/.env.example b/.env.example index b3de4cefa..169ec66e3 100644 --- a/.env.example +++ b/.env.example @@ -238,6 +238,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL # CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras # CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today) +# CCTV_ICELAND_ENABLED=1 # set 0 to disable the Vegagerðin (Iceland) road cameras +# CCTV_ICELAND_MAX_SOURCES=250 # cap cameras loaded from Iceland (~500 views exist today) # CCTV_HK_ENABLED=1 # set 0 to disable the Hong Kong Transport Department cameras # CCTV_HK_MAX_SOURCES=1100 # cap cameras loaded from Hong Kong (~1,013 exist today); min 8, max 1200 # CCTV_HK_CAMERAS_URL= # override the DATA.GOV.HK camera locations XML URL diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 03747265d..e97d2f021 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | +| **Icelandic Road and Coastal Administration (Vegagerðin, IRCA)** | CCTV camera catalog + frames, Iceland road cameras | [CC BY 4.0](https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions) (IRCA terms of use, Act 45/2018) — attribution REQUIRED; no suggestion of IRCA endorsement; IRCA and State logos are excluded; real-time data is provided as is | "Icelandic Road and Coastal Administration (Vegagerðin)", CC BY 4.0 | | **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) | | **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) | | **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" | @@ -178,6 +179,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **FL511 Florida cameras.** Off by default; set `CCTV_FLORIDA_ENABLED=1` to load them. The camera list is the keyless FL511 traffic camera layer on ArcGIS (`services.arcgis.com/.../FL511_Traffic_Cameras`), published from a Florida Division of Emergency Management account rather than by FL511 directly, and the service carries no licence text of its own. It is paged up to 2,000 rows at a time, ordered by the layer's object id and following ArcGIS's `exceededTransferLimit`, to cover the ~4,000-camera statewide set. Frames are stills on two Divas Cloud hosts (`images-dis.divas.cloud`, `snapshots.divas.cloud`); both are host-pinned and a row whose image resolves elsewhere is dropped. `CCTV_FLORIDA_MAX_SOURCES` caps the pack (default 500, anchored per region across the state). Each catalog page is read under a 2 MiB cap and redirects are refused. Frames are fetched live at request time and never stored or redistributed. FL511's terms (fl511.com/privacy#disclaimer) license its content for individual, personal use only; any other use, including a public deployment, needs FDOT's express written consent. - **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`. +- **Iceland (Vegagerðin / IRCA) cameras.** The camera list is the keyless `https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` JSON feed (about 500 camera views at roughly 170 stations), fetched over https with redirects refused and the body read capped at 2 MiB, since the list decides ids, coordinates and which frame path is proxied. Frames are JPEG stills in `https://www.vegagerdin.is/vgdata/vefmyndavelar/`; only links inside that directory are registered (userinfo, explicit ports and encoded path separators are rejected). Camera ids come from the frame file name. The feed's `Skyring` view description is the camera label, and a stated facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading; a view that states none, or two different ones ("niður á veg", a bare road direction), takes the id-hash low-confidence pose. The feed has no elevation, so cameras carry a flat prior that the client's ground snap corrects. IRCA publishes road camera images under CC BY 4.0 and requires the licensor to be acknowledged without implying endorsement; the pack shows the attribution above (licensor, dataset, CC BY 4.0, terms link and no-endorsement note) in the Data attribution popover, and every camera record carries the licence string. IRCA describes the images as fetched several times per hour (a live frame's `Last-Modified` was about four minutes old on 2026-11-06) and the real-time data as provided as is, without warranty. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_ICELAND_ENABLED=0`. - **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`. - **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here. - **Hong Kong Transport Department traffic snapshot cameras.** Keyless. The English camera list is the public DATA.GOV.HK XML at `static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml`; frames are stills on `tdcctv.data.one.gov.hk`, and only that host is registered. Frame URLs are rebuilt from each camera key (`https://tdcctv.data.one.gov.hk/{KEY}.JPG`) rather than taken verbatim from the catalog. Stills were probed to answer directly on that origin with no redirect hop. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default nearly the full catalog loads (~1,013; pack cap 1,100), prioritized nearest Central, Tsim Sha Tsui, Sha Tin, Tsuen Wan, Tai Po, Fanling, Tuen Mun, Yuen Long and Tseung Kwan O so a lowered cap still spans Hong Kong Island, Kowloon and the New Territories. When the sum of packs exceeds the catalog-wide ceiling, shared round-robin keeps every lane represented. `CCTV_HK_MAX_SOURCES` (8–1200) changes the cap and `CCTV_HK_ENABLED=0` turns the pack off. The DATA.GOV.HK Terms of Use require attribution to the Government / Transport Department / DATA.GOV.HK. diff --git a/README.md b/README.md index 32981b337..8af137fcb 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Iceland (Vegagerðin) · Hong Kong (Transport Department) · Taiwan (Freeway Bureau, MOTC) · Italy (A22 Brennero motorway), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed), opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent) and opt-in Houston (TranStar, enable only with TxDOT's written permission per TranStar's Content Usage terms). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | diff --git a/changelog.d/1578-space-missions-failed-entry.md b/changelog.d/1578-space-missions-failed-entry.md new file mode 100644 index 000000000..5d8d67735 --- /dev/null +++ b/changelog.d/1578-space-missions-failed-entry.md @@ -0,0 +1,4 @@ +- Space Missions no longer locks the workspace when its mission index fails to + load (429, timeout or bad payload): entry rolls back to the previous layers + and camera view and a toast says the index could not load (German, @gekh, + #1578). diff --git a/changelog.d/1609-traffic-lines-dots-toggles.md b/changelog.d/1609-traffic-lines-dots-toggles.md new file mode 100644 index 000000000..be5060b45 --- /dev/null +++ b/changelog.d/1609-traffic-lines-dots-toggles.md @@ -0,0 +1,4 @@ +- Street Traffic offers separate Lines and Dots toggles while live flow is on: + Lines draws each road with flow in green, amber or red, and Dots shows or + hides the moving dots. A host can choose which road modes it offers; without + OpenStreetMap the layer never draws simulated roads (#1609). diff --git a/changelog.d/1616-openai-chat-tool-patterns.md b/changelog.d/1616-openai-chat-tool-patterns.md new file mode 100644 index 000000000..225d3f4a5 --- /dev/null +++ b/changelog.d/1616-openai-chat-tool-patterns.md @@ -0,0 +1,4 @@ +- Typed chat on OpenAI answers again: OpenAI refused every message because + `resolve_area`'s area-id pattern uses Unicode classes Chat Completions does + not accept. Chat leaves those patterns out; area ids are still checked + before the tool runs (samehkhamis, #1616). diff --git a/changelog.d/1617-cctv-iceland.md b/changelog.d/1617-cctv-iceland.md new file mode 100644 index 000000000..cf66651bf --- /dev/null +++ b/changelog.d/1617-cctv-iceland.md @@ -0,0 +1,12 @@ +- Add Icelandic road cameras from the Icelandic Road and Coastal Administration + (Vegagerðin) as a CCTV pack. The camera list is one keyless JSON request to + `gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1` (about 500 views at 166 + stations); frames are JPEG stills in `www.vegagerdin.is/vgdata/vefmyndavelar/`, + and only links inside that directory are registered. The catalog is fetched + over https with redirects refused and a 2 MiB body cap. An explicit facing in + the view description ("til vesturs", "í suður", "séð austur") sets a + high-confidence heading; other views keep the low-confidence fallback pose. + IRCA publishes the images under CC BY 4.0, credited in the Data attribution + popover. Loads the 250 views nearest Reykjavík, Akureyri, Egilsstaðir, + Ísafjörður and Selfoss; `CCTV_ICELAND_MAX_SOURCES` changes that cap and + `CCTV_ICELAND_ENABLED=0` turns the pack off (daikaginza, #1617). diff --git a/changelog.d/1620-cctv-opening-camera.md b/changelog.d/1620-cctv-opening-camera.md new file mode 100644 index 000000000..10aa13f19 --- /dev/null +++ b/changelog.d/1620-cctv-opening-camera.md @@ -0,0 +1,3 @@ +- Turning CCTV on opens the camera nearest you instead of the first camera in + the catalog, which showed a Sydney camera in London. A share link opens on + the camera nearest the shared view once its flight lands (samehkhamis, #1620). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d7bef357a..fc3104cfd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -134,7 +134,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. +Traffic holds continuous rendering only while enabled with shown animated dots, a pulsing jam heat-line, or road lines whose geometry is still building (replaced lines stay drawn until their successors are ready, so a rebuild never blinks); hidden dots (`dots` off) and settled flow lines hold nothing. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is @@ -4358,7 +4358,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | @@ -5336,7 +5336,7 @@ silently demoting every later lookup for the session. - Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. -- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. +- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. - 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. @@ -6438,7 +6438,26 @@ easier to meet (detection is now on more often), but does not create it. - Road source (`src/layers/traffic/roadModes.js`): the Street Traffic row has TomTom / OSM / Hybrid chips (`roadMode` param, stored in the existing layer state `lo` field, no new share token); `?trafficRoads=tomtom|osm|hybrid` - overrides restored state until the user picks a chip. With no stored choice + overrides restored state until the user picks a chip. A host may offer + fewer modes with `createTrafficSource({ roadModes })` (a non-empty subset, + default all three): only offered modes get chips (none for a single mode), + and other choices from chips, restores, share links or the query are + ignored. Without `osm` the layer never simulates: keyless or with the + status probe unreachable, loads fail with "TomTom roads need a TomTom key" + or "TomTom roads unavailable while the traffic service is unreachable" and + no roads are drawn. +- Row toggles (live mode only): after the road-source chips, Lines draws + every road with flow as a steady draped line in its bucket color (green + free, amber slow, red jam; jam first, then slow, then free, longest first, + capped at 1,500; on 3D tiles under photoreal, on terrain or tiles + otherwise), and Dots shows or hides the moving dots (hidden dots are kept, + are not offered to detection overlays, and hold no render frames). + Without live flow only a hidden Dots toggle is offered, so dots hidden by a + live choice, a restore or a share link can always be shown again. + Defaults: Lines off, Dots on. Both are `lines` / `dots` params stored in the + layer state `lo` field (`l`, `d`). Toggling either restyles in place, with + no refetch. The owner `jamViz` heat-line prototype is unchanged when Lines + is off. With no stored choice the default is Hybrid with a TomTom key and OSM without one; share links without a road-source choice (including older ones) likewise use the installation's default (Hybrid when keyed, OpenStreetMap when keyless). Without a key diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c4ca46f1f..ed45f435c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -475,6 +475,7 @@ "src/layers/satellites/stale.test.mjs", "src/layers/launches/source.test.mjs", "src/layers/launches/ingestion.test.mjs", + "src/layers/launches/failedEntry.test.mjs", "src/layers/installations/lifecycle.js", "src/layers/installations/viewport.js", "src/layers/installations/controls.js", @@ -582,6 +583,7 @@ "src/data/cctvHk.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvA22.test.mjs", + "src/data/cctvIceland.test.mjs", "src/data/cctvNyc.test.mjs", "src/data/cctvHouston.test.mjs", "src/data/cctvNswSource.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 4d3cf63fe..b5128e02a 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -20,6 +20,7 @@ import { loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, loadCataloniaSourcesFromOpenData, + loadIcelandSourcesFromOpenData, loadHkSourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadKingCountySourcesFromOpenData, @@ -110,6 +111,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), load: loadCataloniaSourcesFromOpenData, }, + { + name: 'iceland', + enabled: () => envEnabled('CCTV_ICELAND_ENABLED'), + load: loadIcelandSourcesFromOpenData, + }, { name: 'hk', enabled: () => envEnabled('CCTV_HK_ENABLED'), diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index e49567c1f..096a67f96 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -309,6 +309,37 @@ export const CATALONIA_ANCHORS = [ { lat: 41.5636, lon: 2.0111 }, // Terrassa ]; +/** + * Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras: + * one keyless JSON list, one row per camera view (about 500 views at roughly + * 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each + * rewritten by the publisher every few minutes. Camera images are published + * under CC BY 4.0 (IRCA terms of use, "Open data" section). + */ +export const ICELAND_CAMERAS_URL = + 'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1'; +/** The only directory Iceland camera frames may come from. */ +export const ICELAND_IMAGE_ORIGIN = + 'https://www.vegagerdin.is/vgdata/vefmyndavelar/'; +/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */ +export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024; +/** Ground-elevation prior in metres: the feed carries none, and the country + * runs from sea level to highland passes. The client's ground snap corrects + * this on 3D-tile stacks. */ +export const ICELAND_GROUND_ELEVATION_M = 60; +/** The feed lists about 500 views; the default keeps half so the shared + * catalog cap still leaves room for denser regions. */ +export const DEFAULT_ICELAND_MAX_SOURCES = 250; +/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps + * every region's nearest cameras instead of only the capital's. */ +export const ICELAND_ANCHORS = [ + { lat: 64.1466, lon: -21.9426 }, // Reykjavík + { lat: 65.6885, lon: -18.1262 }, // Akureyri + { lat: 65.2639, lon: -14.3948 }, // Egilsstaðir + { lat: 66.0749, lon: -23.1351 }, // Ísafjörður + { lat: 63.9331, lon: -20.9971 }, // Selfoss +]; + /** Hong Kong Transport Department traffic snapshot cameras: keyless English * locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */ export const DEFAULT_HK_CAMERAS_URL = diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 7f89f2ae1..ecb423c4f 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -14,6 +14,7 @@ import { NSW_IMAGE_USER_AGENT, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, TFL_IMAGE_ORIGIN, WARENDORF_IMAGE_ORIGINS, WSDOT_IMAGE_ORIGIN, @@ -523,6 +524,7 @@ for (const prefix of [ TFL_IMAGE_ORIGIN, TALLINN_IMAGE_ORIGIN, TARKTEE_IMAGE_ORIGIN, + ICELAND_IMAGE_ORIGIN, ...WARENDORF_IMAGE_ORIGINS, NSW_IMAGE_ORIGIN, CALGARY_IMAGE_ORIGIN, diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e49d12ffa..a1815eedc 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) { ); } +/** Iceland bounding box (mainland and nearby islands, with a margin). */ +export function isLikelyIcelandCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 63.0 && + lat <= 66.7 && + lon >= -25.0 && + lon <= -13.0 + ); +} + /** * Decode numeric HTML character references, decimal ("í") and hex * ("í") alike, the entity form the Catalonia feed uses for accented diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 1ae4de637..f6bfa35be 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -134,6 +134,12 @@ import { HOUSTON_MAX_CATALOG_BYTES, HOUSTON_GROUND_ELEVATION_M, HOUSTON_ANCHORS, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, + ICELAND_GROUND_ELEVATION_M, + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -152,6 +158,7 @@ import { isLikelyCalgaryCoordinate, isLikelyFloridaCoordinate, isLikelyCataloniaCoordinate, + isLikelyIcelandCoordinate, decodeNumericEntities, isLikelyNycCoordinate, isLikelyHoustonCoordinate, @@ -2029,6 +2036,174 @@ export async function loadCataloniaSourcesFromOpenData() { } } +/** + * Compass headings for Icelandic direction words, as they appear after "til" + * (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen", + * as in "séð austur"). + */ +const ICELAND_DIRECTION_HEADINGS = { + norður: 0, + norðaustur: 45, + austur: 90, + suðaustur: 135, + suður: 180, + suðvestur: 225, + vestur: 270, + norðvestur: 315, +}; +const ICELAND_DIRECTION_WORD = + '(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?'; +/** A facing, plus a second one joined by "og" ("til austurs og vesturs"). */ +const ICELAND_DIRECTION_RE = new RegExp( + `(?", "í <átt>" or "séð <átt>" counts: a bare place name or + * a road direction ("Miklabraut austur") is not a facing. A description naming + * two different directions ("til norðurs og til suðurs", "til austurs og + * vesturs") is ambiguous and yields NaN, and "niður á veg" (down onto the + * road) states none. + * + * @param {string} text - The feed's `Skyring` field. + * @returns {number} Heading in degrees [0..360), or NaN. + */ +export function icelandHeadingFromDescription(text) { + const headings = new Set(); + for (const match of String(text || '') + .toLowerCase() + .matchAll(ICELAND_DIRECTION_RE)) { + for (const word of [match[1], match[2]]) { + if (word) headings.add(ICELAND_DIRECTION_HEADINGS[word]); + } + } + return headings.size === 1 ? headings.values().next().value : NaN; +} + +/** + * One Vegagerðin camera row -> one catalog source, or null. A row is one camera + * view; a station (`Maelist_nr`) carries several. The id comes from the frame + * file name, which is unique per view in the live feed. + * + * @param {object} row - Element of the `vefmyndavelar2014_1` array. + * @returns {?object} + */ +export function icelandCameraToSource(row) { + const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN); + if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null; + // Numbers only: Number(null) and Number('') are 0. + const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN; + const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN; + if (!isLikelyIcelandCoordinate(lat, lon)) return null; + const stem = new URL(url).pathname + .slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length) + .replace(/\.jpe?g$/i, '') + .toLowerCase(); + if (!/^[a-z0-9_-]+$/.test(stem)) return null; + const cameraId = `is-vegagerdin-${stem}`; + const description = String(row.Skyring || '') + .replace(/\s+/g, ' ') + // Letter lookarounds, not \b: \b is ASCII-only and would join "Þverá + // Árnessýslu" while missing "séð séð". + .replace(/(?>} Normalized camera source objects. + */ +export async function loadIcelandSourcesFromOpenData() { + try { + const resp = await fetch(ICELAND_CAMERAS_URL, { + headers: { + Accept: 'application/json', + 'User-Agent': 'gods-eye-view-cctv-proxy/1.0', + }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + // A response this loader will not read still owns its transport until the + // body is released, so every rejection path cancels before returning. + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Iceland catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Iceland camera download failed:', resp.status); + return discard(); + } + const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES); + const rows = Array.isArray(body) ? body : []; + const cameras = rows.map(icelandCameraToSource).filter(Boolean); + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_ICELAND_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS); + console.log( + `[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Iceland camera download error:', + error?.message || error, + ); + return []; + } +} + /** * Read one XML child tag's text from an `` block. * diff --git a/server/providers/openai/chat.js b/server/providers/openai/chat.js index 413d69ba9..6ca4648d6 100644 --- a/server/providers/openai/chat.js +++ b/server/providers/openai/chat.js @@ -64,6 +64,30 @@ function validMessages(messages) { return !pending.length; } +/** + * A tool schema without the `pattern`s Chat Completions refuses: it answers + * 400 for a whole request whose tool uses Unicode property classes (`\p{L}`), + * as `resolve_area`'s area ids do. Every argument is still checked against + * the action's own contract before the tool runs, so the model loses a hint, + * not a rule. Realtime voice accepts these patterns and keeps them. + */ +export function chatSchema(schema) { + if (Array.isArray(schema)) return schema.map(chatSchema); + if (!object(schema)) return schema; + return Object.fromEntries( + Object.entries(schema) + .filter( + ([key, value]) => + !( + key === 'pattern' && + typeof value === 'string' && + /\\[pP]\{/.test(value) + ), + ) + .map(([key, value]) => [key, chatSchema(value)]), + ); +} + export function createOpenAiChatHandler({ annotationGuidance, tools = GEV_REALTIME_TOOLS, @@ -126,7 +150,12 @@ export function createOpenAiChatHandler({ tools: offeredTools(tools, { overpass }).map( ({ name, description, parameters }) => ({ type: 'function', - function: { name, description, parameters, strict: false }, + function: { + name, + description, + parameters: chatSchema(parameters), + strict: false, + }, }), ), }, diff --git a/src/data/cctv.js b/src/data/cctv.js index ad95f2794..ed3f56654 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest = export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest; export const focusCctvRecord = layer.focusCctvRecord; export const maybeAutoHop = layer.maybeAutoHop; +export const openingCameraId = layer.openingCameraId; +export const reopenNearestCamera = layer.reopenNearestCamera; export const cctvCycleIndex = layer.cctvCycleIndex; export { CCTV_CALIBRATION_STORAGE_KEY_V1, diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index f76acdb72..bcd5c48ab 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -61,6 +61,8 @@ import cctvLayer, { materializeCctvActiveCoverageEntities, materializeCctvVisibleCoverageEntities, maybeAutoHop, + openingCameraId, + reopenNearestCamera, prioritizeActiveCctvGeometryRecord, processCctvGeometryDrainBatch, processCctvGeometryQueueBatch, @@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones } }); +test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => { + // QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera, + // the first catalog record, instead of the TfL camera 0.3 km away. + const viewer = makeDeselectViewer(); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + const sydney = makeDeselectRecord('au-sydney'); + sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 }; + const london = makeDeselectRecord('gb-kings-cross'); + london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 }; + try { + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: null, + enabled: false, + }); + assert.equal(openingCameraId(), 'gb-kings-cross'); + _setCctvCoverageStateForTest({ + viewer: null, + records: [sydney, london], + activeCameraId: null, + enabled: false, + }); + assert.equal( + openingCameraId(), + 'au-sydney', + 'with no viewer position the first record is the fallback', + ); + _setCctvCoverageStateForTest({ viewer, records: [], enabled: false }); + assert.equal(openingCameraId(), null); + } finally { + _setCctvCoverageStateForTest({ enabled: false }); + } +}); + +test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => { + // A share link turns CCTV on before its flight lands, so enable() picks + // from the startup view; the flight's settled move re-picks near London. + const viewer = makeDeselectViewer(); + viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( + -0.1209, + 51.5295, + 187, + ); + const sydney = makeDeselectRecord('au-sydney'); + sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 }; + const london = makeDeselectRecord('gb-kings-cross'); + london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 }; + for (const record of [sydney, london]) + Object.assign(record, { + groundResolved: {}, + groundSamples: {}, + groundPrior: 0, + frustumGeometry: null, + frustumPositions: null, + probeClampRangeM: null, + }); + _setCctvOverlayHostForTest({ + setEntries() {}, + setVisible() {}, + clearSource() {}, + }); + // Activation builds the projection canvas and frame image. + const originalDocument = globalThis.document; + const originalImage = globalThis.Image; + globalThis.document = { + createElement: () => ({ getContext: () => null }), + }; + globalThis.Image = class {}; + try { + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: 'au-sydney', + enabled: true, + openingCameraProvisional: true, + }); + assert.equal(reopenNearestCamera(), true); + assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross'); + assert.equal(reopenNearestCamera(), false, 'already the nearest'); + + // Someone chooses a camera: settled moves leave it alone. + setActiveCamera('au-sydney'); + assert.equal(reopenNearestCamera(), false); + assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney'); + + // A deselect is a choice too. + _setCctvCoverageStateForTest({ + viewer, + records: [sydney, london], + activeCameraId: 'au-sydney', + enabled: true, + openingCameraProvisional: true, + }); + deactivateActiveCamera(); + assert.equal(reopenNearestCamera(), false); + assert.equal(cctvLayer.getUIState().activeCameraId, null); + } finally { + globalThis.document = originalDocument; + globalThis.Image = originalImage; + _setCctvOverlayHostForTest(); + _setCctvCoverageStateForTest({ enabled: false }); + } +}); + test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => { const calls = []; _setCctvOverlayHostForTest({ diff --git a/src/data/cctvIceland.test.mjs b/src/data/cctvIceland.test.mjs new file mode 100644 index 000000000..1db511478 --- /dev/null +++ b/src/data/cctvIceland.test.mjs @@ -0,0 +1,429 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { + DEFAULT_ICELAND_MAX_SOURCES, + ICELAND_CAMERAS_URL, + ICELAND_IMAGE_ORIGIN, + ICELAND_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { fetchWithinHost } from '../../server/providers/cctv/media.js'; +import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js'; +import { + icelandCameraToSource, + icelandHeadingFromDescription, + loadIcelandSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** One row shaped like the live `vefmyndavelar2014_1` payload. */ +const row = (over = {}) => ({ + Maelist_nr: 7001, + Myndavel: 'Hellisheiði', + Vegheiti: 'Hringvegur', + NrVegur: '1', + Skyring: 'Hellisheiði séð til vesturs', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`, + PntX: 395000, + PntY: 390000, + Breidd: 64.0136, + Lengd: -21.4, + ...over, +}); + +/** A live response body plus a flag that flips when the stream is cancelled. */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const json = (value) => + new Response(JSON.stringify(value), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + +test('an Iceland row maps to a source on the pinned frame directory', () => { + const source = icelandCameraToSource(row()); + assert.equal(source.id, 'is-vegagerdin-hellisheidi_1'); + assert.equal(source.name, 'Hellisheiði séð til vesturs'); + assert.equal(source.city, 'Iceland'); + assert.equal(source.cityId, 'iceland'); + assert.equal(source.provider, 'Vegagerðin (IRCA)'); + assert.equal(source.lat, 64.0136); + assert.equal(source.lon, -21.4); + assert.equal(source.headingDeg, 270); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.pitchDeg, -24); + assert.equal(source.fovDeg, 56); + assert.equal(source.rangeM, 210); + assert.equal(source.mountHeightM, 10); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'iceland-vegagerdin'); + assert.equal(source.code, 'HELLISHEIÐI'); + assert.match(source.license, /CC BY 4\.0/); + assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('a view that states no facing takes the low-confidence id-hash pose', () => { + const source = icelandCameraToSource( + row({ + Skyring: 'Hellisheiði séð niður á veg', + Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`, + }), + ); + assert.equal(source.headingConfidence, 'low'); + assert.equal(source.headingDeg % 22.5, 0); + assert.equal( + source.headingDeg, + icelandCameraToSource( + row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }), + ).headingDeg, + 'the fallback depends on the id, not on the text', + ); +}); + +test('the label collapses the feed’s doubled spaces and repeated words', () => { + assert.equal( + icelandCameraToSource( + row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }), + ).name, + 'Strandarheiði séð niður á veg til austurs', + ); + const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name; + // Repeats of words with Icelandic letters collapse too. + assert.equal( + label('Hellisheiði séð séð til austurs'), + 'Hellisheiði séð til austurs', + ); + assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá'); + // A word ending in a letter the next word starts with is not a repeat. + for (const text of [ + 'Þverá Árnessýslu', + 'brú Úlfarsá', + 'Vesturlandsvegur við Elliðaá Ártúnsbrekku', + ]) + assert.equal(label(text), text); +}); + +test('a row with no label, station or station number is named by its frame', () => { + const source = icelandCameraToSource( + row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }), + ); + assert.equal(source.name, 'Vegagerðin hellisheidi_1'); +}); + +test('only an explicit facing sets a heading', () => { + const cases = [ + ['Hellisheiði séð til vesturs', 270], + ['Holtavörðuheiði til norðurs', 0], + ['Klettsháls í suður', 180], + ['Arnarneshæð séð austur', 90], + ['Bústaðabrú séð norður Kringlumýrarbraut', 0], + ['Staður séð til norðausturs', 45], + ['Staður séð til suðausturs', 135], + ['Staður séð í suðvestur', 225], + ['Staður séð til norðvesturs', 315], + ['STAÐUR SÉÐ TIL VESTURS', 270], + ['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing + ['Staður séð til austurs og Vesturlandsvegur', 90], + ]; + for (const [text, heading] of cases) { + assert.equal(icelandHeadingFromDescription(text), heading, text); + } + for (const text of [ + '', + null, + 'Gautsdalur niður á veg', + 'Miklabraut austur', // a road direction, not a facing + 'Biskupsháls - norður', + 'Hringtorg norðan Selfoss', + 'Norðurárdalur niður á veg', // a place name that contains a cardinal + 'Staður séð til suðurlands', // a longer word, not "suður" + 'Staður séð til norðurs og til suðurs', // two facings: ambiguous + 'Staður séð til austurs og vesturs', // the second facing shares "til" + 'Staður séð í norður og suður', + 'Staður séð í norður og í suður', + 'Norðfjarðargöng séð til Eskifjarðar', + ]) { + assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text)); + } +}); + +test('rows off the pinned directory, or with unusable frames or places, are dropped', () => { + const bad = [ + { Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://user:pw@www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' }, + { Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' }, + { Slod: '' }, + { Slod: null }, + { Breidd: 0, Lengd: 0 }, + { Breidd: null, Lengd: null }, + { Breidd: '64.01', Lengd: '-21.4' }, + { Breidd: '64.01' }, // each coordinate must be a number on its own + { Lengd: '-21.4' }, + { Breidd: 59.9, Lengd: 10.7 }, // Oslo + { Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere + ]; + for (const over of bad) { + assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over)); + } + assert.equal(icelandCameraToSource(null), null); + assert.equal(icelandCameraToSource({}), null); + assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true); + assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true); +}); + +test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' }); + const rows = Array.from({ length: 12 }, (_, i) => + row({ + Maelist_nr: 8000 + i, + Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`, + Skyring: `Far ${i}`, + Breidd: 66.2, + Lengd: -15.9 + i * 0.01, + }), + ); + // Reykjavík cameras come last in the feed but first in the priority order. + // A frame the feed repeats keeps its last row, as the other packs do. + rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' })); + rows.push( + row({ + Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, + Skyring: 'Kringlumýrarbraut', + Breidd: 64.1466, + Lengd: -21.9426, + }), + ); + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return json(rows); + }); + const sources = await loadIcelandSourcesFromOpenData(); + assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]); + assert.equal(sources.length, 8); + assert.equal(sources[0].id, 'is-vegagerdin-rvk_1'); + assert.equal(sources[0].name, 'Kringlumýrarbraut'); + assert.equal(sources[0].lat, 64.1466); + assert.equal( + new Set(sources.map((s) => s.id)).size, + sources.length, + 'a frame the feed repeats collapses to one camera', + ); + assert.equal(sources.available, 13); +}); + +test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from( + { length: DEFAULT_ICELAND_MAX_SOURCES + 40 }, + (_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + DEFAULT_ICELAND_MAX_SOURCES, + ); +}); + +test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => { + t.mock.method(console, 'log', () => {}); + withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined }); + const rows = Array.from({ length: 650 }, (_, i) => + row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }), + ); + t.mock.method(globalThis, 'fetch', async () => json(rows)); + for (const [value, expected] of [ + ['0', 8], + ['3', 8], + ['1000', 600], + ['abc', DEFAULT_ICELAND_MAX_SOURCES], + ]) { + process.env.CCTV_ICELAND_MAX_SOURCES = value; + assert.equal( + (await loadIcelandSourcesFromOpenData()).length, + expected, + value, + ); + } +}); + +test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => { + const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`; + const redirectTo = (location) => async (url) => + url === frame + ? new Response(null, { status: 302, headers: { location } }) + : new Response('frame', { headers: { 'content-type': 'image/jpeg' } }); + for (const location of [ + '/vgdata/other/x.jpg', + '/vgdata/vefmyndavelar/../x.jpg', + '/', + ]) + assert.equal( + await fetchWithinHost(frame, {}, redirectTo(location)), + null, + location, + ); + const followed = await fetchWithinHost( + frame, + {}, + redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'), + ); + assert.equal(await followed.text(), 'frame'); +}); + +test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('x', { status: 503 }), + ); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.restoreAll(); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => json({ rows: [] })); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method( + globalThis, + 'fetch', + async () => new Response('', { status: 200 }), + ); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const redirected = streamingResponse({ + status: 302, + headers: { + location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1', + }, + }); + t.mock.method(globalThis, 'fetch', async () => redirected.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 500 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the error body is cancelled'); + + const valid = JSON.stringify([row()]); + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + return new Response(valid, { + headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) }, + }); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(padded)); + controller.close(); + }, + }), + ); + }); + assert.deepEqual(await loadIcelandSourcesFromOpenData(), []); +}); + +test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'), + ); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_CALTRANS_DISTRICTS: undefined, + CCTV_TFL_ENABLED: undefined, + CCTV_ICELAND_ENABLED: undefined, + CCTV_ICELAND_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); + if (String(url) !== ICELAND_CAMERAS_URL) { + return new Response('unavailable', { status: 503 }); + } + return json([row()]); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['is-vegagerdin-hellisheidi_1'], + ); + + process.env.CCTV_ICELAND_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.some(([url]) => url === ICELAND_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 7d88c6462..704db7fb8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -321,6 +321,15 @@ export const DATA_CREDITS = [ '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', }, + { + key: 'iceland-cctv', + html: + 'CCTV cameras & frames (Iceland): road camera images, fetched live, from the ' + + 'Icelandic Road and Coastal Administration (Vegagerðin) — ' + + 'CC BY 4.0 ' + + '(terms); ' + + 'no endorsement by IRCA is implied', + }, { key: 'kingcounty-cctv', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index 4c679f7af..ed53abeb1 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -335,6 +335,9 @@ const OPTION_GROUPS = Object.freeze({ osm: 'o', hybrid: 'h', }), + // Street Traffic row toggles: flow-colored road lines, moving dots. + booleanOption('lines', 'l', false), + booleanOption('dots', 'd', true), ]), 'weather-lightning': Object.freeze([ enumOption('opacity', 'o', 'strong', ['light', 'strong'], { diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 273a9dc56..46b5d8894 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => { const v = viewer(t), empty = new Proxy({}, { get: () => noop }); const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost }, services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty }, - parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} }); + parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} }); lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable(); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); }); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d912b904f..5baf976f3 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -301,6 +301,8 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", + "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.", + "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", @@ -1747,6 +1749,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)", "Hybrid": "هجين", + "Lines": "خطوط", + "Dots": "نقاط", + "Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر", + "Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر", + "Moving dots": "نقاط متحركة", "TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d7d14c629..2f1af958f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -760,6 +760,8 @@ "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", + "Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.", + "30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL", "Internet radio and local SDR": "Internetradio und lokales SDR", "STATION TAG": "KATEGORIE", @@ -1665,6 +1667,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)", "Hybrid": "Hybrid", + "Lines": "Linien", + "Dots": "Punkte", + "Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot", + "Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt", + "Moving dots": "Bewegte Punkte", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 1988597a5..b7a09e4ee 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -192,6 +192,8 @@ "AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE", "RADIO": "RADIO", "ENABLE": "ACTIVAR", "Previous filtered radio station": "Emisora de radio filtrada anterior", @@ -2697,6 +2699,11 @@ "Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales", "Camera coverage unavailable": "Cobertura de cámaras no disponible", "Hybrid": "Híbrido", + "Lines": "Líneas", + "Dots": "Puntos", + "Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo", + "Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo", + "Moving dots": "Puntos en movimiento", "TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo", "TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)", "OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f1304f300..738800324 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1103,6 +1103,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)", "Hybrid": "Hybride", + "Lines": "Lignes", + "Dots": "Points", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", + "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", "TomTom daily budget reached": "Quota quotidien TomTom atteint", "Monthly traffic allowance used": "Quota mensuel de trafic épuisé", @@ -2805,6 +2810,8 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", + "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", "STATION TAG": "TAG DE STATION", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index fe2c0eb57..afdc4fdc5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1103,6 +1103,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)", "Hybrid": "Ibrido", + "Lines": "Linee", + "Dots": "Punti", + "Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso", + "Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale", + "Moving dots": "Punti in movimento", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", "Monthly traffic allowance used": "Quota mensile del traffico esaurita", @@ -2805,6 +2810,8 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.", + "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", "STATION TAG": "TAG STAZIONE", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d03b1b0ca..10f183da9 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1071,6 +1071,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)", "Hybrid": "ハイブリッド", + "Lines": "ライン", + "Dots": "ドット", + "Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)", + "Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット", + "Moving dots": "移動ドット", "TomTom key unavailable": "TomTomキーを利用できません", "TomTom daily budget reached": "TomTomの1日の上限に達しました", "Monthly traffic allowance used": "今月の交通情報の上限に達しました", @@ -2663,6 +2668,8 @@ "AVAILABLE MISSIONS": "利用可能なミッション", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", + "Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。", + "30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択", "Internet radio and local SDR": "インターネットラジオとローカルSDR", "STATION TAG": "局タグ", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 594de4889..6194cd31b 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1101,6 +1101,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)", "Hybrid": "Híbrido", + "Lines": "Linhas", + "Dots": "Pontos", + "Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho", + "Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo", + "Moving dots": "Pontos em movimento", "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", @@ -2807,6 +2812,8 @@ "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.", + "30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA", "Internet radio and local SDR": "Rádio pela internet e SDR local", "STATION TAG": "TAG DA ESTAÇÃO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e4974ff25..f0a52cf0a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -722,6 +722,8 @@ "Contact Context actions": "Действия контекста контакта", "Available Space Missions": "Доступные космические миссии", "LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.", + "30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН", "Radio playback": "Воспроизведение радио", "Radio volume": "Громкость радио", "STATION TAG": "ТЕГ СТАНЦИИ", @@ -2755,6 +2757,11 @@ "Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы", "Camera coverage unavailable": "Покрытие камер недоступно", "Hybrid": "Гибрид", + "Lines": "Линии", + "Dots": "Точки", + "Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному", + "Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн", + "Moving dots": "Движущиеся точки", "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", "TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)", "OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index c28ce93ac..f01e84504 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1118,6 +1118,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)", "Hybrid": "Гібрид", + "Lines": "Лінії", + "Dots": "Точки", + "Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного", + "Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн", + "Moving dots": "Рухомі точки", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", @@ -2875,6 +2880,8 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", + "Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.", + "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "STATION TAG": "ТЕГ СТАНЦІЇ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a0b8c456f..3ddcb3b34 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1047,6 +1047,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)", "Hybrid": "混合", + "Lines": "线条", + "Dots": "圆点", + "Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红", + "Moving dots colored by live flow": "按实时路况着色的移动圆点", + "Moving dots": "移动圆点", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", "Monthly traffic allowance used": "本月交通流量额度已用完", @@ -2562,6 +2567,8 @@ "AVAILABLE MISSIONS": "可用任务", "SELECT A MISSION TO INSPECT": "选择一项任务以查看", "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", "Internet radio and local SDR": "网络电台与本地 SDR", "STATION TAG": "电台标签", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 88dac3838..590ea6e15 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1042,6 +1042,11 @@ "TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況", "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)", "Hybrid": "混合", + "Lines": "線條", + "Dots": "圓點", + "Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅", + "Moving dots colored by live flow": "依即時路況著色的移動圓點", + "Moving dots": "移動圓點", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", "Monthly traffic allowance used": "本月交通流量額度已用完", @@ -2550,6 +2555,8 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。", + "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", "STATION TAG": "電臺標籤", diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 6f1cc77c7..36e1360b4 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) { _setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest, focusCctvRecord: parts.navigation.focusCctvRecord, maybeAutoHop: parts.navigation.maybeAutoHop, + openingCameraId: parts.navigation.openingCameraId, + reopenNearestCamera: parts.navigation.reopenNearestCamera, cctvCycleIndex: parts.navigation.cctvCycleIndex, }, ); diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 4e4443df8..0a10d0090 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ layerState._enabled = false; layerState._activeCameraId = null; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; layerState._lastHopAt = 0; layerState._lastViewContext = ''; layerState._calibrationById = parts.calibration.loadCalibrationStore(); @@ -299,11 +300,8 @@ export function createLifecycle({ } layerState._count = layerState._records.length; - if (layerState._records.length > 0) { - // Projection runtime + first frame fetch are deferred to enable() so - // initializing the catalog stays render-cheap. - layerState._activeCameraId = layerState._records[0].camera.id; - } + // No camera is picked here: enable() opens on the one nearest the + // viewer, unless restored params selected one first. // Task 5: if the prior batch lost init's bounded race, apply it post-hoc // when it lands (pure recomputes — applyLateGroundPriors guards against @@ -361,6 +359,7 @@ export function createLifecycle({ layerState._horizonCullListener = () => { layerState._cameraMoving = false; stopViewMotionTimer(); + parts.navigation.reopenNearestCamera(); settleView(); }; layerState._viewer.camera.moveEnd.addEventListener( @@ -508,8 +507,9 @@ export function createLifecycle({ return Boolean(coverage && layerState._recordById.has(coverage[1])); }); if (!layerState._activeCameraId && layerState._records.length) { - layerState._activeCameraId = layerState._records[0].camera.id; + layerState._activeCameraId = parts.navigation.openingCameraId(); layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = true; } const activeRecord = parts.selection.getActiveRecord(); if (activeRecord) { @@ -629,6 +629,7 @@ export function createLifecycle({ layerState._enabled = false; layerState._activeCameraId = null; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; // Clear existing subscribers rather than replacing the Set — // replacing would silently orphan any unsubscribe() closures layerState._listeners.clear(); diff --git a/src/layers/cctv/navigation.js b/src/layers/cctv/navigation.js index 6c576bb4f..5ee3ed2a4 100644 --- a/src/layers/cctv/navigation.js +++ b/src/layers/cctv/navigation.js @@ -33,6 +33,36 @@ export function createNavigation({ return best?.id || null; } + /** + * The camera the layer opens on when none is selected: the one nearest the + * viewer, or the first record when the viewer has no position yet. + * @returns {string|null} Camera ID, or null when the catalog is empty. + */ + + function openingCameraId() { + return ( + nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null + ); + } + + /** + * After a settled move, moves the opening camera to the one nearest the + * view, until someone chooses a camera. A shared view's flight lands after + * enable() picked from the startup view. + * @returns {boolean} True when the active camera changed. + */ + + function reopenNearestCamera() { + if (!layerState._openingCameraProvisional || !layerState._enabled) + return false; + const nearest = nearestCameraIdToViewer(); + if (!nearest || nearest === layerState._activeCameraId) return false; + parts.selection.setActiveCamera(nearest); + // setActiveCamera ends the opening pick; this is still that pick. + layerState._openingCameraProvisional = true; + return true; + } + /** * Flies the Cesium viewer camera to frame the specified CCTV camera, * looking along its heading from above. @@ -149,6 +179,8 @@ export function createNavigation({ } return { nearestCameraIdToViewer, + openingCameraId, + reopenNearestCamera, focusCctvRecord, focusCamera, maybeAutoHop, diff --git a/src/layers/cctv/selection.js b/src/layers/cctv/selection.js index c86425fd0..f4c8f639e 100644 --- a/src/layers/cctv/selection.js +++ b/src/layers/cctv/selection.js @@ -83,6 +83,7 @@ export function createSelection({ } layerState._activeCameraId = cameraId; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = false; // A real activation creates projection work — wake the self-stopping loop. parts.projection.startProjectionLoop(); if (previousActiveRecord && previousActiveRecord !== record) { @@ -169,6 +170,7 @@ export function createSelection({ if (!record) return false; layerState._activeCameraId = null; layerState._autoHopSuspended = true; + layerState._openingCameraProvisional = false; record.activationDone = false; parts.geometry.clearProbeClampOnDeactivation(record, (previous) => { parts.geometry.applyFrustumGeometry( diff --git a/src/layers/cctv/state.js b/src/layers/cctv/state.js index 78c2b8fa9..5e70c814f 100644 --- a/src/layers/cctv/state.js +++ b/src/layers/cctv/state.js @@ -36,6 +36,12 @@ export function createState({ services }) { layerState._autoHop = false; + // True while the active camera is the one enable() opened on and nobody has + // chosen a camera since: each settled move then re-picks the one nearest + // the view, so a share flight that lands after enable still opens nearby. + + layerState._openingCameraProvisional = false; + // An explicit empty-space deselect keeps AUTO HOP configured but prevents its // timer from silently choosing a replacement. A later explicit activation or // AUTO HOP toggle-on releases the hold. diff --git a/src/layers/cctv/testing.js b/src/layers/cctv/testing.js index c4aada9bb..207256f36 100644 --- a/src/layers/cctv/testing.js +++ b/src/layers/cctv/testing.js @@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) { coverageMode = 'on', showProjection = false, healthById = new Map(), + openingCameraProvisional = false, } = {}) { layerState._viewer = viewer; layerState._records = Array.isArray(records) ? records : []; @@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) { layerState._billboards = null; layerState._activeCameraId = activeCameraId; layerState._autoHopSuspended = false; + layerState._openingCameraProvisional = !!openingCameraProvisional; layerState._enabled = !!enabled; layerState._coverageMode = parts.model.normalizeCoverageMode( coverageMode, diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index b0dc035cb..9adc88922 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { + // Navigation takes the camera; a failed entry must not restore the old view. + layerState._entryCameraView = null; + layerState._cameraReleased = true; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); @@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) { orbitMatches: layerState._orbitMatches, lastUpdate: layerState._lastUpdate, error: layerState._lastError, + errorAt: layerState._lastErrorAt, stale: layerState._stale, }; }, diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs new file mode 100644 index 000000000..a2bccad7d --- /dev/null +++ b/src/layers/launches/failedEntry.test.mjs @@ -0,0 +1,495 @@ +// The real manager, Context coordinator and launches ingestion run against a +// stubbed /api/launches, so a failed first load takes the browser's path. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { DataLayerManager } from '../../data/manager.js'; +import { ContextControls } from '../../ui/contextControls.js'; +import { spaceMissionsIndexFailure } from '../../ui/contextActions.js'; +import { createLaunchSource } from './source.js'; +import { createState } from './state.js'; +import { createModel } from './model.js'; +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createPanel } from './panel.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createReplay } from './replay.js'; + +const noop = () => {}; + +const launchRecord = { + id: 'fixture-launch', + name: 'Fixture mission', + net: new Date(Date.now() - 3600000).toISOString(), + status: { name: 'Launch Successful' }, + pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 }, + rocket: { payloads: [] }, +}; + +function plainLayer(id, name, params = {}) { + let current = { ...params }; + return { + id, + name, + icon: '', + source: 'test', + updateInterval: -1, + init: noop, + enable: noop, + disable: noop, + update: noop, + getParams: () => ({ ...current }), + setParams(next) { + current = { ...current, ...next }; + return true; + }, + }; +} + +function fakeCamera() { + const camera = { + position: new Cesium.Cartesian3(1, 2, 3), + heading: 0.1, + pitch: -0.5, + roll: 0, + flights: 0, + cancelled: 0, + flight: null, + get positionWC() { + return camera.position; + }, + // Like Cesium, a new flight cancels the one in progress. + flyTo({ destination, complete, cancel }) { + camera.flight?.cancel?.(); + camera.flight = { complete, cancel }; + camera.flights += 1; + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = 0; + camera.pitch = -Math.PI / 2; + }, + land() { + const flight = camera.flight; + camera.flight = null; + flight?.complete?.(); + }, + cancelFlight() { + camera.cancelled += 1; + const flight = camera.flight; + camera.flight = null; + flight?.cancel?.(); + }, + lookAtTransform: noop, + setView({ destination, orientation }) { + camera.position = Cesium.Cartesian3.clone(destination); + camera.heading = orientation.heading; + camera.pitch = orientation.pitch; + camera.roll = orientation.roll; + }, + }; + return camera; +} + +async function fixture(t) { + const upstream = { status: 429, results: [launchRecord] }; + const source = createLaunchSource({ + async fetchImpl(url) { + if (url === '/api/celestrak/active') return new Response(''); + if (upstream.hold) await upstream.hold; + return Response.json( + upstream.status === 200 ? { results: upstream.results } : {}, + { status: upstream.status }, + ); + }, + }); + + const services = { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + render: { holdContinuousRender: noop, releaseContinuousRender: noop }, + }; + const state = createState({ services }); + const camera = fakeCamera(); + const canvas = Object.assign(new EventTarget(), { + disableRootEvents: true, + onwheel: null, + style: {}, + clientWidth: 1200, + clientHeight: 800, + }); + const viewer = { + camera, + dataSources: new Cesium.DataSourceCollection(), + scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] }, + }; + // Scene presentation is stubbed; ingestion, lifecycle and panel are real. + const parts = { + overlays: { + shortMissionLabel: (name) => name, + createReplayVehicleOverlay: noop, + clearMissionOverlaySources: noop, + hideReplayVehicleOverlay: noop, + destroyReplayVehicleOverlay: noop, + syncMissionOverlayEntries: noop, + }, + selection: { + focusFullGlobe(target, flight) { + target.camera.flyTo({ + destination: new Cesium.Cartesian3(9, 9, 9), + ...flight, + }); + return true; + }, + setSelectedMission: noop, + focusMission: noop, + stopMissionZoomAnchor: noop, + entityLaunchId: () => null, + }, + orbitRendering: { + removeMissionOrbitPrimitives: noop, + syncMissionOrbitPrimitiveVisibility: noop, + }, + launchPad: { + initLaunchPadZonePrimitive: noop, + hideLaunchPadZone: noop, + destroyLaunchPadZonePrimitive: noop, + }, + rendering: { + updateMissionFrame: noop, + addLaunchEntity(record) { + state._dataSource.entities.add({ + id: record.id, + position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat), + }); + }, + }, + }; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.model = createModel(context); + parts.panel = createPanel(context); + parts.replay = createReplay(context); + parts.ingestion = createIngestion(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + const missions = Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + ); + const rosters = []; + parts.panel.subscribePanel((view) => rosters.push(view.roster)); + + const manager = new DataLayerManager(viewer); + manager.register(plainLayer('earthquakes', 'Earthquakes (24h)')); + manager.register(plainLayer('satellites', 'Satellites')); + manager.register(plainLayer('wind', 'Wind')); + manager.register(missions); + missions.attachDataManager(manager); + + const toasts = []; + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: noop, + showToast: (message) => toasts.push(message), + setClearBusy: noop, + syncDetection: noop, + scheduleLayout: noop, + refreshRadio: noop, + setPanelCollapsed: noop, + }, + }); + controls.connect(manager); + + t.after(async () => { + controls.stop(); + controls.disconnect(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind']) + await manager.setEnabled(id, false); + await missions.destroy(viewer); + }); + + const enabledIds = () => [...manager.getEnabledLayerIds()].sort(); + const view = () => ({ + x: camera.position.x, + y: camera.position.y, + z: camera.position.z, + heading: camera.heading, + pitch: camera.pitch, + }); + const settle = async () => { + await controls._waitForContextLayerSettlement(); + for (const id of ['rocket-launches', 'satellites', 'earthquakes']) + await manager.waitForLayerSettled(id); + await controls._waitForContextLayerSettlement(); + }; + return { + upstream, + manager, + controls, + missions, + toasts, + state, + camera, + rosters, + enabledIds, + view, + settle, + }; +} + +const entries = { + direct: (f) => + f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + 'Context-panel': async (f) => + ( + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }) + ).ok, +}; + +for (const [path, enter] of Object.entries(entries)) + test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + + const entered = await enter(f); + await f.settle(); + + assert.equal(entered, false, 'a failed mission index fails the enable'); + assert.deepEqual(f.enabledIds(), ['earthquakes']); + assert.equal(f.controls.getContextModeState().mode, null); + assert.equal(f.controls.getContextModeState().active, false); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.deepEqual(f.view(), before, 'the pre-entry view comes back'); + assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled'); + + assert.equal( + await f.manager.setEnabled('wind', true, { origin: 'user' }), + true, + 'an unrelated layer is not blocked after the failed entry', + ); + }); + +// Holds the /api/launches reply until the returned release is called. +function holdReply(f) { + let release; + f.upstream.hold = new Promise((resolve) => { + release = resolve; + }); + return () => { + f.upstream.hold = null; + release(); + }; +} + +async function untilFlown(f) { + for (let i = 0; i < 200 && f.camera.flights === 0; i++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight'); +} + +test('a landed entry flight still gives the view back after a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), before); +}); + +test('a camera move after the entry flight survives a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + // The user drags the globe while the mission index is still loading. + f.camera.setView({ + destination: new Cesium.Cartesian3(7, 7, 7), + orientation: { heading: 0.3, pitch: -0.2, roll: 0 }, + }); + const moved = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), moved); +}); + +test('a navigation during a failed first load is not cancelled by the rollback', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + // Search or Home: navigation releases mission camera ownership, then flies. + f.missions.releaseCameraOwnership(); + let navigationCancelled = false; + f.camera.flyTo({ + destination: new Cesium.Cartesian3(5, 5, 5), + cancel: () => { + navigationCancelled = true; + }, + }); + const cancelled = f.camera.cancelled; + const navigated = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.equal( + navigationCancelled, + false, + 'the rollback does not cancel the Search/Home flight', + ); + assert.equal(f.camera.cancelled, cancelled); + assert.deepEqual(f.view(), navigated); +}); + +test('the roster reports a failed mission index instead of loading forever', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + + const roster = f.rosters.at(-1); + assert.ok(roster, 'a failed load publishes a roster view'); + assert.deepEqual(roster.entries, []); + assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE'); + + // A new attempt starts from the loading state, then shows the index. + f.upstream.status = 200; + const loadingFrom = f.rosters.length; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.rosters.slice(loadingFrom).includes(null), true); + assert.equal(f.rosters.at(-1).entries.length, 1); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); +}); + +test('a valid empty mission index is a successful entry', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + f.upstream.results = []; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal( + f.rosters.at(-1).empty, + 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + ); + assert.ok(f.camera.flights > 0, 'a successful entry frames the globe'); +}); + +test('a refresh failure after a loaded index keeps the mode and its records', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const framed = f.view(); + + f.upstream.status = 429; + await f.missions.update(); + await f.settle(); + + assert.equal(f.manager.isEnabled('rocket-launches'), true); + assert.equal(f.controls.getContextModeState().mode, 'space-missions'); + assert.equal(f.missions.getStats().count, 1); + assert.equal(f.missions.getStats().error, 'HTTP 429'); + assert.equal(f.rosters.at(-1).entries.length, 1); + + // Leaving a loaded mode does not restore the pre-entry view. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.deepEqual(f.view(), framed); +}); +test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + assert.equal( + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }), + true, + ); + await f.settle(); + assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress'); + + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + assert.equal( + f.camera.flight, + null, + 'leaving Space Missions stops the flight it started (main cancels it)', + ); +}); +// The rollback hides the roster, so the toast has to name the cause. +test('a failed first load tells the user the mission index is unavailable', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + + await f.controls.setContextMode('space-missions', { + claimVisualAuthority: false, + }); + await f.settle(); + assert.equal(f.controls.getContextModeState().mode, null, 'rolled back'); + assert.ok( + f.toasts.some((message) => /mission index/i.test(message)), + `visible feedback names the failure, got ${JSON.stringify(f.toasts)}`, + ); +}); + +test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => { + const f = await fixture(t); + f.upstream.status = 200; + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + const flight = f.camera.flight; + assert.ok(flight, 'the entry flight is in progress'); + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, flight, 'navigation decides about the flight'); + // After exit an inactive layer never cancels another owner's flight. + await f.manager.setEnabled('rocket-launches', false, { origin: 'user' }); + await f.settle(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) }); + const other = f.camera.flight; + f.missions.releaseCameraOwnership(); + assert.equal(f.camera.flight, other); +}); + +test("an earlier attempt's index error is not blamed for a later failure", async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); + await f.settle(); + assert.match(spaceMissionsIndexFailure(f.manager), /mission index/); + assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null); +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index bd1529c0b..fbd3778e1 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -50,8 +50,9 @@ export function createIngestion({ layerState._postTleRetryCount++; layerState._retryTimer = setTimeout(() => { layerState._retryTimer = null; + // A failed retry is already recorded in the layer stats. if (layerState._enabled && token === layerState._lifecycleToken) - requestMissionUpdate(); + requestMissionUpdate().catch(() => {}); }, POST_TLE_RETRY_DELAY_MS); } @@ -201,6 +202,10 @@ export function createIngestion({ if (layerState._renderedTleText === layerState._activeTleText) clearPostTleRetry(); layerState._count = launches.length; + // The roster reads these to tell a loaded empty index from loading. + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + layerState._stale = stale === true; parts.panel.renderMissionRoster(); if ( !layerState._selectedLaunchId || @@ -219,13 +224,18 @@ export function createIngestion({ } layerState._focusAfterActiveLookup = false; parts.panel.renderMissionPanel(); - layerState._lastUpdate = Date.now(); - layerState._lastError = null; - layerState._stale = stale === true; + layerState._entryCameraView = null; } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; + layerState._lastErrorAt = Date.now(); console.warn('[Data:RocketLaunches] Fetch error:', error); + // No index yet: fail the enable so Context rolls back. + // After a loaded index, a failed refresh keeps its records. + if (layerState._lastUpdate === null) { + parts.panel.renderMissionRoster(); + throw error; + } } } } diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index ca36a87ec..252ca7ab1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -111,6 +111,43 @@ export function createLaunchHover({ }; } +/** The camera pose to give back, in world coordinates. */ +function captureCameraView(viewer) { + const camera = viewer?.camera; + if (!camera?.positionWC) return null; + return { + destination: Cesium.Cartesian3.clone(camera.positionWC), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + }; +} + +function sameCameraView(camera, view) { + return ( + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + view.destination, + 0, + 1, + ) && + ['heading', 'pitch', 'roll'].every((key) => + Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6), + ) + ); +} + +/** Restore the pre-entry view if the entry flight still holds the camera. */ +function restoreCameraView(viewer, view, landedView) { + const camera = viewer?.camera; + if (!camera) return; + if (!landedView) camera.cancelFlight?.(); + else if (!sameCameraView(camera, landedView)) return; + camera.setView(view); +} + export function createLifecycle({ state: layerState, services, @@ -200,7 +237,27 @@ export function createLifecycle({ layerState._updateDirty = false; if (layerState._dataSource) layerState._dataSource.show = true; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); - parts.selection.focusFullGlobe(layerState._viewer); + if (layerState._lastUpdate === null) { + // A new attempt at an index that never loaded starts from loading. + layerState._lastError = null; + parts.panel.renderMissionRoster(); + // Kept until the mission index loads, so an entry that never does + // gives the view back. A newer flight takes the camera and drops it. + layerState._entryCameraView = captureCameraView(layerState._viewer); + layerState._entryLandedView = null; + } + const token = layerState._lifecycleToken; + const flying = parts.selection.focusFullGlobe(layerState._viewer, { + complete: () => { + if (token === layerState._lifecycleToken) + layerState._entryLandedView = captureCameraView(layerState._viewer); + }, + cancel: () => { + if (token === layerState._lifecycleToken) + layerState._entryCameraView = null; + }, + }); + if (!flying) layerState._entryCameraView = null; if (layerState._selectedLaunchId) parts.selection.setSelectedMission( layerState._selectedLaunchId, @@ -214,12 +271,21 @@ export function createLifecycle({ hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; + const entryView = layerState._entryCameraView; + layerState._entryCameraView = null; + if (entryView) + restoreCameraView( + layerState._viewer, + entryView, + layerState._entryLandedView, + ); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; parts.ingestion.clearPostTleRetry(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); if (layerState._dataSource) layerState._dataSource.show = false; parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); @@ -244,6 +310,7 @@ export function createLifecycle({ await parts.ingestion.restoreSatelliteDependency(); parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); + layerState._cameraReleased = true; parts.selection.stopMissionZoomAnchor(); parts.ingestion.clearPostTleRetry(); if (layerState._clickHandler) layerState._clickHandler.destroy(); diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 07f1c8497..b93fcb2e5 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) { publishPanel(); } - /** The roster entries as the list shows them. */ + /** The roster as listed; null until the first index loads or fails. */ function presentRoster() { + if (layerState._lastUpdate === null && !layerState._launches.length) { + if (!layerState._lastError) return null; + return { + count: '—', + entries: [], + empty: t('30-DAY MISSION INDEX UNAVAILABLE'), + }; + } const entries = parts.policyHelpers .missionRosterEntries(layerState._launches) .map(({ launch, index }) => { diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js index d4539289a..b0ec18373 100644 --- a/src/layers/launches/policyHelpers.js +++ b/src/layers/launches/policyHelpers.js @@ -229,6 +229,7 @@ export function createPolicyHelpers({ layerState._viewer.camera.positionCartographic?.height, ); const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(position, 0), { diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 86b6a08f0..d2fe51bb4 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) { layerState._replayCameraToken++; if (layerState._replayCameraRemover) layerState._replayCameraRemover(); layerState._replayCameraRemover = null; - layerState._viewer?.camera?.cancelFlight(); - if (layerState._viewer?.camera) + // Cancel the layer's own flights, never one navigation started after + // taking the camera. + if ( + (stoppedLaunchId || !layerState._cameraReleased) && + layerState._viewer?.camera + ) { + layerState._viewer.camera.cancelFlight(); layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } layerState._replayCameraLaunchId = null; layerState._replayPaused = false; layerState._replayPausedAtMs = null; diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 813b7875f..6d7ae40cb 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -9,7 +9,8 @@ export function createSelection({ }) { const { getKeyholeGeometry } = services.geometry; - function focusFullGlobe(viewer, duration = 2.4) { + /** @returns {boolean} Whether the flight started. */ + function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) { const canvas = viewer?.scene?.canvas; const height = canvas?.clientHeight || canvas?.height; const width = canvas?.clientWidth || canvas?.width; @@ -23,7 +24,7 @@ export function createSelection({ fovy <= 0 || fovy >= Math.PI ) - return; + return false; const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry(width, height).radius; const targetScreenRadius = keyholeRadius * 0.61; @@ -32,6 +33,7 @@ export function createSelection({ ); const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + layerState._cameraReleased = false; viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromRadians( cartographic.longitude, @@ -45,7 +47,10 @@ export function createSelection({ }, duration, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete, + cancel, }); + return true; } function entityLaunchId(entity) { @@ -187,6 +192,7 @@ export function createSelection({ Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3(), ); + layerState._cameraReleased = false; layerState._viewer.camera.flyTo({ destination, orientation: { direction, up }, @@ -209,6 +215,7 @@ export function createSelection({ layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( `rocket-launch:${launch.id}`, ); + layerState._cameraReleased = false; layerState._viewer.camera.flyToBoundingSphere( new Cesium.BoundingSphere(launchPosition, 0), { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..fd2ae7176 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -19,6 +19,15 @@ export function createState({ services }) { state._lastError = null; + state._lastErrorAt = null; + + state._entryCameraView = null; + + state._entryLandedView = null; + + /** Navigation took the camera; set until the layer flies again. */ + state._cameraReleased = false; + state._stale = false; state._orbitMatches = 0; diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 6c58a57a4..de520587c 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -21,9 +21,20 @@ export function createAnimation({ source, }) { function syncRenderDemand() { + // Hidden dots (`dots` off) need no frames. Lines need them only while + // their asynchronous geometry builds (or old lines wait to be retired), + // and the heat-line prototype's jam batch while it pulses. + const lines = [ + layerState._heatJamPrim, + layerState._heatSlowPrim, + layerState._heatFreePrim, + ].filter(Boolean); const needed = Boolean( layerState._enabled && - (layerState._dots.length || layerState._heatJamPrim), + ((layerState._showDots !== false && layerState._dots.length) || + (layerState._heatJamPrim && !layerState._showLines) || + layerState._heatRetired?.length || + lines.some((primitive) => !primitive.ready)), ); // Height easing only runs in a held frame. Settle it before releasing, so // a population emptied mid-correction cannot report loading forever. @@ -224,6 +235,7 @@ export function createAnimation({ */ function animate() { + parts.rendering.retireHeatLines?.(); syncRenderDemand(); if (!layerState._renderHeld) return; const now = Date.now(); @@ -317,8 +329,9 @@ export function createAnimation({ } // Jam heat-lines throb (~1.6 s period) — one shared material uniform for - // the whole jam batch, no geometry rebuild. Null outside live heatline mode. - if (layerState._heatJamPrim?.appearance) { + // the whole jam batch, no geometry rebuild. Null outside live heatline + // mode; the steady flow lines (`lines` toggle) never pulse. + if (layerState._heatJamPrim?.appearance && !layerState._showLines) { layerState._heatJamPrim.appearance.material.uniforms.color.alpha = HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); } diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 6570c7958..403aaaa88 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -1,7 +1,7 @@ import { normalizeRoadMode, resolveRoadMode, - TRAFFIC_ROAD_MODES, + roadModesSimulate, } from './roadModes.js'; import { trafficBucketTier, @@ -19,19 +19,25 @@ export function createControls({ state: layerState, services, parts, source }) { const notes = [feed.loadingLabel]; if (!layerState._liveMode || feed.error) notes.push(t('Roads: {source}', { source })); - // An explicit TomTom or Hybrid choice without a key draws OpenStreetMap. + // An explicit TomTom or Hybrid choice without a key draws OpenStreetMap; + // a host without OpenStreetMap draws nothing. + const requested = roadModesSimulate(layerState._roadModes) + ? layerState._roadMode + : resolveRoadMode(layerState._roadMode, true, layerState._roadModes); + // A host without OpenStreetMap waits for the probe before naming a key. if ( !layerState._liveMode && - ['tomtom', 'hybrid'].includes(layerState._roadMode) + ['tomtom', 'hybrid'].includes(requested) && + (roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown) ) notes.push( layerState._flowStatusUnavailable - ? layerState._roadMode === 'tomtom' + ? requested === 'tomtom' ? t( 'TomTom roads unavailable while the traffic service is unreachable', ) : t('Hybrid unavailable while the traffic service is unreachable') - : layerState._roadMode === 'tomtom' + : requested === 'tomtom' ? t('TomTom roads need a TomTom key') : t('Hybrid needs a TomTom key'), ); @@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) { updateInterval: 0, /** - * Update user-adjustable parameters (road source, density and speed scaling). + * Update user-adjustable parameters (road source, row toggles, density and + * speed scaling). * * @param {Object} [params] - * @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default. + * @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored. + * @param {boolean} [params.lines] - Draw flow-colored road lines (live mode). + * @param {boolean} [params.dots] - Draw the moving dots. * @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5). * @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0). * @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`. @@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) { const previousSpeed = layerState._speedScale; if ( Object.hasOwn(params, 'roadMode') && - (params.roadMode === null || normalizeRoadMode(params.roadMode)) + (params.roadMode === null || + normalizeRoadMode(params.roadMode, layerState._roadModes)) ) { const explicit = ['user', 'voice', 'tool'].includes(origin); const mode = @@ -98,6 +108,31 @@ export function createControls({ state: layerState, services, parts, source }) { parts.viewport.onCameraChanged({ immediate: true }); } } + if ( + typeof params.lines === 'boolean' && + params.lines !== layerState._showLines + ) { + layerState._showLines = params.lines; + // Rebuild in place from the roads on screen: no refetch, no respawn. + if (layerState._enabled) + parts.rendering.rebuildHeatLines( + parts.rendering.visibleRoadsForAltitude( + layerState._roads, + layerState._lastRenderAltitude, + ), + ); + } + if ( + typeof params.dots === 'boolean' && + params.dots !== layerState._showDots + ) { + layerState._showDots = params.dots; + // Hidden dots are kept, so showing them again is instant. + if (layerState._enabled && layerState._pointCollection) + layerState._pointCollection.show = params.dots; + parts.animation.syncRenderDemand(); + layerState._viewer?.scene?.requestRender?.(); + } if (typeof params.densityScale === 'number') { layerState._densityScale = Math.max( 0.2, @@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) { /** * Return the current user-adjustable parameters. - * @returns {{densityScale:number, speedScale:number}} + * @returns {{roadMode:string|null, lines:boolean, dots:boolean, + * densityScale:number, speedScale:number}} */ getParams() { return { roadMode: layerState._roadMode, + lines: layerState._showLines, + dots: layerState._showDots, densityScale: layerState._densityScale, speedScale: layerState._speedScale, uncoveredRoads: layerState._uncoveredMode, @@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) { }; }, - /** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */ + /** + * Street Traffic row chips: a road-source chip per offered mode (none for + * a single mode), then Lines and Dots toggles while live flow colors them. + */ getRowControls() { + const modes = layerState._roadModes; const selected = - layerState._roadMode || resolveRoadMode(null, layerState._liveMode); + layerState._roadMode || + resolveRoadMode(null, layerState._liveMode, modes); const live = layerState._liveMode; const titles = { tomtom: live @@ -187,14 +230,36 @@ export function createControls({ state: layerState, services, parts, source }) { }; // TomTom and OSM are provider names and stay as they are. const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') }; + const lines = { + id: 'lines', + label: t('Lines'), + title: t('Road lines colored by live flow: green to red'), + active: layerState._showLines, + params: { lines: !layerState._showLines }, + }; + const dots = { + id: 'dots', + label: t('Dots'), + title: live ? t('Moving dots colored by live flow') : t('Moving dots'), + active: layerState._showDots, + params: { dots: !layerState._showDots }, + }; + // Lines need live flow. Dots, once hidden (a live choice, a restore or + // a share link), stay offered so they can always be shown again. + const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots]; return { - chips: TRAFFIC_ROAD_MODES.map((mode) => ({ - id: `roads-${mode}`, - label: labels[mode], - title: titles[mode], - active: mode === selected, - params: { roadMode: mode }, - })), + chips: [ + ...(modes.length > 1 + ? modes.map((mode) => ({ + id: `roads-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === selected, + params: { roadMode: mode }, + })) + : []), + ...toggles, + ], }; }, @@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) { * @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} */ getDetectableObjects(options = {}) { - if (!layerState._enabled || layerState._dots.length === 0) return []; + // Hidden dots are not on screen to detect. + if ( + !layerState._enabled || + !layerState._showDots || + layerState._dots.length === 0 + ) + return []; const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) : layerState._dots.length; @@ -382,7 +453,11 @@ export function createControls({ state: layerState, services, parts, source }) { // in one had better be the honest one. This is also where LIVE vs // SIMULATED mode is surfaced, and it must never imply a live feed the // layer does not have. - roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode), + roadMode: resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ), roadModeRequested: layerState._roadMode, roadSource: layerState._roadSource, source: layerState._roadSource, diff --git a/src/layers/traffic/hostModes.test.mjs b/src/layers/traffic/hostModes.test.mjs new file mode 100644 index 000000000..630e38a37 --- /dev/null +++ b/src/layers/traffic/hostModes.test.mjs @@ -0,0 +1,326 @@ +// A host's road-source offer (createTrafficSource `roadModes`) and the +// Street Traffic Lines / Dots row toggles. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + normalizeRoadModes, + resolveRoadMode, + roadModesSimulate, + TRAFFIC_ROAD_MODES, +} from './roadModes.js'; +import { createTrafficSource } from './source.js'; +import { createTrafficLayer } from './index.js'; +import { + createDefaultLayerState, + decodeLayerStateParams, + encodeLayerStateParams, + parseStoredLayerState, + serializeStoredLayerState, +} from '../../data/layerState.js'; + +const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 }; +const flow = { + coords: [ + [-97.744, 30.267], + [-97.742, 30.267], + ], + trafficLevel: 0.3, + closure: false, + roadType: 'Major road', +}; + +function tomtomOnlySource() { + let requests = 0; + const source = createTrafficSource({ + roadModes: ['tomtom'], + mapTiles: { + async fetchBounds() { + requests++; + return { tiles: [], partial: false }; + }, + async getMetadata() { + requests++; + }, + clear() {}, + }, + }); + return { source, requests: () => requests }; +} + +test('host road modes are a non-empty subset in canonical order', () => { + assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES); + assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [ + 'tomtom', + 'hybrid', + ]); + assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom']))); + for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null]) + assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad)); + assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError); + assert.equal(roadModesSimulate(), true); + assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false); +}); + +test('a TomTom-only host resolves every request to TomTom, keyed or not', () => { + for (const value of [null, 'osm', 'hybrid', 'tomtom']) + for (const hasKey of [true, false]) + assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom'); + // Without Hybrid the default is the first offered mode. + assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom'); + assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm'); +}); + +test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => { + const f = tomtomOnlySource(); + assert.deepEqual(f.source.roadModes, ['tomtom']); + for (const roadMode of [null, 'osm', 'hybrid']) { + const data = await ( + await f.source.requestRoads(box, { + roadMode, + flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }), + }) + ).json(); + assert.equal(data.roadSource, 'TomTom', String(roadMode)); + assert.equal(data.roads.length, 1); + assert.equal(data.roads[0].directFlow, true); + } + assert.equal(f.requests(), 0); + await assert.rejects(f.source.requestRoads(box, {}), TypeError); +}); + +test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => { + const f = tomtomOnlySource(); + const failure = (snapshot) => + f.source + .requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) }) + .then( + () => assert.fail('a keyless load drew roads'), + (error) => error, + ); + const keyless = await failure({ hasKey: false, segments: [] }); + assert.equal(keyless.message, 'TomTom roads need a TomTom key'); + assert.equal(keyless.retryable, false); + const unreachable = await failure({ + hasKey: false, + segments: [], + statusUnavailable: true, + }); + assert.equal( + unreachable.message, + 'TomTom roads unavailable while the traffic service is unreachable', + ); + assert.equal(unreachable.retryable, true); + const late = await failure({ + hasKey: false, + segments: [], + error: 'TomTom flow timed out', + }); + assert.equal(late.message, 'TomTom flow timed out'); + assert.equal(f.requests(), 0); +}); + +test('a TomTom-only layer offers no road chips and ignores other road choices', () => { + const oldLocation = globalThis.location; + globalThis.location = { search: '?trafficRoads=osm' }; + try { + const { source } = tomtomOnlySource(); + const layer = createTrafficLayer({ + services: { credits: {}, render: {} }, + source, + }); + assert.equal(layer.getParams().roadMode, null); + for (const origin of ['user', 'local-restore', 'share-restore']) + layer.setParams({ roadMode: 'osm' }, { origin }); + assert.equal(layer.getParams().roadMode, null); + assert.deepEqual(layer.getRowControls().chips, []); + const stats = layer.getStats(); + assert.equal(stats.roadMode, 'tomtom'); + // No key verdict before the status probe settles. + assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/); + } finally { + if (oldLocation === undefined) delete globalThis.location; + else globalThis.location = oldLocation; + } +}); + +test('Lines and Dots are boolean params that only appear as chips with live flow', () => { + const layer = createTrafficLayer({ + services: { credits: {}, render: {} }, + source: tomtomOnlySource().source, + }); + assert.equal(layer.getParams().lines, false); + assert.equal(layer.getParams().dots, true); + // Keyless (the status probe has not reported a key): nothing to color. + assert.deepEqual(layer.getRowControls().chips, []); + layer.setParams({ lines: true }); + assert.equal(layer.getParams().lines, true); + layer.setParams({ lines: 'no', dots: 0 }); + assert.equal(layer.getParams().lines, true, 'non-boolean ignored'); + assert.equal(layer.getParams().dots, true, 'non-boolean ignored'); + layer.setParams({ lines: false, dots: false }); + assert.deepEqual( + [layer.getParams().lines, layer.getParams().dots], + [false, false], + ); + assert.deepEqual(layer.getDetectableObjects(), []); + // Hidden dots stay recoverable without live flow (restore, share link). + assert.deepEqual( + layer.getRowControls().chips.map((chip) => [chip.id, chip.title]), + [['dots', 'Moving dots']], + ); + layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' }); + assert.equal(layer.getParams().dots, true); + assert.deepEqual(layer.getRowControls().chips, []); +}); + +test('live chips toggle each control without touching the other', async () => { + const { createState } = await import('./state.js'); + const { createControls } = await import('./controls.js'); + const state = createState({ + services: {}, + source: { roadModes: ['tomtom'] }, + }); + state._liveMode = true; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: { + animation: { syncRenderDemand() {} }, + rendering: {}, + }, + }).methods; + const chip = (id) => + controls.getRowControls().chips.find((entry) => entry.id === id); + assert.deepEqual( + controls.getRowControls().chips.map((entry) => entry.id), + ['lines', 'dots'], + ); + assert.equal(chip('lines').active, false); + assert.equal(chip('dots').active, true); + controls.setParams(chip('lines').params, { origin: 'user' }); + assert.equal(chip('lines').active, true); + assert.equal(chip('dots').active, true); + controls.setParams(chip('dots').params, { origin: 'user' }); + assert.equal(chip('dots').active, false); + assert.equal(chip('lines').active, true); + // Every mode keeps its road chips beside the toggles. + const all = createState({ services: {}, source: {} }); + all._liveMode = true; + assert.deepEqual( + createControls({ + state: all, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: {}, + }) + .methods.getRowControls() + .chips.map((entry) => entry.id), + ['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'], + ); +}); + +test('hiding dots hides the point collection of an enabled layer', async () => { + const { createState } = await import('./state.js'); + const { createControls } = await import('./controls.js'); + const state = createState({ services: {}, source: {} }); + let demand = 0; + state._enabled = true; + state._pointCollection = { show: true }; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: { animation: { syncRenderDemand: () => demand++ } }, + }).methods; + controls.setParams({ dots: false }); + assert.equal(state._pointCollection.show, false); + controls.setParams({ dots: true }); + assert.equal(state._pointCollection.show, true); + assert.equal(demand, 2); +}); + +test('Lines and Dots persist in layer state and local storage', () => { + const state = createDefaultLayerState(); + assert.equal(state.options.traffic.lines, false); + assert.equal(state.options.traffic.dots, true); + state.enabledLayerIds = ['traffic']; + state.options.traffic.lines = true; + state.options.traffic.dots = false; + const params = new URLSearchParams('v=2'); + encodeLayerStateParams(params, state); + const decoded = decodeLayerStateParams(params).options.traffic; + assert.equal(decoded.lines, true); + assert.equal(decoded.dots, false); + const stored = parseStoredLayerState(serializeStoredLayerState(state)).options + .traffic; + assert.equal(stored.lines, true); + assert.equal(stored.dots, false); +}); + +test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => { + const { createRendering } = await import('./rendering.js'); + const removed = []; + const state = { + _viewer: { + scene: { groundPrimitives: { remove: (p) => removed.push(p) } }, + }, + _liveMode: false, + _heatFreePrim: { ready: true, id: 'old' }, + _heatRetired: [], + }; + const parts = { + style: { heatlineOn: () => true }, + animation: { syncRenderDemand() {} }, + }; + const rendering = createRendering({ state, services: {}, parts, source: {} }); + // Nothing new to draw (not live): the old line goes at once. + rendering.rebuildHeatLines([]); + assert.deepEqual( + removed.map((p) => p.id), + ['old'], + ); + // A pending successor holds the retired line until it is ready. + const next = { ready: false, id: 'new' }; + state._heatRetired = [{ id: 'older' }]; + state._heatJamPrim = next; + rendering.retireHeatLines(); + assert.equal(state._heatRetired.length, 1); + next.ready = true; + rendering.retireHeatLines(); + assert.deepEqual(state._heatRetired, []); + assert.deepEqual( + removed.map((p) => p.id), + ['old', 'older'], + ); +}); + +test('lines hold frames only while building; hidden dots hold none', async () => { + const { createAnimation } = await import('./animation.js'); + const calls = []; + const state = { + _enabled: true, + _dots: [{}], + _showDots: false, + _showLines: true, + _motion: { rebuilds: 0 }, + }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: () => calls.push('hold'), + releaseContinuousRender: () => calls.push('release'), + }, + }, + parts: {}, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._heatFreePrim = { ready: false }; + animation.syncRenderDemand(); + assert.deepEqual(calls, ['hold']); + state._heatFreePrim.ready = true; + animation.syncRenderDemand(); + assert.deepEqual(calls, ['hold', 'release']); +}); diff --git a/src/layers/traffic/index.js b/src/layers/traffic/index.js index 1a7201281..4a5839f7d 100644 --- a/src/layers/traffic/index.js +++ b/src/layers/traffic/index.js @@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) { ].every((key) => typeof source?.[key] === 'function') ) throw new TypeError('A traffic source is required'); - const state = createState({ services }); + const state = createState({ services, source }); const parts = {}; const context = { state, services, parts, source }; parts.style = createStyle(context); diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index 8bb1da5f4..a5e09f543 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { requestWithDeadline } from '../../sources/requestDeadline.js'; -import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js'; +import { + normalizeRoadMode, + resolveRoadMode, + roadModesSimulate, + ROAD_SOURCE_LABELS, +} from './roadModes.js'; import { phaseTiming } from '../../sources/phaseTiming.js'; import { RoadRequestError, roadRequestError } from './source.js'; import { @@ -294,10 +299,14 @@ export function createIngestion({ // settled; before that (a session's first load) the cache is skipped // rather than waiting on the probe. Writes are decided by the snapshot. const cacheMode = - layerState._roadMode === 'osm' + normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm' ? 'osm' : layerState._flowStatusKnown - ? resolveRoadMode(layerState._roadMode, layerState._liveMode) + ? resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ) : null; // Cache key: fixed-precision bounding-box string for deterministic lookups const cacheKey = @@ -378,6 +387,7 @@ export function createIngestion({ (segments) => ({ segments, hasKey: layerState._liveMode, + statusUnavailable: layerState._flowStatusUnavailable, partial: source.getFlowSessionStats?.().partial, }), (error) => { @@ -388,6 +398,7 @@ export function createIngestion({ return { segments: [], hasKey: layerState._liveMode, + statusUnavailable: layerState._flowStatusUnavailable, error: reason, }; }, @@ -397,7 +408,9 @@ export function createIngestion({ // This uses the same shared XYZ cache and cap, with no extra ring. if ( altitude <= FAST_FETCH_ALTITUDE && - layerState._roadMode !== 'tomtom' + resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !== + 'tomtom' && + (roadModesSimulate(layerState._roadModes) || layerState._liveMode) ) { source .requestOsmRoads?.(clamped, { @@ -411,8 +424,19 @@ export function createIngestion({ .catch(() => {}); } requestSignal.throwIfAborted(); - // Until the probe settles only OpenStreetMap roads can be drawn. - layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm']; + // Until the probe settles only OpenStreetMap roads can be drawn (or, + // for a host without them, the TomTom roads still to come). + layerState._roadSource = + ROAD_SOURCE_LABELS[ + cacheMode || + (roadModesSimulate(layerState._roadModes) + ? 'osm' + : resolveRoadMode( + layerState._roadMode, + true, + layerState._roadModes, + )) + ]; let cache = cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null; if (cache) { @@ -581,7 +605,11 @@ export function createIngestion({ if (generation === layerState._loadGeneration && !renderedSomething) layerState._roadSource = ROAD_SOURCE_LABELS[ - resolveRoadMode(layerState._roadMode, layerState._liveMode) + resolveRoadMode( + layerState._roadMode, + layerState._liveMode, + layerState._roadModes, + ) ]; retryable = !isUnavailableCapability(e); layerState._roadRetryStopped = !retryable; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 1c53e3ebe..b89077cf7 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -1,6 +1,7 @@ import { observeTrafficSurface } from './surface.js'; import * as Cesium from 'cesium'; import { TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { resolveRoadMode } from './roadModes.js'; import { claimCameraSensitivity, releaseCameraSensitivity, @@ -81,10 +82,15 @@ export function createLifecycle({ if (layerState._enabled) return; layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); - if (layerState._roadMode !== 'tomtom') source.prefetch?.(); + // OpenFreeMap metadata, unless only TomTom roads can be drawn. + if ( + resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !== + 'tomtom' + ) + source.prefetch?.(); parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; - layerState._pointCollection.show = true; + layerState._pointCollection.show = layerState._showDots; layerState._preRenderRemover = viewer.scene.preRender.addEventListener( parts.animation.animate, diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index c20b15e41..13f5b101c 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -117,6 +117,23 @@ export const FLOW_BUCKET_COLORS = { jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), }; +// ─── Flow road lines (`lines` row toggle, live mode only) ──────────────── +/** + * @const {number} Max flow-colored road lines per render. Jam first, then + * slow, then free-flow, longest first within a bucket, so a dense city keeps + * its congestion when the cap trims the free roads. + */ + +export const FLOW_LINE_CAP = 1500; + +/** @const {number} Px — flow road-line width (every bucket). */ + +export const FLOW_LINE_WIDTH = 4; + +/** @const {number} Flow road-line opacity: the bucket colors, steady. */ + +export const FLOW_LINE_ALPHA = 0.8; + // ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── /** @const {number} Max congestion heat-line polylines per render (jam first). */ diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index bf0132b98..402cd08b6 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -2,6 +2,10 @@ import * as Cesium from 'cesium'; import { flowBucket } from '../../data/trafficFlowStyle.js'; import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; import { + FLOW_BUCKET_COLORS, + FLOW_LINE_ALPHA, + FLOW_LINE_CAP, + FLOW_LINE_WIDTH, HEAT_LINE_CAP, HEAT_JAM_COLOR, HEAT_SLOW_COLOR, @@ -34,45 +38,81 @@ export function createRendering({ : roads; } - /** Remove both heat-line ground primitives from the scene. */ + const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim']; - function removeHeatLines() { - if (layerState._heatJamPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatJamPrim, - ); - layerState._heatJamPrim = null; - } - if (layerState._heatSlowPrim) { - layerState._viewer?.scene.groundPrimitives.remove( - layerState._heatSlowPrim, - ); - layerState._heatSlowPrim = null; + /** Detach the current line primitives, leaving them in the scene. */ + function takeHeatLines() { + const taken = []; + for (const key of LINE_KEYS) { + if (layerState[key]) taken.push(layerState[key]); + layerState[key] = null; } layerState._heatLineCount = 0; + return taken; + } + + function removePrimitives(primitives) { + for (const primitive of primitives) + layerState._viewer?.scene.groundPrimitives.remove(primitive); + } + + /** Remove the heat-line / flow-line ground primitives from the scene. */ + + function removeHeatLines() { + removePrimitives(takeHeatLines()); + removePrimitives(layerState._heatRetired || []); + layerState._heatRetired = []; parts.animation.syncRenderDemand?.(); } /** - * Rebuild the congestion heat-line underlay (jam-viz heatline prototype): - * slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing - * pulsing red for jam, faint flat amber for slow — with the dots animating - * on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam - * batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam - * first, longest first); overflow is logged. No-op in sim mode, when the - * heatline mode is off, or without ground-primitive support. + * Replaced lines stay drawn until their successors finish building + * (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never + * blinks. Called every held frame. + */ + + function retireHeatLines() { + if (!layerState._heatRetired?.length) return; + if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready)) + return; + removePrimitives(layerState._heatRetired); + layerState._heatRetired = []; + } + + /** + * Rebuild the road-line underlay drawn from live flow, in one of two looks: + * + * - Flow lines (the `lines` row toggle): every road with flow drapes a + * steady line in its bucket color — green free-flow, amber slow, red jam. + * Capped at FLOW_LINE_CAP. + * - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a + * glowing pulsing red for jam and a faint flat amber for slow. Capped at + * HEAT_LINE_CAP. + * + * One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch + * pulses through one shared material. Kept roads are jam first, then slow, + * then free, longest first; overflow is logged. Lines drape on 3D tiles when + * the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in + * sim mode, when both looks are off, or without ground-primitive support. * * @param {Array} roads - Road objects visible in the current render. */ function rebuildHeatLines(roads) { - removeHeatLines(); + const previous = takeHeatLines(); + const finish = () => { + // Nothing new to wait for: the old lines go now. + if (LINE_KEYS.some((key) => layerState[key])) + (layerState._heatRetired ||= []).push(...previous); + else removePrimitives(previous); + parts.animation.syncRenderDemand?.(); + }; if ( !layerState._viewer || !layerState._liveMode || !parts.style.heatlineOn() ) - return; + return finish(); if (layerState._heatSupported === null) { layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( layerState._viewer.scene, @@ -82,22 +122,25 @@ export function createRendering({ '[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled', ); } - if (!layerState._heatSupported) return; + if (!layerState._heatSupported) return finish(); + const flowLines = layerState._showLines; + const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP; + const rank = { jam: 0, slow: 1, free: 2 }; const candidates = []; for (const road of roads) { const flow = road.flow; if (!flow || flow.closure) continue; const bucket = flowBucket(flow.level); - if (bucket === 'free') continue; + if (bucket === 'free' && !flowLines) continue; let len = 0; for (const d of road.segmentDist) len += d; candidates.push({ road, bucket, len }); } candidates.sort((a, b) => - a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1, + a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket], ); - const kept = candidates.slice(0, HEAT_LINE_CAP); + const kept = candidates.slice(0, cap); const instancesFor = (bucket, width) => kept @@ -112,51 +155,72 @@ export function createRendering({ }), ); - // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in - // luminance like the dots: jam = white glow, slow = faint gray. - const monoHeat = + // Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in + // luminance like the dots: jam = white, slow = gray, free = dim gray. + const mono = layerState._presetDots === 'on' && trafficStyleProfile(layerState._stylePreset) === 'mono'; - const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; - const slowLineColor = monoHeat - ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) - : HEAT_SLOW_COLOR; - - const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); - if (jamInstances.length) { - layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: jamInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('PolylineGlow', { - color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), - glowPower: 0.25, + const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha); + const classificationType = + layerState._viewer.scene.globe?.show === false + ? Cesium.ClassificationType.CESIUM_3D_TILE + : flowLines + ? Cesium.ClassificationType.BOTH + : Cesium.ClassificationType.CESIUM_3D_TILE; + const add = (instances, material) => + instances.length + ? layerState._viewer.scene.groundPrimitives.add( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + classificationType, + appearance: new Cesium.PolylineMaterialAppearance({ material }), }), + ) + : null; + + // Lines take the dots' active (preset-aware) colors. + const dotColor = (bucket) => + layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket]; + if (flowLines) { + const lineColor = { + jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'), + slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'), + free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'), + }; + for (const [bucket, key] of [ + ['free', '_heatFreePrim'], + ['slow', '_heatSlowPrim'], + ['jam', '_heatJamPrim'], + ]) + layerState[key] = add( + instancesFor(bucket, FLOW_LINE_WIDTH), + Cesium.Material.fromType('Color', { + color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA), }), + ); + } else { + const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR; + const slowLineColor = mono + ? gray(0.7, HEAT_SLOW_COLOR.alpha) + : HEAT_SLOW_COLOR; + layerState._heatJamPrim = add( + instancesFor('jam', HEAT_LINE_JAM_WIDTH), + Cesium.Material.fromType('PolylineGlow', { + color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA), + glowPower: 0.25, }), ); - } - const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH); - if (slowInstances.length) { - layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add( - new Cesium.GroundPolylinePrimitive({ - geometryInstances: slowInstances, - classificationType: Cesium.ClassificationType.CESIUM_3D_TILE, - appearance: new Cesium.PolylineMaterialAppearance({ - material: Cesium.Material.fromType('Color', { - color: slowLineColor, - }), - }), - }), + layerState._heatSlowPrim = add( + instancesFor('slow', HEAT_LINE_SLOW_WIDTH), + Cesium.Material.fromType('Color', { color: slowLineColor }), ); } layerState._heatLineCount = kept.length; - parts.animation.syncRenderDemand?.(); + finish(); if (candidates.length > kept.length) { console.log( - `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, + `[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`, ); } } @@ -270,7 +334,13 @@ export function createRendering({ renderMetrics, ) : null; - if (!label.includes('tile')) rebuildHeatLines(filteredRoads); + // The flow recolor above already rebuilt the lines from every retained + // road; without dots to recolor, rebuild them here. + if ( + !label.includes('tile') && + !(layerState._liveMode && layerState._dots.length) + ) + rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude)); if (state) { const heatEnd = parts.timing.trafficTimingMark( state, @@ -314,6 +384,7 @@ export function createRendering({ return { visibleRoadsForAltitude, removeHeatLines, + retireHeatLines, rebuildHeatLines, renderRoadsForAltitude, }; diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index ce62f5e1f..785390d93 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -46,24 +46,59 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75]; const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']); const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']); +/** + * Validate a host's road-source list (`createTrafficSource({ roadModes })`): + * a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order. + * @param {*} value - Candidate list; undefined means every mode. + * @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>} + */ +export function normalizeRoadModes(value) { + if (value === undefined) return TRAFFIC_ROAD_MODES; + const modes = Array.isArray(value) + ? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode)) + : []; + if (!modes.length || modes.length !== value.length) + throw new TypeError( + `Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`, + ); + return Object.freeze(modes); +} + +/** + * Whether a host's modes allow simulated roads. Without OpenStreetMap the + * layer never simulates: keyless, it draws no roads and says why. + * @param {ReadonlyArray} [modes] + * @returns {boolean} + */ +export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) { + return modes.includes('osm'); +} + /** * Validate an explicit choice. * @param {*} value - Candidate mode. + * @param {ReadonlyArray} [modes] - The host's offered modes. * @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default". */ -export function normalizeRoadMode(value) { - return TRAFFIC_ROAD_MODES.includes(value) ? value : null; +export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) { + return modes.includes(value) ? value : null; } /** * Resolve the mode actually drawn. Without a TomTom key every choice draws - * OpenStreetMap roads; with one, an explicit choice wins over Hybrid. + * OpenStreetMap roads when the host offers them; with one, an explicit + * choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps + * its TomTom choice keyless (nothing is drawn; the status names the reason), + * and a host without Hybrid defaults to its first offered mode. * @param {*} value - Requested mode (null for the default). * @param {boolean} hasKey - The server reported a TomTom key. + * @param {ReadonlyArray} [modes] - The host's offered modes. * @returns {'tomtom'|'osm'|'hybrid'} */ -export function resolveRoadMode(value, hasKey) { - return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm'; +export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) { + const chosen = normalizeRoadMode(value, modes); + if (!hasKey && roadModesSimulate(modes)) return 'osm'; + return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]); } const FLOW_ROAD_TYPES = Object.freeze({ diff --git a/src/layers/traffic/roadStatus.i18n.test.mjs b/src/layers/traffic/roadStatus.i18n.test.mjs index 655ae6557..17dc074a3 100644 --- a/src/layers/traffic/roadStatus.i18n.test.mjs +++ b/src/layers/traffic/roadStatus.i18n.test.mjs @@ -74,7 +74,23 @@ function roadChips(liveMode) { source: { getFlowSessionStats: () => ({}) }, parts: {}, }); - return controls.methods.getRowControls().chips; + return controls.methods + .getRowControls() + .chips.filter((chip) => chip.id.startsWith('roads-')); +} + +function toggleChips() { + const state = createState({ services: {} }); + state._liveMode = true; + const controls = createControls({ + state, + services: {}, + source: { getFlowSessionStats: () => ({}) }, + parts: {}, + }); + return controls.methods + .getRowControls() + .chips.filter((chip) => !chip.id.startsWith('roads-')); } test('en: road-source chips keep their English labels and titles', () => { @@ -124,3 +140,28 @@ for (const [locale, hybrid] of [ } }); } + +test('en: the Lines and Dots toggles keep their English labels and titles', () => { + setLocale('en'); + assert.deepEqual( + toggleChips().map((chip) => [chip.id, chip.label, chip.title]), + [ + ['lines', 'Lines', 'Road lines colored by live flow: green to red'], + ['dots', 'Dots', 'Moving dots colored by live flow'], + ], + ); +}); + +for (const locale of ['ru', 'es', 'ja']) { + test(`${locale}: the Lines and Dots toggles are translated`, () => { + setLocale(locale); + try { + for (const chip of toggleChips()) { + assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale); + assert.doesNotMatch(chip.title, /colored by live flow/, locale); + } + } finally { + setLocale('en'); + } + }); +} diff --git a/src/layers/traffic/source.js b/src/layers/traffic/source.js index e23dac27c..7327a3f98 100644 --- a/src/layers/traffic/source.js +++ b/src/layers/traffic/source.js @@ -1,7 +1,10 @@ import { createHybridFill, flowSegmentsToRoads, + normalizeRoadMode, + normalizeRoadModes, resolveRoadMode, + roadModesSimulate, ROAD_SOURCE_LABELS, } from './roadModes.js'; import { phaseTiming } from '../../sources/phaseTiming.js'; @@ -80,7 +83,15 @@ export function roadRequestError(status, cause) { }); } -/** Supply tile-derived road geometry and flow availability without Overpass queries. */ +/** + * Supply tile-derived road geometry and flow availability without Overpass queries. + * + * `roadModes` is the host's offer of road sources (default: all three). The + * layer shows a chip per offered mode (none for a single mode) and never + * draws one outside it. Without `osm` nothing is simulated: keyless, or with + * the traffic service unreachable, requests fail with the reason instead of + * drawing white OpenStreetMap dots. + */ export function createTrafficSource({ fetchImpl = (...args) => globalThis.fetch(...args), tileFetchImpl = (...args) => globalThis.fetch(...args), @@ -88,10 +99,13 @@ export function createTrafficSource({ fetchImpl: tileFetchImpl, selectDecoded: ({ roads }) => ({ roads }), }), + roadModes, } = {}) { + const modes = normalizeRoadModes(roadModes); const flow = createFlowTileSource({ fetchImpl }); const api = { ...flow, + roadModes: modes, prefetch: () => mapTiles.getMetadata().catch(() => {}), resetFlowTileCache() { flow.resetFlowTileCache(); @@ -247,8 +261,8 @@ export function createTrafficSource({ * * @param {{south:number, west:number, north:number, east:number}} box * @param {Object} [options] - * @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default). - * @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot] + * @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default). + * @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot] * @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline. * @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait. * @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets. @@ -269,7 +283,16 @@ export function createTrafficSource({ box.east - box.west > 10 ) throw new TypeError('A bounded road viewport is required'); - if (roadMode === 'osm' || !flowSnapshot) + roadMode = normalizeRoadMode(roadMode, modes); + if (!roadModesSimulate(modes) && !flowSnapshot) + throw new TypeError( + 'Road selection requires a flow availability snapshot', + ); + if ( + roadMode === 'osm' || + !flowSnapshot || + (modes.length === 1 && modes[0] === 'osm') + ) return api.requestOsmRoads(box, { ...options, onTile }); const signal = options.signal; const osm = []; @@ -332,7 +355,13 @@ export function createTrafficSource({ osmError = error; } }; - const osmJob = roadMode === 'tomtom' ? null : loadOsm(); + // A host without OpenStreetMap waits for the key before fetching fill. + const osmJob = + roadMode === 'tomtom' || + modes.every((entry) => entry === 'tomtom') || + !roadModesSimulate(modes) + ? null + : loadOsm(); osmJob?.catch(() => {}); // The snapshot waits on the status probe and flow tiles; never let it // hold the road pass past its own deadline. A late snapshot counts as a @@ -357,7 +386,21 @@ export function createTrafficSource({ throw new TypeError( 'Road selection requires a flow availability snapshot', ); - mode = resolveRoadMode(roadMode, live.hasKey); + mode = resolveRoadMode(roadMode, live.hasKey, modes); + // A host without OpenStreetMap never simulates: name why no roads draw. + if (!live.hasKey && !roadModesSimulate(modes)) + throw Object.assign( + new RoadRequestError( + live.error || + (live.statusUnavailable + ? msg( + 'TomTom roads unavailable while the traffic service is unreachable', + ) + : msg('TomTom roads need a TomTom key')), + ), + // A known missing key cannot recover this session; the rest can. + { retryable: Boolean(live.error || live.statusUnavailable) }, + ); if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error); if (mode !== 'osm') { tomtom = flowSegmentsToRoads(live.segments); @@ -365,7 +408,7 @@ export function createTrafficSource({ publish(composed(), true); } if (osmJob) await osmJob; - else if (mode === 'osm') await loadOsm(); + else if (mode !== 'tomtom') await loadOsm(); signal?.throwIfAborted(); if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError; const data = snapshot(composed(), true); diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index ecd34c9ee..8c2fd9f94 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,14 +1,17 @@ -import { normalizeRoadMode } from './roadModes.js'; +import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js'; import * as Cesium from 'cesium'; import { FLOW_BUCKET_COLORS } from './policy.js'; -export function createState({ services }) { +export function createState({ services, source }) { const layerState = {}; + /** The host's offered road sources (createTrafficSource `roadModes`). */ + layerState._roadModes = normalizeRoadModes(source?.roadModes); layerState._roadMode = typeof location === 'undefined' ? null : normalizeRoadMode( new URLSearchParams(location.search).get('trafficRoads'), + layerState._roadModes, ); // The query override is consumed at construction and shields passive restoration. layerState._roadModeUrlOverride = layerState._roadMode; @@ -167,6 +170,14 @@ export function createState({ services }) { layerState._jamViz = 'density'; + /** + * Row toggles (`lines` / `dots` params): flow-colored road lines draped on + * the surface, and the moving dots. Live mode only for lines — the keyless + * simulation has no flow to color them with. + */ + layerState._showLines = false; + layerState._showDots = true; + /** * Congestion heat-lines are GroundPolylinePrimitive batches draped onto the * rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One @@ -181,6 +192,14 @@ export function createState({ services }) { layerState._heatSlowPrim = null; + /** @type {Cesium.GroundPolylinePrimitive|null} Free-flow road-line batch (`lines` only). */ + + layerState._heatFreePrim = null; + + /** @type {Cesium.GroundPolylinePrimitive[]} Replaced lines kept until successors are ready. */ + + layerState._heatRetired = []; + /** @type {number} Heat-lines in the current render (stats). */ layerState._heatLineCount = 0; diff --git a/src/layers/traffic/style.js b/src/layers/traffic/style.js index 6d236da92..f303e69a9 100644 --- a/src/layers/traffic/style.js +++ b/src/layers/traffic/style.js @@ -137,10 +137,14 @@ export function createStyle({ state: layerState, services, parts, source }) { return layerState._jamViz === 'density' || layerState._jamViz === 'both'; } - /** @returns {boolean} Heat-line prototype active. */ + /** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */ function heatlineOn() { - return layerState._jamViz === 'heatline' || layerState._jamViz === 'both'; + return ( + layerState._showLines || + layerState._jamViz === 'heatline' || + layerState._jamViz === 'both' + ); } return { presetProfileActive, diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index f54ff23d8..809e0a57b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -253,7 +253,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), - traffic: Object.freeze({ roadMode: null }), + traffic: Object.freeze({ roadMode: null, lines: false, dots: true }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), 'weather-satellite': Object.freeze({ diff --git a/src/textChatServer.test.mjs b/src/textChatServer.test.mjs index 61b09e987..b7137bbdd 100644 --- a/src/textChatServer.test.mjs +++ b/src/textChatServer.test.mjs @@ -3,7 +3,10 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import { once } from 'node:events'; import { createGeminiChatHandler } from '../server/providers/gemini/chat.js'; -import { createOpenAiChatHandler } from '../server/providers/openai/chat.js'; +import { + chatSchema, + createOpenAiChatHandler, +} from '../server/providers/openai/chat.js'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import { realtimeDirectives, @@ -534,6 +537,11 @@ for (const [name, provider] of Object.entries(providers)) { assert.equal(offered.includes('osm_query'), overpass); assert.equal(prompt.includes('osm_query'), overpass); assert.ok(offered.includes('resolve_area')); + // Chat Completions answers 400 invalid_function_parameters for a + // pattern with Unicode property classes, as resolve_area's area ids + // use; OpenAI chat leaves those patterns out. + if (name === 'openai') + assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/); } }); test(`${name} throttled replies say they are rate limited`, async (t) => { @@ -697,3 +705,25 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t const body = await (await f.send()).json(); assert.equal(body.content.tool_calls, undefined); }); + +test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => { + assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/); + assert.deepEqual( + chatSchema({ + type: 'object', + properties: { + id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{ pattern: '\\P{N}' }] }, + }, + }), + { + type: 'object', + properties: { + id: { type: 'string', maxLength: 80 }, + code: { type: 'string', pattern: '^[A-Z]{2}$' }, + list: { type: 'array', items: [{}] }, + }, + }, + ); +}); diff --git a/src/ui/contextActions.js b/src/ui/contextActions.js index a621137ad..eeb22fefe 100644 --- a/src/ui/contextActions.js +++ b/src/ui/contextActions.js @@ -4,6 +4,19 @@ import { contextModeWord, } from '../contextModePolicy.js'; +/** Why a Space Missions entry failed: its mission index never loaded, and + * the error is no older than `since` (an earlier attempt's is not the cause). */ +export function spaceMissionsIndexFailure(dataManager, since = 0) { + const stats = dataManager?.layers + ?.get('rocket-launches') + ?.module?.getStats?.(); + return stats?.error && stats.lastUpdate == null && stats.errorAt >= since + ? t( + 'Space Missions unavailable: the mission index could not load. Try again later.', + ) + : null; +} + export async function _runUserFacingContextAction( operation, message = t('Context could not restore every layer; try again'), @@ -19,7 +32,10 @@ export async function _runUserFacingContextAction( onFailure: (error) => { if (this.destroyed) return; console.warn('[Context] user-facing transition failed', error); - this.showToast(localizedMessage || message); + this.showToast( + localizedMessage || + (typeof message === 'function' ? message() : message), + ); }, }); } finally { diff --git a/src/ui/contextBindings.js b/src/ui/contextBindings.js index cc09c49bf..8c6b8760e 100644 --- a/src/ui/contextBindings.js +++ b/src/ui/contextBindings.js @@ -1,4 +1,5 @@ import { t } from '../i18n/index.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js'; export function _initGlobalContextPanel() { @@ -59,12 +60,15 @@ export function _initGlobalContextPanel() { const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions'; this._claimContextVisualAuthority(); + const startedAt = Date.now(); void this._runUserFacingContextAction( (notificationToken) => this._selectContextMode(nextMode, { notificationToken }), - t( - 'Space Missions could not complete the requested transition; try again', - ), + () => + (nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) || + t( + 'Space Missions could not complete the requested transition; try again', + ), ).then((succeeded) => { if ( !this.destroyed && diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index d4ddd6cd5..295242239 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -9,6 +9,7 @@ import { spaceMissionEntryCancellationDisposition, } from '../contextModePolicy.js'; import { localizeLayerName } from './layerNames.js'; +import { spaceMissionsIndexFailure } from './contextActions.js'; /** * A blocked-visibility reason in the UI language. Reasons stay English (voice @@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) { return; } if (change?.type === 'visibility-failed') { - const failureMessage = t('{layerId} could not {action} cleanly', { - layerId: change.layerId, - action: change.enabled ? t('start') : t('stop'), - }); + const failureMessage = + (change.layerId === 'rocket-launches' && + change.enabled && + spaceMissionsIndexFailure(this._dataManager)) || + t('{layerId} could not {action} cleanly', { + layerId: change.layerId, + action: change.enabled ? t('start') : t('stop'), + }); // A failed direct Context-shell START has already had its siblings // cleared by the visibility guard. Wait outside the synchronous manager // notification for this queue to settle, then reconcile the complete