From 25ec9e10a35ea3b850d078b9d3de36735078899d Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 16:53:24 +0200 Subject: [PATCH 01/43] feat(cctv): vendor the Bosch MIC PTZ datasheet records the Amsterdam register names Adds MIC-7522-Z30B, MIC-7502-Z30B and MIC-7130-PB4 (MIC IP starlight 7000 HD) from the CCTV Camera Database v2.29.0 model-enrichment extract. Colour siblings (B/G/W) share one datasheet, so they and the register's x wildcard (MIC-7522-Z30x) are aliases of one record. The MIC datasheets state FOV without an axis, so these records add specs but no horizontal FOV range. Refreshes the dataset size in the module header and DATA_SOURCES.md. --- DATA_SOURCES.md | 7 +-- src/layers/cctv/modelSpecs.js | 67 ++++++++++++++++++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 28 ++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..a9d676c85 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -208,7 +208,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) | | **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | -| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | +| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -236,11 +236,12 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under **CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is fetched at runtime. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 2d93166a7..89fcf58d2 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,18 +14,25 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV, as of v2.26.0). This module vendors the records that the currently integrated public + * of 29,000+ camera models transcribed from manufacturer datasheets (76% with + * FOV, as of v2.29.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data; packs that - * publish other hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC + * PTZs in the City of Amsterdam camera register; packs that publish other + * hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ +/** + * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the + * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour + * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * and the `x` wildcard registries write for the colour letter, are listed as + * aliases of the one vendored record. + */ const RECORDS = [ { id: 'axis-q6135-le', @@ -264,6 +271,56 @@ const RECORDS = [ autotracking: true, environment: ['outdoor'], }, + { + id: 'bosch-mic-7130-pb4', + brand: 'Bosch', + model: 'MIC-7130-PB4', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/', + aliases: [ + 'MIC IP starlight 7000 HD', + 'MIC-7130-PG4', + 'MIC-7130-PW4', + 'MIC-7130-Px4', + ], + fov_deg: '2.1-59', + resolution: { mp: 1, w: 1280, h: 720, label: '720p' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7502-z30b', + brand: 'Bosch', + model: 'MIC-7502-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/', + aliases: ['MIC-7502-Z30G', 'MIC-7502-Z30W', 'MIC-7502-Z30x'], + fov_deg: '2.3-63.7', + resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7522-z30b', + brand: 'Bosch', + model: 'MIC-7522-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/', + aliases: ['MIC-7522-Z30G', 'MIC-7522-Z30W', 'MIC-7522-Z30x'], + fov_deg: '2.1-58.3', + // i18n-ignore: datasheet label + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + ndaa_compliant: true, + }, { id: 'cohu-3920-idome', brand: 'Cohu', diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 9fbdb00dd..852c6ad64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -245,6 +245,34 @@ test('King County snapshot model strings still resolve', () => { ); }); +test('Amsterdam camera register model strings resolve', () => { + // The register writes the Bosch MIC colour letter as `x`. + assert.equal( + lookupModelSpec('Bosch MIC-7522-Z30x')?.id, + 'bosch-mic-7522-z30b', + ); + assert.equal( + lookupModelSpec('Bosch MIC-7502-Z30x')?.id, + 'bosch-mic-7502-z30b', + ); + assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4'); + assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b'); + // Its ANPR units are not in the table: unknown, never a guess. + assert.equal(lookupModelSpec('CSG-8800'), null); + // The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand + // is needed) stay apart. + assert.equal( + lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-mic-7130-pb4', + ); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-autodome-ip-starlight-7000-hd', + ); + // The MIC datasheets state FOV without an axis, so no horizontal range. + assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 6a7597d344960f7495fb3bdee2b34eed0a917c17 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 17:01:50 +0200 Subject: [PATCH 02/43] docs(changelog): fragment for #1319 --- changelog.d/1319-cctv-bosch-mic-specs.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1319-cctv-bosch-mic-specs.md diff --git a/changelog.d/1319-cctv-bosch-mic-specs.md b/changelog.d/1319-cctv-bosch-mic-specs.md new file mode 100644 index 000000000..bf1bd0b7f --- /dev/null +++ b/changelog.d/1319-cctv-bosch-mic-specs.md @@ -0,0 +1,3 @@ +- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera + register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour + wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319). From dec6cb937ec290d514f2c44d8cdb87a1b4f7fba7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 22 Oct 2026 23:27:00 +0100 Subject: [PATCH 03/43] test(cctv): verify Bosch MIC aliases and preserve estimated optics --- DATA_SOURCES.md | 10 ++++--- docs/CURRENT-STATE.md | 4 +++ src/layers/cctv/modelSpecs.js | 20 +++++++------- src/layers/cctv/modelSpecs.test.mjs | 41 +++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a9d676c85..1602cfa49 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -236,13 +236,15 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under -**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is -fetched at runtime. The records describe camera products (optics, resolution, night vision) +**CC0-1.0**. Only the records for hardware named by the integrated public catalogs (King +County, Sarasota County and Sioux Falls), plus the City of Amsterdam register proposed +in [#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251), are bundled, compiled into the module. Nothing is +fetched at runtime. The MIC specification fields are unchanged; product titles are shortened +to model codes and colour aliases are added locally. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9db91490..13174d759 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4585,6 +4585,10 @@ silently demoting every later lookup for the session. multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). + The table also includes Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam + register proposed in #251, including its `x` colour aliases (#1319). Their axis-less + datasheet FOV does not supply a horizontal capability range or replace the estimated + FOV. Adding these records does not enable the proposed source pack. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 89fcf58d2..ba6702584 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -12,24 +12,26 @@ * Cameras without an identified model are untouched — unknown never implies * anything. * - * Spec records are vendored verbatim from the CCTV Camera Database + * Specification fields are vendored from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 29,000+ camera models transcribed from manufacturer datasheets (76% with - * FOV, as of v2.29.0). This module vendors the records that the currently integrated public + * FOV, as of v2.29.0). This module vendors records that the integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC - * PTZs in the City of Amsterdam camera register; packs that publish other - * hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC + * PTZs for the City of Amsterdam camera register proposed in #251; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ /** - * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the - * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour - * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * Records from the dataset's model-enrichment extract (v2.26.0; the Bosch MIC + * records from v2.29.0). MIC specification fields are unchanged; product + * titles are shortened to model codes and colour aliases are added locally. + * Bosch MIC order codes end in a colour letter (B black, G grey, W white) + * on one shared datasheet; those siblings, * and the `x` wildcard registries write for the colour letter, are listed as * aliases of the one vendored record. */ @@ -488,7 +490,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => { * guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @param {string} [manufacturer] - The source's manufacturer, when published. - * @returns {Object|null} The verbatim spec record, or null when unknown. + * @returns {Object|null} The matched spec record, or null when unknown. */ export function lookupModelSpec(model, manufacturer) { const query = words(model).map(stripVariantSuffix); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 852c6ad64..d76f37c24 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -273,6 +273,47 @@ test('Amsterdam camera register model strings resolve', () => { assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); }); +test('Bosch MIC colour aliases resolve without accepting unknown variants', () => { + for (const [pattern, id] of [ + ['MIC-7130-P?4', 'bosch-mic-7130-pb4'], + ['MIC-7502-Z30?', 'bosch-mic-7502-z30b'], + ['MIC-7522-Z30?', 'bosch-mic-7522-z30b'], + ]) { + for (const colour of ['B', 'G', 'W', 'x']) { + const model = pattern.replace('?', colour); + assert.equal(lookupModelSpec(model)?.id, id, model); + assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id); + assert.equal(lookupModelSpec(model, 'Bosch')?.id, id); + assert.equal(lookupModelSpec(model, 'Axis'), null); + assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null); + } + assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null); + } +}); + +test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => { + for (const model of [ + 'Bosch MIC-7130-Px4', + 'Bosch MIC-7502-Z30x', + 'Bosch MIC-7522-Z30x', + ]) { + for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) { + const [camera] = browserCatalog().buildCatalogFromSources([ + { id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose }, + ]); + assert.ok(camera.spec, model); + assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model); + assert.equal(camera.fovSource, undefined); + assert.equal(camera.specFovMinDeg, undefined); + assert.equal(camera.specFovMaxDeg, undefined); + assert.equal(horizontalFovRangeDeg(camera.spec), null); + assert.equal(fixedHorizontalFovDeg(camera.spec), null); + assert.equal(specFovCapabilityToken(camera), null); + assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/); + } + } +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 38d29f2beb22b5200b794751b32f1f21a0409e25 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 13:09:32 +0400 Subject: [PATCH 04/43] feat(fires): FRP intensity filter, confidence switch and six-stop color ramp (HAL-676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color every FIRMS detection by fire radiative power on the Portolan cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), replacing the three warm stops that made a 2 MW and a 40 MW fire look alike. Heat cells, glow sprites and card accents all read the same ramp (src/data/firmsIntensity.js). Add a minimum-FRP log slider whose track is painted with the ramp (so it doubles as the legend), an All / Nominal+ / High confidence switch and an "N of M shown" counter to the Active Fires row. The filter runs once in ingestion.applyFilter(), so markers, cards, heat cells, the context store, the top-fires query and the analyst records all see one set. Default is 0 MW / All: many of the strongest fires are rated low. Persist both options in the layer state (token w: minFrp `f`, confidence `c`), including the short share codec, and give the agent's get_active_fires tool matching min_frp_mw / confidence arguments. Layer panel gains generic `slider` and `segmented` row descriptors (src/ui/rowFilterControls.js). New strings in es / ru / zh-CN. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 17 +++ src/data/firmsCards.test.mjs | 22 ++- src/data/firmsIntensity.js | 209 +++++++++++++++++++++++++++++ src/data/firmsIntensity.test.mjs | 207 ++++++++++++++++++++++++++++ src/data/firmsLabels.js | 23 ++-- src/data/firmsLabels.test.mjs | 15 ++- src/data/layerState.js | 21 ++- src/data/layerState.test.mjs | 83 +++++++++++- src/i18n/locales/es.json | 14 +- src/i18n/locales/ru.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/i18n/same-as-english.json | 5 +- src/layers/firms/ingestion.js | 32 ++++- src/layers/firms/lifecycle.js | 4 + src/layers/firms/model.js | 57 +++++--- src/layers/firms/policy.js | 19 ++- src/layers/firms/queries.js | 142 +++++++++++++++++++- src/layers/firms/rendering.js | 5 +- src/layers/firms/state.js | 28 ++++ src/share/viewCodec.js | 1 + src/tools/queries/hazards.js | 64 ++++++++- src/tools/queries/hazards.test.mjs | 36 +++++ src/ui/layerPanel.js | 54 +++++++- src/ui/rowFilterControls.js | 169 +++++++++++++++++++++++ src/ui/rowFilterControls.test.mjs | 207 ++++++++++++++++++++++++++++ src/ui/styles/radio.css | 102 ++++++++++++++ 27 files changed, 1500 insertions(+), 70 deletions(-) create mode 100644 src/data/firmsIntensity.js create mode 100644 src/data/firmsIntensity.test.mjs create mode 100644 src/ui/rowFilterControls.js create mode 100644 src/ui/rowFilterControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..c7ae4e3cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1117,5 +1117,9 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/data/firmsIntensity.js", + "src/data/firmsIntensity.test.mjs", + "src/ui/rowFilterControls.js", + "src/ui/rowFilterControls.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..ca2ecb542 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -495,6 +495,8 @@ "src/ui/railCards.js", "src/ui/railCardBlocks.js", "src/ui/slider.js", + "src/ui/rowFilterControls.js", + "src/ui/segmentedControl.js", "src/ui/chipGroup.js", "src/ui/rowList.js", "src/ui/railTimeline.js", @@ -670,6 +672,7 @@ "src/data/detectionPolicy.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -734,6 +737,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -954,6 +958,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1007,6 +1012,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1052,6 +1058,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1136,6 +1143,7 @@ "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1525,6 +1533,7 @@ "modules": [ "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1848,6 +1857,7 @@ "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2259,6 +2269,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -2687,6 +2698,7 @@ "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -3283,6 +3295,7 @@ "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3899,6 +3912,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/view/index.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", @@ -3975,6 +3989,8 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/data/firmsIntensity.js", + "src/data/firmsAdapt.js", "src/data/idSwitches.js", "src/data/vesselTypes.js", "src/data/layerState.js", @@ -4107,6 +4123,7 @@ "modules": [ "src/view/index.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index a0ee83c01..f82b8da80 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -68,11 +68,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.notEqual(card.accent, accentForSeverity('red')); + assert.equal(card.accent, accentForSeverity('blue')); +}); + +test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { + const accentAt = (frp) => + buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; + assert.equal(accentAt(4.9), accentForSeverity('blue')); + assert.equal(accentAt(5), accentForSeverity('teal')); + assert.equal(accentAt(20), accentForSeverity('lime')); + assert.equal(accentAt(50), accentForSeverity('amber')); + assert.equal(accentAt(100), accentForSeverity('orange')); + assert.equal(accentAt(300), accentForSeverity('red')); + // Confidence never moves the color: a low-confidence 864 MW fire stays red. + assert.equal( + buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, + accentForSeverity('red'), + ); }); test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => { @@ -125,7 +141,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow'); + assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js new file mode 100644 index 000000000..417566346 --- /dev/null +++ b/src/data/firmsIntensity.js @@ -0,0 +1,209 @@ +/** + * @module firmsIntensity + * @description Fire intensity policy shared by the Active Fires layer and the + * agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale + * slider that doubles as its legend, and the one filter every consumer + * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs + * before anything is rendered or selected. Pure — no Cesium, no DOM. + * + * Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not + * interpolated). The thresholds are GEV's own, set on a real week's + * percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW + * leaves one red fire a week (HAL-675). + */ + +/** + * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. + */ +export const FRP_RAMP = Object.freeze([ + Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }), + Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }), + Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }), + Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }), + Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }), + Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }), +]); + +/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ +export const FRP_SLIDER_TICKS = Object.freeze( + FRP_RAMP.map((stop) => stop.minFrp), +); + +/** Minimum-FRP values the share codec accepts, in whole megawatts. */ +export const MIN_FRP_MW_MIN = 0; +export const MIN_FRP_MW_MAX = 10_000; + +/** Confidence switch positions; VIIRS reports only low / nominal / high. */ +export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']); + +/** + * Normalized-confidence floor for a switch position. The thresholds match + * `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt). + */ +const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 }); + +export const DEFAULT_FIRE_FILTER = Object.freeze({ + minFrp: 0, + confidence: 'all', +}); + +/** + * The ramp stop for a fire radiative power. + * @param {number} frp Megawatts; non-finite or negative reads as 0. + * @returns {{name: string, minFrp: number, hex: string}} + */ +export function frpRampStop(frp) { + const value = Number.isFinite(frp) ? Math.max(0, frp) : 0; + let stop = FRP_RAMP[0]; + for (const candidate of FRP_RAMP) { + if (value >= candidate.minFrp) stop = candidate; + else break; + } + return stop; +} + +/** `#rrggbb` → `[r, g, b]` in 0..255. */ +export function hexToRgb(hex) { + const text = String(hex).replace('#', ''); + return [0, 2, 4].map((offset) => + Number.parseInt(text.slice(offset, offset + 2), 16), + ); +} + +/** + * Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest + * of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1 + * MW because the median detection is ~6 MW and a sub-megawatt floor hides + * nothing worth a tick. + */ +export const SLIDER_MAX_MW = 1000; +const LOG_MAX = Math.log10(SLIDER_MAX_MW); + +/** + * Track position in 0..1 for a minimum FRP. + * @param {number} minFrp Megawatts. + * @returns {number} + */ +export function sliderPositionForFrp(minFrp) { + if (!Number.isFinite(minFrp) || minFrp < 1) return 0; + return Math.min(1, Math.log10(minFrp) / LOG_MAX); +} + +/** + * Minimum FRP for a track position, rounded to whole megawatts so a slider + * value and its share-link form are the same number. + * @param {number} position 0..1. + * @returns {number} + */ +export function frpForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + if (p <= 0) return 0; + const frp = Math.pow(10, p * LOG_MAX); + return Math.max(1, Math.round(frp)); +} + +/** + * CSS `linear-gradient` painting the stepped ramp along the log axis, so the + * slider track IS the legend: the color under the thumb is the color of the + * weakest fire still shown. + * @returns {string} + */ +export function rampTrackGradient() { + const parts = []; + FRP_RAMP.forEach((stop, index) => { + const start = sliderPositionForFrp(stop.minFrp) * 100; + const next = FRP_RAMP[index + 1]; + const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100; + parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`); + }); + return `linear-gradient(90deg, ${parts.join(', ')})`; +} + +/** + * Minimum FRP → whole megawatts inside the codec bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeMinFrp(value) { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : NaN; + if (!Number.isFinite(number)) return null; + const rounded = Math.round(number); + return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX + ? rounded + : null; +} + +/** + * Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null. + * @param {*} value + * @returns {?string} + */ +export function normalizeConfidenceMode(value) { + const text = String(value ?? '') + .trim() + .toLowerCase(); + return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null; +} + +/** + * Sanitize a partial filter against the current one; unknown or invalid + * fields keep their current value. + * @param {Object} [params] + * @param {{minFrp: number, confidence: string}} [current] + * @returns {{minFrp: number, confidence: string}} + */ +export function normalizeFireFilter( + params = {}, + current = DEFAULT_FIRE_FILTER, +) { + const minFrp = normalizeMinFrp(params?.minFrp); + const confidence = normalizeConfidenceMode(params?.confidence); + return { + minFrp: minFrp === null ? current.minFrp : minFrp, + confidence: confidence === null ? current.confidence : confidence, + }; +} + +/** + * Whether one fire passes the filter. `confidence` is the normalized 0..1 + * value firmsAdapt produces; `frp` is megawatts. + * @param {{frp: number, confidence: number}} fire + * @param {{minFrp: number, confidence: string}} filter + * @returns {boolean} + */ +export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) { + const frp = Number.isFinite(fire?.frp) ? fire.frp : 0; + if (frp < (filter.minFrp || 0)) return false; + const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0; + if (floor <= 0) return true; + const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0; + return confidence >= floor; +} + +/** + * The fires that pass the filter, in input order. Returns the SAME array when + * the filter is the default, so callers can tell "nothing hidden" cheaply. + * @template T + * @param {Array} fires Internal records (normalized confidence). + * @param {{minFrp: number, confidence: string}} filter + * @returns {Array} + */ +export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) { + if (!Array.isArray(fires)) return []; + if (isDefaultFireFilter(filter)) return fires; + return fires.filter((fire) => firePassesFilter(fire, filter)); +} + +/** True when the filter hides nothing. */ +export function isDefaultFireFilter(filter) { + return ( + !(filter?.minFrp > 0) && + (filter?.confidence === 'all' || filter?.confidence == null) + ); +} diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs new file mode 100644 index 000000000..558068e67 --- /dev/null +++ b/src/data/firmsIntensity.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_FIRE_FILTER, + FIRMS_CONFIDENCE_MODES, + FRP_RAMP, + FRP_SLIDER_TICKS, + filterFires, + firePassesFilter, + frpForSliderPosition, + frpRampStop, + hexToRgb, + isDefaultFireFilter, + normalizeConfidenceMode, + normalizeFireFilter, + normalizeMinFrp, + rampTrackGradient, + sliderPositionForFrp, +} from './firmsIntensity.js'; + +test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { + assert.deepEqual( + FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), + [ + ['blue', 0, '#4a5bd4'], + ['teal', 5, '#39a0a8'], + ['lime', 20, '#c9cf4a'], + ['amber', 50, '#f2b134'], + ['orange', 100, '#e8722c'], + ['red', 300, '#d1382a'], + ], + ); + assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); + assert.ok(Object.isFrozen(FRP_RAMP)); +}); + +test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { + assert.equal(frpRampStop(0).name, 'blue'); + assert.equal(frpRampStop(4.99).name, 'blue'); + assert.equal(frpRampStop(5).name, 'teal'); + assert.equal(frpRampStop(19.9).name, 'teal'); + assert.equal(frpRampStop(20).name, 'lime'); + assert.equal(frpRampStop(50).name, 'amber'); + assert.equal(frpRampStop(100).name, 'orange'); + assert.equal(frpRampStop(299.9).name, 'orange'); + assert.equal(frpRampStop(300).name, 'red'); + assert.equal(frpRampStop(1246).name, 'red'); + assert.equal(frpRampStop(NaN).name, 'blue'); + assert.equal(frpRampStop(-3).name, 'blue'); + assert.equal(frpRampStop(undefined).name, 'blue'); +}); + +test('hex stops convert to the "r, g, b" accents the cards paint', () => { + assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); +}); + +test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => { + assert.equal(sliderPositionForFrp(0), 0); + assert.equal(sliderPositionForFrp(0.5), 0); + assert.equal(sliderPositionForFrp(1), 0); + assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9); + assert.equal(sliderPositionForFrp(1000), 1); + assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end'); + assert.equal(frpForSliderPosition(0), 0); + assert.equal(frpForSliderPosition(1), 1000); + assert.equal(frpForSliderPosition(-1), 0); + assert.equal(frpForSliderPosition(2), 1000); + for (const frp of FRP_SLIDER_TICKS) { + assert.equal( + frpForSliderPosition(sliderPositionForFrp(frp)), + frp, + `tick ${frp} MW round-trips through the axis`, + ); + } + // Ticks spread along the track instead of bunching at one end. + const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp); + for (let i = 1; i < positions.length; i += 1) { + assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`); + } +}); + +test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { + const gradient = rampTrackGradient(); + assert.match(gradient, /^linear-gradient\(90deg, /); + assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/); + assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/); + assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); +}); + +test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => { + assert.equal(normalizeMinFrp(0), 0); + assert.equal(normalizeMinFrp(19.6), 20); + assert.equal(normalizeMinFrp('100'), 100); + assert.equal(normalizeMinFrp(10000), 10000); + assert.equal(normalizeMinFrp(10001), null); + assert.equal(normalizeMinFrp(-1), null); + assert.equal(normalizeMinFrp('abc'), null); + assert.equal(normalizeMinFrp(''), null); + assert.equal(normalizeMinFrp(null), null); + assert.equal(normalizeMinFrp(NaN), null); +}); + +test('confidence modes are exactly all / nominal / high', () => { + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(normalizeConfidenceMode(' High '), 'high'); + assert.equal(normalizeConfidenceMode('nominal'), 'nominal'); + assert.equal(normalizeConfidenceMode('low'), null); + assert.equal(normalizeConfidenceMode(0.9), null); + assert.equal(normalizeConfidenceMode(undefined), null); +}); + +test('a partial filter patches the current one and invalid fields keep their value', () => { + assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER); + assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), { + minFrp: 20, + confidence: 'all', + }); + assert.deepEqual( + normalizeFireFilter( + { confidence: 'high' }, + { minFrp: 50, confidence: 'all' }, + ), + { minFrp: 50, confidence: 'high' }, + ); + assert.deepEqual( + normalizeFireFilter( + { minFrp: 'nope', confidence: 'medium' }, + { minFrp: 50, confidence: 'nominal' }, + ), + { minFrp: 50, confidence: 'nominal' }, + ); + assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true); + assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false); +}); + +test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => { + const low = { frp: 864, confidence: 0.3 }; + const nominal = { frp: 20, confidence: 0.6 }; + const high = { frp: 2, confidence: 0.9 }; + assert.equal(firePassesFilter(low), true, 'default shows everything'); + assert.equal( + firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }), + false, + ); + assert.equal( + firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }), + false, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }), + false, + ); + assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true); + assert.equal( + firePassesFilter(high, { minFrp: 5, confidence: 'high' }), + false, + ); + // Missing numbers read as 0, never as a pass-through. + assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false); + assert.equal( + firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }), + false, + ); +}); + +test('filterFires returns the same array for the default filter and preserves order otherwise', () => { + const fires = [ + { frp: 864, confidence: 0.3 }, + { frp: 20, confidence: 0.6 }, + { frp: 2, confidence: 0.9 }, + { frp: 120, confidence: 0.9 }, + ]; + assert.equal(filterFires(fires), fires); + assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires); + assert.deepEqual( + filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp), + [864, 20, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp), + [2, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map( + (f) => f.frp, + ), + [120], + ); + assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []); +}); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index b9e9d1765..3af6a15a5 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -4,6 +4,8 @@ * dedicated card canvas moved into the shared world-overlay host. */ +import { FRP_RAMP, hexToRgb } from './firmsIntensity.js'; + /** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */ export const FIRMS_AMBIENT_COHORT_LIMIT = 18; export const FIRMS_OVERLAY_SOURCE_ID = 'firms'; @@ -23,20 +25,23 @@ export function fireDetectionKey(fire) { return `firms:${lat}:${lon}:${acq}:${source}`; } -/** Severity accent palette — matches the FIRMS glow-sprite color stops. */ -const ACCENT_RGB = Object.freeze({ - red: '224, 82, 82', - orange: '240, 178, 62', - yellow: '244, 227, 108', -}); +/** + * Severity accent palette — the FIRMS glow-sprite color stops (the six-stop + * FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents. + */ +const ACCENT_RGB = Object.freeze( + Object.fromEntries( + FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]), + ), +); /** - * Severity stop name → "r, g, b" accent string (defaults to yellow). - * @param {string} stopName - 'red' | 'orange' | 'yellow'. + * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). + * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). * @returns {string} */ export function accentForSeverity(stopName) { - return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; + return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name]; } /** diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 8573d7330..62234bb2d 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,11 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -test('FIRMS formatting helpers retain the shipped severity palette', () => { - assert.equal(accentForSeverity('red'), '224, 82, 82'); - assert.equal(accentForSeverity('orange'), '240, 178, 62'); - assert.equal(accentForSeverity('yellow'), '244, 227, 108'); - assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow')); +test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => { + // Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675). + assert.equal(accentForSeverity('blue'), '74, 91, 212'); + assert.equal(accentForSeverity('teal'), '57, 160, 168'); + assert.equal(accentForSeverity('lime'), '201, 207, 74'); + assert.equal(accentForSeverity('amber'), '242, 177, 52'); + assert.equal(accentForSeverity('orange'), '232, 114, 44'); + assert.equal(accentForSeverity('red'), '209, 56, 42'); + assert.equal(accentForSeverity('yellow'), accentForSeverity('blue')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/data/layerState.js b/src/data/layerState.js index 089c8c08b..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,6 +298,20 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ + // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on + // the codec side; the panel's log slider rounds to it. Both default to + // "show everything": many of the strongest fires are rated low confidence. + // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in + // firmsIntensity.js (pinned by layerState.test); spelled out here because + // this file runs in isolation in the token-ledger tests. + 'local-firms': Object.freeze([ + boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }), + enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], { + all: 'a', + nominal: 'n', + high: 'h', + }), + ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { tomtom: 't', @@ -621,7 +635,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'd', disposition: 'enabled-only', }), - Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ + id: 'local-firms', + token: 'w', + disposition: 'enabled+options', + optionOwner: 'local-firms', + }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index d05fb9a95..d343fe20f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -29,6 +29,7 @@ import radioLayer from './radio.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; +import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; function deferred() { let resolve; @@ -2984,9 +2985,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => { gate.resolve(); const results = await restore; for (const id of ['earthquakes', 'flights']) { - assert.equal(results.find((result) => result.layerId === id).succeeded, false); + assert.equal( + results.find((result) => result.layerId === id).succeeded, + false, + ); } assert.deepEqual([...manager.getEnabledLayerIds()], []); assert.equal(enabledLayerParam(sink), ''); coordinator.destroy(); }); + +/** Active Fires options decoded from a v2 link with these `lo` assignments. */ +function firmsOptions(lo) { + const params = new URLSearchParams([ + ['v', '2'], + ['l', 'w'], + ]); + if (lo) params.set('lo', lo); + return decodeLayerStateParams(params).options['local-firms']; +} + +test('Active Fires: the intensity filter rides share token w and round-trips', () => { + const state = createDefaultLayerState(); + assert.deepEqual(state.options['local-firms'], { + minFrp: 0, + confidence: 'all', + }); + state.enabledLayerIds = ['local-firms']; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + const encoded = encode(state); + assert.equal(new URLSearchParams(encoded).get('l'), 'w'); + // Other owners write their own explicit defaults into `lo` too (flights 3D, + // wind overlay); the fire assignments ride beside them. + const assignments = new URLSearchParams(encoded).get('lo').split('_'); + assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); + assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); + const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); + assert.deepEqual(decoded.options['local-firms'], { + minFrp: 20, + confidence: 'high', + }); + assert.deepEqual( + parseStoredLayerState(serializeStoredLayerState(state)).options[ + 'local-firms' + ], + state.options['local-firms'], + ); + // Defaults are omitted from the link: a plain `l=w` is "show everything". + const plain = createDefaultLayerState(); + plain.enabledLayerIds = ['local-firms']; + assert.equal( + new URLSearchParams(encode(plain)) + .get('lo') + .split('_') + .filter((assignment) => assignment.startsWith('w.')).length, + 0, + ); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); +}); + +test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { + // The codec spells its bounds out (it runs in isolation in the ledger + // tests); they must stay the layer's. + assert.equal(MIN_FRP_MW_MAX, 10000); + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(firmsOptions('w.f.300').minFrp, 300); + assert.equal(firmsOptions('w.f.10000').minFrp, 10000); + assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default'); + assert.equal(firmsOptions('w.f.-5').minFrp, 0); + assert.equal( + firmsOptions('w.f.2.5').minFrp, + 0, + 'a third segment is rejected', + ); + assert.equal(firmsOptions('w.c.a').confidence, 'all'); + assert.equal(firmsOptions('w.c.n').confidence, 'nominal'); + assert.equal(firmsOptions('w.c.h').confidence, 'high'); + assert.equal( + firmsOptions('w.c.x').confidence, + 'all', + 'unknown code → default', + ); + assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { + minFrp: 5, + confidence: 'nominal', + }); +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..ffee7fb87 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,17 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "fire confidence filter|All": "Todas", + "fire confidence filter|Nominal+": "Nominal+", + "fire confidence filter|High": "Alta", + "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", + "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", + "High confidence detections only": "Solo detecciones de confianza alta", + "Min FRP": "FRP mín.", + "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "{value} MW": "{value} MW", + "fire intensity filter|All": "Todo", + "Detection confidence": "Confianza de la detección", + "{shown} of {total} shown": "{shown} de {total} mostrados" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..1cd88757d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,17 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "fire confidence filter|All": "Все", + "fire confidence filter|Nominal+": "Средняя+", + "fire confidence filter|High": "Высокая", + "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", + "Nominal and high confidence detections": "Детекции средней и высокой достоверности", + "High confidence detections only": "Только детекции высокой достоверности", + "Min FRP": "Мин. FRP", + "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "{value} MW": "{value} МВт", + "fire intensity filter|All": "Все", + "Detection confidence": "Достоверность детекции", + "{shown} of {total} shown": "показано {shown} из {total}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..3044c45f8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,17 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "fire confidence filter|All": "全部", + "fire confidence filter|Nominal+": "标称+", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", + "Nominal and high confidence detections": "标称和高置信度的检测", + "High confidence detections only": "仅高置信度的检测", + "Min FRP": "最小 FRP", + "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "{value} MW": "{value} MW", + "fire intensity filter|All": "全部", + "Detection confidence": "检测置信度", + "{shown} of {total} shown": "显示 {shown} / {total}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 05f5d3dee..5f1b14f8a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -98,6 +98,7 @@ "WX", "audio {state}", "dB", + "fire confidence filter|Nominal+", "fire confidence|nominal", "ft", "hPa", @@ -120,6 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -168,9 +170,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -195,6 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 494ab5f1e..215c35da3 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -1,5 +1,6 @@ import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; import { fireDetectionKey } from '../../data/firmsLabels.js'; +import { filterFires } from '../../data/firmsIntensity.js'; import { msg } from '../../i18n/index.js'; export function createIngestion({ @@ -40,12 +41,13 @@ export function createIngestion({ layerState._stale = Boolean(payload?.stale); const previousSelection = layerState._selectedFire; layerState._selectedFire = null; - layerState._fires = adaptFirmsRecords(payload?.fires); - layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells - layerState._firesByFrp = [...layerState._fires].sort( + layerState._allFires = adaptFirmsRecords(payload?.fires); + // Sorted once per payload; applyFilter only filters, so a slider drag + // over 200k detections never re-sorts. + layerState._allFiresByFrp = [...layerState._allFires].sort( (a, b) => b.frp - a.frp, ); - layerState._count = layerState._fires.length; + applyFilter(); // Data age, not response age: a stale proxy payload truthfully reads old. layerState._lastUpdate = Number.isFinite(payload?.fetchedAt) ? payload.fetchedAt @@ -65,6 +67,7 @@ export function createIngestion({ } components.rendering.renderCurrentLod(true); if (reselected) components.selection.selectFire(reselected, false); + layerState._rowControlsListener?.(); } catch (error) { if ( request.signal.aborted || @@ -83,6 +86,25 @@ export function createIngestion({ } } + /** + * Run the intensity filter over the loaded payload. This is the ONE place + * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, + * heat cells, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells are + * aggregated per shown set, so the cache goes with it. + */ + + function applyFilter() { + layerState._fires = filterFires(layerState._allFires, layerState._filter); + layerState._cellCacheByGrid.clear(); + layerState._firesByFrp = filterFires( + layerState._allFiresByFrp, + layerState._filter, + ); + layerState._count = layerState._fires.length; + layerState._totalCount = layerState._allFires.length; + } + /** * Find the record in the freshly-loaded set matching a previous selection. * Identity is the detection itself (lat/lon/acquisition time) — indices @@ -109,5 +131,5 @@ export function createIngestion({ }, }; - return { loadHeatmap, findMatchingFire, methods }; + return { loadHeatmap, applyFilter, findMatchingFire, methods }; } diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..1138504dd 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -91,10 +91,14 @@ export function createLifecycle({ components.rendering.removeDetectionCollections(viewer); components.selection.clearContextRegistrations(); clearSelectedEntityContextForLayer(id); + layerState._allFires = []; + layerState._allFiresByFrp = []; layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); layerState._count = 0; + layerState._totalCount = 0; + layerState._rowControlsListener = null; layerState._cellCount = 0; layerState._lastUpdate = null; layerState._keyRequired = false; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 14b875bf8..a90066cc6 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -6,6 +6,7 @@ import { accentForSeverity, formatFrpMw, } from '../../data/firmsLabels.js'; +import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js'; import { VIEW_PADDING, DETECTION_COLOR_STOPS, @@ -119,27 +120,42 @@ export function createModel({ ); } - function heatColor(value, alpha) { - if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); - if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); - return Cesium.Color.YELLOW.withAlpha(alpha); + /** + * The ramp stop for a fire radiative power (stepped, coldest first). + * Intensity alone drives the color: confidence feeds the card text and the + * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. + * @param {number} frp - Megawatts. + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function frpColorStop(frp) { + const name = frpRampStop(frp).name; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); } /** - * Pick a sprite color stop from FRP + confidence, reusing the same - * yellow → orange → red thresholds as the aggregated heat cells. + * Fill color for an aggregated heat cell: the ramp color of its strongest + * detection, so cells, markers and the slider legend read on one scale. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} alpha - Fill alpha (heat-normalized by the caller). + * @returns {Cesium.Color} + */ + + function heatColor(maxFrp, alpha) { + return frpColorStop(maxFrp).color.withAlpha(alpha); + } + + /** + * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. * @returns {{name: string, color: Cesium.Color}} */ function detectionColorStop(fire) { - const heat = Math.min( - 1, - Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, - ); - if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; - if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; - return DETECTION_COLOR_STOPS[2]; + return frpColorStop(fire?.frp); } /** FRP → core marker pixel size, clamped to 8..28px. */ @@ -418,7 +434,7 @@ export function createModel({ position: candidate.position, cullPosition: candidate.cullPosition || candidate.position, gapPx: 10, - accent: candidate.accent || accentForSeverity('yellow'), + accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name), title: `${cell.count} ${noun}`, details: [parts.join(' · ')], selected: false, @@ -460,16 +476,14 @@ export function createModel({ } /** - * Heat-normalized cell score → severity accent, mirroring heatColor's - * red/orange/yellow thresholds. - * @param {number} normalized - 0..1 normalized heat score. + * Cell card accent: the ramp stop of the cell's strongest detection, + * mirroring heatColor. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - if (normalized > 0.72) return accentForSeverity('red'); - if (normalized > 0.42) return accentForSeverity('orange'); - return accentForSeverity('yellow'); + function cellAccent(maxFrp) { + return accentForSeverity(frpColorStop(maxFrp).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -576,6 +590,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + frpColorStop, detectionColorStop, frpPixelSize, sizeBucket, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..18ea88238 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -1,4 +1,5 @@ import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; +import { FRP_RAMP } from '../../data/firmsIntensity.js'; import * as Cesium from 'cesium'; /** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ @@ -88,10 +89,16 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; -/** Color stops shared by cell heat fills and detection glow sprites. */ +/** + * Color stops shared by cell heat fills and detection glow sprites: the + * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * colors. Stop names key the glow-sprite cache and the card accents. + */ -export const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; +export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) => + Object.freeze({ + name: stop.name, + minFrp: stop.minFrp, + color: Cesium.Color.fromCssColorString(stop.hex), + }), +); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..220012133 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -1,7 +1,19 @@ import { fireDetectionKey } from '../../data/firmsLabels.js'; -import { t } from '../../i18n/index.js'; +import { + FIRMS_CONFIDENCE_MODES, + FRP_SLIDER_TICKS, + firePassesFilter, + frpForSliderPosition, + normalizeFireFilter, + rampTrackGradient, + sliderPositionForFrp, +} from '../../data/firmsIntensity.js'; +import { formatNumber, t } from '../../i18n/index.js'; import { REFRESH_INTERVAL_MS } from './policy.js'; +/** Slider resolution: whole-percent positions along the log axis. */ +const SLIDER_STEPS = 1000; + export function createQueries({ layerState, services, @@ -11,6 +23,25 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ + function frpLabel(frp) { + return formatNumber(frp, { maximumFractionDigits: 0 }); + } + + const confidenceLabels = () => ({ + all: t('All', { context: 'fire confidence filter' }), + nominal: t('Nominal+', { context: 'fire confidence filter' }), + high: t('High', { context: 'fire confidence filter' }), + }); + + const confidenceTitles = () => ({ + all: t( + 'Every detection — many of the strongest fires are rated low confidence', + ), + nominal: t('Nominal and high confidence detections'), + high: t('High confidence detections only'), + }); + const methods = { /** Return the selected detection as a plain record for application consumers. */ getSelectedInfo() { @@ -51,6 +82,109 @@ export function createQueries({ // not this interval — is what protects the upstream FIRMS quota). updateInterval: REFRESH_INTERVAL_MS, + /** + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} + */ + getParams() { + return { ...layerState._filter }; + }, + + /** + * Apply a (partial) intensity filter. Invalid fields keep their current + * value. The filter runs in one place (ingestion.applyFilter) and the LOD + * rebuild re-derives markers, cells, cards and context registrations from + * the shown set; a selected fire that no longer passes is deselected. + * @param {{minFrp?: number, confidence?: string}} params + * @returns {boolean} True — the request is always accepted. + */ + setParams(params = {}) { + const next = normalizeFireFilter(params, layerState._filter); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) + return true; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + if (layerState._enabled) components.rendering.renderCurrentLod(true); + layerState._rowControlsListener?.(); + return true; + }, + + /** + * Row controls under the Active Fires row: the log-scale minimum-FRP + * slider whose track is painted with the intensity ramp (so it doubles as + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. + */ + getRowControls() { + const { minFrp, confidence } = layerState._filter; + const labels = confidenceLabels(); + const titles = confidenceTitles(); + const shown = layerState._count; + const total = layerState._totalCount; + return { + slider: { + id: 'min-frp', + label: t('Min FRP'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('{value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + segmented: { + id: 'confidence', + ariaLabel: t('Detection confidence'), + options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ + id: `confidence-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === confidence, + params: { confidence: mode }, + })), + }, + info: total + ? t('{shown} of {total} shown', { + shown: formatNumber(shown), + total: formatNumber(total), + }) + : '', + }; + }, + + /** + * Install the panel's "row controls changed" callback: a load or a filter + * change moves the counter, and nothing else repaints the row before the + * next scheduled refresh. + * @param {(() => void)|null} listener + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + /** * Layer stats for the data panel. Degraded feed states surface through * `error` (established qa-failstate pattern: a dead feed must never look @@ -84,7 +218,9 @@ export function createQueries({ }); } return { + // Shown (filtered) detections; `total` is the loaded payload. count: layerState._count, + total: layerState._totalCount, cells: layerState._cellCount, lastUpdate: layerState._lastUpdate, loading: layerState._loading, @@ -176,8 +312,12 @@ export function createQueries({ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; layerState._enabled = true; + layerState._allFires = fires; + layerState._allFiresByFrp = [...fires]; layerState._fires = fires; layerState._firesByFrp = [...fires]; + layerState._count = fires.length; + layerState._totalCount = fires.length; layerState._pickIndexById.clear(); layerState._labelLodDistance = 1e7; layerState._labelCandidates = fires.map((fire) => { diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..757fd4cf7 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); + const color = components.model.heatColor(cell.maxFrp, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,13 +170,12 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellAccent(cell.maxFrp), }); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..d84ee2026 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) { layerState._error = null; + /** Every adapted detection in the current payload, before the filter. */ + + layerState._allFires = []; + + /** `_allFires` sorted by FRP descending, once per payload. */ + + layerState._allFiresByFrp = []; + + /** + * The detections that pass the intensity filter — the ONLY set rendering, + * selection, cells, the top-fires query and the analyst records ever walk. + */ + layerState._fires = []; layerState._firesByFrp = []; + /** Shown (filtered) detection count. */ + layerState._count = 0; + /** Loaded detection count before the filter ("N of M shown"). */ + + layerState._totalCount = 0; + + /** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */ + + layerState._filter = { ...DEFAULT_FIRE_FILTER }; + + /** Panel callback: repaint the row controls (counter) after a load. */ + + layerState._rowControlsListener = null; + layerState._cellCount = 0; layerState._lastUpdate = null; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 84ba8fbc1..18f64e0aa 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -255,6 +255,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 2b11c639c..e671db426 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -4,6 +4,14 @@ import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { + FIRMS_CONFIDENCE_MODES, + MIN_FRP_MW_MAX, + MIN_FRP_MW_MIN, + firePassesFilter, + normalizeFireFilter, +} from '../../data/firmsIntensity.js'; +import { normalizeConfidence } from '../../data/firmsAdapt.js'; function acquiredMsUtc(acqDate, acqTime) { if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) @@ -87,10 +95,30 @@ export const getActiveFires = defineTool({ title: 'Active fires', description: 'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' + - 'area, most intense (fire radiative power) first.', + 'area, most intense (fire radiative power) first. Optionally limited to a ' + + 'minimum fire radiative power in megawatts and a confidence level — the ' + + "same filter as the Active Fires layer's intensity controls.", inputSchema: { type: 'object', - properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + properties: { + area: AREA_SCHEMA, + min_frp_mw: { + type: 'number', + minimum: MIN_FRP_MW_MIN, + maximum: MIN_FRP_MW_MAX, + description: + 'Hide detections weaker than this fire radiative power (MW). ' + + 'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.', + }, + confidence: { + type: 'string', + enum: [...FIRMS_CONFIDENCE_MODES], + description: + '"all" (default), "nominal" (nominal and high), or "high". Many of the ' + + 'strongest fires are rated low confidence, so prefer "all" unless asked.', + }, + limit: LIMIT_SCHEMA, + }, required: ['area'], additionalProperties: false, }, @@ -103,9 +131,31 @@ export const getActiveFires = defineTool({ 'unavailable', 'Fire detections need a NASA FIRMS key configured for this server', ); + const filter = normalizeFireFilter({ + minFrp: args.min_frp_mw, + confidence: args.confidence, + }); const fires = snapshot.fires - .filter((fire) => areaContains(area, fire)) + .filter( + (fire) => + areaContains(area, fire) && + firePassesFilter( + { + frp: Number(fire.frp), + confidence: normalizeConfidence(fire.confidence), + }, + filter, + ), + ) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); + const filterNotes = [ + ...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []), + ...(filter.confidence !== 'all' + ? [ + `${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`, + ] + : []), + ]; const result = capRows(fires, args.limit); // The proxy serves its last good snapshot when a refresh fails, and marks // satellites whose download failed. @@ -119,10 +169,16 @@ export const getActiveFires = defineTool({ ]; return { summary: - `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + `${countNoun(fires.length, 'fire detection')}` + + (filterNotes.length ? ` (${filterNotes.join(', ')})` : '') + + ` in ${area.label} in the last 24 hours` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { area, layers: ['local-firms'] }), + filter: { + min_frp_mw: filter.minFrp, + confidence: filter.confidence, + }, stale, missing_sources: missing, fetched_at: isoTime(snapshot.fetchedAt), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fa17158dd..c1d3eb43a 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async assert.equal(result.data.rows[0].acquired, expected); } }); + +const WEEK = [ + { lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, +]; +const TAIWAN = { area: { bbox: [120, 23, 122, 25] } }; + +test('get_active_fires shows every detection by default and reports the filter it used', async () => { + const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN); + assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]); + assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' }); + assert.match(result.summary, /^4 fire detections in /); +}); + +test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => { + const catalog = catalogWith(WEEK); + const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 }); + assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]); + assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /); + + const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' }); + assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops'); + assert.match(nominal.summary, /\(nominal or high confidence\)/); + + const both = await catalog.call('get_active_fires', { + ...TAIWAN, + min_frp_mw: 19.6, + confidence: 'high', + }); + assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]); + assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' }); + assert.match(both.summary, /\(≥ 20 MW, high confidence\)/); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..dc6566848 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,4 +1,5 @@ import { syncChipGroup } from './chipGroup.js'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; import { syncRowList } from './rowList.js'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; @@ -388,18 +389,42 @@ export class LayerPanel { const controls = document.createElement('div'); controls.className = 'data-toggle-controls'; this._bind(controls, 'click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); + const button = + event.target?.closest?.('.data-toggle-chip') || + event.target?.closest?.('.data-toggle-segment'); if (!button || button.disabled) return; // Re-read the live descriptor rather than trusting the rendered // chip, so a stale row can never apply an inverted toggle. - const chip = this._rowControlsFor(layer.id)?.chips?.find( - (entry) => entry.id === button.dataset.chipId, - ); + const live = this._rowControlsFor(layer.id); + const chip = + live?.chips?.find( + (entry) => entry.id === button.dataset.chipId, + ) || + live?.segmented?.options?.find( + (entry) => entry.id === button.dataset.chipId, + ); if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); + // A row slider (e.g. the Active Fires minimum-FRP filter) maps its + // position to params through the live descriptor on every tick. + this._bind(controls, 'input', (event) => { + const input = event.target; + if (!input?.matches?.('.data-toggle-slider-input')) return; + const slider = this._rowControlsFor(layer.id)?.slider; + if ( + !slider || + slider.id !== input.dataset.sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(layer.id) + ) + return; + this.setLayerParams(layer.id, slider.params(input.value), { + origin: 'user', + }); + }); row.appendChild(controls); // An ordered list below the chips, for a layer whose row carries a // sequence (turn-by-turn directions). Its own delegated listener, its @@ -521,13 +546,30 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; const infoText = controls?.info; + const slider = controls?.slider || null; + const segmented = controls?.segmented || null; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0 && !infoText; + container.hidden = + chips.length === 0 && + legend.length === 0 && + !infoText && + !slider && + !segmented; const info = container._rowControlsInfo || null; const firstLegend = container.querySelector('.data-toggle-legend-item'); - syncChipGroup(container, chips, { before: firstLegend || info }); + // Order: chips, switch, slider, legend, info. + syncSegmentedGroup(container, segmented, { before: firstLegend || info }); + const switchNode = container.querySelector('.data-toggle-segmented'); + syncSliderBlock(container, slider, { + before: switchNode?.nextSibling || firstLegend || info, + }); + const firstControl = + switchNode || container.querySelector('.data-toggle-slider'); + syncChipGroup(container, chips, { + before: firstControl || firstLegend || info, + }); const legendSignature = JSON.stringify( legend.map(({ label, color, count, blurb }) => [ diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js new file mode 100644 index 000000000..b0299d24b --- /dev/null +++ b/src/ui/rowFilterControls.js @@ -0,0 +1,169 @@ +import { createSlider } from './slider.js'; +import { syncSegmentedControl } from './segmentedControl.js'; + +const set = (node, key, value) => { + if (node[key] !== value) node[key] = value; +}; +const attribute = (node, key, value) => { + if (node.getAttribute(key) !== value) node.setAttribute(key, value); +}; +// Custom properties need setProperty; test fixtures carry a plain style bag. +const styleVar = (node, name, value) => { + if (typeof node.style?.setProperty === 'function') { + if (value) node.style.setProperty(name, value); + else node.style.removeProperty(name); + } else if (node.style) node.style[name] = value || ''; +}; + +/** + * Reconcile a layer row's quantitative slider block in place (keyed by the + * descriptor id), so a refresh triggered by the slider's own input never + * replaces the node under the pointer. The caller owns dispatch: it listens + * for `input` on the row container and reads `.data-toggle-slider-input`. + * + * Descriptor: `{ id, label, ariaLabel, valueText, min, max, step, value, + * track, ticks: [{ at, label }] }` — `track` is a CSS background painting the + * runnable track (a color ramp doubles as the legend), `at` is 0..1. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} slider Descriptor, or null to remove the block. + * @param {{before?: ?Node}} [options] Insertion anchor for a new block. + * @returns {?HTMLElement} The block, when rendered. + */ +export function syncSliderBlock(container, slider, { before = null } = {}) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.sliderId, + ); + if (!slider) { + existing?.remove(); + return null; + } + let block = existing; + if (!block || block.dataset.sliderId !== slider.id) { + existing?.remove(); + block = document.createElement('div'); + block.className = 'data-toggle-slider'; + block.dataset.sliderId = slider.id; + const head = document.createElement('div'); + head.className = 'data-toggle-slider-head'; + const label = document.createElement('span'); + label.className = 'data-toggle-slider-label'; + const value = document.createElement('span'); + value.className = 'data-toggle-slider-value gev-slider-value'; + head.append(label, value); + const input = createSlider({ + documentRef: document, + className: 'data-toggle-slider-input', + min: slider.min, + max: slider.max, + step: slider.step, + value: slider.value, + }); + input.dataset.sliderId = slider.id; + const ticks = document.createElement('div'); + ticks.className = 'data-toggle-slider-ticks'; + block.append(head, input, ticks); + block._parts = { label, value, input, ticks }; + container.insertBefore(block, before); + } + const { label, value, input, ticks } = block._parts; + set(label, 'textContent', slider.label ?? ''); + set(value, 'textContent', slider.valueText ?? ''); + set(input, 'min', String(slider.min)); + set(input, 'max', String(slider.max)); + set(input, 'step', String(slider.step ?? 1)); + // Never move the thumb under an active drag: the layer's rounded value can + // sit a hair off the pointer, and a refresh runs on every input tick. + if (document.activeElement !== input) + set(input, 'value', String(slider.value)); + if (slider.ariaLabel) attribute(input, 'aria-label', slider.ariaLabel); + if (slider.valueText) attribute(input, 'aria-valuetext', slider.valueText); + const track = slider.track || ''; + if ((block.dataset.track || '') !== track) { + styleVar(input, '--slider-track', track); + block.dataset.track = track; + } + const signature = JSON.stringify(slider.ticks || []); + if (block.dataset.ticks !== signature) { + for (const node of [...ticks.children]) node.remove(); + for (const tick of slider.ticks || []) { + const node = document.createElement('span'); + node.className = 'data-toggle-slider-tick'; + styleVar(node, '--at', `${(tick.at * 100).toFixed(2)}%`); + node.textContent = tick.label; + ticks.appendChild(node); + } + block.dataset.ticks = signature; + } + return block; +} + +/** + * Reconcile a one-of-many switch (`.gev-segmented` radiogroup) in place. + * Buttons carry `data-chip-id` like chips so the row's delegated click handler + * can resolve them; the caller matches `.data-toggle-segment`. + * + * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }`. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} segmented Descriptor, or null to remove the group. + * @param {{before?: ?Node}} [options] Insertion anchor for a new group. + * @returns {?HTMLElement} The group, when rendered. + */ +export function syncSegmentedGroup( + container, + segmented, + { before = null } = {}, +) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.segmentedId, + ); + if (!segmented) { + existing?.remove(); + return null; + } + let group = existing; + if (!group || group.dataset.segmentedId !== segmented.id) { + existing?.remove(); + group = document.createElement('div'); + group.className = 'gev-segmented data-toggle-segmented'; + group.dataset.segmentedId = segmented.id; + group.setAttribute('role', 'radiogroup'); + container.insertBefore(group, before); + } + if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); + const stale = new Map(); + for (const node of [...group.children]) + if (node.dataset?.chipId) stale.set(node.dataset.chipId, node); + const ordered = []; + for (const option of segmented.options || []) { + let button = stale.get(option.id); + stale.delete(option.id); + if (!button) { + button = document.createElement('button'); + button.type = 'button'; + button.className = 'gev-segment data-toggle-segment'; + button.dataset.chipId = option.id; + button.setAttribute('role', 'radio'); + } + set(button, 'textContent', option.label); + set(button, 'title', option.title || ''); + set(button, 'disabled', Boolean(option.disabled)); + ordered.push(button); + } + for (const node of stale.values()) node.remove(); + ordered.forEach((node, index) => { + if (group.children[index] !== node) + group.insertBefore(node, group.children[index] || null); + }); + const active = (segmented.options || []).find((option) => option.active); + syncSegmentedControl(ordered, active?.id, { + valueOf: (button) => button.dataset.chipId, + activeClass: 'active', + rovingFocus: true, + }); + return group; +} diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs new file mode 100644 index 000000000..9cc8fb1d4 --- /dev/null +++ b/src/ui/rowFilterControls.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; +import { railFixture } from './railTestFixture.mjs'; + +const sliderDescriptor = (overrides = {}) => ({ + id: 'min-frp', + label: 'MIN FRP', + ariaLabel: 'Minimum fire radiative power', + valueText: 'All', + min: 0, + max: 1000, + step: 1, + value: 0, + track: 'linear-gradient(90deg, #4a5bd4 0% 23%, #d1382a 82% 100%)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.233, label: '5' }, + { at: 0.826, label: '300' }, + ], + params: (value) => ({ minFrp: Number(value) }), + ...overrides, +}); + +const segmentedDescriptor = (active = 'all') => ({ + id: 'confidence', + ariaLabel: 'Detection confidence', + options: ['all', 'nominal', 'high'].map((mode) => ({ + id: `confidence-${mode}`, + label: mode.toUpperCase(), + title: `${mode} title`, + active: mode === active, + params: { confidence: mode }, + })), +}); + +test('the slider block renders label, value, painted track and ticks, and is reconciled in place', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const block = syncSliderBlock(container, sliderDescriptor()); + assert.equal(block.className, 'data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); + const input = block.querySelector('.data-toggle-slider-input'); + assert.equal(input.type, 'range'); + assert.equal(input.max, '1000'); + assert.equal(input.value, '0'); + assert.equal( + input.getAttribute('aria-label'), + 'Minimum fire radiative power', + ); + assert.equal(input.getAttribute('aria-valuetext'), 'All'); + assert.match(input.style['--slider-track'], /^linear-gradient/); + assert.equal( + block.querySelector('.data-toggle-slider-label').textContent, + 'MIN FRP', + ); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + 'All', + ); + const ticks = block.querySelector('.data-toggle-slider-ticks'); + assert.deepEqual( + ticks.children.map((node) => [node.textContent, node.style['--at']]), + [ + ['0', '0.00%'], + ['5', '23.30%'], + ['300', '82.60%'], + ], + ); + + const again = syncSliderBlock( + container, + sliderDescriptor({ value: 434, valueText: '20 MW' }), + ); + assert.equal(again, block, 'same node — a refresh never drops the thumb'); + assert.equal(input.value, '434'); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + '20 MW', + ); + assert.equal(container.children.length, 1); + + // A thumb under the pointer is never moved by a refresh. + input.focus(); + syncSliderBlock(container, sliderDescriptor({ value: 0 })); + assert.equal(input.value, '434'); + + syncSliderBlock(container, null); + assert.equal(container.children.length, 0); +}); + +test('the segmented group is a radiogroup whose active option reads checked, reconciled by id', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const group = syncSegmentedGroup(container, segmentedDescriptor('all')); + assert.equal(group.getAttribute('role'), 'radiogroup'); + assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); + assert.ok(group.className.split(' ').includes('gev-segmented')); + const buttons = group.children; + assert.deepEqual( + buttons.map((b) => [ + b.dataset.chipId, + b.textContent, + b.getAttribute('aria-checked'), + b.getAttribute('tabindex'), + ]), + [ + ['confidence-all', 'ALL', 'true', '0'], + ['confidence-nominal', 'NOMINAL', 'false', '-1'], + ['confidence-high', 'HIGH', 'false', '-1'], + ], + ); + assert.equal(buttons[0].getAttribute('role'), 'radio'); + assert.equal(buttons[1].title, 'nominal title'); + + syncSegmentedGroup(container, segmentedDescriptor('high')); + assert.equal(group.children[0], buttons[0], 'buttons are reused'); + assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); + assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); + assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 1); + + syncSegmentedGroup(container, null); + assert.equal(container.children.length, 0); +}); + +test('a layer row dispatches slider input and switch clicks as user params', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const f = railFixture(); + const previousDocument = globalThis.document; + globalThis.document = f.document; + const calls = []; + let controls = { + slider: sliderDescriptor(), + segmented: segmentedDescriptor('all'), + info: '12 of 40 shown', + }; + const panel = new LayerPanel({ + getLayers: () => [ + { + id: 'local-firms', + name: 'Active Fires', + icon: '▲', + source: 'NASA FIRMS', + enabled: true, + showInTogglePanel: true, + stats: {}, + }, + ], + isEnabled: () => true, + setEnabled: () => {}, + setLayerParams: (...args) => calls.push(args), + hasRowControls: () => true, + subscribeRowControls: () => {}, + getRowControls: () => controls, + }); + try { + panel.mount(f.container); + const row = f.find((n) => n.dataset.layerId === 'local-firms'); + const container = row.querySelector('.data-toggle-controls'); + assert.equal(container.hidden, false); + assert.equal( + row.querySelector('.data-toggle-controls-info').textContent, + '12 of 40 shown', + ); + // Order: switch, slider, info. + assert.deepEqual( + container.children.map((node) => node.className.split(' ')[0]), + ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + ); + + const input = row.querySelector('.data-toggle-slider-input'); + input.value = '434'; + const inputEvent = new Event('input'); + Object.defineProperty(inputEvent, 'target', { value: input }); + container.dispatchEvent(inputEvent); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { minFrp: 434 }, + { origin: 'user' }, + ]); + + const high = row + .querySelector('.data-toggle-segmented') + .children.find((b) => b.dataset.chipId === 'confidence-high'); + high.click(); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { confidence: 'high' }, + { origin: 'user' }, + ]); + + // A stale descriptor never applies: the live one decides. + controls = { ...controls, segmented: segmentedDescriptor('high') }; + // The fixture DOM has no attribute selectors, so sync the row directly. + panel._syncRowControls(container, { id: 'local-firms', enabled: true }); + assert.equal(high.getAttribute('aria-checked'), 'true'); + assert.equal( + row.querySelector('.data-toggle-slider-input'), + input, + 'the slider survives a refresh', + ); + } finally { + panel.destroy(); + globalThis.document = previousDocument; + } +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 483321992..5047ae6f4 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1035,6 +1035,108 @@ html:not([lang='en']) .data-row-list-text { padding-top: 3px; overflow-wrap: anywhere; } + +/* Optional per-layer one-of-many switch (Active Fires confidence). The shared + .gev-segmented surface, sized to the chip row. */ +.data-toggle-controls .data-toggle-segmented { + flex-basis: 100%; + max-width: 220px; +} + +.data-toggle-controls .data-toggle-segment { + min-height: 20px; + padding: 3px 6px; + font-size: 8px; + letter-spacing: 0.8px; +} + +/* Optional per-layer quantitative slider (Active Fires minimum FRP). The + track is painted by the layer (`--slider-track`, a stepped color ramp), so + the slider doubles as the marker legend; ticks sit under their thresholds. */ +.data-toggle-slider { + flex-basis: 100%; + display: grid; + gap: 2px; + padding-top: 2px; +} + +.data-toggle-slider-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + +.data-toggle-slider-value { + min-width: 0; + font-size: 9px; + letter-spacing: 0.5px; + text-transform: none; +} + +.data-toggle-slider-input { + height: 14px; +} + +.data-toggle-slider-input::-webkit-slider-runnable-track { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.data-toggle-slider-input::-moz-range-track, +.data-toggle-slider-input::-moz-range-progress { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); +} + +.data-toggle-slider-input::-webkit-slider-thumb { + width: 11px; + height: 11px; + margin-top: -3px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-input::-moz-range-thumb { + width: 11px; + height: 11px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-ticks { + position: relative; + height: 10px; + margin: 0 5px; + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.4px; + color: var(--text-dim); +} + +.data-toggle-slider-tick { + position: absolute; + top: 0; + left: var(--at); + transform: translateX(-50%); + white-space: nowrap; +} + +.data-toggle-slider-tick:first-child { + transform: none; +} /* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */ .sdr-radio-card { display: grid; From 22a87500c6b3296da1003a4101ca0ed80ba1f992 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:30:58 +0400 Subject: [PATCH 05/43] docs(fires): current-state note and changelog fragment for the intensity filter (#1426) Describe the six-stop FRP ramp, the slider / confidence switch / counter, the single filter point, the share-token options and the tool arguments in docs/CURRENT-STATE.md, and add changelog.d/1426-fire-intensity-filter.md as CONTRIBUTING asks for runtime changes. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 8 ++++++++ docs/CURRENT-STATE.md | 24 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 changelog.d/1426-fire-intensity-filter.md diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md new file mode 100644 index 000000000..fad9051e9 --- /dev/null +++ b/changelog.d/1426-fire-intensity-filter.md @@ -0,0 +1,8 @@ +- Color every Active Fires detection by fire radiative power on a six-stop + cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · + 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the + ramp, an All / Nominal+ / High confidence switch and an "N of M shown" + counter to the layer row. The filter applies once before rendering, so + markers, cards, heat cells, context and the top-fires query agree; share + links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching + `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..61699bda9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2054,6 +2054,30 @@ Generated style sliders use the same name as their visible parameter label. The first-run suppression checkbox keeps its native wrapping label, so its accessible name remains "Don't show this again". Control behavior is unchanged. +## Active Fires intensity filter and color ramp + +Every FIRMS detection is colored by fire radiative power on a six-stop cold → +hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat +cells, glow sprites and card accents read the same ramp; confidence and +brightness do not affect the color. + +The Active Fires row carries a minimum-FRP slider on a log scale whose track is +painted with the ramp and ticked at the same six values, an All / Nominal+ / +High confidence switch, and an "N of M shown" counter. The default is 0 MW and +All, because many of the strongest detections are rated low confidence. The +filter runs once in `ingestion.applyFilter()` before rendering, so markers, +cards, heat cells, the shared context store, the top-fires query and the +analyst records all see one filtered set; a selected fire that no longer passes +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. + +The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` +(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and +names the applied criteria in its notes. Stale-snapshot and missing-source +reporting are unchanged. + ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. From 1dad6b658a77b80465204cd10c5247cb4c2c084c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:50:03 +0400 Subject: [PATCH 06/43] feat(fires): caption the confidence switch and name the slider "Fire intensity (FRP)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch had no visible word saying what ALL / NOMINAL+ / HIGH refer to, and "Min FRP" assumed the reader knows the acronym. The segmented row descriptor gains an optional visible `label` (a caption above the group); the fires row uses "Confidence" and "Fire intensity (FRP)", and the slider value reads "≥ N MW" so the threshold is still clear. Locales updated. Co-Authored-By: Claude Fable 5.1 --- docs/CURRENT-STATE.md | 7 ++++--- src/i18n/locales/es.json | 5 +++-- src/i18n/locales/ru.json | 5 +++-- src/i18n/locales/zh-CN.json | 5 +++-- src/i18n/same-as-english.json | 4 ++-- src/layers/firms/queries.js | 5 +++-- src/ui/rowFilterControls.js | 24 ++++++++++++++++++++++-- src/ui/rowFilterControls.test.mjs | 23 ++++++++++++++++++++--- src/ui/styles/radio.css | 10 ++++++++++ 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61699bda9..2132a910f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2062,9 +2062,10 @@ hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from cells, glow sprites and card accents read the same ramp; confidence and brightness do not affect the color. -The Active Fires row carries a minimum-FRP slider on a log scale whose track is -painted with the ramp and ticked at the same six values, an All / Nominal+ / -High confidence switch, and an "N of M shown" counter. The default is 0 MW and +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, heat cells, the shared context store, the top-fires query and the diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ffee7fb87..babea8bfb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,9 +3008,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "High confidence detections only": "Solo detecciones de confianza alta", - "Min FRP": "FRP mín.", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "{value} MW": "{value} MW", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1cd88757d..6289ad38c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3077,9 +3077,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Nominal and high confidence detections": "Детекции средней и высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности", - "Min FRP": "Мин. FRP", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "{value} MW": "{value} МВт", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3044c45f8..110f7a77c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,9 +2870,10 @@ "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Nominal and high confidence detections": "标称和高置信度的检测", "High confidence detections only": "仅高置信度的检测", - "Min FRP": "最小 FRP", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "{value} MW": "{value} MW", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5f1b14f8a..5d0313965 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -121,7 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -197,7 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 220012133..44ce8573d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -135,11 +135,11 @@ export function createQueries({ return { slider: { id: 'min-frp', - label: t('Min FRP'), + label: t('Fire intensity (FRP)'), ariaLabel: t('Minimum fire radiative power, megawatts'), valueText: minFrp > 0 - ? t('{value} MW', { value: frpLabel(minFrp) }) + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) : t('All', { context: 'fire intensity filter' }), min: 0, max: SLIDER_STEPS, @@ -156,6 +156,7 @@ export function createQueries({ }, segmented: { id: 'confidence', + label: t('Confidence'), ariaLabel: t('Detection confidence'), options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ id: `confidence-${mode}`, diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js index b0299d24b..87a3e0bc6 100644 --- a/src/ui/rowFilterControls.js +++ b/src/ui/rowFilterControls.js @@ -104,8 +104,9 @@ export function syncSliderBlock(container, slider, { before = null } = {}) { * Buttons carry `data-chip-id` like chips so the row's delegated click handler * can resolve them; the caller matches `.data-toggle-segment`. * - * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, - * disabled, params }] }`. + * Descriptor: `{ id, label, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }` — `label` is an optional visible caption above the + * buttons (the group itself is a flex item, so the caption is a sibling). * @param {HTMLElement} container The row's `.data-toggle-controls` node. * @param {?object} segmented Descriptor, or null to remove the group. * @param {{before?: ?Node}} [options] Insertion anchor for a new group. @@ -121,19 +122,38 @@ export function syncSegmentedGroup( const existing = [...container.children].find( (node) => node.dataset?.segmentedId, ); + const existingCaption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor, + ); if (!segmented) { existing?.remove(); + existingCaption?.remove(); return null; } let group = existing; if (!group || group.dataset.segmentedId !== segmented.id) { existing?.remove(); + existingCaption?.remove(); group = document.createElement('div'); group.className = 'gev-segmented data-toggle-segmented'; group.dataset.segmentedId = segmented.id; group.setAttribute('role', 'radiogroup'); container.insertBefore(group, before); } + let caption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor === segmented.id, + ); + if (segmented.label) { + if (!caption) { + caption = document.createElement('span'); + caption.className = 'data-toggle-segmented-label'; + caption.dataset.segmentedCaptionFor = segmented.id; + container.insertBefore(caption, group); + } + set(caption, 'textContent', segmented.label); + } else { + caption?.remove(); + } if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); const stale = new Map(); for (const node of [...group.children]) diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs index 9cc8fb1d4..ac5d2c1d0 100644 --- a/src/ui/rowFilterControls.test.mjs +++ b/src/ui/rowFilterControls.test.mjs @@ -24,6 +24,7 @@ const sliderDescriptor = (overrides = {}) => ({ const segmentedDescriptor = (active = 'all') => ({ id: 'confidence', + label: 'Confidence', ariaLabel: 'Detection confidence', options: ['all', 'nominal', 'high'].map((mode) => ({ id: `confidence-${mode}`, @@ -96,6 +97,9 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(group.getAttribute('role'), 'radiogroup'); assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); assert.ok(group.className.split(' ').includes('gev-segmented')); + const caption = container.children[container.children.indexOf(group) - 1]; + assert.equal(caption.className, 'data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); const buttons = group.children; assert.deepEqual( buttons.map((b) => [ @@ -118,10 +122,18 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 2, 'caption and group'); + + syncSegmentedGroup(container, { ...segmentedDescriptor('high'), label: '' }); + assert.equal( + container.querySelector('.data-toggle-segmented-label'), + null, + 'an empty label removes the caption', + ); assert.equal(container.children.length, 1); syncSegmentedGroup(container, null); - assert.equal(container.children.length, 0); + assert.equal(container.children.length, 0, 'null removes group and caption'); }); test('a layer row dispatches slider input and switch clicks as user params', async () => { @@ -163,10 +175,15 @@ test('a layer row dispatches slider input and switch clicks as user params', asy row.querySelector('.data-toggle-controls-info').textContent, '12 of 40 shown', ); - // Order: switch, slider, info. + // Order: switch caption, switch, slider, info. assert.deepEqual( container.children.map((node) => node.className.split(' ')[0]), - ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + [ + 'data-toggle-segmented-label', + 'gev-segmented', + 'data-toggle-slider', + 'data-toggle-controls-info', + ], ); const input = row.querySelector('.data-toggle-slider-input'); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5047ae6f4..e005c5288 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1043,6 +1043,16 @@ html:not([lang='en']) .data-row-list-text { max-width: 220px; } +.data-toggle-segmented-label { + flex-basis: 100%; + padding-top: 2px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + .data-toggle-controls .data-toggle-segment { min-height: 20px; padding: 3px 6px; From 887b261195fe4a425e9cd8dfdd5027172b7c5342 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:19:40 +0400 Subject: [PATCH 07/43] fix(fires): paint aggregated heat cells warm by density, not by strongest FRP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coloring each far-zoom cell with the ramp stop of its strongest detection turned a burning region into a blue / teal / lime mosaic: neighbouring cells flipped hue on one outlier and the cold stops did not read as fire. Cells say where it burns, so they go back to the heat-normalized score (density, night share, max FRP) on the warm end of the ramp — lime, amber, red — with the cell card accent matching. Single detections keep the full six-stop FRP ramp. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 3 +- docs/CURRENT-STATE.md | 9 ++++-- src/layers/firms/model.js | 37 +++++++++++++++++------ src/layers/firms/rendering.js | 5 +-- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index fad9051e9..09e3c3a7d 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,7 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. The filter applies once before rendering, so + counter to the layer row. Far-zoom heat cells keep a warm density fill + (lime · amber · red). The filter applies once before rendering, so markers, cards, heat cells, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2132a910f..7e29d8675 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2058,9 +2058,12 @@ accessible name remains "Don't show this again". Control behavior is unchanged. Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat -cells, glow sprites and card accents read the same ramp; confidence and -brightness do not affect the color. +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow +sprites and fire-card accents read that ramp; confidence and brightness do +not affect the color. Aggregated heat cells (far zoom) instead take the warm +end of the ramp — lime, amber or red — from their heat-normalized score +(density, night share and strongest FRP), so a region reads as one field +rather than a per-cell mosaic; the cell card's accent matches its fill. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index a90066cc6..83f573c98 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -137,15 +137,33 @@ export function createModel({ } /** - * Fill color for an aggregated heat cell: the ramp color of its strongest - * detection, so cells, markers and the slider legend read on one scale. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * Severity stop for an aggregated heat cell from its heat-normalized score + * (density, night share and strongest FRP together). Cells say *where* it + * burns, so they stay on the warm end of the ramp — lime, amber, red — and + * read as one field instead of a per-cell mosaic; the cold stops belong to + * single detections, which say *how hard* each one burns. + * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function cellColorStop(normalized) { + const name = + normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); + } + + /** + * Fill color for an aggregated heat cell. + * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). * @param {number} alpha - Fill alpha (heat-normalized by the caller). * @returns {Cesium.Color} */ - function heatColor(maxFrp, alpha) { - return frpColorStop(maxFrp).color.withAlpha(alpha); + function heatColor(normalized, alpha) { + return cellColorStop(normalized).color.withAlpha(alpha); } /** @@ -476,14 +494,14 @@ export function createModel({ } /** - * Cell card accent: the ramp stop of the cell's strongest detection, + * Cell card accent: the warm severity stop of the cell's heat score, * mirroring heatColor. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} normalized - 0..1 heat score. * @returns {string} "r, g, b" accent string. */ - function cellAccent(maxFrp) { - return accentForSeverity(frpColorStop(maxFrp).name); + function cellAccent(normalized) { + return accentForSeverity(cellColorStop(normalized).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -590,6 +608,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 757fd4cf7..8ae445f91 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(cell.maxFrp, alpha); + const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,12 +170,13 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. + // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(cell.maxFrp), + accent: components.model.cellAccent(normalized), }); } From b778a43f1d7538a10d29f89b0611c29e3dabfe6e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:05:14 +0400 Subject: [PATCH 08/43] feat(fires): Snap Map-style heat raster and its globe imagery drape Detections are binned on a 2048x1024 equirectangular grid weighted 1 + log10(1 + FRP), Gaussian-blurred (longitude wraps), normalized on a log scale against the 99.5th percentile and mapped blue -> red with an alpha ramp that is fully transparent below the floor. heatImagery drapes the bytes as one full-globe SingleTileImageryProvider on the active imagery host. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsHeatRaster.js | 176 ++++++++++++++++++++++++++ src/data/firmsHeatRaster.test.mjs | 73 +++++++++++ src/layers/firms/heatImagery.js | 71 +++++++++++ src/layers/firms/heatImagery.test.mjs | 87 +++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/data/firmsHeatRaster.js create mode 100644 src/data/firmsHeatRaster.test.mjs create mode 100644 src/layers/firms/heatImagery.js create mode 100644 src/layers/firms/heatImagery.test.mjs diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js new file mode 100644 index 000000000..52a020671 --- /dev/null +++ b/src/data/firmsHeatRaster.js @@ -0,0 +1,176 @@ +/** + * Snap Map–style heat raster for the Active Fires far-zoom bands: detections + * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), + * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: + * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + */ + +/** Below this normalized heat nothing is painted: empty land stays empty. */ +export const HEAT_FLOOR = 0.03; + +export const HEAT_PALETTE = Object.freeze([ + { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, + { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, + { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, + { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, + { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, + { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, + { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, +]); + +export function heatColorAt(t) { + if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0]; + const v = Math.min(1, t); + let i = 1; + while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1; + const a = HEAT_PALETTE[i - 1]; + const b = HEAT_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + const mix = (p, q) => Math.round(p + (q - p) * k); + return [ + mix(a.rgb[0], b.rgb[0]), + mix(a.rgb[1], b.rgb[1]), + mix(a.rgb[2], b.rgb[2]), + Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255), + ]; +} + +const defaultWeight = (fire) => + 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + +export function binFires(fires, { width, height, weight = defaultWeight }) { + const grid = new Float32Array(width * height); + for (const fire of fires) { + if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; + const x = Math.min( + width - 1, + Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), + ); + const y = Math.min( + height - 1, + Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), + ); + grid[y * width + x] += weight(fire); + } + return grid; +} + +function kernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const k = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { + k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma)); + sum += k[i + radius]; + } + for (let i = 0; i < k.length; i++) k[i] /= sum; + return { k, radius }; +} + +export function blurGaussian(grid, width, height, sigma) { + const { k, radius } = kernel(sigma); + const tmp = new Float32Array(grid.length); + const out = new Float32Array(grid.length); + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + const v = grid[row + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const xx = (((x + i) % width) + width) % width; // longitude wraps + tmp[row + xx] += v * k[i + radius]; + } + } + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const v = tmp[y * width + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp + out[yy * width + x] += v * k[i + radius]; + } + } + } + return out; +} + +/** Bins of the log-density histogram used to find the reference quantile. */ +const QUANTILE_BINS = 4096; + +/** + * The p-quantile of the non-zero values, from a histogram of log1p(value): + * one linear pass instead of sorting ~350k burning pixels (a sort cost + * 200 ms+ and dominated the build). Bin width on the log axis is + * max/4096, far finer than the 256-step palette it feeds. + */ +function percentileOfNonZero(values, p) { + let count = 0; + let max = 0; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) { + count += 1; + if (v > max) max = v; + } + } + if (!count) return 0; + const logMax = Math.log1p(max); + const bins = new Uint32Array(QUANTILE_BINS); + const toBin = (QUANTILE_BINS - 1) / logMax; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1; + } + const target = Math.floor(p * (count - 1)); + let seen = 0; + for (let b = 0; b < QUANTILE_BINS; b++) { + seen += bins[b]; + if (seen > target) return Math.expm1((b + 1) / toBin); + } + return max; +} + +/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */ +const LUT_STEPS = 256; +let paletteLut = null; +function heatLut() { + if (!paletteLut) { + paletteLut = new Uint8ClampedArray(LUT_STEPS * 4); + for (let i = 0; i < LUT_STEPS; i++) { + paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4); + } + } + return paletteLut; +} + +export function buildHeatRaster( + fires, + { width, height, sigmaPx, percentile = 0.995 }, +) { + const density = blurGaussian( + binFires(fires, { width, height }), + width, + height, + sigmaPx, + ); + const reference = percentileOfNonZero(density, percentile); + const rgba = new Uint8ClampedArray(width * height * 4); + if (reference > 0) { + const lut = heatLut(); + const scale = (LUT_STEPS - 1) / Math.log1p(reference); + for (let i = 0; i < density.length; i++) { + const d = density[i]; + if (d <= 0) continue; + const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale)); + const l = step * 4; + if (lut[l + 3] === 0) continue; + const o = i * 4; + rgba[o] = lut[l]; + rgba[o + 1] = lut[l + 1]; + rgba[o + 2] = lut[l + 2]; + rgba[o + 3] = lut[l + 3]; + } + } + return { width, height, rgba, reference }; +} diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs new file mode 100644 index 000000000..704c83427 --- /dev/null +++ b/src/data/firmsHeatRaster.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + HEAT_PALETTE, + heatColorAt, + binFires, + blurGaussian, + buildHeatRaster, +} from './firmsHeatRaster.js'; + +const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); + +test('palette runs cold to hot and starts fully transparent', () => { + assert.equal(HEAT_PALETTE[0].at, 0); + assert.equal(HEAT_PALETTE.at(-1).at, 1); + assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); + assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); + const low = heatColorAt(0.15); + const high = heatColorAt(1); + assert.ok(low[2] > low[0], 'low heat is blue'); + assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(high[3] > low[3], 'hotter is more opaque'); +}); + +test('binFires drops each detection into its equirectangular pixel', () => { + const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + width: 4, + height: 2, + }); + assert.equal(grid[0], 1, 'north-west corner'); + assert.equal(grid[7], 1, 'south-east corner'); + assert.equal( + grid.reduce((a, b) => a + b, 0), + 2, + ); +}); + +test('binFires weights strong fires slightly more', () => { + const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); + assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +}); + +test('blurGaussian keeps mass and wraps across the antimeridian', () => { + const w = 16; + const h = 8; + const grid = new Float32Array(w * h); + grid[3 * w + 0] = 1; + const out = blurGaussian(grid, w, h, 1.5); + const sum = out.reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`); + assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge'); +}); + +test('buildHeatRaster is transparent where there are no fires', () => { + const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], { + width: 360, + height: 180, + sigmaPx: 2, + }); + assert.equal(r.rgba.length, 360 * 180 * 4); + const alphaAt = (lat, lon) => { + const x = Math.floor(((lon + 180) / 360) * 360); + const y = Math.floor(((90 - lat) / 180) * 180); + return r.rgba[(y * 360 + x) * 4 + 3]; + }; + assert.ok(alphaAt(40, -120) > 0, 'blob over the fires'); + assert.equal(alphaAt(-30, 60), 0, 'empty far away'); +}); + +test('buildHeatRaster with no fires is fully transparent', () => { + const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); + assert.ok(r.rgba.every((v) => v === 0)); +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js new file mode 100644 index 000000000..f2f99fbe6 --- /dev/null +++ b/src/layers/firms/heatImagery.js @@ -0,0 +1,71 @@ +/** + * Drapes the Active Fires heat raster over the globe as one full-extent + * imagery tile on the active imagery host (globe or 3D-tiles), like the wind + * layer's scalar field. Owns the canvas and the imagery layer; nothing else + * in the fires layer touches imagery. + */ + +const defaultCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createHeatImagery({ + cesium, + getHost, + createCanvas = defaultCanvas, +}) { + let raster = null; + let layer = null; + let collection = null; + let show = true; + + function detach() { + if (layer && collection) collection.remove(layer, true); + layer = null; + collection = null; + } + + function attach() { + detach(); + if (!raster) return; + const host = getHost?.() ?? { collection: null, kind: 'none' }; + if (!host.collection) return; + const canvas = createCanvas(raster.width, raster.height); + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(raster.width, raster.height); + image.data.set(raster.rgba); + ctx.putImageData(image, 0, 0); + const provider = new cesium.SingleTileImageryProvider({ + url: canvas.toDataURL('image/png'), + tileWidth: raster.width, + tileHeight: raster.height, + rectangle: cesium.Rectangle.MAX_VALUE, + }); + collection = host.collection; + layer = collection.addImageryProvider(provider); + layer.show = show; + collection.raiseToTop?.(layer); + } + + return { + setRaster(next) { + raster = next; + attach(); + }, + setShow(value) { + show = Boolean(value); + if (layer) layer.show = show; + }, + rehome() { + const host = getHost?.(); + if ((host?.collection ?? null) !== collection) attach(); + }, + destroy() { + detach(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs new file mode 100644 index 000000000..0fca7b97f --- /dev/null +++ b/src/layers/firms/heatImagery.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeatImagery } from './heatImagery.js'; + +function harness() { + const layers = []; + const removed = []; + const collection = { + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + layers.push(layer); + return layer; + }, + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + raiseToTop() {}, + }; + let host = { collection, kind: 'globe' }; + const cesium = { + Rectangle: { MAX_VALUE: { max: true } }, + SingleTileImageryProvider: class { + constructor(options) { + this.options = options; + } + }, + }; + const createCanvas = (w, h) => ({ + width: w, + height: h, + getContext: () => ({ + createImageData: (cw, ch) => ({ + data: new Uint8ClampedArray(cw * ch * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }); + const imagery = createHeatImagery({ + cesium, + getHost: () => host, + createCanvas, + }); + return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; +} + +const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + +test('setRaster drapes one full-globe tile and replaces it on the next build', () => { + const h = harness(); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1); + assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); + assert.equal(h.layers[0].provider.options.tileWidth, 4); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1, 'never two heat layers'); + assert.equal(h.removed[0].destroy, true); +}); + +test('setShow toggles without rebuilding', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setShow(false); + assert.equal(layer.show, false); + h.imagery.setShow(true); + assert.equal(h.layers[0], layer); + assert.equal(layer.show, true); +}); + +test('no imagery host keeps nothing draped; rehome brings it back', () => { + const h = harness(); + h.setHost({ collection: null, kind: 'none' }); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 0); + h.setHost({ collection: h.collection, kind: 'globe' }); + h.imagery.rehome(); + assert.equal(h.layers.length, 1); +}); + +test('destroy removes the layer', () => { + const h = harness(); + h.imagery.setRaster(raster); + h.imagery.destroy(); + assert.equal(h.layers.length, 0); +}); From de87aaf86acfbf3fd358833c6478e93d9718d3f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:11:32 +0400 Subject: [PATCH 09/43] feat(fires): far zoom shows a Snap Map-style heatmap instead of heat squares The global and regional LOD bands drape the heat raster on the active imagery host and draw no rectangle entities; the 2/1 degree aggregation now only feeds the "N FIRES" cards, whose accent is the strongest fire's FRP color. Markers below ~750 km hide the heatmap, as do disable and destroy. A stale raster is rebuilt immediately on first paint and with a 150 ms trailing debounce afterwards, so a slider drag does not re-encode on every step. The shell hands the layer its imagery host, so the heatmap follows globe and 3D-tiles map stacks. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsInteraction.test.mjs | 75 ++++++++++++++++++ src/layers/firms/heatImagery.js | 4 + src/layers/firms/ingestion.js | 7 +- src/layers/firms/lifecycle.js | 24 ++++++ src/layers/firms/model.js | 48 ++---------- src/layers/firms/policy.js | 32 ++++++-- src/layers/firms/queries.js | 15 +++- src/layers/firms/rendering.js | 119 +++++++++++++++++++---------- src/layers/firms/state.js | 19 +++++ src/ui/layerBindings.js | 6 ++ 10 files changed, 257 insertions(+), 92 deletions(-) diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..39f0382c8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().loading, false); } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); + +test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { + // A canvas stub for the heat drape and the detection glow sprites. + const priorDocument = globalThis.document; + const restoreDocument = () => { + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + }; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + const added = []; + const removed = []; + h.viewer.scene.primitives = { + add() {}, + remove() {}, + raiseToTop() {}, + contains: () => true, + }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + try { + const collection = { + addImageryProvider: (provider) => { + const layer = { provider, show: true }; + added.push(layer); + return layer; + }, + remove: (layer, destroy) => removed.push({ layer, destroy }), + raiseToTop() {}, + }; + h.layer.attachShellServices({ + imageryHost: () => ({ kind: 'globe', collection }), + }); + h.layer._renderLodForTest('global'); + assert.equal(added.length, 1, 'one heat imagery layer'); + assert.equal(added[0].show, true); + assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.ok(h.published.length > 0, 'the far band still paints cards'); + + h.layer._renderLodForTest('regional'); + assert.equal(added.length, 1, 'switching far bands reuses the raster'); + + h.layer._renderLodForTest('close'); + assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + + h.layer._renderLodForTest('global'); + assert.equal(added[0].show, true); + h.layer.disable(); + assert.equal(added[0].show, false, 'disable hides the heatmap'); + + h.layer.destroy(h.viewer); + assert.deepEqual( + removed.map((entry) => [entry.layer, entry.destroy]), + [[added[0], true]], + ); + } finally { + h.cleanup(); + restoreDocument(); + } +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index f2f99fbe6..31fbf1f69 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -55,6 +55,10 @@ export function createHeatImagery({ raster = next; attach(); }, + /** True once a raster was handed over (painted or waiting for a host). */ + hasRaster() { + return raster !== null; + }, setShow(value) { show = Boolean(value); if (layer) layer.show = show; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 215c35da3..cb9dbd72f 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -89,14 +89,15 @@ export function createIngestion({ /** * Run the intensity filter over the loaded payload. This is the ONE place * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, - * heat cells, the context store, the top-fires query and the analyst - * records, so none of them can disagree about which fires exist. Cells are - * aggregated per shown set, so the cache goes with it. + * the heatmap, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells and + * the heat raster are built per shown set, so their caches go with it. */ function applyFilter() { layerState._fires = filterFires(layerState._allFires, layerState._filter); layerState._cellCacheByGrid.clear(); + layerState._heatRaster = null; layerState._firesByFrp = filterFires( layerState._allFiresByFrp, layerState._filter, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 1138504dd..72434ad46 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -14,6 +15,20 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { + /** + * Shell services: `imageryHost()` resolves where draped imagery goes on + * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The + * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services + */ + attachShellServices(services) { + layerState._imageryHost = + typeof services?.imageryHost === 'function' + ? services.imageryHost + : null; + layerState._heatImagery?.rehome(); + }, + init(viewer) { if (layerState._destroyed) return; layerState._viewer = viewer; @@ -40,6 +55,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') + layerState._heatImagery?.setShow(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -64,6 +81,7 @@ export function createLifecycle({ components.selection.clearFireSelection(); if (layerState._dataSource) layerState._dataSource.show = false; if (layerState._billboards) layerState._billboards.show = false; + layerState._heatImagery?.setShow(false); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -96,6 +114,12 @@ export function createLifecycle({ layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + layerState._heatImagery?.destroy(); + layerState._heatImagery = null; + layerState._heatRaster = null; + layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; layerState._rowControlsListener = null; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 83f573c98..c3d3be71a 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -136,36 +136,6 @@ export function createModel({ ); } - /** - * Severity stop for an aggregated heat cell from its heat-normalized score - * (density, night share and strongest FRP together). Cells say *where* it - * burns, so they stay on the warm end of the ramp — lime, amber, red — and - * read as one field instead of a per-cell mosaic; the cold stops belong to - * single detections, which say *how hard* each one burns. - * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). - * @returns {{name: string, minFrp: number, color: Cesium.Color}} - */ - - function cellColorStop(normalized) { - const name = - normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; - return ( - DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || - DETECTION_COLOR_STOPS[0] - ); - } - - /** - * Fill color for an aggregated heat cell. - * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). - * @param {number} alpha - Fill alpha (heat-normalized by the caller). - * @returns {Cesium.Color} - */ - - function heatColor(normalized, alpha) { - return cellColorStop(normalized).color.withAlpha(alpha); - } - /** * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. @@ -432,8 +402,8 @@ export function createModel({ /** * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. + * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets + * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {number} nowMs - Current epoch milliseconds. * @returns {Object} firmsLabels entry. @@ -494,14 +464,14 @@ export function createModel({ } /** - * Cell card accent: the warm severity stop of the cell's heat score, - * mirroring heatColor. - * @param {number} normalized - 0..1 heat score. + * Cell card accent: the cell's strongest fire on the FRP ramp, the same + * color its markers take once the camera is close enough to show them. + * @param {{maxFrp?: number}} cell - Aggregated cell. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - return accentForSeverity(cellColorStop(normalized).name); + function cellCardAccent(cell) { + return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -607,8 +577,6 @@ export function createModel({ boundsContainPoint, cellIntersectsBounds, heatScore, - heatColor, - cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, @@ -623,7 +591,7 @@ export function createModel({ buildFireCard, buildCellCard, applyFirmsOverlayPolicy, - cellAccent, + cellCardAccent, formatLatLon, formatFrp, formatAge, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 18ea88238..eb235bc81 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,9 +7,10 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. + * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their + * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` + * bands render individual fire detections as glow-sprite billboards. * Labels in every band go through the shared screen-space greedy declutter * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. */ @@ -18,7 +19,7 @@ export const LOD_LEVELS = [ { id: 'global', minHeight: 9000000, - mode: 'cells', + mode: 'heat', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000, @@ -26,7 +27,7 @@ export const LOD_LEVELS = [ { id: 'regional', minHeight: 3000000, - mode: 'cells', + mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, @@ -47,6 +48,25 @@ export const LOD_LEVELS = [ }, ]; +/** + * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in + * pixels. Built once per data refresh or filter change, never per camera move. + */ + +export const HEAT_RASTER = Object.freeze({ + width: 2048, + height: 1024, + sigmaPx: 3, +}); + +/** + * Trailing delay before a stale heatmap is rebuilt while one is already on + * screen: a slider drag fires many filter changes, and a build plus PNG + * encode costs a few hundred milliseconds. The first paint is immediate. + */ + +export const HEAT_REBUILD_MS = 150; + export const LOD_CHECK_MS = 650; /** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ @@ -90,7 +110,7 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; /** - * Color stops shared by cell heat fills and detection glow sprites: the + * Color stops for detection glow sprites and card accents: the * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 44ce8573d..797734597 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -9,7 +9,7 @@ import { sliderPositionForFrp, } from '../../data/firmsIntensity.js'; import { formatNumber, t } from '../../i18n/index.js'; -import { REFRESH_INTERVAL_MS } from './policy.js'; +import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; /** Slider resolution: whole-percent positions along the log axis. */ const SLIDER_STEPS = 1000; @@ -309,6 +309,19 @@ export function createQueries({ return result; }, + /** Test seam: render one named LOD band against a near-global view. */ + _renderLodForTest(lodId) { + const lod = LOD_LEVELS.find((level) => level.id === lodId); + if (lod.mode === 'detections') + components.rendering.renderDetections(lod, null); + else components.rendering.renderHeat(lod, null); + }, + + /** Test seam: entities drawn by the layer's data source. */ + _entityCountForTest() { + return layerState._dataSource?.entities?.values?.length ?? 0; + }, + /** Test seam that binds the production click path and indexes. */ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..84a6eed36 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,14 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + LOD_LEVELS, + CONTEXT_TOP_N, + HEAT_RASTER, + HEAT_REBUILD_MS, +} from './policy.js'; +import { createHeatImagery } from './heatImagery.js'; export function createRendering({ layerState, @@ -29,8 +36,11 @@ export function createRendering({ !layerState._dataSource || !layerState._fires.length || !layerState._viewer - ) + ) { + // A filter that leaves nothing shown must not leave the old heat behind. + if (!layerState._fires.length) layerState._heatImagery?.setShow(false); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -54,7 +64,7 @@ export function createRendering({ if (lod.mode === 'detections') { renderDetections(lod, bounds); } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); + renderHeat(lod, bounds); } return true; } @@ -118,65 +128,89 @@ export function createRendering({ return clipped; } + /** The heat imagery adapter, created on first use. */ + + function heatImagery() { + if (!layerState._heatImagery) { + layerState._heatImagery = createHeatImagery({ + cesium: Cesium, + getHost: () => + layerState._imageryHost?.() ?? { + collection: layerState._viewer?.imageryLayers ?? null, + kind: 'globe', + }, + }); + } + return layerState._heatImagery; + } + + /** Build the heat raster from the shown set and drape it. */ + + function rebuildHeatRaster() { + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + if (layerState._destroyed) return; + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + heatImagery().setRaster(layerState._heatRaster); + } + /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. + * Make sure the draped heatmap matches the shown set. The first paint is + * immediate; later rebuilds (filter drags, refreshes) trail by + * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + */ + + function ensureHeatRaster() { + if (layerState._heatRaster) return; + if (!heatImagery().hasRaster()) { + rebuildHeatRaster(); + return; + } + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = setTimeout( + rebuildHeatRaster, + HEAT_REBUILD_MS, + ); + } + + /** + * Far bands (global/regional): a Snap Map–style heatmap draped over the + * globe, transparent where nothing burns, plus the decluttered "N FIRES" + * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards go through the same screen-space label pipeline as detections + * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. * @param {?Object} bounds - Padded view bounds in degrees, or null. */ - function renderCells(cells, lod, bounds) { + function renderHeat(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (layerState._billboards) layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; - layerState._cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + const imagery = heatImagery(); + // The map stack may have changed since the last far render. + imagery.rehome(); + ensureHeatRaster(); + imagery.setShow(layerState._enabled); + + const cells = aggregateFires(lod, bounds); + layerState._cellCount = cells.length; layerState._labelCandidates = []; layerState._labelLodDistance = lod.labelDistance; + // Cells arrive heat-sorted, so candidate order doubles as label priority. for (const cell of cells) { - const score = components.model.heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - layerState._dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees, - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ - position, + position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellCardAccent(cell), }); } @@ -202,6 +236,7 @@ export function createRendering({ */ function renderDetections(lod, bounds) { + layerState._heatImagery?.setShow(false); ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (!layerState._billboards) return; @@ -345,7 +380,7 @@ export function createRendering({ return { renderCurrentLod, aggregateFires, - renderCells, + renderHeat, renderDetections, ensureDetectionCollections, removeDetectionCollections, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index d84ee2026..9a0b031b2 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) { layerState._cellCacheByGrid = new Map(); + /** + * Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt + * on the next far render. Cleared with the cell cache on every new shown set. + */ + + layerState._heatRaster = null; + + /** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */ + + layerState._heatRebuildTimer = null; + + /** Imagery adapter (heatImagery.js), created on the first far render. */ + + layerState._heatImagery = null; + + /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ + + layerState._imageryHost = null; + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index fb8f29119..60128f8e0 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -141,6 +141,12 @@ export class LayerBindings { }); this._weatherShellModules.push(layer); } + // Active Fires drapes its far-zoom heatmap on the same imagery host. + const fires = this._dataManager?.layers?.get('local-firms')?.module; + if (typeof fires?.attachShellServices === 'function') { + fires.attachShellServices({ imageryHost: this.services.imageryHost }); + this._weatherShellModules.push(fires); + } } _persistAwarenessSelection(event, cleared = false) { From 52e428955f4c56249bec1573a542169026561ed2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:17:06 +0400 Subject: [PATCH 10/43] docs(fires): describe the far-zoom heatmap Co-Authored-By: Claude Opus 5.5 --- changelog.d/1426-fire-intensity-filter.md | 6 +++--- docs/CURRENT-STATE.md | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 09e3c3a7d..188f964ad 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,8 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far-zoom heat cells keep a warm density fill - (lime · amber · red). The filter applies once before rendering, so - markers, cards, heat cells, context and the top-fires query agree; share + counter to the layer row. Far zoom shows a Snap Map–style heatmap instead + of squares, empty where nothing burns. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e29d8675..336b32132 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2060,10 +2060,19 @@ Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from 100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. Aggregated heat cells (far zoom) instead take the warm -end of the ramp — lime, amber or red — from their heat-normalized score -(density, night share and strongest FRP), so a region reads as one field -rather than a per-cell mosaic; the cell card's accent matches its fill. +not affect the color. + +At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +heatmap instead of individual markers: the shown detections are binned on a +global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +normalized on a log scale against the 99.5th percentile and mapped from blue +through cyan, green, yellow and orange to red. Nothing is painted where +nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery +tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +on each data refresh or filter change (the first paint at once, later ones +150 ms after the last change) and hidden once markers take over below +~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its @@ -2071,7 +2080,7 @@ value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, -cards, heat cells, the shared context store, the top-fires query and the +cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes is deselected. The layer state keeps both options under the `w` (`local-firms`) share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), From 2c422d2f3820707cf86de8c0f0618d57aade8a84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:26:23 +0400 Subject: [PATCH 11/43] build(boundaries): register the fires heat raster and imagery modules Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 17772b58e..894e70a68 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1174,6 +1174,7 @@ "modules": [ "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1185,6 +1186,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -1916,6 +1918,7 @@ "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2076,6 +2079,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2760,6 +2764,7 @@ "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -2890,6 +2895,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", From d32a4f62d33ef6dfe78d8e2b6f9373625c14e8e0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:09 +0400 Subject: [PATCH 12/43] i18n(zh-Hant): fire filter strings follow zh-CN wording (s2tw) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-Hant.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c71924233..9dadb3a50 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,16 +2868,16 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "fire confidence filter|All": "全部", - "fire confidence filter|Nominal+": "中+", + "fire confidence filter|Nominal+": "標稱+", "fire confidence filter|High": "高", - "Every detection — many of the strongest fires are rated low confidence": "所有探測——許多最強的火點可信度為低", - "Nominal and high confidence detections": "中、高可信度的探測", - "High confidence detections only": "僅高可信度的探測", + "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", + "Nominal and high confidence detections": "標稱和高置信度的檢測", + "High confidence detections only": "僅高置信度的檢測", "Fire intensity (FRP)": "火勢強度 (FRP)", "≥ {value} MW": "≥ {value} MW", - "Confidence": "可信度", - "Minimum fire radiative power, megawatts": "最小火輻射功率(百萬瓦)", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "探測可信度", + "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" } From bef47ee7c7cca963c3eeb145da5aad0bb08263bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 18:25:33 +0400 Subject: [PATCH 13/43] fix(fires): weight the far-zoom heatmap by fire radiative power Heat was a detection count, so raising the minimum-FRP slider removed most detections and the hot spots faded to blue even where strong fires still burned. Each detection now adds its FRP in megawatts (0.5 MW floor), so weak fires carry little heat and filtering them out keeps strong-fire areas hot. On the live payload, hot spots that stay yellow or hotter at >=100 MW rise from 7% to 17% and their median heat from 0.10 to 0.38. --- docs/CURRENT-STATE.md | 4 +++- src/data/firmsHeatRaster.js | 11 ++++++++++- src/data/firmsHeatRaster.test.mjs | 33 +++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b34636004..1bd9c82cd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2096,7 +2096,9 @@ not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +radiated power, and raising the FRP slider does not dim areas where strong fires +burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped from blue through cyan, green, yellow and orange to red. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 52a020671..516c7676f 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -35,8 +35,17 @@ export function heatColorAt(t) { ]; } +/** Weight of a detection with no or zero FRP, in megawatts. */ +export const HEAT_MIN_WEIGHT_MW = 0.5; + +/** + * Heat is radiated power, not a head count: each detection adds its FRP in + * megawatts. Weak fires carry little power, so raising the minimum-FRP slider + * removes most detections without dimming the areas where strong fires burn + * (a count-based heat collapsed there and the hot spots faded). + */ const defaultWeight = (fire) => - 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 704c83427..2b51785e6 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + HEAT_MIN_WEIGHT_MW, HEAT_PALETTE, heatColorAt, binFires, @@ -23,7 +24,7 @@ test('palette runs cold to hot and starts fully transparent', () => { }); test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { width: 4, height: 2, }); @@ -35,9 +36,33 @@ test('binFires drops each detection into its equirectangular pixel', () => { ); }); -test('binFires weights strong fires slightly more', () => { - const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); - assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +test('binFires weights each detection by its radiative power', () => { + const grid = binFires( + [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + { width: 4, height: 2 }, + ); + assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); +}); + +test('filtering out weak fires keeps a strong fire hot', () => { + const strong = fire(0.1, 0.1, 300); + const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2)); + const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100)); + const options = { width: 360, height: 180, sigmaPx: 2 }; + const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0); + const red = (r) => r.rgba[pixel * 4]; + const alpha = (r) => r.rgba[pixel * 4 + 3]; + const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options); + const filtered = buildHeatRaster([strong, ...reference], options); + assert.ok(alpha(filtered) > 0); + assert.ok( + alpha(filtered) >= alpha(unfiltered) * 0.9, + `alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`, + ); + assert.ok( + red(filtered) >= red(unfiltered) * 0.9, + `red ${red(unfiltered)} -> ${red(filtered)}`, + ); }); test('blurGaussian keeps mass and wraps across the antimeridian', () => { From a4c7b20ca825a731531b61fc2935f1a47621248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:10 -0300 Subject: [PATCH 14/43] feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0) Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the city transport secretariat (SMTR): municipal buses (SPPO) and the Mobi-Rio BRT, the Transit layer's first Latin American region. Both feeds publish Position.speed in km/h instead of m/s. A new optional registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it once, so every speedMps consumer stays correct and other feeds are untouched. Document the source in DATA_SOURCES.md and update the region lists in README.md and docs/CURRENT-STATE.md. Refs #1480 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 3 +- README.md | 2 +- docs/CURRENT-STATE.md | 8 ++++-- src/data/transitFeeds.js | 50 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 43 +++++++++++++++++++++++++++++ src/data/transitProxy.js | 21 +++++++++++++- src/data/transitProxy.test.mjs | 26 ++++++++++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e31d0d3f0..2b989c509 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -95,6 +95,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | +| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -177,7 +178,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington 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. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/README.md b/README.md index 745bec3d8..965c0e283 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 966bb3878..2adac270e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4295,11 +4295,13 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland, and nine in +gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro +(municipal buses and the Mobi-Rio BRT, two feeds), and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, -Burlington, Barrie and Kingston. Mode silhouettes +Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which +the proxy converts to m/s before any card reads it. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index b8476a6ee..e871a2e89 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -385,7 +385,12 @@ function burlingtonRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'kmh', * }>>} + * + * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h + * instead of the m/s the spec requires; the proxy converts it before anything + * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -729,6 +734,51 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: burlingtonRouteLabel, }), + Object.freeze({ + id: 'smtr-rio-onibus', + name: 'Rio buses (SPPO)', + operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro', + region: 'Rio de Janeiro, RJ', + // Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to + // the Zona Sul, sat within 31 km of this point. + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), + Object.freeze({ + id: 'smtr-rio-brt', + name: 'Mobi-Rio BRT', + operator: + 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet', + region: 'Rio de Janeiro, RJ', + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index fd990af45..51903b7db 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -482,3 +482,46 @@ test('Burlington is live and carries its terms link, which its licence requires' assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); }); + +test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => { + const ids = (lat, lon) => + transitFeedsInRange(lat, lon).map((feed) => feed.id); + for (const [place, lat, lon] of [ + ['Centro', -22.9068, -43.1729], + ['Barra da Tijuca', -23.0004, -43.3659], + ['Santa Cruz', -22.9194, -43.6847], + ]) { + assert.ok( + ids(lat, lon).includes('smtr-rio-onibus'), + `buses reach ${place}`, + ); + assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`); + } + assert.deepEqual( + ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')), + [], + 'São Paulo, 360 km away, never polls Rio', + ); +}); + +test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { + for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { + const feed = getTransitFeed(id); + assert.equal(feed.defaultEnabled, true, `${id} is live`); + assert.equal(feed.license, 'CC BY 4.0'); + assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); + assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); + assert.equal(feed.speedUnit, 'kmh', `${id} publishes km/h`); + assert.equal(transitModeFor(feed, null), 'bus'); + } + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources + .split('\n') + .some((line) => line.startsWith('| **SMTR Rio de Janeiro')), + 'Rio has a DATA_SOURCES row', + ); +}); diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 458240e9d..2923c9c32 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,6 +224,25 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } +/** + * Bring a feed's speeds to the m/s the spec requires. A feed registered with + * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a + * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every + * consumer of `speedMps` honest without each one knowing about the feed. + * + * @param {object} feed Registry entry. + * @param {object[]} vehicles Normalized vehicle records. + * @returns {object[]} + */ +export function normalizeFeedSpeeds(feed, vehicles) { + if (feed?.speedUnit !== 'kmh') return vehicles; + return vehicles.map((vehicle) => + Number.isFinite(vehicle.speedMps) + ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } + : vehicle, + ); +} + /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -257,7 +276,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + normalizeFeedSpeeds(feed, decoded.vehicles), decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index dea10ab25..445e658f2 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -137,6 +137,7 @@ function encodeFeed({ const position = new PbfWriter(); position.writeFloatField(1, entity.lat); position.writeFloatField(2, entity.lon); + if (entity.speed != null) position.writeFloatField(5, entity.speed); vehicle.writeBytesField(2, position.finish()); if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp); const descriptor = new PbfWriter(); @@ -284,6 +285,31 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); +test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { + // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus + // would be reported at 130 km/h. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, + { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, + { id: 'silent', lat: -22.9, lon: -43.2 }, + ], + }); + const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); + const speeds = Object.fromEntries( + rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), + ); + assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); + assert.equal(speeds.still, 0); + assert.equal(speeds.silent, null, 'an absent speed stays absent'); + const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); + assert.equal( + mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, + 36, + 'a spec-conforming feed is left alone', + ); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); From 53d30c9e80d765b14eb2a2c35a957168cad2bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:47 -0300 Subject: [PATCH 15/43] docs(changelog): add fragment for #1481 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1481-rio-transit.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1481-rio-transit.md diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md new file mode 100644 index 000000000..62b4ede04 --- /dev/null +++ b/changelog.d/1481-rio-transit.md @@ -0,0 +1,4 @@ +- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT + from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin + American region. A feed that publishes speed in km/h declares + `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). From 096383b43266fb6b13d3cc471be4b80f74f7876a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 21:00:13 +0400 Subject: [PATCH 16/43] Translate the fire filter strings into de, fr and ja --- src/i18n/locales/de.json | 15 ++++++++++++++- src/i18n/locales/fr.json | 15 ++++++++++++++- src/i18n/locales/ja.json | 15 ++++++++++++++- src/i18n/same-as-english.json | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..f18b5ca67 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2926,5 +2926,18 @@ "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", + "fire confidence filter|All": "Alle", + "fire confidence filter|Nominal+": "Mittel+", + "fire confidence filter|High": "Hoch", + "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz", + "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", + "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", + "Fire intensity (FRP)": "Intensität (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Konfidenz", + "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "fire intensity filter|All": "Alle", + "Detection confidence": "Konfidenz der Erkennung", + "{shown} of {total} shown": "{shown} von {total} angezeigt" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 931e7c3ff..59641d143 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2996,5 +2996,18 @@ "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", + "fire confidence filter|All": "Toutes", + "fire confidence filter|Nominal+": "Nominale+", + "fire confidence filter|High": "Élevée", + "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible", + "Nominal and high confidence detections": "Détections de confiance nominale et élevée", + "High confidence detections only": "Détections de confiance élevée uniquement", + "Fire intensity (FRP)": "Intensité (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiance", + "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "fire intensity filter|All": "Toutes", + "Detection confidence": "Confiance de la détection", + "{shown} of {total} shown": "{shown} sur {total} affichées" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a48dc886..85757c1dd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2856,5 +2856,18 @@ "routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます", "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", - "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました" + "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", + "fire confidence filter|All": "すべて", + "fire confidence filter|Nominal+": "中以上", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています", + "Nominal and high confidence detections": "信頼度が中・高の検知", + "High confidence detections only": "信頼度が高の検知のみ", + "Fire intensity (FRP)": "強度(FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "信頼度", + "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "fire intensity filter|All": "すべて", + "Detection confidence": "検知の信頼度", + "{shown} of {total} shown": "{total}件中{shown}件を表示" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index dd0df9990..390b56e58 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -163,6 +163,7 @@ "{value} KM/S", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "es": [ @@ -469,6 +470,7 @@ "ΔE", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "it": [ @@ -688,6 +690,7 @@ "{type} SPD: {speed} HDG: {heading}", "ΔE", "ΔN", + "≥ {value} MW", "▲ {frp} MW" ], "pt": [ From 18d55f58b9a756cb758996eb2593e6b2967a956d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 13:48:28 +0400 Subject: [PATCH 17/43] Color fires from ember to white-hot instead of blue to red About 90% of detections are under 20 MW, so the old blue and teal stops painted most of the map in cold colors that do not read as fire. The ramp now runs ember, red, flame, orange, gold and white-hot at the same thresholds, each stop lighter than the one below, so the brightest marks are the strongest fires. The heatmap uses the same colors. --- changelog.d/1426-fire-intensity-filter.md | 12 ++--- docs/CURRENT-STATE.md | 17 ++++--- src/data/firmsCards.test.mjs | 26 +++++----- src/data/firmsHeatRaster.js | 18 ++++--- src/data/firmsHeatRaster.test.mjs | 10 ++-- src/data/firmsIntensity.js | 27 +++++----- src/data/firmsIntensity.test.mjs | 62 ++++++++++++++--------- src/data/firmsLabels.js | 4 +- src/data/firmsLabels.test.mjs | 20 ++++---- src/layers/firms/model.js | 2 +- src/layers/firms/policy.js | 2 +- src/ui/layerPanel/layerRows.test.mjs | 2 +- 12 files changed, 115 insertions(+), 87 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 188f964ad..c1d06643e 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -1,9 +1,9 @@ - Color every Active Fires detection by fire radiative power on a six-stop - cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · - 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the - ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far zoom shows a Snap Map–style heatmap instead - of squares, empty where nothing burns. The filter applies once before - rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at + 0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a + log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / + High confidence switch and an "N of M shown" counter to the layer row. Far + zoom shows a Snap Map–style heatmap in the same colors instead of squares, + empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7d941001..5644960bc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2175,19 +2175,22 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## Active Fires intensity filter and color ramp -Every FIRMS detection is colored by fire radiative power on a six-stop cold → -hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow -sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. +Every FIRMS detection is colored by fire radiative power on a six-stop ember → +white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from +50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in +`src/data/firmsIntensity.js`). Each stop is lighter than the one below and +none is a cold hue, so the brightest marks are the strongest fires; ~90% of +detections are under 20 MW and stay dim red. Glow sprites and fire-card +accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, -normalized on a log scale against the 99.5th percentile and mapped from blue -through cyan, green, yellow and orange to red. Nothing is painted where +normalized on a log scale against the 99.5th percentile and mapped through the +same colors, from a faint ember edge through red, orange and gold to a pale +white-hot core. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index d0ca7bd70..9d6157324 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () = assert.equal(card.details.length, 1); assert.equal(card.details[0], 'high · 2h · N20'); assert.equal(card.selected, false); - assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot'); + assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot'); assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched'); }); @@ -69,26 +69,26 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.equal(card.accent, accentForSeverity('blue')); + assert.equal(card.accent, accentForSeverity('ember')); }); test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { const accentAt = (frp) => buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; - assert.equal(accentAt(4.9), accentForSeverity('blue')); - assert.equal(accentAt(5), accentForSeverity('teal')); - assert.equal(accentAt(20), accentForSeverity('lime')); - assert.equal(accentAt(50), accentForSeverity('amber')); - assert.equal(accentAt(100), accentForSeverity('orange')); - assert.equal(accentAt(300), accentForSeverity('red')); + assert.equal(accentAt(4.9), accentForSeverity('ember')); + assert.equal(accentAt(5), accentForSeverity('red')); + assert.equal(accentAt(20), accentForSeverity('flame')); + assert.equal(accentAt(50), accentForSeverity('orange')); + assert.equal(accentAt(100), accentForSeverity('gold')); + assert.equal(accentAt(300), accentForSeverity('white-hot')); // Confidence never moves the color: a low-confidence 864 MW fire stays red. assert.equal( buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, - accentForSeverity('red'), + accentForSeverity('white-hot'), ); }); @@ -109,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', ( const candidate = { cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 }, position: { x: 0, y: 0, z: 0 }, - accent: accentForSeverity('orange'), + accent: accentForSeverity('gold'), }; const card = buildCellCard(candidate, NOW); assert.equal(card.title, '14 FIRES'); assert.equal(card.details[0], 'max 210 MW · new 3h'); - assert.equal(card.accent, accentForSeverity('orange')); + assert.equal(card.accent, accentForSeverity('gold')); }); test('buildCellCard: the count picks the plural form of the language', () => { @@ -166,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop'); + assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 516c7676f..985699d7e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -8,14 +8,18 @@ /** Below this normalized heat nothing is painted: empty land stays empty. */ export const HEAT_FLOOR = 0.03; +/** + * The FRP marker ramp as a continuous glow: faint ember at the edges, a + * pale white-hot core where the most power burns. + */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, - { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, - { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, - { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, - { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, - { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, - { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, + { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, + { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, + { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, + { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, + { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, + { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, + { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 2b51785e6..a5cf7eccf 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -11,16 +11,20 @@ import { const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); -test('palette runs cold to hot and starts fully transparent', () => { +test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(HEAT_PALETTE[0].at, 0); assert.equal(HEAT_PALETTE.at(-1).at, 1); assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[2] > low[0], 'low heat is blue'); - assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); + for (let i = 1; i <= 100; i++) { + const [r, , b] = heatColorAt(i / 100); + assert.ok(r >= b, `no cold hue at ${i}%`); + } }); test('binFires drops each detection into its equirectangular pixel', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js index 417566346..0b14dd3bd 100644 --- a/src/data/firmsIntensity.js +++ b/src/data/firmsIntensity.js @@ -1,28 +1,31 @@ /** * @module firmsIntensity * @description Fire intensity policy shared by the Active Fires layer and the - * agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale + * agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale * slider that doubles as its legend, and the one filter every consumer * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs * before anything is rendered or selected. Pure — no Cesium, no DOM. * - * Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not - * interpolated). The thresholds are GEV's own, set on a real week's - * percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW - * leaves one red fire a week (HAL-675). + * The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling + * down: a dim ember for weak fires through red, orange and gold to a pale + * white-hot core, lightness rising with power, so the brightest marks are the + * strongest fires. No cold hues: ~90% of detections are under 20 MW and a + * blue start painted most of the map blue. The thresholds are GEV's own, set + * on a real week's percentiles so every color appears: Portolan's + * 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675). */ /** - * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * Stepped FRP ramp, weakest first. A detection takes the LAST stop whose * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. */ export const FRP_RAMP = Object.freeze([ - Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }), - Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }), - Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }), - Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }), - Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }), - Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }), + Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }), + Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }), + Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }), + Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }), + Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }), + Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }), ]); /** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs index 558068e67..c1d22948b 100644 --- a/src/data/firmsIntensity.test.mjs +++ b/src/data/firmsIntensity.test.mjs @@ -18,40 +18,54 @@ import { sliderPositionForFrp, } from './firmsIntensity.js'; -test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { +test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => { assert.deepEqual( FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), [ - ['blue', 0, '#4a5bd4'], - ['teal', 5, '#39a0a8'], - ['lime', 20, '#c9cf4a'], - ['amber', 50, '#f2b134'], - ['orange', 100, '#e8722c'], - ['red', 300, '#d1382a'], + ['ember', 0, '#9e2a1b'], + ['red', 5, '#d2401f'], + ['flame', 20, '#f06a28'], + ['orange', 50, '#fb9a2e'], + ['gold', 100, '#fdc84a'], + ['white-hot', 300, '#fff3b0'], ], ); assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); assert.ok(Object.isFrozen(FRP_RAMP)); }); +test('every stop is warm and each is lighter than the one below', () => { + const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; + let previous = -1; + for (const stop of FRP_RAMP) { + const rgb = hexToRgb(stop.hex); + assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`); + assert.ok( + luma(rgb) > previous, + `${stop.name} is lighter than the stop below`, + ); + previous = luma(rgb); + } +}); + test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { - assert.equal(frpRampStop(0).name, 'blue'); - assert.equal(frpRampStop(4.99).name, 'blue'); - assert.equal(frpRampStop(5).name, 'teal'); - assert.equal(frpRampStop(19.9).name, 'teal'); - assert.equal(frpRampStop(20).name, 'lime'); - assert.equal(frpRampStop(50).name, 'amber'); - assert.equal(frpRampStop(100).name, 'orange'); - assert.equal(frpRampStop(299.9).name, 'orange'); - assert.equal(frpRampStop(300).name, 'red'); - assert.equal(frpRampStop(1246).name, 'red'); - assert.equal(frpRampStop(NaN).name, 'blue'); - assert.equal(frpRampStop(-3).name, 'blue'); - assert.equal(frpRampStop(undefined).name, 'blue'); + assert.equal(frpRampStop(0).name, 'ember'); + assert.equal(frpRampStop(4.99).name, 'ember'); + assert.equal(frpRampStop(5).name, 'red'); + assert.equal(frpRampStop(19.9).name, 'red'); + assert.equal(frpRampStop(20).name, 'flame'); + assert.equal(frpRampStop(50).name, 'orange'); + assert.equal(frpRampStop(100).name, 'gold'); + assert.equal(frpRampStop(299.9).name, 'gold'); + assert.equal(frpRampStop(300).name, 'white-hot'); + assert.equal(frpRampStop(1246).name, 'white-hot'); + assert.equal(frpRampStop(NaN).name, 'ember'); + assert.equal(frpRampStop(-3).name, 'ember'); + assert.equal(frpRampStop(undefined).name, 'ember'); }); test('hex stops convert to the "r, g, b" accents the cards paint', () => { - assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]); assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); }); @@ -84,9 +98,9 @@ test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round- test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { const gradient = rampTrackGradient(); assert.match(gradient, /^linear-gradient\(90deg, /); - assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/); - assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/); - assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/); + assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/); + assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/); assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); }); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index 3af6a15a5..bf97982c2 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -36,8 +36,8 @@ const ACCENT_RGB = Object.freeze( ); /** - * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). - * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). + * Severity stop name → "r, g, b" accent string (defaults to the weakest stop). + * @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot'). * @returns {string} */ export function accentForSeverity(stopName) { diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 62234bb2d..a141d0d17 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,16 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => { - // Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675). - assert.equal(accentForSeverity('blue'), '74, 91, 212'); - assert.equal(accentForSeverity('teal'), '57, 160, 168'); - assert.equal(accentForSeverity('lime'), '201, 207, 74'); - assert.equal(accentForSeverity('amber'), '242, 177, 52'); - assert.equal(accentForSeverity('orange'), '232, 114, 44'); - assert.equal(accentForSeverity('red'), '209, 56, 42'); - assert.equal(accentForSeverity('yellow'), accentForSeverity('blue')); - assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue')); +test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => { + // Ember #9e2a1b … white-hot #fff3b0, lightness rising with power. + assert.equal(accentForSeverity('ember'), '158, 42, 27'); + assert.equal(accentForSeverity('red'), '210, 64, 31'); + assert.equal(accentForSeverity('flame'), '240, 106, 40'); + assert.equal(accentForSeverity('orange'), '251, 154, 46'); + assert.equal(accentForSeverity('gold'), '253, 200, 74'); + assert.equal(accentForSeverity('white-hot'), '255, 243, 176'); + assert.equal(accentForSeverity('blue'), accentForSeverity('ember')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 330d237e1..aefb94b21 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -121,7 +121,7 @@ export function createModel({ } /** - * The ramp stop for a fire radiative power (stepped, coldest first). + * The ramp stop for a fire radiative power (stepped, weakest first). * Intensity alone drives the color: confidence feeds the card text and the * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. * @param {number} frp - Megawatts. diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index eb235bc81..faa55bbc3 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -111,7 +111,7 @@ export const CULL_LIFT_M = 12; /** * Color stops for detection glow sprites and card accents: the - * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/ui/layerPanel/layerRows.test.mjs b/src/ui/layerPanel/layerRows.test.mjs index 5e1d543d0..966c41cb6 100644 --- a/src/ui/layerPanel/layerRows.test.mjs +++ b/src/ui/layerPanel/layerRows.test.mjs @@ -75,7 +75,7 @@ const fireRow = (value, active = 'all') => ({ max: 1000, step: 1, value, - track: 'linear-gradient(90deg, #4a5bd4, #d1382a)', + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', ticks: [ { at: 0, label: '0' }, { at: 0.5, label: '30' }, From 314292a006e6fda186f55c8434c9fdf97ff2cb54 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 14:10:57 +0400 Subject: [PATCH 18/43] Brighten the heatmap edge so weak fire regions stay visible The heat scale is set by the hottest regions, so most of the world sits at the low end. The ember edge was a dark red at 35% opacity and nearly vanished into forest and savanna imagery: over the Americas the spots were there but hard to see. The edge is now a bright red starting at 58% opacity, which reads at least as well as the old blue did. --- docs/CURRENT-STATE.md | 5 +++-- src/data/firmsHeatRaster.js | 21 ++++++++++++--------- src/data/firmsHeatRaster.test.mjs | 3 ++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5644960bc..dabc074ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2189,8 +2189,9 @@ global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped through the -same colors, from a faint ember edge through red, orange and gold to a pale -white-hot core. Nothing is painted where +same hues, from a bright red edge through orange and gold to a pale +white-hot core. The edge starts more than half opaque, so regions far below +the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 985699d7e..f6aadff3b 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -9,17 +9,20 @@ export const HEAT_FLOOR = 0.03; /** - * The FRP marker ramp as a continuous glow: faint ember at the edges, a - * pale white-hot core where the most power burns. + * The FRP marker ramp as a continuous glow, from a red edge through orange + * and gold to a pale white-hot core. The edge is a bright red, not the + * markers' dark ember, and starts more than half opaque: most of the world + * sits low on a scale set by the hottest regions, and a dark translucent red + * vanishes into forest and savanna imagery. */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, - { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, - { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, - { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, - { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, - { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, - { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, + { at: 0.0, rgb: [232, 58, 30], alpha: 0 }, + { at: 0.08, rgb: [232, 58, 30], alpha: 0.58 }, + { at: 0.25, rgb: [242, 86, 34], alpha: 0.7 }, + { at: 0.45, rgb: [248, 122, 40], alpha: 0.76 }, + { at: 0.65, rgb: [252, 166, 52], alpha: 0.82 }, + { at: 0.82, rgb: [254, 208, 92], alpha: 0.87 }, + { at: 1.0, rgb: [255, 245, 190], alpha: 0.92 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index a5cf7eccf..25c8b61fb 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -18,7 +18,8 @@ test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red'); + assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery'); assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); for (let i = 1; i <= 100; i++) { From ad171f2854704b2cee1e50b457a96c0adde71e84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 22:43:51 +0400 Subject: [PATCH 19/43] Draw each far-zoom fire as a marker-sized heat spot The heat raster was 2048 px wide with a 3 px blur, so every fire spread over ~200 km and the far-zoom map read as clouds next to the markers. Double the grid to ~10 km per pixel and blur by one pixel: a lone fire is now a spot about as wide as its marker at the 3,000 km hand-off. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 5 +++-- src/layers/firms/heatImagery.test.mjs | 25 +++++++++++++++++++++++ src/layers/firms/policy.js | 13 +++++++----- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index c1d06643e..4c02ccf20 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dabc074ae..5be2a98dc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,9 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred, +burn), Gaussian-blurred by one pixel so each fire stays a spot about the size +of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 0fca7b97f..4b6403298 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createHeatImagery } from './heatImagery.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { HEAT_RASTER } from './policy.js'; function harness() { const layers = []; @@ -85,3 +87,26 @@ test('destroy removes the layer', () => { h.imagery.destroy(); assert.equal(h.layers.length, 0); }); + +test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { + // A hot cluster elsewhere sets the scale, as Africa does on real data. + const fires = [{ lat: 0, lon: 0, frp: 5 }]; + for (let i = 0; i < 40; i++) { + fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); + } + const r = buildHeatRaster(fires, HEAT_RASTER); + const y = Math.floor((90 / 180) * r.height); + const painted = []; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted.push(x); + } + } + const kmPerPx = 40075 / r.width; + assert.ok(painted.length > 0, 'the weak fire still shows'); + assert.ok( + painted.length * kmPerPx <= 80, + `spot ${Math.round(painted.length * kmPerPx)} km wide`, + ); +}); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index faa55bbc3..9de42cfa4 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,14 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in - * pixels. Built once per data refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker + * at the 3,000 km hand-off and neighbours merge only when they really touch + * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data + * refresh or filter change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, - sigmaPx: 3, + width: 4096, + height: 2048, + sigmaPx: 1, }); /** From 5ab8ddb4ca3cd848017bc3435ff3784c4bf53c31 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 23:12:38 +0400 Subject: [PATCH 20/43] Double the far-zoom heat spots so they read as spots At 10 km per pixel each fire was a marker-sized spot that faded into the imagery from the globe view. A 2048 px grid with the same one-pixel blur makes every spot exactly twice as wide (~60 km for a lone weak fire), still separate, and the build is three times faster. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 6 +++--- src/layers/firms/heatImagery.test.mjs | 4 ++-- src/layers/firms/policy.js | 15 ++++++++------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 4c02ccf20..6f2d11a33 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,6 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one marker-sized spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + one distinct spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5be2a98dc..4d1b69849 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2185,10 +2185,10 @@ accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is +global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a spot about the size -of its marker, +burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about +twice the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 4b6403298..5543a0691 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -88,7 +88,7 @@ test('destroy removes the layer', () => { assert.equal(h.layers.length, 0); }); -test('the shipped raster paints a lone weak fire as a marker-sized spot', () => { +test('the shipped raster paints a lone weak fire as a distinct spot', () => { // A hot cluster elsewhere sets the scale, as Africa does on real data. const fires = [{ lat: 0, lon: 0, frp: 5 }]; for (let i = 0; i < 40; i++) { @@ -106,7 +106,7 @@ test('the shipped raster paints a lone weak fire as a marker-sized spot', () => const kmPerPx = 40075 / r.width; assert.ok(painted.length > 0, 'the weak fire still shows'); assert.ok( - painted.length * kmPerPx <= 80, + painted.length * kmPerPx <= 100, `spot ${Math.round(painted.length * kmPerPx)} km wide`, ); }); diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 9de42cfa4..2af5d9f5c 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,16 +49,17 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a spot about as wide as its marker - * at the 3,000 km hand-off and neighbours merge only when they really touch - * (2048 px with σ 3 smeared each fire over ~200 km). Built once per data - * refresh or filter change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide + * as its marker at the 3,000 km hand-off and still reads from the + * whole-globe view; neighbours merge only when they nearly touch (σ 3 here + * smeared each fire over ~200 km). Built once per data refresh or filter + * change, never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 4096, - height: 2048, + width: 2048, + height: 1024, sigmaPx: 1, }); From ef5c034dc9391f139918c5f3ca03f04e1fd014d3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:39:02 +0400 Subject: [PATCH 21/43] Linearly bin fires so weak heat spots stay round Dropping each detection whole into its nearest pixel snapped spots by up to half a pixel and left weak lone fires as squares or plus signs once thresholded. Share each detection's weight between the four nearest pixel centres instead (linear binning, Wand 1994): spots sit at the true position and blur round, at the same build cost. --- docs/CURRENT-STATE.md | 4 ++- src/data/firmsHeatRaster.js | 41 ++++++++++++++++++++++--------- src/data/firmsHeatRaster.test.mjs | 25 +++++++++++++++---- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4d1b69849..73ea42a0f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2184,7 +2184,9 @@ detections are under 20 MW and stay dim red. Glow sprites and fire-card accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style -heatmap instead of individual markers: the shown detections are binned on a +heatmap instead of individual markers: the shown detections are linearly binned +(each detection's weight shared between the four nearest pixel centres, so spots +sit at the true position and stay round) on a global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index f6aadff3b..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -1,8 +1,9 @@ /** * Snap Map–style heat raster for the Active Fires far-zoom bands: detections - * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), - * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: - * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + * are linearly binned on an equirectangular grid (row 0 = north, column + * 0 = 180°W), blurred, normalized on a log scale and mapped through + * HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested + * in node. */ /** Below this normalized heat nothing is painted: empty land stays empty. */ @@ -54,19 +55,35 @@ export const HEAT_MIN_WEIGHT_MW = 0.5; const defaultWeight = (fire) => Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); +/** + * Linear binning (Wand 1994): each detection's weight is shared between the + * four pixel centres around it in proportion to closeness, instead of + * dropping whole into one pixel. Spots then sit at the true position rather + * than snapping by up to half a pixel (~10 km), and a lone weak fire blurs + * into a round spot instead of a square or a plus sign. Longitude wraps; + * rows beyond the poles fold back onto the edge row. + */ export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); + const row = (y) => Math.min(height - 1, Math.max(0, y)) * width; + const col = (x) => ((x % width) + width) % width; for (const fire of fires) { if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; - const x = Math.min( - width - 1, - Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), - ); - const y = Math.min( - height - 1, - Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), - ); - grid[y * width + x] += weight(fire); + const fx = ((fire.lon + 180) / 360) * width - 0.5; + const fy = ((90 - fire.lat) / 180) * height - 0.5; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const w = weight(fire); + const top = row(y0); + const bottom = row(y0 + 1); + const left = col(x0); + const right = col(x0 + 1); + grid[top + left] += w * (1 - tx) * (1 - ty); + grid[top + right] += w * tx * (1 - ty); + grid[bottom + left] += w * (1 - tx) * ty; + grid[bottom + right] += w * tx * ty; } return grid; } diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 25c8b61fb..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -28,22 +28,37 @@ test('palette runs ember to white-hot and starts fully transparent', () => { } }); -test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { +test('binFires puts a detection at a pixel centre wholly in that pixel', () => { + // 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45. + const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], { width: 4, height: 2, }); - assert.equal(grid[0], 1, 'north-west corner'); - assert.equal(grid[7], 1, 'south-east corner'); + assert.equal(grid[0], 1, 'north-west pixel'); + assert.equal(grid[7], 1, 'south-east pixel'); assert.equal( grid.reduce((a, b) => a + b, 0), 2, ); }); +test('binFires shares a detection between the pixels around it', () => { + const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 }); + assert.equal(between[1], 0.5, 'half to the pixel west of it'); + assert.equal(between[2], 0.5, 'half to the pixel east of it'); + const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 }); + assert.equal(quarter[0], 0.75, 'closer centre takes more'); + assert.equal(quarter[1], 0.25); + const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 }); + assert.equal(wrapped[3], 0.5, 'the antimeridian wraps'); + assert.equal(wrapped[0], 0.5); + const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 }); + assert.equal(polar[0], 1, 'beyond the last row folds back onto it'); +}); + test('binFires weights each detection by its radiative power', () => { const grid = binFires( - [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + [fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)], { width: 4, height: 2 }, ); assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); From 31393696734d6a66fe7533bd58ec2261ec594299 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:56:40 +0400 Subject: [PATCH 22/43] Add a heatmap spot size slider to the Active Fires row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ½×–4× slider (log2 axis, 5 % steps, default 1×) under the intensity filter sets how large far-zoom heat spots are. It scales the grid, not the blur: 2× is a 1024 × 512 grid with the same one-pixel blur, so each spot grows exactly and weak lone fires don't fade as they widen. A change rebuilds only the heatmap. Share links keep it as w.s (percent, 50–400). The layer row now takes a list of sliders. --- changelog.d/1426-fire-intensity-filter.md | 9 +- docs/CURRENT-STATE.md | 13 ++- src/data/firmsHeatRaster.js | 64 +++++++++++ src/data/firmsHeatRaster.test.mjs | 61 +++++++++++ src/data/firmsInteraction.test.mjs | 63 +++++++++++ src/data/layerState.js | 5 +- src/data/layerState.test.mjs | 32 +++++- src/i18n/locales/ar.json | 3 + src/i18n/locales/de.json | 3 + src/i18n/locales/es.json | 3 + src/i18n/locales/fr.json | 3 + src/i18n/locales/it.json | 3 + src/i18n/locales/ja.json | 3 + src/i18n/locales/pt.json | 3 + src/i18n/locales/ru.json | 3 + src/i18n/locales/uk.json | 3 + src/i18n/locales/zh-CN.json | 3 + src/i18n/locales/zh-Hant.json | 3 + src/i18n/same-as-english.json | 15 ++- src/layers/firms/queries.js | 128 +++++++++++++++------- src/layers/firms/rendering.js | 12 +- src/layers/firms/state.js | 5 + src/share/viewCodec.js | 6 +- src/ui/layerPanel.js | 19 ++-- src/ui/layerPanel/LayerRows.vue | 9 +- src/ui/layerPanel/layerRows.test.mjs | 66 +++++++---- 26 files changed, 449 insertions(+), 91 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 6f2d11a33..2126e3c7f 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,6 +4,9 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one distinct spot per fire, empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share - links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching - `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). + one distinct spot per fire, empty where nothing burns, with a ½×–4× spot + size slider. The filter applies once before rendering, so markers, cards, + the heatmap, context and the top-fires query agree; share links keep the + filter and the spot size (`w` token: `f`, `c`, `s`), and `get_active_fires` + takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All + / 1× (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 73ea42a0f..4b06c30d5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,14 +2205,19 @@ areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / -High, and an "N of M shown" counter. The default is 0 MW and +High, a "Heatmap spot size" slider (½× to 4× on a log2 axis, 5 % steps, +default 1×) and an "N of M shown" counter. The spot size scales the heat +grid rather than the blur (2× is a 1024 × 512 grid with the same one-pixel +blur), so every spot grows exactly and weak lone fires do not fade as they +widen; changing it rebuilds only the heatmap, not the shown set. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes -is deselected. The layer state keeps both options under the `w` (`local-firms`) -share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), -including the short share codec; a link without them restores the defaults. +is deselected. The layer state keeps the options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000), `c` (`a`, `n`, `h`) and `s` +(spot size in percent, 50–400), including the short share codec; a link +without them restores the defaults. The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` (`all`, `nominal`, `high`) arguments, independent of the panel's setting, and diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 6704cf6e2..85894b19e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,6 +43,70 @@ export function heatColorAt(t) { ]; } +/** + * Spot size, in percent of the shipped size: the panel slider and share + * option `w.s`. Whole percents, half to four times. + */ +export const HEAT_SPOT_SIZE_MIN = 50; +export const HEAT_SPOT_SIZE_MAX = 400; +export const HEAT_SPOT_SIZE_DEFAULT = 100; + +/** + * Spot size → whole percent inside the bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeHeatSpotSize(value) { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : NaN; + if (!Number.isFinite(number)) return null; + const rounded = Math.round(number); + return rounded >= HEAT_SPOT_SIZE_MIN && rounded <= HEAT_SPOT_SIZE_MAX + ? rounded + : null; +} + +/** + * The grid for a spot size. The same one-pixel blur on a grid that many + * times coarser (or finer) scales every spot exactly; widening the blur + * instead lowers each spot's peak, so weak lone fires faded as they grew. + * @param {{width: number, height: number, sigmaPx: number}} base Shipped grid. + * @param {number} spotSize Percent of the shipped size. + * @returns {{width: number, height: number, sigmaPx: number}} + */ +export function heatGridForSpotSize(base, spotSize) { + const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; + const height = Math.round((base.height * HEAT_SPOT_SIZE_DEFAULT) / percent); + return { width: height * 2, height, sigmaPx: base.sigmaPx }; +} + +/** + * Slider axis for the spot size: log2, so ½× and 2× sit equally far from 1×. + * Values snap to 5 % steps, which is also their share-link form. + */ +const SPOT_LOG_MIN = Math.log2(HEAT_SPOT_SIZE_MIN / 100); +const SPOT_LOG_SPAN = Math.log2(HEAT_SPOT_SIZE_MAX / 100) - SPOT_LOG_MIN; + +/** Track position in 0..1 for a spot size in percent. */ +export function sliderPositionForSpotSize(spotSize) { + const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; + return (Math.log2(percent / 100) - SPOT_LOG_MIN) / SPOT_LOG_SPAN; +} + +/** Spot size in percent for a track position in 0..1. */ +export function spotSizeForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + const percent = 100 * Math.pow(2, SPOT_LOG_MIN + p * SPOT_LOG_SPAN); + return Math.min( + HEAT_SPOT_SIZE_MAX, + Math.max(HEAT_SPOT_SIZE_MIN, Math.round(percent / 5) * 5), + ); +} + /** Weight of a detection with no or zero FRP, in megawatts. */ export const HEAT_MIN_WEIGHT_MW = 0.5; diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 754493754..9fc0e02c8 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,6 +7,10 @@ import { binFires, blurGaussian, buildHeatRaster, + heatGridForSpotSize, + normalizeHeatSpotSize, + sliderPositionForSpotSize, + spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -116,3 +120,60 @@ test('buildHeatRaster with no fires is fully transparent', () => { const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); assert.ok(r.rgba.every((v) => v === 0)); }); + +test('a spot size scales the grid, not the blur', () => { + const base = { width: 2048, height: 1024, sigmaPx: 1 }; + assert.deepEqual(heatGridForSpotSize(base, 100), base); + assert.deepEqual(heatGridForSpotSize(base, 200), { + width: 1024, + height: 512, + sigmaPx: 1, + }); + assert.deepEqual(heatGridForSpotSize(base, 50), { + width: 4096, + height: 2048, + sigmaPx: 1, + }); + const odd = heatGridForSpotSize(base, 150); + assert.equal(odd.width, odd.height * 2, 'the grid stays 2:1'); + assert.deepEqual(heatGridForSpotSize(base, 9999), base, 'invalid → default'); +}); + +test('twice the spot size paints a lone fire twice as wide', () => { + const base = { width: 2048, height: 1024, sigmaPx: 1 }; + const fires = [fire(0, 0, 5)]; + for (let i = 0; i < 40; i++) fires.push(fire(-10, 25 + i * 0.05, 300)); + const widthKm = (spotSize) => { + const r = buildHeatRaster(fires, heatGridForSpotSize(base, spotSize)); + const y = Math.floor(r.height / 2); + let painted = 0; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 10 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted += 1; + } + } + return (painted * 40075) / r.width; + }; + const ratio = widthKm(200) / widthKm(100); + assert.ok(ratio > 1.7 && ratio < 2.3, `ratio ${ratio.toFixed(2)}`); +}); + +test('the spot-size slider is log2 with 1x a third along and 5 % steps', () => { + assert.equal(sliderPositionForSpotSize(50), 0); + assert.equal(sliderPositionForSpotSize(400), 1); + assert.ok(Math.abs(sliderPositionForSpotSize(100) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForSpotSize(200) - 2 / 3) < 1e-9); + assert.equal(spotSizeForSliderPosition(0), 50); + assert.equal(spotSizeForSliderPosition(1), 400); + assert.equal(spotSizeForSliderPosition(1 / 3), 100); + assert.equal(spotSizeForSliderPosition(-1), 50); + for (let i = 0; i <= 100; i++) { + const percent = spotSizeForSliderPosition(i / 100); + assert.equal(percent % 5, 0, `${percent} at ${i}%`); + assert.equal(normalizeHeatSpotSize(percent), percent); + } + assert.equal(normalizeHeatSpotSize('150'), 150); + assert.equal(normalizeHeatSpotSize(49), null); + assert.equal(normalizeHeatSpotSize(''), null); +}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 39f0382c8..cd901f729 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -467,3 +467,66 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h restoreDocument(); } }); + +test('the spot-size slider rebuilds only the heatmap, on a proportionally coarser grid', async () => { + const priorDocument = globalThis.document; + const grids = []; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => { + grids.push([w, hh]); + return { data: new Uint8ClampedArray(w * hh * 4) }; + }, + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const collection = { + addImageryProvider: (provider) => ({ provider, show: true }), + remove() {}, + raiseToTop() {}, + }; + try { + h.layer.attachShellServices({ imageryHost: () => ({ kind: 'globe', collection }) }); + h.layer._renderLodForTest('global'); + assert.deepEqual(grids.at(-1), [2048, 1024]); + assert.deepEqual( + h.layer.getRowControls().sliders.map((slider) => slider.id), + ['min-frp', 'heat-spot-size'], + ); + const spot = h.layer.getRowControls().sliders[1]; + assert.equal(spot.valueText, '1×'); + assert.deepEqual(spot.params(1000), { spotSize: 400 }); + + let repaints = 0; + h.layer.setRowControlsListener(() => repaints++); + h.layer.setParams({ spotSize: 200 }); + assert.equal(h.layer.getParams().spotSize, 200); + assert.equal(h.layer.getParams().minFrp, 0, 'the filter is untouched'); + assert.equal(h.layer.getStats().count, 2, 'every fire still shown'); + assert.equal(repaints, 1, 'the row repaints its value'); + assert.equal(h.layer.getRowControls().sliders[1].valueText, '2×'); + // The rebuild trails a drag (HEAT_REBUILD_MS) and keeps the old drape meanwhile. + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.deepEqual(grids.at(-1), [1024, 512]); + + h.layer.setParams({ spotSize: 'nope' }); + assert.equal(h.layer.getParams().spotSize, 200, 'invalid input is ignored'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index c68a67e67..a049a80c5 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,7 +298,7 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ - // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on + // Active Fires intensity filter and heatmap spot size (HAL-676). `minFrp` is whole megawatts on // the codec side; the panel's log slider rounds to it. Both default to // "show everything": many of the strongest fires are rated low confidence. // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in @@ -311,6 +311,9 @@ const OPTION_GROUPS = Object.freeze({ nominal: 'n', high: 'h', }), + // Heatmap spot size in percent; mirrors HEAT_SPOT_SIZE_* in + // firmsHeatRaster.js. + boundedIntegerOption('spotSize', 's', 100, { min: 50, max: 400 }), ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 55d40dfe2..78b36c3a6 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,6 +30,11 @@ import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; +import { + HEAT_SPOT_SIZE_DEFAULT, + HEAT_SPOT_SIZE_MAX, + HEAT_SPOT_SIZE_MIN, +} from './firmsHeatRaster.js'; function deferred() { let resolve; @@ -3019,9 +3024,14 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( assert.deepEqual(state.options['local-firms'], { minFrp: 0, confidence: 'all', + spotSize: 100, }); state.enabledLayerIds = ['local-firms']; - state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + state.options['local-firms'] = { + minFrp: 20, + confidence: 'high', + spotSize: 250, + }; const encoded = encode(state); assert.equal(new URLSearchParams(encoded).get('l'), 'w'); // Other owners write their own explicit defaults into `lo` too (flights 3D, @@ -3029,10 +3039,12 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( const assignments = new URLSearchParams(encoded).get('lo').split('_'); assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); + assert.ok(assignments.includes('w.s.250'), assignments.join(' ')); const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); assert.deepEqual(decoded.options['local-firms'], { minFrp: 20, confidence: 'high', + spotSize: 250, }); assert.deepEqual( parseStoredLayerState(serializeStoredLayerState(state)).options[ @@ -3050,7 +3062,11 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( .filter((assignment) => assignment.startsWith('w.')).length, 0, ); - assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); + assert.deepEqual(firmsOptions(''), { + minFrp: 0, + confidence: 'all', + spotSize: 100, + }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3078,5 +3094,17 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { minFrp: 5, confidence: 'nominal', + spotSize: 100, }); }); + +test('Active Fires: the codec accepts whole-percent spot sizes from half to four times', () => { + assert.equal(HEAT_SPOT_SIZE_MIN, 50); + assert.equal(HEAT_SPOT_SIZE_MAX, 400); + assert.equal(HEAT_SPOT_SIZE_DEFAULT, 100); + assert.equal(firmsOptions('w.s.50').spotSize, 50); + assert.equal(firmsOptions('w.s.400').spotSize, 400); + assert.equal(firmsOptions('w.s.45').spotSize, 100, 'out of bounds → default'); + assert.equal(firmsOptions('w.s.401').spotSize, 100); + assert.equal(firmsOptions('w.s.x').spotSize, 100); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 301b60207..47d3808cc 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3229,6 +3229,9 @@ "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", + "Heatmap spot size": "حجم بقع الخريطة الحرارية", + "Far-zoom heatmap spot size": "حجم بقع الخريطة الحرارية عند العرض البعيد", + "{value}×": "{value}×", "fire intensity filter|All": "الكل", "Detection confidence": "ثقة الرصد", "{shown} of {total} shown": "المعروضة: {shown} من {total}" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 92c843948..1449ad502 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2949,6 +2949,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "Heatmap spot size": "Fleckengröße der Heatmap", + "Far-zoom heatmap spot size": "Fleckengröße der Heatmap in der Fernansicht", + "{value}×": "{value}×", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt" diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3cc9f4456..ffbc0bceb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2992,6 +2992,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "Heatmap spot size": "Tamaño de las manchas del mapa de calor", + "Far-zoom heatmap spot size": "Tamaño de las manchas del mapa de calor en la vista lejana", + "{value}×": "{value}×", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a95b158c9..050a93b1c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiance", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "Heatmap spot size": "Taille des taches de la carte de chaleur", + "Far-zoom heatmap spot size": "Taille des taches de la carte de chaleur en vue éloignée", + "{value}×": "{value}×", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", "{shown} of {total} shown": "{shown} sur {total} affichées" diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 74d9a4a39..bf3517df6 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confidenza", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", + "Heatmap spot size": "Dimensione delle macchie della mappa di calore", + "Far-zoom heatmap spot size": "Dimensione delle macchie della mappa di calore nella vista da lontano", + "{value}×": "{value}×", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", "{shown} of {total} shown": "{shown} di {total} mostrati" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bc1477531..8f1c4dc6b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2879,6 +2879,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "Heatmap spot size": "ヒートマップの点の大きさ", + "Far-zoom heatmap spot size": "遠景ヒートマップの点の大きさ", + "{value}×": "{value}×", "fire intensity filter|All": "すべて", "Detection confidence": "検知の信頼度", "{shown} of {total} shown": "{total}件中{shown}件を表示" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 837cbad93..6df69f579 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3019,6 +3019,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiança", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", + "Heatmap spot size": "Tamanho das manchas do mapa de calor", + "Far-zoom heatmap spot size": "Tamanho das manchas do mapa de calor na vista distante", + "{value}×": "{value}×", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", "{shown} of {total} shown": "{shown} de {total} exibidas" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d457abd24..9afd7eb6c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3062,6 +3062,9 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "Heatmap spot size": "Размер пятен тепловой карты", + "Far-zoom heatmap spot size": "Размер пятен тепловой карты на дальнем масштабе", + "{value}×": "{value}×", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 3cf7e8f9e..83b9d5871 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3060,6 +3060,9 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достовірність", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", + "Heatmap spot size": "Розмір плям теплової карти", + "Far-zoom heatmap spot size": "Розмір плям теплової карти на далекому масштабі", + "{value}×": "{value}×", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", "{shown} of {total} shown": "показано {shown} з {total}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e46c8882b..b0764c1e2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2852,6 +2852,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "Heatmap spot size": "热力图光斑大小", + "Far-zoom heatmap spot size": "远景热力图光斑大小", + "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c534272b3..84aa385e7 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2879,6 +2879,9 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", + "Heatmap spot size": "熱力圖光斑大小", + "Far-zoom heatmap spot size": "遠景熱力圖光斑大小", + "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0061d2941..b1dc842a7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,6 +31,7 @@ "dB", "hPa", "{source} HTTP {status}", + "{value}×", "▲ {frp} MW" ], "de": [ @@ -196,6 +197,7 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -325,6 +327,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -505,6 +508,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -677,6 +681,7 @@ "{value} KM/H", "{value} KM/S", "{value} conf", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -727,6 +732,7 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", + "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -861,6 +867,7 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", + "{value}×", "≥ {value} MW" ], "ru": [ @@ -879,7 +886,8 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "uk": [ "AIS: --", @@ -892,7 +900,8 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT" + "WHOT/BHOT", + "{value}×" ], "zh-CN": [ "1090 MHz", @@ -947,6 +956,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1005,6 +1015,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 797734597..b9577925e 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,6 +8,11 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; +import { + normalizeHeatSpotSize, + sliderPositionForSpotSize, + spotSizeForSliderPosition, +} from '../../data/firmsHeatRaster.js'; import { formatNumber, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; @@ -23,6 +28,16 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Spot sizes marked under the spot-size slider, in percent. */ + const SPOT_SIZE_TICKS = [50, 100, 200, 400]; + + /** Spot-size label for a percent ("0.5×", "1×", "1.5×"). */ + function spotSizeLabel(percent) { + return t('{value}×', { + value: formatNumber(percent / 100, { maximumFractionDigits: 2 }), + }); + } + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ function frpLabel(frp) { return formatNumber(frp, { maximumFractionDigits: 0 }); @@ -83,12 +98,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter as layer options (share token `w`, owner - * `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string}} + * The intensity filter and the heatmap spot size as layer options (share + * token `w`, owner `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string, spotSize: number}} */ getParams() { - return { ...layerState._filter }; + return { ...layerState._filter, spotSize: layerState._spotSize }; }, /** @@ -96,24 +111,36 @@ export function createQueries({ * value. The filter runs in one place (ingestion.applyFilter) and the LOD * rebuild re-derives markers, cells, cards and context registrations from * the shown set; a selected fire that no longer passes is deselected. - * @param {{minFrp?: number, confidence?: string}} params + * A spot-size change only marks the heat raster stale: the shown set, + * markers and cards stay as they are. + * @param {{minFrp?: number, confidence?: string, spotSize?: number}} params * @returns {boolean} True — the request is always accepted. */ setParams(params = {}) { const next = normalizeFireFilter(params, layerState._filter); - if ( - next.minFrp === layerState._filter.minFrp && - next.confidence === layerState._filter.confidence - ) - return true; + const spotSize = + normalizeHeatSpotSize(params?.spotSize) ?? layerState._spotSize; + const filterChanged = + next.minFrp !== layerState._filter.minFrp || + next.confidence !== layerState._filter.confidence; + const spotChanged = spotSize !== layerState._spotSize; + if (!filterChanged && !spotChanged) return true; layerState._filter = next; - if (!layerState._allFires.length) return true; - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); + layerState._spotSize = spotSize; + if (!layerState._allFires.length) { + layerState._rowControlsListener?.(); + return true; + } + if (filterChanged) { + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + } else { + layerState._heatRaster = null; } if (layerState._enabled) components.rendering.renderCurrentLod(true); layerState._rowControlsListener?.(); @@ -123,8 +150,8 @@ export function createQueries({ /** * Row controls under the Active Fires row: the log-scale minimum-FRP * slider whose track is painted with the intensity ramp (so it doubles as - * the legend), the All / Nominal+ / High confidence switch, and the - * "N of M shown" counter. + * the legend), the All / Nominal+ / High confidence switch, the far-zoom + * heatmap spot-size slider and the "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -133,27 +160,48 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - slider: { - id: 'min-frp', - label: t('Fire intensity (FRP)'), - ariaLabel: t('Minimum fire radiative power, megawatts'), - valueText: - minFrp > 0 - ? t('≥ {value} MW', { value: frpLabel(minFrp) }) - : t('All', { context: 'fire intensity filter' }), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), - track: rampTrackGradient(), - ticks: FRP_SLIDER_TICKS.map((frp) => ({ - at: sliderPositionForFrp(frp), - label: frpLabel(frp), - })), - params: (value) => ({ - minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, + sliders: [ + { + id: 'min-frp', + label: t('Fire intensity (FRP)'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + { + id: 'heat-spot-size', + label: t('Heatmap spot size'), + ariaLabel: t('Far-zoom heatmap spot size'), + valueText: spotSizeLabel(layerState._spotSize), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round( + sliderPositionForSpotSize(layerState._spotSize) * SLIDER_STEPS, + ), + ticks: SPOT_SIZE_TICKS.map((percent) => ({ + at: sliderPositionForSpotSize(percent), + label: spotSizeLabel(percent), + })), + params: (value) => ({ + spotSize: spotSizeForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + ], segmented: { id: 'confidence', label: t('Confidence'), diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 84a6eed36..dd91effec 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,10 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + buildHeatRaster, + heatGridForSpotSize, +} from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, @@ -144,13 +147,16 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set and drape it. */ + /** Build the heat raster from the shown set at the chosen spot size and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + layerState._heatRaster = buildHeatRaster( + layerState._fires, + heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), + ); heatImagery().setRaster(layerState._heatRaster); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 9a0b031b2..349fcd375 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; +import { HEAT_SPOT_SIZE_DEFAULT } from '../../data/firmsHeatRaster.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -69,6 +70,10 @@ export function createFirmsState({ services, config }) { layerState._filter = { ...DEFAULT_FIRE_FILTER }; + /** Heatmap spot size, percent of the shipped size (firmsHeatRaster.js). */ + + layerState._spotSize = HEAT_SPOT_SIZE_DEFAULT; + /** Panel callback: repaint the row controls (counter) after a load. */ layerState._rowControlsListener = null; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8ac00f51b..8f556ea1e 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,7 +252,11 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ - 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), + 'local-firms': Object.freeze({ + minFrp: 0, + confidence: 'all', + spotSize: 100, + }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 3d2f62b26..466817ec3 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -430,7 +430,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, slider, color legend, info line + * A layer's row chips, one-of-many switch, sliders, color legend, info line * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are @@ -456,7 +456,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const slider = controls?.slider || null; + const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -465,10 +465,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - !slider, + sliders.length === 0, chips, segmented, - slider, + sliders, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -522,13 +522,10 @@ export class LayerPanel { // to params through the live descriptor on every tick. _moveSlider(id, sliderId, value, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const slider = this._rowControlsFor(id)?.slider; - if ( - !slider || - slider.id !== sliderId || - typeof slider.params !== 'function' || - !this.isEnabled(id) - ) + const slider = this._rowControlsFor(id)?.sliders?.find( + (entry) => entry.id === sliderId, + ); + if (!slider || typeof slider.params !== 'function' || !this.isEnabled(id)) return; this.setLayerParams(id, slider.params(value), { origin: 'user' }); } diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index 5f5d7c97e..b8c272e24 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,11 +63,10 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - slider: { - id: 'min-frp', - label: 'Fire intensity (FRP)', - ariaLabel: 'Minimum fire radiative power, megawatts', - valueText: value ? `≥ ${value} MW` : 'All', - min: 0, - max: 1000, - step: 1, - value, - track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', - ticks: [ - { at: 0, label: '0' }, - { at: 0.5, label: '30' }, - ], - }, + sliders: [ + { + id: 'min-frp', + label: 'Fire intensity (FRP)', + ariaLabel: 'Minimum fire radiative power, megawatts', + valueText: value ? `≥ ${value} MW` : 'All', + min: 0, + max: 1000, + step: 1, + value, + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.5, label: '30' }, + ], + }, + { + id: 'heat-spot-size', + label: 'Heatmap spot size', + ariaLabel: 'Far-zoom heatmap spot size', + valueText: '1×', + min: 0, + max: 1000, + value: 333, + ticks: [{ at: 1 / 3, label: '1×' }], + }, + ], legend: [], info: null, list: null, }, }); -test('a row switch and slider render their markup and report choices', () => { +test('a row switch and sliders render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -115,8 +127,13 @@ test('a row switch and slider render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const block = container.querySelector('.data-toggle-slider'); - assert.equal(block.dataset.sliderId, 'min-frp'); + const blocks = [...container.querySelectorAll('.data-toggle-slider')]; + assert.deepEqual( + blocks.map((entry) => entry.dataset.sliderId), + ['min-frp', 'heat-spot-size'], + 'sliders render in order, after the switch', + ); + const [block, spotBlock] = blocks; assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -147,7 +164,18 @@ test('a row switch and slider render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [['fires', 'min-frp', 120]]); + const spotInput = spotBlock.querySelector('input.data-toggle-slider-input'); + assert.equal( + spotInput.getAttribute('aria-label'), + 'Far-zoom heatmap spot size', + ); + assert.equal(spotInput.style.getPropertyValue('--slider-track'), ''); + spotInput.value = '700'; + spotInput.dispatchEvent(new Event('input', { bubbles: true })); + assert.deepEqual(moves, [ + ['fires', 'min-frp', 120], + ['fires', 'heat-spot-size', 700], + ]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container); From c68f167acb5b20c692aa0632b5de48c446b05e40 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:07:00 +0400 Subject: [PATCH 23/43] fix(transit): count distinct coverage regions --- src/layers/transit/queries.js | 7 ++++++- src/layers/transit/queries.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/layers/transit/queries.test.mjs diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..dc3ea4d79 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -12,6 +12,11 @@ import { } from '../../data/transitFeeds.js'; import { transitModeTier } from '../../data/transitPresetStyle.js'; +/** Regions, not feeds: one region can carry several feeds (Rio has two). */ +const TRANSIT_REGION_COUNT = new Set( + TRANSIT_ENABLED_FEEDS.map((feed) => feed.region), +).size; + /** * The registry entry behind a vehicle. Live feeds answer first so a feed the * session disabled stops supplying vocabulary; the registry is the fallback, @@ -249,7 +254,7 @@ export function createQueries({ state, parts }) { // English data (the analyst's empty note to the model); the voice // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). coverage: state._altitudeGateOpen - ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + ? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available` : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, }; } diff --git a/src/layers/transit/queries.test.mjs b/src/layers/transit/queries.test.mjs new file mode 100644 index 000000000..a7ac1224c --- /dev/null +++ b/src/layers/transit/queries.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createQueries } from './queries.js'; +import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js'; + +test('the empty-coverage note counts regions, not feeds', () => { + const rio = TRANSIT_ENABLED_FEEDS.filter( + (feed) => feed.region === 'Rio de Janeiro, RJ', + ); + assert.equal(rio.length, 2, 'Rio is one region with two feeds'); + const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region)); + assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length); + + const stats = createQueries({ + state: { + _activeFeeds: new Map(), + _vehicles: new Map(), + _enabled: true, + _altitudeGateOpen: true, + }, + parts: {}, + }).methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.coverage, + `No feed here yet · ${regions.size} regions available`, + ); +}); From fae50c0791ddc7bf2e75b49bb22ae58326a012ea Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:08:36 +0400 Subject: [PATCH 24/43] fix(cctv): display colour-neutral Bosch MIC models --- src/layers/cctv/modelSpecs.js | 24 ++++++++++++++++++------ src/layers/cctv/modelSpecs.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index ba6702584..58734aa14 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -33,7 +33,8 @@ * Bosch MIC order codes end in a colour letter (B black, G grey, W white) * on one shared datasheet; those siblings, * and the `x` wildcard registries write for the colour letter, are listed as - * aliases of the one vendored record. + * aliases of the one vendored record. Its `model` leaves the colour out, so + * the HUD never names a variant the source did not. */ const RECORDS = [ { @@ -276,11 +277,12 @@ const RECORDS = [ { id: 'bosch-mic-7130-pb4', brand: 'Bosch', - model: 'MIC-7130-PB4', + model: 'MIC-7130-P_4', type: 'ptz', url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/', aliases: [ 'MIC IP starlight 7000 HD', + 'MIC-7130-PB4', 'MIC-7130-PG4', 'MIC-7130-PW4', 'MIC-7130-Px4', @@ -295,10 +297,15 @@ const RECORDS = [ { id: 'bosch-mic-7502-z30b', brand: 'Bosch', - model: 'MIC-7502-Z30B', + model: 'MIC-7502-Z30', type: 'ptz', url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/', - aliases: ['MIC-7502-Z30G', 'MIC-7502-Z30W', 'MIC-7502-Z30x'], + aliases: [ + 'MIC-7502-Z30B', + 'MIC-7502-Z30G', + 'MIC-7502-Z30W', + 'MIC-7502-Z30x', + ], fov_deg: '2.3-63.7', resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' }, ptz: true, @@ -310,10 +317,15 @@ const RECORDS = [ { id: 'bosch-mic-7522-z30b', brand: 'Bosch', - model: 'MIC-7522-Z30B', + model: 'MIC-7522-Z30', type: 'ptz', url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/', - aliases: ['MIC-7522-Z30G', 'MIC-7522-Z30W', 'MIC-7522-Z30x'], + aliases: [ + 'MIC-7522-Z30B', + 'MIC-7522-Z30G', + 'MIC-7522-Z30W', + 'MIC-7522-Z30x', + ], fov_deg: '2.1-58.3', // i18n-ignore: datasheet label resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index d76f37c24..d379479d0 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -291,6 +291,26 @@ test('Bosch MIC colour aliases resolve without accepting unknown variants', () = } }); +test('Bosch MIC HUD summary never names a colour the source did not', () => { + assert.equal( + specSummary(lookupModelSpec('MIC-7522-Z30W')), + 'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ', + ); + assert.equal( + specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')), + 'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ', + ); + for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) { + for (const colour of ['B', 'G', 'W', 'x']) { + const spec = lookupModelSpec(pattern.replace('?', colour)); + const summary = specSummary(spec); + for (const other of ['B', 'G', 'W', 'x']) { + assert.ok(!summary.includes(pattern.replace('?', other)), summary); + } + } + } +}); + test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => { for (const model of [ 'Bosch MIC-7130-Px4', From 415f23739f7c81dc9f798fb5107f0e9e1044b2bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:26:55 +0400 Subject: [PATCH 25/43] Name the reporting provider in Contacts and Cockpit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol, AISStream) while the Layers panel and Contacts named the provider the last snapshot reported, and the vessels layer kept that name out of getStats(), so Contacts always said AISStream. Each cohort now carries the provider and whether it answered as a fallback or from a stale snapshot, and both views show it the way the Layers row does ("adsb.lol · FALLBACK"). Fixes HAL-721. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 7 ++++ src/layers/awareness/model.js | 1 + src/layers/awareness/panel.js | 3 +- src/layers/awareness/panel.test.mjs | 38 +++++++++++++++++++++ src/layers/awareness/queries.test.mjs | 26 +++++++++++++++ src/layers/awareness/subject.js | 12 +++++++ src/layers/vessels/ownership.test.mjs | 14 ++++++++ src/layers/vessels/queries.js | 2 ++ src/ui/cockpitContext.js | 3 ++ src/ui/cockpitContext.test.mjs | 46 ++++++++++++++++++++++++++ src/ui/cockpitContextState.js | 5 ++- src/ui/layerNames.js | 14 ++++++++ src/ui/layerNames.test.mjs | 17 ++++++++++ src/ui/views/panels/CockpitContext.vue | 8 ++--- 15 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/layers/awareness/panel.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1713,6 +1714,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3208,6 +3213,8 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/feedState.js", + "src/loadingFeedback.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) { id: cohort.id, label: cohort.label, source: cohort.source, + sourceStatus: cohort.sourceStatus || null, coverage: cohort.coverage || null, relationship: cohort.summary.relationship, count: cohort.summary.count, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -8,6 +8,7 @@ import { t, translateKnown, } from '../../i18n/index.js'; +import { sourceStatusLabel } from '../../ui/layerNames.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; import { distanceParts } from '../../data/distanceFormat.js'; @@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) { relationship: summary.relationship.toLowerCase(), label: cohort.label, count: `${presentAwarenessCount(summary)}${pageLabel}`, - detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`, + detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`, nearest, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPanel } from './panel.js'; + +test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { + const cohort = (id, source, sourceStatus) => ({ + id, + label: id, + source, + sourceStatus, + summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' }, + }); + const state = { + enabled: true, + results: { + subject: { label: 'AFR38JF' }, + cohorts: [ + cohort('flights', 'adsb.lol', 'fallback'), + cohort('military', 'adsb.lol', null), + cohort('ais-live-vessels', 'Datalastic', 'stale'), + ], + }, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + assert.deepEqual( + state.panelView.rows.map((row) => row.detail), + ['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'], + ); + state.offPanelLocale?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj assert.equal(moved.reason, 'mapped matches within 100 km of the subject'); assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 }); }); + +test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => { + const services = { installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } }; + const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source) } }; + parts.queries = createQueries({ state: {}, services, parts }); + const subjectOwner = createSubject({ state: {}, services, parts }); + const states = { + // The OpenSky proxy answers from adsb.lol when OpenSky fails. + flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } }, + military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } }, + 'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } }, + 'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const { cohorts } = subjectOwner.evaluateSubject({ + id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000), + }, states); + const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort])); + assert.equal(byId.flights.source, 'adsb.lol'); + assert.equal(byId.flights.sourceStatus, 'fallback'); + assert.equal(byId.military.sourceStatus, null); + assert.equal(byId['ais-live-vessels'].source, 'Datalastic'); + assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale'); + assert.equal(byId['military-installations'].sourceStatus, null); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -3,6 +3,7 @@ import { AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; import * as Cesium from 'cesium'; +import { layerFeedState } from '../../data/feedState.js'; import { t } from '../../i18n/index.js'; import { AWARENESS_QUERY_LIMIT, @@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); + // Whether the provider answered as a fallback or from a stale snapshot. + const sourceStatus = (sourceState) => { + const feedState = layerFeedState(sourceState.stats); + return feedState === 'fallback' || feedState === 'stale' + ? feedState + : null; + }; const retainedProvenance = (id, label, sourceState, fallbackSource) => { const retainedSource = sourceState.stats.source || fallbackSource; return { @@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'flights', label: t('Flights'), source: flightsState.stats.source || SOURCE_LABEL.flights, + sourceStatus: sourceStatus(flightsState), provenance: retainedProvenance( 'flights', 'Flights', @@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'military', label: t('Military flights'), source: militaryState.stats.source || SOURCE_LABEL.military, + sourceStatus: sourceStatus(militaryState), provenance: retainedProvenance( 'military', 'Military flights', @@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'ais-live-vessels', label: t('AIS vessels'), source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + sourceStatus: sourceStatus(vesselsState), provenance: retainedProvenance( 'ais-live-vessels', 'AIS vessels', @@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) { source: installationsState.stats.source || SOURCE_LABEL['military-installations'], + sourceStatus: sourceStatus(installationsState), provenance: retainedProvenance( 'military-installations', 'Mapped installations', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('vessel stats name the provider the last snapshot reported', async () => { + // HAL-721: Contacts named a fixed AIS provider while this layer showed the + // one its server reported. + const { layer } = setup({ + label: 'Vessels', + async getSnapshot() { + return snapshot([observation('111')], { source: 'Datalastic' }); + }, + }); + assert.equal(layer.getStats().source, 'Vessels'); + await layer.update(); + assert.equal(layer.getStats().source, 'Datalastic'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,6 +548,8 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), + // The provider the last snapshot named, as flights and military report it. + source: layer.source, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,7 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; import { formatNumber, t, tn } from '../i18n/index.js'; +import { sourceStatusLabel } from './layerNames.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -110,6 +111,8 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; + // The provider that answered, named as the Layers panel names it. + shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t ), ['—', '—', '—', '—'], ); + // No provider is named before a snapshot reports one. + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] small')].map( + (source) => source.textContent, + ), + ['—', '—', '—', '—'], + ); assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); assert.equal( $('#cockpit-context-nearest-label').hasAttribute('aria-label'), @@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => assert.equal(layouts.length, 1); }); +test('each count names the provider its snapshot reports, with its feed state', async (t) => { + // HAL-721: the counters named fixed providers while the layer and the + // Contacts list named the one that answered. + const reported = snapshot(); + reported.cohorts[0].source = 'adsb.lol'; + reported.cohorts[0].sourceStatus = 'fallback'; + reported.cohorts[1].source = 'Plane Finder'; + reported.cohorts.push( + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'Datalastic', + sourceStatus: 'stale', + count: 3, + relationship: 'OBSERVED', + nearest: [], + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + sourceStatus: null, + count: 0, + relationship: 'MAPPED', + nearest: [], + }, + ); + const { $, owner } = fixture(t, reported); + updateContext.call(owner, INFO, 0); + await nextTick(); + const source = (id) => $(`[data-context-cohort="${id}"] small`); + assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK'); + assert.equal(source('military').textContent, 'Plane Finder'); + assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE'); + assert.equal(source('military-installations').textContent, 'OpenStreetMap'); + // The tile ellipsizes; the full name stays readable on hover. + assert.equal(source('flights').title, 'adsb.lol · FALLBACK'); +}); + test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { const { $, text, owner, layouts } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -25,7 +25,10 @@ export function createContextState() { canPrevious: true, canNext: true, cohorts: Object.fromEntries( - COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + COCKPIT_CONTEXT_COHORTS.map((id) => [ + id, + { count: '—', unknown: false, source: '—' }, + ]), ), nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, distance: { text: '—', ariaLabel: null }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -137,3 +137,17 @@ export function sourceLabel(source) { ? source.split(' · ').map(translateKnown).join(' · ') : translateKnown(source); } + +/** + * A provider name with the feed state it answered in, so a fallback or stale + * snapshot does not read as the live primary feed. + * @param {string} source English source name. + * @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`. + * @returns {string} + */ +export function sourceStatusLabel(source, status) { + const name = sourceLabel(source); + if (status === 'fallback') return `${name} · ${t('FALLBACK')}`; + if (status === 'stale') return `${name} · ${t('STALE')}`; + return name; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -7,6 +7,7 @@ import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS, sourceLabel, + sourceStatusLabel, } from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every @@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a provider name carries the fallback or stale state it answered in', () => { + assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder'); + assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK'); + assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE'); + setLocale('ru'); + try { + assert.equal( + sourceStatusLabel('adsb.lol', 'fallback'), + `adsb.lol · ${t('FALLBACK')}`, + ); + assert.notEqual(t('FALLBACK'), 'FALLBACK'); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}
-
{{ t('FLT') }}{{ c.cohorts.flights.count }}OpenSky
-
{{ t('MIL') }}{{ c.cohorts.military.count }}adsb.lol
-
AIS{{ c.cohorts['ais-live-vessels'].count }}AISStream
-
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}OpenStreetMap
+
{{ t('FLT') }}{{ c.cohorts.flights.count }}{{ c.cohorts.flights.source }}
+
{{ t('MIL') }}{{ c.cohorts.military.count }}{{ c.cohorts.military.source }}
+
AIS{{ c.cohorts['ais-live-vessels'].count }}{{ c.cohorts['ais-live-vessels'].source }}
+
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}{{ c.cohorts['military-installations'].source }}
From 490c8d1769cc96fd9c93c4daff7986028e9a02e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:27:25 +0400 Subject: [PATCH 26/43] Add the changelog entry for #1521 --- changelog.d/1521-provider-labels.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1521-provider-labels.md diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md new file mode 100644 index 000000000..d921d9d7b --- /dev/null +++ b/changelog.d/1521-provider-labels.md @@ -0,0 +1,4 @@ +- Name the provider that answered in the Cockpit Contact counts and the + Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when + it is. The counts no longer read OpenSky while flights come from the adsb.lol + fallback, and vessels no longer always read AISStream (German, @gekh, #1521). From f01200b46769c595a85655c5fa2b53c3882b6ad3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 20:41:50 +0400 Subject: [PATCH 27/43] Cross-fade Active Fires markers and heatmap; fix the spot size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The far-zoom heatmap now uses the ½× grid (4096 × 2048, ≈10 km per pixel, one-pixel blur), so a lone fire's spot matches its marker at the 3,000 km hand-off. The spot size slider and its share option are gone. Markers and heatmap are both kept built at every zoom and cross-fade by camera height between 2,400 and 3,600 km on every frame, so a fast zoom never shows a beat with neither. The heatmap fades by imagery alpha: hiding a Cesium imagery layer unmaps it from the globe tiles, and showing it again re-mapped them late, which was the gap at the band edge. Under the markers the heatmap is built 150 ms after the data or filter changes. --- changelog.d/1426-fire-intensity-filter.md | 10 +- docs/CURRENT-STATE.md | 29 ++-- src/data/firmsHeatRaster.js | 64 -------- src/data/firmsHeatRaster.test.mjs | 61 ------- src/data/firmsInteraction.test.mjs | 151 ++++++++++-------- src/data/layerState.js | 5 +- src/data/layerState.test.mjs | 32 +--- src/i18n/locales/ar.json | 3 - src/i18n/locales/de.json | 3 - src/i18n/locales/es.json | 3 - src/i18n/locales/fr.json | 3 - src/i18n/locales/it.json | 3 - src/i18n/locales/ja.json | 3 - src/i18n/locales/pt.json | 3 - src/i18n/locales/ru.json | 3 - src/i18n/locales/uk.json | 3 - src/i18n/locales/zh-CN.json | 3 - src/i18n/locales/zh-Hant.json | 3 - src/i18n/same-as-english.json | 15 +- src/layers/firms/heatImagery.js | 13 ++ src/layers/firms/heatImagery.test.mjs | 16 ++ src/layers/firms/lifecycle.js | 7 +- src/layers/firms/policy.js | 25 ++- src/layers/firms/queries.js | 141 ++++++---------- src/layers/firms/rendering.js | 186 ++++++++++++++-------- src/layers/firms/state.js | 9 +- src/layers/firms/viewport.js | 3 + src/share/viewCodec.js | 6 +- src/ui/layerPanel.js | 19 ++- src/ui/layerPanel/LayerRows.vue | 9 +- src/ui/layerPanel/layerRows.test.mjs | 66 +++----- 31 files changed, 372 insertions(+), 528 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 2126e3c7f..2254b1169 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -4,9 +4,9 @@ log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, - one distinct spot per fire, empty where nothing burns, with a ½×–4× spot - size slider. The filter applies once before rendering, so markers, cards, - the heatmap, context and the top-fires query agree; share links keep the - filter and the spot size (`w` token: `f`, `c`, `s`), and `get_active_fires` + one distinct spot per fire, empty where nothing burns, and cross-fades with + the markers around 3,000 km with no gap. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query + agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All - / 1× (gekh, #1426). + (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 76cbd573a..8fdb5b582 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2209,37 +2209,36 @@ At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are linearly binned (each detection's weight shared between the four nearest pixel centres, so spots sit at the true position and stay round) on a -global 2048 × 1024 grid (≈20 km per pixel), each weighted by its FRP in megawatts (so heat is +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires -burn), Gaussian-blurred by one pixel so each fire stays a distinct spot about -twice the size of its marker, +burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one +pixel so each fire stays a distinct spot about the size of its marker, normalized on a log scale against the 99.5th percentile and mapped through the same hues, from a bright red edge through orange and gold to a pale white-hot core. The edge starts more than half opaque, so regions far below the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt -on each data refresh or filter change (the first paint at once, later ones -150 ms after the last change) and hidden once markers take over below -~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +on each data refresh or filter change (the first far-band paint at once, +later ones 150 ms after the last change). Heatmap and markers are both kept +built at every zoom and cross-fade by camera height between 2,400 and +3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe +tiles and re-mapped late when shown), the markers by sprite alpha, and the +marker collection is hidden once the heatmap is whole. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / -High, a "Heatmap spot size" slider (½× to 4× on a log2 axis, 5 % steps, -default 1×) and an "N of M shown" counter. The spot size scales the heat -grid rather than the blur (2× is a 1024 × 512 grid with the same one-pixel -blur), so every spot grows exactly and weak lone fires do not fade as they -widen; changing it rebuilds only the heatmap, not the shown set. The default is 0 MW and +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes -is deselected. The layer state keeps the options under the `w` (`local-firms`) -share token as `f` (whole megawatts, 0–10,000), `c` (`a`, `n`, `h`) and `s` -(spot size in percent, 50–400), including the short share codec; a link -without them restores the defaults. +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` (`all`, `nominal`, `high`) arguments, independent of the panel's setting, and diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 85894b19e..6704cf6e2 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -43,70 +43,6 @@ export function heatColorAt(t) { ]; } -/** - * Spot size, in percent of the shipped size: the panel slider and share - * option `w.s`. Whole percents, half to four times. - */ -export const HEAT_SPOT_SIZE_MIN = 50; -export const HEAT_SPOT_SIZE_MAX = 400; -export const HEAT_SPOT_SIZE_DEFAULT = 100; - -/** - * Spot size → whole percent inside the bounds, or null. - * @param {*} value - * @returns {?number} - */ -export function normalizeHeatSpotSize(value) { - const number = - typeof value === 'number' - ? value - : typeof value === 'string' && value.trim() !== '' - ? Number(value) - : NaN; - if (!Number.isFinite(number)) return null; - const rounded = Math.round(number); - return rounded >= HEAT_SPOT_SIZE_MIN && rounded <= HEAT_SPOT_SIZE_MAX - ? rounded - : null; -} - -/** - * The grid for a spot size. The same one-pixel blur on a grid that many - * times coarser (or finer) scales every spot exactly; widening the blur - * instead lowers each spot's peak, so weak lone fires faded as they grew. - * @param {{width: number, height: number, sigmaPx: number}} base Shipped grid. - * @param {number} spotSize Percent of the shipped size. - * @returns {{width: number, height: number, sigmaPx: number}} - */ -export function heatGridForSpotSize(base, spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - const height = Math.round((base.height * HEAT_SPOT_SIZE_DEFAULT) / percent); - return { width: height * 2, height, sigmaPx: base.sigmaPx }; -} - -/** - * Slider axis for the spot size: log2, so ½× and 2× sit equally far from 1×. - * Values snap to 5 % steps, which is also their share-link form. - */ -const SPOT_LOG_MIN = Math.log2(HEAT_SPOT_SIZE_MIN / 100); -const SPOT_LOG_SPAN = Math.log2(HEAT_SPOT_SIZE_MAX / 100) - SPOT_LOG_MIN; - -/** Track position in 0..1 for a spot size in percent. */ -export function sliderPositionForSpotSize(spotSize) { - const percent = normalizeHeatSpotSize(spotSize) ?? HEAT_SPOT_SIZE_DEFAULT; - return (Math.log2(percent / 100) - SPOT_LOG_MIN) / SPOT_LOG_SPAN; -} - -/** Spot size in percent for a track position in 0..1. */ -export function spotSizeForSliderPosition(position) { - const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; - const percent = 100 * Math.pow(2, SPOT_LOG_MIN + p * SPOT_LOG_SPAN); - return Math.min( - HEAT_SPOT_SIZE_MAX, - Math.max(HEAT_SPOT_SIZE_MIN, Math.round(percent / 5) * 5), - ); -} - /** Weight of a detection with no or zero FRP, in megawatts. */ export const HEAT_MIN_WEIGHT_MW = 0.5; diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 9fc0e02c8..754493754 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -7,10 +7,6 @@ import { binFires, blurGaussian, buildHeatRaster, - heatGridForSpotSize, - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, } from './firmsHeatRaster.js'; const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); @@ -120,60 +116,3 @@ test('buildHeatRaster with no fires is fully transparent', () => { const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); assert.ok(r.rgba.every((v) => v === 0)); }); - -test('a spot size scales the grid, not the blur', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - assert.deepEqual(heatGridForSpotSize(base, 100), base); - assert.deepEqual(heatGridForSpotSize(base, 200), { - width: 1024, - height: 512, - sigmaPx: 1, - }); - assert.deepEqual(heatGridForSpotSize(base, 50), { - width: 4096, - height: 2048, - sigmaPx: 1, - }); - const odd = heatGridForSpotSize(base, 150); - assert.equal(odd.width, odd.height * 2, 'the grid stays 2:1'); - assert.deepEqual(heatGridForSpotSize(base, 9999), base, 'invalid → default'); -}); - -test('twice the spot size paints a lone fire twice as wide', () => { - const base = { width: 2048, height: 1024, sigmaPx: 1 }; - const fires = [fire(0, 0, 5)]; - for (let i = 0; i < 40; i++) fires.push(fire(-10, 25 + i * 0.05, 300)); - const widthKm = (spotSize) => { - const r = buildHeatRaster(fires, heatGridForSpotSize(base, spotSize)); - const y = Math.floor(r.height / 2); - let painted = 0; - for (let x = 0; x < r.width; x++) { - const lon = ((x + 0.5) / r.width) * 360 - 180; - if (Math.abs(lon) < 10 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { - painted += 1; - } - } - return (painted * 40075) / r.width; - }; - const ratio = widthKm(200) / widthKm(100); - assert.ok(ratio > 1.7 && ratio < 2.3, `ratio ${ratio.toFixed(2)}`); -}); - -test('the spot-size slider is log2 with 1x a third along and 5 % steps', () => { - assert.equal(sliderPositionForSpotSize(50), 0); - assert.equal(sliderPositionForSpotSize(400), 1); - assert.ok(Math.abs(sliderPositionForSpotSize(100) - 1 / 3) < 1e-9); - assert.ok(Math.abs(sliderPositionForSpotSize(200) - 2 / 3) < 1e-9); - assert.equal(spotSizeForSliderPosition(0), 50); - assert.equal(spotSizeForSliderPosition(1), 400); - assert.equal(spotSizeForSliderPosition(1 / 3), 100); - assert.equal(spotSizeForSliderPosition(-1), 50); - for (let i = 0; i <= 100; i++) { - const percent = spotSizeForSliderPosition(i / 100); - assert.equal(percent % 5, 0, `${percent} at ${i}%`); - assert.equal(normalizeHeatSpotSize(percent), percent); - } - assert.equal(normalizeHeatSpotSize('150'), 150); - assert.equal(normalizeHeatSpotSize(49), null); - assert.equal(normalizeHeatSpotSize(''), null); -}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cd901f729..fddf82f49 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { HEAT_REBUILD_MS } from '../layers/firms/policy.js'; const LAYER_SOURCE = readLayerSource( path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'), @@ -393,7 +394,58 @@ test('disable cancels a pending source and a late response cannot repopulate the } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); -test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { +test('the heatmap is preloaded under the markers before the first zoom-out', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const priorDocument = globalThis.document; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), + }); + h.viewer.camera = { positionCartographic: { height: 1_000_000 } }; + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const added = []; + try { + h.layer.attachShellServices({ + imageryHost: () => ({ + kind: 'globe', + collection: { + addImageryProvider: (provider) => { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove() {}, + raiseToTop() {}, + }, + }), + }); + h.layer._renderLodForTest('close'); + assert.equal(added.length, 0, 'the markers paint first; the build trails'); + t.mock.timers.tick(HEAT_REBUILD_MS); + assert.equal(added.length, 1, 'then the heatmap is draped'); + assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); + assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => { // A canvas stub for the heat drape and the detection glow sprites. const priorDocument = globalThis.document; const restoreDocument = () => { @@ -420,8 +472,16 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h }); const added = []; const removed = []; + let markers = null; + // The fade reads the camera height; the cull reads scene.camera. + const setHeight = (height) => { + h.viewer.camera = { positionCartographic: { height } }; + }; + setHeight(12_000_000); h.viewer.scene.primitives = { - add() {}, + add(primitive) { + markers = primitive; + }, remove() {}, raiseToTop() {}, contains: () => true, @@ -430,7 +490,7 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h try { const collection = { addImageryProvider: (provider) => { - const layer = { provider, show: true }; + const layer = { provider, show: true, alpha: 1 }; added.push(layer); return layer; }, @@ -443,17 +503,33 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h h.layer._renderLodForTest('global'); assert.equal(added.length, 1, 'one heat imagery layer'); assert.equal(added[0].show, true); + assert.equal(added[0].alpha, 1); assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); assert.ok(h.published.length > 0, 'the far band still paints cards'); + assert.equal(markers.length, 2, 'the markers are built under the heatmap'); + assert.equal(markers.show, false, 'and hidden while the heatmap is whole'); h.layer._renderLodForTest('regional'); assert.equal(added.length, 1, 'switching far bands reuses the raster'); + setHeight(1_000_000); h.layer._renderLodForTest('close'); - assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); + assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); + assert.equal(added[0].alpha, 0, 'faded out under the markers'); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 1); + + // Mid hand-off both are drawn, half each, in either band. + setHeight(3_000_000); + h.layer._applyHandoffFadeForTest(); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 0.5); + h.layer._renderLodForTest('regional'); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade'); - h.layer._renderLodForTest('global'); - assert.equal(added[0].show, true); h.layer.disable(); assert.equal(added[0].show, false, 'disable hides the heatmap'); @@ -467,66 +543,3 @@ test('far zoom drapes one heatmap and draws no rectangles; markers and disable h restoreDocument(); } }); - -test('the spot-size slider rebuilds only the heatmap, on a proportionally coarser grid', async () => { - const priorDocument = globalThis.document; - const grids = []; - globalThis.document = { - createElement: () => ({ - getContext: () => ({ - createImageData: (w, hh) => { - grids.push([w, hh]); - return { data: new Uint8ClampedArray(w * hh * 4) }; - }, - putImageData() {}, - createRadialGradient: () => ({ addColorStop() {} }), - fillRect() {}, - }), - toDataURL: () => 'data:image/png;base64,AAAA', - }), - }; - const h = harness({ - fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], - withDataSource: true, - cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), - }); - h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; - h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; - const collection = { - addImageryProvider: (provider) => ({ provider, show: true }), - remove() {}, - raiseToTop() {}, - }; - try { - h.layer.attachShellServices({ imageryHost: () => ({ kind: 'globe', collection }) }); - h.layer._renderLodForTest('global'); - assert.deepEqual(grids.at(-1), [2048, 1024]); - assert.deepEqual( - h.layer.getRowControls().sliders.map((slider) => slider.id), - ['min-frp', 'heat-spot-size'], - ); - const spot = h.layer.getRowControls().sliders[1]; - assert.equal(spot.valueText, '1×'); - assert.deepEqual(spot.params(1000), { spotSize: 400 }); - - let repaints = 0; - h.layer.setRowControlsListener(() => repaints++); - h.layer.setParams({ spotSize: 200 }); - assert.equal(h.layer.getParams().spotSize, 200); - assert.equal(h.layer.getParams().minFrp, 0, 'the filter is untouched'); - assert.equal(h.layer.getStats().count, 2, 'every fire still shown'); - assert.equal(repaints, 1, 'the row repaints its value'); - assert.equal(h.layer.getRowControls().sliders[1].valueText, '2×'); - // The rebuild trails a drag (HEAT_REBUILD_MS) and keeps the old drape meanwhile. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.deepEqual(grids.at(-1), [1024, 512]); - - h.layer.setParams({ spotSize: 'nope' }); - assert.equal(h.layer.getParams().spotSize, 200, 'invalid input is ignored'); - } finally { - h.layer.destroy(h.viewer); - h.cleanup(); - if (priorDocument === undefined) delete globalThis.document; - else globalThis.document = priorDocument; - } -}); diff --git a/src/data/layerState.js b/src/data/layerState.js index a049a80c5..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,7 +298,7 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ - // Active Fires intensity filter and heatmap spot size (HAL-676). `minFrp` is whole megawatts on + // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on // the codec side; the panel's log slider rounds to it. Both default to // "show everything": many of the strongest fires are rated low confidence. // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in @@ -311,9 +311,6 @@ const OPTION_GROUPS = Object.freeze({ nominal: 'n', high: 'h', }), - // Heatmap spot size in percent; mirrors HEAT_SPOT_SIZE_* in - // firmsHeatRaster.js. - boundedIntegerOption('spotSize', 's', 100, { min: 50, max: 400 }), ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 78b36c3a6..55d40dfe2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -30,11 +30,6 @@ import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; -import { - HEAT_SPOT_SIZE_DEFAULT, - HEAT_SPOT_SIZE_MAX, - HEAT_SPOT_SIZE_MIN, -} from './firmsHeatRaster.js'; function deferred() { let resolve; @@ -3024,14 +3019,9 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( assert.deepEqual(state.options['local-firms'], { minFrp: 0, confidence: 'all', - spotSize: 100, }); state.enabledLayerIds = ['local-firms']; - state.options['local-firms'] = { - minFrp: 20, - confidence: 'high', - spotSize: 250, - }; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; const encoded = encode(state); assert.equal(new URLSearchParams(encoded).get('l'), 'w'); // Other owners write their own explicit defaults into `lo` too (flights 3D, @@ -3039,12 +3029,10 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( const assignments = new URLSearchParams(encoded).get('lo').split('_'); assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); - assert.ok(assignments.includes('w.s.250'), assignments.join(' ')); const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); assert.deepEqual(decoded.options['local-firms'], { minFrp: 20, confidence: 'high', - spotSize: 250, }); assert.deepEqual( parseStoredLayerState(serializeStoredLayerState(state)).options[ @@ -3062,11 +3050,7 @@ test('Active Fires: the intensity filter rides share token w and round-trips', ( .filter((assignment) => assignment.startsWith('w.')).length, 0, ); - assert.deepEqual(firmsOptions(''), { - minFrp: 0, - confidence: 'all', - spotSize: 100, - }); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); }); test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { @@ -3094,17 +3078,5 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { minFrp: 5, confidence: 'nominal', - spotSize: 100, }); }); - -test('Active Fires: the codec accepts whole-percent spot sizes from half to four times', () => { - assert.equal(HEAT_SPOT_SIZE_MIN, 50); - assert.equal(HEAT_SPOT_SIZE_MAX, 400); - assert.equal(HEAT_SPOT_SIZE_DEFAULT, 100); - assert.equal(firmsOptions('w.s.50').spotSize, 50); - assert.equal(firmsOptions('w.s.400').spotSize, 400); - assert.equal(firmsOptions('w.s.45').spotSize, 100, 'out of bounds → default'); - assert.equal(firmsOptions('w.s.401').spotSize, 100); - assert.equal(firmsOptions('w.s.x').spotSize, 100); -}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b65608ed6..c96530641 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3224,9 +3224,6 @@ "≥ {value} MW": "{value} MW على الأقل", "Confidence": "الثقة", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", - "Heatmap spot size": "حجم بقع الخريطة الحرارية", - "Far-zoom heatmap spot size": "حجم بقع الخريطة الحرارية عند العرض البعيد", - "{value}×": "{value}×", "fire intensity filter|All": "الكل", "Detection confidence": "ثقة الرصد", "{shown} of {total} shown": "المعروضة: {shown} من {total}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8bbec9f29..b4e02ea23 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2944,9 +2944,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Konfidenz", "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", - "Heatmap spot size": "Fleckengröße der Heatmap", - "Far-zoom heatmap spot size": "Fleckengröße der Heatmap in der Fernansicht", - "{value}×": "{value}×", "fire intensity filter|All": "Alle", "Detection confidence": "Konfidenz der Erkennung", "{shown} of {total} shown": "{shown} von {total} angezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3721c1ea0..ddfc90df9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2993,9 +2993,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "Heatmap spot size": "Tamaño de las manchas del mapa de calor", - "Far-zoom heatmap spot size": "Tamaño de las manchas del mapa de calor en la vista lejana", - "{value}×": "{value}×", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 17c4dcf9d..d368bb387 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiance", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", - "Heatmap spot size": "Taille des taches de la carte de chaleur", - "Far-zoom heatmap spot size": "Taille des taches de la carte de chaleur en vue éloignée", - "{value}×": "{value}×", "fire intensity filter|All": "Toutes", "Detection confidence": "Confiance de la détection", "{shown} of {total} shown": "{shown} sur {total} affichées", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 8d43c74d7..9f1821423 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confidenza", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", - "Heatmap spot size": "Dimensione delle macchie della mappa di calore", - "Far-zoom heatmap spot size": "Dimensione delle macchie della mappa di calore nella vista da lontano", - "{value}×": "{value}×", "fire intensity filter|All": "Tutti", "Detection confidence": "Confidenza del rilevamento", "{shown} of {total} shown": "{shown} di {total} mostrati", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0725812a4..192c52a27 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2874,9 +2874,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "信頼度", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", - "Heatmap spot size": "ヒートマップの点の大きさ", - "Far-zoom heatmap spot size": "遠景ヒートマップの点の大きさ", - "{value}×": "{value}×", "fire intensity filter|All": "すべて", "Detection confidence": "検知の信頼度", "{shown} of {total} shown": "{total}件中{shown}件を表示", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 74316dc1b..0a6617eb8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3014,9 +3014,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "Confiança", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", - "Heatmap spot size": "Tamanho das manchas do mapa de calor", - "Far-zoom heatmap spot size": "Tamanho das manchas do mapa de calor na vista distante", - "{value}×": "{value}×", "fire intensity filter|All": "Todas", "Detection confidence": "Confiança da detecção", "{shown} of {total} shown": "{shown} de {total} exibidas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index db69d0f4a..9551c1b52 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3063,9 +3063,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "Heatmap spot size": "Размер пятен тепловой карты", - "Far-zoom heatmap spot size": "Размер пятен тепловой карты на дальнем масштабе", - "{value}×": "{value}×", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4d05d26b6..bff311c79 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3055,9 +3055,6 @@ "≥ {value} MW": "≥ {value} МВт", "Confidence": "Достовірність", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", - "Heatmap spot size": "Розмір плям теплової карти", - "Far-zoom heatmap spot size": "Розмір плям теплової карти на далекому масштабі", - "{value}×": "{value}×", "fire intensity filter|All": "Усі", "Detection confidence": "Достовірність виявлення", "{shown} of {total} shown": "показано {shown} з {total}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3a4762c5..1481226da 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2853,9 +2853,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "Heatmap spot size": "热力图光斑大小", - "Far-zoom heatmap spot size": "远景热力图光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index af214e6e7..cd182ecfd 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2880,9 +2880,6 @@ "≥ {value} MW": "≥ {value} MW", "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", - "Heatmap spot size": "熱力圖光斑大小", - "Far-zoom heatmap spot size": "遠景熱力圖光斑大小", - "{value}×": "{value}×", "fire intensity filter|All": "全部", "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 9310e0d97..7aa51d344 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -31,7 +31,6 @@ "dB", "hPa", "{source} HTTP {status}", - "{value}×", "▲ {frp} MW" ], "de": [ @@ -198,7 +197,6 @@ "{value} KM DOWNRANGE", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔN", "Ⅱ PAUSE", "≥ {value} MW", @@ -329,7 +327,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -511,7 +508,6 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value}×", "ΔE", "ΔN", "Ⅱ PAUSE", @@ -685,7 +681,6 @@ "{value} KM/H", "{value} KM/S", "{value} conf", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -736,7 +731,6 @@ "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", - "{value}×", "ΔE", "ΔN", "≥ {value} MW", @@ -871,7 +865,6 @@ "{value} KM/S", "ΔN", "▲ {frp} MW", - "{value}×", "≥ {value} MW" ], "ru": [ @@ -890,8 +883,7 @@ "S2 GhostMaps", "S2 · {name}", "VS", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "uk": [ "AIS: --", @@ -904,8 +896,7 @@ "NVG", "REC", "S2 GhostMaps", - "WHOT/BHOT", - "{value}×" + "WHOT/BHOT" ], "zh-CN": [ "1090 MHz", @@ -960,7 +951,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", @@ -1019,7 +1009,6 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value}×", "≥ {value} MW", "ΔE", "ΔN", diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index 31fbf1f69..710a87f43 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -3,6 +3,12 @@ * imagery tile on the active imagery host (globe or 3D-tiles), like the wind * layer's scalar field. Owns the canvas and the imagery layer; nothing else * in the fires layer touches imagery. + * + * `show` is only for the layer being on or off. The zoom hand-off fades + * `alpha` instead: Cesium unmaps a hidden imagery layer from the globe + * tiles and re-maps it when shown, so a show toggle at the band edge left + * a beat with neither markers nor heat. At alpha 0 the layer stays mapped + * and costs no draw. */ const defaultCanvas = (width, height) => { @@ -21,6 +27,7 @@ export function createHeatImagery({ let layer = null; let collection = null; let show = true; + let opacity = 1; function detach() { if (layer && collection) collection.remove(layer, true); @@ -47,6 +54,7 @@ export function createHeatImagery({ collection = host.collection; layer = collection.addImageryProvider(provider); layer.show = show; + layer.alpha = opacity; collection.raiseToTop?.(layer); } @@ -63,6 +71,11 @@ export function createHeatImagery({ show = Boolean(value); if (layer) layer.show = show; }, + /** Hand-off fade, 0..1; the layer stays mapped at 0. */ + setOpacity(value) { + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + if (layer) layer.alpha = opacity; + }, rehome() { const host = getHost?.(); if ((host?.collection ?? null) !== collection) attach(); diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs index 5543a0691..27f45f55f 100644 --- a/src/layers/firms/heatImagery.test.mjs +++ b/src/layers/firms/heatImagery.test.mjs @@ -71,6 +71,22 @@ test('setShow toggles without rebuilding', () => { assert.equal(layer.show, true); }); +test('setOpacity fades the same layer and survives a rebuild', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setOpacity(0); + assert.equal(layer.alpha, 0); + assert.equal(layer.show, true, 'a faded layer stays mapped'); + h.imagery.setOpacity(0.25); + assert.equal(h.layers[0], layer); + assert.equal(layer.alpha, 0.25); + h.imagery.setRaster(raster); + assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade'); + h.imagery.setOpacity(7); + assert.equal(h.layers[0].alpha, 1); +}); + test('no imagery host keeps nothing draped; rehome brings it back', () => { const h = harness(); h.setHost({ collection: null, kind: 'none' }); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 72434ad46..541efca16 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; -import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -55,8 +54,9 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') - layerState._heatImagery?.setShow(true); + layerState._heatImagery?.setShow(true); + // The camera can have crossed the hand-off while the layer was off. + components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -119,6 +119,7 @@ export function createLifecycle({ layerState._heatImagery?.destroy(); layerState._heatImagery = null; layerState._heatRaster = null; + layerState._handoffHeat = -1; layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 2af5d9f5c..6ba8ebb8e 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -49,20 +49,29 @@ export const LOD_LEVELS = [ ]; /** - * Global heat raster for the `heat` bands: ≈0.18° (≈20 km) per pixel and a - * one-pixel Gaussian σ, so a lone fire is a distinct spot about twice as wide - * as its marker at the 3,000 km hand-off and still reads from the - * whole-globe view; neighbours merge only when they nearly touch (σ 3 here - * smeared each fire over ~200 km). Built once per data refresh or filter - * change, never per camera move. + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about the size of its + * marker at the 3,000 km hand-off and the two cross-fade without a jump; + * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared + * each fire over ~200 km). Built once per data refresh or filter change, + * never per camera move. */ export const HEAT_RASTER = Object.freeze({ - width: 2048, - height: 1024, + width: 4096, + height: 2048, sigmaPx: 1, }); +/** + * Camera heights, in metres, over which the heatmap and the markers + * cross-fade around the 3,000 km band edge: markers alone below `from`, + * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so + * whichever band is active inside it, both layers are already built. + */ + +export const HANDOFF_FADE = Object.freeze({ from: 2_400_000, to: 3_600_000 }); + /** * Trailing delay before a stale heatmap is rebuilt while one is already on * screen: a slider drag fires many filter changes, and a build plus PNG diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index b9577925e..34040ea43 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -8,11 +8,6 @@ import { rampTrackGradient, sliderPositionForFrp, } from '../../data/firmsIntensity.js'; -import { - normalizeHeatSpotSize, - sliderPositionForSpotSize, - spotSizeForSliderPosition, -} from '../../data/firmsHeatRaster.js'; import { formatNumber, t } from '../../i18n/index.js'; import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; @@ -28,16 +23,6 @@ export function createQueries({ }) { const { id, name, icon, source } = config; - /** Spot sizes marked under the spot-size slider, in percent. */ - const SPOT_SIZE_TICKS = [50, 100, 200, 400]; - - /** Spot-size label for a percent ("0.5×", "1×", "1.5×"). */ - function spotSizeLabel(percent) { - return t('{value}×', { - value: formatNumber(percent / 100, { maximumFractionDigits: 2 }), - }); - } - /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ function frpLabel(frp) { return formatNumber(frp, { maximumFractionDigits: 0 }); @@ -98,12 +83,12 @@ export function createQueries({ updateInterval: REFRESH_INTERVAL_MS, /** - * The intensity filter and the heatmap spot size as layer options (share - * token `w`, owner `local-firms` in layerState.js). - * @returns {{minFrp: number, confidence: string, spotSize: number}} + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} */ getParams() { - return { ...layerState._filter, spotSize: layerState._spotSize }; + return { ...layerState._filter }; }, /** @@ -111,36 +96,24 @@ export function createQueries({ * value. The filter runs in one place (ingestion.applyFilter) and the LOD * rebuild re-derives markers, cells, cards and context registrations from * the shown set; a selected fire that no longer passes is deselected. - * A spot-size change only marks the heat raster stale: the shown set, - * markers and cards stay as they are. - * @param {{minFrp?: number, confidence?: string, spotSize?: number}} params + * @param {{minFrp?: number, confidence?: string}} params * @returns {boolean} True — the request is always accepted. */ setParams(params = {}) { const next = normalizeFireFilter(params, layerState._filter); - const spotSize = - normalizeHeatSpotSize(params?.spotSize) ?? layerState._spotSize; - const filterChanged = - next.minFrp !== layerState._filter.minFrp || - next.confidence !== layerState._filter.confidence; - const spotChanged = spotSize !== layerState._spotSize; - if (!filterChanged && !spotChanged) return true; - layerState._filter = next; - layerState._spotSize = spotSize; - if (!layerState._allFires.length) { - layerState._rowControlsListener?.(); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) return true; - } - if (filterChanged) { - components.ingestion.applyFilter(); - if ( - layerState._selectedFire && - !firePassesFilter(layerState._selectedFire, next) - ) { - components.selection.clearFireSelection(); - } - } else { - layerState._heatRaster = null; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); } if (layerState._enabled) components.rendering.renderCurrentLod(true); layerState._rowControlsListener?.(); @@ -150,8 +123,8 @@ export function createQueries({ /** * Row controls under the Active Fires row: the log-scale minimum-FRP * slider whose track is painted with the intensity ramp (so it doubles as - * the legend), the All / Nominal+ / High confidence switch, the far-zoom - * heatmap spot-size slider and the "N of M shown" counter. + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. */ getRowControls() { const { minFrp, confidence } = layerState._filter; @@ -160,48 +133,27 @@ export function createQueries({ const shown = layerState._count; const total = layerState._totalCount; return { - sliders: [ - { - id: 'min-frp', - label: t('Fire intensity (FRP)'), - ariaLabel: t('Minimum fire radiative power, megawatts'), - valueText: - minFrp > 0 - ? t('≥ {value} MW', { value: frpLabel(minFrp) }) - : t('All', { context: 'fire intensity filter' }), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), - track: rampTrackGradient(), - ticks: FRP_SLIDER_TICKS.map((frp) => ({ - at: sliderPositionForFrp(frp), - label: frpLabel(frp), - })), - params: (value) => ({ - minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, - { - id: 'heat-spot-size', - label: t('Heatmap spot size'), - ariaLabel: t('Far-zoom heatmap spot size'), - valueText: spotSizeLabel(layerState._spotSize), - min: 0, - max: SLIDER_STEPS, - step: 1, - value: Math.round( - sliderPositionForSpotSize(layerState._spotSize) * SLIDER_STEPS, - ), - ticks: SPOT_SIZE_TICKS.map((percent) => ({ - at: sliderPositionForSpotSize(percent), - label: spotSizeLabel(percent), - })), - params: (value) => ({ - spotSize: spotSizeForSliderPosition(Number(value) / SLIDER_STEPS), - }), - }, - ], + slider: { + id: 'min-frp', + label: t('Fire intensity (FRP)'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, segmented: { id: 'confidence', label: t('Confidence'), @@ -359,10 +311,15 @@ export function createQueries({ /** Test seam: render one named LOD band against a near-global view. */ _renderLodForTest(lodId) { - const lod = LOD_LEVELS.find((level) => level.id === lodId); - if (lod.mode === 'detections') - components.rendering.renderDetections(lod, null); - else components.rendering.renderHeat(lod, null); + components.rendering.renderBand( + LOD_LEVELS.find((level) => level.id === lodId), + null, + ); + }, + + /** Test seam: one frame of the hand-off fade at the current camera. */ + _applyHandoffFadeForTest() { + components.rendering.applyHandoffFade(); }, /** Test seam: entities drawn by the layer's data source. */ diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index dd91effec..d6e8b2d3b 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,18 +1,22 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { - buildHeatRaster, - heatGridForSpotSize, -} from '../../data/firmsHeatRaster.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; import { LOD_LEVELS, CONTEXT_TOP_N, + HANDOFF_FADE, HEAT_RASTER, HEAT_REBUILD_MS, } from './policy.js'; import { createHeatImagery } from './heatImagery.js'; +/** The markers drawn under the far bands' heatmap for the hand-off fade. */ +const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections'); + +/** Fade steps: a moving camera rewrites marker colors at most this often. */ +const FADE_STEPS = 32; + export function createRendering({ layerState, services, @@ -64,14 +68,54 @@ export function createRendering({ ? components.model.paddedDegreeBounds(viewRect) : null; - if (lod.mode === 'detections') { - renderDetections(lod, bounds); - } else { - renderHeat(lod, bounds); - } + renderBand(lod, bounds); return true; } + /** + * Draw one band. Markers and heatmap stay built in every band; the + * hand-off only fades between them. + * @param {Object} lod - Band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderBand(lod, bounds) { + prepareHeat(lod.mode === 'heat'); + if (lod.mode === 'detections') renderDetections(lod, bounds); + else renderHeat(lod, bounds); + applyHandoffFade(true); + } + + const fadeColor = new Cesium.Color(); + + /** + * Cross-fade between markers and heatmap by camera height (HANDOFF_FADE), + * on every rendered frame, so a fast zoom never outruns the throttled band + * check. Cheap when nothing changed: one height read and a compare. + * @param {boolean} [force] Reapply, e.g. to freshly added markers. + */ + + function applyHandoffFade(force = false) { + const height = components.viewport.cameraHeight(); + const t = Math.min( + 1, + Math.max( + 0, + (height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from), + ), + ); + const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS; + if (!force && heat === layerState._handoffHeat) return; + layerState._handoffHeat = heat; + layerState._heatImagery?.setOpacity(heat); + const billboards = layerState._billboards; + if (!billboards) return; + billboards.show = layerState._enabled && heat < 1; + const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor); + for (let i = 0; i < billboards.length; i += 1) + billboards.get(i).color = color; + } + /** * Bin all detections into grid cells, then viewport-clip BEFORE the * top-N cap so fires in view never lose their slot to higher-scoring @@ -147,28 +191,27 @@ export function createRendering({ return layerState._heatImagery; } - /** Build the heat raster from the shown set at the chosen spot size and drape it. */ + /** Build the heat raster from the shown set and drape it. */ function rebuildHeatRaster() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; if (layerState._destroyed) return; - layerState._heatRaster = buildHeatRaster( - layerState._fires, - heatGridForSpotSize(HEAT_RASTER, layerState._spotSize), - ); + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); heatImagery().setRaster(layerState._heatRaster); } /** - * Make sure the draped heatmap matches the shown set. The first paint is - * immediate; later rebuilds (filter drags, refreshes) trail by - * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + * Make sure the draped heatmap matches the shown set. The first paint in a + * far band is immediate; other builds (filter drags, refreshes, the + * preload under the markers) trail by HEAT_REBUILD_MS and keep the + * previous heatmap on screen meanwhile. + * @param {boolean} now Build at once when nothing is draped yet. */ - function ensureHeatRaster() { + function ensureHeatRaster(now) { if (layerState._heatRaster) return; - if (!heatImagery().hasRaster()) { + if (now && !heatImagery().hasRaster()) { rebuildHeatRaster(); return; } @@ -179,10 +222,25 @@ export function createRendering({ ); } + /** + * Keep the heatmap draped on the current map stack and current with the + * shown set in every band; the hand-off fade decides how much of it shows. + * @param {boolean} now The camera is in a far band. + */ + + function prepareHeat(now) { + const imagery = heatImagery(); + // The map stack may have changed since the last render. + imagery.rehome(); + ensureHeatRaster(now); + imagery.setShow(layerState._enabled); + } + /** * Far bands (global/regional): a Snap Map–style heatmap draped over the * globe, transparent where nothing burns, plus the decluttered "N FIRES" - * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards from the coarse aggregation. The nearest band's markers are kept + * built underneath for the hand-off fade and hidden once it completes; * cards go through the same screen-space label pipeline as detections * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. @@ -190,17 +248,7 @@ export function createRendering({ */ function renderHeat(lod, bounds) { - ensureDetectionCollections(); - layerState._dataSource.entities.removeAll(); - if (layerState._billboards) layerState._billboards.removeAll(); - layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; - - const imagery = heatImagery(); - // The map stack may have changed since the last far render. - imagery.rehome(); - ensureHeatRaster(); - imagery.setShow(layerState._enabled); + paintMarkers(HANDOFF_MARKER_LOD, bounds); const cells = aggregateFires(lod, bounds); layerState._cellCount = cells.length; @@ -242,10 +290,48 @@ export function createRendering({ */ function renderDetections(lod, bounds) { - layerState._heatImagery?.setShow(false); + const markers = paintMarkers(lod, bounds); + if (!markers) return; + const candidates = markers.map((marker) => marker.fire); + + layerState._cellCount = candidates.length; + // FRP-sorted walk: marker order doubles as label priority. + layerState._labelCandidates = markers; + layerState._labelLodDistance = lod.labelDistance; + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + /** + * The glow-sprite markers for a detections band: the strongest fires in + * view, capped at the band's maxDetections, horizon-culled. + * @param {Object} lod - Detections band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {?Array} Label candidates for the fires drawn, + * strongest first; null before the sprite collection exists. + */ + + function paintMarkers(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (!layerState._billboards) return; + if (!layerState._billboards) return null; layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; @@ -267,10 +353,7 @@ export function createRendering({ candidates = layerState._firesByFrp.slice(0, lod.maxDetections); } - layerState._cellCount = candidates.length; - layerState._labelCandidates = []; - layerState._labelLodDistance = lod.labelDistance; - + const markers = []; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); const position = components.model.firePosition(fire); @@ -278,6 +361,7 @@ export function createRendering({ const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); + markers.push({ position, cullPosition, fire, cell: null }); layerState._billboards.add({ id: pickId, position, @@ -289,37 +373,13 @@ export function createRendering({ horizontalOrigin: Cesium.HorizontalOrigin.CENTER, disableDepthTestDistance: Number.POSITIVE_INFINITY, }); - // FRP-sorted walk: candidate order doubles as label priority. - layerState._labelCandidates.push({ - position, - cullPosition, - fire, - cell: null, - }); } // Freshly-added sprites default to show=true; cull the far side now so a // rebuild never flashes through-the-planet detections while the camera // sits still (the moveEnd/preRender hooks only fire on camera motion). refreshHorizonCulling(); - components.cards.rebuildAmbientLabels(); - components.selection.refreshContextRegistrations( - candidates.slice(0, CONTEXT_TOP_N), - ); - - if (lod.id === 'close') { - // Batched (chunked ≤200, sequential, session-cached) DEM warm for the - // rendered subset; fires are static so each coarse cell resolves once - // ever. Re-render ONLY when a floor actually landed — a failed resolve - // reports false, so this chain terminates instead of looping against a - // down proxy (the next camera-driven rebuild retries). - warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !layerState._enabled || !layerState._viewer) return; - const currentLod = LOD_LEVELS[layerState._currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } + return markers; } function ensureDetectionCollections() { @@ -385,9 +445,11 @@ export function createRendering({ } return { renderCurrentLod, + renderBand, aggregateFires, renderHeat, renderDetections, + applyHandoffFade, ensureDetectionCollections, removeDetectionCollections, fireHorizonOccluder, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 349fcd375..0b96c53ea 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; -import { HEAT_SPOT_SIZE_DEFAULT } from '../../data/firmsHeatRaster.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -70,10 +69,6 @@ export function createFirmsState({ services, config }) { layerState._filter = { ...DEFAULT_FIRE_FILTER }; - /** Heatmap spot size, percent of the shipped size (firmsHeatRaster.js). */ - - layerState._spotSize = HEAT_SPOT_SIZE_DEFAULT; - /** Panel callback: repaint the row controls (counter) after a load. */ layerState._rowControlsListener = null; @@ -147,6 +142,10 @@ export function createFirmsState({ services, config }) { layerState._heatRebuildTimer = null; + /** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */ + + layerState._handoffHeat = -1; + /** Imagery adapter (heatImagery.js), created on the first far render. */ layerState._heatImagery = null; diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 932fe2e75..b32a02e37 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -151,6 +151,9 @@ export function createViewport({ // on screen throughout it — so the horizon pass below must keep running // while a fetch is in flight. Only the rebuild is gated on fresh data. if (!layerState._enabled) return; + // Every frame, ahead of the throttle: a fast zoom crosses the whole + // hand-off between two band checks. + components.rendering.applyHandoffFade(); const now = performance.now(); if (now - layerState._lastLodCheck < LOD_CHECK_MS) return; layerState._lastLodCheck = now; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 8f556ea1e..8ac00f51b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,11 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ - 'local-firms': Object.freeze({ - minFrp: 0, - confidence: 'all', - spotSize: 100, - }), + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 2327105be..d16a0c6ff 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -470,7 +470,7 @@ export class LayerPanel { } /** - * A layer's row chips, one-of-many switch, sliders, color legend, info line + * A layer's row chips, one-of-many switch, slider, color legend, info line * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are @@ -496,7 +496,7 @@ export class LayerPanel { })); const infoText = controls?.info ? String(controls.info) : ''; const segmented = controls?.segmented || null; - const sliders = Array.isArray(controls?.sliders) ? controls.sliders : []; + const slider = controls?.slider || null; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { @@ -505,10 +505,10 @@ export class LayerPanel { legend.length === 0 && !infoText && !segmented && - sliders.length === 0, + !slider, chips, segmented, - sliders, + slider, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -562,10 +562,13 @@ export class LayerPanel { // to params through the live descriptor on every tick. _moveSlider(id, sliderId, value, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const slider = this._rowControlsFor(id)?.sliders?.find( - (entry) => entry.id === sliderId, - ); - if (!slider || typeof slider.params !== 'function' || !this.isEnabled(id)) + const slider = this._rowControlsFor(id)?.slider; + if ( + !slider || + slider.id !== sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(id) + ) return; this.setLayerParams(id, slider.params(value), { origin: 'user' }); } diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index b8c272e24..5f5d7c97e 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -63,10 +63,11 @@ defineProps({ :segmented="item.controls.segmented" :on-select="(optionId) => onChip(item.id, optionId)" /> ({ active: mode === active, })), }, - sliders: [ - { - id: 'min-frp', - label: 'Fire intensity (FRP)', - ariaLabel: 'Minimum fire radiative power, megawatts', - valueText: value ? `≥ ${value} MW` : 'All', - min: 0, - max: 1000, - step: 1, - value, - track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', - ticks: [ - { at: 0, label: '0' }, - { at: 0.5, label: '30' }, - ], - }, - { - id: 'heat-spot-size', - label: 'Heatmap spot size', - ariaLabel: 'Far-zoom heatmap spot size', - valueText: '1×', - min: 0, - max: 1000, - value: 333, - ticks: [{ at: 1 / 3, label: '1×' }], - }, - ], + slider: { + id: 'min-frp', + label: 'Fire intensity (FRP)', + ariaLabel: 'Minimum fire radiative power, megawatts', + valueText: value ? `≥ ${value} MW` : 'All', + min: 0, + max: 1000, + step: 1, + value, + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.5, label: '30' }, + ], + }, legend: [], info: null, list: null, }, }); -test('a row switch and sliders render their markup and report choices', () => { +test('a row switch and slider render their markup and report choices', () => { document.body.replaceChildren(); const container = document.body.appendChild(document.createElement('div')); const chips = []; @@ -127,13 +115,8 @@ test('a row switch and sliders render their markup and report choices', () => { segments[1].click(); assert.deepEqual(chips, [['fires', 'confidence-high']]); - const blocks = [...container.querySelectorAll('.data-toggle-slider')]; - assert.deepEqual( - blocks.map((entry) => entry.dataset.sliderId), - ['min-frp', 'heat-spot-size'], - 'sliders render in order, after the switch', - ); - const [block, spotBlock] = blocks; + const block = container.querySelector('.data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); assert.equal( block.querySelector('.data-toggle-slider-label').textContent, 'Fire intensity (FRP)', @@ -164,18 +147,7 @@ test('a row switch and sliders render their markup and report choices', () => { input.value = '120'; input.dispatchEvent(new Event('input', { bubbles: true })); - const spotInput = spotBlock.querySelector('input.data-toggle-slider-input'); - assert.equal( - spotInput.getAttribute('aria-label'), - 'Far-zoom heatmap spot size', - ); - assert.equal(spotInput.style.getPropertyValue('--slider-track'), ''); - spotInput.value = '700'; - spotInput.dispatchEvent(new Event('input', { bubbles: true })); - assert.deepEqual(moves, [ - ['fires', 'min-frp', 120], - ['fires', 'heat-spot-size', 700], - ]); + assert.deepEqual(moves, [['fires', 'min-frp', 120]]); // The refresh that tick triggers keeps the same nodes and updates the text. render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container); From 9a1bc3404287d12f71eec0df030b8038a89e9e11 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:09:09 +0400 Subject: [PATCH 28/43] Keep weak Active Fires as markers through the hand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 750–3,000 km markers band now draws up to 20,000 detections instead of 2,500. At the 2,400 km end of the cross-fade the padded view over Africa holds ~21,000 fires, and the old cap kept only those above ~17 MW, so most heat spots faded into empty ground as the heatmap went out. A rebuild of that many costs ~60 ms, and only in the densest views. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 5 ++++- src/layers/firms/policy.js | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 2254b1169..d22e3f843 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -5,7 +5,7 @@ High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, one distinct spot per fire, empty where nothing burns, and cross-fades with - the markers around 3,000 km with no gap. The filter applies once before + the markers around 3,000 km with no gap, keeping weak fires as markers. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8fdb5b582..84325e63d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2225,7 +2225,10 @@ built at every zoom and cross-fade by camera height between 2,400 and 3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe tiles and re-mapped late when shown), the markers by sprite alpha, and the -marker collection is hidden once the heatmap is whole. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +marker collection is hidden once the heatmap is whole. The 750–3,000 km +markers band draws up to 20,000 detections (the strongest in the padded +view), so the weak fires the heatmap shows stay on screen as markers when it +fades out. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 6ba8ebb8e..8431655bd 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -33,10 +33,14 @@ export const LOD_LEVELS = [ labelDistance: 8500000, }, { + // Up to 20,000 markers: at the 2,400 km end of the hand-off the padded + // view over Africa, the densest region, holds ~21,000 detections, and a + // tighter cap left the heatmap's weak spots dissolving into nothing. + // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 2500, + maxDetections: 20000, labelDistance: 4500000, }, { From f608ae81e7691835628735400f5484af48096509 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 21:24:50 +0400 Subject: [PATCH 29/43] Move the Active Fires hand-off up to 4,500 km The heatmap now takes over above 4,500 km instead of 3,000 km, with the cross-fade between 3,600 and 5,400 km. The markers band caps at 40,000 detections, which covers the ~40,000 in the padded view over Africa at the low end of the fade, so weak fires still stay as markers. To keep that rebuild near its old cost, markers outside the close band read the anchor already built for each fire instead of the ground floor cache: only the close band warms floors, so the lookup there always missed. A 40,000-marker rebuild now costs ~55 ms, down from ~120 ms. --- changelog.d/1426-fire-intensity-filter.md | 2 +- docs/CURRENT-STATE.md | 13 +++++++------ src/data/firmsInteraction.test.mjs | 2 +- src/layers/firms/model.js | 18 ++++++++++++++++-- src/layers/firms/policy.js | 16 ++++++++-------- src/layers/firms/rendering.js | 9 +++++++-- 6 files changed, 40 insertions(+), 20 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index d22e3f843..aacc37d71 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -5,7 +5,7 @@ High confidence switch and an "N of M shown" counter to the layer row. Far zoom shows a Snap Map–style heatmap in the same colors instead of squares, one distinct spot per fire, empty where nothing burns, and cross-fades with - the markers around 3,000 km with no gap, keeping weak fires as markers. The filter applies once before + the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84325e63d..934376fc7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2205,7 +2205,7 @@ none is a cold hue, so the brightest marks are the strongest fires; ~90% of detections are under 20 MW and stay dim red. Glow sprites and fire-card accents read that ramp; confidence and brightness do not affect the color. -At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are linearly binned (each detection's weight shared between the four nearest pixel centres, so spots sit at the true position and stay round) on a @@ -2221,14 +2221,15 @@ nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first far-band paint at once, later ones 150 ms after the last change). Heatmap and markers are both kept -built at every zoom and cross-fade by camera height between 2,400 and -3,600 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +built at every zoom and cross-fade by camera height between 3,600 and +5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe tiles and re-mapped late when shown), the markers by sprite alpha, and the -marker collection is hidden once the heatmap is whole. The 750–3,000 km -markers band draws up to 20,000 detections (the strongest in the padded +marker collection is hidden once the heatmap is whole. The 750–4,500 km +markers band draws up to 40,000 detections (the strongest in the padded view), so the weak fires the heatmap shows stay on screen as markers when it -fades out. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +fades out; it reads each marker's already-built anchor instead of the ground +floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index fddf82f49..8e12a435d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -521,7 +521,7 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke assert.equal(markers.get(0).color.alpha, 1); // Mid hand-off both are drawn, half each, in either band. - setHeight(3_000_000); + setHeight(4_500_000); h.layer._applyHandoffFadeForTest(); assert.equal(added[0].alpha, 0.5); assert.equal(markers.show, true); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index aefb94b21..812f4f462 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -231,6 +231,19 @@ export function createModel({ return fire.position; } + /** + * The anchor already built for a detection, without the floor lookup; + * for the far marker band, where floors are never warmed and the height + * difference is invisible, so tens of thousands of markers skip a cache + * read each per rebuild. Builds one through {@link firePosition} once. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireBuiltPosition(fire) { + return fire.position ?? firePosition(fire); + } + /** * Occlusion-test anchor for one detection. Returns the render position * outright for anchors comfortably above the ellipsoid (the common case) and @@ -238,11 +251,11 @@ export function createModel({ * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} * is what the sprite and the card are drawn at. * @param {Object} fire - Detection record. + * @param {Cesium.Cartesian3} [position] Its render anchor, if at hand. * @returns {Cesium.Cartesian3} */ - function fireCullPosition(fire) { - const position = firePosition(fire); + function fireCullPosition(fire, position = firePosition(fire)) { if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; if (!fire.cullPosition) { fire.cullPosition = Cesium.Cartesian3.fromDegrees( @@ -587,6 +600,7 @@ export function createModel({ sizeBucket, glowSprite, firePosition, + fireBuiltPosition, fireCullPosition, cellCullPosition, applyHorizonCull, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 8431655bd..e6f4bd664 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,7 +7,7 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` * bands render individual fire detections as glow-sprite billboards. @@ -26,21 +26,21 @@ export const LOD_LEVELS = [ }, { id: 'regional', - minHeight: 3000000, + minHeight: 4500000, mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, }, { - // Up to 20,000 markers: at the 2,400 km end of the hand-off the padded - // view over Africa, the densest region, holds ~21,000 detections, and a + // Up to 40,000 markers: at the 3,600 km end of the hand-off the padded + // view over Africa, the densest region, holds ~40,000 detections, and a // tighter cap left the heatmap's weak spots dissolving into nothing. // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 20000, + maxDetections: 40000, labelDistance: 4500000, }, { @@ -55,7 +55,7 @@ export const LOD_LEVELS = [ /** * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a * one-pixel Gaussian σ, so a lone fire is a spot about the size of its - * marker at the 3,000 km hand-off and the two cross-fade without a jump; + * marker near the hand-off and the two cross-fade without a jump; * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared * each fire over ~200 km). Built once per data refresh or filter change, * never per camera move. @@ -69,12 +69,12 @@ export const HEAT_RASTER = Object.freeze({ /** * Camera heights, in metres, over which the heatmap and the markers - * cross-fade around the 3,000 km band edge: markers alone below `from`, + * cross-fade around the 4,500 km band edge: markers alone below `from`, * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so * whichever band is active inside it, both layers are already built. */ -export const HANDOFF_FADE = Object.freeze({ from: 2_400_000, to: 3_600_000 }); +export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 }); /** * Trailing delay before a stale heatmap is rebuilt while one is already on diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index d6e8b2d3b..9718470f8 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -354,10 +354,15 @@ export function createRendering({ } const markers = []; + // Only the close band warms terrain floors (renderDetections). + const anchor = + lod.id === 'close' + ? components.model.firePosition + : components.model.fireBuiltPosition; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); - const position = components.model.firePosition(fire); - const cullPosition = components.model.fireCullPosition(fire); + const position = anchor(fire); + const cullPosition = components.model.fireCullPosition(fire, position); const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); From 37e751f1e33c179520cecb43ed64681ca55c5369 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:06:56 -0300 Subject: [PATCH 30/43] fix(fires): keep retained heat hidden when reenabling an empty filter Add a lifecycle regression and register it in the format scope. --- docs/CURRENT-STATE.md | 2 ++ scripts/format-scope.json | 1 + src/layers/firms/heatVisibility.test.mjs | 44 ++++++++++++++++++++++++ src/layers/firms/lifecycle.js | 3 +- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/layers/firms/heatVisibility.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 66dd7068c..c67967183 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2231,6 +2231,8 @@ view), so the weak fires the heatmap shows stay on screen as markers when it fades out; it reads each marker's already-built anchor instead of the ground floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest areas; their accent is the strongest fire's FRP color. +Re-enabling Active Fires keeps the retained heat imagery hidden when the current +filtered set is empty, including while a new snapshot is pending. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3df4155f2..606314960 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -408,6 +408,7 @@ "src/layers/firms/cards.js", "src/layers/firms/index.js", "src/layers/firms/ingestion.js", + "src/layers/firms/heatVisibility.test.mjs", "src/layers/firms/lifecycle.js", "src/layers/firms/model.js", "src/layers/firms/policy.js", diff --git a/src/layers/firms/heatVisibility.test.mjs b/src/layers/firms/heatVisibility.test.mjs new file mode 100644 index 000000000..4926db156 --- /dev/null +++ b/src/layers/firms/heatVisibility.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLifecycle } from './lifecycle.js'; + +test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => { + let shown = false; + const noop = () => {}; + const layerState = { + _enabled: false, + _viewer: {}, + _dataSource: {}, + _fires: [], + _loading: true, + _pickIndexById: new Map(), + _heatImagery: { + setShow(v) { + shown = v; + }, + }, + }; + const lifecycle = createLifecycle({ + layerState, + services: { + picking: { registerPickOwner: noop, unregisterPickOwner: noop }, + sprites: { restoreSpriteOrderOnEnable: noop }, + context: { clearSelectedEntityContextForLayer: noop }, + }, + config: { id: 'local-firms', overlayHost: { setVisible: noop } }, + components: { + rendering: { applyHandoffFade: noop }, + viewport: { installLodWatcher: noop, installMoveEndWatcher: noop }, + selection: { installClickHandler: noop }, + }, + }); + await lifecycle.methods.enable(layerState._viewer); + assert.equal( + shown, + false, + 'empty current set must keep the old raster hidden while refresh is pending', + ); + layerState._fires = [{ frp: null, confidence: null }]; + await lifecycle.methods.enable(layerState._viewer); + assert.equal(shown, true, 'nonempty sets still resume their raster'); +}); diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 541efca16..90d9cc217 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -54,7 +54,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); - layerState._heatImagery?.setShow(true); + // A filter can leave an old raster cached while the current set is empty. + layerState._heatImagery?.setShow(layerState._fires.length > 0); // The camera can have crossed the hand-off while the layer was off. components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); From 19e85941e63fad99439e77739137666f686bff45 Mon Sep 17 00:00:00 2001 From: Adrian Sbardella Date: Tue, 3 Nov 2026 21:07:29 -0300 Subject: [PATCH 31/43] docs: add changelog entry for empty-filter heat lifecycle fix --- changelog.d/1535-fires-empty-heat-reenable.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1535-fires-empty-heat-reenable.md diff --git a/changelog.d/1535-fires-empty-heat-reenable.md b/changelog.d/1535-fires-empty-heat-reenable.md new file mode 100644 index 000000000..a862a7b56 --- /dev/null +++ b/changelog.d/1535-fires-empty-heat-reenable.md @@ -0,0 +1,2 @@ +- Keep retained Active Fires heat imagery hidden when an empty filtered layer is + re-enabled while a new snapshot is pending (radianx, #1535). From 99831ea19b2ff5b9fff9d40e26a11422e277bb99 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 16:34:39 +0400 Subject: [PATCH 32/43] Keep STALE on Contacts while a poll is in flight A poll that returned the same stale snapshot dropped STALE from the Contacts cohort until the next answer, because the in-flight loading flag outranked stale and the source revision ignored the provider name. A source that has answered now reads its status without the loading flag; one that has never answered (AIS first connect) still reads as loading, so it shows no STALE before any snapshot exists. --- src/layers/awareness/queries.js | 1 + .../awareness/staleAfterLoading.test.mjs | 159 ++++++++++++++++++ src/layers/awareness/subject.js | 8 +- src/layers/vessels/ownership.test.mjs | 2 - src/ui/cockpitContext.test.mjs | 2 - 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/layers/awareness/staleAfterLoading.test.mjs diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 593c85ebf..0c5bd78f3 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) { stats.count ?? null, stats.status || null, stats.error || null, + stats.source || null, ]; }) .map((parts) => parts.join(':')) diff --git a/src/layers/awareness/staleAfterLoading.test.mjs b/src/layers/awareness/staleAfterLoading.test.mjs new file mode 100644 index 000000000..7128f4958 --- /dev/null +++ b/src/layers/awareness/staleAfterLoading.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createQueries } from './queries.js'; +import { createSubject } from './subject.js'; +import { createModel } from './model.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { layerFeedState } from '../../data/feedState.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, + _beginAisSessionForTest, + _applyAisFeedSnapshotForTest, + _setAisRuntimeForTest, +} from '../../data/aisLiveVessels.js'; + +test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => { + globalThis.document ??= { body: null }; + const services = { + installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: { getNearby: () => [] }, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + let states; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + const realQueries = createQueries({ state: layerState, services, parts }); + parts.queries = { ...realQueries, collectSourceStates: () => states }; + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + const flights = (lastUpdate) => ({ + available: true, enabled: true, stale: false, + stats: { source: 'OpenSky Network', count: 4, lastUpdate }, + }); + const vessels = (loading) => ({ + available: true, enabled: true, stale: true, + stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading }, + }); + const rest = { + military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } }, + 'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + + // 1. Stale AIS cache, nothing loading. + states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(true); + assert.equal(ais().sourceStatus, 'stale'); + + // 2. AIS re-poll in flight while the flights poll lands (revision changes). + states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest }; + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale'); + + // 3. AIS poll returns the same stale snapshot; only loading flips back. + states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(); + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too'); +}); + +function fakeRuntime() { + return { + now: () => 1000, + setTimeout: () => ({}), + clearTimeout() {}, + }; +} + +test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => { + globalThis.document ??= { body: null }; + _setAisRuntimeForTest(fakeRuntime()); + _setVesselStateForTest({ viewer: {}, records: [] }); + try { + _beginAisSessionForTest(); + // What server/providers/vessels/ais-live.js answers while its socket connects. + _applyAisFeedSnapshotForTest({}, { + rows: [], source: 'AISStream', status: 'connecting', refreshing: true, + }); + const vesselStats = aisLiveVesselsLayer.getStats(); + assert.equal(vesselStats.loading, true); + assert.equal(vesselStats.stale, true); + assert.equal(vesselStats.lastUpdate, null); + assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING'); + + const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 }; + const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 }; + const installStats = { coverage: { kind: 'subject', radiusM: 100000 } }; + const statsById = { + flights: flightsStats, + military: militaryStats, + 'ais-live-vessels': vesselStats, + 'military-installations': installStats, + }; + const services = { + installations: { getNearby: () => [], getStats: () => installStats }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: aisLiveVesselsLayer, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + dataManager: { + isEnabled: () => true, + getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }), + layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])), + }, + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + parts.queries = createQueries({ state: layerState, services, parts }); + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + + const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels']; + assert.equal(vesselsSource.available, false, 'never-answered source is unavailable'); + + subject.refreshSelectedSubject(true); + const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING'); + } finally { + _setVesselStateForTest({ enabled: false }); + _setAisRuntimeForTest(); + } +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index a555d332e..32123d248 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -54,8 +54,14 @@ export function createSubject({ state: layerState, services, parts, source }) { const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); // Whether the provider answered as a fallback or from a stale snapshot. + // A poll in flight does not change that, and the source revision ignores it. + // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { - const feedState = layerFeedState(sourceState.stats); + const feedState = layerFeedState( + sourceState.available + ? { ...sourceState.stats, loading: false } + : sourceState.stats, + ); return feedState === 'fallback' || feedState === 'stale' ? feedState : null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a838a8099..2404434a0 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -411,8 +411,6 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy }); test('vessel stats name the provider the last snapshot reported', async () => { - // HAL-721: Contacts named a fixed AIS provider while this layer showed the - // one its server reported. const { layer } = setup({ label: 'Vessels', async getSnapshot() { diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index a8d6616d5..c2f20abcf 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -167,8 +167,6 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => }); test('each count names the provider its snapshot reports, with its feed state', async (t) => { - // HAL-721: the counters named fixed providers while the layer and the - // Contacts list named the one that answered. const reported = snapshot(); reported.cohorts[0].source = 'adsb.lol'; reported.cohorts[0].sourceStatus = 'fallback'; From e66b99c8ff7bb8a023cd7f394741097d88cfdaa7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:53:23 +0400 Subject: [PATCH 33/43] Fix the typed AI chat translations The quick commands "Reset to globe view" and "Show earthquakes" reached t() through a .map(), so the catalog check never saw them and every language showed them in English. They are now marked with msg() and translated. Review of the 71 strings from #1507 in all eleven languages: - zh-Hant is regenerated from zh-CN with OpenCC s2tw, as its guide says; 40 entries had Taiwan vocabulary instead. - ar "Dock to the right" names the left side, where RTL puts that rail. - ja "What am I looking at?" no longer asks what the assistant sees. - Provider Settings, dock, provider, live and map use each glossary's terms. - de drops the du-form; ja drops spaces between Japanese and Latin text. - Calques, register and the example commands read naturally. --- src/i18n/locales/ar.json | 42 +++++++++--------- src/i18n/locales/de.json | 64 ++++++++++++++------------- src/i18n/locales/es.json | 30 +++++++------ src/i18n/locales/fr.json | 54 ++++++++++++----------- src/i18n/locales/it.json | 58 +++++++++++++------------ src/i18n/locales/ja.json | 52 +++++++++++----------- src/i18n/locales/pt.json | 36 +++++++-------- src/i18n/locales/ru.json | 36 +++++++-------- src/i18n/locales/uk.json | 40 +++++++++-------- src/i18n/locales/zh-CN.json | 16 ++++--- src/i18n/locales/zh-Hant.json | 82 ++++++++++++++++++----------------- src/i18n/same-as-english.json | 1 + src/voice/chatPreferences.js | 10 ++--- 13 files changed, 272 insertions(+), 249 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index deb68ec36..629bcacab 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3307,15 +3307,17 @@ "Search cancelled": "أُلغي البحث", "Sea Marks": "علامات بحرية", "Ski Pistes": "مسارات التزلج", - "Fly to Tokyo": "الانتقال إلى طوكيو", - "Show live flights": "عرض الرحلات المباشرة", + "Fly to Tokyo": "انتقل إلى طوكيو", + "Show live flights": "اعرض تتبّع الرحلات المباشر", + "Reset to globe view": "ارجع إلى عرض الكرة الأرضية", + "Show earthquakes": "اعرض الزلازل", "AI chat": "دردشة الذكاء الاصطناعي", "GEV CHAT": "دردشة GEV", - "Ask about the map. Tell it where to go.": "اسأل عن الخريطة. أخبرها إلى أين تذهب.", + "Ask about the map. Tell it where to go.": "اسأل عن الخريطة، ووجّهها إلى حيث تريد.", "Close chat": "إغلاق الدردشة", "Conversation": "المحادثة", "Explore with a question": "استكشف بسؤال", - "Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف بيانات مباشرة، أو غيّر العرض.", + "Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف البيانات المباشرة، أو غيّر العرض الحالي.", "Attachments": "المرفقات", "Remove attachment": "إزالة المرفق", "Attach a map layer": "إرفاق طبقة خريطة", @@ -3332,36 +3334,36 @@ "Stop reply": "إيقاف الرد", "Send message": "إرسال الرسالة", "Panel position": "موضع اللوحة", - "Move above the dock": "النقل فوق الشريط", - "Dock to the right": "تثبيت إلى اليمين", + "Move above the dock": "النقل فوق الشريط السفلي", + "Dock to the right": "تثبيت على اليسار", "Expand panel": "توسيع اللوحة", "Minimize panel": "تصغير اللوحة", "You can attach up to four items.": "يمكنك إرفاق أربعة عناصر كحد أقصى.", - "Attachments are too large together. Use smaller files.": "المرفقات كبيرة جدًا معًا. استخدم ملفات أصغر.", + "Attachments are too large together. Use smaller files.": "الحجم الإجمالي للمرفقات كبير جدًا. استخدم ملفات أصغر.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "اختر ملف TXT أو Markdown أو CSV أو JSON أو GeoJSON.", "Each file must be 10 KB or smaller.": "يجب ألا يتجاوز حجم كل ملف 10 كيلوبايت.", - "Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا بترميز UTF-8 غير فارغ.", + "Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا غير فارغ بترميز UTF-8.", "Connecting…": "جارٍ الاتصال…", "Thinking…": "جارٍ التفكير…", "Working on the map…": "جارٍ العمل على الخريطة…", "Silent chat · microphone off": "دردشة صامتة · الميكروفون متوقف", - "Reply interrupted. You can try again.": "توقف الرد. يمكنك المحاولة مرة أخرى.", - "Could not connect. Try again.": "تعذّر الاتصال. حاول مرة أخرى.", + "Reply interrupted. You can try again.": "انقطع الرد. يمكنك إعادة المحاولة.", + "Could not connect. Try again.": "تعذّر الاتصال. أعد المحاولة.", "Session ended. Send a message to start a new conversation.": "انتهت الجلسة. أرسل رسالة لبدء محادثة جديدة.", - "Connection timed out. Try again.": "انتهت مهلة الاتصال. حاول مرة أخرى.", + "Connection timed out. Try again.": "انتهت مهلة الاتصال. أعد المحاولة.", "Chat closed.": "أُغلقت الدردشة.", "Connection stopped.": "توقف الاتصال.", "Message and attachments are too large. Remove an attachment or shorten your message.": "الرسالة والمرفقات كبيرة جدًا. أزل مرفقًا أو اختصر رسالتك.", - "Message was not sent. Try again.": "لم تُرسل الرسالة. حاول مرة أخرى.", + "Message was not sent. Try again.": "لم تُرسل الرسالة. أعد المحاولة.", "Reply stopped.": "أُوقف الرد.", - "Suggestions": "اقتراحات", + "Suggestions": "الاقتراحات", "Recent": "الأخيرة", - "Quick commands": "أوامر سريعة", + "Quick commands": "الأوامر السريعة", "Open AI chat": "فتح دردشة الذكاء الاصطناعي", "CHAT": "دردشة", "Back to chat": "العودة إلى الدردشة", "Back to voice": "العودة إلى الصوت", - "Latest voice exchange": "آخر تبادل صوتي", + "Latest voice exchange": "آخر حوار صوتي", "Try saying": "جرّب أن تقول", "What am I looking at?": "ما الذي أنظر إليه؟", "AI Provider": "مزوّد الذكاء الاصطناعي", @@ -3370,13 +3372,13 @@ "Chat replies are silent. No microphone access is needed.": "ردود الدردشة صامتة. لا حاجة إلى الوصول إلى الميكروفون.", "Chat": "الدردشة", "Attach layer": "إرفاق الطبقة", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة الكتابية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة الكتابية غير متاحة بينما يفرض GEV_PREFER_CODEX_OAUTH استخدام ChatGPT OAuth.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة النصية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة النصية غير متاحة ما دام GEV_PREFER_CODEX_OAUTH يفرض استخدام ChatGPT OAuth.", "That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.", "That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.", - "The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.", - "Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. حاول مجددًا بعد {seconds} ث.", - "The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول لهذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.", + "The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في «إعدادات المزوّدين».", + "Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.", + "The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.", "{lower} to {upper}": "{lower} إلى {upper}", "Up to {upper}": "حتى {upper}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6876199d1..e795801fd 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3021,73 +3021,75 @@ "Ski Pistes": "Skipisten", "Fly to Tokyo": "Nach Tokio fliegen", "Show live flights": "Live-Flüge anzeigen", + "Reset to globe view": "Zur Globusansicht zurückkehren", + "Show earthquakes": "Erdbeben anzeigen", "AI chat": "KI-Chat", "GEV CHAT": "GEV-CHAT", - "Ask about the map. Tell it where to go.": "Frag zur Karte. Sag ihr, wohin es geht.", + "Ask about the map. Tell it where to go.": "Fragen zur Karte stellen und Ziele nennen.", "Close chat": "Chat schließen", - "Conversation": "Unterhaltung", + "Conversation": "Gespräch", "Explore with a question": "Mit einer Frage erkunden", - "Find a place, explore live data, or change your view.": "Finde einen Ort, erkunde Live-Daten oder ändere deine Ansicht.", + "Find a place, explore live data, or change your view.": "Einen Ort finden, Live-Daten erkunden oder die Ansicht ändern.", "Attachments": "Anhänge", "Remove attachment": "Anhang entfernen", "Attach a map layer": "Kartenebene anhängen", "Choose a layer…": "Ebene wählen…", "Attach a file": "Datei anhängen", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit deiner Nachricht an deinen KI-Anbieter gesendet.", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit der Nachricht an den gewählten KI-Anbieter gesendet.", "Latest AI response": "Neueste KI-Antwort", - "Expand conversation": "Unterhaltung erweitern", + "Expand conversation": "Gespräch ausklappen", "Command suggestions": "Befehlsvorschläge", - "Expand chat": "Chat erweitern", + "Expand chat": "Chat ausklappen", "Add attachment": "Anhang hinzufügen", "Message GEV": "Nachricht an GEV", - "Ask GEV anything about the map…": "Frag GEV alles zur Karte…", + "Ask GEV anything about the map…": "GEV etwas zur Karte fragen…", "Stop reply": "Antwort stoppen", "Send message": "Nachricht senden", - "Panel position": "Panelposition", + "Panel position": "Bereichsposition", "Move above the dock": "Über das Dock verschieben", "Dock to the right": "Rechts andocken", - "Expand panel": "Panel erweitern", - "Minimize panel": "Panel minimieren", - "You can attach up to four items.": "Du kannst bis zu vier Elemente anhängen.", - "Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Verwende kleinere Dateien.", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Wähle eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei.", + "Expand panel": "Bereich ausklappen", + "Minimize panel": "Bereich minimieren", + "You can attach up to four items.": "Es können höchstens vier Elemente angehängt werden.", + "Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Kleinere Dateien verwenden.", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei wählen.", "Each file must be 10 KB or smaller.": "Jede Datei darf höchstens 10 KB groß sein.", - "Choose a non-empty UTF-8 text file.": "Wähle eine nicht leere UTF-8-Textdatei.", - "Connecting…": "Verbinde…", + "Choose a non-empty UTF-8 text file.": "Eine nicht leere UTF-8-Textdatei wählen.", + "Connecting…": "Verbindet…", "Thinking…": "Denkt nach…", "Working on the map…": "Arbeitet an der Karte…", - "Silent chat · microphone off": "Stiller Chat · Mikrofon aus", - "Reply interrupted. You can try again.": "Antwort unterbrochen. Du kannst es erneut versuchen.", - "Could not connect. Try again.": "Verbindung fehlgeschlagen. Versuch es erneut.", - "Session ended. Send a message to start a new conversation.": "Sitzung beendet. Sende eine Nachricht, um eine neue Unterhaltung zu beginnen.", - "Connection timed out. Try again.": "Zeitüberschreitung der Verbindung. Versuch es erneut.", + "Silent chat · microphone off": "Chat ohne Ton · Mikrofon aus", + "Reply interrupted. You can try again.": "Antwort unterbrochen. Erneut versuchen.", + "Could not connect. Try again.": "Verbindung fehlgeschlagen. Erneut versuchen.", + "Session ended. Send a message to start a new conversation.": "Sitzung beendet. Für ein neues Gespräch eine Nachricht senden.", + "Connection timed out. Try again.": "Zeitüberschreitung bei der Verbindung. Erneut versuchen.", "Chat closed.": "Chat geschlossen.", "Connection stopped.": "Verbindung beendet.", - "Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Entferne einen Anhang oder kürze deine Nachricht.", - "Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Versuch es erneut.", + "Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Einen Anhang entfernen oder die Nachricht kürzen.", + "Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Erneut versuchen.", "Reply stopped.": "Antwort gestoppt.", "Suggestions": "Vorschläge", - "Recent": "Zuletzt", + "Recent": "Zuletzt verwendet", "Quick commands": "Schnellbefehle", "Open AI chat": "KI-Chat öffnen", "CHAT": "CHAT", "Back to chat": "Zurück zum Chat", "Back to voice": "Zurück zur Sprachsteuerung", - "Latest voice exchange": "Letzter Sprachaustausch", - "Try saying": "Sag zum Beispiel", + "Latest voice exchange": "Letzter Sprachdialog", + "Try saying": "Zum Beispiel", "What am I looking at?": "Was sehe ich gerade?", "AI Provider": "KI-Anbieter", - "The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprache und Chat.", + "The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprachsteuerung und Chat.", "Show quick commands": "Schnellbefehle anzeigen", "Chat replies are silent. No microphone access is needed.": "Chat-Antworten sind stumm. Kein Mikrofonzugriff nötig.", "Chat": "Chat", "Attach layer": "Ebene anhängen", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt deinen OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für Sprache.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt den OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für die Sprachsteuerung.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Der Text-Chat ist nicht verfügbar, solange GEV_PREFER_CODEX_OAUTH ChatGPT OAuth vorschreibt.", "That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.", - "That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Versuche eine einfachere Anfrage.", - "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Prüfe ihn in den Anbieter-Einstellungen.", + "That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Eine einfachere Anfrage versuchen.", + "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.", "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Prüfe Projekt, Region oder Berechtigungen des Schlüssels.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Prüfe die Abrechnung oder wähle in den Einstellungen einen anderen KI-Anbieter." + "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index df11469ab..b49d311b4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3094,11 +3094,13 @@ "Search cancelled": "Búsqueda cancelada", "Sea Marks": "Señales marítimas", "Ski Pistes": "Pistas de esquí", - "Fly to Tokyo": "Volar a Tokio", - "Show live flights": "Mostrar vuelos en vivo", + "Fly to Tokyo": "Vuela a Tokio", + "Show live flights": "Muestra los vuelos en vivo", + "Reset to globe view": "Vuelve a la vista del globo", + "Show earthquakes": "Muestra los terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT DE GEV", - "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.", + "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Dile adónde ir.", "Close chat": "Cerrar chat", "Conversation": "Conversación", "Explore with a question": "Explora con una pregunta", @@ -3109,12 +3111,12 @@ "Choose a layer…": "Elige una capa…", "Attach a file": "Adjuntar un archivo", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · hasta 10 KB en total. Los adjuntos se envían a tu proveedor de IA con el mensaje.", - "Latest AI response": "Última respuesta de IA", + "Latest AI response": "Última respuesta de la IA", "Expand conversation": "Expandir conversación", "Command suggestions": "Sugerencias de comandos", "Expand chat": "Expandir chat", "Add attachment": "Añadir adjunto", - "Message GEV": "Enviar mensaje a GEV", + "Message GEV": "Mensaje para GEV", "Ask GEV anything about the map…": "Pregunta a GEV lo que quieras sobre el mapa…", "Stop reply": "Detener respuesta", "Send message": "Enviar mensaje", @@ -3124,7 +3126,7 @@ "Expand panel": "Expandir panel", "Minimize panel": "Minimizar panel", "You can attach up to four items.": "Puedes adjuntar hasta cuatro elementos.", - "Attachments are too large together. Use smaller files.": "Los adjuntos juntos son demasiado grandes. Usa archivos más pequeños.", + "Attachments are too large together. Use smaller files.": "El tamaño total de los adjuntos es demasiado grande. Usa archivos más pequeños.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Elige un archivo TXT, Markdown, CSV, JSON o GeoJSON.", "Each file must be 10 KB or smaller.": "Cada archivo debe tener como máximo 10 KB.", "Choose a non-empty UTF-8 text file.": "Elige un archivo de texto UTF-8 que no esté vacío.", @@ -3135,7 +3137,7 @@ "Reply interrupted. You can try again.": "Respuesta interrumpida. Puedes intentarlo de nuevo.", "Could not connect. Try again.": "No se pudo conectar. Inténtalo de nuevo.", "Session ended. Send a message to start a new conversation.": "Sesión finalizada. Envía un mensaje para iniciar una nueva conversación.", - "Connection timed out. Try again.": "La conexión agotó el tiempo de espera. Inténtalo de nuevo.", + "Connection timed out. Try again.": "Se agotó el tiempo de conexión. Inténtalo de nuevo.", "Chat closed.": "Chat cerrado.", "Connection stopped.": "Conexión detenida.", "Message and attachments are too large. Remove an attachment or shorten your message.": "El mensaje y los adjuntos son demasiado grandes. Quita un adjunto o acorta el mensaje.", @@ -3147,9 +3149,9 @@ "Open AI chat": "Abrir chat de IA", "CHAT": "CHAT", "Back to chat": "Volver al chat", - "Back to voice": "Volver a voz", + "Back to voice": "Volver a la voz", "Latest voice exchange": "Último intercambio de voz", - "Try saying": "Prueba a decir", + "Try saying": "Intenta decir", "What am I looking at?": "¿Qué estoy viendo?", "AI Provider": "Proveedor de IA", "The selected AI provider is shared by voice and chat.": "El proveedor de IA seleccionado se usa tanto para voz como para chat.", @@ -3157,11 +3159,11 @@ "Chat replies are silent. No microphone access is needed.": "Las respuestas del chat son silenciosas. No se necesita acceso al micrófono.", "Chat": "Chat", "Attach layer": "Adjuntar capa", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de la API de OpenAI. El inicio de sesión de ChatGPT solo cubre la voz.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud necesitó demasiados pasos. Los cambios ya hechos en el mapa se mantienen.", - "That request needed too many steps. Try a simpler request.": "Esa solicitud necesitó demasiados pasos. Prueba una solicitud más sencilla.", - "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes del proveedor.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de API de OpenAI. El inicio de sesión de ChatGPT solo sirve para la voz.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija OAuth de ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud requirió demasiados pasos. Los cambios ya hechos en el mapa se mantienen.", + "That request needed too many steps. Try a simpler request.": "Esa solicitud requirió demasiados pasos. Prueba una solicitud más sencilla.", + "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.", "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes." diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 69d19c9f7..27c0453c4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3096,41 +3096,43 @@ "Ski Pistes": "Pistes de ski", "Fly to Tokyo": "Aller à Tokyo", "Show live flights": "Afficher les vols en direct", + "Reset to globe view": "Revenir à la vue du globe", + "Show earthquakes": "Afficher les séismes", "AI chat": "Discussion IA", "GEV CHAT": "DISCUSSION GEV", "Ask about the map. Tell it where to go.": "Posez une question sur la carte. Dites-lui où aller.", "Close chat": "Fermer la discussion", "Conversation": "Conversation", - "Explore with a question": "Explorer avec une question", + "Explore with a question": "Explorez en posant une question", "Find a place, explore live data, or change your view.": "Trouvez un lieu, explorez des données en direct ou changez de vue.", "Attachments": "Pièces jointes", "Remove attachment": "Retirer la pièce jointe", - "Attach a map layer": "Joindre une couche de carte", + "Attach a map layer": "Joindre une couche de la carte", "Choose a layer…": "Choisir une couche…", "Attach a file": "Joindre un fichier", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d’IA avec votre message.", - "Latest AI response": "Dernière réponse de l’IA", - "Expand conversation": "Agrandir la conversation", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d'IA avec votre message.", + "Latest AI response": "Dernière réponse de l'IA", + "Expand conversation": "Développer la conversation", "Command suggestions": "Suggestions de commandes", - "Expand chat": "Agrandir la discussion", + "Expand chat": "Développer la discussion", "Add attachment": "Ajouter une pièce jointe", "Message GEV": "Message à GEV", - "Ask GEV anything about the map…": "Demandez à GEV ce que vous voulez sur la carte…", + "Ask GEV anything about the map…": "Posez à GEV toutes vos questions sur la carte…", "Stop reply": "Arrêter la réponse", "Send message": "Envoyer le message", "Panel position": "Position du panneau", "Move above the dock": "Placer au-dessus du dock", "Dock to the right": "Ancrer à droite", - "Expand panel": "Agrandir le panneau", + "Expand panel": "Développer le panneau", "Minimize panel": "Réduire le panneau", - "You can attach up to four items.": "Vous pouvez joindre jusqu’à quatre éléments.", - "Attachments are too large together. Use smaller files.": "Les pièces jointes sont trop volumineuses ensemble. Utilisez des fichiers plus petits.", + "You can attach up to four items.": "Vous pouvez joindre jusqu'à quatre éléments.", + "Attachments are too large together. Use smaller files.": "La taille totale des pièces jointes est trop importante. Utilisez des fichiers plus petits.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Choisissez un fichier TXT, Markdown, CSV, JSON ou GeoJSON.", "Each file must be 10 KB or smaller.": "Chaque fichier doit faire 10 Ko ou moins.", "Choose a non-empty UTF-8 text file.": "Choisissez un fichier texte UTF-8 non vide.", "Connecting…": "Connexion…", "Thinking…": "Réflexion…", - "Working on the map…": "Travail sur la carte…", + "Working on the map…": "Mise à jour de la carte…", "Silent chat · microphone off": "Discussion silencieuse · micro coupé", "Reply interrupted. You can try again.": "Réponse interrompue. Vous pouvez réessayer.", "Could not connect. Try again.": "Connexion impossible. Réessayez.", @@ -3139,30 +3141,30 @@ "Chat closed.": "Discussion fermée.", "Connection stopped.": "Connexion arrêtée.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Le message et les pièces jointes sont trop volumineux. Retirez une pièce jointe ou raccourcissez votre message.", - "Message was not sent. Try again.": "Le message n’a pas été envoyé. Réessayez.", + "Message was not sent. Try again.": "Le message n'a pas été envoyé. Réessayez.", "Reply stopped.": "Réponse arrêtée.", "Suggestions": "Suggestions", "Recent": "Récents", "Quick commands": "Commandes rapides", "Open AI chat": "Ouvrir la discussion IA", - "CHAT": "DISCUSSION", + "CHAT": "CHAT", "Back to chat": "Retour à la discussion", - "Back to voice": "Retour à la voix", + "Back to voice": "Retour au mode vocal", "Latest voice exchange": "Dernier échange vocal", "Try saying": "Essayez de dire", - "What am I looking at?": "Qu’est-ce que je regarde ?", - "AI Provider": "Fournisseur d’IA", - "The selected AI provider is shared by voice and chat.": "Le fournisseur d’IA choisi est partagé par la voix et la discussion.", + "What am I looking at?": "Qu'est-ce que je regarde ?", + "AI Provider": "Fournisseur d'IA", + "The selected AI provider is shared by voice and chat.": "Le fournisseur d'IA choisi est commun à la voix et à la discussion.", "Show quick commands": "Afficher les commandes rapides", - "Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au micro n’est nécessaire.", + "Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au microphone n'est nécessaire.", "Chat": "Discussion", "Attach layer": "Joindre la couche", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Le chat écrit utilise votre clé d’API OpenAI. La connexion ChatGPT ne couvre que la voix.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Le chat écrit est indisponible tant que GEV_PREFER_CODEX_OAUTH impose ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d’étapes. Les modifications déjà apportées à la carte sont conservées.", - "That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d’étapes. Essayez une demande plus simple.", - "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé d’API. Vérifiez-la dans les paramètres du fournisseur.", - "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de chat. Réessayez dans {seconds} s.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l’accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n’a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d’IA dans les paramètres." + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La discussion écrite utilise votre clé API OpenAI. La connexion ChatGPT ne couvre que la voix.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La discussion écrite est indisponible tant que GEV_PREFER_CODEX_OAUTH impose l'OAuth ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d'étapes. Les modifications déjà apportées à la carte sont conservées.", + "That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d'étapes. Essayez une demande plus simple.", + "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.", + "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", + "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f33721cbb..a089ba758 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3095,47 +3095,49 @@ "Sea Marks": "Segnalamenti marittimi", "Ski Pistes": "Piste da sci", "Fly to Tokyo": "Vola a Tokyo", - "Show live flights": "Mostra voli in diretta", + "Show live flights": "Mostra i voli live", + "Reset to globe view": "Ripristina la vista del globo", + "Show earthquakes": "Mostra i terremoti", "AI chat": "Chat IA", "GEV CHAT": "CHAT GEV", - "Ask about the map. Tell it where to go.": "Chiedi della mappa. Dille dove andare.", - "Close chat": "Chiudi chat", + "Ask about the map. Tell it where to go.": "Fai domande sulla mappa. Digli dove andare.", + "Close chat": "Chiudi la chat", "Conversation": "Conversazione", "Explore with a question": "Esplora con una domanda", - "Find a place, explore live data, or change your view.": "Trova un luogo, esplora dati in diretta o cambia vista.", + "Find a place, explore live data, or change your view.": "Trova un luogo, esplora i dati live o cambia vista.", "Attachments": "Allegati", - "Remove attachment": "Rimuovi allegato", + "Remove attachment": "Rimuovi l'allegato", "Attach a map layer": "Allega un livello della mappa", "Choose a layer…": "Scegli un livello…", "Attach a file": "Allega un file", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo fornitore di IA insieme al messaggio.", - "Latest AI response": "Ultima risposta dell’IA", - "Expand conversation": "Espandi conversazione", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo provider IA insieme al messaggio.", + "Latest AI response": "Ultima risposta dell'IA", + "Expand conversation": "Espandi la conversazione", "Command suggestions": "Suggerimenti di comandi", - "Expand chat": "Espandi chat", + "Expand chat": "Espandi la chat", "Add attachment": "Aggiungi allegato", "Message GEV": "Messaggio a GEV", "Ask GEV anything about the map…": "Chiedi a GEV qualsiasi cosa sulla mappa…", - "Stop reply": "Interrompi risposta", - "Send message": "Invia messaggio", + "Stop reply": "Interrompi la risposta", + "Send message": "Invia il messaggio", "Panel position": "Posizione del pannello", "Move above the dock": "Sposta sopra il dock", "Dock to the right": "Aggancia a destra", - "Expand panel": "Espandi pannello", - "Minimize panel": "Riduci pannello", + "Expand panel": "Espandi il pannello", + "Minimize panel": "Riduci il pannello", "You can attach up to four items.": "Puoi allegare fino a quattro elementi.", - "Attachments are too large together. Use smaller files.": "Gli allegati insieme sono troppo grandi. Usa file più piccoli.", + "Attachments are too large together. Use smaller files.": "Nel complesso gli allegati sono troppo grandi. Usa file più piccoli.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Scegli un file TXT, Markdown, CSV, JSON o GeoJSON.", - "Each file must be 10 KB or smaller.": "Ogni file deve essere di 10 KB o meno.", + "Each file must be 10 KB or smaller.": "Ogni file non deve superare i 10 KB.", "Choose a non-empty UTF-8 text file.": "Scegli un file di testo UTF-8 non vuoto.", "Connecting…": "Connessione…", "Thinking…": "Sto pensando…", - "Working on the map…": "Lavoro sulla mappa…", + "Working on the map…": "Sto lavorando sulla mappa…", "Silent chat · microphone off": "Chat silenziosa · microfono spento", "Reply interrupted. You can try again.": "Risposta interrotta. Puoi riprovare.", "Could not connect. Try again.": "Impossibile connettersi. Riprova.", "Session ended. Send a message to start a new conversation.": "Sessione terminata. Invia un messaggio per iniziare una nuova conversazione.", - "Connection timed out. Try again.": "Connessione scaduta. Riprova.", + "Connection timed out. Try again.": "La connessione è andata in timeout. Riprova.", "Chat closed.": "Chat chiusa.", "Connection stopped.": "Connessione interrotta.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Messaggio e allegati sono troppo grandi. Rimuovi un allegato o accorcia il messaggio.", @@ -3144,25 +3146,25 @@ "Suggestions": "Suggerimenti", "Recent": "Recenti", "Quick commands": "Comandi rapidi", - "Open AI chat": "Apri chat IA", + "Open AI chat": "Apri la chat IA", "CHAT": "CHAT", "Back to chat": "Torna alla chat", "Back to voice": "Torna alla voce", "Latest voice exchange": "Ultimo scambio vocale", "Try saying": "Prova a dire", "What am I looking at?": "Cosa sto guardando?", - "AI Provider": "Fornitore di IA", - "The selected AI provider is shared by voice and chat.": "Il fornitore di IA scelto è condiviso da voce e chat.", - "Show quick commands": "Mostra comandi rapidi", - "Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l’accesso al microfono.", + "AI Provider": "Provider IA", + "The selected AI provider is shared by voice and chat.": "Il provider IA selezionato è condiviso tra voce e chat.", + "Show quick commands": "Mostra i comandi rapidi", + "Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l'accesso al microfono.", "Chat": "Chat", "Attach layer": "Allega livello", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L’accesso a ChatGPT copre solo la voce.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L'accesso a ChatGPT vale solo per la voce.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La chat scritta non è disponibile finché GEV_PREFER_CODEX_OAUTH richiede ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Questa richiesta ha richiesto troppi passaggi. Le modifiche già fatte alla mappa restano.", - "That request needed too many steps. Try a simpler request.": "Questa richiesta ha richiesto troppi passaggi. Prova una richiesta più semplice.", - "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala nelle impostazioni del provider.", + "That request needed too many steps. Changes already made to the map stay in place.": "Per questa richiesta sono serviti troppi passaggi. Le modifiche già apportate alla mappa restano.", + "That request needed too many steps. Try a simpler request.": "Per questa richiesta sono serviti troppi passaggi. Prova con una richiesta più semplice.", + "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.", "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l’accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L’account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA nelle impostazioni." + "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e47708830..1536306f1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2944,28 +2944,30 @@ "Search cancelled": "検索をキャンセルしました", "Sea Marks": "航路標識", "Ski Pistes": "スキーゲレンデ", - "Fly to Tokyo": "東京へ移動", - "Show live flights": "ライブのフライトを表示", + "Fly to Tokyo": "東京へ移動して", + "Show live flights": "ライブフライトを表示して", + "Reset to globe view": "地球全体ビューに戻して", + "Show earthquakes": "地震を表示して", "AI chat": "AIチャット", - "GEV CHAT": "GEV チャット", + "GEV CHAT": "GEVチャット", "Ask about the map. Tell it where to go.": "地図について質問できます。行き先を伝えてください。", "Close chat": "チャットを閉じる", "Conversation": "会話", "Explore with a question": "質問から探索", "Find a place, explore live data, or change your view.": "場所を探したり、ライブデータを見たり、表示を変えたりできます。", - "Attachments": "添付ファイル", + "Attachments": "添付", "Remove attachment": "添付を削除", "Attach a map layer": "地図レイヤーを添付", "Choose a layer…": "レイヤーを選択…", "Attach a file": "ファイルを添付", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計 10 KB まで。添付はメッセージとともに AI プロバイダーへ送信されます。", - "Latest AI response": "最新の AI の応答", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計10 KBまで。添付はメッセージとともにAIプロバイダーへ送信されます。", + "Latest AI response": "AIの最新の応答", "Expand conversation": "会話を展開", "Command suggestions": "コマンドの候補", "Expand chat": "チャットを展開", "Add attachment": "添付を追加", - "Message GEV": "GEV へのメッセージ", - "Ask GEV anything about the map…": "地図について GEV に何でも質問…", + "Message GEV": "GEVへのメッセージ", + "Ask GEV anything about the map…": "地図についてGEVに何でも質問…", "Stop reply": "応答を停止", "Send message": "メッセージを送信", "Panel position": "パネルの位置", @@ -2973,15 +2975,15 @@ "Dock to the right": "右側にドッキング", "Expand panel": "パネルを展開", "Minimize panel": "パネルを最小化", - "You can attach up to four items.": "添付できるのは 4 件までです。", - "Attachments are too large together. Use smaller files.": "添付ファイルの合計が大きすぎます。小さいファイルを使ってください。", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSON のファイルを選んでください。", - "Each file must be 10 KB or smaller.": "各ファイルは 10 KB 以下にしてください。", - "Choose a non-empty UTF-8 text file.": "空でない UTF-8 のテキストファイルを選んでください。", + "You can attach up to four items.": "添付できるのは4件までです。", + "Attachments are too large together. Use smaller files.": "添付の合計サイズが大きすぎます。小さいファイルを使ってください。", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSONのいずれかのファイルを選んでください。", + "Each file must be 10 KB or smaller.": "各ファイルは10 KB以下にしてください。", + "Choose a non-empty UTF-8 text file.": "空でないUTF-8のテキストファイルを選んでください。", "Connecting…": "接続中…", "Thinking…": "考え中…", "Working on the map…": "地図を操作中…", - "Silent chat · microphone off": "サイレントチャット · マイクオフ", + "Silent chat · microphone off": "テキストチャット · マイク オフ", "Reply interrupted. You can try again.": "応答が中断されました。もう一度お試しください。", "Could not connect. Try again.": "接続できませんでした。もう一度お試しください。", "Session ended. Send a message to start a new conversation.": "セッションが終了しました。メッセージを送ると新しい会話を始められます。", @@ -2992,7 +2994,7 @@ "Message was not sent. Try again.": "メッセージを送信できませんでした。もう一度お試しください。", "Reply stopped.": "応答を停止しました。", "Suggestions": "候補", - "Recent": "最近", + "Recent": "履歴", "Quick commands": "クイックコマンド", "Open AI chat": "AIチャットを開く", "CHAT": "チャット", @@ -3000,19 +3002,19 @@ "Back to voice": "音声に戻る", "Latest voice exchange": "直近の音声のやり取り", "Try saying": "話しかけてみましょう", - "What am I looking at?": "何を見ていますか?", - "AI Provider": "AI プロバイダー", - "The selected AI provider is shared by voice and chat.": "選んだ AI プロバイダーは音声とチャットで共通です。", + "What am I looking at?": "いま見えているのは何?", + "AI Provider": "AIプロバイダー", + "The selected AI provider is shared by voice and chat.": "選んだAIプロバイダーは音声とチャットで共通です。", "Show quick commands": "クイックコマンドを表示", - "Chat replies are silent. No microphone access is needed.": "チャットの応答は音声なしです。マイクへのアクセスは不要です。", + "Chat replies are silent. No microphone access is needed.": "チャットの応答は読み上げられません。マイクへのアクセスは不要です。", "Chat": "チャット", "Attach layer": "レイヤーを添付", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットは OpenAI API キーを使用します。ChatGPT のサインインは音声のみが対象です。", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH が ChatGPT OAuth を必須にしている間、テキストチャットは使えません。", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットはOpenAI APIキーを使用します。ChatGPTのサインインは音声のみが対象です。", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTHがChatGPT OAuthを必須にしている間、テキストチャットは使えません。", "That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。", "That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。", - "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーが API キーを拒否しました。プロバイダー設定で確認してください。", - "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds} 秒後にもう一度お試しください。", - "The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーへのアクセスを拒否しました。キーのプロジェクト、地域、権限を確認してください。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーのアカウントのクレジットが不足しています。請求を確認するか、設定で別の AI プロバイダーを選んでください。" + "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。", + "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。", + "The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 23c44f786..aa57b24a4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3094,15 +3094,17 @@ "Search cancelled": "Busca cancelada", "Sea Marks": "Sinais marítimos", "Ski Pistes": "Pistas de esqui", - "Fly to Tokyo": "Voar para Tóquio", - "Show live flights": "Mostrar voos ao vivo", + "Fly to Tokyo": "Voe para Tóquio", + "Show live flights": "Mostre os voos ao vivo", + "Reset to globe view": "Volte à vista do globo", + "Show earthquakes": "Mostre os terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT GEV", "Ask about the map. Tell it where to go.": "Pergunte sobre o mapa. Diga para onde ir.", "Close chat": "Fechar chat", "Conversation": "Conversa", "Explore with a question": "Explore com uma pergunta", - "Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude sua visão.", + "Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude a vista do mapa.", "Attachments": "Anexos", "Remove attachment": "Remover anexo", "Attach a map layer": "Anexar uma camada do mapa", @@ -3119,8 +3121,8 @@ "Stop reply": "Parar resposta", "Send message": "Enviar mensagem", "Panel position": "Posição do painel", - "Move above the dock": "Mover para cima do dock", - "Dock to the right": "Encaixar à direita", + "Move above the dock": "Posicionar acima do dock", + "Dock to the right": "Acoplar à direita", "Expand panel": "Expandir painel", "Minimize panel": "Minimizar painel", "You can attach up to four items.": "Você pode anexar até quatro itens.", @@ -3132,14 +3134,14 @@ "Thinking…": "Pensando…", "Working on the map…": "Trabalhando no mapa…", "Silent chat · microphone off": "Chat silencioso · microfone desligado", - "Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar de novo.", - "Could not connect. Try again.": "Não foi possível conectar. Tente de novo.", + "Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar novamente.", + "Could not connect. Try again.": "Não foi possível conectar. Tente novamente.", "Session ended. Send a message to start a new conversation.": "Sessão encerrada. Envie uma mensagem para iniciar uma nova conversa.", - "Connection timed out. Try again.": "A conexão expirou. Tente de novo.", + "Connection timed out. Try again.": "A conexão expirou. Tente novamente.", "Chat closed.": "Chat fechado.", "Connection stopped.": "Conexão interrompida.", "Message and attachments are too large. Remove an attachment or shorten your message.": "A mensagem e os anexos são grandes demais. Remova um anexo ou encurte sua mensagem.", - "Message was not sent. Try again.": "A mensagem não foi enviada. Tente de novo.", + "Message was not sent. Try again.": "A mensagem não foi enviada. Tente novamente.", "Reply stopped.": "Resposta interrompida.", "Suggestions": "Sugestões", "Recent": "Recentes", @@ -3148,21 +3150,21 @@ "CHAT": "CHAT", "Back to chat": "Voltar ao chat", "Back to voice": "Voltar à voz", - "Latest voice exchange": "Última troca de voz", + "Latest voice exchange": "Última interação por voz", "Try saying": "Experimente dizer", "What am I looking at?": "O que estou vendo?", "AI Provider": "Provedor de IA", - "The selected AI provider is shared by voice and chat.": "O provedor de IA escolhido é compartilhado por voz e chat.", + "The selected AI provider is shared by voice and chat.": "O provedor de IA selecionado é usado tanto pela voz quanto pelo chat.", "Show quick commands": "Mostrar comandos rápidos", "Chat replies are silent. No microphone access is needed.": "As respostas do chat são silenciosas. Não é preciso acesso ao microfone.", "Chat": "Chat", "Attach layer": "Anexar camada", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave da API da OpenAI. O login do ChatGPT cobre apenas a voz.", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa continuam.", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave de API da OpenAI. O login do ChatGPT vale apenas para a voz.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o OAuth do ChatGPT.", + "That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa são mantidas.", "That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.", - "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a nas configurações do provedor.", - "Too many chat requests. Try again in {seconds} s.": "Pedidos de chat demais. Tente de novo em {seconds} s.", + "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".", + "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA nas configurações." + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b364d3bf1..35f14ec0d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3169,14 +3169,16 @@ "Search cancelled": "Поиск отменён", "Sea Marks": "Морские знаки", "Ski Pistes": "Горнолыжные трассы", - "Fly to Tokyo": "Перелететь в Токио", - "Show live flights": "Показать рейсы в реальном времени", + "Fly to Tokyo": "Перелети в Токио", + "Show live flights": "Покажи рейсы в реальном времени", + "Reset to globe view": "Вернись к виду всего глобуса", + "Show earthquakes": "Покажи землетрясения", "AI chat": "Чат с ИИ", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Укажите, куда перейти.", + "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Скажите, куда лететь.", "Close chat": "Закрыть чат", "Conversation": "Разговор", - "Explore with a question": "Начните исследование с вопроса", + "Explore with a question": "Начните с вопроса", "Find a place, explore live data, or change your view.": "Найдите место, изучите данные в реальном времени или измените вид.", "Attachments": "Вложения", "Remove attachment": "Удалить вложение", @@ -3185,7 +3187,7 @@ "Attach a file": "Прикрепить файл", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON или GeoJSON · до 10 КБ в сумме. Вложения отправляются провайдеру ИИ вместе с сообщением.", "Latest AI response": "Последний ответ ИИ", - "Expand conversation": "Развернуть разговор", + "Expand conversation": "Развернуть чат", "Command suggestions": "Подсказки команд", "Expand chat": "Развернуть чат", "Add attachment": "Добавить вложение", @@ -3194,7 +3196,7 @@ "Stop reply": "Остановить ответ", "Send message": "Отправить сообщение", "Panel position": "Положение панели", - "Move above the dock": "Переместить над панелью", + "Move above the dock": "Переместить над доком", "Dock to the right": "Закрепить справа", "Expand panel": "Развернуть панель", "Minimize panel": "Свернуть панель", @@ -3204,15 +3206,15 @@ "Each file must be 10 KB or smaller.": "Размер каждого файла не должен превышать 10 КБ.", "Choose a non-empty UTF-8 text file.": "Выберите непустой текстовый файл UTF-8.", "Connecting…": "Подключение…", - "Thinking…": "Обдумывание…", - "Working on the map…": "Работа с картой…", + "Thinking…": "Думаю…", + "Working on the map…": "Работаю с картой…", "Silent chat · microphone off": "Текстовый чат · микрофон выключен", "Reply interrupted. You can try again.": "Ответ прерван. Попробуйте ещё раз.", "Could not connect. Try again.": "Не удалось подключиться. Попробуйте ещё раз.", - "Session ended. Send a message to start a new conversation.": "Сеанс завершён. Отправьте сообщение, чтобы начать новый разговор.", + "Session ended. Send a message to start a new conversation.": "Сессия завершена. Отправьте сообщение, чтобы начать новый разговор.", "Connection timed out. Try again.": "Время ожидания подключения истекло. Попробуйте ещё раз.", "Chat closed.": "Чат закрыт.", - "Connection stopped.": "Соединение остановлено.", + "Connection stopped.": "Соединение прервано.", "Message and attachments are too large. Remove an attachment or shorten your message.": "Сообщение и вложения слишком велики. Удалите вложение или сократите сообщение.", "Message was not sent. Try again.": "Сообщение не отправлено. Попробуйте ещё раз.", "Reply stopped.": "Ответ остановлен.", @@ -3221,9 +3223,9 @@ "Quick commands": "Быстрые команды", "Open AI chat": "Открыть чат с ИИ", "CHAT": "ЧАТ", - "Back to chat": "Вернуться в чат", + "Back to chat": "Назад в чат", "Back to voice": "Назад к голосу", - "Latest voice exchange": "Последний голосовой обмен", + "Latest voice exchange": "Последние реплики голосового диалога", "Try saying": "Попробуйте сказать", "What am I looking at?": "Что я вижу?", "AI Provider": "Провайдер ИИ", @@ -3234,10 +3236,10 @@ "Attach layer": "Прикрепить слой", "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовый чат использует ваш ключ API OpenAI. Вход в ChatGPT действует только для голоса.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовый чат недоступен, пока GEV_PREFER_CODEX_OAUTH требует ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Уже внесённые изменения карты сохранятся.", + "That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Изменения, уже внесённые на карту, остаются.", "That request needed too many steps. Try a simpler request.": "Этот запрос потребовал слишком много шагов. Попробуйте запрос попроще.", - "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в настройках провайдера.", - "Too many chat requests. Try again in {seconds} s.": "Слишком много запросов чата. Повторите через {seconds} с.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер запретил доступ для этого ключа. Проверьте проект, регион или разрешения ключа.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На счёте провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." + "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».", + "Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.", + "The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер отказал этому ключу в доступе. Проверьте проект, регион или разрешения ключа.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d01a6af13..9e76044eb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3169,18 +3169,20 @@ "Search cancelled": "Пошук скасовано", "Sea Marks": "Морські знаки", "Ski Pistes": "Гірськолижні траси", - "Fly to Tokyo": "Летіти до Токіо", - "Show live flights": "Показати рейси наживо", + "Fly to Tokyo": "Полети до Токіо", + "Show live flights": "Покажи рейси онлайн", + "Reset to globe view": "Поверни вигляд глобуса", + "Show earthquakes": "Покажи землетруси", "AI chat": "ШІ-чат", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Питайте про мапу. Скажіть, куди перейти.", + "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди летіти.", "Close chat": "Закрити чат", "Conversation": "Розмова", - "Explore with a question": "Досліджуйте за допомогою запитання", - "Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані наживо або змініть вигляд.", + "Explore with a question": "Почніть із запитання", + "Find a place, explore live data, or change your view.": "Знайдіть місце, перегляньте дані в реальному часі або змініть вигляд.", "Attachments": "Вкладення", - "Remove attachment": "Вилучити вкладення", - "Attach a map layer": "Прикріпити шар мапи", + "Remove attachment": "Видалити вкладення", + "Attach a map layer": "Прикріпити шар карти", "Choose a layer…": "Виберіть шар…", "Attach a file": "Прикріпити файл", "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON або GeoJSON · до 10 КБ загалом. Вкладення надсилаються вашому постачальнику ШІ разом із повідомленням.", @@ -3190,30 +3192,30 @@ "Expand chat": "Розгорнути чат", "Add attachment": "Додати вкладення", "Message GEV": "Повідомлення для GEV", - "Ask GEV anything about the map…": "Запитайте GEV будь-що про мапу…", + "Ask GEV anything about the map…": "Запитайте GEV будь-що про карту…", "Stop reply": "Зупинити відповідь", "Send message": "Надіслати повідомлення", - "Panel position": "Положення панелі", + "Panel position": "Розташування панелі", "Move above the dock": "Перемістити над доком", "Dock to the right": "Закріпити праворуч", "Expand panel": "Розгорнути панель", "Minimize panel": "Згорнути панель", "You can attach up to four items.": "Можна прикріпити до чотирьох елементів.", - "Attachments are too large together. Use smaller files.": "Вкладення разом завеликі. Використайте менші файли.", + "Attachments are too large together. Use smaller files.": "Загальний розмір вкладень завеликий. Використайте менші файли.", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Виберіть файл TXT, Markdown, CSV, JSON або GeoJSON.", "Each file must be 10 KB or smaller.": "Кожен файл має бути не більшим за 10 КБ.", "Choose a non-empty UTF-8 text file.": "Виберіть непорожній текстовий файл UTF-8.", "Connecting…": "Підключення…", "Thinking…": "Думаю…", - "Working on the map…": "Працюю з мапою…", - "Silent chat · microphone off": "Тихий чат · мікрофон вимкнено", + "Working on the map…": "Працюю з картою…", + "Silent chat · microphone off": "Беззвучний чат · мікрофон вимкнено", "Reply interrupted. You can try again.": "Відповідь перервано. Можна спробувати ще раз.", "Could not connect. Try again.": "Не вдалося підключитися. Спробуйте ще раз.", "Session ended. Send a message to start a new conversation.": "Сеанс завершено. Надішліть повідомлення, щоб почати нову розмову.", - "Connection timed out. Try again.": "Час підключення вичерпано. Спробуйте ще раз.", + "Connection timed out. Try again.": "Минув час очікування підключення. Спробуйте ще раз.", "Chat closed.": "Чат закрито.", "Connection stopped.": "Підключення зупинено.", - "Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Вилучіть вкладення або скоротіть повідомлення.", + "Message and attachments are too large. Remove an attachment or shorten your message.": "Повідомлення й вкладення завеликі. Видаліть вкладення або скоротіть повідомлення.", "Message was not sent. Try again.": "Повідомлення не надіслано. Спробуйте ще раз.", "Reply stopped.": "Відповідь зупинено.", "Suggestions": "Підказки", @@ -3223,7 +3225,7 @@ "CHAT": "ЧАТ", "Back to chat": "Назад до чату", "Back to voice": "Назад до голосу", - "Latest voice exchange": "Останній голосовий обмін", + "Latest voice exchange": "Остання голосова репліка й відповідь", "Try saying": "Спробуйте сказати", "What am I looking at?": "Що я бачу?", "AI Provider": "Постачальник ШІ", @@ -3234,10 +3236,10 @@ "Attach layer": "Прикріпити шар", "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Текстовий чат використовує ваш ключ API OpenAI. Вхід у ChatGPT діє лише для голосу.", "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Текстовий чат недоступний, поки GEV_PREFER_CODEX_OAUTH вимагає ChatGPT OAuth.", - "That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на мапі залишаються.", + "That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на карті залишаються.", "That request needed too many steps. Try a simpler request.": "Цей запит потребував забагато кроків. Спробуйте простіший запит.", - "The provider rejected the API key. Check it in Provider Settings.": "Провайдер відхилив ключ API. Перевірте його в налаштуваннях провайдера.", + "The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».", "Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.", - "The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер заборонив доступ для цього ключа. Перевірте проєкт, регіон або дозволи ключа.", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На рахунку провайдера закінчилися кошти. Перевірте оплату або виберіть іншого провайдера ШІ в налаштуваннях." + "The provider refused access for this key. Check the key's project, region or permissions.": "Постачальник відмовив цьому ключу в доступі. Перевірте проєкт, регіон або дозволи ключа.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 07b1083ab..f583c201a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2946,6 +2946,8 @@ "Ski Pistes": "滑雪道", "Fly to Tokyo": "飞往东京", "Show live flights": "显示实时航班", + "Reset to globe view": "重置为地球视图", + "Show earthquakes": "显示地震", "AI chat": "AI 聊天", "GEV CHAT": "GEV 聊天", "Ask about the map. Tell it where to go.": "询问地图信息,或指定前往的位置。", @@ -2958,7 +2960,7 @@ "Attach a map layer": "附加地图图层", "Choose a layer…": "选择图层…", "Attach a file": "附加文件", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给您的 AI 服务商。", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 总计最多 10 KB。附件会随消息发送给你的 AI 服务商。", "Latest AI response": "最新 AI 回复", "Expand conversation": "展开对话", "Command suggestions": "命令建议", @@ -2969,18 +2971,18 @@ "Stop reply": "停止回复", "Send message": "发送消息", "Panel position": "面板位置", - "Move above the dock": "移到底栏上方", + "Move above the dock": "移到停靠栏上方", "Dock to the right": "停靠到右侧", "Expand panel": "展开面板", "Minimize panel": "最小化面板", "You can attach up to four items.": "最多可添加四个附件。", - "Attachments are too large together. Use smaller files.": "附件总大小过大。请使用较小的文件。", + "Attachments are too large together. Use smaller files.": "附件总大小超出上限。请使用较小的文件。", "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "请选择 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。", "Each file must be 10 KB or smaller.": "每个文件不得超过 10 KB。", "Choose a non-empty UTF-8 text file.": "请选择非空的 UTF-8 文本文件。", "Connecting…": "正在连接…", "Thinking…": "正在思考…", - "Working on the map…": "正在处理地图…", + "Working on the map…": "正在操作地图…", "Silent chat · microphone off": "静默聊天 · 麦克风已关闭", "Reply interrupted. You can try again.": "回复已中断。请重试。", "Could not connect. Try again.": "无法连接。请重试。", @@ -3011,8 +3013,8 @@ "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 时,文字聊天不可用。", "That request needed too many steps. Changes already made to the map stay in place.": "此请求所需步骤过多。已对地图做出的更改会保留。", "That request needed too many steps. Try a simpler request.": "此请求所需步骤过多。请尝试更简单的请求。", - "The provider rejected the API key. Check it in Provider Settings.": "提供商拒绝了 API 密钥。请在提供商设置中检查。", + "The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。", "Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。", - "The provider refused access for this key. Check the key's project, region or permissions.": "提供商拒绝了此密钥的访问。请检查该密钥的项目、地区或权限。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "提供商账户余额不足。请检查账单,或在设置中选择其他 AI 提供商。" + "The provider refused access for this key. Check the key's project, region or permissions.": "服务商拒绝了此密钥的访问请求。请检查该密钥所属的项目、区域或权限。", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c13b7d39c..77045451d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2945,74 +2945,76 @@ "Sea Marks": "航標", "Ski Pistes": "滑雪道", "Fly to Tokyo": "飛往東京", - "Show live flights": "顯示即時航班", + "Show live flights": "顯示實時航班", + "Reset to globe view": "重置為地球視圖", + "Show earthquakes": "顯示地震", "AI chat": "AI 聊天", "GEV CHAT": "GEV 聊天", - "Ask about the map. Tell it where to go.": "詢問地圖相關問題,告訴它要去哪裡。", + "Ask about the map. Tell it where to go.": "詢問地圖信息,或指定前往的位置。", "Close chat": "關閉聊天", "Conversation": "對話", - "Explore with a question": "用一個問題開始探索", - "Find a place, explore live data, or change your view.": "尋找地點、探索即時資料,或變更檢視。", + "Explore with a question": "從提問開始探索", + "Find a place, explore live data, or change your view.": "查找地點、探索實時數據或更改視圖。", "Attachments": "附件", "Remove attachment": "移除附件", "Attach a map layer": "附加地圖圖層", "Choose a layer…": "選擇圖層…", - "Attach a file": "附加檔案", - "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨訊息傳送給你的 AI 供應商。", + "Attach a file": "附加文件", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON 或 GeoJSON · 總計最多 10 KB。附件會隨消息發送給你的 AI 服務商。", "Latest AI response": "最新 AI 回覆", "Expand conversation": "展開對話", - "Command suggestions": "指令建議", + "Command suggestions": "命令建議", "Expand chat": "展開聊天", - "Add attachment": "新增附件", - "Message GEV": "傳訊息給 GEV", - "Ask GEV anything about the map…": "問 GEV 任何關於地圖的問題…", + "Add attachment": "添加附件", + "Message GEV": "向 GEV 發送消息", + "Ask GEV anything about the map…": "向 GEV 詢問地圖相關問題…", "Stop reply": "停止回覆", - "Send message": "傳送訊息", + "Send message": "發送消息", "Panel position": "面板位置", - "Move above the dock": "移到底欄上方", + "Move above the dock": "移到停靠欄上方", "Dock to the right": "停靠到右側", "Expand panel": "展開面板", "Minimize panel": "最小化面板", - "You can attach up to four items.": "最多可附加四個項目。", - "Attachments are too large together. Use smaller files.": "附件合計太大,請使用較小的檔案。", - "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 檔案。", - "Each file must be 10 KB or smaller.": "每個檔案不得超過 10 KB。", - "Choose a non-empty UTF-8 text file.": "請選擇非空白的 UTF-8 文字檔。", - "Connecting…": "連線中…", - "Thinking…": "思考中…", + "You can attach up to four items.": "最多可添加四個附件。", + "Attachments are too large together. Use smaller files.": "附件總大小超出上限。請使用較小的文件。", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "請選擇 TXT、Markdown、CSV、JSON 或 GeoJSON 文件。", + "Each file must be 10 KB or smaller.": "每個文件不得超過 10 KB。", + "Choose a non-empty UTF-8 text file.": "請選擇非空的 UTF-8 文本文件。", + "Connecting…": "正在連接…", + "Thinking…": "正在思考…", "Working on the map…": "正在操作地圖…", - "Silent chat · microphone off": "靜音聊天 · 麥克風關閉", - "Reply interrupted. You can try again.": "回覆已中斷,你可以再試一次。", - "Could not connect. Try again.": "無法連線,請再試一次。", - "Session ended. Send a message to start a new conversation.": "工作階段已結束。傳送訊息即可開始新的對話。", - "Connection timed out. Try again.": "連線逾時,請再試一次。", + "Silent chat · microphone off": "靜默聊天 · 麥克風已關閉", + "Reply interrupted. You can try again.": "回覆已中斷。請重試。", + "Could not connect. Try again.": "無法連接。請重試。", + "Session ended. Send a message to start a new conversation.": "會話已結束。發送消息以開始新對話。", + "Connection timed out. Try again.": "連接超時。請重試。", "Chat closed.": "聊天已關閉。", - "Connection stopped.": "連線已停止。", - "Message and attachments are too large. Remove an attachment or shorten your message.": "訊息與附件太大。請移除一個附件或縮短訊息。", - "Message was not sent. Try again.": "訊息未送出,請再試一次。", - "Reply stopped.": "已停止回覆。", + "Connection stopped.": "連接已停止。", + "Message and attachments are too large. Remove an attachment or shorten your message.": "消息和附件過大。請移除附件或縮短消息。", + "Message was not sent. Try again.": "消息未發送。請重試。", + "Reply stopped.": "回覆已停止。", "Suggestions": "建議", "Recent": "最近", - "Quick commands": "快速指令", - "Open AI chat": "開啟 AI 聊天", + "Quick commands": "快捷命令", + "Open AI chat": "打開 AI 聊天", "CHAT": "聊天", "Back to chat": "返回聊天", "Back to voice": "返回語音", "Latest voice exchange": "最近一次語音對話", "Try saying": "試著說", "What am I looking at?": "我在看什麼?", - "AI Provider": "AI 供應商", - "The selected AI provider is shared by voice and chat.": "所選的 AI 供應商由語音與聊天共用。", - "Show quick commands": "顯示快速指令", - "Chat replies are silent. No microphone access is needed.": "聊天回覆不會發出聲音,不需要麥克風權限。", + "AI Provider": "AI 服務商", + "The selected AI provider is shared by voice and chat.": "語音和聊天共用所選的 AI 服務商。", + "Show quick commands": "顯示快捷命令", + "Chat replies are silent. No microphone access is needed.": "聊天回覆不播放聲音,無需訪問麥克風。", "Chat": "聊天", "Attach layer": "附加圖層", - "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 金鑰。ChatGPT 登入僅適用於語音。", - "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天無法使用。", - "That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的變更會保留。", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "文字聊天使用你的 OpenAI API 密鑰。ChatGPT 登錄僅適用於語音。", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTH 要求使用 ChatGPT OAuth 時,文字聊天不可用。", + "That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的更改會保留。", "That request needed too many steps. Try a simpler request.": "此請求所需步驟過多。請嘗試更簡單的請求。", - "The provider rejected the API key. Check it in Provider Settings.": "供應商拒絕了 API 金鑰。請在供應商設定中檢查。", + "The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。", "Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。", - "The provider refused access for this key. Check the key's project, region or permissions.": "供應商拒絕了此金鑰的存取。請檢查該金鑰的專案、地區或權限。", - "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "供應商帳戶餘額不足。請檢查帳單,或在設定中選擇其他 AI 供應商。" + "The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0d1022668..51cd4edec 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -361,6 +361,7 @@ "CCTV", "CCTV OFF", "CCTV ON", + "CHAT", "COCKPIT", "COMMERCIAL", "CONTACT", diff --git a/src/voice/chatPreferences.js b/src/voice/chatPreferences.js index e9f1aca05..b094624bb 100644 --- a/src/voice/chatPreferences.js +++ b/src/voice/chatPreferences.js @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; /** * Chat suggestions and the one chat preference (quick commands), written to @@ -17,10 +17,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { }; const quickCommands = () => [ - 'Fly to Tokyo', - 'Show live flights', - 'Reset to globe view', - 'Show earthquakes', + msg('Fly to Tokyo'), + msg('Show live flights'), + msg('Reset to globe view'), + msg('Show earthquakes'), ].map((text) => t(text)); try { const stored = JSON.parse(localStorage.getItem(key)); From 353ca04b61b7f674207d5173d9e00834c734701d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:53:47 +0400 Subject: [PATCH 34/43] Add the changelog entry for #1545 --- changelog.d/1545-typed-chat-translations.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1545-typed-chat-translations.md diff --git a/changelog.d/1545-typed-chat-translations.md b/changelog.d/1545-typed-chat-translations.md new file mode 100644 index 000000000..dd95167b4 --- /dev/null +++ b/changelog.d/1545-typed-chat-translations.md @@ -0,0 +1,5 @@ +- Finish the typed AI chat translations. The "Reset to globe view" and "Show + earthquakes" quick commands are now translated, Traditional Chinese follows + Simplified Chinese again, Arabic docks the panel to the side it actually + lands on, and the new strings use each language's glossary terms + (German, @gekh, #1545). From 20a7016987bddfff57904ad942b16f6c3b087e05 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:56:57 +0400 Subject: [PATCH 35/43] Keep "Tell it where to go" as a direction, not a flight, in ru, uk and es --- src/i18n/locales/es.json | 2 +- src/i18n/locales/ru.json | 2 +- src/i18n/locales/uk.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b49d311b4..0dfc4dea3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3100,7 +3100,7 @@ "Show earthquakes": "Muestra los terremotos", "AI chat": "Chat de IA", "GEV CHAT": "CHAT DE GEV", - "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Dile adónde ir.", + "Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.", "Close chat": "Cerrar chat", "Conversation": "Conversación", "Explore with a question": "Explora con una pregunta", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 35f14ec0d..df4e575d3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3175,7 +3175,7 @@ "Show earthquakes": "Покажи землетрясения", "AI chat": "Чат с ИИ", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Скажите, куда лететь.", + "Ask about the map. Tell it where to go.": "Задайте вопрос о карте. Укажите, куда перейти.", "Close chat": "Закрыть чат", "Conversation": "Разговор", "Explore with a question": "Начните с вопроса", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 9e76044eb..70d4accf9 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3175,7 +3175,7 @@ "Show earthquakes": "Покажи землетруси", "AI chat": "ШІ-чат", "GEV CHAT": "ЧАТ GEV", - "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди летіти.", + "Ask about the map. Tell it where to go.": "Питайте про карту. Скажіть, куди перейти.", "Close chat": "Закрити чат", "Conversation": "Розмова", "Explore with a question": "Почніть із запитання", From 55e6c0017c79215220421c6a727742ec8005839d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:15:16 +0400 Subject: [PATCH 36/43] Tighten the provider-label comments and docs --- changelog.d/1521-provider-labels.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/subject.js | 3 +-- src/layers/vessels/queries.js | 2 +- src/ui/cockpitContext.js | 1 - 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md index d921d9d7b..887d6a775 100644 --- a/changelog.d/1521-provider-labels.md +++ b/changelog.d/1521-provider-labels.md @@ -1,4 +1,4 @@ -- Name the provider that answered in the Cockpit Contact counts and the - Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when - it is. The counts no longer read OpenSky while flights come from the adsb.lol - fallback, and vessels no longer always read AISStream (German, @gekh, #1521). +- Cockpit Contact counts and the Contacts list name the provider that + answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from + the adsb.lol fallback no longer read OpenSky, and vessels no longer always + read AISStream (German, @gekh, #1521). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e845b1472..c6abbf74a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5112,7 +5112,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. +- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 32123d248..60f622e29 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -53,8 +53,7 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); - // Whether the provider answered as a fallback or from a stale snapshot. - // A poll in flight does not change that, and the source revision ignores it. + // A poll in flight keeps FALLBACK or STALE: the revision key ignores loading. // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { const feedState = layerFeedState( diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 48ded012b..d4d9ef796 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,7 +548,7 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), - // The provider the last snapshot named, as flights and military report it. + // Same field as flights and military, so Contacts names the provider. source: layer.source, }; }, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index ad73869db..6dc209977 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -111,7 +111,6 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; - // The provider that answered, named as the Layers panel names it. shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; From f79e9328d6c628b5bc002f6ba7af7b868ffaf55c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:17:26 +0400 Subject: [PATCH 37/43] docs(cctv): tighten the Bosch MIC record notes --- DATA_SOURCES.md | 12 ++++++------ docs/CURRENT-STATE.md | 8 ++++---- src/layers/cctv/modelSpecs.js | 14 ++++++-------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6f8907812..cac0b5ab7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -256,12 +256,12 @@ The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bos records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under -**CC0-1.0**. Only the records for hardware named by the integrated public catalogs (King -County, Sarasota County and Sioux Falls), plus the City of Amsterdam register proposed -in [#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251), are bundled, compiled into the module. Nothing is -fetched at runtime. The MIC specification fields are unchanged; product titles are shortened -to model codes and colour aliases are added locally. The records describe camera products (optics, resolution, night vision) -only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +**CC0-1.0**. Only records for hardware named by the integrated public catalogs (King County, +Sarasota County and Sioux Falls) or by the City of Amsterdam register proposed in +[#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251) are bundled, compiled into the +module. Nothing is fetched at runtime. The MIC records keep their specification fields; their +titles are shortened to model codes and colour aliases are added. The records describe camera +products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. ### Bundled OpenStreetMap data diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c386a85db..e868dfc95 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5160,10 +5160,10 @@ silently demoting every later lookup for the session. multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). - The table also includes Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam - register proposed in #251, including its `x` colour aliases (#1319). Their axis-less - datasheet FOV does not supply a horizontal capability range or replace the estimated - FOV. Adding these records does not enable the proposed source pack. + The table also has Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam + register proposed in #251, with its `x` colour aliases (#1319). Their datasheet FOV names + no axis, so it neither gives a capability range nor replaces the estimate. The records + alone do not enable that source pack. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. Fire-power labels share display rounding before localization: one decimal below the rounded 10 MW boundary, then whole megawatts (for example, 9.96 MW reads 10 MW). - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 58734aa14..adc35f10b 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -27,14 +27,12 @@ */ /** - * Records from the dataset's model-enrichment extract (v2.26.0; the Bosch MIC - * records from v2.29.0). MIC specification fields are unchanged; product - * titles are shortened to model codes and colour aliases are added locally. - * Bosch MIC order codes end in a colour letter (B black, G grey, W white) - * on one shared datasheet; those siblings, - * and the `x` wildcard registries write for the colour letter, are listed as - * aliases of the one vendored record. Its `model` leaves the colour out, so - * the HUD never names a variant the source did not. + * Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch + * MIC records from v2.29.0 with titles shortened to model codes. + * A Bosch MIC order code ends in a colour letter (B, G, W; registries write + * `x`) on one shared datasheet. The colours are aliases of one record whose + * `model` leaves the letter out, so the HUD never names a variant the source + * did not. */ const RECORDS = [ { From 06c84f92d3791278a80c1ab710e995535768612d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:25 +0400 Subject: [PATCH 38/43] Ignore Arabic short vowels and shadda in chat autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arabic catalog strings carry optional marks ("تتبّع"), but people type without them, so "تتبع" found no suggestion. Fold the marks out of both the draft and the candidates before comparing. --- src/voice/chatPreferences.js | 12 ++++++------ src/voice/chatPreferences.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/voice/chatPreferences.js b/src/voice/chatPreferences.js index b094624bb..e478620cd 100644 --- a/src/voice/chatPreferences.js +++ b/src/voice/chatPreferences.js @@ -22,6 +22,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { msg('Reset to globe view'), msg('Show earthquakes'), ].map((text) => t(text)); + // Arabic short vowels and shadda are optional in writing: typing "تتبع" + // still finds "تتبّع". + const fold = (text) => + text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, ''); try { const stored = JSON.parse(localStorage.getItem(key)); if (typeof stored?.quick === 'boolean') quick = stored.quick; @@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) { function render() { const draft = getDraft(); if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory(); - const query = draft.trim().toLowerCase(); + const query = fold(draft.trim()); const groups = []; if (query) { const candidates = [...recent, ...(quick ? quickCommands() : [])]; groups.push({ label: t('Suggestions'), items: [...new Set(candidates)] - .filter( - (text) => - text.toLowerCase().includes(query) && - text.toLowerCase() !== query, - ) + .filter((text) => fold(text).includes(query) && fold(text) !== query) .slice(0, 4), }); } else { diff --git a/src/voice/chatPreferences.test.mjs b/src/voice/chatPreferences.test.mjs index 916fc3342..28dcdefcb 100644 --- a/src/voice/chatPreferences.test.mjs +++ b/src/voice/chatPreferences.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { setLocale } from '../i18n/index.js'; import { bindChatPreferences } from './chatPreferences.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; @@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => { press('ArrowUp'); assert.equal(field.value, 'Show live flights now'); }); + +test('autocomplete ignores Arabic short vowels and shadda', (t) => { + setLocale('ar'); + t.after(() => setLocale('en')); + const ui = createTestVoiceUi(); + let draft = ''; + const prefs = bindChatPreferences({ ui, getDraft: () => draft }); + t.after(() => prefs.destroy()); + const suggested = (text) => { + draft = text; + prefs.render(); + return ui.state.chat.suggestions.flatMap((group) => group.items); + }; + // The quick command is written "تتبّع", with a shadda. + assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']); + prefs.remember('انتقل إلى طوكيو'); + assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']); +}); From e84cae6baa0d41af79c6a25e2347a3f8587df542 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 23:35:46 +0400 Subject: [PATCH 39/43] Add the changelog entry for #1561 --- changelog.d/1561-chat-autocomplete-arabic-marks.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1561-chat-autocomplete-arabic-marks.md diff --git a/changelog.d/1561-chat-autocomplete-arabic-marks.md b/changelog.d/1561-chat-autocomplete-arabic-marks.md new file mode 100644 index 000000000..b49cd0e86 --- /dev/null +++ b/changelog.d/1561-chat-autocomplete-arabic-marks.md @@ -0,0 +1,3 @@ +- Chat autocomplete finds Arabic suggestions typed without short vowels or + shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر» + (German, @gekh, #1561). From ad48305dac0f4f008a0b94da7db91e6169faf771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:49:54 -0300 Subject: [PATCH 40/43] feat(transit): name Rio routes from SMTR's published GTFS SMTR's realtime feeds carry internal route keys, so a Rio card read "Route O0731AAN0A" where riders know line SN731. Join both feeds to the route_short_name in SMTR's static GTFS routes.txt (CC BY 4.0, same publisher and terms). The keys cannot be parsed: O0014AAA0A is LECD135. A 2026-11-04 snapshot found all 282 bus and 21 BRT routes in service in the table; an unknown key still falls through raw. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- changelog.d/1481-rio-transit.md | 4 +- src/data/rioRouteNames.js | 515 ++++++++++++++++++++++++++++++++ src/data/transitFeeds.js | 19 +- src/data/transitFeeds.test.mjs | 31 ++ 5 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 src/data/rioRouteNames.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 743d83978..2ed14c264 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +98,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | -| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md index 62b4ede04..b45b1b1fd 100644 --- a/changelog.d/1481-rio-transit.md +++ b/changelog.d/1481-rio-transit.md @@ -1,4 +1,6 @@ - Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin American region. A feed that publishes speed in km/h declares - `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). + `speedUnit: 'kmh'` and the proxy converts it to m/s. Rio's internal route + keys are joined to SMTR's published GTFS `routes.txt`, so a card reads + "Route SN731" rather than "Route O0731AAN0A" (IMBrandao, #1481). diff --git a/src/data/rioRouteNames.js b/src/data/rioRouteNames.js new file mode 100644 index 000000000..68a7da7a8 --- /dev/null +++ b/src/data/rioRouteNames.js @@ -0,0 +1,515 @@ +// src/data/rioRouteNames.js +/** + * Rio de Janeiro route ids to the line a rider would say. + * + * SMTR's realtime feeds publish internal route keys: the bus feed sends + * `O0868AAA0A` for line 868 and `O0731AAN0A` for SN731, and the BRT feed + * sends `20000501110` for line 50. The keys look decodable but are not: + * `O0014AAA0A` is LECD135, not 14, and nineteen of the plain-looking keys in + * the published table break the pattern. The join is the only correct route. + * + * Generated verbatim (`route_id` to `route_short_name`, 494 routes) from + * `routes.txt` in SMTR's static GTFS, https://dados.mobilidade.rio/gtfs/schedule + * (CC BY 4.0, the same publisher and terms as the realtime feeds; file dated + * 2026-06-10, feed valid 2024-01-01 to 2026-12-31). A 2026-11-04 snapshot of + * both realtime feeds, 282 bus and 21 BRT routes in service, found every one + * of its route ids here. An id missing from this table falls through + * unchanged, so a route added later degrades to its raw key rather than + * disappearing. + */ +export const RIO_ROUTE_NAMES = Object.freeze({ + 20000101130: '10', + 20000111110: '11', + 20000121130: '12', + 20000131130: '13', + 20000141130: '14', + 20000151130: '15', + 20000161130: '16', + 20000171110: '17', + 20000181130: '18', + 20000191130: '19', + 20000201130: '20', + 20000221110: '22', + 20000251130: '25', + 20000281130: '28', + 20000311140: '31', + 20000351110: '35', + 20000401130: '40', + 20000411130: '41', + 20000421110: '42', + 20000431130: '43', + 20000461130: '46', + 20000501110: '50', + 20000511110: '51', + 20000521110: '52', + 20000531130: '53', + 20000601110: '60', + 20000611130: '61', + 20000671130: '67', + 20000681130: '68', + 20000701110: '70', + 20000731130: '73', + 20000801110: '80', + 20000901110: '90', + '20000EXEC1110': 'ESP01', + E2110AAA0A: '2110', + E2111AAA0A: '2111', + E2112AAA0A: '2112', + E2114AAA0A: '2114', + E2303AAA0A: '2303', + E2305AAA0A: '2305', + E2307AAA0A: '2307', + E2308AAA0A: '2308', + E2309AAA0A: '2309', + E2310AAA0A: '2310', + E2334AAA0A: '2334', + E2335AAA0A: '2335', + E2336AAA0A: '2336', + E2337AAA0A: '2337', + E2338AAA0A: '2338', + E2339AAA0A: '2339', + E2342AAA0A: '2342', + E2343AAA0A: '2343', + E2344AAA0A: '2344', + E2345AAA0A: '2345', + E2381AAA0A: '2381', + E2383AAA0A: '2383', + E2613AAA0A: '2613', + E2614AAA0A: '2614', + E2802AAA0A: '2802', + E2803AAA0A: '2803', + E2804AAA0A: '2804', + O0006AAA0A: '006', + O0007AAA0A: '007', + O0007AAN0A: 'SN007', + O0010AAA0A: '010', + O0014AAA0A: 'LECD135', + O0100AAA0A: '100', + O0104AAA0A: '104', + O0104AAN0A: 'SN104', + O0107AAA0A: '107', + O0109AAA0A: 'LECD131', + O0111AAA0A: 'LECD134', + O0117AAA0A: '117', + O0133AAA0A: '133', + O0133AAN0A: 'SN133', + O0157AAA0A: 'LECD127', + O0160AAA0A: '160', + O0161AAA0A: '161', + O0162AAA0A: '162', + O0164AAA0A: 'LECD136', + O0165AAA0A: '165', + O0167AAA0A: '167', + O0169AAA0A: '169', + O0201AAA0A: '201', + O0202AAA0A: '202', + O0209AAA0A: '209', + O0209AAN0A: 'SN209', + O0210AAA0A: '210', + O0217AAA0A: '217', + O0220AAA0A: '220', + O0229AAA0A: '229', + O0232AAA0A: '232', + O0232AAN0A: 'SN232', + O0238AAA0A: '238', + O0238AAN0A: 'SN238', + O0239AAA0A: '239', + O0247AAA0A: '247', + O0249AAA0A: '249', + O0254AAA0A: '254', + O0265AAA0A: '265', + O0265AAN0A: 'SN265', + O0265AAP0A: 'SP265', + O0275AAA0A: '275', + O0277AAA0A: '277', + O0292AAA0A: '292', + O0292AAN0A: 'SN292', + O0296AAA0A: '296', + O0298AAA0A: '298', + O0298AAN0A: 'SN298', + O0300AAA0A: '300', + O0300AAR0A: 'SR300', + O0301AAA0A: '301', + O0302AAA0A: '302', + O0302AAN0A: 'SN302', + O0306AAA0A: '306', + O0311AAA0A: '311', + O0312AAA0A: '312', + O0312AAN0A: 'SN312', + O0313AAA0A: '313', + O0315AAA0A: '315', + O0319AAA0A: '319', + O0319AAN0A: 'SN319', + O0319AAV0A: 'SV319', + O0321AAA0A: '321', + O0323AAA0A: '323', + O0324AAA0A: '324', + O0324AAN0A: 'SN324', + O0324AAP0A: 'SP324', + O0325AAA0A: '325', + O0326AAA0A: '326', + O0326AAPBA: 'SPB326', + O0327AAA0A: '327', + O0328AAA0A: '328', + O0328AAN0A: 'SN328', + O0328AAP0A: 'SP328', + O0328AAR0A: 'SR328', + O0329AAA0A: '329', + O0329AAP0A: 'SP329', + O0335AAA0A: '335', + O0338AAA0A: '338', + O0341AAA0A: '341', + O0342AAA0A: '342', + O0342AAR0A: 'SR342', + O0343AAA0A: '343', + O0343AAP0A: 'SP343', + O0348AAA0A: '348', + O0349AAA0A: '349', + O0350AAA0A: '350', + O0352AAA0A: '352', + O0353AAA0A: '353', + O0353AAN0A: 'SN353', + O0355AAA0A: '355', + O0355AAN0A: 'SN355', + O0355AAR0A: 'SR355', + O0361AAA0A: '361', + O0362AAA0A: '362', + O0362AAR0A: 'SR362', + O0363AAA0A: '363', + O0363AAN0A: 'SN363', + O0363AAV0A: 'SV363', + O0368AAA0A: '368', + O0368AAN0A: 'SN368', + O0369AAA0A: '369', + O0371AAA0A: '371', + O0378AAA0A: '378', + O0378AAR0A: 'SR378', + O0380AAA0A: '380', + O0383AAA0A: '383', + O0383AAN0A: 'SN383', + O0384AAA0A: '384', + O0384AAR0A: 'SR384', + O0385AAA0A: '385', + O0386AAA0A: '386', + O0386AAR0A: 'SR386', + O0388AAA0A: '388', + O0388AAN0A: 'SN388', + O0388AAR0A: 'SR388', + O0389AAP0A: 'SP389', + O0390AAA0A: '390', + O0391AAV0A: 'SV391', + O0393AAA0A: '393', + O0393AAN0A: 'SN393', + O0393AAR0A: 'SR393', + O0397AAA0A: '397', + O0397AAN0A: 'SN397', + O0397AAR0A: 'SR397', + O0399AAA0A: '399', + O0399AAN0A: 'SN399', + O0399AAR0A: 'SR399', + O0404AAP0A: 'SP404', + O0409AAA0A: '409', + O0410AAA0A: '410', + O0410AAN0A: 'LECD139', + O0415AAA0A: '415', + O0415AAN0A: 'SN415', + O0416AAA0A: '416', + O0422AAA0A: '422', + O0422AAN0A: 'SN422', + O0426AAA0A: '426', + O0433AAA0A: 'LECD150', + O0435AAA0A: 'LECD151', + O0448AAA0A: '448', + O0455AAA0A: '455', + O0456AAA0A: 'LECD132', + O0457AAA0A: '457', + O0457AAN0A: 'SN457', + O0461AAA0A: '461', + O0472AAA0A: '472', + O0473AAA0A: '473', + O0474AAA0A: '474', + O0474AAN0A: 'SN474', + O0474AAV0A: 'SV474', + O0475AAA0A: 'LECD133', + O0476AAA0A: '476', + O0483AAA0A: '483', + O0483AAN0A: 'SN483', + O0483AAPAA: 'SPA483', + O0483AAPBA: 'SPB483', + O0483AAV0A: 'SV483', + O0484AAA0A: '484', + O0485AAA0A: '485', + O0485AAP0A: 'SP485', + O0486AAA0A: '486', + O0492AAA0A: '492', + O0497AAA0A: '497', + O0497AAN0A: 'SN497', + O0498AAA0A: '498', + O0507AAA0A: '507', + O0513AAA0A: '513', + O0518AAA0A: '518', + O0519AAA0A: '519', + O0536AAA0A: 'LECD138', + O0539AAA0A: '539', + O0550AAA0A: '550', + O0550AAN0A: 'SN550', + O0550AAPAA: 'SPA550', + O0550AAPBA: 'SPB550', + O0552AAA0A: '552', + O0552AAN0A: 'LECD149', + O0553AAA0A: '553', + O0554AAA0A: '554', + O0554AAN0A: 'SN554', + O0555AAA0A: '555', + O0557AAA0A: '557', + O0565AAA0A: '565', + O0565AAN0A: 'SN565', + O0583AAA0A: '583', + O0584AAA0A: '584', + O0585AAA0A: '585', + O0600AAA0A: '600', + O0600AAN0A: 'SN600', + O0601AAA0A: '601', + O0603AAA0A: '603', + O0606AAA0A: '606', + O0606AAV0A: 'SV606', + O0607AAA0A: '607', + O0608AAA0A: '608', + O0610AAA0A: '610', + O0611AAA0A: '611', + O0613AAA0A: '613', + O0613AAPAA: 'SPA613', + O0614AAA0A: '614', + O0616AAA0A: '616', + O0621AAA0A: '621', + O0622AAA0A: '622', + O0623AAA0A: '623', + O0624AAA0A: '624', + O0624AAN0A: 'SN624', + O0624AAV0A: 'SV624', + O0625AAA0A: '625', + O0626AAA0A: '626', + O0627AAA0A: '627', + O0628AAA0A: '628', + O0629AAA0A: '629', + O0629AAN0A: 'SN629', + O0630AAA0A: '630', + O0634AAA0A: '634', + O0635AAA0A: '635', + O0635AAP0A: 'SP635', + O0636AAA0A: '636', + O0636AAN0A: 'SN636', + O0638AAA0A: '638', + O0639AAA0A: '639', + O0639AAN0A: 'SN639', + O0645AAA0A: '645', + O0649AAA0A: 'LECD126', + O0650AAA0A: '650', + O0651AAA0A: '651', + O0652AAA0A: '652', + O0653AAA0A: '653', + O0663AAA0A: '663', + O0665AAA0A: '665', + O0665AAVAA: 'LECD147', + O0665AAVBA: 'SVB665', + O0669AAA0A: '669', + O0669AAV0A: 'SV669', + O0678AAA0A: '678', + O0679AAA0A: '679', + O0685AAA0A: 'LECD140', + O0685AAVAA: 'LECD148', + O0685AAVBA: 'LECD141', + O0687AAA0A: '687', + O0688AAA0A: '688', + O0691AAA0A: '691', + O0692AAA0A: '692', + O0692AAV0A: 'SV692', + O0693AAA0A: '693', + O0696AAA0A: '696', + O0696AAN0A: 'SN696', + O0711AAA0A: '711', + O0712AAA0A: '712', + O0721AAA0A: '721', + O0731AAA0A: '731', + O0731AAN0A: 'SN731', + O0737AAA0A: '737', + O0741AAA0A: '741', + O0743AAA0A: '743', + O0745AAA0A: '745', + O0746AAA0A: '746', + O0753AAA0A: '753', + O0754AAA0A: '754', + O0756AAA0A: '756', + O0757AAA0A: '757', + O0759AAA0A: '759', + O0759AAP0A: 'SP759', + O0764AAA0A: '764', + O0765AAA0A: '765', + O0766AAA0A: '766', + O0766AAP0A: 'SP766', + O0770AAA0A: '770', + O0771AAA0A: '771', + O0774AAA0A: '774', + O0774AAV0A: 'LECD142', + O0775AAA0A: '775', + O0777AAV0A: 'SV777', + O0778AAA0A: '778', + O0779AAA0A: '779', + O0779AAN0A: 'SN779', + O0780AAA0A: '780', + O0783AAA0A: '783', + O0785AAA0A: '785', + O0786AAA0A: '786', + O0788AAA0A: '788', + O0790AAA0A: '790', + O0790AAN0A: 'SN790', + O0790AAV0A: 'SV790', + O0791AAA0A: '791', + O0793AAA0A: '793', + O0794AAA0A: '794', + O0794AAN0A: 'SN794', + O0795AAP0A: 'SP795', + O0796AAA0A: '796', + O0798AAA0A: '798', + O0799AAA0A: '799', + O0801AAA0A: '801', + O0801AAP0A: 'SP801', + O0802AAA0A: '802', + O0803AAA0A: '803', + O0803AAN0A: 'SN803', + O0803AAP0A: 'SP803', + O0804AAA0A: '804', + O0805AAA0A: '805', + O0805AAN0A: 'SN805', + O0805AAP0A: 'SP805', + O0807AAA0A: '807', + O0808AAA0A: '808', + O0809AAA0A: '809', + O0810AAA0A: '810', + O0810AAN0A: 'SN810', + O0810AAP0A: 'SP810', + O0812AAA0A: '812', + O0817AAA0A: '817', + O0819AAA0A: '819', + O0820AAA0A: '820', + O0821AAA0A: '821', + O0821AAN0A: 'SN821', + O0822AAA0A: '822', + O0825AAA0A: '825', + O0825AAN0A: 'SN825', + O0826AAA0A: '826', + O0829AAA0A: '829', + O0830AAA0A: '830', + O0831AAA0A: '831', + O0831AAV0A: 'SV831', + O0833AAA0A: '833', + O0834AAA0A: '834', + O0835AAA0A: '835', + O0836AAA0A: '836', + O0837AAA0A: '837', + O0838AAA0A: '838', + O0840AAA0A: '840', + O0840AAN0A: 'SN840', + O0841AAA0A: '841', + O0842AAA0A: '842', + O0843AAA0A: '843', + O0844AAA0A: '844', + O0845AAA0A: '845', + O0846AAA0A: '846', + O0846AAN0A: 'SN846', + O0847AAA0A: '847', + O0848AAA0A: '848', + O0848AAN0A: 'SN848', + O0849AAA0A: '849', + O0850AAA0A: '850', + O0851AAA0A: '851', + O0852AAA0A: '852', + O0853AAA0A: '853', + O0853AAN0A: 'SN853', + O0853AAV0A: 'SV853', + O0854AAAAA: '854', + O0855AAA0A: '855', + O0856AAA0A: '856', + O0857AAA0A: '857', + O0857AAN0A: 'SN857', + O0859AAA0A: '859', + O0861AAA0A: '861', + O0861AAN0A: 'SN861', + O0862AAA0A: '862', + O0863AAA0A: '863', + O0863AAV0A: 'SV863', + O0864AAA0A: '864', + O0864AAP0A: 'SP864', + O0865AAA0A: '865', + O0866AAA0A: '866', + O0866AAV0A: 'SV866', + O0867AAA0A: '867', + O0867AAN0A: 'SN867', + O0868AAA0A: '868', + O0869AAA0A: '869', + O0870AAA0A: '870', + O0871AAA0A: '871', + O0872AAA0A: '872', + O0878AAA0A: '878', + O0878AAN0A: 'SN878', + O0878AAV0A: 'SV878', + O0880AAA0A: '880', + O0880AAN0A: 'SN880', + O0881AAA0A: '881', + O0883AAA0A: '883', + O0884AAA0A: '884', + O0884AAN0A: 'SN884', + O0885AAA0A: '885', + O0885AAN0A: 'SN885', + O0892AAA0A: '892', + O0893AAA0A: '893', + O0893AAN0A: 'SN893', + O0894AAA0A: '894', + O0895AAA0A: '895', + O0895AAN0A: 'SN895', + O0897AAA0A: '897', + O0897AAN0A: 'SN897', + O0898AAA0A: '898', + O0898AAN0A: 'SN898', + O0899AAA0A: '899', + O0899AAV0A: 'SV899', + O0900AAA0A: '900', + O0901AAA0A: '901', + O0901AAVBA: 'SVB901', + O0905AAA0A: '905', + O0906AAA0A: '906', + O0908AAA0A: '908', + O0908AAN0A: 'SN908', + O0908AAV0A: 'SV908', + O0910AAA0A: '910', + O0913AAA0A: '913', + O0915AAA0A: '915', + O0917AAA0A: '917', + O0917AAV0A: 'SV917', + O0918AAA0A: '918', + O0918AAP0A: 'SP918', + O0919AAA0A: '919', + O0919AAN0A: 'SN919', + O0920AAA0A: '920', + O0921AAA0A: '921', + O0921AAV0A: 'SV921', + O0922AAA0A: '922', + O0922AAV0A: 'SV922', + O0925AAA0A: '925', + O0926AAA0A: '926', + O0928AAA0A: '928', + O0932AAA0A: '932', + O0940AAA0A: '940', + O0944AAA0A: 'LECD143', + O0946AAA0A: '946', + O0950AAA0A: 'LECD144', + O0951AAA0A: 'LECD145', + O0954AAA0A: '954', + O0954AAN0A: 'SN954', + O0959AAA0A: 'LECD146', + O0961AAA0A: '961', + O0975AAA0A: '975', + O0979AAA0A: '979', + O0987AAA0A: '987', + O0990AAA0A: '990', +}); diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index e871a2e89..d7993a954 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1,4 +1,5 @@ import { greatCircleKm } from '../geoDistance.js'; +import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; /** * @module transitFeeds @@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) { return BURLINGTON_ROUTE_NAMES[routeId] || routeId; } +/** + * Rio route key to the line a rider would say, for both SMTR feeds: one + * published `routes.txt` covers the buses and the BRT (see rioRouteNames.js). + * @param {string|null} routeId + * @returns {string|null} + */ +function rioRouteLabel(routeId) { + return Object.hasOwn(RIO_ROUTE_NAMES, routeId) + ? RIO_ROUTE_NAMES[routeId] + : routeId; +} + /** * Registry of feeds. Order is presentation order in the stats/credit text. * `loadRadiusKm` is the distance from `center` inside which the feed is polled. @@ -752,10 +765,11 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', }), defaultMode: 'bus', speedUnit: 'kmh', + routeLabel: rioRouteLabel, }), Object.freeze({ id: 'smtr-rio-brt', @@ -774,10 +788,11 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', }), defaultMode: 'bus', speedUnit: 'kmh', + routeLabel: rioRouteLabel, }), ]); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 51903b7db..8333207d5 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -15,6 +15,7 @@ import { transitModeResolved, transitRouteLabel, } from './transitFeeds.js'; +import { RIO_ROUTE_NAMES } from './rioRouteNames.js'; test('every registered feed is keyless, https, licensed, and uniquely identified', () => { const ids = new Set(); @@ -504,6 +505,36 @@ test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São P ); }); +test('Rio route keys read as the line on the front of the bus', () => { + // SMTR publishes internal keys. Plain keys carry the number (`O0868AAA0A` + // is 868), variants carry a prefix (`O0731AAN0A` is SN731), and some plain + // keys are traps: `O0014AAA0A` is LECD135, so the table is joined, not parsed. + const buses = getTransitFeed('smtr-rio-onibus'); + assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868'); + assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731'); + assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759'); + assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135'); + assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303'); + const brt = getTransitFeed('smtr-rio-brt'); + assert.equal(transitRouteLabel(brt, '20000501110'), '50'); + assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01'); + // A route added after the table was built keeps its raw key, never a blank + // or a name borrowed from the prototype chain. + assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A'); + assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor'); + assert.equal(transitRouteLabel(brt, null), null); +}); + +test('the Rio route table is a clean join of published keys to names', () => { + const entries = Object.entries(RIO_ROUTE_NAMES); + assert.ok(entries.length > 400, 'the whole published table is carried'); + for (const [id, name] of entries) { + assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id); + assert.ok(name && name === name.trim(), `${id} has a name`); + assert.notEqual(name, id, `${id} is translated`); + } +}); + test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { const feed = getTransitFeed(id); From a67c7969fdc52449dfeeef59b495772ab563b7a6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:48:11 +0400 Subject: [PATCH 41/43] fix(transit): read Rio speeds as the m/s SMTR publishes Both SMTR feeds now publish Position.speed in m/s. Compared with how far vehicles moved between reports on 2026-11-05, the published values ran 3.1x (buses) and 3.6x (BRT) below the observed speed, so the km/h conversion divided real speeds by 3.6. Drop speedUnit and the conversion. --- DATA_SOURCES.md | 2 +- changelog.d/1481-rio-transit.md | 7 +++---- docs/CURRENT-STATE.md | 3 +-- src/data/transitFeeds.js | 11 ++--------- src/data/transitFeeds.test.mjs | 3 +-- src/data/transitProxy.js | 21 +-------------------- src/data/transitProxy.test.mjs | 25 +++++-------------------- 7 files changed, 14 insertions(+), 58 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 2ed14c264..eb20f793e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +98,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | -| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md index b45b1b1fd..ac245a9c8 100644 --- a/changelog.d/1481-rio-transit.md +++ b/changelog.d/1481-rio-transit.md @@ -1,6 +1,5 @@ - Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin - American region. A feed that publishes speed in km/h declares - `speedUnit: 'kmh'` and the proxy converts it to m/s. Rio's internal route - keys are joined to SMTR's published GTFS `routes.txt`, so a card reads - "Route SN731" rather than "Route O0731AAN0A" (IMBrandao, #1481). + American region. Rio's internal route keys are joined to SMTR's published + GTFS `routes.txt`, so a card reads "Route SN731" rather than + "Route O0731AAN0A" (IMBrandao, #1481). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 81e71655c..170605951 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4649,8 +4649,7 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (municipal buses and the Mobi-Rio BRT, two feeds), and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, -Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which -the proxy converts to m/s before any card reads it. Mode silhouettes +Burlington, Barrie and Kingston. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index d7993a954..aa953c00b 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -398,12 +398,7 @@ function rioRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, - * speedUnit?: 'kmh', * }>>} - * - * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h - * instead of the m/s the spec requires; the proxy converts it before anything - * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -765,10 +760,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: on 2026-11-05 they matched how far vehicles moved between reports, though a 2026-10-29 snapshot read as km/h. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', }), defaultMode: 'bus', - speedUnit: 'kmh', routeLabel: rioRouteLabel, }), Object.freeze({ @@ -788,10 +782,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s here too. Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', }), defaultMode: 'bus', - speedUnit: 'kmh', routeLabel: rioRouteLabel, }), ]); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 8333207d5..dbae35c24 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -535,14 +535,13 @@ test('the Rio route table is a clean join of published keys to names', () => { } }); -test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { +test('Rio feeds are CC BY 4.0 and live', () => { for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { const feed = getTransitFeed(id); assert.equal(feed.defaultEnabled, true, `${id} is live`); assert.equal(feed.license, 'CC BY 4.0'); assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); - assert.equal(feed.speedUnit, 'kmh', `${id} publishes km/h`); assert.equal(transitModeFor(feed, null), 'bus'); } const sources = readFileSync( diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 2923c9c32..458240e9d 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,25 +224,6 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } -/** - * Bring a feed's speeds to the m/s the spec requires. A feed registered with - * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a - * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every - * consumer of `speedMps` honest without each one knowing about the feed. - * - * @param {object} feed Registry entry. - * @param {object[]} vehicles Normalized vehicle records. - * @returns {object[]} - */ -export function normalizeFeedSpeeds(feed, vehicles) { - if (feed?.speedUnit !== 'kmh') return vehicles; - return vehicles.map((vehicle) => - Number.isFinite(vehicle.speedMps) - ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } - : vehicle, - ); -} - /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -276,7 +257,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - normalizeFeedSpeeds(feed, decoded.vehicles), + decoded.vehicles, decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index 445e658f2..07911a939 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -285,29 +285,14 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); -test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { - // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus - // would be reported at 130 km/h. +test('Rio speeds pass through as the m/s they are published in', () => { + // SMTR publishes m/s, as the spec requires: checked against how far + // vehicles moved between reports on 2026-11-05. const bytes = encodeFeed({ - entities: [ - { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, - { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, - { id: 'silent', lat: -22.9, lon: -43.2 }, - ], + entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }], }); const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); - const speeds = Object.fromEntries( - rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), - ); - assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); - assert.equal(speeds.still, 0); - assert.equal(speeds.silent, null, 'an absent speed stays absent'); - const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); - assert.equal( - mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, - 36, - 'a spec-conforming feed is left alone', - ); + assert.equal(rio.vehicles[0].speedMps, 10); }); test('conditional-request validators and gzip ride along with feed identification', () => { From 70f405cf1b448e1543b4df527f15a35d556e55f2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 13:48:11 +0400 Subject: [PATCH 42/43] chore(transit): register rioRouteNames.js in the package and format scopes --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c589d8cc..b390581d0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -786,6 +786,7 @@ "server/providers/transit.js", "server/providers/transitHistory.js", "server/providers/transitHistory.test.mjs", + "src/data/rioRouteNames.js", "src/data/transitFeeds.js", "src/data/transitFeeds.test.mjs", "src/data/transitProxy.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d008ceaaa..a70878bd1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2954,6 +2954,7 @@ "src/app/layers/transit.js", "src/data/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -3704,6 +3705,7 @@ "src/sources/nominatim.js", "src/app/layers/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -4465,6 +4467,7 @@ "server/providers/transitHistory.js", "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/httpBody.js", @@ -4481,6 +4484,7 @@ "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" @@ -4514,6 +4518,7 @@ "src/data/terrainHeights.js", "src/data/transit.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/data/transitProxy.js", @@ -4572,6 +4577,7 @@ "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", @@ -4629,6 +4635,7 @@ "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/geoDistance.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", @@ -4873,6 +4880,7 @@ "src/tools/surfaces.js", "src/layers/transit/policy.js", "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", "src/layers/transit/movement.js", "src/data/contactPlayback.js", "src/data/layerSnapshot.js", From b5cef8daf4c3518340cd8d76fc1e318308a1b818 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 5 Nov 2026 21:29:21 +0400 Subject: [PATCH 43/43] Tighten the Rio comments, notes and credits Name each Rio feed in its credit instead of repeating the licence, drop the deprecation history from the bus note, list Rio among the feeds with a route-name table, and shorten the route-table header and test comments. --- DATA_SOURCES.md | 4 ++-- src/data/rioRouteNames.js | 20 +++++--------------- src/data/transitFeeds.js | 13 +++++++------ src/data/transitFeeds.test.mjs | 7 ++----- src/data/transitProxy.test.mjs | 3 +-- 5 files changed, 17 insertions(+), 30 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index eb20f793e..ed0895b0a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -98,7 +98,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | -| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | +| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | @@ -186,7 +186,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km.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 eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. diff --git a/src/data/rioRouteNames.js b/src/data/rioRouteNames.js index 68a7da7a8..1341f873a 100644 --- a/src/data/rioRouteNames.js +++ b/src/data/rioRouteNames.js @@ -1,21 +1,11 @@ -// src/data/rioRouteNames.js /** * Rio de Janeiro route ids to the line a rider would say. * - * SMTR's realtime feeds publish internal route keys: the bus feed sends - * `O0868AAA0A` for line 868 and `O0731AAN0A` for SN731, and the BRT feed - * sends `20000501110` for line 50. The keys look decodable but are not: - * `O0014AAA0A` is LECD135, not 14, and nineteen of the plain-looking keys in - * the published table break the pattern. The join is the only correct route. - * - * Generated verbatim (`route_id` to `route_short_name`, 494 routes) from - * `routes.txt` in SMTR's static GTFS, https://dados.mobilidade.rio/gtfs/schedule - * (CC BY 4.0, the same publisher and terms as the realtime feeds; file dated - * 2026-06-10, feed valid 2024-01-01 to 2026-12-31). A 2026-11-04 snapshot of - * both realtime feeds, 282 bus and 21 BRT routes in service, found every one - * of its route ids here. An id missing from this table falls through - * unchanged, so a route added later degrades to its raw key rather than - * disappearing. + * SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731, + * `20000501110` is BRT line 50). They look decodable but are not: + * `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in + * SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0), + * valid to 2026-12-31. An id missing here renders as its raw key. */ export const RIO_ROUTE_NAMES = Object.freeze({ 20000101130: '10', diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index aa953c00b..48ca450a1 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -378,8 +378,8 @@ function burlingtonRouteLabel(routeId) { } /** - * Rio route key to the line a rider would say, for both SMTR feeds: one - * published `routes.txt` covers the buses and the BRT (see rioRouteNames.js). + * Rio route key to the line a rider would say. One SMTR `routes.txt` covers + * both feeds (see rioRouteNames.js). * @param {string|null} routeId * @returns {string|null} */ @@ -755,12 +755,12 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ license: 'CC BY 4.0', licenseUrl: 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', - attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)', defaultEnabled: true, terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: on 2026-11-05 they matched how far vehicles moved between reports, though a 2026-10-29 snapshot read as km/h. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.', }), defaultMode: 'bus', routeLabel: rioRouteLabel, @@ -777,12 +777,13 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ license: 'CC BY 4.0', licenseUrl: 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', - attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + attribution: + 'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)', defaultEnabled: true, terms: Object.freeze({ quote: 'This work is licensed under a Creative Commons Attribution 4.0 International License', - note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s here too. Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.', }), defaultMode: 'bus', routeLabel: rioRouteLabel, diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index dbae35c24..d6dc93eb6 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -506,9 +506,7 @@ test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São P }); test('Rio route keys read as the line on the front of the bus', () => { - // SMTR publishes internal keys. Plain keys carry the number (`O0868AAA0A` - // is 868), variants carry a prefix (`O0731AAN0A` is SN731), and some plain - // keys are traps: `O0014AAA0A` is LECD135, so the table is joined, not parsed. + // Some keys look parseable and are not (`O0014AAA0A` is LECD135). const buses = getTransitFeed('smtr-rio-onibus'); assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868'); assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731'); @@ -518,8 +516,7 @@ test('Rio route keys read as the line on the front of the bus', () => { const brt = getTransitFeed('smtr-rio-brt'); assert.equal(transitRouteLabel(brt, '20000501110'), '50'); assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01'); - // A route added after the table was built keeps its raw key, never a blank - // or a name borrowed from the prototype chain. + // Unknown ids, prototype names included, keep their raw key. assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A'); assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor'); assert.equal(transitRouteLabel(brt, null), null); diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index 07911a939..f03ea2e30 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -286,8 +286,7 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { }); test('Rio speeds pass through as the m/s they are published in', () => { - // SMTR publishes m/s, as the spec requires: checked against how far - // vehicles moved between reports on 2026-11-05. + // Checked against vehicle movement on 2026-11-05. const bytes = encodeFeed({ entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }], });