mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into authoring-panel
This commit is contained in:
59 files changed
+2551
-129
No files matched your search
@@ -256,6 +256,8 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TAIWAN_ENABLED=1 # set 0 to skip Taiwan Freeway Bureau (MOTC) road cameras
|
||||
# CCTV_TAIWAN_MAX_SOURCES=600 # cap Taiwan freeway cameras (1-2000); no API key required
|
||||
# CCTV_TAIWAN_URL= # override the Freeway Bureau MOTC 2.0 CCTV.xml URL
|
||||
# CCTV_A22_ENABLED=1 # set 0 to disable the Autostrada del Brennero (A22, Italy) webcams
|
||||
# CCTV_A22_URL= # override the Open Data Hub A22 webcam catalog URL
|
||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||
|
||||
@@ -69,6 +69,7 @@ How to read this:
|
||||
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
|
||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
|
||||
| **Autostrada del Brennero (A22)** (catalog `tourism.api.opendatahub.com`, frames `www.autobrennero.it/WebCamImg/`) | CCTV camera catalog + frames, A22 Brenner–Modena motorway, Italy | Public Autobrennero webcam images; camera list republished by the Open Data Hub, whose records are tagged CC0 (`ClosedData: false`, licence holder Autobrennero); Autobrennero publishes no terms of its own for the images | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) |
|
||||
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
|
||||
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
|
||||
@@ -176,6 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
|
||||
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
|
||||
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
|
||||
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
|
||||
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
|
||||
@@ -399,7 +399,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). 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) · 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) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). 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 | 🟢 |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to
|
||||
connect: a request whose every address fails to connect retries once over
|
||||
IPv4, and FIRMS warnings name the underlying connection errors instead of
|
||||
only `fetch failed` (jeffblakely, #1078).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Give the side panels one look. Every panel title now uses the command dock's
|
||||
label type in a brighter colour; a panel's edge and title light up on hover,
|
||||
as the dock's trays do; and in the default theme each collapse button sits
|
||||
19px from its panel's edge (plus the scrollbar's width in an open, scrolling
|
||||
panel where the system always shows scrollbars) (asturksever, #1466).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Draw only the CCTV cameras in view, and thin them to one per ground cell when
|
||||
zoomed out. Dragging the globe with the default 4,000-camera catalog no
|
||||
longer stalls, and the camera picker lists the cameras in view with a count
|
||||
of the ones outside it (samehkhamis, #1485).
|
||||
@@ -0,0 +1,8 @@
|
||||
- Add the Autostrada del Brennero (A22, Brenner–Modena) motorway webcams as a
|
||||
CCTV pack, the first Italian cameras. The 13-camera list is one keyless
|
||||
request to the Open Data Hub (NOI Techpark) tourism API, which republishes
|
||||
Autobrennero's own list with coordinates; frames are the operator's stills,
|
||||
refreshed about once a minute and pinned to each camera's own `km<N>.jpg`.
|
||||
Each camera's ground elevation comes from a per-kilometre DEM table, since
|
||||
the road climbs from the Po valley to the Brenner Pass. `CCTV_A22_ENABLED=0`
|
||||
disables the pack (Jibran Tahir, @jibraaan, #956). Closes #155.
|
||||
+24
-2
@@ -978,6 +978,15 @@ directory. Every `/api/cctv` route refuses cross-site browser requests but
|
||||
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
|
||||
request skips the key-bearing Street View fallback and gets the placeholder.
|
||||
|
||||
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
|
||||
keeps one camera per ground cell sized to the view, preferring a healthy feed,
|
||||
and the cells are anchored to the ground so a pan keeps the same cameras. The
|
||||
active camera always shows. Ground placement and coverage geometry load only
|
||||
for the cameras in view, with at most two attempts per camera and view regime.
|
||||
The view pass reruns while the camera moves, paced by its own cost. The camera
|
||||
picker lists the cameras in view, active camera first, and closes with the
|
||||
count of cameras outside the view.
|
||||
|
||||
Traffic owns its rendered-surface observer only while enabled, removes it on disable/destroy, and caches surface identity without allocating on unchanged frames.
|
||||
|
||||
Traffic and bikeshare expose factories through `./layers/traffic` and
|
||||
@@ -2118,6 +2127,19 @@ throws, that source reports failure without a contradictory success entry.
|
||||
The existing per-record append continues to support large feeds; sequential
|
||||
fetching, trailing-24-hour filtering and partial-success caching are unchanged.
|
||||
|
||||
FIRMS requests retry once over IPv4 when every resolved address fails to
|
||||
connect. Node's network-family auto-selection gives each address a short
|
||||
attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake
|
||||
was abandoned and the request reported only `fetch failed` (#68). The retry
|
||||
makes a single IPv4 connection within the same deadline and the same body
|
||||
cap, and does not follow redirects. HTTP errors, an expired request deadline
|
||||
and DNS failures are not retried; a per-address connect timeout, which Node 26
|
||||
reports as `ConnectTimeoutError`, is. A retry answered with a null-body status
|
||||
(204, 205, 304) or a status above 599 fails that source instead of crashing
|
||||
the server, and a non-OK response releases its connection at once. FIRMS
|
||||
warnings include the per-address connection errors and never the request URL,
|
||||
which embeds the MAP_KEY.
|
||||
|
||||
## Installations and map-source guidance
|
||||
|
||||
- Keyless mapped installations use OpenFreeMap `landuse` military polygons,
|
||||
@@ -3876,7 +3898,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) + 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) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + 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 |
|
||||
@@ -4810,7 +4832,7 @@ silently demoting every later lookup for the session.
|
||||
- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft.
|
||||
- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width.
|
||||
- The Context header holds only its title and collapse control. It no longer carries a Radio shortcut or a compact Radio player: Radio is the `RADIO` section inside the panel, with its own disclosure and the single `ENABLE` power control. Cockpit keeps its own compact Radio utility.
|
||||
- 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 expanded panel's section titles use `--text-secondary`. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
|
||||
- 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.
|
||||
|
||||
@@ -561,6 +561,7 @@
|
||||
"src/data/cctvCataloniaSource.test.mjs",
|
||||
"src/data/cctvHk.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvA22.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -165,6 +166,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||
load: loadTaiwanSources,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
load: loadA22SourcesFromOpenDataHub,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -443,6 +443,39 @@ export const NYC_ANCHORS = [
|
||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||
];
|
||||
|
||||
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
|
||||
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
|
||||
* own camera list with coordinates, one request for the whole motorway
|
||||
* (13 cameras). Frames are stills on the operator's host, refreshed about
|
||||
* once a minute. */
|
||||
export const DEFAULT_A22_CCTV_URL =
|
||||
'https://tourism.api.opendatahub.com/v1/WebcamInfo?source=a22&pagesize=200&removenullvalues=true';
|
||||
/** The only origin A22 camera frames may come from: `km<N>.jpg` per camera. */
|
||||
export const A22_IMAGE_ORIGIN = 'https://www.autobrennero.it/WebCamImg/';
|
||||
/** Hard ceiling on the catalog body. The whole motorway is ~45 KB. */
|
||||
export const A22_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Orthometric ground elevation (m) at each camera, keyed by its motorway
|
||||
* kilometre, from the Copernicus GLO-90 DEM via Open-Meteo's elevation API.
|
||||
* The feed's coordinates are 2D and the road climbs from the Po valley (~20 m)
|
||||
* to the Brenner Pass (~1,370 m), so one flat prior would bury the Alpine
|
||||
* cameras. A camera not listed here uses A22_DEFAULT_GROUND_ELEVATION_M. */
|
||||
export const A22_GROUND_ELEVATION_BY_KM = Object.freeze({
|
||||
1: 1365,
|
||||
68: 375,
|
||||
77: 301,
|
||||
100: 219,
|
||||
129: 206,
|
||||
138: 204,
|
||||
159: 175,
|
||||
205: 188,
|
||||
228: 72,
|
||||
240: 38,
|
||||
272: 16,
|
||||
285: 19,
|
||||
313: 40,
|
||||
});
|
||||
export const A22_DEFAULT_GROUND_ELEVATION_M = 200;
|
||||
|
||||
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
|
||||
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
|
||||
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { hashSeed, escapeXml, pinnedCameraUrl } from './normalize.js';
|
||||
import {
|
||||
A22_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CCTV_FRAME_FETCH_TIMEOUT_MS,
|
||||
CCTV_FRAME_MAX_BODY_BYTES,
|
||||
@@ -528,6 +529,7 @@ for (const prefix of [
|
||||
HK_IMAGE_ORIGIN,
|
||||
KING_COUNTY_IMAGE_ORIGIN,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
A22_IMAGE_ORIGIN,
|
||||
]) {
|
||||
const host = new URL(prefix).hostname;
|
||||
CCTV_PINNED_PREFIXES_BY_HOST.set(host, [
|
||||
|
||||
@@ -421,6 +421,17 @@ export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The A22 corridor, Brenner Pass to Modena, with slack either side. */
|
||||
export function isLikelyA22Coordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 44.5 &&
|
||||
lat <= 47.1 &&
|
||||
lon >= 10.5 &&
|
||||
lon <= 11.8
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFinlandCoordinate(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
||||
|
||||
@@ -122,6 +122,11 @@ import {
|
||||
NYC_MAX_CATALOG_BYTES,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
NYC_ANCHORS,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
A22_IMAGE_ORIGIN,
|
||||
A22_MAX_CATALOG_BYTES,
|
||||
A22_GROUND_ELEVATION_BY_KM,
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -145,6 +150,7 @@ import {
|
||||
isLikelyHongKongCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyTorontoCoordinate,
|
||||
isLikelyA22Coordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
@@ -3645,3 +3651,123 @@ export async function loadFloridaSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source,
|
||||
* or null.
|
||||
*
|
||||
* The frame must be the operator's own `WebCamImg/km<N>.jpg` for the
|
||||
* camera's motorway kilometre. The feed names the place and the kilometre but
|
||||
* no bearing, so the heading uses the shared id-hash fallback at low
|
||||
* confidence, like Calgary and Vegvesen; the ground elevation comes from the
|
||||
* per-kilometre DEM table because the road spans ~1,350 m of altitude.
|
||||
*
|
||||
* @param {object} item - One element of the API's `Items` array.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function a22WebcamToSource(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
if (item.Active === false) return null;
|
||||
if (String(item.Source ?? '').toLowerCase() !== 'a22') return null;
|
||||
|
||||
const gps = Array.isArray(item.GpsInfo) ? item.GpsInfo[0] : null;
|
||||
const lat = toFiniteNumber(gps?.Latitude);
|
||||
const lon = toFiniteNumber(gps?.Longitude);
|
||||
if (!isLikelyA22Coordinate(lat, lon)) return null;
|
||||
|
||||
const km = String(item.Mapping?.a22?.km ?? '').trim();
|
||||
if (!/^\d{1,3}$/.test(km)) return null;
|
||||
const imageUrl = `${A22_IMAGE_ORIGIN}km${Number(km)}.jpg`;
|
||||
let image;
|
||||
try {
|
||||
image = new URL(String(item.Webcamurl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (image.username || image.password || image.href !== imageUrl) return null;
|
||||
|
||||
const place =
|
||||
String(item.Shortname ?? item.Webcamname?.it ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || `Km ${Number(km)}`;
|
||||
const cameraId = `it-a22-km${Number(km)}`;
|
||||
const ground = A22_GROUND_ELEVATION_BY_KM[Number(km)];
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: `A22 ${place} (km ${Number(km)})`,
|
||||
// One motorway-wide category in the camera picker; the place is already
|
||||
// in the name.
|
||||
city: 'A22 Brennero',
|
||||
cityId: 'a22',
|
||||
provider: 'Autostrada del Brennero',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -15,
|
||||
fovDeg: 50,
|
||||
rangeM: 160,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: Number.isFinite(ground)
|
||||
? ground
|
||||
: A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'a22-opendatahub',
|
||||
license:
|
||||
'Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)',
|
||||
code: cameraDisplayCode(place.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Autostrada del Brennero (A22) webcams from the keyless Open Data
|
||||
* Hub tourism API. Frames are stills on www.autobrennero.it.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadA22SourcesFromOpenDataHub() {
|
||||
try {
|
||||
const endpoint = process.env.CCTV_A22_URL || DEFAULT_A22_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn('[CCTV] A22 catalog redirected; redirects are not followed');
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] A22 camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(resp, A22_MAX_CATALOG_BYTES);
|
||||
const items = Array.isArray(payload?.Items) ? payload.Items : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const item of items) {
|
||||
const camera = a22WebcamToSource(item);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
// North to south along the motorway, so the picker reads in road order.
|
||||
cameras.sort((a, b) => b.lat - a.lat);
|
||||
console.log(`[CCTV] Loaded A22 camera sources: ${cameras.length}`);
|
||||
return cameras;
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] A22 camera download error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+138
-6
@@ -1,9 +1,132 @@
|
||||
import path from 'node:path';
|
||||
import https from 'node:https';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
|
||||
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||
|
||||
/**
|
||||
* The per-address connection errors behind a fetch failure, or null. Node 24
|
||||
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
|
||||
* a ConnectTimeoutError one level deeper.
|
||||
* @param {unknown} err
|
||||
* @returns {?Array<unknown>}
|
||||
*/
|
||||
function connectAttemptErrors(err) {
|
||||
let cause = err?.cause;
|
||||
for (let depth = 0; cause && depth < 3; depth++) {
|
||||
if (Array.isArray(cause.errors)) return cause.errors;
|
||||
cause = cause.cause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a fetch failure with its socket-level cause. Node reports every
|
||||
* connection failure as `fetch failed`; the cause carries the error code and
|
||||
* the address that failed (never the URL, which embeds the MAP_KEY).
|
||||
* @param {unknown} err
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFetchError(err) {
|
||||
const message = err?.message || String(err);
|
||||
const cause = err?.cause;
|
||||
if (!cause) return message;
|
||||
const attempts = connectAttemptErrors(err);
|
||||
const parts = attempts?.length
|
||||
? attempts.map((inner) => inner?.message || inner?.code || String(inner))
|
||||
: [cause.message || cause.code || String(cause)];
|
||||
return `${message} (${parts.join('; ')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fetch failed because every address it tried failed to connect.
|
||||
* Node's network-family auto-selection gives each address a short attempt
|
||||
* timeout, so a slow IPv4 handshake is abandoned in favour of an unreachable
|
||||
* IPv6 address and the whole request fails (#68).
|
||||
* @param {unknown} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAddressSelectionFailure(err) {
|
||||
return err instanceof TypeError && connectAttemptErrors(err)?.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET over IPv4 only, with one connection attempt bounded by the caller's
|
||||
* signal rather than by the auto-selection attempt timeout.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export function fetchIpv4(url, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
family: 4,
|
||||
autoSelectFamily: false,
|
||||
agent: false,
|
||||
},
|
||||
(response) => {
|
||||
// Nothing thrown here may escape: this callback runs outside the
|
||||
// promise, so an exception would be uncaught and crash the server.
|
||||
try {
|
||||
const status = response.statusCode || 500;
|
||||
// A `Response` only represents statuses up to 599; Node never hands
|
||||
// this callback a 1xx.
|
||||
if (status > 599)
|
||||
throw new Error(`FIRMS returned unsupported status ${status}`);
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(response.headers)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((item) => headers.append(name, item));
|
||||
else if (value !== undefined) headers.set(name, String(value));
|
||||
}
|
||||
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||
const upstream = new Response(
|
||||
nullBody ? null : Readable.toWeb(response),
|
||||
{ status, statusText: response.statusMessage || '', headers },
|
||||
);
|
||||
if (nullBody) response.resume();
|
||||
resolve(upstream);
|
||||
} catch (error) {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a FIRMS URL, retrying once over IPv4 when address selection fails.
|
||||
* Other failures (HTTP status, timeout, DNS) are returned or thrown as is.
|
||||
* @param {string} url
|
||||
* @param {{signal?: AbortSignal}} options
|
||||
* @param {{fetchImpl?: typeof fetch, fetchIpv4Impl?: typeof fetchIpv4, warn?: Function}} [deps]
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function fetchFirms(
|
||||
url,
|
||||
options,
|
||||
{ fetchImpl = fetch, fetchIpv4Impl = fetchIpv4, warn = console.warn } = {},
|
||||
) {
|
||||
try {
|
||||
return await fetchImpl(url, options);
|
||||
} catch (err) {
|
||||
if (!isAddressSelectionFailure(err) || options?.signal?.aborted) throw err;
|
||||
warn(`[firms-proxy] ${describeFetchError(err)} — retrying over IPv4`);
|
||||
return fetchIpv4Impl(url, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
|
||||
@@ -91,8 +214,12 @@ export function firmsProxy() {
|
||||
async function fetchSource(key, source) {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
||||
const signal = AbortSignal.timeout(60_000);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, { signal });
|
||||
if (!res.ok) {
|
||||
// Release the connection now rather than when the deadline fires.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const records = parseFirmsCsv(
|
||||
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
||||
);
|
||||
@@ -121,7 +248,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[firms-proxy] ${source} fetch failed:`,
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
sources.push({ source, count: 0, ok: false });
|
||||
}
|
||||
@@ -156,8 +283,13 @@ export function firmsProxy() {
|
||||
statusInflight = (async () => {
|
||||
try {
|
||||
const url = `https://firms.modaps.eosdis.nasa.gov/mapserver/mapkey_status/?MAP_KEY=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const res = await fetchFirms(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const used = Number(body?.current_transactions);
|
||||
const limit = Number(body?.transaction_limit);
|
||||
@@ -167,7 +299,7 @@ export function firmsProxy() {
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[firms-proxy] mapkey status failed:',
|
||||
err?.message || err,
|
||||
describeFetchError(err),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -172,10 +172,10 @@ test('Context reads as one segmented control with legible type and no halo', ()
|
||||
assert.ok(px(standby, 'font') >= 10, 'standby guidance must stay at least 10px');
|
||||
assert.doesNotMatch(standby, /dashed/);
|
||||
assert.ok(px(rule('.global-context-actions .panel-layer-toggle'), 'font-size') >= 10);
|
||||
// Default theme only, expanded only: Cyber keeps its red titles and the
|
||||
// collapsed launcher matches its sibling launchers.
|
||||
assert.match(
|
||||
// The shared rail title colour (brighter than --text-secondary) keeps the
|
||||
// open Context title legible; no Context-only rule dims it again.
|
||||
assert.equal(
|
||||
rule(":root:not([data-ui-theme='cyber']) #global-context-panel:not(.collapsed) .panel-title"),
|
||||
/color:\s*var\(--text-secondary\)/,
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
a22WebcamToSource,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
A22_IMAGE_ORIGIN,
|
||||
DEFAULT_A22_CCTV_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
|
||||
import { staticFrameRefreshMs } from './cctvLod.js';
|
||||
|
||||
/** One Open Data Hub `WebcamInfo` item, shaped like the live a22 payload. */
|
||||
const item = (
|
||||
patch = {},
|
||||
{ km = '68', lat = 46.53591, lon = 11.49772 } = {},
|
||||
) => ({
|
||||
Id: `A22_${km}`,
|
||||
Active: true,
|
||||
Source: 'a22',
|
||||
Shortname: 'ADS Sciliar',
|
||||
Webcamname: { it: 'ADS Sciliar' },
|
||||
Webcamurl: `https://www.autobrennero.it/WebCamImg/km${km}.jpg`,
|
||||
Mapping: { a22: { km } },
|
||||
GpsInfo: [{ Gpstype: 'position', Latitude: lat, Longitude: lon }],
|
||||
LicenseInfo: {
|
||||
ClosedData: false,
|
||||
LicenseHolder: 'https://www.autobrennero.it/',
|
||||
},
|
||||
...patch,
|
||||
});
|
||||
|
||||
test('an A22 webcam maps to a source on the pinned operator frame', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(source.id, 'it-a22-km68');
|
||||
assert.equal(source.name, 'A22 ADS Sciliar (km 68)');
|
||||
assert.equal(source.city, 'A22 Brennero');
|
||||
assert.equal(source.cityId, 'a22');
|
||||
assert.equal(source.provider, 'Autostrada del Brennero');
|
||||
assert.equal(source.lat, 46.53591);
|
||||
assert.equal(source.lon, 11.49772);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'a22-opendatahub');
|
||||
assert.equal(source.code, 'ADS SCILIAR');
|
||||
assert.equal(source.url, `${A22_IMAGE_ORIGIN}km68.jpg`);
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('ground elevation follows the motorway from the Brenner Pass to the Po', () => {
|
||||
const brenner = a22WebcamToSource(
|
||||
item({ Shortname: 'Brennero' }, { km: '1', lat: 46.99682, lon: 11.50218 }),
|
||||
);
|
||||
const ponte = a22WebcamToSource(
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
);
|
||||
assert.ok(brenner.groundElevationM > 1300);
|
||||
assert.ok(ponte.groundElevationM < 50);
|
||||
// A kilometre the table does not know falls back to the corridor prior.
|
||||
const unknown = a22WebcamToSource(
|
||||
item({}, { km: '150', lat: 45.95, lon: 11.05 }),
|
||||
);
|
||||
assert.equal(unknown.groundElevationM, A22_DEFAULT_GROUND_ELEVATION_M);
|
||||
});
|
||||
|
||||
test('inactive cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(a22WebcamToSource(item({ Active: false })), null);
|
||||
assert.equal(a22WebcamToSource(item({ Source: 'other' })), null);
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://evil.test/WebCamImg/km68.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame must be exactly the camera's own kilometre image.
|
||||
assert.equal(
|
||||
a22WebcamToSource(
|
||||
item({ Webcamurl: 'https://www.autobrennero.it/WebCamImg/km1.jpg' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({ Mapping: { a22: { km: '../x' } } })),
|
||||
null,
|
||||
);
|
||||
// Milan: plausible, but off the Brenner–Modena corridor.
|
||||
assert.equal(
|
||||
a22WebcamToSource(item({}, { km: '68', lat: 45.46, lon: 9.19 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(a22WebcamToSource(item({ GpsInfo: [] })), null);
|
||||
assert.equal(a22WebcamToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, orders north to south, and degrades to empty', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
TotalResults: 3,
|
||||
Items: [
|
||||
item(
|
||||
{ Shortname: 'Ponte Po' },
|
||||
{ km: '272', lat: 45.03145, lon: 10.85369 },
|
||||
),
|
||||
item(),
|
||||
item(),
|
||||
item(
|
||||
{ Shortname: 'Brennero' },
|
||||
{ km: '1', lat: 46.99682, lon: 11.50218 },
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadA22SourcesFromOpenDataHub();
|
||||
assert.deepEqual(requested, [DEFAULT_A22_CCTV_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['it-a22-km1', 'it-a22-km68', 'it-a22-km272'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://evil.test/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadA22SourcesFromOpenDataHub(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_A22_CCTV_URL) {
|
||||
return Response.json({ Items: [item()] });
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_A22_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the A22 lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_A22_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22').map((s) => s.id),
|
||||
['it-a22-km68'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_A22_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_A22_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'a22'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('a redirect on the A22 frame host stays inside WebCamImg/', async () => {
|
||||
const frame = `${A22_IMAGE_ORIGIN}km68.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 ['/it/area-riservata/', '/WebCamImg/../x.jpg'])
|
||||
assert.equal(
|
||||
await fetchWithinHost(frame, {}, redirectTo(location)),
|
||||
null,
|
||||
location,
|
||||
);
|
||||
const followed = await fetchWithinHost(
|
||||
frame,
|
||||
{},
|
||||
redirectTo('/WebCamImg/km77.jpg'),
|
||||
);
|
||||
assert.equal(await followed.text(), 'frame');
|
||||
});
|
||||
|
||||
test('A22 ambient cards refresh on the highway cadence, not the default', () => {
|
||||
const source = a22WebcamToSource(item());
|
||||
assert.equal(staticFrameRefreshMs(source), 3 * 60 * 1000);
|
||||
});
|
||||
@@ -42,6 +42,9 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
// Autobrennero (A22) refreshes its stills about once a minute (Last-Modified
|
||||
// tracks the minute); 3 min matches the other highway packs.
|
||||
'autostrada del brennero': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -343,6 +343,13 @@ export const DATA_CREDITS = [
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'a22-cctv',
|
||||
html:
|
||||
'Motorway webcams (A22 Brennero–Modena): images © ' +
|
||||
'<a href="https://www.autobrennero.it/" target="_blank" rel="noopener">Autostrada del Brennero S.p.A.</a>, ' +
|
||||
'camera list via the <a href="https://opendatahub.com/" target="_blank" rel="noopener">Open Data Hub</a> (NOI Techpark)',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import dns from 'node:dns/promises';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||
import {
|
||||
describeFetchError,
|
||||
fetchFirms,
|
||||
fetchIpv4,
|
||||
isAddressSelectionFailure,
|
||||
} from '../../server/providers/firms.js';
|
||||
|
||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||
const start = config.indexOf(' async function refreshUpstream(key) {');
|
||||
@@ -17,8 +28,8 @@ const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
// Inject only its upstream, clock and filter dependencies; keep its aggregation
|
||||
// and source-status code intact, including failures while consuming records.
|
||||
function createRefresh(fetchSource, filter = filterTrailing24h) {
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} });
|
||||
return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'describeFetchError', 'Date', 'console',
|
||||
`return (${refreshSource});`)(SOURCES, fetchSource, filter, describeFetchError, { now: () => NOW }, { warn() {} });
|
||||
}
|
||||
|
||||
test('FIRMS retains large sources in order and filters expired rows', async () => {
|
||||
@@ -78,3 +89,216 @@ test('FIRMS distinguishes all-source failure from successful empty sources', asy
|
||||
assert.deepEqual(result.fires, []);
|
||||
assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true })));
|
||||
});
|
||||
|
||||
// #68: Node's network-family auto-selection gives each address a short attempt
|
||||
// timeout, abandons a slow IPv4 handshake for an unreachable IPv6 address, and
|
||||
// reports only `fetch failed`. This is the shape Node 24 throws for it.
|
||||
function addressSelectionFailure() {
|
||||
const v4 = Object.assign(new Error('connect ETIMEDOUT 198.118.194.34:443'), { code: 'ETIMEDOUT' });
|
||||
const v6 = Object.assign(new Error('connect ENETUNREACH 2001:4d0:241a:40c0::34:443'), { code: 'ENETUNREACH' });
|
||||
const cause = Object.assign(new AggregateError([v4, v6]), { code: 'ETIMEDOUT' });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
// Node 26's fetch wraps the same AggregateError in a ConnectTimeoutError.
|
||||
function wrappedAddressSelectionFailure() {
|
||||
const inner = addressSelectionFailure().cause;
|
||||
const cause = Object.assign(new Error('Connect Timeout Error (attempted addresses: 198.118.194.34:443, 2001:4d0:241a:40c0::34:443, timeout: 10000ms)'),
|
||||
{ name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT', cause: inner });
|
||||
return new TypeError('fetch failed', { cause });
|
||||
}
|
||||
const URL_WITH_KEY = 'https://firms.modaps.eosdis.nasa.gov/api/area/csv/SECRET/VIIRS_SNPP_NRT/world/2';
|
||||
|
||||
test('FIRMS failures log the socket cause, never the keyed URL', () => {
|
||||
const described = describeFetchError(addressSelectionFailure());
|
||||
assert.equal(described,
|
||||
'fetch failed (connect ETIMEDOUT 198.118.194.34:443; connect ENETUNREACH 2001:4d0:241a:40c0::34:443)');
|
||||
assert.ok(!described.includes('SECRET'));
|
||||
assert.equal(describeFetchError(wrappedAddressSelectionFailure()), described, 'Node 26 reports the same attempts');
|
||||
const single = new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) });
|
||||
assert.equal(describeFetchError(single), 'fetch failed (getaddrinfo ENOTFOUND x)');
|
||||
assert.equal(describeFetchError(new Error('HTTP 503')), 'HTTP 503');
|
||||
});
|
||||
|
||||
test('FIRMS retries once over IPv4 when every address fails to connect', async () => {
|
||||
for (const shape of [addressSelectionFailure, wrappedAddressSelectionFailure]) {
|
||||
const retried = [];
|
||||
await fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw shape(); },
|
||||
fetchIpv4Impl: async (url) => { retried.push(url); return new Response('csv'); },
|
||||
warn() {},
|
||||
});
|
||||
assert.deepEqual(retried, [URL_WITH_KEY], `${shape.name} is retried`);
|
||||
}
|
||||
const signal = new AbortController().signal;
|
||||
const ok = new Response('csv');
|
||||
const retried = [];
|
||||
const warnings = [];
|
||||
const response = await fetchFirms(URL_WITH_KEY, { signal }, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async (url, options) => { retried.push([url, options]); return ok; },
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
assert.equal(response, ok);
|
||||
assert.deepEqual(retried, [[URL_WITH_KEY, { signal }]], 'one retry, same URL and deadline');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /ETIMEDOUT 198\.118\.194\.34:443; .*ENETUNREACH .* retrying over IPv4/);
|
||||
assert.ok(!warnings[0].includes('SECRET'));
|
||||
|
||||
const failure = new Error('connect ETIMEDOUT 198.118.194.34:443');
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, {
|
||||
fetchImpl: async () => { throw addressSelectionFailure(); },
|
||||
fetchIpv4Impl: async () => { throw failure; },
|
||||
warn() {},
|
||||
}), (err) => err === failure, 'a failed retry reports its own error');
|
||||
});
|
||||
|
||||
test('FIRMS does not retry anything but an address-selection failure', async () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
fetchImpl,
|
||||
fetchIpv4Impl: async () => assert.fail('must not retry'),
|
||||
warn: () => assert.fail('must not warn'),
|
||||
});
|
||||
const serverError = new Response('down', { status: 503 });
|
||||
assert.equal(await fetchFirms(URL_WITH_KEY, {}, deps(async () => serverError)), serverError);
|
||||
for (const thrown of [
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND x'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: new AggregateError([]) }),
|
||||
new TypeError('fetch failed'),
|
||||
new DOMException('The operation was aborted due to timeout', 'TimeoutError'),
|
||||
]) {
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, {}, deps(async () => { throw thrown; })), (err) => err === thrown);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const aborted = addressSelectionFailure();
|
||||
await assert.rejects(fetchFirms(URL_WITH_KEY, { signal: controller.signal },
|
||||
deps(async () => { throw aborted; })), (err) => err === aborted, 'an expired deadline is not retried');
|
||||
});
|
||||
|
||||
test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
||||
// `localhost` resolves to both ::1 and 127.0.0.1. Listen on both with the
|
||||
// same port and record which one the retry reaches; the TLS handshake then
|
||||
// fails against a plain socket, which is fine — only the connection matters.
|
||||
const reached = [];
|
||||
const listen = (host, port) => new Promise((resolve, reject) => {
|
||||
const server = net.createServer((socket) => { reached.push(host); socket.destroy(); });
|
||||
server.once('error', reject).listen(port, host, () => resolve(server));
|
||||
});
|
||||
const v4 = await listen('127.0.0.1', 0);
|
||||
t.after(() => v4.close());
|
||||
const { port } = v4.address();
|
||||
let v6;
|
||||
try {
|
||||
v6 = await listen('::1', port);
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
t.after(() => v6.close());
|
||||
const order = await dns.lookup('localhost', { all: true });
|
||||
if (order[0]?.family !== 6) {
|
||||
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||
return;
|
||||
}
|
||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||
assert.deepEqual(reached, ['127.0.0.1']);
|
||||
});
|
||||
|
||||
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||
// asynchronously as `https.request` does, outside the caller's promise.
|
||||
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||
// the race at once instead of crashing the test runner.
|
||||
function mockIpv4Status(t, status, statusMessage = '') {
|
||||
let crash;
|
||||
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||
const onUncaught = (error) =>
|
||||
crash(new Error(`uncaught exception: ${error.message}`));
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||
const request = new EventEmitter();
|
||||
request.end = () =>
|
||||
setImmediate(() =>
|
||||
callback(
|
||||
Object.assign(Readable.from([]), {
|
||||
statusCode: status,
|
||||
statusMessage,
|
||||
headers: { 'content-type': 'text/csv' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return request;
|
||||
});
|
||||
return crashed;
|
||||
}
|
||||
|
||||
for (const status of [204, 205, 304]) {
|
||||
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.body, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [600, 999]) {
|
||||
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||
const crashed = mockIpv4Status(t, status);
|
||||
await assert.rejects(
|
||||
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||
new RegExp(`unsupported status ${status}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||
});
|
||||
|
||||
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||
// a Node or undici change that stops matching fails here instead of silently
|
||||
// disabling the retry. A closed port on `localhost` refuses both addresses.
|
||||
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||
const probe = net.createServer();
|
||||
try {
|
||||
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||
} catch {
|
||||
t.skip('no IPv6 loopback on this host');
|
||||
return;
|
||||
}
|
||||
const { port } = probe.address();
|
||||
await new Promise((resolve) => probe.close(resolve));
|
||||
const v4 = net.createServer();
|
||||
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||
await new Promise((resolve) => v4.close(resolve));
|
||||
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||
assert.ok(err, 'a closed port refuses the connection');
|
||||
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||
});
|
||||
|
||||
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||
const calls = [];
|
||||
const response = new Response('latitude,longitude\n');
|
||||
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||
() => [],
|
||||
1024,
|
||||
AbortSignal,
|
||||
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||
);
|
||||
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0][0], 'fetchFirms');
|
||||
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||
});
|
||||
@@ -372,6 +372,10 @@
|
||||
"one": "{count} Kamera geladen · CCTV aktivieren",
|
||||
"other": "{count} Kameras geladen · CCTV aktivieren"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} weitere Kamera außerhalb dieser Ansicht · verschieben oder herauszoomen",
|
||||
"other": "{count} weitere Kameras außerhalb dieser Ansicht · verschieben oder herauszoomen"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "CCTV aktivieren, um Kreuzungskameras zu laden",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.",
|
||||
"No summary available.": "Keine Zusammenfassung verfügbar.",
|
||||
|
||||
@@ -369,6 +369,11 @@
|
||||
"many": "{count} de cámaras cargadas · activa CCTV para activarlas",
|
||||
"other": "{count} cámaras cargadas · activa CCTV para activarlas"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} cámara más fuera de esta vista · desplaza o aleja",
|
||||
"many": "{count} de cámaras más fuera de esta vista · desplaza o aleja",
|
||||
"other": "{count} cámaras más fuera de esta vista · desplaza o aleja"
|
||||
},
|
||||
"No summary available.": "No hay resumen disponible.",
|
||||
"Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal",
|
||||
"Detection overlay: {mode}": "Superposición de detección: {mode}",
|
||||
|
||||
@@ -1625,6 +1625,11 @@
|
||||
"many": "{count} de caméras chargées · activer CCTV pour les utiliser",
|
||||
"other": "{count} caméras chargées · activer CCTV pour les utiliser"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "{count} autre caméra hors de cette vue · déplacer ou dézoomer",
|
||||
"many": "{count} d'autres caméras hors de cette vue · déplacer ou dézoomer",
|
||||
"other": "{count} autres caméras hors de cette vue · déplacer ou dézoomer"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Activez CCTV pour charger les carrefours équipés de caméras",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.",
|
||||
"No summary available.": "Aucun résumé disponible.",
|
||||
|
||||
@@ -1625,6 +1625,11 @@
|
||||
"many": "{count} di telecamere caricate · attiva CCTV per attivarle",
|
||||
"other": "{count} telecamere caricate · attiva CCTV per attivarle"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom",
|
||||
"many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom",
|
||||
"other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.",
|
||||
"No summary available.": "Nessun riepilogo disponibile.",
|
||||
|
||||
@@ -1579,6 +1579,9 @@
|
||||
"{count} cameras loaded · enable CCTV to activate": {
|
||||
"other": "カメラ {count}台を読込済み · CCTVを有効にして使用"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "この表示範囲外にさらに {count}台のカメラ · 移動またはズームアウト"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。",
|
||||
"No summary available.": "利用できる概要はありません。",
|
||||
|
||||
@@ -1625,6 +1625,11 @@
|
||||
"many": "{count} de câmeras carregadas · ative o CCTV para usar",
|
||||
"other": "{count} câmeras carregadas · ative o CCTV para usar"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom",
|
||||
"many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom",
|
||||
"other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.",
|
||||
"No summary available.": "Nenhum resumo disponível.",
|
||||
|
||||
@@ -342,6 +342,12 @@
|
||||
"many": "Загружено {count} камер · включите CCTV, чтобы активировать",
|
||||
"other": "Загружено {count} камеры · включите CCTV, чтобы активировать"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите",
|
||||
"few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите",
|
||||
"many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите",
|
||||
"other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.",
|
||||
"No summary available.": "Сводка недоступна.",
|
||||
|
||||
@@ -1647,6 +1647,12 @@
|
||||
"many": "{count} камер завантажено · увімкніть CCTV, щоб активувати",
|
||||
"other": "{count} камери завантажено · увімкніть CCTV, щоб активувати"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"one": "Ще {count} камера поза цим видом · посуньте або віддаліть",
|
||||
"few": "Ще {count} камери поза цим видом · посуньте або віддаліть",
|
||||
"many": "Ще {count} камер поза цим видом · посуньте або віддаліть",
|
||||
"other": "Ще {count} камери поза цим видом · посуньте або віддаліть"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.",
|
||||
"No summary available.": "Зведення недоступне.",
|
||||
|
||||
@@ -1553,6 +1553,9 @@
|
||||
"{count} cameras loaded · enable CCTV to activate": {
|
||||
"other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此视图外还有 {count} 个摄像头 · 平移或缩小"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。",
|
||||
"No summary available.": "暂无摘要。",
|
||||
|
||||
@@ -1543,6 +1543,9 @@
|
||||
"{count} cameras loaded · enable CCTV to activate": {
|
||||
"other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活"
|
||||
},
|
||||
"{count} more cameras outside this view · pan or zoom out": {
|
||||
"other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小"
|
||||
},
|
||||
"Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭",
|
||||
"Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。",
|
||||
"No summary available.": "暫無摘要。",
|
||||
|
||||
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
|
||||
const candidates = [];
|
||||
const screenById = new Map();
|
||||
for (const record of layerState._records) {
|
||||
// Cards go only on the icons the view pass shows (refreshHorizonCulling).
|
||||
for (const record of layerState._viewRecords) {
|
||||
const id = record.camera.id;
|
||||
if (id === activeId || !record.position) continue;
|
||||
let inView = false;
|
||||
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
nowMs: Date.now(),
|
||||
cardLimit: budgets.cardLimit,
|
||||
});
|
||||
layerState._cardIds = new Set(retention.keepIds);
|
||||
// Grace keeps a card briefly after it loses selection, but never on a
|
||||
// camera whose icon the view pass has hidden.
|
||||
const shownIds = new Set(
|
||||
layerState._viewRecords.map((record) => record.camera.id),
|
||||
);
|
||||
layerState._cardIds = new Set(
|
||||
retention.keepIds.filter((id) => shownIds.has(id)),
|
||||
);
|
||||
layerState._cardGraceState = retention.graceState;
|
||||
|
||||
// Bounded thumbnail LRU: live cards (grace included) never lose their
|
||||
@@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
|
||||
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
|
||||
parts.hover.clearHoverCard();
|
||||
layerState._cameraMoving = false;
|
||||
layerState._cardIds = new Set();
|
||||
layerState._cardGraceState = new Map();
|
||||
layerState._cardFrameSlots = new Map();
|
||||
|
||||
Vendored
+8
-9
@@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._enabled || layerState._records.length === 0) return [];
|
||||
// The cameras the view pass shows, not icons it has hidden.
|
||||
const records = layerState._viewRecords;
|
||||
if (!layerState._enabled || records.length === 0) return [];
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: layerState._records.length;
|
||||
: records.length;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(layerState._records.length / maxCount),
|
||||
);
|
||||
const stride = Math.max(1, Math.ceil(records.length / maxCount));
|
||||
const start = seed % stride;
|
||||
|
||||
const objects = [];
|
||||
for (let i = start; i < layerState._records.length; i += stride) {
|
||||
const camera = layerState._records[i].camera;
|
||||
for (let i = start; i < records.length; i += stride) {
|
||||
const camera = records[i].camera;
|
||||
objects.push({
|
||||
position: layerState._records[i].position,
|
||||
position: records[i].position,
|
||||
sourceId: camera.id,
|
||||
// Short semantic code where the id is opaque (see cameraDisplayCode).
|
||||
id: `CAM-${camera.code || camera.id}`,
|
||||
|
||||
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
|
||||
function coverageNeighborCount(targetRecord) {
|
||||
if (!targetRecord) return 0;
|
||||
// The summary asks on every notification; scan the catalog only when the
|
||||
// camera, its pose or the catalog changed.
|
||||
const { id, lat, lon, rangeM } = targetRecord.camera;
|
||||
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
|
||||
const cached = layerState._neighborCountCache;
|
||||
if (cached?.key === key && cached.records === layerState._records)
|
||||
return cached.count;
|
||||
let count = 0;
|
||||
for (const record of layerState._records) {
|
||||
if (record === targetRecord) continue;
|
||||
@@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) {
|
||||
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
|
||||
if (dKm <= overlapKm) count++;
|
||||
}
|
||||
layerState._neighborCountCache = {
|
||||
key,
|
||||
records: layerState._records,
|
||||
count,
|
||||
};
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
GEO_TRACKING_BATCH_DELAY_MS,
|
||||
GEO_LOAD_BATCH_SIZE,
|
||||
GEO_LOAD_BATCH_DELAY_MS,
|
||||
VIEW_GEOMETRY_ATTEMPTS,
|
||||
VIEW_GEOMETRY_RETRY_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createGeometryQueue({
|
||||
@@ -25,6 +27,8 @@ export function createGeometryQueue({
|
||||
clearTimeout(layerState._geoQueueTimer);
|
||||
layerState._geoQueueTimer = 0;
|
||||
}
|
||||
clearTimeout(layerState._viewGeometryRetryTimer);
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
layerState._geoQueue = [];
|
||||
layerState._geoProgressNotifier = null;
|
||||
if (clearProgress) {
|
||||
@@ -223,6 +227,7 @@ export function createGeometryQueue({
|
||||
err?.message || err,
|
||||
);
|
||||
}
|
||||
noteViewAttempt(record);
|
||||
if (
|
||||
layerState._geoLoading &&
|
||||
layerState._geoLoadDone < layerState._geoLoadTotal
|
||||
@@ -236,14 +241,15 @@ export function createGeometryQueue({
|
||||
layerState._geoLoading = false;
|
||||
if (wasInitialLoad) {
|
||||
layerState._geoLoadDone = layerState._geoLoadTotal;
|
||||
if (layerState._enabled) {
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// Geometry refinement may have replaced record.position objects — the
|
||||
// one-shot drain completion re-anchors the card entries (event-driven,
|
||||
// not a per-frame or timer pass).
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
|
||||
}
|
||||
// Geometry refinement may have replaced record.position objects — every
|
||||
// drain's completion re-anchors the card entries (event-driven, not a
|
||||
// per-frame or timer pass).
|
||||
// While the camera moves, moveEnd reselects them instead.
|
||||
if (layerState._enabled && !layerState._cameraMoving)
|
||||
parts.cards.refreshAmbientCards();
|
||||
scheduleViewGeometryRetry();
|
||||
// Completion is never coalesced: subscribers must observe the final
|
||||
// loading state even if the last progress tick just happened.
|
||||
layerState._geoProgressNotifier?.finish();
|
||||
@@ -261,16 +267,17 @@ export function createGeometryQueue({
|
||||
|
||||
/**
|
||||
* Appends records to the geometry queue (no progress tracking) and starts
|
||||
* the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion
|
||||
* pass (records left `!groundResolved` by an enable-time drain that ran while
|
||||
* tiles were still streaming) so it shares the same stagger machinery as the
|
||||
* initial load. Fires at most once per enable — never on a recurring timer.
|
||||
* the batch timer if idle. Used by update()'s one-shot tiles-ready
|
||||
* completion pass and by enqueueViewGeometry, so both share the initial
|
||||
* load's stagger machinery. Never driven by a recurring timer.
|
||||
* @param {Object[]} records - Camera records needing geometry refresh.
|
||||
*/
|
||||
|
||||
function enqueueGeometryRefresh(records) {
|
||||
const queued = new Set(layerState._geoQueue);
|
||||
for (const record of records) {
|
||||
if (!layerState._geoQueue.includes(record)) {
|
||||
if (!queued.has(record)) {
|
||||
queued.add(record);
|
||||
layerState._geoQueue.push(record);
|
||||
}
|
||||
}
|
||||
@@ -283,10 +290,10 @@ export function createGeometryQueue({
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the initial staggered load: orders all records active-camera-first,
|
||||
* then by distance from the current viewer position (nearest first, so
|
||||
* cameras likely in view refine before off-screen ones), and exposes
|
||||
* loaded/total progress through uiState()/getStats() while running.
|
||||
* Starts the initial staggered load over the geometry working set: active
|
||||
* camera first, then by distance from the current viewer position (nearest
|
||||
* first), and exposes loaded/total progress through uiState()/getStats()
|
||||
* while running. Cameras outside the view refine when they come into it.
|
||||
*/
|
||||
|
||||
function startGeometryLoadQueue() {
|
||||
@@ -303,7 +310,7 @@ export function createGeometryQueue({
|
||||
const refLon = carto
|
||||
? Cesium.Math.toDegrees(carto.longitude)
|
||||
: (active?.camera.lon ?? 0);
|
||||
const pending = layerState._records
|
||||
const pending = geometryWorkingSet()
|
||||
.filter((record) => record !== active)
|
||||
.map((record) => ({
|
||||
record,
|
||||
@@ -325,7 +332,134 @@ export function createGeometryQueue({
|
||||
);
|
||||
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
|
||||
}
|
||||
/**
|
||||
* The records whose geometry the view needs: the shown icons, the active
|
||||
* camera and the coverage cohort drawn around it.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
|
||||
function geometryWorkingSet() {
|
||||
const records = new Set(layerState._viewRecords);
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (active) records.add(active);
|
||||
for (const id of layerState._coverageVisibleIds) {
|
||||
const record = layerState._recordById.get(id);
|
||||
if (record) records.add(record);
|
||||
}
|
||||
return [...records];
|
||||
}
|
||||
|
||||
/** The regime key a view attempt counts against: interim while tiles stream. */
|
||||
function viewAttemptKey() {
|
||||
const regime = parts.ground.currentSurfaceRegime();
|
||||
const tilesReady =
|
||||
regime !== 'google-3d' || parts.model.projectionTilesReady();
|
||||
return { key: tilesReady ? regime : `${regime}:interim`, tilesReady };
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts a processed geometry attempt against the current regime key; each
|
||||
* key keeps its own count, so tile streaming going on and off does not
|
||||
* reset the ready-tiles budget.
|
||||
*/
|
||||
function noteViewAttempt(record) {
|
||||
const { key } = viewAttemptKey();
|
||||
record.viewAttempts ??= {};
|
||||
record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1;
|
||||
record.viewAttemptAt = performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record may be attempted again under `key`. After a first
|
||||
* unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its
|
||||
* elevation cell can warm, rather than going to the next motion tick.
|
||||
*/
|
||||
/** Whether a record still has an attempt left under the current key. */
|
||||
function withinViewBudget(record) {
|
||||
const { key } = viewAttemptKey();
|
||||
return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
|
||||
}
|
||||
|
||||
function attemptsLeft(record, key) {
|
||||
const attempts = record.viewAttempts?.[key] || 0;
|
||||
if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false;
|
||||
return (
|
||||
attempts === 0 ||
|
||||
performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a drain, gives working-set cameras it left ungrounded one more
|
||||
* attempt once their elevation cells have had time to warm. The timer is
|
||||
* set for the earliest such camera and re-arms for the rest when it fires.
|
||||
*/
|
||||
function scheduleViewGeometryRetry() {
|
||||
if (!layerState._enabled || layerState._viewGeometryRetryTimer) return;
|
||||
const { key } = viewAttemptKey();
|
||||
const active = parts.selection.getActiveRecord();
|
||||
let due = Infinity;
|
||||
for (const record of geometryWorkingSet()) {
|
||||
const attempts = record.viewAttempts?.[key] || 0;
|
||||
if (
|
||||
record === active ||
|
||||
attempts === 0 ||
|
||||
attempts >= VIEW_GEOMETRY_ATTEMPTS ||
|
||||
!parts.ground.needsGeometry(record)
|
||||
)
|
||||
continue;
|
||||
due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS);
|
||||
}
|
||||
if (due === Infinity) return;
|
||||
layerState._viewGeometryRetryTimer = setTimeout(
|
||||
() => {
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
enqueueViewGeometry();
|
||||
// A drain re-arms on completion; with none running, the cameras still
|
||||
// cooling down need their own timer.
|
||||
if (!layerState._geoQueueTimer) scheduleViewGeometryRetry();
|
||||
},
|
||||
Math.max(0, due - performance.now()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues working-set cameras the current surface has not grounded yet,
|
||||
* after a camera move brings new ones into view. Attempts are counted when
|
||||
* a record is processed, not queued, so a drain cut short by disable() is
|
||||
* not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime,
|
||||
* spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses
|
||||
* (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are
|
||||
* interim, and the update() completion pass is re-armed to retry these
|
||||
* records when they land. The active camera is left to selection, which
|
||||
* owns its re-sampling.
|
||||
*/
|
||||
|
||||
function enqueueViewGeometry() {
|
||||
if (!layerState._enabled) return;
|
||||
const { key, tilesReady } = viewAttemptKey();
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const pending = [];
|
||||
let coolingDown = false;
|
||||
for (const record of geometryWorkingSet()) {
|
||||
if (record === active || !parts.ground.needsGeometry(record)) continue;
|
||||
if (!attemptsLeft(record, key)) {
|
||||
coolingDown ||=
|
||||
(record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
|
||||
continue;
|
||||
}
|
||||
pending.push(record);
|
||||
}
|
||||
// A camera back in view during its cooldown still needs its retry timer.
|
||||
if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry();
|
||||
if (!pending.length) return;
|
||||
if (!tilesReady) layerState._tilesReadyReenqueued = false;
|
||||
enqueueGeometryRefresh(pending);
|
||||
}
|
||||
return {
|
||||
enqueueViewGeometry,
|
||||
withinViewBudget,
|
||||
geometryWorkingSet,
|
||||
stopGeometryLoadQueue,
|
||||
createGeometryProgressNotifier,
|
||||
processCctvGeometryQueueBatch,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SUPPORT_KEYS,
|
||||
planeSupportPoints,
|
||||
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
return record?.groundResolved?.[regime] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True until the record has geometry grounded for the regime. Cameras are
|
||||
* refined only once they enter the view, so many never have geometry.
|
||||
*/
|
||||
function needsGeometry(record, regime = currentSurfaceRegime()) {
|
||||
return !record?.frustumGeometry || !isGroundResolved(record, regime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground altitude used for pure geometry recomputes: the given regime's
|
||||
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
|
||||
@@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
|
||||
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
|
||||
* queries:
|
||||
* - records without geometry yet: only the icon moves onto the prior;
|
||||
* the frustum is built when the camera enters the view.
|
||||
* - terrain-globe regime: the prior IS the resolution → re-run the
|
||||
* resolution (updateRecordGeometry latches it) for every record.
|
||||
* resolution (updateRecordGeometry latches it) for every record with
|
||||
* geometry.
|
||||
* - google-3d regime: records still awaiting a shared floor move from the
|
||||
* catalog fallback onto the exact prior; records already holding a shared
|
||||
* mesh/DEM floor keep it untouched.
|
||||
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
function applyLateGroundPriors(records, priors) {
|
||||
if (!Array.isArray(records) || !Array.isArray(priors)) return;
|
||||
let applied = 0;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < records.length && i < priors.length; i++) {
|
||||
const record = records[i];
|
||||
const prior = priors[i];
|
||||
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
record.camera.absoluteHeightM =
|
||||
prior.ellipsoid + record.camera.mountHeightM;
|
||||
}
|
||||
// Cameras without geometry only move their icon onto the prior, so the
|
||||
// view pass projects the right point; their frustums are built when
|
||||
// they enter the view, as recomputing the whole catalog here would
|
||||
// stall a large one.
|
||||
if (!record.frustumGeometry) {
|
||||
if (record.position) {
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.camera.lon,
|
||||
record.camera.lat,
|
||||
record.camera.absoluteHeightM,
|
||||
undefined,
|
||||
record.position,
|
||||
);
|
||||
if (record.billboard) record.billboard.position = record.position;
|
||||
moved += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const regime = currentSurfaceRegime();
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — re-latch onto the fresh value.
|
||||
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
if (applied) parts.presentation.notifyListeners();
|
||||
if (moved && parts.rendering.refreshHorizonCulling())
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
if (applied || moved) parts.presentation.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
|
||||
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
|
||||
* no-ops):
|
||||
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
|
||||
* 1. every record that has geometry recomputes IMMEDIATELY from the new regime's
|
||||
* resolution — cached sample if that regime has one, else the Re:Earth
|
||||
* prior (never blank, zero scene queries);
|
||||
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
|
||||
@@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
layerState._lastAppliedRegime = regime;
|
||||
|
||||
for (const record of layerState._records) {
|
||||
// Only cameras that already have geometry are rewritten; the rest are
|
||||
// grounded for the new regime when they enter the view.
|
||||
if (!record.frustumGeometry) continue;
|
||||
if (regime === 'terrain-globe') {
|
||||
// Prior IS the resolution — latch it (zero scene queries).
|
||||
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
|
||||
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
|
||||
resolveFootprintGround,
|
||||
groundPriorAltFor,
|
||||
isGroundResolved,
|
||||
needsGeometry,
|
||||
groundAltFor,
|
||||
rearmGroundResolution,
|
||||
resolveCommittedGroundAnchor,
|
||||
|
||||
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
|
||||
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
|
||||
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
|
||||
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
|
||||
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
|
||||
_viewRecordIdsForTest: () =>
|
||||
state._viewRecords.map((record) => record.camera.id),
|
||||
clearProbeClampOnDeactivation:
|
||||
parts.geometry.clearProbeClampOnDeactivation,
|
||||
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
|
||||
|
||||
@@ -32,13 +32,22 @@ export function createIngestion({
|
||||
// projectionTilesReady() is false while a (hidden) Google tileset
|
||||
// exists, so this latch effectively fires for the google-3d regime —
|
||||
// terrain-globe records resolve from the prior in their drain pass.
|
||||
const unresolved = layerState._records.filter(
|
||||
(record) => !parts.ground.isGroundResolved(record),
|
||||
);
|
||||
const unresolved = parts.geometryQueue
|
||||
.geometryWorkingSet()
|
||||
.filter(
|
||||
(record) =>
|
||||
!parts.ground.isGroundResolved(record) &&
|
||||
parts.geometryQueue.withinViewBudget(record),
|
||||
);
|
||||
if (unresolved.length)
|
||||
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
|
||||
}
|
||||
await parts.health.syncHealthState();
|
||||
// New health can change which camera wins a crowded cell.
|
||||
if (layerState._enabled && parts.rendering.refreshHorizonCulling()) {
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
parts.navigation.maybeAutoHop(now);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
@@ -7,8 +7,14 @@ import {
|
||||
CAMERA_ICON,
|
||||
IDLE_CAMERA_COLOR,
|
||||
CALIBRATION_RANGE_FLOOR_M,
|
||||
VIEW_MOTION_REFRESH_MS,
|
||||
VIEW_MOTION_MIN_MOVE_FRACTION,
|
||||
VIEW_MOTION_MIN_TURN_DEG,
|
||||
VIEW_MOTION_COST_FACTOR,
|
||||
} from './policy.js';
|
||||
|
||||
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -22,15 +28,94 @@ export function createLifecycle({
|
||||
const { onFocusTargetAppear } = services.focus;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* The settled-view pass: thin the icons, queue the newly visible geometry,
|
||||
* reselect cards, and tell subscribers (the camera picker) when the shown
|
||||
* set changed since they were last told, including by motion passes.
|
||||
*/
|
||||
function settleView() {
|
||||
const changed = parts.rendering.refreshHorizonCulling();
|
||||
noteViewPose();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
if (changed || layerState._viewUnpublished) {
|
||||
layerState._viewUnpublished = false;
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the camera pose the last view pass ran at. */
|
||||
function noteViewPose() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
if (!camera?.positionWC) return;
|
||||
layerState._viewPose = {
|
||||
position: Cesium.Cartesian3.clone(camera.positionWC),
|
||||
direction:
|
||||
camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera moved or turned enough since the last view pass to
|
||||
* change what is on screen. A camera that tracks or flies without settling
|
||||
* would otherwise rerun the full pass on every tick.
|
||||
*/
|
||||
function viewPoseChanged() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
const pose = layerState._viewPose;
|
||||
if (!pose || !camera?.positionWC) return true;
|
||||
const height = Math.max(1, camera.positionCartographic?.height ?? 0);
|
||||
const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position);
|
||||
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
|
||||
if (!pose.direction || !camera.directionWC) return false;
|
||||
return (
|
||||
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
|
||||
);
|
||||
}
|
||||
|
||||
function viewMotionTick() {
|
||||
if (!layerState._enabled) return;
|
||||
const now = performance.now();
|
||||
const pass = layerState._viewMotionPass;
|
||||
if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return;
|
||||
if (!viewPoseChanged()) return;
|
||||
if (parts.rendering.refreshHorizonCulling())
|
||||
layerState._viewUnpublished = true;
|
||||
noteViewPose();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
layerState._viewMotionPass = { at: now, ms: performance.now() - now };
|
||||
}
|
||||
|
||||
function startViewMotionTimer() {
|
||||
if (layerState._viewMotionTimer) return;
|
||||
layerState._viewMotionTimer = setInterval(
|
||||
viewMotionTick,
|
||||
VIEW_MOTION_REFRESH_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function stopViewMotionTimer() {
|
||||
clearInterval(layerState._viewMotionTimer);
|
||||
layerState._viewMotionTimer = 0;
|
||||
}
|
||||
|
||||
/** Resets all module-scoped runtime state to initial values. */
|
||||
|
||||
function clearRuntimeState() {
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
stopViewMotionTimer();
|
||||
// Idempotent — also covers a re-init without a prior destroy().
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.projection.clearProjectionOverlay();
|
||||
layerState._records = [];
|
||||
layerState._recordById = new Map();
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
layerState._neighborCountCache = null;
|
||||
layerState._viewUnpublished = false;
|
||||
layerState._viewPose = null;
|
||||
layerState._viewMotionPass = null;
|
||||
layerState._healthById = new Map();
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
@@ -71,6 +156,13 @@ export function createLifecycle({
|
||||
layerState._calibrationById = parts.calibration.loadCalibrationStore();
|
||||
|
||||
layerState._billboards = new Cesium.BillboardCollection();
|
||||
// Cesium switches a property's buffer-usage hint whenever it starts or
|
||||
// stops changing from one frame to the next, and every switch rewrites
|
||||
// the vertex data of every billboard. The view pass toggles `show` on a
|
||||
// few icons of a large catalog at a time, so on a 33K catalog each pass
|
||||
// cost two full rewrites. With the hint fixed, Cesium uploads only the
|
||||
// icons that changed.
|
||||
layerState._billboards.computeNewBuffersUsage = () => false;
|
||||
layerState._viewer.scene.primitives.add(layerState._billboards);
|
||||
registerSpriteCollection('cctv', layerState._billboards);
|
||||
|
||||
@@ -243,28 +335,76 @@ export function createLifecycle({
|
||||
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
|
||||
// the billboards' always-on-top depth setting) + one initial pass so the
|
||||
// first paint is already culled.
|
||||
// A re-init against a new viewer moves the move listeners to its camera.
|
||||
if (
|
||||
layerState._viewListenerCamera &&
|
||||
layerState._viewListenerCamera !== layerState._viewer.camera
|
||||
) {
|
||||
const previous = layerState._viewListenerCamera;
|
||||
if (layerState._horizonCullListener)
|
||||
previous.moveEnd?.removeEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
if (layerState._moveStartListener)
|
||||
previous.moveStart?.removeEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
layerState._horizonCullListener = null;
|
||||
layerState._moveStartListener = null;
|
||||
layerState._cameraMoving = false;
|
||||
}
|
||||
layerState._viewListenerCamera = layerState._viewer.camera;
|
||||
if (!layerState._horizonCullListener) {
|
||||
// Ambient cards piggyback the same settle event: moveEnd-driven
|
||||
// reselection only, never per frame (refreshAmbientCards no-ops while
|
||||
// the layer is disabled).
|
||||
layerState._horizonCullListener = () => {
|
||||
layerState._cameraMoving = false;
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.cards.refreshAmbientCards();
|
||||
stopViewMotionTimer();
|
||||
settleView();
|
||||
};
|
||||
layerState._viewer.camera.moveEnd.addEventListener(
|
||||
layerState._horizonCullListener,
|
||||
);
|
||||
}
|
||||
if (!layerState._moveStartListener) {
|
||||
// Item B: hover picking pauses while the camera is in motion.
|
||||
// Item B: hover picking pauses while the camera is in motion. A long
|
||||
// move (cockpit flight, a drag) never settles, so the view pass also
|
||||
// reruns on an interval until it does.
|
||||
layerState._moveStartListener = () => {
|
||||
layerState._cameraMoving = true;
|
||||
// enable() starts it for a move already under way.
|
||||
if (layerState._enabled) startViewMotionTimer();
|
||||
};
|
||||
layerState._viewer.camera.moveStart.addEventListener(
|
||||
layerState._moveStartListener,
|
||||
);
|
||||
}
|
||||
// A resize or rotation changes what is on screen without moving the
|
||||
// camera.
|
||||
const canvas = layerState._viewer.scene?.canvas;
|
||||
if (layerState._viewResizeCanvas !== canvas) {
|
||||
// A re-init against a new viewer observes its canvas instead.
|
||||
layerState._viewResizeObserver?.disconnect();
|
||||
layerState._viewResizeObserver = null;
|
||||
layerState._viewResizeCanvas = null;
|
||||
}
|
||||
if (
|
||||
!layerState._viewResizeObserver &&
|
||||
typeof ResizeObserver === 'function' &&
|
||||
canvas
|
||||
) {
|
||||
let frame = 0;
|
||||
layerState._viewResizeObserver = new ResizeObserver(() => {
|
||||
if (frame || !layerState._enabled) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
if (layerState._enabled) settleView();
|
||||
});
|
||||
});
|
||||
layerState._viewResizeObserver.observe(canvas);
|
||||
layerState._viewResizeCanvas = canvas;
|
||||
}
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
@@ -325,6 +465,12 @@ export function createLifecycle({
|
||||
);
|
||||
|
||||
await parts.health.syncHealthState(true);
|
||||
// Health ranks crowded cells: thin again now that it has arrived (a
|
||||
// no-op unless enable() ran while health was loading).
|
||||
if (parts.rendering.refreshHorizonCulling()) {
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
}
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.presentation.notifyListeners();
|
||||
restoreSpriteOrder(layerState._viewer);
|
||||
@@ -370,6 +516,9 @@ export function createLifecycle({
|
||||
parts.projection.ensureProjectionRuntime(activeRecord);
|
||||
parts.frames.refreshProjectionImage(activeRecord, true);
|
||||
}
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
// Re-enabled mid-flight: moveStart has already fired.
|
||||
if (layerState._cameraMoving) startViewMotionTimer();
|
||||
parts.geometryQueue.startGeometryLoadQueue();
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.projection.startProjectionLoop();
|
||||
@@ -402,10 +551,14 @@ export function createLifecycle({
|
||||
layerState._removeFocusAppearListener = null;
|
||||
parts.projection.stopProjectionLoop();
|
||||
parts.geometryQueue.stopGeometryLoadQueue();
|
||||
stopViewMotionTimer();
|
||||
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
|
||||
// source entries, pacer timer, in-flight handlers, and caches.
|
||||
parts.cards.teardownAmbientCards();
|
||||
parts.rendering.hideCctvVisuals();
|
||||
// Nothing is in view while the layer is off.
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
|
||||
@@ -443,6 +596,13 @@ export function createLifecycle({
|
||||
);
|
||||
layerState._moveStartListener = null;
|
||||
}
|
||||
// The move listeners are gone; nothing will report the end of a move.
|
||||
layerState._cameraMoving = false;
|
||||
layerState._viewListenerCamera = null;
|
||||
stopViewMotionTimer();
|
||||
layerState._viewResizeObserver?.disconnect();
|
||||
layerState._viewResizeObserver = null;
|
||||
layerState._viewResizeCanvas = null;
|
||||
if (layerState._gizmo) {
|
||||
layerState._gizmo.destroy();
|
||||
layerState._gizmo = null;
|
||||
|
||||
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
|
||||
|
||||
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
|
||||
|
||||
// View thinning: camera icons show only on or near the screen, and each
|
||||
// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX
|
||||
// at its distance from the viewer (in power-of-two metre steps, so a pan
|
||||
// keeps the same winners until a camera's distance crosses a step). Cells
|
||||
// smaller than VIEW_MIN_CELL_M do not thin:
|
||||
// close up every camera shows, co-located ones included.
|
||||
|
||||
export const VIEW_CELL_PX = 40;
|
||||
|
||||
export const VIEW_MIN_CELL_M = 60;
|
||||
|
||||
// Icons this far past the screen edge (fraction of its size) still show, so
|
||||
// a pan does not reveal empty ground before the next view pass.
|
||||
|
||||
export const VIEW_ICON_MARGIN = 0.5;
|
||||
|
||||
// While the camera moves, the view pass reruns at most this often.
|
||||
|
||||
export const VIEW_MOTION_REFRESH_MS = 250;
|
||||
// A motion tick is skipped unless the camera moved this fraction of its
|
||||
// ellipsoid height, or turned this many degrees, since the last pass.
|
||||
|
||||
export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01;
|
||||
|
||||
export const VIEW_MOTION_MIN_TURN_DEG = 0.5;
|
||||
|
||||
// Motion passes are spaced at least this many times the last pass's cost, so
|
||||
// a large catalog spends at most about a tenth of a long move on them.
|
||||
|
||||
export const VIEW_MOTION_COST_FACTOR = 10;
|
||||
|
||||
// A view camera's geometry is attempted at most this many times per surface
|
||||
// regime (a cold elevation cell can leave the first attempt unresolved); the
|
||||
// retry runs this long after the drain that left it unresolved.
|
||||
|
||||
export const VIEW_GEOMETRY_ATTEMPTS = 2;
|
||||
|
||||
export const VIEW_GEOMETRY_RETRY_MS = 1500;
|
||||
|
||||
// Throttle for placeholder repaints — the projection RAF loop must not
|
||||
// re-fill a 1080p canvas on every frame while a feed image is still loading.
|
||||
|
||||
|
||||
@@ -274,14 +274,53 @@ export function createPresentation({
|
||||
},
|
||||
activeCameraId: activeId,
|
||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||
cameras: layerState._records.map((record) =>
|
||||
getPublicCameraState(record, activeId),
|
||||
),
|
||||
cameraIndex: cameraIndex(),
|
||||
summary: buildSummaryText(),
|
||||
};
|
||||
// The full per-camera state is built only when a subscriber reads it: a
|
||||
// catalog of tens of thousands would otherwise be rebuilt on every
|
||||
// progress tick for listeners that only want the active camera. It reads
|
||||
// the records live at that moment, and spreading or serializing the
|
||||
// payload builds it, so callers that want a count use `count`.
|
||||
let cameras = null;
|
||||
Object.defineProperty(payload, 'cameras', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
cameras ??= layerState._records.map((record) =>
|
||||
getPublicCameraState(record, activeId),
|
||||
);
|
||||
return cameras;
|
||||
},
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ id, city, name }` for the active camera, then every camera the view
|
||||
* pass shows: what the camera picker lists. A whole catalog would put tens
|
||||
* of thousands of options in the DOM and slow every layout. The same array
|
||||
* is returned until the view or the active camera changes, so a consumer
|
||||
* can compare it by identity.
|
||||
* @returns {{id: string, city: string, name: string}[]}
|
||||
*/
|
||||
|
||||
function cameraIndex() {
|
||||
const view = layerState._viewRecords;
|
||||
const active = parts.selection.getActiveRecord();
|
||||
const cached = layerState._cameraIndexCache;
|
||||
if (cached?.view === view && cached.active === active) return cached.list;
|
||||
const records = active
|
||||
? [active, ...view.filter((record) => record !== active)]
|
||||
: view;
|
||||
const list = records.map(({ camera }) => ({
|
||||
id: camera.id,
|
||||
city: camera.city,
|
||||
name: camera.name,
|
||||
}));
|
||||
layerState._cameraIndexCache = { view, active, list };
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Dispatches the current UI state to all registered subscriber callbacks. */
|
||||
|
||||
function notifyListeners() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
ACTIVE_CAMERA_COLOR,
|
||||
@@ -11,8 +10,21 @@ import {
|
||||
ACTIVE_COVERAGE_EDGE,
|
||||
IDLE_COVERAGE_EDGE_MUTED,
|
||||
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
|
||||
VIEW_CELL_PX,
|
||||
VIEW_ICON_MARGIN,
|
||||
VIEW_MIN_CELL_M,
|
||||
} from './policy.js';
|
||||
|
||||
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
|
||||
function idHash(id) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash ^= id.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -138,24 +150,186 @@ export function createRendering({
|
||||
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
|
||||
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
|
||||
* fix), so without this pass London's cluster would shine through the planet
|
||||
* from a US viewpoint. Pure math over ≤ catalog-size points; runs on
|
||||
* camera.moveEnd + init only (event-driven — no steady-state work).
|
||||
* from a US viewpoint.
|
||||
*
|
||||
* The same pass thins the view so a large catalog stays readable and cheap:
|
||||
* icons well off screen hide, and each camera competes for a world-anchored
|
||||
* cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable
|
||||
* id order). The active camera shows whenever it is above the horizon
|
||||
* and near the screen, even in a crowded cell. The shown records become
|
||||
* `_viewRecords`, the working set for geometry, cards and the camera picker;
|
||||
* the array keeps its identity while the shown set is unchanged.
|
||||
*
|
||||
* Runs on camera settle, throttled during motion, on resize, enable and
|
||||
* activation changes — never per frame.
|
||||
* @returns {boolean} Whether the shown set changed.
|
||||
*/
|
||||
|
||||
function refreshHorizonCulling() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
layerState._viewer.isDestroyed() ||
|
||||
!layerState._records.length
|
||||
)
|
||||
return;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
return false;
|
||||
const { camera, scene } = layerState._viewer;
|
||||
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
|
||||
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
|
||||
const minX = -width * VIEW_ICON_MARGIN;
|
||||
const maxX = width * (1 + VIEW_ICON_MARGIN);
|
||||
const minY = -height * VIEW_ICON_MARGIN;
|
||||
const maxY = height * (1 + VIEW_ICON_MARGIN);
|
||||
// Ground metres one pixel covers per metre of distance from the viewer.
|
||||
const fovy = Number.isFinite(camera.frustum?.fovy)
|
||||
? camera.frustum.fovy
|
||||
: Math.PI / 3;
|
||||
const metresPerPixelPerMetre =
|
||||
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
|
||||
const viewer = camera.positionWC;
|
||||
// The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop
|
||||
// visits every camera in the catalog.
|
||||
const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
|
||||
const cx = viewer.x * inverseRadii.x;
|
||||
const cy = viewer.y * inverseRadii.y;
|
||||
const cz = viewer.z * inverseRadii.z;
|
||||
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
|
||||
const activeId = layerState._activeCameraId;
|
||||
const project = screenProjector(scene, camera, width, height);
|
||||
const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1);
|
||||
const shown = [];
|
||||
const cells = new Map();
|
||||
for (const record of layerState._records) {
|
||||
const position = record.billboard?.position;
|
||||
if (!position) continue;
|
||||
const vx = position.x * inverseRadii.x - cx;
|
||||
const vy = position.y * inverseRadii.y - cy;
|
||||
const vz = position.z * inverseRadii.z - cz;
|
||||
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
|
||||
if (
|
||||
limbSquared < 0
|
||||
? vtDotVc > 0
|
||||
: vtDotVc > limbSquared &&
|
||||
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared
|
||||
)
|
||||
continue;
|
||||
const screen = project(position);
|
||||
if (
|
||||
!screen ||
|
||||
!(screen.x >= minX && screen.x <= maxX) ||
|
||||
!(screen.y >= minY && screen.y <= maxY)
|
||||
)
|
||||
continue;
|
||||
const dx = position.x - viewer.x;
|
||||
const dy = position.y - viewer.y;
|
||||
const dz = position.z - viewer.z;
|
||||
const cellM =
|
||||
VIEW_CELL_PX *
|
||||
metresPerPixelPerMetre *
|
||||
Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) {
|
||||
record.viewPass = pass;
|
||||
shown.push(record);
|
||||
continue;
|
||||
}
|
||||
const key = worldCellKey(record, cellM);
|
||||
const rank = viewRank(record);
|
||||
const holder = cells.get(key);
|
||||
if (!holder || rank < holder.rank) cells.set(key, { record, rank });
|
||||
}
|
||||
for (const { record } of cells.values()) {
|
||||
record.viewPass = pass;
|
||||
shown.push(record);
|
||||
}
|
||||
for (const record of layerState._records) {
|
||||
const bb = record.billboard;
|
||||
if (!bb) continue;
|
||||
const visible = occluder.isPointVisible(bb.position);
|
||||
const visible = record.viewPass === pass;
|
||||
if (bb.show !== visible) bb.show = visible;
|
||||
}
|
||||
const previous = layerState._viewRecords;
|
||||
const changed =
|
||||
previous.length !== shown.length ||
|
||||
previous.some((record) => record.viewPass !== pass);
|
||||
if (changed) layerState._viewRecords = shown;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates for world positions. In 3D the pass multiplies by one
|
||||
* view-projection matrix per call instead of Cesium's per-point
|
||||
* cartesianToCanvasCoordinates, which dominated the pass on a large
|
||||
* catalog; other scene modes keep Cesium's projection.
|
||||
* @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)}
|
||||
*/
|
||||
function screenProjector(scene, camera, width, height) {
|
||||
const out = layerState._scratchViewScreen;
|
||||
const projection = camera.frustum?.projectionMatrix;
|
||||
if (
|
||||
scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
!projection ||
|
||||
!camera.viewMatrix
|
||||
)
|
||||
return (position) => scene.cartesianToCanvasCoordinates(position, out);
|
||||
const m = Cesium.Matrix4.multiply(
|
||||
projection,
|
||||
camera.viewMatrix,
|
||||
layerState._scratchViewProjection,
|
||||
);
|
||||
return (p) => {
|
||||
const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
|
||||
const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
|
||||
// Behind the eye or before the near plane: Cesium does not draw it.
|
||||
if (!(w > 0) || z < 0) return null;
|
||||
out.x =
|
||||
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width;
|
||||
out.y =
|
||||
(1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid cell fixed to the ground: `cellM` rounded up to a power of two
|
||||
* metres, so the cell (and its winner) only changes when the camera's
|
||||
* distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom
|
||||
* can move cameras near the edge of a step to the next one.
|
||||
*/
|
||||
function worldCellKey(record, cellM) {
|
||||
const level = Math.ceil(Math.log2(cellM));
|
||||
const { lat, lon } = record.camera;
|
||||
// A camera's key changes only with the level (or an edited position), so
|
||||
// it is kept per level.
|
||||
if (
|
||||
record.viewCellLevel === level &&
|
||||
record.viewCellLat === lat &&
|
||||
record.viewCellLon === lon
|
||||
)
|
||||
return record.viewCellKey;
|
||||
const latStep = 2 ** level / 111_320;
|
||||
const row = Math.floor(lat / latStep);
|
||||
const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep);
|
||||
const lonStep = latStep / Math.max(0.05, Math.cos(rowLat));
|
||||
record.viewCellLevel = level;
|
||||
record.viewCellLat = lat;
|
||||
record.viewCellLon = lon;
|
||||
record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`;
|
||||
return record.viewCellKey;
|
||||
}
|
||||
|
||||
/** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */
|
||||
function viewRank(record) {
|
||||
const status = layerState._healthById.get(record.camera.id)?.status;
|
||||
const tier =
|
||||
status === 'ok'
|
||||
? 0
|
||||
: status === 'degraded'
|
||||
? 1
|
||||
: status === 'offline'
|
||||
? 3
|
||||
: 2;
|
||||
record.viewHash ??= idHash(record.camera.id);
|
||||
return tier * 2 ** 32 + record.viewHash;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +378,8 @@ export function createRendering({
|
||||
const activeId = activeRecord?.camera.id || null;
|
||||
const coverageVisible =
|
||||
parts.geometry.buildCoverageVisibleSet(activeRecord);
|
||||
// Drawn coverage needs geometry too (geometryQueue's working set).
|
||||
layerState._coverageVisibleIds = coverageVisible;
|
||||
const coverageOn = layerState._coverageMode !== 'off';
|
||||
const viewshedOn = layerState._coverageMode === 'viewshed';
|
||||
if (coverageOn) {
|
||||
@@ -215,8 +391,13 @@ export function createRendering({
|
||||
for (const record of layerState._records) {
|
||||
const isActive = record.camera.id === activeId;
|
||||
if (record.billboard) {
|
||||
record.billboard.color = cameraColorFor(record);
|
||||
record.billboard.scale = isActive ? 1.25 : 1.0;
|
||||
// Unchanged writes still dirty the collection, which then rewrites
|
||||
// every icon's vertices: across a large catalog, write only changes.
|
||||
const color = cameraColorFor(record);
|
||||
if (!Cesium.Color.equals(record.billboard.color, color))
|
||||
record.billboard.color = color;
|
||||
const scale = isActive ? 1.25 : 1.0;
|
||||
if (record.billboard.scale !== scale) record.billboard.scale = scale;
|
||||
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
}
|
||||
|
||||
@@ -141,6 +141,11 @@ export function createSelection({
|
||||
void parts.ground.resolveFootprintGround(record);
|
||||
record.activationDone = true;
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
// The active camera's icon always shows; the previously active one
|
||||
// competes for its cell again. Its coverage neighbours join the working
|
||||
// set and are queued for grounding.
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
// The newly active camera leaves the ambient ring (its monitor plane takes
|
||||
// over); the freed slot re-fills on this same pass.
|
||||
parts.cards.refreshAmbientCards();
|
||||
@@ -171,7 +176,9 @@ export function createSelection({
|
||||
parts.ground.groundAltFor(previous),
|
||||
);
|
||||
});
|
||||
parts.rendering.refreshHorizonCulling();
|
||||
parts.rendering.refreshCoverageStyles();
|
||||
parts.geometryQueue.enqueueViewGeometry();
|
||||
parts.cards.refreshAmbientCards();
|
||||
layerState._gizmo?.refresh();
|
||||
parts.presentation.notifyListeners();
|
||||
|
||||
@@ -76,6 +76,63 @@ export function createState({ services }) {
|
||||
|
||||
layerState._scratchFocusScreen = new Cesium.Cartesian2();
|
||||
|
||||
layerState._scratchViewScreen = new Cesium.Cartesian2();
|
||||
|
||||
layerState._scratchViewProjection = new Cesium.Matrix4();
|
||||
|
||||
// Counts view passes; a record shown by the latest pass carries its number.
|
||||
|
||||
layerState._viewPassId = 0;
|
||||
|
||||
// Records whose icons the last view pass showed (see refreshHorizonCulling):
|
||||
// geometry refinement, the tiles-ready completion pass and ambient cards
|
||||
// work over these, not the whole catalog.
|
||||
|
||||
layerState._viewRecords = [];
|
||||
|
||||
// Coverage ids refreshCoverageStyles last drew: part of the geometry
|
||||
// working set even when their icons are thinned out.
|
||||
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
|
||||
// View pass triggers besides camera settle: an interval while the camera
|
||||
// moves, and a canvas resize observer.
|
||||
|
||||
layerState._viewMotionTimer = 0;
|
||||
|
||||
layerState._viewResizeObserver = null;
|
||||
|
||||
layerState._viewResizeCanvas = null;
|
||||
|
||||
// The camera the moveStart/moveEnd listeners are attached to.
|
||||
|
||||
layerState._viewListenerCamera = null;
|
||||
|
||||
// A motion pass changed the shown set and the settle pass has not yet told
|
||||
// subscribers; the camera pose the last view pass ran at.
|
||||
|
||||
layerState._viewUnpublished = false;
|
||||
|
||||
layerState._viewPose = null;
|
||||
|
||||
// { at, ms } — when the last motion pass ran and what it cost.
|
||||
|
||||
layerState._viewMotionPass = null;
|
||||
|
||||
// Retries the view geometry a drain left unresolved, once cells warm.
|
||||
|
||||
layerState._viewGeometryRetryTimer = 0;
|
||||
|
||||
// { key, count } — the active camera's coverage-overlap count, which the
|
||||
// summary reads on every notification.
|
||||
|
||||
layerState._neighborCountCache = null;
|
||||
|
||||
// { view, active, list } — the camera picker's index uiState publishes,
|
||||
// rebuilt only when the view pass or the active camera changes.
|
||||
|
||||
layerState._cameraIndexCache = null;
|
||||
|
||||
// Staggered geometry-load queue state (see startGeometryLoadQueue).
|
||||
|
||||
layerState._geoQueue = [];
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
* @param {boolean} [options.enabled=true] Layer enabled state.
|
||||
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
|
||||
* @param {boolean} [options.showProjection=false] Projection visibility.
|
||||
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
enabled = true,
|
||||
coverageMode = 'on',
|
||||
showProjection = false,
|
||||
healthById = new Map(),
|
||||
} = {}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._records = Array.isArray(records) ? records : [];
|
||||
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
'on',
|
||||
);
|
||||
layerState._showProjection = !!showProjection;
|
||||
layerState._healthById = healthById;
|
||||
layerState._viewRecords = [];
|
||||
layerState._cameraIndexCache = null;
|
||||
layerState._coverageVisibleIds = new Set();
|
||||
layerState._neighborCountCache = null;
|
||||
}
|
||||
return {
|
||||
_pushAmbientCardEntriesForTest,
|
||||
|
||||
@@ -59,6 +59,12 @@ export function _initCctvPanel() {
|
||||
);
|
||||
});
|
||||
|
||||
// The picker defers rebuilds while focused; catch up once it lets go.
|
||||
this.listen(this._cctvSelect, 'blur', () => {
|
||||
this._cctvSelectSource = null;
|
||||
this._renderCctvState(this._cctvState);
|
||||
});
|
||||
|
||||
this.listen(this._cctvSelect, 'change', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
@@ -72,9 +78,9 @@ export function _initCctvPanel() {
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
// Picking a camera from the dropdown flies to it. The catalog spans
|
||||
// three metros, so a bare selection used to leave the view in the old
|
||||
// city with a camera active thousands of km away.
|
||||
// Picking a camera from the dropdown flies to it. The dropdown lists the
|
||||
// cameras in view (and the active camera, which may be far away), so a
|
||||
// bare selection could leave the view somewhere else.
|
||||
this.actions.runExplicitFocus(
|
||||
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
||||
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
|
||||
// says how many more the view leaves out.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
|
||||
/** The slice of a <select> and its document the picker touches. */
|
||||
function pickerDocument() {
|
||||
const select = new EventTarget();
|
||||
const option = () => ({
|
||||
value: '',
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
dataset: {},
|
||||
remove() {
|
||||
select.options = select.options.filter((entry) => entry !== this);
|
||||
},
|
||||
});
|
||||
Object.assign(select, {
|
||||
options: [],
|
||||
disabled: false,
|
||||
selectedIndex: -1,
|
||||
get value() {
|
||||
return this.options[this.selectedIndex]?.value ?? '';
|
||||
},
|
||||
set value(id) {
|
||||
this.selectedIndex = this.options.findIndex((o) => o.value === id);
|
||||
},
|
||||
replaceChildren(fragment) {
|
||||
this.options = [...fragment.children];
|
||||
this.selectedIndex = this.options.length ? 0 : -1;
|
||||
},
|
||||
prepend(child) {
|
||||
this.options.unshift(child);
|
||||
},
|
||||
});
|
||||
const document = {
|
||||
activeElement: null,
|
||||
createElement: option,
|
||||
createDocumentFragment: () => ({
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { document, select };
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const { document, select } = pickerDocument();
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = document;
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvSelect: select },
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
globalThis.document = previous;
|
||||
});
|
||||
return { controls, select };
|
||||
}
|
||||
|
||||
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||
|
||||
test('the picker closes with the count of cameras outside the view', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 40,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b', ''],
|
||||
);
|
||||
const note = select.options[2];
|
||||
assert.equal(note.disabled, true);
|
||||
assert.equal(
|
||||
note.textContent,
|
||||
'38 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['a', 'b'],
|
||||
'a view holding every camera has no note',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no camera in view, the closed picker shows the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(
|
||||
select.options[select.selectedIndex].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
});
|
||||
|
||||
test('a disabled layer has no note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 0);
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
const a = camera('a');
|
||||
const b = camera('b');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'2 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [b, a],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(
|
||||
select.options.map((o) => o.value),
|
||||
['b', 'a', ''],
|
||||
);
|
||||
assert.equal(
|
||||
select.options.at(-1).textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('a focused picker does not pick a stale camera when the view empties', (t) => {
|
||||
const { controls, select } = fixture(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [camera('a')],
|
||||
});
|
||||
globalThis.document.activeElement = select;
|
||||
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
+80
-14
@@ -16,6 +16,15 @@ export function _calBadgeLabel(badge) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's closing line for the cameras the view leaves out. */
|
||||
function outsideNoteText(count) {
|
||||
return tn(
|
||||
count,
|
||||
'{count} more camera outside this view · pan or zoom out',
|
||||
'{count} more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
}
|
||||
|
||||
export function _renderCctvState(state) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
@@ -26,7 +35,13 @@ export function _renderCctvState(state) {
|
||||
this._calibrationEdit?.(false);
|
||||
}
|
||||
this._cctvState = state || null;
|
||||
const cameras = state?.cameras || [];
|
||||
// The light index, not `state.cameras`: reading that builds every camera's
|
||||
// full state, which a large catalog cannot afford on each notification.
|
||||
const cameras = state?.cameraIndex || state?.cameras || [];
|
||||
// The picker lists what is in view; the catalog is every camera.
|
||||
const catalogCount = Number.isFinite(state?.count)
|
||||
? state.count
|
||||
: cameras.length;
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
@@ -59,28 +74,79 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
if (this._cctvSelect) {
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list skips the per-option comparison. A
|
||||
// focused picker is not rebuilt under the user; blur re-renders it.
|
||||
const picking = document.activeElement === this._cctvSelect;
|
||||
// Cameras the view leaves out get one closing line, so a short list
|
||||
// does not read as the whole catalog. A disabled layer has no view.
|
||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||
const shouldRebuild =
|
||||
this._cctvSelect.options.length !== cameras.length ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
);
|
||||
!picking &&
|
||||
(this._cctvSelectSource !== cameras ||
|
||||
this._cctvSelectOutside !== outside) &&
|
||||
(this._cctvSelectOutside !== outside ||
|
||||
this._cctvSelect.options.length !==
|
||||
cameras.length + (outside ? 1 : 0) ||
|
||||
cameras.some(
|
||||
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
|
||||
));
|
||||
if (!picking) {
|
||||
this._cctvSelectSource = cameras;
|
||||
this._cctvSelectOutside = outside;
|
||||
}
|
||||
if (shouldRebuild) {
|
||||
this._cctvSelect.innerHTML = '';
|
||||
const options = document.createDocumentFragment();
|
||||
for (const camera of cameras) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
option.textContent = `${camera.city} · ${camera.name}`;
|
||||
this._cctvSelect.appendChild(option);
|
||||
options.appendChild(option);
|
||||
}
|
||||
if (outside) {
|
||||
const note = document.createElement('option');
|
||||
note.value = '';
|
||||
note.disabled = true;
|
||||
note.dataset.cctvOutside = '';
|
||||
note.textContent = outsideNoteText(outside);
|
||||
options.appendChild(note);
|
||||
}
|
||||
this._cctvSelect.replaceChildren(options);
|
||||
}
|
||||
this._cctvSelect.disabled = !enabled || cameras.length === 0;
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank.
|
||||
if (
|
||||
picking &&
|
||||
activeId &&
|
||||
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
|
||||
activeCamera &&
|
||||
![...this._cctvSelect.options].some((option) => option.value === activeId)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = activeId;
|
||||
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
|
||||
this._cctvSelect.prepend(option);
|
||||
// The added camera is one fewer outside the view.
|
||||
const note = [...this._cctvSelect.options].find(
|
||||
(entry) => entry.dataset?.cctvOutside !== undefined,
|
||||
);
|
||||
if (note) {
|
||||
const left = Math.max(
|
||||
0,
|
||||
catalogCount - (this._cctvSelect.options.length - 1),
|
||||
);
|
||||
if (left) note.textContent = outsideNoteText(left);
|
||||
else note.remove();
|
||||
}
|
||||
}
|
||||
// The active camera is always one of the listed records.
|
||||
if (activeId && activeCamera) {
|
||||
this._cctvSelect.value = activeId;
|
||||
} else if (!activeId) {
|
||||
this._cctvSelect.selectedIndex = -1;
|
||||
// With nothing in view, the closed picker says where the cameras are.
|
||||
// A focused picker keeps its old options, so it only clears.
|
||||
this._cctvSelect.selectedIndex =
|
||||
!picking && cameras.length === 0 && outside ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +156,10 @@ export function _renderCctvState(state) {
|
||||
this._cctvNextBtn,
|
||||
]) {
|
||||
if (!btn) continue;
|
||||
btn.disabled = !enabled || cameras.length === 0;
|
||||
btn.disabled = !enabled || catalogCount === 0;
|
||||
}
|
||||
if (this._cctvFocusBtn) {
|
||||
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
|
||||
this._cctvFocusBtn.disabled = !enabled || catalogCount === 0 || !activeId;
|
||||
}
|
||||
|
||||
if (this._cctvCoverageBtn) {
|
||||
@@ -180,15 +246,15 @@ export function _renderCctvState(state) {
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
);
|
||||
} else if (cameras.length > 0) {
|
||||
} else if (catalogCount > 0) {
|
||||
this._cctvMeta.textContent = enabled
|
||||
? tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · click a camera to activate',
|
||||
'{count} cameras loaded · click a camera to activate',
|
||||
)
|
||||
: tn(
|
||||
cameras.length,
|
||||
catalogCount,
|
||||
'{count} camera loaded · enable CCTV to activate',
|
||||
'{count} cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
|
||||
const css = readStylesheet(new URL('../../style.css', import.meta.url));
|
||||
const clean = (selector) =>
|
||||
selector
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
|
||||
([, selector, body]) => ({
|
||||
selector: clean(selector),
|
||||
body: body.replace(/\/\*[\s\S]*?\*\//g, ''),
|
||||
}),
|
||||
);
|
||||
const railRules = rules.filter(({ selector }) =>
|
||||
selector.includes('#left-panel-stack, #right-context-rail'),
|
||||
);
|
||||
const hoverBlock =
|
||||
css.match(
|
||||
/@media \(hover: hover\) and \(pointer: fine\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g,
|
||||
) || [];
|
||||
|
||||
test('a side panel lights up on hover only where a pointer can hover', () => {
|
||||
const lit = railRules.filter(({ selector }) => selector.includes(':hover'));
|
||||
assert.ok(lit.length > 0, 'the rail hover rule is missing');
|
||||
for (const { selector } of lit) {
|
||||
assert.ok(
|
||||
hoverBlock.some((block) => clean(block).includes(selector)),
|
||||
`${selector} must sit inside @media (hover: hover) and (pointer: fine)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('hover matches the dock tray hover, not its pinned look', () => {
|
||||
const [hover] = railRules.filter(({ selector }) =>
|
||||
selector.endsWith(':hover'),
|
||||
);
|
||||
assert.ok(hover, 'the rail surface hover rule is missing');
|
||||
assert.match(hover.body, /border-color:\s*var\(--glass-border-hover\)/);
|
||||
assert.doesNotMatch(hover.body, /box-shadow/);
|
||||
assert.match(hover.body, /--rail-label-hover:\s*var\(--accent\)/);
|
||||
// A low-specificity list: no ID or state selector inside it lifts the rule.
|
||||
assert.match(hover.selector, /:where\(/);
|
||||
});
|
||||
|
||||
test('hover recolours only the hovered panel header, never nested sections', () => {
|
||||
for (const { selector } of railRules) {
|
||||
assert.ok(
|
||||
!/\.radio-panel-inner/.test(selector),
|
||||
`Radio sits inside Context and must not light up as its own panel: ${selector}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
selector,
|
||||
/:hover\s+:is\(/,
|
||||
'a descendant colour list would reach labels in the panel body',
|
||||
);
|
||||
}
|
||||
const colour = railRules.find(
|
||||
({ body }) => /--rail-label-hover/.test(body) && /color:/.test(body),
|
||||
);
|
||||
assert.ok(colour, 'the rail title colour rule is missing');
|
||||
assert.match(colour.selector, /\.panel-header > \.panel-title/);
|
||||
const radio = rules.find(({ selector }) => selector === '#radio-panel');
|
||||
assert.match(radio?.body || '', /--rail-label-hover:\s*initial/);
|
||||
});
|
||||
|
||||
test('no later rule dims the open Context title below its siblings', () => {
|
||||
const dimmed = rules.filter(
|
||||
({ selector, body }) =>
|
||||
/#global-context-panel:not\(\.collapsed\) \.panel-title$/.test(
|
||||
selector,
|
||||
) && /color:/.test(body),
|
||||
);
|
||||
assert.deepEqual(dimmed, []);
|
||||
});
|
||||
|
||||
test('collapsed launchers on both rails keep titles inside the card', () => {
|
||||
const ellipsis = railRules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith(
|
||||
':is(.panel-header > .panel-title, .pp-header-label)',
|
||||
) && /text-overflow:\s*ellipsis/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
ellipsis,
|
||||
'rail titles must end in an ellipsis rather than overflow',
|
||||
);
|
||||
assert.match(ellipsis.body, /min-width:\s*0/);
|
||||
|
||||
const translated = rules.find(
|
||||
({ selector }) =>
|
||||
selector.startsWith("html:not([lang='en'])") &&
|
||||
selector.includes('#left-panel-stack, #right-context-rail') &&
|
||||
selector.includes('.panel-collapsible.collapsed'),
|
||||
);
|
||||
assert.ok(translated, 'translated collapsed titles must fit on both rails');
|
||||
assert.match(translated.body, /font-size:\s*8px/);
|
||||
// Chinese and Japanese titles fit at full size; 8px would put them under
|
||||
// the 9px CJK floor in cjk.css.
|
||||
assert.match(translated.selector, /:not\(:lang\(zh\)\):not\(:lang\(ja\)\)/);
|
||||
// Only the rail's own launchers: Radio, nested in Context, keeps its type.
|
||||
assert.match(
|
||||
translated.selector,
|
||||
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header > \.panel-title$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a collapsed launcher drops its divider so English titles fit whole', () => {
|
||||
const launcher = (tail) =>
|
||||
railRules.filter(({ selector }) =>
|
||||
selector
|
||||
.split(/,(?![^(]*\))/)
|
||||
.some((part) =>
|
||||
part
|
||||
.trim()
|
||||
.endsWith(
|
||||
`.collapsed > [class$='-panel-inner'] > .panel-header${tail}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const [header] = launcher('');
|
||||
const [divider] = launcher(' > .panel-divider');
|
||||
const [toggle] = launcher(' > .panel-collapse-btn');
|
||||
assert.match(header?.body || '', /gap:\s*8px/, 'the launcher header gap');
|
||||
assert.match(divider?.body || '', /display:\s*none/, 'the launcher divider');
|
||||
assert.match(
|
||||
toggle?.body || '',
|
||||
/margin-left:\s*auto/,
|
||||
'the launcher toggle',
|
||||
);
|
||||
const button = rules.find(
|
||||
({ selector }) => selector === '.panel-collapse-btn',
|
||||
);
|
||||
assert.match(
|
||||
button?.body || '',
|
||||
/flex-shrink:\s*0/,
|
||||
'a long title must not squeeze the toggle',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default theme geometry change leaves cyber gutters alone', () => {
|
||||
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
|
||||
const base = rules.find(
|
||||
({ selector, body }) => selector === surface && /padding:/.test(body),
|
||||
);
|
||||
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
|
||||
}
|
||||
const display = rules.find(
|
||||
({ selector }) =>
|
||||
selector === '#pp-toggles > .pp-header-row .pp-collapse-btn',
|
||||
);
|
||||
assert.match(display?.body || '', /font-size:\s*12px/);
|
||||
});
|
||||
|
||||
test('rail titles and dock labels share one set of label tokens', () => {
|
||||
// command-dock-trays.css sets the dock's label type; earlier dock rules
|
||||
// for the same selector are overridden by it.
|
||||
const dock = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector ===
|
||||
'#command-dock .panel-title, #command-dock .location-toolbar-label' &&
|
||||
/--dock-label-/.test(body),
|
||||
);
|
||||
const rail = railRules.find(({ body }) =>
|
||||
/--dock-label-font-size/.test(body),
|
||||
);
|
||||
assert.match(dock?.body || '', /font-size:\s*var\(--dock-label-font-size\)/);
|
||||
assert.match(
|
||||
dock?.body || '',
|
||||
/letter-spacing:\s*var\(--dock-label-letter-spacing\)/,
|
||||
);
|
||||
// The dock's rem type, capped at its 16px-root size: a collapsed launcher
|
||||
// is a fixed 176px, so a larger browser font must not cut titles short.
|
||||
assert.match(
|
||||
rail?.body || '',
|
||||
/font-size:\s*min\(var\(--dock-label-font-size\),\s*9\.28px\)/,
|
||||
);
|
||||
assert.match(
|
||||
rail?.body || '',
|
||||
/letter-spacing:\s*min\(var\(--dock-label-letter-spacing\),\s*2\.08px\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('default theme overrides never reach the cyber theme', () => {
|
||||
const notCyber = ":root:not([data-ui-theme='cyber'])";
|
||||
const padding = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith(
|
||||
':is(.data-panel-inner, .global-context-panel-inner)',
|
||||
) && /padding-left:\s*14px/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
padding?.selector.startsWith(notCyber),
|
||||
'the 14px gutters must skip cyber',
|
||||
);
|
||||
const display = rules.find(
|
||||
({ selector, body }) =>
|
||||
selector.endsWith('#pp-toggles > .pp-header-row .pp-collapse-btn') &&
|
||||
/font-size:\s*13px/.test(body),
|
||||
);
|
||||
assert.ok(
|
||||
display?.selector.startsWith(notCyber),
|
||||
"Display's 22px toggle must skip cyber",
|
||||
);
|
||||
// Cyber draws its title marker at left: -8px; a clip on the title would cut it.
|
||||
for (const { selector, body } of rules) {
|
||||
if (!/\.panel-title/.test(selector) || !/overflow:\s*hidden/.test(body))
|
||||
continue;
|
||||
if (!/#left-panel-stack|#right-context-rail/.test(selector)) continue;
|
||||
assert.ok(
|
||||
selector.startsWith(notCyber),
|
||||
`${selector} clips the cyber title marker`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher compaction and header colour stay on the rail panels themselves', () => {
|
||||
// The divider, gap and toggle rules; the translated type rule is checked
|
||||
// above.
|
||||
const compact = railRules.filter(
|
||||
({ selector }) =>
|
||||
/\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(
|
||||
selector,
|
||||
) && !selector.startsWith('html:'),
|
||||
);
|
||||
assert.equal(compact.length, 3);
|
||||
for (const { selector } of compact) {
|
||||
// Each launcher is a direct child of its rail: Radio, nested inside
|
||||
// Context, is not a launcher. The header must not itself be required to
|
||||
// be a rail child.
|
||||
assert.match(
|
||||
selector,
|
||||
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/,
|
||||
);
|
||||
assert.match(
|
||||
selector,
|
||||
/#right-context-rail\) > #pp-toggles\.collapsed > \.pp-header-row/,
|
||||
);
|
||||
}
|
||||
const colour = railRules.find(({ body }) =>
|
||||
/--rail-label-hover, /.test(body),
|
||||
);
|
||||
assert.match(
|
||||
colour.selector,
|
||||
/:is\(\.panel-header, \.pp-header-row\) > \.panel-collapse-btn/,
|
||||
);
|
||||
assert.doesNotMatch(colour.selector, /, \.panel-collapse-btn\)/);
|
||||
});
|
||||
@@ -17,10 +17,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
color: rgba(206, 232, 239, 0.72);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.13rem;
|
||||
color: var(--dock-label-color);
|
||||
font-size: var(--dock-label-font-size);
|
||||
font-weight: var(--dock-label-font-weight);
|
||||
letter-spacing: var(--dock-label-letter-spacing);
|
||||
}
|
||||
|
||||
#command-dock .dock-label-icon {
|
||||
|
||||
+157
-15
@@ -81,16 +81,104 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* A longer translated title in a collapsed rail launcher tightens its
|
||||
tracking rather than pushing the disclosure button out of the card. */
|
||||
html:not([lang='en'])
|
||||
#right-context-rail
|
||||
.panel-collapsible.collapsed
|
||||
.panel-title {
|
||||
/* Side panel titles read like the command dock's labels: one type for every
|
||||
panel, collapsed or open, and bright enough to read over the globe. The
|
||||
dock's type is in rem, but a collapsed launcher is a fixed 176px, so the
|
||||
rail caps it at its 16px-root size: a larger browser font must not cut
|
||||
Recent Imagery short. */
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(.panel-header > .panel-title, .pp-header-label) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: min(var(--dock-label-font-size), 9.28px);
|
||||
font-weight: var(--dock-label-font-weight);
|
||||
letter-spacing: min(var(--dock-label-letter-spacing), 2.08px);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* A hovered panel sets --rail-label-hover on itself (see below); its own
|
||||
header picks it up. Radio sits inside Context and keeps its own colour. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(
|
||||
.panel-header > .panel-title,
|
||||
.pp-header-label,
|
||||
:is(.panel-header, .pp-header-row) > .panel-collapse-btn
|
||||
) {
|
||||
color: var(--rail-label-hover, var(--dock-label-color));
|
||||
}
|
||||
|
||||
#radio-panel {
|
||||
--rail-label-hover: initial;
|
||||
}
|
||||
|
||||
/* A title never pushes its toggle out of the card: one that does not fit
|
||||
ends in an ellipsis. Cyber draws a marker left of the title, which a clip
|
||||
would cut, and keeps its own header layout. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:is(.panel-header > .panel-title, .pp-header-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
/* A collapsed launcher is 176px wide and its divider has no room to draw,
|
||||
so the header drops it and packs closer. English titles such as Recent
|
||||
Imagery then fit whole. Only the rail's own launchers: Radio, nested in
|
||||
Context, keeps its divider. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-divider,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row
|
||||
> .panel-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-collapse-btn,
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> #pp-toggles.collapsed
|
||||
> .pp-header-row
|
||||
> .panel-collapse-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Translated titles run longer (Aktuelle Bilddaten), so a collapsed rail
|
||||
launcher sets them in compact type rather than pushing the disclosure
|
||||
button out of the card or cutting the word short. Chinese and Japanese
|
||||
titles fit at full size and stay at or above their 9px floor (cjk.css);
|
||||
Radio, nested in Context, is not a launcher. */
|
||||
html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
> .panel-collapsible.collapsed
|
||||
> [class$='-panel-inner']
|
||||
> .panel-header
|
||||
> .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
@@ -109,6 +197,8 @@ html:not([lang='en'])
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
/* A long title or a header action must not squeeze the toggle. */
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
@@ -116,17 +206,50 @@ html:not([lang='en'])
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* A long translated title must not squeeze the toggle into a sliver. */
|
||||
html:not([lang='en']) .panel-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-collapse-btn:hover {
|
||||
border-color: var(--glass-border-hover);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* A side panel answers the pointer as the command dock's trays do: a
|
||||
brighter edge and an accent title and toggle. Touch screens have no hover,
|
||||
so a tap must not leave a panel lit. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
#pp-toggles.collapsed > .pp-header-row
|
||||
) {
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(#left-panel-stack, #right-context-rail)
|
||||
:where(
|
||||
.data-panel-inner,
|
||||
.scene-panel-inner,
|
||||
.cctv-panel-inner,
|
||||
.weather-panel-inner,
|
||||
.recent-imagery-panel-inner,
|
||||
.street-level-panel-inner,
|
||||
.global-context-panel-inner,
|
||||
#pp-toggles:not(.collapsed),
|
||||
#pp-toggles.collapsed > .pp-header-row
|
||||
):hover {
|
||||
--rail-label-hover: var(--accent);
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Button Grid ──────────────────────────────── */
|
||||
.button-grid {
|
||||
display: flex;
|
||||
@@ -431,12 +554,21 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
}
|
||||
|
||||
#pp-toggles .pp-header-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.4px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Display's title and toggle sit where the other panels' do: 14px of panel
|
||||
padding plus a 4px header inset. The cyber theme sets its own insets. */
|
||||
:root:not([data-ui-theme='cyber']) #pp-toggles.collapsed > .pp-header-row {
|
||||
padding: 8px 18px;
|
||||
}
|
||||
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#pp-toggles:not(.collapsed)
|
||||
> .pp-header-row {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#pp-toggles > .pp-header-row .pp-collapse-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -445,6 +577,16 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* In the default theme Display's toggle matches the other panels' 22px. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#pp-toggles
|
||||
> .pp-header-row
|
||||
.pp-collapse-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#pp-toggles.collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -21,6 +21,11 @@
|
||||
--panel-radius: 16px;
|
||||
--btn-radius: 10px;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Label type shared by the command dock and the side panel titles. */
|
||||
--dock-label-color: rgba(206, 232, 239, 0.72);
|
||||
--dock-label-font-size: 0.58rem;
|
||||
--dock-label-font-weight: 500;
|
||||
--dock-label-letter-spacing: 0.13rem;
|
||||
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--left-stack-x: 52px;
|
||||
--right-rail-x: 52px;
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
padding: 8px 10px 8px 14px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
#right-context-rail #global-context-panel.collapsed .panel-header {
|
||||
margin: 0;
|
||||
@@ -989,13 +989,6 @@
|
||||
#global-context-panel .panel-glow {
|
||||
display: none;
|
||||
}
|
||||
/* Expanded, its section titles (Context, Radio) stay legible over bright
|
||||
imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
#global-context-panel:not(.collapsed)
|
||||
.panel-title {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.global-context-panel-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1011,6 +1004,15 @@
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
|
||||
/* Data Layers and Context share the 14px side padding of the other default
|
||||
theme panels, so every title and toggle sits 19px from its edge. Cyber
|
||||
keeps its own gutters. */
|
||||
:root:not([data-ui-theme='cyber'])
|
||||
:is(.data-panel-inner, .global-context-panel-inner) {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
.global-context-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
#recent-imagery-split-line[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#recent-imagery-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -476,14 +476,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#data-panel.collapsed .panel-title,
|
||||
#cctv-panel.collapsed .panel-title,
|
||||
#radio-panel.collapsed .panel-title,
|
||||
#scene-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
|
||||
#control-panel.collapsed .panel-inner,
|
||||
#data-panel.collapsed .data-panel-inner,
|
||||
#cctv-panel.collapsed .cctv-panel-inner,
|
||||
|
||||
@@ -50,10 +50,6 @@
|
||||
.weather-readout[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
#weather-panel.collapsed .panel-title {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.8px;
|
||||
}
|
||||
#weather-panel.collapsed .panel-header {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -1660,9 +1660,8 @@ export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
||||
return {
|
||||
activeCameraId: ui.activeCameraId || null,
|
||||
activeCamera: ui.activeCamera?.name || ui.activeCamera?.id || null,
|
||||
cameraCount: Array.isArray(ui.cameras)
|
||||
? ui.cameras.length
|
||||
: ui.count || 0,
|
||||
// `count`, not `cameras.length`: reading `cameras` builds every camera.
|
||||
cameraCount: Number.isFinite(ui.count) ? ui.count : 0,
|
||||
showCoverage: !!ui.showCoverage,
|
||||
coverageMode: ui.coverageMode || (ui.showCoverage ? 'on' : 'off'),
|
||||
showProjection: !!ui.showProjection,
|
||||
|
||||
Reference in new issue
Block a user