From b11390404abb2306efef1266e121898c222594b8 Mon Sep 17 00:00:00 2001 From: jibraaan Date: Wed, 7 Oct 2026 16:19:45 +0500 Subject: [PATCH 01/14] feat(cctv): add the Autostrada del Brennero (A22) motorway webcams Closes bilawalsidhu/gods-eye-view#155. The first Italian cameras: the 13 webcams along the A22 from the Brenner Pass to Modena. The camera list is one keyless request to the Open Data Hub (NOI Techpark) tourism API, which republishes Autobrennero's own list with coordinates and kilometre markers. Frames are the operator's stills, refreshed about once a minute, and only each camera's own WebCamImg/km.jpg is registered. The catalog fetch refuses redirects and is size-capped. The road climbs from ~20 m in the Po valley to ~1,370 m at the Brenner Pass, so ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM instead of one flat prior. Headings use the id-hash fallback at low confidence. CCTV_A22_ENABLED=0 disables the pack. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + CHANGELOG.md | 9 ++ DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 33 +++++ server/providers/cctv/normalize.js | 11 ++ server/providers/cctv/sources.js | 126 ++++++++++++++++++ src/data/cctvA22.test.mjs | 199 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 7 + 12 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvA22.test.mjs diff --git a/.env.example b/.env.example index ee7620be9..ec431b03a 100644 --- a/.env.example +++ b/.env.example @@ -196,6 +196,8 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..4f03ac311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +- 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.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. Closes #155. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..d90cb8030 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -55,6 +55,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **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 as open data (`ClosedData: false`, licence holder Autobrennero, no licence named) | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -90,6 +91,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **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.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 marks the list as open (`ClosedData: false`) with Autobrennero as licence holder but names no licence, so the images 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 seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/README.md b/README.md index 724ea18cb..c84a67af8 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Italy (A22 Brennero motorway). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d4fe022a8..11a97f55f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2919,7 +2919,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) + 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) + 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) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662b2a43a..c1733a555 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -523,6 +523,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvVegvesen.test.mjs", + "src/data/cctvA22.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..c438f4075 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadA22SourcesFromOpenDataHub, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'a22', + enabled: () => envEnabled('CCTV_A22_ENABLED'), + load: loadA22SourcesFromOpenDataHub, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..531a00051 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,39 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** 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.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; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..9339c2166 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -378,6 +378,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; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..89d051420 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,11 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_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, @@ -83,6 +88,7 @@ import { isLikelyNswCoordinate, isLikelyCalgaryCoordinate, isLikelyNorwayCoordinate, + isLikelyA22Coordinate, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1863,3 +1869,123 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +/** + * One Open Data Hub `WebcamInfo` item (source `a22`) -> one catalog source, + * or null. + * + * The frame must be the operator's own `WebCamImg/km.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>} 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 []; + } +} diff --git a/src/data/cctvA22.test.mjs b/src/data/cctvA22.test.mjs new file mode 100644 index 000000000..fc483bf9d --- /dev/null +++ b/src/data/cctvA22.test.mjs @@ -0,0 +1,199 @@ +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'; + +/** 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'), + [], + ); + }); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..d3cdf74e2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,13 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'a22-cctv', + html: + 'Motorway webcams (A22 Brennero–Modena): images © ' + + 'Autostrada del Brennero S.p.A., ' + + 'camera list via the Open Data Hub (NOI Techpark)', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From 166aef58db448788d58eb6db078055d1eef79af2 Mon Sep 17 00:00:00 2001 From: Jeff Blakely <7987380+jeffblakely@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:53:33 -0400 Subject: [PATCH 02/14] fix(firms): retry over IPv4 when every address fails to connect Node's network-family auto-selection gives each resolved address a short attempt timeout. On hosts where IPv6 is unreachable, a slow IPv4 handshake is abandoned for the IPv6 address, which fails at once, and the request reports only `fetch failed`. Retry such failures once with a single IPv4 connection within the same deadline, and log the per-address connection errors (never the keyed URL). Node 26 wraps the AggregateError one level deeper, so both shapes are recognised. Fixes #68 --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 9 +++ server/providers/firms.js | 119 +++++++++++++++++++++++++++++++++-- src/data/firmsProxy.test.mjs | 118 +++++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..8901fa255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- 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`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..bf26418ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1393,6 +1393,15 @@ 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; HTTP errors, +timeouts and DNS failures are not retried. 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, diff --git a/server/providers/firms.js b/server/providers/firms.js index 636968348..531c9a99f 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -1,8 +1,117 @@ 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'; +/** + * 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} + */ +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} + */ +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) => { + 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)); + } + resolve( + new Response(Readable.toWeb(response), { + status: response.statusCode || 500, + statusText: response.statusMessage || '', + headers, + }), + ); + }, + ); + 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} + */ +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 @@ -83,7 +192,7 @@ export function firmsProxy() { */ async function fetchSource(key, source) { const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`; - const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const res = await fetchFirms(url, { signal: AbortSignal.timeout(60_000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const records = parseFirmsCsv(await res.text()); if (records === null) throw new Error('non-CSV upstream response'); @@ -111,7 +220,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 }); } @@ -146,7 +255,9 @@ 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) }); + const res = await fetchFirms(url, { + signal: AbortSignal.timeout(10_000), + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const used = Number(body?.current_transactions); @@ -157,7 +268,7 @@ export function firmsProxy() { } catch (err) { console.warn( '[firms-proxy] mapkey status failed:', - err?.message || err, + describeFetchError(err), ); return null; } diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index a54bd42fe..f24d93349 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -2,6 +2,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; +import net from 'node:net'; +import { + describeFetchError, + fetchFirms, + fetchIpv4, +} 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 +23,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 +84,111 @@ 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 = null; + try { + v6 = await listen('::1', port); + t.after(() => v6.close()); + } catch { + // No IPv6 loopback on this host; the IPv4 assertion still holds. + } + await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) })); + assert.deepEqual(reached, ['127.0.0.1']); +}); From d2647f35ba055c8aa81a59d8aef7ae070b143e10 Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:14 +0000 Subject: [PATCH 03/14] Give the side panels one title style, hover and toggle inset Every rail panel title uses the command dock's label type and colour, collapsed or open, instead of three sizes at 30% opacity. Panels answer hover as the dock trays do, with a blue edge, a soft glow and an accent title. In the default theme each panel's title and collapse button sit 19px from its edge; Context's toggle no longer shrinks beside the radio button. The cyber theme keeps its own colours and insets. --- src/ui/styles/controls.css | 105 +++++++++++++++++++++++++++---- src/ui/styles/layers.css | 8 ++- src/ui/styles/recent-imagery.css | 4 -- src/ui/styles/scenes.css | 8 --- src/ui/styles/weather.css | 4 -- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 59f275109..58f756039 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -81,6 +81,23 @@ white-space: nowrap; } +/* 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. */ +:is(#left-panel-stack, #right-context-rail) + :is(.panel-header > .panel-title, .pp-header-label) { + font-family: var(--font-mono); + font-size: 0.58rem; + font-weight: 500; + letter-spacing: 0.13rem; + text-transform: uppercase; +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is(.panel-header > .panel-title, .pp-header-label, .panel-collapse-btn) { + color: rgba(206, 232, 239, 0.72); +} + /* 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']) @@ -109,6 +126,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 +135,72 @@ 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 does: a blue edge, + a soft glow and an accent title. */ +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-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 160ms ease, + box-shadow 160ms ease; +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-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 { + border-color: rgba(0, 212, 255, 0.55); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(0, 212, 255, 0.08) inset, + 0 0 1rem rgba(0, 212, 255, 0.12); +} + +:root:not([data-ui-theme='cyber']) + :is(#left-panel-stack, #right-context-rail) + :is( + .data-panel-inner, + .scene-panel-inner, + .cctv-panel-inner, + .radio-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 + :is(.panel-title, .pp-header-label, .panel-collapse-btn) { + color: var(--accent); +} + /* ── Button Grid ──────────────────────────────── */ .button-grid { display: flex; @@ -431,17 +505,26 @@ 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; + width: 22px; + height: 22px; border-radius: 6px; - font-size: 12px; + font-size: 13px; padding: 0; } diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index ea64eefc5..0c17d372f 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -58,7 +58,7 @@ background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); - padding: 14px 16px 12px; + padding: 14px 14px 12px; backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: @@ -249,10 +249,12 @@ 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; + /* Room for the radio button between the title and the toggle. */ + gap: 8px; } #right-context-rail.layout-focus { height: var(--right-stack-max-height); @@ -1002,7 +1004,7 @@ display: flex; min-height: 0; flex-direction: column; - padding: 14px 16px 12px; + padding: 14px 14px 12px; border: 1px solid var(--glass-border); border-radius: var(--panel-radius); background: var(--glass-bg); diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index e13e17b95..70ee7a9c2 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -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; } diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 29250a636..da17a9c80 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -378,14 +378,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, diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index f7cd999d3..b2c052e0e 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -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; } From 361f7de3bdaf5503ed4997970739890e1a84f95a Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:43 +0000 Subject: [PATCH 04/14] Add the changelog entry for the panel chrome change --- changelog.d/1466-panel-chrome-consistency.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1466-panel-chrome-consistency.md diff --git a/changelog.d/1466-panel-chrome-consistency.md b/changelog.d/1466-panel-chrome-consistency.md new file mode 100644 index 000000000..64aefd607 --- /dev/null +++ b/changelog.d/1466-panel-chrome-consistency.md @@ -0,0 +1,4 @@ +- Give the side panels one look. Every panel title now uses the command dock's + label type in a brighter colour, collapsed or open; a panel lights up with a + blue edge and an accent title on hover, as the dock does; and every collapse + button sits the same distance from its panel's edge (asturksever, #1466). From 2b1129c10ee502df4a873fc1bafa83ec74398384 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:31:53 +0000 Subject: [PATCH 05/14] Rename the Scenes panel to Authoring and lay it out in steps The panel reads AUTHORING and groups its controls under SCENE, SHOTS, PREVIEW and SAVE & SHARE headings, with the EDIT DETAILS / SHARE SCENE row moved beside EXPORT / IMPORT. Text is larger with more contrast, shots are numbered, every action has a tooltip and the status line is a live region. Outside playback, buttons that would do nothing are disabled. New labels are translated in every shipped language; PLAY uses its own catalog context apart from the radio's PLAY. --- docs/CURRENT-STATE.md | 32 +++++--- docs/DIRECTOR-SHARING.md | 2 +- docs/SCENE-DOCUMENT.md | 2 +- src/i18n/locales/es.json | 31 +++++-- src/i18n/locales/ru.json | 31 +++++-- src/i18n/locales/zh-CN.json | 31 +++++-- src/i18n/locales/zh-Hant.json | 31 +++++-- src/ui/sceneControls.js | 41 +++++++-- src/ui/sceneControls.test.mjs | 31 +++++++ src/ui/scenePresentation.js | 21 ++++- src/ui/sceneSharing.js | 17 ++-- src/ui/styles/scenes.css | 128 +++++++++++++++++++++++------ src/ui/templates/layer-panels.html | 33 ++++---- 13 files changed, 336 insertions(+), 95 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 146e157d8..0e98ce774 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -535,7 +535,7 @@ Native ` - - + + + +

SHOTS

- - + +
+

Click a shot to select it, double-click to rename.

+

PREVIEW

- - - + + +
-
- - - +

SAVE & SHARE

+
+ + +
0%
-
Ready
+
Ready
From 452292001ad0d3b5a060be3ee87ee65aa33dde10 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:32:27 +0000 Subject: [PATCH 06/14] Add the changelog fragment for the Authoring panel --- changelog.d/1472-authoring-panel.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1472-authoring-panel.md diff --git a/changelog.d/1472-authoring-panel.md b/changelog.d/1472-authoring-panel.md new file mode 100644 index 000000000..cec54b18e --- /dev/null +++ b/changelog.d/1472-authoring-panel.md @@ -0,0 +1,4 @@ +- Rename the Scenes panel to Authoring and lay it out in steps (Scene, Shots, + Preview, Save & share) with larger, higher-contrast text, numbered shots, a + tooltip on every action and buttons that are disabled when they would do + nothing (asturksever, #1472). From 4192a6f87d965b6b96395250543bf7396178d7c1 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 11:48:52 +0000 Subject: [PATCH 07/14] Keep Authoring within its stack and theme, and cover its wiring - Keep the documented 72px / flex-grow 1 action sizing and the 360px panel width; the larger type applies to the Authoring panel only, so CCTV and Street Level buttons are unchanged. - Scope the primary and file-row styles to the panel so Cyber's shared button surface no longer overrides them. - Shorten the scene PLAY in Spanish and Russian so it does not break mid-word, and fix two translation inconsistencies. - Map the spoken name "authoring" to the panel. - Clear the has-shots marker when the controls are destroyed. - Test the idle button states through published changes, the PLAY translation context, and where the sharing row is placed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/zh-Hant.json | 2 +- src/ui/sceneControls.js | 1 + src/ui/sceneControls.test.mjs | 141 ++++++++++++++++++++++++++++++++++ src/ui/styles/scenes.css | 18 ++--- src/voice/gevActions.js | 1 + 8 files changed, 157 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e98ce774..42865a09d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1176,7 +1176,8 @@ The panel is titled Authoring and is laid out in four labelled steps: SCENE (picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT, the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row). -Every action has a tooltip, and the status line is a polite live region. Outside +Every button in the panel's step rows has a tooltip, and the status line is a +polite live region. Outside playback a button that would do nothing is disabled: DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, PLAY and NEXT on a scene with no shots. During playback everything but STOP stays disabled as before. diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 6fd845182..e9ec5a1c6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3012,9 +3012,9 @@ "Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano", "Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual", "Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.", - "scene|PLAY": "REPRODUCIR", + "scene|PLAY": "REPROD.", "Play the whole scene from its first shot": "Reproduce toda la escena desde su primer plano", - "Stop playback (Esc)": "Detiene la reproducción (Esc)", + "Stop playback (Esc)": "Detén la reproducción (Esc)", "Play only the shot after the selected one": "Reproduce solo el plano posterior al seleccionado", "SAVE & SHARE": "GUARDAR Y COMPARTIR", "Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index eacad5511..8d0ed1f24 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3081,7 +3081,7 @@ "Add the current view, style and layers as the next shot": "Добавить текущий вид, стиль и слои следующим кадром", "Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом", "Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.", - "scene|PLAY": "ВОСПРОИЗВЕСТИ", + "scene|PLAY": "ПУСК", "Play the whole scene from its first shot": "Воспроизвести всю сцену с первого кадра", "Stop playback (Esc)": "Остановить воспроизведение (Esc)", "Play only the shot after the selected one": "Воспроизвести только кадр после выбранного", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 17a98338d..33316d6ed 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2862,7 +2862,7 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "AUTHORING": "創作", - "Create and edit scenes": "建立和編輯場景", + "Create and edit scenes": "創建和編輯場景", "SCENE": "場景", "Scene": "場景", "NEW SCENE": "新建場景", diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 3be960b9d..5976c2c05 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -272,6 +272,7 @@ export class SceneControls { ]) remove(); if (this.elements.shots) this.elements.shots.textContent = ''; + this.elements.panel?.classList.toggle('has-shots', false); presentSceneRuntime(this.elements.runtime, ''); this.setPlaybackActive(false); } diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b150d36ed..b31a1a681 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { createStateChannel } from '../app/stateChannel.js'; import { SceneControls } from './sceneControls.js'; import { SceneDirector } from '../scenes/director.js'; +import { mountSceneSharing } from './sceneSharing.js'; +import { setLocale } from '../i18n/index.js'; function fixture() { class Element extends EventTarget { @@ -466,3 +468,142 @@ test('Scene controls consume current state, preserve rows on progress, and unsub f.restore(); } }); + +test('Authoring refreshes idle buttons from published changes', () => { + const f = fixture(); + try { + f.owner.destroy(); + const channel = createStateChannel(() => f.state); + const owner = new SceneControls({ + read: () => f.state, + actions: f.actions, + elements: f.elements, + subscribe: (listener) => channel.subscribe(listener), + }); + const disabled = (name) => f.elements[name].disabled; + assert.equal(disabled('start'), false); + assert.equal(f.elements.panel.classList.contains('has-shots'), true); + + f.state.selectedShotId = null; + channel.publish({ type: 'selection-changed' }); + assert.equal(disabled('update'), true); + + f.state.scenes[0].shots = []; + channel.publish({ type: 'shot-deleted' }); + assert.equal(disabled('start'), true); + assert.equal(disabled('next'), true); + assert.equal(f.elements.panel.classList.contains('has-shots'), false); + + // A run that ends hands the buttons back with the idle rules applied. + f.state.running = true; + channel.publish({ type: 'buttons-changed' }); + assert.equal(disabled('capture'), true); + assert.equal(disabled('stop'), false); + f.state.running = false; + channel.publish({ type: 'buttons-changed' }); + assert.equal(disabled('capture'), false); + assert.equal(disabled('start'), true); + + f.state.scenes = []; + f.state.selectedSceneId = null; + channel.publish({ type: 'scene-deleted' }); + assert.equal(disabled('delete'), true); + assert.equal(disabled('capture'), true); + assert.equal(disabled('new'), false); + + f.state.scenes = [{ id: 'scene-b', title: 'B', shots: [{ id: 's' }] }]; + f.state.selectedSceneId = 'scene-b'; + f.state.selectedShotId = 's'; + channel.publish({ type: 'project-imported' }); + assert.equal(disabled('start'), false); + assert.equal(f.elements.panel.classList.contains('has-shots'), true); + + owner.destroy(); + assert.equal(f.elements.panel.classList.contains('has-shots'), false); + channel.destroy(); + } finally { + f.restore(); + } +}); + +test('Authoring PLAY uses the scene context, not the radio PLAY', (context) => { + context.after(() => setLocale('en')); + setLocale('es'); + const f = fixture(); + try { + assert.equal(f.elements.start.textContent, 'REPROD.'); + } finally { + f.restore(); + } +}); + +test('The sharing row sits under SAVE & SHARE, after the file row', () => { + const saved = globalThis.document; + const node = (className = '') => { + const element = { + className, + children: [], + dataset: {}, + parent: null, + addEventListener() {}, + removeEventListener() {}, + append(...items) { + for (const item of items) { + item.parent = element; + element.children.push(item); + } + }, + appendChild(item) { + element.append(item); + }, + after(item) { + const siblings = element.parent.children; + item.parent = element.parent; + siblings.splice(siblings.indexOf(element) + 1, 0, item); + }, + remove() { + const siblings = element.parent?.children; + if (siblings) siblings.splice(siblings.indexOf(element), 1); + }, + querySelector(selector) { + const name = selector.slice(1); + const walk = (at) => { + for (const child of at.children) { + if (child.className.split(' ').includes(name)) return child; + const found = walk(child); + if (found) return found; + } + return null; + }; + return walk(element); + }, + }; + return element; + }; + globalThis.document = { createElement: () => node() }; + try { + const panel = node(); + const inner = node('scene-panel-inner'); + const fileRow = node('scene-controls scene-file-controls'); + const status = node('scene-status'); + panel.append(inner); + inner.append(fileRow, status); + const dispose = mountSceneSharing({ edit() {}, share() {} }, panel); + const bar = inner.children[1]; + assert.equal(bar.className, 'scene-controls'); + assert.equal(inner.children[2], status); + assert.ok(bar.children.every((button) => button.title)); + dispose(); + assert.deepEqual(inner.children, [fileRow, status]); + + // Without the file row, the bar still lands inside the panel. + const plain = node(); + const plainInner = node('scene-panel-inner'); + plain.append(plainInner); + mountSceneSharing({ edit() {}, share() {} }, plain)(); + mountSceneSharing({ edit() {}, share() {} }, plain); + assert.equal(plainInner.children.length, 1); + } finally { + globalThis.document = saved; + } +}); diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 975f37a57..f2bec04d9 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -1,6 +1,6 @@ /* ── Scene Director Panel ───────────────────── */ #scene-panel { - --panel-expanded-width: 372px; + --panel-expanded-width: 360px; /* Secondary text in Authoring: dimmer than labels, but legible on the globe. */ --scene-quiet: rgba(232, 234, 237, 0.62); position: fixed; @@ -179,6 +179,9 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { .scene-btn { flex: 1; min-width: 72px; +} + +#scene-panel .scene-btn { font-size: 10px; padding: 8px 10px; } @@ -206,21 +209,16 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { margin-top: 2px; } -/* One primary action per step: CAPTURE SHOT and PLAY. */ -.scene-btn-primary:not(:disabled) { +/* One primary action per step: CAPTURE SHOT and PLAY. The panel id keeps + this above Cyber's shared button surface. */ +#scene-panel .scene-btn-primary:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); } -#scene-capture-btn { - flex: 2 1 0; -} - /* The file row is housekeeping; it stays quieter than the steps above. */ -.scene-file-controls .scene-btn { - flex: 1 1 0; - min-width: 0; +#scene-panel .scene-file-controls .scene-btn { font-size: 9px; background: transparent; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0a43f02f1..f0afc9527 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -98,6 +98,7 @@ const PANEL_ALIASES = new Map([ ['global context', 'global-context-panel'], ['right context', 'global-context-panel'], ['context right panel', 'global-context-panel'], + ['authoring', 'scene-panel'], ['scenes', 'scene-panel'], ['scene', 'scene-panel'], ['post processing', 'pp-toggles'], From 8de75b172a941825413cb99441e66b124fe7b2bd Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 12:29:11 +0000 Subject: [PATCH 08/14] Keep two shot rows visible in Authoring on shorter windows The step headings make the panel taller, so the left stack squeezed the shot list down to one row at about 1000px of window height. Its minimum now holds two rows; the panel scrolls when space is short. --- src/ui/styles/scenes.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index f2bec04d9..9893061e1 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -245,7 +245,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { flex-direction: column; gap: 6px; max-height: 260px; - min-height: 72px; + /* Two shot rows stay visible; the panel scrolls when space is short. */ + min-height: 128px; flex: 1; overflow-y: auto; padding: 4px 2px 6px; From 5a80facf8d4254079bc7601bd299a8f0ad325897 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 18:39:53 +0000 Subject: [PATCH 09/14] Address review: playable PLAY/NEXT, visible rename editor, accurate copy - PLAY and NEXT continue into the following scenes, so they stay enabled while any scene in the project has a shot, not only the selected one. - Renaming a shot drops the number and ellipsis so the editor is no longer hidden; qa-scene-controls checks a long rename stays visible. - PLAY, NEXT, EDIT DETAILS and SHARE SCENE tooltips describe what the buttons do, translated in every shipped language. - The primary actions and the file row keep a hover state. - Docs: the EXPORT rename in DIRECTOR-SHARING and the playback button states in CURRENT-STATE are accurate. - Test the voice "authoring" alias through set_panel_open. --- docs/CURRENT-STATE.md | 14 +++++++------ docs/DIRECTOR-SHARING.md | 2 +- scripts/qa-scene-controls.mjs | 33 ++++++++++++++++++++++++++++++ src/i18n/locales/es.json | 8 ++++---- src/i18n/locales/ru.json | 8 ++++---- src/i18n/locales/zh-CN.json | 8 ++++---- src/i18n/locales/zh-Hant.json | 8 ++++---- src/ui/sceneControls.js | 6 +++++- src/ui/sceneControls.test.mjs | 9 ++++++++ src/ui/scenePresentation.js | 14 +++++++------ src/ui/sceneSharing.js | 8 ++++++-- src/ui/styles/scenes.css | 18 ++++++++++++++++ src/ui/templates/layer-panels.html | 4 ++-- src/voice/gevActions.test.mjs | 23 +++++++++++++++++++++ 14 files changed, 129 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42865a09d..330249ec9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1176,12 +1176,14 @@ The panel is titled Authoring and is laid out in four labelled steps: SCENE (picker on its own row, NEW SCENE, DELETE), SHOTS (+ CAPTURE SHOT, UPDATE SHOT, the numbered shot list and a select/rename hint), PREVIEW (PLAY, STOP, NEXT) and SAVE & SHARE (EXPORT, IMPORT, RUN LOG, then the EDIT DETAILS / SHARE SCENE row). -Every button in the panel's step rows has a tooltip, and the status line is a -polite live region. Outside -playback a button that would do nothing is disabled: DELETE and CAPTURE SHOT -without a scene, UPDATE SHOT without a selected shot, PLAY and NEXT on a scene -with no shots. During playback everything but STOP stays disabled as before. -PLAY uses the `scene|PLAY` catalog entry, apart from the radio's PLAY. +Every button in the step rows has a tooltip, and the status line is a polite +live region. Outside playback a button that would do nothing is disabled: +DELETE and CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, +PLAY and NEXT when no scene in the project has a shot (both continue into the +following scenes). During playback the authoring and file actions stay disabled +and STOP is enabled, as before; RUN LOG follows the last run, and EDIT DETAILS / +SHARE SCENE keep their own checks. PLAY uses the `scene|PLAY` catalog entry, +apart from the radio's PLAY. Director clock listeners, interaction observers and scene release failures no longer leave the director inconsistent, and a phase error is kept when cleanup diff --git a/docs/DIRECTOR-SHARING.md b/docs/DIRECTOR-SHARING.md index 4e3c5a85e..84e9d514a 100644 --- a/docs/DIRECTOR-SHARING.md +++ b/docs/DIRECTOR-SHARING.md @@ -42,7 +42,7 @@ field definitions. This is a basic draft editor, not a node graph or path editor SHARE SCENE exports only the selected scene. Download scene JSON preserves its camera, timing, layers, anchors, manifests, interactions and attribution, but contains no asset bytes. The recipient needs the referenced source registrations -and files. Existing EXPORT PRESETS continues to export the entire project. +and files. EXPORT (formerly EXPORT PRESETS) continues to export the entire project. For a bundle, select the declared pack files or a folder, then Download asset bundle. Folder paths match a manifest path either directly or after removing the diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index ab92c128d..223202221 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -106,6 +106,39 @@ try { ), ), ); + // A long rename keeps its editor visible inside the shot row. + await page.click('.scene-shot-label', { count: 2 }); + await page.waitForSelector('.scene-shot-rename', { visible: true }); + await page.type( + '.scene-shot-rename', + ' with a much longer title that runs well past the shot row', + ); + await page.keyboard.press('End'); + check( + 'A long rename keeps the editor visible inside its shot row', + await page.evaluate(() => { + // The label clips with an ellipsis, which hides an editor that + // overflows it, so its content must fit its width. + const input = document.querySelector('.scene-shot-rename'); + const label = input?.closest('.scene-shot-label'); + const box = input?.getBoundingClientRect(); + const style = input && getComputedStyle(input); + return ( + !!box && + box.width > 120 && + box.height > 10 && + label.scrollWidth <= label.clientWidth + 1 && + style.visibility === 'visible' && + style.borderTopWidth !== '0px' && + document.activeElement === input + ); + }), + ); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => !document.querySelector('.scene-shot-rename'), + ); + await page.click('#scene-capture-btn'); await page.waitForFunction( () => document.querySelectorAll('.scene-shot-row').length === 2, diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e9ec5a1c6..759db7117 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3013,13 +3013,13 @@ "Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual", "Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.", "scene|PLAY": "REPROD.", - "Play the whole scene from its first shot": "Reproduce toda la escena desde su primer plano", "Stop playback (Esc)": "Detén la reproducción (Esc)", - "Play only the shot after the selected one": "Reproduce solo el plano posterior al seleccionado", "SAVE & SHARE": "GUARDAR Y COMPARTIR", "Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde", "Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado", "Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción", - "Edit the scene title and description": "Edita el título y la descripción de la escena", - "Share this scene as a link or file": "Comparte esta escena como enlace o archivo" + "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto", + "Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta", + "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", + "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 8d0ed1f24..c65265d5e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3082,13 +3082,13 @@ "Replace the selected shot with the current view": "Заменить выбранный кадр текущим видом", "Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.", "scene|PLAY": "ПУСК", - "Play the whole scene from its first shot": "Воспроизвести всю сцену с первого кадра", "Stop playback (Esc)": "Остановить воспроизведение (Esc)", - "Play only the shot after the selected one": "Воспроизвести только кадр после выбранного", "SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ", "Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта", "Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON", "Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения", - "Edit the scene title and description": "Изменить название и описание сцены", - "Share this scene as a link or file": "Поделиться сценой ссылкой или файлом" + "Play from this scene through the rest of the project": "Воспроизвести с этой сцены и далее по всему проекту", + "Play the shot after the selected one, moving on to the next scene when needed": "Воспроизвести кадр после выбранного, при необходимости перейдя к следующей сцене", + "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", + "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 21632d398..63f4b6b05 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2875,13 +2875,13 @@ "Replace the selected shot with the current view": "用当前视角替换所选镜头", "Click a shot to select it, double-click to rename.": "单击镜头即可选中,双击可重命名。", "scene|PLAY": "播放", - "Play the whole scene from its first shot": "从第一个镜头开始播放整个场景", "Stop playback (Esc)": "停止播放(Esc)", - "Play only the shot after the selected one": "仅播放所选镜头的下一个镜头", "SAVE & SHARE": "保存与分享", "Download every scene as a JSON file you can import later": "将所有场景下载为 JSON 文件,以便日后导入", "Load scenes from an exported JSON file": "从导出的 JSON 文件加载场景", "Download the timing log of the last play": "下载上次播放的计时日志", - "Edit the scene title and description": "编辑场景标题和描述", - "Share this scene as a link or file": "以链接或文件形式分享此场景" + "Play from this scene through the rest of the project": "从此场景开始播放项目中的其余场景", + "Play the shot after the selected one, moving on to the next scene when needed": "播放所选镜头的下一个镜头,必要时进入下一个场景", + "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", + "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 33316d6ed..d42742659 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2875,13 +2875,13 @@ "Replace the selected shot with the current view": "用當前視角替換所選鏡頭", "Click a shot to select it, double-click to rename.": "單擊鏡頭即可選中,雙擊可重命名。", "scene|PLAY": "播放", - "Play the whole scene from its first shot": "從第一個鏡頭開始播放整個場景", "Stop playback (Esc)": "停止播放(Esc)", - "Play only the shot after the selected one": "僅播放所選鏡頭的下一個鏡頭", "SAVE & SHARE": "保存與分享", "Download every scene as a JSON file you can import later": "將所有場景下載為 JSON 文件,以便日後導入", "Load scenes from an exported JSON file": "從導出的 JSON 文件加載場景", "Download the timing log of the last play": "下載上次播放的計時日誌", - "Edit the scene title and description": "編輯場景標題和描述", - "Share this scene as a link or file": "以鏈接或文件形式分享此場景" + "Play from this scene through the rest of the project": "從此場景開始播放項目中的其餘場景", + "Play the shot after the selected one, moving on to the next scene when needed": "播放所選鏡頭的下一個鏡頭,必要時進入下一個場景", + "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", + "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包" } diff --git a/src/ui/sceneControls.js b/src/ui/sceneControls.js index 5976c2c05..c7e023f10 100644 --- a/src/ui/sceneControls.js +++ b/src/ui/sceneControls.js @@ -220,7 +220,11 @@ export class SceneControls { const shots = scene?.shots ?? []; presentSceneButtons(this.elements, running, state.hasRun, { hasScene: !!scene, - shotCount: shots.length, + // PLAY and NEXT run on into the following scenes. + playableShots: (state.scenes ?? []).reduce( + (total, item) => total + (item.shots?.length ?? 0), + 0, + ), hasSelectedShot: shots.some((shot) => shot.id === state.selectedShotId), }); this.elements.panel?.classList.toggle('has-shots', shots.length > 0); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b31a1a681..f53612a66 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -184,7 +184,16 @@ test('Authoring disables idle actions that would do nothing', () => { assert.equal(f.elements.update.disabled, true); assert.equal(f.elements.start.disabled, false); + // PLAY and NEXT run on into the following scenes, so an empty selected + // scene beside one with shots keeps them available. f.state.scenes[0].shots = []; + f.state.scenes.push({ id: 'scene-b', title: 'B', shots: [{ id: 'b1' }] }); + f.owner.setButtons(false); + assert.equal(f.elements.start.disabled, false); + assert.equal(f.elements.next.disabled, false); + assert.equal(f.elements.update.disabled, true); + + f.state.scenes.pop(); f.owner.setButtons(false); assert.equal(f.elements.start.disabled, true); assert.equal(f.elements.next.disabled, true); diff --git a/src/ui/scenePresentation.js b/src/ui/scenePresentation.js index 4f5711226..e77c832f0 100644 --- a/src/ui/scenePresentation.js +++ b/src/ui/scenePresentation.js @@ -173,22 +173,24 @@ export function presentSceneSelection(element, selectedShotId) { } /** - * Everything but STOP is off while a scene plays. Otherwise a button is off - * when it would do nothing: DELETE and CAPTURE SHOT without a scene, UPDATE - * SHOT without a selected shot, PLAY and NEXT on a scene with no shots. + * While a scene plays, the authoring and file actions here are off and STOP + * is on. Otherwise a button is off when it would do nothing: DELETE and + * CAPTURE SHOT without a scene, UPDATE SHOT without a selected shot, PLAY and + * NEXT when no scene in the project has a shot (both continue into the + * following scenes). */ export function presentSceneButtons( elements, running, hasRun, - { hasScene = true, shotCount = 1, hasSelectedShot = true } = {}, + { hasScene = true, playableShots = 1, hasSelectedShot = true } = {}, ) { const idleOff = { delete: !hasScene, capture: !hasScene, update: !hasSelectedShot, - start: shotCount === 0, - next: shotCount === 0, + start: playableShots === 0, + next: playableShots === 0, }; for (const name of [ 'start', diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index 30fbe9732..e8ebf9110 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -181,8 +181,12 @@ export function mountSceneSharing( bar.dataset.directorAuthoring = ''; bar.className = 'scene-controls'; const entries = [ - [t('EDIT DETAILS'), t('Edit the scene title and description'), edit], - [t('SHARE SCENE'), t('Share this scene as a link or file'), share], + [t('EDIT DETAILS'), t('Edit anchors, data packs and shot details'), edit], + [ + t('SHARE SCENE'), + t('Download this scene as JSON or an asset bundle'), + share, + ], ]; const removers = []; for (const [text, title, fn] of entries) { diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 9893061e1..441bbbf65 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -223,6 +223,14 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { background: transparent; } +/* Keep the shared hover on the restyled actions. */ +#scene-panel .scene-btn-primary:hover:not(:disabled), +#scene-panel .scene-file-controls .scene-btn:hover:not(:disabled) { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 16%, transparent); +} + .scene-shot-hint { margin: 0 0 8px; font-size: 11px; @@ -311,6 +319,16 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { color: var(--text-primary); } +/* While renaming, the label holds only the editor at full width. */ +.scene-shot-label:has(.scene-shot-rename) { + overflow: visible; + white-space: normal; +} + +.scene-shot-label:has(.scene-shot-rename)::before { + content: none; +} + /* Shots are numbered 01, 02… in playback order. */ .scene-shot-label::before { content: counter(scene-shot, decimal-leading-zero); diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 02b02d05a..5f5d11b4d 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -166,9 +166,9 @@

Click a shot to select it, double-click to rename.

PREVIEW

- + - +

SAVE & SHARE

diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 4a3a8ec72..6145a998d 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3931,3 +3931,26 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.ok, true); assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); + +test('set_panel_open accepts the Authoring name for the scene panel', async () => { + const calls = []; + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1000), + }, + }, + styleManager: { + setPanelCollapsed: (...args) => calls.push(args), + }, + dataManager: { layers: new Map(), isEnabled: () => false, getAll: () => [] }, + }); + for (const panel of ['authoring', 'Authoring', 'scenes']) { + const result = await runner('set_panel_open', { panelId: panel, open: true }); + assert.equal(result.panelId, 'scene-panel'); + } + assert.deepEqual(calls.at(-1), ['scene-panel', false, { explicit: true }]); +}); From a4820315d9ac091753e22faab192050711434a23 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 29 Oct 2026 18:47:56 +0000 Subject: [PATCH 10/14] Translate Authoring for the six new languages and fit long words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Authoring labels and tooltips to the de, fr, it, ja, pt and uk catalogs that arrived from main, and prunes the retired SCENES-era entries there. The file row's tighter padding and letter spacing keep one long translated word per line in the narrow Cyber panel (IMPORTIEREN, ESECUZIONE, ІМПОРТУВАТИ, EJECUCIÓN). --- src/i18n/locales/de.json | 31 ++++++++++++++++++++++++------- src/i18n/locales/fr.json | 31 ++++++++++++++++++++++++------- src/i18n/locales/it.json | 31 ++++++++++++++++++++++++------- src/i18n/locales/ja.json | 31 ++++++++++++++++++++++++------- src/i18n/locales/pt.json | 31 ++++++++++++++++++++++++------- src/i18n/locales/uk.json | 31 ++++++++++++++++++++++++------- src/ui/styles/scenes.css | 3 +++ 7 files changed, 147 insertions(+), 42 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 874fd5f31..3c5618b5b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -833,13 +833,7 @@ "SAVE CAL": "KAL. SPEICHERN", "RESET CAL": "KAL. ZURÜCKSETZEN", "SCENE SUMMARY": "SZENEN-ÜBERSICHT", - "SCENES": "SZENEN", - "Scene recipe": "Szenenrezept", - "NEW": "NEU", - "CAPTURE SHOT": "AUFNAHME SPEICHERN", "UPDATE SHOT": "AUFNAHME AKTUALISIEREN", - "START": "START", - "EXPORT PRESETS": "PRESETS EXPORTIEREN", "IMPORT": "IMPORTIEREN", "RUN LOG": "PROTOKOLL", "POWER UP": "STARTKLAR", @@ -2922,5 +2916,28 @@ "Malformed cyclone snapshot": "Fehlerhafter Wirbelsturm-Snapshot", "STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache", "routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig", - "route proxy error": "Fehler des Routen-Proxys" + "route proxy error": "Fehler des Routen-Proxys", + "scene|PLAY": "START", + "AUTHORING": "ERSTELLUNG", + "Create and edit scenes": "Szenen erstellen und bearbeiten", + "SCENE": "SZENE", + "Scene": "Szene", + "NEW SCENE": "NEUE SZENE", + "Start a new, empty scene": "Neue, leere Szene beginnen", + "DELETE": "LÖSCHEN", + "Delete this scene and all of its shots": "Diese Szene mit allen Aufnahmen löschen", + "SHOTS": "AUFNAHMEN", + "+ CAPTURE SHOT": "+ AUFNAHME SPEICHERN", + "Add the current view, style and layers as the next shot": "Aktuelle Ansicht, Stil und Ebenen als nächste Aufnahme hinzufügen", + "Replace the selected shot with the current view": "Ausgewählte Aufnahme durch die aktuelle Ansicht ersetzen", + "Click a shot to select it, double-click to rename.": "Aufnahme anklicken zum Auswählen, Doppelklick zum Umbenennen.", + "Play from this scene through the rest of the project": "Ab dieser Szene bis zum Ende des Projekts abspielen", + "Stop playback (Esc)": "Wiedergabe stoppen (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Aufnahme nach der ausgewählten abspielen, bei Bedarf in der nächsten Szene", + "SAVE & SHARE": "SPEICHERN & TEILEN", + "Download every scene as a JSON file you can import later": "Alle Szenen als JSON-Datei zum späteren Import herunterladen", + "Load scenes from an exported JSON file": "Szenen aus einer exportierten JSON-Datei laden", + "Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen", + "Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten", + "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 005a22417..9fbe32264 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2954,13 +2954,7 @@ "ALL": "TOUTES", "FLAT": "CLASSIQUES", "SINCE": "DEPUIS", - "SCENES": "SCÈNES", - "Scene recipe": "Recette de scène", - "NEW": "NOUV.", - "CAPTURE SHOT": "CAPTURER UN PLAN", "UPDATE SHOT": "MAJ DU PLAN", - "START": "LANCER", - "EXPORT PRESETS": "EXPORTER LES PRÉRÉGLAGES", "IMPORT": "IMPORTER", "RUN LOG": "JOURNAL", "POWER UP": "ALLUMER", @@ -2991,5 +2985,28 @@ "ESC to dismiss": "ESC pour fermer", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Malformed cyclone snapshot": "Instantané cyclonique mal formé", - "Cyclone request timed out": "Délai dépassé pour la requête cyclonique" + "Cyclone request timed out": "Délai dépassé pour la requête cyclonique", + "scene|PLAY": "LIRE", + "AUTHORING": "CRÉATION", + "Create and edit scenes": "Créer et modifier des scènes", + "SCENE": "SCÈNE", + "Scene": "Scène", + "NEW SCENE": "NOUVELLE SCÈNE", + "Start a new, empty scene": "Commencer une nouvelle scène vide", + "DELETE": "SUPPRIMER", + "Delete this scene and all of its shots": "Supprimer cette scène et tous ses plans", + "SHOTS": "PLANS", + "+ CAPTURE SHOT": "+ CAPTURER UN PLAN", + "Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant", + "Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle", + "Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.", + "Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet", + "Stop playback (Esc)": "Arrêter la lecture (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin", + "SAVE & SHARE": "ENREGISTRER ET PARTAGER", + "Download every scene as a JSON file you can import later": "Télécharger toutes les scènes dans un fichier JSON à importer plus tard", + "Load scenes from an exported JSON file": "Charger des scènes depuis un fichier JSON exporté", + "Download the timing log of the last play": "Télécharger le journal de minutage de la dernière lecture", + "Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans", + "Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bc9732d67..42938ab2e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -2954,13 +2954,7 @@ "ALL": "TUTTE", "FLAT": "PIATTE", "SINCE": "DAL", - "SCENES": "SCENE", - "Scene recipe": "Ricetta della scena", - "NEW": "NUOVA", - "CAPTURE SHOT": "CATTURA SCATTO", "UPDATE SHOT": "AGGIORNA SCATTO", - "START": "AVVIA", - "EXPORT PRESETS": "ESPORTA PRESET", "IMPORT": "IMPORTA", "RUN LOG": "LOG ESECUZIONE", "POWER UP": "ACCENDI", @@ -2991,5 +2985,28 @@ "ESC to dismiss": "ESC per chiudere", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.", "Malformed cyclone snapshot": "Snapshot dei cicloni non valido", - "Cyclone request timed out": "La richiesta dei cicloni è andata in timeout" + "Cyclone request timed out": "La richiesta dei cicloni è andata in timeout", + "scene|PLAY": "AVVIA", + "AUTHORING": "CREAZIONE", + "Create and edit scenes": "Crea e modifica le scene", + "SCENE": "SCENA", + "Scene": "Scena", + "NEW SCENE": "NUOVA SCENA", + "Start a new, empty scene": "Inizia una nuova scena vuota", + "DELETE": "ELIMINA", + "Delete this scene and all of its shots": "Elimina questa scena e tutti i suoi scatti", + "SHOTS": "SCATTI", + "+ CAPTURE SHOT": "+ CATTURA SCATTO", + "Add the current view, style and layers as the next shot": "Aggiungi la vista, lo stile e i livelli attuali come scatto successivo", + "Replace the selected shot with the current view": "Sostituisci lo scatto selezionato con la vista attuale", + "Click a shot to select it, double-click to rename.": "Fai clic su uno scatto per selezionarlo, doppio clic per rinominarlo.", + "Play from this scene through the rest of the project": "Riproduci da questa scena fino alla fine del progetto", + "Stop playback (Esc)": "Interrompi la riproduzione (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Riproduci lo scatto dopo quello selezionato, passando alla scena successiva se serve", + "SAVE & SHARE": "SALVA E CONDIVIDI", + "Download every scene as a JSON file you can import later": "Scarica tutte le scene in un file JSON da importare in seguito", + "Load scenes from an exported JSON file": "Carica le scene da un file JSON esportato", + "Download the timing log of the last play": "Scarica il log dei tempi dell'ultima riproduzione", + "Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti", + "Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7ac2840f6..34e4ffaf5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2814,13 +2814,7 @@ "ALL": "すべて", "FLAT": "平面", "SINCE": "期間", - "SCENES": "シーン", - "Scene recipe": "シーンレシピ", - "NEW": "新規", - "CAPTURE SHOT": "ショット取得", "UPDATE SHOT": "ショット更新", - "START": "開始", - "EXPORT PRESETS": "エクスポート", "IMPORT": "インポート", "RUN LOG": "実行ログ", "POWER UP": "パワーアップ", @@ -2853,5 +2847,28 @@ "routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます", "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", - "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました" + "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", + "scene|PLAY": "再生", + "AUTHORING": "作成", + "Create and edit scenes": "シーンを作成・編集", + "SCENE": "シーン", + "Scene": "シーン", + "NEW SCENE": "新規シーン", + "Start a new, empty scene": "空の新しいシーンを作成", + "DELETE": "削除", + "Delete this scene and all of its shots": "このシーンとすべてのショットを削除", + "SHOTS": "ショット", + "+ CAPTURE SHOT": "+ ショット取得", + "Add the current view, style and layers as the next shot": "現在の視点・スタイル・レイヤーを次のショットとして追加", + "Replace the selected shot with the current view": "選択中のショットを現在の視点で置き換え", + "Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。", + "Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生", + "Stop playback (Esc)": "再生を停止(Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)", + "SAVE & SHARE": "保存と共有", + "Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード", + "Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む", + "Download the timing log of the last play": "前回の再生のタイミングログをダウンロード", + "Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集", + "Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5e9a9a3ff..e4fe30626 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2956,13 +2956,7 @@ "ALL": "TODAS", "FLAT": "PLANAS", "SINCE": "DESDE", - "SCENES": "CENAS", - "Scene recipe": "Receita de cena", - "NEW": "NOVA", - "CAPTURE SHOT": "CAPTURAR PLANO", "UPDATE SHOT": "ATUALIZAR PLANO", - "START": "INICIAR", - "EXPORT PRESETS": "EXPORTAR PREDEF.", "IMPORT": "IMPORTAR", "RUN LOG": "REGISTRO", "POWER UP": "ATIVAR", @@ -2991,5 +2985,28 @@ "Begin with a clean globe": "Comece com um globo limpo", "Don't show this again": "Não mostrar novamente", "ESC to dismiss": "ESC para fechar", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa." + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.", + "scene|PLAY": "INICIAR", + "AUTHORING": "CRIAÇÃO", + "Create and edit scenes": "Criar e editar cenas", + "SCENE": "CENA", + "Scene": "Cena", + "NEW SCENE": "NOVA CENA", + "Start a new, empty scene": "Começar uma nova cena vazia", + "DELETE": "EXCLUIR", + "Delete this scene and all of its shots": "Excluir esta cena e todos os seus planos", + "SHOTS": "PLANOS", + "+ CAPTURE SHOT": "+ CAPTURAR PLANO", + "Add the current view, style and layers as the next shot": "Adicionar a vista, o estilo e as camadas atuais como próximo plano", + "Replace the selected shot with the current view": "Substituir o plano selecionado pela vista atual", + "Click a shot to select it, double-click to rename.": "Clique em um plano para selecioná-lo e clique duas vezes para renomeá-lo.", + "Play from this scene through the rest of the project": "Reproduzir desta cena até o fim do projeto", + "Stop playback (Esc)": "Parar a reprodução (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Reproduzir o plano após o selecionado, passando à próxima cena se necessário", + "SAVE & SHARE": "SALVAR E COMPARTILHAR", + "Download every scene as a JSON file you can import later": "Baixar todas as cenas em um arquivo JSON para importar depois", + "Load scenes from an exported JSON file": "Carregar cenas de um arquivo JSON exportado", + "Download the timing log of the last play": "Baixar o registro de tempos da última reprodução", + "Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos", + "Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f87454f1a..28fab0d40 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3023,13 +3023,7 @@ "ALL": "УСІ", "FLAT": "ПЛОСКІ", "SINCE": "З ДАТИ", - "SCENES": "СЦЕНИ", - "Scene recipe": "Рецепт сцени", - "NEW": "НОВА", - "CAPTURE SHOT": "ЗНЯТИ КАДР", "UPDATE SHOT": "ОНОВИТИ КАДР", - "START": "СТАРТ", - "EXPORT PRESETS": "ЕКСПОРТУВАТИ ПРЕСЕТИ", "IMPORT": "ІМПОРТУВАТИ", "RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ", "POWER UP": "ЗАПУСК", @@ -3060,5 +3054,28 @@ "ESC to dismiss": "ESC — приховати", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.", "Malformed cyclone snapshot": "Некоректний знімок циклонів", - "Cyclone request timed out": "Минув час очікування запиту циклонів" + "Cyclone request timed out": "Минув час очікування запиту циклонів", + "scene|PLAY": "СТАРТ", + "AUTHORING": "РЕДАКТОР", + "Create and edit scenes": "Створення й редагування сцен", + "SCENE": "СЦЕНА", + "Scene": "Сцена", + "NEW SCENE": "НОВА СЦЕНА", + "Start a new, empty scene": "Створити нову порожню сцену", + "DELETE": "ВИДАЛИТИ", + "Delete this scene and all of its shots": "Видалити цю сцену з усіма кадрами", + "SHOTS": "КАДРИ", + "+ CAPTURE SHOT": "+ ЗНЯТИ КАДР", + "Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр", + "Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом", + "Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.", + "Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту", + "Stop playback (Esc)": "Зупинити відтворення (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени", + "SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ", + "Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту", + "Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON", + "Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення", + "Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів", + "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів" } diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index 441bbbf65..726d7507a 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -220,6 +220,9 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { /* The file row is housekeeping; it stays quieter than the steps above. */ #scene-panel .scene-file-controls .scene-btn { font-size: 9px; + /* Room for one long translated word (IMPORTIEREN, ІМПОРТУВАТИ) per line. */ + padding: 8px 4px; + letter-spacing: 0.6px; background: transparent; } From 44f9138d1e71827b34c4a2812ab7f2d802a6ddc6 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 30 Oct 2026 13:05:33 +0000 Subject: [PATCH 11/14] Keep panel titles inside their cards and scope the hover Collapsed Recent Imagery (English) and Data Layers (Portuguese) pushed their toggles out of the card. Collapsed rail launchers now drop the divider and pack closer, translated collapsed titles use compact type, and any title that still does not fit ends in an ellipsis. The hover now matches the dock trays' hover rather than their pinned look, only on pointers that can hover, and recolours only the hovered panel's own header, so Radio and body labels keep their colours. The cyber theme keeps main's gutters and Display toggle size, the dock and rails share --dock-label-* tokens, and the dimmed open Context title rule is gone. --- changelog.d/1466-panel-chrome-consistency.md | 7 +- docs/CURRENT-STATE.md | 2 +- src/contextTabKeyboard.test.mjs | 8 +- src/ui/railPanelChrome.test.mjs | 193 ++++++++++++++++++ src/ui/styles/command-dock-trays.css | 8 +- src/ui/styles/controls.css | 194 ++++++++++++------- src/ui/styles/foundation.css | 5 + src/ui/styles/layers.css | 22 +-- 8 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 src/ui/railPanelChrome.test.mjs diff --git a/changelog.d/1466-panel-chrome-consistency.md b/changelog.d/1466-panel-chrome-consistency.md index 64aefd607..c7f1f28a1 100644 --- a/changelog.d/1466-panel-chrome-consistency.md +++ b/changelog.d/1466-panel-chrome-consistency.md @@ -1,4 +1,5 @@ - Give the side panels one look. Every panel title now uses the command dock's - label type in a brighter colour, collapsed or open; a panel lights up with a - blue edge and an accent title on hover, as the dock does; and every collapse - button sits the same distance from its panel's edge (asturksever, #1466). + 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 966bb3878..73c984471 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4797,7 +4797,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. diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index ca44d4c25..528341dd4 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -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\)/, + '', ); }); diff --git a/src/ui/railPanelChrome.test.mjs b/src/ui/railPanelChrome.test.mjs new file mode 100644 index 000000000..bfcd7845e --- /dev/null +++ b/src/ui/railPanelChrome.test.mjs @@ -0,0 +1,193 @@ +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 }), +); +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/); +}); + +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), + ); + for (const body of [dock?.body, rail?.body]) { + assert.match(body || '', /font-size:\s*var\(--dock-label-font-size\)/); + assert.match( + body || '', + /letter-spacing:\s*var\(--dock-label-letter-spacing\)/, + ); + } +}); + +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', () => { + const compact = railRules.filter(({ selector }) => + /\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(selector), + ); + 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\)/); +}); diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index e3607a8ce..ecc6bc432 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -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 { diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 58f756039..936f095df 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -86,28 +86,92 @@ :is(#left-panel-stack, #right-context-rail) :is(.panel-header > .panel-title, .pp-header-label) { font-family: var(--font-mono); - font-size: 0.58rem; - font-weight: 500; - letter-spacing: 0.13rem; + font-size: var(--dock-label-font-size); + font-weight: var(--dock-label-font-weight); + letter-spacing: var(--dock-label-letter-spacing); 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; +} + +/* 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) - :is(.panel-header > .panel-title, .pp-header-label, .panel-collapse-btn) { - color: rgba(206, 232, 239, 0.72); + > .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; } -/* A longer translated title in a collapsed rail launcher tightens its - tracking rather than pushing the disclosure button out of the card. */ +: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. */ html:not([lang='en']) - #right-context-rail + :is(#left-panel-stack, #right-context-rail) .panel-collapsible.collapsed .panel-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: 1.5px; + font-size: 8px; + letter-spacing: 1px; } .panel-divider { @@ -141,64 +205,42 @@ html:not([lang='en']) background: rgba(255, 255, 255, 0.07); } -/* A side panel answers the pointer as the command dock does: a blue edge, - a soft glow and an accent title. */ -:root:not([data-ui-theme='cyber']) - :is(#left-panel-stack, #right-context-rail) - :is( - .data-panel-inner, - .scene-panel-inner, - .cctv-panel-inner, - .radio-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 160ms ease, - box-shadow 160ms ease; -} +/* 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) - :is( - .data-panel-inner, - .scene-panel-inner, - .cctv-panel-inner, - .radio-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 { - border-color: rgba(0, 212, 255, 0.55); - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(0, 212, 255, 0.08) inset, - 0 0 1rem rgba(0, 212, 255, 0.12); -} - -:root:not([data-ui-theme='cyber']) - :is(#left-panel-stack, #right-context-rail) - :is( - .data-panel-inner, - .scene-panel-inner, - .cctv-panel-inner, - .radio-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 - :is(.panel-title, .pp-header-label, .panel-collapse-btn) { - color: var(--accent); + :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 ──────────────────────────────── */ @@ -521,11 +563,21 @@ html:not([lang='en']) } #pp-toggles > .pp-header-row .pp-collapse-btn { + width: 20px; + height: 20px; + border-radius: 6px; + font-size: 12px; + 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; - border-radius: 6px; font-size: 13px; - padding: 0; } #pp-toggles.collapsed { diff --git a/src/ui/styles/foundation.css b/src/ui/styles/foundation.css index 2c2006094..185b256af 100644 --- a/src/ui/styles/foundation.css +++ b/src/ui/styles/foundation.css @@ -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; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index a51bdc052..0f8ed5522 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -58,7 +58,7 @@ background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); - padding: 14px 14px 12px; + padding: 14px 16px 12px; backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: @@ -253,8 +253,6 @@ } #right-context-rail #global-context-panel.collapsed .panel-header { margin: 0; - /* Room for the radio button between the title and the toggle. */ - gap: 8px; } #right-context-rail.layout-focus { height: var(--right-stack-max-height); @@ -991,19 +989,12 @@ #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; min-height: 0; flex-direction: column; - padding: 14px 14px 12px; + padding: 14px 16px 12px; border: 1px solid var(--glass-border); border-radius: var(--panel-radius); background: var(--glass-bg); @@ -1013,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); From df01964171d621bcd4233d75c27cb0f6a144b279 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 30 Oct 2026 14:50:27 +0000 Subject: [PATCH 12/14] fix(firms): keep the IPv4 retry from crashing the server; guard the call sites - fetchIpv4 builds its Response inside the https response callback, outside the promise. A 204, 205 or 304, a status above 599 or an invalid reason phrase made the Response constructor throw there, an uncaught exception that stops the dev server. Null-body statuses now get a null body and anything else that cannot become a Response rejects, as the Radio Browser transport does. - A non-OK FIRMS or mapkey-status response cancels its body so the IPv4 socket closes at once instead of at the deadline. - Tests: the crash cases (fail on the old code), a source check that every upstream request goes through fetchFirms and that fetchSource's body read shares its deadline (fails if either call reverts to fetch), a real-fetch contract test for the address-selection error shape, and an explicit skip in the IPv4-only socket test where the host cannot tell the families apart. - Changelog entry moves to changelog.d/1078-firms-ipv4-fallback.md per CONTRIBUTING; CURRENT-STATE says which timeouts are retried and that the retry does not follow redirects. --- changelog.d/1078-firms-ipv4-fallback.md | 4 + docs/CURRENT-STATE.md | 12 ++- server/providers/firms.js | 49 +++++++--- src/data/firmsProxy.test.mjs | 118 +++++++++++++++++++++++- 4 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 changelog.d/1078-firms-ipv4-fallback.md diff --git a/changelog.d/1078-firms-ipv4-fallback.md b/changelog.d/1078-firms-ipv4-fallback.md new file mode 100644 index 000000000..ff4e60e1b --- /dev/null +++ b/changelog.d/1078-firms-ipv4-fallback.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index feddb04be..8298b16c4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2118,10 +2118,14 @@ 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; HTTP errors, -timeouts and DNS failures are not retried. FIRMS warnings include the -per-address connection errors and never the request URL, which embeds the -MAP_KEY. +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 diff --git a/server/providers/firms.js b/server/providers/firms.js index 79c69a7f6..20a2f4bab 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -6,6 +6,8 @@ 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 @@ -71,19 +73,31 @@ export function fetchIpv4(url, { signal } = {}) { agent: false, }, (response) => { - 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)); + // 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); } - resolve( - new Response(Readable.toWeb(response), { - status: response.statusCode || 500, - statusText: response.statusMessage || '', - headers, - }), - ); }, ); request.on('error', reject); @@ -201,7 +215,11 @@ export function firmsProxy() { 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 fetchFirms(url, { signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + 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), ); @@ -268,7 +286,10 @@ export function firmsProxy() { const res = await fetchFirms(url, { signal: AbortSignal.timeout(10_000), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + 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); diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index f24d93349..9ed35ba24 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -1,12 +1,17 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; -import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; +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'); @@ -182,13 +187,118 @@ test('the IPv4 retry connects to an IPv4 address only', async (t) => { const v4 = await listen('127.0.0.1', 0); t.after(() => v4.close()); const { port } = v4.address(); - let v6 = null; + let v6; try { v6 = await listen('::1', port); - t.after(() => v6.close()); } catch { - // No IPv6 loopback on this host; the IPv4 assertion still holds. + 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'); +}); From d6c60f94b6a978c334150eed3585bb0fc056e989 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 30 Oct 2026 14:50:53 +0000 Subject: [PATCH 13/14] fix(cctv): pin A22 frame redirects to WebCamImg/ and refresh on the highway cadence - Add A22_IMAGE_ORIGIN to the pinned frame directories, so a same-host redirect from www.autobrennero.it/WebCamImg/ cannot leave that directory, as for the other directory-pinned packs. - Give Autostrada del Brennero a 3-minute ambient refresh in cctvLod.js, like the other highway packs, instead of the 5-minute default. - DATA_SOURCES: the Open Data Hub now tags every A22 record CC0; note that this is the aggregator's metadata, not Autobrennero's own terms. - Tests for the redirect pin and the refresh cadence. --- DATA_SOURCES.md | 4 ++-- server/providers/cctv/media.js | 2 ++ src/data/cctvA22.test.mjs | 27 +++++++++++++++++++++++++++ src/data/cctvLod.js | 3 +++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index f7c4c4d07..a4035f175 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -69,7 +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 as open data (`ClosedData: false`, licence holder Autobrennero, no licence named) | "Webcam images © Autostrada del Brennero S.p.A., via the Open Data Hub (NOI Techpark)" (courtesy) | +| **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á" | @@ -177,7 +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 `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. -- **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.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 marks the list as open (`ClosedData: false`) with Autobrennero as licence holder but names no licence, so the images 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`. +- **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.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. diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 206a24888..7f89f2ae1 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -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, [ diff --git a/src/data/cctvA22.test.mjs b/src/data/cctvA22.test.mjs index fc483bf9d..b9ac24b20 100644 --- a/src/data/cctvA22.test.mjs +++ b/src/data/cctvA22.test.mjs @@ -11,6 +11,8 @@ import { 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 = ( @@ -197,3 +199,28 @@ test('CCTV_A22_ENABLED=0 keeps the lane from being loaded', async (t) => { ); }); }); + +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); +}); diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 834dd964f..e1d54ae62 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -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, }); /** From ab1239d3ed9feae0efdfcec052a61b186df77b2c Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 30 Oct 2026 15:26:21 +0000 Subject: [PATCH 14/14] Cap rail title size and keep compact type to launchers The rail titles took the dock's rem type inside fixed 176px launchers, so a larger browser font cut collapsed titles such as Recent Imagery short. Cap the rail type at its 16px-root size. The compact type for translated titles now skips Chinese and Japanese, which fit at full size and keep their 9px floor, and reaches only the rail's own launchers, so Radio inside Context keeps its type. Tests now check the launcher divider, gap, toggle margin and flex-shrink rules by their declarations, not only their selectors. --- src/ui/railPanelChrome.test.mjs | 77 ++++++++++++++++++++++++++++----- src/ui/styles/controls.css | 21 ++++++--- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/ui/railPanelChrome.test.mjs b/src/ui/railPanelChrome.test.mjs index bfcd7845e..3b79bbcc5 100644 --- a/src/ui/railPanelChrome.test.mjs +++ b/src/ui/railPanelChrome.test.mjs @@ -9,7 +9,10 @@ const clean = (selector) => .replace(/\s+/g, ' ') .trim(); const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map( - ([, selector, body]) => ({ selector: clean(selector), body }), + ([, selector, body]) => ({ + selector: clean(selector), + body: body.replace(/\/\*[\s\S]*?\*\//g, ''), + }), ); const railRules = rules.filter(({ selector }) => selector.includes('#left-panel-stack, #right-context-rail'), @@ -94,6 +97,47 @@ test('collapsed launchers on both rails keep titles inside the card', () => { ); 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', () => { @@ -122,13 +166,21 @@ test('rail titles and dock labels share one set of label tokens', () => { const rail = railRules.find(({ body }) => /--dock-label-font-size/.test(body), ); - for (const body of [dock?.body, rail?.body]) { - assert.match(body || '', /font-size:\s*var\(--dock-label-font-size\)/); - assert.match( - body || '', - /letter-spacing:\s*var\(--dock-label-letter-spacing\)/, - ); - } + 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', () => { @@ -165,8 +217,13 @@ test('default theme overrides never reach the cyber theme', () => { }); test('launcher compaction and header colour stay on the rail panels themselves', () => { - const compact = railRules.filter(({ selector }) => - /\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(selector), + // 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) { diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 936f095df..b50e9c2a1 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -82,13 +82,16 @@ } /* 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. */ + 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: var(--dock-label-font-size); + font-size: min(var(--dock-label-font-size), 9.28px); font-weight: var(--dock-label-font-weight); - letter-spacing: var(--dock-label-letter-spacing); + letter-spacing: min(var(--dock-label-letter-spacing), 2.08px); text-transform: uppercase; } @@ -165,11 +168,15 @@ /* 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. */ -html:not([lang='en']) + 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 - .panel-title { + > .panel-collapsible.collapsed + > [class$='-panel-inner'] + > .panel-header + > .panel-title { font-size: 8px; letter-spacing: 1px; }