diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md new file mode 100644 index 000000000..aacc37d71 --- /dev/null +++ b/changelog.d/1426-fire-intensity-filter.md @@ -0,0 +1,12 @@ +- Color every Active Fires detection by fire radiative power on a six-stop + ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at + 0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a + log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / + High confidence switch and an "N of M shown" counter to the layer row. Far + zoom shows a Snap Map–style heatmap in the same colors instead of squares, + one distinct spot per fire, empty where nothing burns, and cross-fades with + the markers around 4,500 km with no gap, keeping weak fires as markers. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query + agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` + takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All + (gekh, #1426). 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). 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab6dba5b0..81e71655c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2323,6 +2323,62 @@ Generated style sliders use the same name as their visible parameter label. The first-run suppression checkbox keeps its native wrapping label, so its accessible name remains "Don't show this again". Control behavior is unchanged. +## Active Fires intensity filter and color ramp + +Every FIRMS detection is colored by fire radiative power on a six-stop ember → +white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from +50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in +`src/data/firmsIntensity.js`). Each stop is lighter than the one below and +none is a cold hue, so the brightest marks are the strongest fires; ~90% of +detections are under 20 MW and stay dim red. Glow sprites and fire-card +accents read that ramp; confidence and brightness do not affect the color. + +At far zoom (camera above ~4,500 km) the layer drapes a Snap Map–style +heatmap instead of individual markers: the shown detections are linearly binned +(each detection's weight shared between the four nearest pixel centres, so spots +sit at the true position and stay round) on a +global 4096 × 2048 grid (≈10 km per pixel), each weighted by its FRP in megawatts (so heat is +radiated power, and raising the FRP slider does not dim areas where strong fires +burn, and a cluster of detections reads brighter than one), Gaussian-blurred by one +pixel so each fire stays a distinct spot about the size of its marker, +normalized on a log scale against the 99.5th percentile and mapped through the +same hues, from a bright red edge through orange and gold to a pale +white-hot core. The edge starts more than half opaque, so regions far below +the hottest ones still read over forest and savanna imagery. Nothing is painted where +nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery +tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +on each data refresh or filter change (the first far-band paint at once, +later ones 150 ms after the last change). Heatmap and markers are both kept +built at every zoom and cross-fade by camera height between 3,600 and +5,400 km, every frame, so a fast zoom never shows a gap: the heatmap fades by +imagery `alpha` (a hidden Cesium imagery layer is unmapped from the globe +tiles and re-mapped late when shown), the markers by sprite alpha, and the +marker collection is hidden once the heatmap is whole. The 750–4,500 km +markers band draws up to 40,000 detections (the strongest in the padded +view), so the weak fires the heatmap shows stay on screen as markers when it +fades out; it reads each marker's already-built anchor instead of the ground +floor cache, which only the close band warms. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +areas; their accent is the strongest fire's FRP color. +Re-enabling Active Fires keeps the retained heat imagery hidden when the current +filtered set is empty, including while a new snapshot is pending. + +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and +All, because many of the strongest detections are rated low confidence. The +filter runs once in `ingestion.applyFilter()` before rendering, so markers, +cards, the heatmap, the shared context store, the top-fires query and the +analyst records all see one filtered set; a selected fire that no longer passes +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. + +The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` +(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and +names the applied criteria in its notes. Stale-snapshot and missing-source +reporting are unchanged. + ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5a734bb12..2c589d8cc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -420,6 +420,7 @@ "src/layers/firms/cards.js", "src/layers/firms/index.js", "src/layers/firms/ingestion.js", + "src/layers/firms/heatVisibility.test.mjs", "src/layers/firms/lifecycle.js", "src/layers/firms/model.js", "src/layers/firms/policy.js", @@ -1210,6 +1211,10 @@ "src/data/headingDegrees.test.mjs", "src/data/distanceFormat.test.mjs", "src/sources/adsbFields.test.mjs", + "src/data/firmsIntensity.js", + "src/data/firmsIntensity.test.mjs", + "src/ui/layerPanel/RowSwitch.vue", + "src/ui/layerPanel/RowSlider.vue", "src/sources/phaseTiming.test.mjs", "scripts/build-census-places.mjs", "src/data/placeBoundaries.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a774993b2..d008ceaaa 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -685,6 +685,8 @@ "src/ui/rail/classToggle.js", "src/ui/rail/focusSafeChildren.js", "src/ui/layerPanel/RowText.vue", + "src/ui/layerPanel/RowSwitch.vue", + "src/ui/layerPanel/RowSlider.vue", "src/ui/railTimeline.js", "src/ui/clearLayersControl.js", "src/loadingFeedback.js", @@ -722,7 +724,8 @@ "src/ui/kit/UiButton.vue", "src/ui/kit/UiBadge.vue", "src/ui/rail/fieldValue.js", - "src/ui/kit/UiSlider.js" + "src/ui/kit/UiSlider.js", + "src/ui/kit/UiSegmented.vue" ], "external": ["vue"] }, @@ -946,6 +949,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", @@ -1018,6 +1022,8 @@ "src/ui/rail/classToggle.js", "src/ui/rail/focusSafeChildren.js", "src/ui/layerPanel/RowText.vue", + "src/ui/layerPanel/RowSwitch.vue", + "src/ui/layerPanel/RowSlider.vue", "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", @@ -1141,7 +1147,8 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/ui/kit/UiSegmented.vue" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1310,6 +1317,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1372,6 +1380,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1425,6 +1434,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1526,6 +1536,8 @@ "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1537,6 +1549,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", @@ -2000,6 +2013,7 @@ "modules": [ "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -2372,6 +2386,8 @@ "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", + "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2533,6 +2549,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2794,6 +2811,8 @@ "src/ui/rail/classToggle.js", "src/ui/rail/focusSafeChildren.js", "src/ui/layerPanel/RowText.vue", + "src/ui/layerPanel/RowSwitch.vue", + "src/ui/layerPanel/RowSlider.vue", "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", @@ -3333,6 +3352,8 @@ "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -3464,6 +3485,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", @@ -3987,6 +4009,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", @@ -4704,6 +4727,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/view/index.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", @@ -4825,6 +4849,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", @@ -4959,6 +4985,7 @@ "modules": [ "src/view/index.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/visualStylePolicy.js" diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index 3928051db..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,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.notEqual(card.accent, accentForSeverity('red')); + assert.equal(card.accent, accentForSeverity('ember')); +}); + +test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { + const accentAt = (frp) => + buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; + assert.equal(accentAt(4.9), accentForSeverity('ember')); + assert.equal(accentAt(5), accentForSeverity('red')); + assert.equal(accentAt(20), accentForSeverity('flame')); + assert.equal(accentAt(50), accentForSeverity('orange')); + assert.equal(accentAt(100), accentForSeverity('gold')); + assert.equal(accentAt(300), accentForSeverity('white-hot')); + // Confidence never moves the color: a low-confidence 864 MW fire stays red. + assert.equal( + buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, + accentForSeverity('white-hot'), + ); }); test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => { @@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', ( const candidate = { cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 }, position: { x: 0, y: 0, z: 0 }, - accent: accentForSeverity('orange'), + accent: accentForSeverity('gold'), }; const card = buildCellCard(candidate, NOW); assert.equal(card.title, '14 FIRES'); assert.equal(card.details[0], 'max 210 MW · new 3h'); - assert.equal(card.accent, accentForSeverity('orange')); + assert.equal(card.accent, accentForSeverity('gold')); }); test('buildCellCard: the count picks the plural form of the language', () => { @@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow'); + assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js new file mode 100644 index 000000000..6704cf6e2 --- /dev/null +++ b/src/data/firmsHeatRaster.js @@ -0,0 +1,209 @@ +/** + * Snap Map–style heat raster for the Active Fires far-zoom bands: detections + * are linearly binned on an equirectangular grid (row 0 = north, column + * 0 = 180°W), blurred, normalized on a log scale and mapped through + * HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested + * in node. + */ + +/** Below this normalized heat nothing is painted: empty land stays empty. */ +export const HEAT_FLOOR = 0.03; + +/** + * The FRP marker ramp as a continuous glow, from a red edge through orange + * and gold to a pale white-hot core. The edge is a bright red, not the + * markers' dark ember, and starts more than half opaque: most of the world + * sits low on a scale set by the hottest regions, and a dark translucent red + * vanishes into forest and savanna imagery. + */ +export const HEAT_PALETTE = Object.freeze([ + { at: 0.0, rgb: [232, 58, 30], alpha: 0 }, + { at: 0.08, rgb: [232, 58, 30], alpha: 0.58 }, + { at: 0.25, rgb: [242, 86, 34], alpha: 0.7 }, + { at: 0.45, rgb: [248, 122, 40], alpha: 0.76 }, + { at: 0.65, rgb: [252, 166, 52], alpha: 0.82 }, + { at: 0.82, rgb: [254, 208, 92], alpha: 0.87 }, + { at: 1.0, rgb: [255, 245, 190], alpha: 0.92 }, +]); + +export function heatColorAt(t) { + if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0]; + const v = Math.min(1, t); + let i = 1; + while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1; + const a = HEAT_PALETTE[i - 1]; + const b = HEAT_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + const mix = (p, q) => Math.round(p + (q - p) * k); + return [ + mix(a.rgb[0], b.rgb[0]), + mix(a.rgb[1], b.rgb[1]), + mix(a.rgb[2], b.rgb[2]), + Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255), + ]; +} + +/** Weight of a detection with no or zero FRP, in megawatts. */ +export const HEAT_MIN_WEIGHT_MW = 0.5; + +/** + * Heat is radiated power, not a head count: each detection adds its FRP in + * megawatts. Weak fires carry little power, so raising the minimum-FRP slider + * removes most detections without dimming the areas where strong fires burn + * (a count-based heat collapsed there and the hot spots faded). + */ +const defaultWeight = (fire) => + Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); + +/** + * Linear binning (Wand 1994): each detection's weight is shared between the + * four pixel centres around it in proportion to closeness, instead of + * dropping whole into one pixel. Spots then sit at the true position rather + * than snapping by up to half a pixel (~10 km), and a lone weak fire blurs + * into a round spot instead of a square or a plus sign. Longitude wraps; + * rows beyond the poles fold back onto the edge row. + */ +export function binFires(fires, { width, height, weight = defaultWeight }) { + const grid = new Float32Array(width * height); + const row = (y) => Math.min(height - 1, Math.max(0, y)) * width; + const col = (x) => ((x % width) + width) % width; + for (const fire of fires) { + if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; + const fx = ((fire.lon + 180) / 360) * width - 0.5; + const fy = ((90 - fire.lat) / 180) * height - 0.5; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const w = weight(fire); + const top = row(y0); + const bottom = row(y0 + 1); + const left = col(x0); + const right = col(x0 + 1); + grid[top + left] += w * (1 - tx) * (1 - ty); + grid[top + right] += w * tx * (1 - ty); + grid[bottom + left] += w * (1 - tx) * ty; + grid[bottom + right] += w * tx * ty; + } + return grid; +} + +function kernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const k = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { + k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma)); + sum += k[i + radius]; + } + for (let i = 0; i < k.length; i++) k[i] /= sum; + return { k, radius }; +} + +export function blurGaussian(grid, width, height, sigma) { + const { k, radius } = kernel(sigma); + const tmp = new Float32Array(grid.length); + const out = new Float32Array(grid.length); + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + const v = grid[row + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const xx = (((x + i) % width) + width) % width; // longitude wraps + tmp[row + xx] += v * k[i + radius]; + } + } + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const v = tmp[y * width + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp + out[yy * width + x] += v * k[i + radius]; + } + } + } + return out; +} + +/** Bins of the log-density histogram used to find the reference quantile. */ +const QUANTILE_BINS = 4096; + +/** + * The p-quantile of the non-zero values, from a histogram of log1p(value): + * one linear pass instead of sorting ~350k burning pixels (a sort cost + * 200 ms+ and dominated the build). Bin width on the log axis is + * max/4096, far finer than the 256-step palette it feeds. + */ +function percentileOfNonZero(values, p) { + let count = 0; + let max = 0; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) { + count += 1; + if (v > max) max = v; + } + } + if (!count) return 0; + const logMax = Math.log1p(max); + const bins = new Uint32Array(QUANTILE_BINS); + const toBin = (QUANTILE_BINS - 1) / logMax; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1; + } + const target = Math.floor(p * (count - 1)); + let seen = 0; + for (let b = 0; b < QUANTILE_BINS; b++) { + seen += bins[b]; + if (seen > target) return Math.expm1((b + 1) / toBin); + } + return max; +} + +/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */ +const LUT_STEPS = 256; +let paletteLut = null; +function heatLut() { + if (!paletteLut) { + paletteLut = new Uint8ClampedArray(LUT_STEPS * 4); + for (let i = 0; i < LUT_STEPS; i++) { + paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4); + } + } + return paletteLut; +} + +export function buildHeatRaster( + fires, + { width, height, sigmaPx, percentile = 0.995 }, +) { + const density = blurGaussian( + binFires(fires, { width, height }), + width, + height, + sigmaPx, + ); + const reference = percentileOfNonZero(density, percentile); + const rgba = new Uint8ClampedArray(width * height * 4); + if (reference > 0) { + const lut = heatLut(); + const scale = (LUT_STEPS - 1) / Math.log1p(reference); + for (let i = 0; i < density.length; i++) { + const d = density[i]; + if (d <= 0) continue; + const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale)); + const l = step * 4; + if (lut[l + 3] === 0) continue; + const o = i * 4; + rgba[o] = lut[l]; + rgba[o + 1] = lut[l + 1]; + rgba[o + 2] = lut[l + 2]; + rgba[o + 3] = lut[l + 3]; + } + } + return { width, height, rgba, reference }; +} diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs new file mode 100644 index 000000000..754493754 --- /dev/null +++ b/src/data/firmsHeatRaster.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + HEAT_MIN_WEIGHT_MW, + HEAT_PALETTE, + heatColorAt, + binFires, + blurGaussian, + buildHeatRaster, +} from './firmsHeatRaster.js'; + +const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); + +test('palette runs ember to white-hot and starts fully transparent', () => { + assert.equal(HEAT_PALETTE[0].at, 0); + assert.equal(HEAT_PALETTE.at(-1).at, 1); + assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); + assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); + const low = heatColorAt(0.15); + const high = heatColorAt(1); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red'); + assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery'); + assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); + assert.ok(high[3] > low[3], 'hotter is more opaque'); + for (let i = 1; i <= 100; i++) { + const [r, , b] = heatColorAt(i / 100); + assert.ok(r >= b, `no cold hue at ${i}%`); + } +}); + +test('binFires puts a detection at a pixel centre wholly in that pixel', () => { + // 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45. + const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], { + width: 4, + height: 2, + }); + assert.equal(grid[0], 1, 'north-west pixel'); + assert.equal(grid[7], 1, 'south-east pixel'); + assert.equal( + grid.reduce((a, b) => a + b, 0), + 2, + ); +}); + +test('binFires shares a detection between the pixels around it', () => { + const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 }); + assert.equal(between[1], 0.5, 'half to the pixel west of it'); + assert.equal(between[2], 0.5, 'half to the pixel east of it'); + const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 }); + assert.equal(quarter[0], 0.75, 'closer centre takes more'); + assert.equal(quarter[1], 0.25); + const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 }); + assert.equal(wrapped[3], 0.5, 'the antimeridian wraps'); + assert.equal(wrapped[0], 0.5); + const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 }); + assert.equal(polar[0], 1, 'beyond the last row folds back onto it'); +}); + +test('binFires weights each detection by its radiative power', () => { + const grid = binFires( + [fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)], + { width: 4, height: 2 }, + ); + assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); +}); + +test('filtering out weak fires keeps a strong fire hot', () => { + const strong = fire(0.1, 0.1, 300); + const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2)); + const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100)); + const options = { width: 360, height: 180, sigmaPx: 2 }; + const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0); + const red = (r) => r.rgba[pixel * 4]; + const alpha = (r) => r.rgba[pixel * 4 + 3]; + const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options); + const filtered = buildHeatRaster([strong, ...reference], options); + assert.ok(alpha(filtered) > 0); + assert.ok( + alpha(filtered) >= alpha(unfiltered) * 0.9, + `alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`, + ); + assert.ok( + red(filtered) >= red(unfiltered) * 0.9, + `red ${red(unfiltered)} -> ${red(filtered)}`, + ); +}); + +test('blurGaussian keeps mass and wraps across the antimeridian', () => { + const w = 16; + const h = 8; + const grid = new Float32Array(w * h); + grid[3 * w + 0] = 1; + const out = blurGaussian(grid, w, h, 1.5); + const sum = out.reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`); + assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge'); +}); + +test('buildHeatRaster is transparent where there are no fires', () => { + const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], { + width: 360, + height: 180, + sigmaPx: 2, + }); + assert.equal(r.rgba.length, 360 * 180 * 4); + const alphaAt = (lat, lon) => { + const x = Math.floor(((lon + 180) / 360) * 360); + const y = Math.floor(((90 - lat) / 180) * 180); + return r.rgba[(y * 360 + x) * 4 + 3]; + }; + assert.ok(alphaAt(40, -120) > 0, 'blob over the fires'); + assert.equal(alphaAt(-30, 60), 0, 'empty far away'); +}); + +test('buildHeatRaster with no fires is fully transparent', () => { + const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); + assert.ok(r.rgba.every((v) => v === 0)); +}); diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js new file mode 100644 index 000000000..0b14dd3bd --- /dev/null +++ b/src/data/firmsIntensity.js @@ -0,0 +1,212 @@ +/** + * @module firmsIntensity + * @description Fire intensity policy shared by the Active Fires layer and the + * agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale + * slider that doubles as its legend, and the one filter every consumer + * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs + * before anything is rendered or selected. Pure — no Cesium, no DOM. + * + * The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling + * down: a dim ember for weak fires through red, orange and gold to a pale + * white-hot core, lightness rising with power, so the brightest marks are the + * strongest fires. No cold hues: ~90% of detections are under 20 MW and a + * blue start painted most of the map blue. The thresholds are GEV's own, set + * on a real week's percentiles so every color appears: Portolan's + * 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675). + */ + +/** + * Stepped FRP ramp, weakest first. A detection takes the LAST stop whose + * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. + */ +export const FRP_RAMP = Object.freeze([ + Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }), + Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }), + Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }), + Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }), + Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }), + Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }), +]); + +/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ +export const FRP_SLIDER_TICKS = Object.freeze( + FRP_RAMP.map((stop) => stop.minFrp), +); + +/** Minimum-FRP values the share codec accepts, in whole megawatts. */ +export const MIN_FRP_MW_MIN = 0; +export const MIN_FRP_MW_MAX = 10_000; + +/** Confidence switch positions; VIIRS reports only low / nominal / high. */ +export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']); + +/** + * Normalized-confidence floor for a switch position. The thresholds match + * `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt). + */ +const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 }); + +export const DEFAULT_FIRE_FILTER = Object.freeze({ + minFrp: 0, + confidence: 'all', +}); + +/** + * The ramp stop for a fire radiative power. + * @param {number} frp Megawatts; non-finite or negative reads as 0. + * @returns {{name: string, minFrp: number, hex: string}} + */ +export function frpRampStop(frp) { + const value = Number.isFinite(frp) ? Math.max(0, frp) : 0; + let stop = FRP_RAMP[0]; + for (const candidate of FRP_RAMP) { + if (value >= candidate.minFrp) stop = candidate; + else break; + } + return stop; +} + +/** `#rrggbb` → `[r, g, b]` in 0..255. */ +export function hexToRgb(hex) { + const text = String(hex).replace('#', ''); + return [0, 2, 4].map((offset) => + Number.parseInt(text.slice(offset, offset + 2), 16), + ); +} + +/** + * Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest + * of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1 + * MW because the median detection is ~6 MW and a sub-megawatt floor hides + * nothing worth a tick. + */ +export const SLIDER_MAX_MW = 1000; +const LOG_MAX = Math.log10(SLIDER_MAX_MW); + +/** + * Track position in 0..1 for a minimum FRP. + * @param {number} minFrp Megawatts. + * @returns {number} + */ +export function sliderPositionForFrp(minFrp) { + if (!Number.isFinite(minFrp) || minFrp < 1) return 0; + return Math.min(1, Math.log10(minFrp) / LOG_MAX); +} + +/** + * Minimum FRP for a track position, rounded to whole megawatts so a slider + * value and its share-link form are the same number. + * @param {number} position 0..1. + * @returns {number} + */ +export function frpForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + if (p <= 0) return 0; + const frp = Math.pow(10, p * LOG_MAX); + return Math.max(1, Math.round(frp)); +} + +/** + * CSS `linear-gradient` painting the stepped ramp along the log axis, so the + * slider track IS the legend: the color under the thumb is the color of the + * weakest fire still shown. + * @returns {string} + */ +export function rampTrackGradient() { + const parts = []; + FRP_RAMP.forEach((stop, index) => { + const start = sliderPositionForFrp(stop.minFrp) * 100; + const next = FRP_RAMP[index + 1]; + const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100; + parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`); + }); + return `linear-gradient(90deg, ${parts.join(', ')})`; +} + +/** + * Minimum FRP → whole megawatts inside the codec bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeMinFrp(value) { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : NaN; + if (!Number.isFinite(number)) return null; + const rounded = Math.round(number); + return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX + ? rounded + : null; +} + +/** + * Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null. + * @param {*} value + * @returns {?string} + */ +export function normalizeConfidenceMode(value) { + const text = String(value ?? '') + .trim() + .toLowerCase(); + return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null; +} + +/** + * Sanitize a partial filter against the current one; unknown or invalid + * fields keep their current value. + * @param {Object} [params] + * @param {{minFrp: number, confidence: string}} [current] + * @returns {{minFrp: number, confidence: string}} + */ +export function normalizeFireFilter( + params = {}, + current = DEFAULT_FIRE_FILTER, +) { + const minFrp = normalizeMinFrp(params?.minFrp); + const confidence = normalizeConfidenceMode(params?.confidence); + return { + minFrp: minFrp === null ? current.minFrp : minFrp, + confidence: confidence === null ? current.confidence : confidence, + }; +} + +/** + * Whether one fire passes the filter. `confidence` is the normalized 0..1 + * value firmsAdapt produces; `frp` is megawatts. + * @param {{frp: number, confidence: number}} fire + * @param {{minFrp: number, confidence: string}} filter + * @returns {boolean} + */ +export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) { + const frp = Number.isFinite(fire?.frp) ? fire.frp : 0; + if (frp < (filter.minFrp || 0)) return false; + const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0; + if (floor <= 0) return true; + const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0; + return confidence >= floor; +} + +/** + * The fires that pass the filter, in input order. Returns the SAME array when + * the filter is the default, so callers can tell "nothing hidden" cheaply. + * @template T + * @param {Array} 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..c1d22948b --- /dev/null +++ b/src/data/firmsIntensity.test.mjs @@ -0,0 +1,221 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_FIRE_FILTER, + FIRMS_CONFIDENCE_MODES, + FRP_RAMP, + FRP_SLIDER_TICKS, + filterFires, + firePassesFilter, + frpForSliderPosition, + frpRampStop, + hexToRgb, + isDefaultFireFilter, + normalizeConfidenceMode, + normalizeFireFilter, + normalizeMinFrp, + rampTrackGradient, + sliderPositionForFrp, +} from './firmsIntensity.js'; + +test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => { + assert.deepEqual( + FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), + [ + ['ember', 0, '#9e2a1b'], + ['red', 5, '#d2401f'], + ['flame', 20, '#f06a28'], + ['orange', 50, '#fb9a2e'], + ['gold', 100, '#fdc84a'], + ['white-hot', 300, '#fff3b0'], + ], + ); + assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); + assert.ok(Object.isFrozen(FRP_RAMP)); +}); + +test('every stop is warm and each is lighter than the one below', () => { + const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; + let previous = -1; + for (const stop of FRP_RAMP) { + const rgb = hexToRgb(stop.hex); + assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`); + assert.ok( + luma(rgb) > previous, + `${stop.name} is lighter than the stop below`, + ); + previous = luma(rgb); + } +}); + +test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { + assert.equal(frpRampStop(0).name, 'ember'); + assert.equal(frpRampStop(4.99).name, 'ember'); + assert.equal(frpRampStop(5).name, 'red'); + assert.equal(frpRampStop(19.9).name, 'red'); + assert.equal(frpRampStop(20).name, 'flame'); + assert.equal(frpRampStop(50).name, 'orange'); + assert.equal(frpRampStop(100).name, 'gold'); + assert.equal(frpRampStop(299.9).name, 'gold'); + assert.equal(frpRampStop(300).name, 'white-hot'); + assert.equal(frpRampStop(1246).name, 'white-hot'); + assert.equal(frpRampStop(NaN).name, 'ember'); + assert.equal(frpRampStop(-3).name, 'ember'); + assert.equal(frpRampStop(undefined).name, 'ember'); +}); + +test('hex stops convert to the "r, g, b" accents the cards paint', () => { + assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]); + assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); +}); + +test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => { + assert.equal(sliderPositionForFrp(0), 0); + assert.equal(sliderPositionForFrp(0.5), 0); + assert.equal(sliderPositionForFrp(1), 0); + assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9); + assert.equal(sliderPositionForFrp(1000), 1); + assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end'); + assert.equal(frpForSliderPosition(0), 0); + assert.equal(frpForSliderPosition(1), 1000); + assert.equal(frpForSliderPosition(-1), 0); + assert.equal(frpForSliderPosition(2), 1000); + for (const frp of FRP_SLIDER_TICKS) { + assert.equal( + frpForSliderPosition(sliderPositionForFrp(frp)), + frp, + `tick ${frp} MW round-trips through the axis`, + ); + } + // Ticks spread along the track instead of bunching at one end. + const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp); + for (let i = 1; i < positions.length; i += 1) { + assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`); + } +}); + +test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { + const gradient = rampTrackGradient(); + assert.match(gradient, /^linear-gradient\(90deg, /); + assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/); + assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/); + assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); +}); + +test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => { + assert.equal(normalizeMinFrp(0), 0); + assert.equal(normalizeMinFrp(19.6), 20); + assert.equal(normalizeMinFrp('100'), 100); + assert.equal(normalizeMinFrp(10000), 10000); + assert.equal(normalizeMinFrp(10001), null); + assert.equal(normalizeMinFrp(-1), null); + assert.equal(normalizeMinFrp('abc'), null); + assert.equal(normalizeMinFrp(''), null); + assert.equal(normalizeMinFrp(null), null); + assert.equal(normalizeMinFrp(NaN), null); +}); + +test('confidence modes are exactly all / nominal / high', () => { + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(normalizeConfidenceMode(' High '), 'high'); + assert.equal(normalizeConfidenceMode('nominal'), 'nominal'); + assert.equal(normalizeConfidenceMode('low'), null); + assert.equal(normalizeConfidenceMode(0.9), null); + assert.equal(normalizeConfidenceMode(undefined), null); +}); + +test('a partial filter patches the current one and invalid fields keep their value', () => { + assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER); + assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), { + minFrp: 20, + confidence: 'all', + }); + assert.deepEqual( + normalizeFireFilter( + { confidence: 'high' }, + { minFrp: 50, confidence: 'all' }, + ), + { minFrp: 50, confidence: 'high' }, + ); + assert.deepEqual( + normalizeFireFilter( + { minFrp: 'nope', confidence: 'medium' }, + { minFrp: 50, confidence: 'nominal' }, + ), + { minFrp: 50, confidence: 'nominal' }, + ); + assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true); + assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false); +}); + +test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => { + const low = { frp: 864, confidence: 0.3 }; + const nominal = { frp: 20, confidence: 0.6 }; + const high = { frp: 2, confidence: 0.9 }; + assert.equal(firePassesFilter(low), true, 'default shows everything'); + assert.equal( + firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }), + false, + ); + assert.equal( + firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }), + false, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }), + false, + ); + assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true); + assert.equal( + firePassesFilter(high, { minFrp: 5, confidence: 'high' }), + false, + ); + // Missing numbers read as 0, never as a pass-through. + assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false); + assert.equal( + firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }), + false, + ); +}); + +test('filterFires returns the same array for the default filter and preserves order otherwise', () => { + const fires = [ + { frp: 864, confidence: 0.3 }, + { frp: 20, confidence: 0.6 }, + { frp: 2, confidence: 0.9 }, + { frp: 120, confidence: 0.9 }, + ]; + assert.equal(filterFires(fires), fires); + assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires); + assert.deepEqual( + filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp), + [864, 20, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp), + [2, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map( + (f) => f.frp, + ), + [120], + ); + assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []); +}); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..8e12a435d 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'), @@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().loading, false); } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); + +test('the heatmap is preloaded under the markers before the first zoom-out', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const priorDocument = globalThis.document; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), + }); + h.viewer.camera = { positionCartographic: { height: 1_000_000 } }; + h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + const added = []; + try { + h.layer.attachShellServices({ + imageryHost: () => ({ + kind: 'globe', + collection: { + addImageryProvider: (provider) => { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove() {}, + raiseToTop() {}, + }, + }), + }); + h.layer._renderLodForTest('close'); + assert.equal(added.length, 0, 'the markers paint first; the build trails'); + t.mock.timers.tick(HEAT_REBUILD_MS); + assert.equal(added.length, 1, 'then the heatmap is draped'); + assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); + assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); + } finally { + h.layer.destroy(h.viewer); + h.cleanup(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => { + // A canvas stub for the heat drape and the detection glow sprites. + const priorDocument = globalThis.document; + const restoreDocument = () => { + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + }; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + const added = []; + const removed = []; + let markers = null; + // The fade reads the camera height; the cull reads scene.camera. + const setHeight = (height) => { + h.viewer.camera = { positionCartographic: { height } }; + }; + setHeight(12_000_000); + h.viewer.scene.primitives = { + add(primitive) { + markers = primitive; + }, + remove() {}, + raiseToTop() {}, + contains: () => true, + }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + try { + const collection = { + addImageryProvider: (provider) => { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove: (layer, destroy) => removed.push({ layer, destroy }), + raiseToTop() {}, + }; + h.layer.attachShellServices({ + imageryHost: () => ({ kind: 'globe', collection }), + }); + h.layer._renderLodForTest('global'); + assert.equal(added.length, 1, 'one heat imagery layer'); + assert.equal(added[0].show, true); + assert.equal(added[0].alpha, 1); + assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.ok(h.published.length > 0, 'the far band still paints cards'); + assert.equal(markers.length, 2, 'the markers are built under the heatmap'); + assert.equal(markers.show, false, 'and hidden while the heatmap is whole'); + + h.layer._renderLodForTest('regional'); + assert.equal(added.length, 1, 'switching far bands reuses the raster'); + + setHeight(1_000_000); + h.layer._renderLodForTest('close'); + assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); + assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); + assert.equal(added[0].alpha, 0, 'faded out under the markers'); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 1); + + // Mid hand-off both are drawn, half each, in either band. + setHeight(4_500_000); + h.layer._applyHandoffFadeForTest(); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.show, true); + assert.equal(markers.get(0).color.alpha, 0.5); + h.layer._renderLodForTest('regional'); + assert.equal(added[0].alpha, 0.5); + assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade'); + + h.layer.disable(); + assert.equal(added[0].show, false, 'disable hides the heatmap'); + + h.layer.destroy(h.viewer); + assert.deepEqual( + removed.map((entry) => [entry.layer, entry.destroy]), + [[added[0], true]], + ); + } finally { + h.cleanup(); + restoreDocument(); + } +}); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index b9e9d1765..bf97982c2 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 weakest stop). + * @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot'). * @returns {string} */ export function accentForSeverity(stopName) { - return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; + return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name]; } /** diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 8573d7330..a141d0d17 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, 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/data/layerState.js b/src/data/layerState.js index 968c53520..46dadf9a0 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', @@ -626,7 +640,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'd', disposition: 'enabled-only', }), - Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ + id: 'local-firms', + token: 'w', + disposition: 'enabled+options', + optionOwner: 'local-firms', + }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 9d9bb29b5..9d30e8973 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; @@ -3007,9 +3008,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => { gate.resolve(); const results = await restore; for (const id of ['earthquakes', 'flights']) { - assert.equal(results.find((result) => result.layerId === id).succeeded, false); + assert.equal( + results.find((result) => result.layerId === id).succeeded, + false, + ); } assert.deepEqual([...manager.getEnabledLayerIds()], []); assert.equal(enabledLayerParam(sink), ''); coordinator.destroy(); }); + +/** Active Fires options decoded from a v2 link with these `lo` assignments. */ +function firmsOptions(lo) { + const params = new URLSearchParams([ + ['v', '2'], + ['l', 'w'], + ]); + if (lo) params.set('lo', lo); + return decodeLayerStateParams(params).options['local-firms']; +} + +test('Active Fires: the intensity filter rides share token w and round-trips', () => { + const state = createDefaultLayerState(); + assert.deepEqual(state.options['local-firms'], { + minFrp: 0, + confidence: 'all', + }); + state.enabledLayerIds = ['local-firms']; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + const encoded = encode(state); + assert.equal(new URLSearchParams(encoded).get('l'), 'w'); + // Other owners write their own explicit defaults into `lo` too (flights 3D, + // wind overlay); the fire assignments ride beside them. + const assignments = new URLSearchParams(encoded).get('lo').split('_'); + assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); + assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); + const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); + assert.deepEqual(decoded.options['local-firms'], { + minFrp: 20, + confidence: 'high', + }); + assert.deepEqual( + parseStoredLayerState(serializeStoredLayerState(state)).options[ + 'local-firms' + ], + state.options['local-firms'], + ); + // Defaults are omitted from the link: a plain `l=w` is "show everything". + const plain = createDefaultLayerState(); + plain.enabledLayerIds = ['local-firms']; + assert.equal( + new URLSearchParams(encode(plain)) + .get('lo') + .split('_') + .filter((assignment) => assignment.startsWith('w.')).length, + 0, + ); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); +}); + +test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { + // The codec spells its bounds out (it runs in isolation in the ledger + // tests); they must stay the layer's. + assert.equal(MIN_FRP_MW_MAX, 10000); + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(firmsOptions('w.f.300').minFrp, 300); + assert.equal(firmsOptions('w.f.10000').minFrp, 10000); + assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default'); + assert.equal(firmsOptions('w.f.-5').minFrp, 0); + assert.equal( + firmsOptions('w.f.2.5').minFrp, + 0, + 'a third segment is rejected', + ); + assert.equal(firmsOptions('w.c.a').confidence, 'all'); + assert.equal(firmsOptions('w.c.n').confidence, 'nominal'); + assert.equal(firmsOptions('w.c.h').confidence, 'high'); + assert.equal( + firmsOptions('w.c.x').confidence, + 'all', + 'unknown code → default', + ); + assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { + minFrp: 5, + confidence: 'nominal', + }); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 629bcacab..c9fca14ce 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3214,6 +3214,19 @@ "Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل", "Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة", "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", + "fire confidence filter|All": "الكل", + "fire confidence filter|Nominal+": "متوسطة فأعلى", + "fire confidence filter|High": "عالية", + "Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة", + "Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية", + "High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط", + "Fire intensity (FRP)": "الشدة (FRP)", + "≥ {value} MW": "{value} MW على الأقل", + "Confidence": "الثقة", + "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", + "fire intensity filter|All": "الكل", + "Detection confidence": "ثقة الرصد", + "{shown} of {total} shown": "المعروضة: {shown} من {total}", "Name of {name}": "اسم {name}", "Change the colour of {name}": "تغيير لون {name}", "Colour of {name}": "لون {name}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e795801fd..bb683ed9f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2946,6 +2946,19 @@ "Right panels": "Rechte Panels", "CONFIRMED": "BESTÄTIGT", "PAD / RTLS": "STARTPLATZ / RTLS", + "fire confidence filter|All": "Alle", + "fire confidence filter|Nominal+": "Mittel+", + "fire confidence filter|High": "Hoch", + "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz", + "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", + "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", + "Fire intensity (FRP)": "Intensität (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Konfidenz", + "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "fire intensity filter|All": "Alle", + "Detection confidence": "Konfidenz der Erkennung", + "{shown} of {total} shown": "{shown} von {total} angezeigt", "Name of {name}": "Name von {name}", "Change the colour of {name}": "Farbe von {name} ändern", "Colour of {name}": "Farbe von {name}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0dfc4dea3..10dcbef8f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2983,6 +2983,19 @@ "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "fire confidence filter|All": "Todas", + "fire confidence filter|Nominal+": "Nominal+", + "fire confidence filter|High": "Alta", + "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", + "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", + "High confidence detections only": "Solo detecciones de confianza alta", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", + "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "fire intensity filter|All": "Todo", + "Detection confidence": "Confianza de la detección", + "{shown} of {total} shown": "{shown} de {total} mostrados", "{lower} to {upper}": "{lower} a {upper}", "Up to {upper}": "Hasta {upper}", "From {lower}": "Desde {lower}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 27c0453c4..108e56f36 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3016,6 +3016,19 @@ "Right panels": "Panneaux de droite", "CONFIRMED": "CONFIRMÉE", "PAD / RTLS": "PAS DE TIR / RTLS", + "fire confidence filter|All": "Toutes", + "fire confidence filter|Nominal+": "Nominale+", + "fire confidence filter|High": "Élevée", + "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible", + "Nominal and high confidence detections": "Détections de confiance nominale et élevée", + "High confidence detections only": "Détections de confiance élevée uniquement", + "Fire intensity (FRP)": "Intensité (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiance", + "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "fire intensity filter|All": "Toutes", + "Detection confidence": "Confiance de la détection", + "{shown} of {total} shown": "{shown} sur {total} affichées", "Name of {name}": "Nom de {name}", "Change the colour of {name}": "Changer la couleur de {name}", "Colour of {name}": "Couleur de {name}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index a089ba758..6768f93c7 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3016,6 +3016,19 @@ "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", "PAD / RTLS": "SITO DI LANCIO / RTLS", + "fire confidence filter|All": "Tutti", + "fire confidence filter|Nominal+": "Nominale+", + "fire confidence filter|High": "Alta", + "Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa", + "Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta", + "High confidence detections only": "Solo rilevamenti con confidenza alta", + "Fire intensity (FRP)": "Intensità (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confidenza", + "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", + "fire intensity filter|All": "Tutti", + "Detection confidence": "Confidenza del rilevamento", + "{shown} of {total} shown": "{shown} di {total} mostrati", "Name of {name}": "Nome di {name}", "Change the colour of {name}": "Cambia il colore di {name}", "Colour of {name}": "Colore di {name}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1536306f1..b4566b4cb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2876,6 +2876,19 @@ "Right panels": "右パネル", "CONFIRMED": "確認済み", "PAD / RTLS": "射場 / RTLS", + "fire confidence filter|All": "すべて", + "fire confidence filter|Nominal+": "中以上", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています", + "Nominal and high confidence detections": "信頼度が中・高の検知", + "High confidence detections only": "信頼度が高の検知のみ", + "Fire intensity (FRP)": "強度(FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "信頼度", + "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "fire intensity filter|All": "すべて", + "Detection confidence": "検知の信頼度", + "{shown} of {total} shown": "{total}件中{shown}件を表示", "Name of {name}": "{name}の名前", "Change the colour of {name}": "{name}の色を変更", "Colour of {name}": "{name}の色", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index aa57b24a4..3f37cde2e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3016,6 +3016,19 @@ "Right panels": "Painéis à direita", "CONFIRMED": "CONFIRMADA", "PAD / RTLS": "PLATAFORMA / RTLS", + "fire confidence filter|All": "Todas", + "fire confidence filter|Nominal+": "Nominal+", + "fire confidence filter|High": "Alta", + "Every detection — many of the strongest fires are rated low confidence": "Todas as detecções — muitos dos incêndios mais intensos têm confiança baixa", + "Nominal and high confidence detections": "Detecções de confiança nominal e alta", + "High confidence detections only": "Somente detecções de confiança alta", + "Fire intensity (FRP)": "Intensidade (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiança", + "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", + "fire intensity filter|All": "Todas", + "Detection confidence": "Confiança da detecção", + "{shown} of {total} shown": "{shown} de {total} exibidas", "Name of {name}": "Nome de {name}", "Change the colour of {name}": "Mudar a cor de {name}", "Colour of {name}": "Cor de {name}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index df4e575d3..e279508ef 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3053,6 +3053,19 @@ "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "fire confidence filter|All": "Все", + "fire confidence filter|Nominal+": "Средняя+", + "fire confidence filter|High": "Высокая", + "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", + "Nominal and high confidence detections": "Детекции средней и высокой достоверности", + "High confidence detections only": "Только детекции высокой достоверности", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", + "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "fire intensity filter|All": "Все", + "Detection confidence": "Достоверность детекции", + "{shown} of {total} shown": "показано {shown} из {total}", "{lower} to {upper}": "от {lower} до {upper}", "Up to {upper}": "До {upper}", "From {lower}": "От {lower}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 70d4accf9..8c3b634b9 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3045,6 +3045,19 @@ "Don't show this again": "Більше не показувати", "ESC to dismiss": "ESC — приховати", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.", + "fire confidence filter|All": "Усі", + "fire confidence filter|Nominal+": "Середня+", + "fire confidence filter|High": "Висока", + "Every detection — many of the strongest fires are rated low confidence": "Усі виявлення — багато найсильніших пожеж мають низьку достовірність", + "Nominal and high confidence detections": "Виявлення середньої та високої достовірності", + "High confidence detections only": "Лише виявлення високої достовірності", + "Fire intensity (FRP)": "Інтенсивність (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достовірність", + "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", + "fire intensity filter|All": "Усі", + "Detection confidence": "Достовірність виявлення", + "{shown} of {total} shown": "показано {shown} з {total}", "{lower} to {upper}": "від {lower} до {upper}", "Up to {upper}": "До {upper}", "From {lower}": "Від {lower}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f583c201a..6125315f2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2843,6 +2843,19 @@ "other": "{count} 个地标" }, "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "fire confidence filter|All": "全部", + "fire confidence filter|Nominal+": "标称+", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", + "Nominal and high confidence detections": "标称和高置信度的检测", + "High confidence detections only": "仅高置信度的检测", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "fire intensity filter|All": "全部", + "Detection confidence": "检测置信度", + "{shown} of {total} shown": "显示 {shown} / {total}", "{lower} to {upper}": "{lower} 至 {upper}", "Up to {upper}": "最高 {upper}", "From {lower}": "自 {lower} 起", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 77045451d..66eb37b96 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2882,6 +2882,19 @@ "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Left panels": "左側面板", "Right panels": "右側面板", + "fire confidence filter|All": "全部", + "fire confidence filter|Nominal+": "標稱+", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", + "Nominal and high confidence detections": "標稱和高置信度的檢測", + "High confidence detections only": "僅高置信度的檢測", + "Fire intensity (FRP)": "火勢強度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", + "fire intensity filter|All": "全部", + "Detection confidence": "檢測置信度", + "{shown} of {total} shown": "顯示 {shown} / {total}", "UTILITIES": "工具", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 51cd4edec..7edaad647 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -201,6 +201,7 @@ "{value} KM/S", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "es": [ @@ -307,6 +308,7 @@ "WX", "audio {state}", "dB", + "fire confidence filter|Nominal+", "fire confidence|nominal", "ft", "hPa", @@ -329,6 +331,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -515,6 +518,7 @@ "ΔE", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "it": [ @@ -687,6 +691,7 @@ "{value} KM/H", "{value} KM/S", "{value} conf", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -738,6 +743,7 @@ "{type} SPD: {speed} HDG: {heading}", "ΔE", "ΔN", + "≥ {value} MW", "▲ {frp} MW" ], "pt": [ @@ -851,6 +857,7 @@ "WHOT/BHOT", "WX", "dB", + "fire confidence filter|Nominal+", "fire confidence|nominal", "ft", "hPa", @@ -869,7 +876,8 @@ "{value} KM/H", "{value} KM/S", "ΔN", - "▲ {frp} MW" + "▲ {frp} MW", + "≥ {value} MW" ], "ru": [ "AIS: --", @@ -928,9 +936,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -955,6 +963,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -1012,6 +1021,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js new file mode 100644 index 000000000..710a87f43 --- /dev/null +++ b/src/layers/firms/heatImagery.js @@ -0,0 +1,88 @@ +/** + * Drapes the Active Fires heat raster over the globe as one full-extent + * imagery tile on the active imagery host (globe or 3D-tiles), like the wind + * layer's scalar field. Owns the canvas and the imagery layer; nothing else + * in the fires layer touches imagery. + * + * `show` is only for the layer being on or off. The zoom hand-off fades + * `alpha` instead: Cesium unmaps a hidden imagery layer from the globe + * tiles and re-maps it when shown, so a show toggle at the band edge left + * a beat with neither markers nor heat. At alpha 0 the layer stays mapped + * and costs no draw. + */ + +const defaultCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createHeatImagery({ + cesium, + getHost, + createCanvas = defaultCanvas, +}) { + let raster = null; + let layer = null; + let collection = null; + let show = true; + let opacity = 1; + + function detach() { + if (layer && collection) collection.remove(layer, true); + layer = null; + collection = null; + } + + function attach() { + detach(); + if (!raster) return; + const host = getHost?.() ?? { collection: null, kind: 'none' }; + if (!host.collection) return; + const canvas = createCanvas(raster.width, raster.height); + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(raster.width, raster.height); + image.data.set(raster.rgba); + ctx.putImageData(image, 0, 0); + const provider = new cesium.SingleTileImageryProvider({ + url: canvas.toDataURL('image/png'), + tileWidth: raster.width, + tileHeight: raster.height, + rectangle: cesium.Rectangle.MAX_VALUE, + }); + collection = host.collection; + layer = collection.addImageryProvider(provider); + layer.show = show; + layer.alpha = opacity; + collection.raiseToTop?.(layer); + } + + return { + setRaster(next) { + raster = next; + attach(); + }, + /** True once a raster was handed over (painted or waiting for a host). */ + hasRaster() { + return raster !== null; + }, + setShow(value) { + show = Boolean(value); + if (layer) layer.show = show; + }, + /** Hand-off fade, 0..1; the layer stays mapped at 0. */ + setOpacity(value) { + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + if (layer) layer.alpha = opacity; + }, + rehome() { + const host = getHost?.(); + if ((host?.collection ?? null) !== collection) attach(); + }, + destroy() { + detach(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs new file mode 100644 index 000000000..27f45f55f --- /dev/null +++ b/src/layers/firms/heatImagery.test.mjs @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeatImagery } from './heatImagery.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { HEAT_RASTER } from './policy.js'; + +function harness() { + const layers = []; + const removed = []; + const collection = { + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + layers.push(layer); + return layer; + }, + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + raiseToTop() {}, + }; + let host = { collection, kind: 'globe' }; + const cesium = { + Rectangle: { MAX_VALUE: { max: true } }, + SingleTileImageryProvider: class { + constructor(options) { + this.options = options; + } + }, + }; + const createCanvas = (w, h) => ({ + width: w, + height: h, + getContext: () => ({ + createImageData: (cw, ch) => ({ + data: new Uint8ClampedArray(cw * ch * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }); + const imagery = createHeatImagery({ + cesium, + getHost: () => host, + createCanvas, + }); + return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; +} + +const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + +test('setRaster drapes one full-globe tile and replaces it on the next build', () => { + const h = harness(); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1); + assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); + assert.equal(h.layers[0].provider.options.tileWidth, 4); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1, 'never two heat layers'); + assert.equal(h.removed[0].destroy, true); +}); + +test('setShow toggles without rebuilding', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setShow(false); + assert.equal(layer.show, false); + h.imagery.setShow(true); + assert.equal(h.layers[0], layer); + assert.equal(layer.show, true); +}); + +test('setOpacity fades the same layer and survives a rebuild', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setOpacity(0); + assert.equal(layer.alpha, 0); + assert.equal(layer.show, true, 'a faded layer stays mapped'); + h.imagery.setOpacity(0.25); + assert.equal(h.layers[0], layer); + assert.equal(layer.alpha, 0.25); + h.imagery.setRaster(raster); + assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade'); + h.imagery.setOpacity(7); + assert.equal(h.layers[0].alpha, 1); +}); + +test('no imagery host keeps nothing draped; rehome brings it back', () => { + const h = harness(); + h.setHost({ collection: null, kind: 'none' }); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 0); + h.setHost({ collection: h.collection, kind: 'globe' }); + h.imagery.rehome(); + assert.equal(h.layers.length, 1); +}); + +test('destroy removes the layer', () => { + const h = harness(); + h.imagery.setRaster(raster); + h.imagery.destroy(); + assert.equal(h.layers.length, 0); +}); + +test('the shipped raster paints a lone weak fire as a distinct spot', () => { + // A hot cluster elsewhere sets the scale, as Africa does on real data. + const fires = [{ lat: 0, lon: 0, frp: 5 }]; + for (let i = 0; i < 40; i++) { + fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 }); + } + const r = buildHeatRaster(fires, HEAT_RASTER); + const y = Math.floor((90 / 180) * r.height); + const painted = []; + for (let x = 0; x < r.width; x++) { + const lon = ((x + 0.5) / r.width) * 360 - 180; + if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) { + painted.push(x); + } + } + const kmPerPx = 40075 / r.width; + assert.ok(painted.length > 0, 'the weak fire still shows'); + assert.ok( + painted.length * kmPerPx <= 100, + `spot ${Math.round(painted.length * kmPerPx)} km wide`, + ); +}); 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/ingestion.js b/src/layers/firms/ingestion.js index 494ab5f1e..cb9dbd72f 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,26 @@ export function createIngestion({ } } + /** + * Run the intensity filter over the loaded payload. This is the ONE place + * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, + * the heatmap, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells and + * the heat raster are built per shown set, so their caches go with it. + */ + + function applyFilter() { + layerState._fires = filterFires(layerState._allFires, layerState._filter); + layerState._cellCacheByGrid.clear(); + layerState._heatRaster = null; + layerState._firesByFrp = filterFires( + layerState._allFiresByFrp, + layerState._filter, + ); + layerState._count = layerState._fires.length; + layerState._totalCount = layerState._allFires.length; + } + /** * Find the record in the freshly-loaded set matching a previous selection. * Identity is the detection itself (lat/lon/acquisition time) — indices @@ -109,5 +132,5 @@ export function createIngestion({ }, }; - return { loadHeatmap, findMatchingFire, methods }; + return { loadHeatmap, applyFilter, findMatchingFire, methods }; } diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..90d9cc217 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -14,6 +14,20 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { + /** + * Shell services: `imageryHost()` resolves where draped imagery goes on + * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The + * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services + */ + attachShellServices(services) { + layerState._imageryHost = + typeof services?.imageryHost === 'function' + ? services.imageryHost + : null; + layerState._heatImagery?.rehome(); + }, + init(viewer) { if (layerState._destroyed) return; layerState._viewer = viewer; @@ -40,6 +54,10 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + // A filter can leave an old raster cached while the current set is empty. + layerState._heatImagery?.setShow(layerState._fires.length > 0); + // The camera can have crossed the hand-off while the layer was off. + components.rendering.applyHandoffFade(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -64,6 +82,7 @@ export function createLifecycle({ components.selection.clearFireSelection(); if (layerState._dataSource) layerState._dataSource.show = false; if (layerState._billboards) layerState._billboards.show = false; + layerState._heatImagery?.setShow(false); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -91,10 +110,21 @@ export function createLifecycle({ components.rendering.removeDetectionCollections(viewer); components.selection.clearContextRegistrations(); clearSelectedEntityContextForLayer(id); + layerState._allFires = []; + layerState._allFiresByFrp = []; layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + layerState._heatImagery?.destroy(); + layerState._heatImagery = null; + layerState._heatRaster = null; + layerState._handoffHeat = -1; + layerState._imageryHost = null; layerState._count = 0; + layerState._totalCount = 0; + layerState._rowControlsListener = null; layerState._cellCount = 0; layerState._lastUpdate = null; layerState._keyRequired = false; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 9e3cf7f95..812f4f462 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,30 @@ export function createModel({ ); } - function heatColor(value, alpha) { - if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); - if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); - return Cesium.Color.YELLOW.withAlpha(alpha); + /** + * The ramp stop for a fire radiative power (stepped, weakest first). + * Intensity alone drives the color: confidence feeds the card text and the + * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. + * @param {number} frp - Megawatts. + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function frpColorStop(frp) { + const name = frpRampStop(frp).name; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); } /** - * Pick a sprite color stop from FRP + confidence, reusing the same - * yellow → orange → red thresholds as the aggregated heat cells. + * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. * @returns {{name: string, color: Cesium.Color}} */ function detectionColorStop(fire) { - const heat = Math.min( - 1, - Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, - ); - if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; - if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; - return DETECTION_COLOR_STOPS[2]; + return frpColorStop(fire?.frp); } /** FRP → core marker pixel size, clamped to 8..28px. */ @@ -227,6 +231,19 @@ export function createModel({ return fire.position; } + /** + * The anchor already built for a detection, without the floor lookup; + * for the far marker band, where floors are never warmed and the height + * difference is invisible, so tens of thousands of markers skip a cache + * read each per rebuild. Builds one through {@link firePosition} once. + * @param {Object} fire - Detection record. + * @returns {Cesium.Cartesian3} + */ + + function fireBuiltPosition(fire) { + return fire.position ?? firePosition(fire); + } + /** * Occlusion-test anchor for one detection. Returns the render position * outright for anchors comfortably above the ellipsoid (the common case) and @@ -234,11 +251,11 @@ export function createModel({ * NEVER feeds rendering: the datum-correct anchor from {@link firePosition} * is what the sprite and the card are drawn at. * @param {Object} fire - Detection record. + * @param {Cesium.Cartesian3} [position] Its render anchor, if at hand. * @returns {Cesium.Cartesian3} */ - function fireCullPosition(fire) { - const position = firePosition(fire); + function fireCullPosition(fire, position = firePosition(fire)) { if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position; if (!fire.cullPosition) { fire.cullPosition = Cesium.Cartesian3.fromDegrees( @@ -398,8 +415,8 @@ export function createModel({ /** * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. + * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets + * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {number} nowMs - Current epoch milliseconds. * @returns {Object} firmsLabels entry. @@ -417,7 +434,7 @@ export function createModel({ position: candidate.position, cullPosition: candidate.cullPosition || candidate.position, gapPx: 10, - accent: candidate.accent || accentForSeverity('yellow'), + accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name), // The count picks the plural form of the language ("حريقان (2)", // "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the // other card titles. @@ -464,16 +481,14 @@ export function createModel({ } /** - * Heat-normalized cell score → severity accent, mirroring heatColor's - * red/orange/yellow thresholds. - * @param {number} normalized - 0..1 normalized heat score. + * Cell card accent: the cell's strongest fire on the FRP ramp, the same + * color its markers take once the camera is close enough to show them. + * @param {{maxFrp?: number}} cell - Aggregated cell. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - if (normalized > 0.72) return accentForSeverity('red'); - if (normalized > 0.42) return accentForSeverity('orange'); - return accentForSeverity('yellow'); + function cellCardAccent(cell) { + return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -579,12 +594,13 @@ export function createModel({ boundsContainPoint, cellIntersectsBounds, heatScore, - heatColor, + frpColorStop, detectionColorStop, frpPixelSize, sizeBucket, glowSprite, firePosition, + fireBuiltPosition, fireCullPosition, cellCullPosition, applyHorizonCull, @@ -593,7 +609,7 @@ export function createModel({ buildFireCard, buildCellCard, applyFirmsOverlayPolicy, - cellAccent, + cellCardAccent, formatLatLon, formatFrp, formatAge, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..e6f4bd664 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. */ @@ -6,9 +7,10 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. + * LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a + * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their + * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` + * bands render individual fire detections as glow-sprite billboards. * Labels in every band go through the shared screen-space greedy declutter * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. */ @@ -17,24 +19,28 @@ export const LOD_LEVELS = [ { id: 'global', minHeight: 9000000, - mode: 'cells', + mode: 'heat', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000, }, { id: 'regional', - minHeight: 3000000, - mode: 'cells', + minHeight: 4500000, + mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, }, { + // Up to 40,000 markers: at the 3,600 km end of the hand-off the padded + // view over Africa, the densest region, holds ~40,000 detections, and a + // tighter cap left the heatmap's weak spots dissolving into nothing. + // A rebuild of that many costs ~60 ms, only there. id: 'local', minHeight: 750000, mode: 'detections', - maxDetections: 2500, + maxDetections: 40000, labelDistance: 4500000, }, { @@ -46,6 +52,38 @@ export const LOD_LEVELS = [ }, ]; +/** + * Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a + * one-pixel Gaussian σ, so a lone fire is a spot about the size of its + * marker near the hand-off and the two cross-fade without a jump; + * neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared + * each fire over ~200 km). Built once per data refresh or filter change, + * never per camera move. + */ + +export const HEAT_RASTER = Object.freeze({ + width: 4096, + height: 2048, + sigmaPx: 1, +}); + +/** + * Camera heights, in metres, over which the heatmap and the markers + * cross-fade around the 4,500 km band edge: markers alone below `from`, + * heatmap alone above `to`. Wider than the ±10 % band hysteresis, so + * whichever band is active inside it, both layers are already built. + */ + +export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 }); + +/** + * Trailing delay before a stale heatmap is rebuilt while one is already on + * screen: a slider drag fires many filter changes, and a build plus PNG + * encode costs a few hundred milliseconds. The first paint is immediate. + */ + +export const HEAT_REBUILD_MS = 150; + export const LOD_CHECK_MS = 650; /** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ @@ -88,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; -/** Color stops shared by cell heat fills and detection glow sprites. */ +/** + * Color stops for detection glow sprites and card accents: the + * six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium + * colors. Stop names key the glow-sprite cache and the card accents. + */ -export const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; +export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) => + Object.freeze({ + name: stop.name, + minFrp: stop.minFrp, + color: Cesium.Color.fromCssColorString(stop.hex), + }), +); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..34040ea43 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -1,6 +1,18 @@ import { fireDetectionKey } from '../../data/firmsLabels.js'; -import { t } from '../../i18n/index.js'; -import { REFRESH_INTERVAL_MS } from './policy.js'; +import { + FIRMS_CONFIDENCE_MODES, + FRP_SLIDER_TICKS, + firePassesFilter, + frpForSliderPosition, + normalizeFireFilter, + rampTrackGradient, + sliderPositionForFrp, +} from '../../data/firmsIntensity.js'; +import { formatNumber, t } from '../../i18n/index.js'; +import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; + +/** Slider resolution: whole-percent positions along the log axis. */ +const SLIDER_STEPS = 1000; export function createQueries({ layerState, @@ -11,6 +23,25 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ + function frpLabel(frp) { + return formatNumber(frp, { maximumFractionDigits: 0 }); + } + + const confidenceLabels = () => ({ + all: t('All', { context: 'fire confidence filter' }), + nominal: t('Nominal+', { context: 'fire confidence filter' }), + high: t('High', { context: 'fire confidence filter' }), + }); + + const confidenceTitles = () => ({ + all: t( + 'Every detection — many of the strongest fires are rated low confidence', + ), + nominal: t('Nominal and high confidence detections'), + high: t('High confidence detections only'), + }); + const methods = { /** Return the selected detection as a plain record for application consumers. */ getSelectedInfo() { @@ -51,6 +82,110 @@ export function createQueries({ // not this interval — is what protects the upstream FIRMS quota). updateInterval: REFRESH_INTERVAL_MS, + /** + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} + */ + getParams() { + return { ...layerState._filter }; + }, + + /** + * Apply a (partial) intensity filter. Invalid fields keep their current + * value. The filter runs in one place (ingestion.applyFilter) and the LOD + * rebuild re-derives markers, cells, cards and context registrations from + * the shown set; a selected fire that no longer passes is deselected. + * @param {{minFrp?: number, confidence?: string}} params + * @returns {boolean} True — the request is always accepted. + */ + setParams(params = {}) { + const next = normalizeFireFilter(params, layerState._filter); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) + return true; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + if (layerState._enabled) components.rendering.renderCurrentLod(true); + layerState._rowControlsListener?.(); + return true; + }, + + /** + * Row controls under the Active Fires row: the log-scale minimum-FRP + * slider whose track is painted with the intensity ramp (so it doubles as + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. + */ + getRowControls() { + const { minFrp, confidence } = layerState._filter; + const labels = confidenceLabels(); + const titles = confidenceTitles(); + const shown = layerState._count; + const total = layerState._totalCount; + return { + slider: { + id: 'min-frp', + label: t('Fire intensity (FRP)'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + segmented: { + id: 'confidence', + label: t('Confidence'), + ariaLabel: t('Detection confidence'), + options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ + id: `confidence-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === confidence, + params: { confidence: mode }, + })), + }, + info: total + ? t('{shown} of {total} shown', { + shown: formatNumber(shown), + total: formatNumber(total), + }) + : '', + }; + }, + + /** + * Install the panel's "row controls changed" callback: a load or a filter + * change moves the counter, and nothing else repaints the row before the + * next scheduled refresh. + * @param {(() => void)|null} listener + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + /** * Layer stats for the data panel. Degraded feed states surface through * `error` (established qa-failstate pattern: a dead feed must never look @@ -84,7 +219,9 @@ export function createQueries({ }); } return { + // Shown (filtered) detections; `total` is the loaded payload. count: layerState._count, + total: layerState._totalCount, cells: layerState._cellCount, lastUpdate: layerState._lastUpdate, loading: layerState._loading, @@ -172,12 +309,34 @@ export function createQueries({ return result; }, + /** Test seam: render one named LOD band against a near-global view. */ + _renderLodForTest(lodId) { + components.rendering.renderBand( + LOD_LEVELS.find((level) => level.id === lodId), + null, + ); + }, + + /** Test seam: one frame of the hand-off fade at the current camera. */ + _applyHandoffFadeForTest() { + components.rendering.applyHandoffFade(); + }, + + /** Test seam: entities drawn by the layer's data source. */ + _entityCountForTest() { + return layerState._dataSource?.entities?.values?.length ?? 0; + }, + /** Test seam that binds the production click path and indexes. */ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; layerState._enabled = true; + layerState._allFires = fires; + layerState._allFiresByFrp = [...fires]; layerState._fires = fires; layerState._firesByFrp = [...fires]; + layerState._count = fires.length; + layerState._totalCount = fires.length; layerState._pickIndexById.clear(); layerState._labelLodDistance = 1e7; layerState._labelCandidates = fires.map((fire) => { diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..9718470f8 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,21 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + LOD_LEVELS, + CONTEXT_TOP_N, + HANDOFF_FADE, + HEAT_RASTER, + HEAT_REBUILD_MS, +} from './policy.js'; +import { createHeatImagery } from './heatImagery.js'; + +/** The markers drawn under the far bands' heatmap for the hand-off fade. */ +const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections'); + +/** Fade steps: a moving camera rewrites marker colors at most this often. */ +const FADE_STEPS = 32; export function createRendering({ layerState, @@ -29,8 +43,11 @@ export function createRendering({ !layerState._dataSource || !layerState._fires.length || !layerState._viewer - ) + ) { + // A filter that leaves nothing shown must not leave the old heat behind. + if (!layerState._fires.length) layerState._heatImagery?.setShow(false); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -51,14 +68,54 @@ export function createRendering({ ? components.model.paddedDegreeBounds(viewRect) : null; - if (lod.mode === 'detections') { - renderDetections(lod, bounds); - } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); - } + renderBand(lod, bounds); return true; } + /** + * Draw one band. Markers and heatmap stay built in every band; the + * hand-off only fades between them. + * @param {Object} lod - Band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + */ + + function renderBand(lod, bounds) { + prepareHeat(lod.mode === 'heat'); + if (lod.mode === 'detections') renderDetections(lod, bounds); + else renderHeat(lod, bounds); + applyHandoffFade(true); + } + + const fadeColor = new Cesium.Color(); + + /** + * Cross-fade between markers and heatmap by camera height (HANDOFF_FADE), + * on every rendered frame, so a fast zoom never outruns the throttled band + * check. Cheap when nothing changed: one height read and a compare. + * @param {boolean} [force] Reapply, e.g. to freshly added markers. + */ + + function applyHandoffFade(force = false) { + const height = components.viewport.cameraHeight(); + const t = Math.min( + 1, + Math.max( + 0, + (height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from), + ), + ); + const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS; + if (!force && heat === layerState._handoffHeat) return; + layerState._handoffHeat = heat; + layerState._heatImagery?.setOpacity(heat); + const billboards = layerState._billboards; + if (!billboards) return; + billboards.show = layerState._enabled && heat < 1; + const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor); + for (let i = 0; i < billboards.length; i += 1) + billboards.get(i).color = color; + } + /** * Bin all detections into grid cells, then viewport-clip BEFORE the * top-N cap so fires in view never lose their slot to higher-scoring @@ -118,65 +175,96 @@ export function createRendering({ return clipped; } + /** The heat imagery adapter, created on first use. */ + + function heatImagery() { + if (!layerState._heatImagery) { + layerState._heatImagery = createHeatImagery({ + cesium: Cesium, + getHost: () => + layerState._imageryHost?.() ?? { + collection: layerState._viewer?.imageryLayers ?? null, + kind: 'globe', + }, + }); + } + return layerState._heatImagery; + } + + /** Build the heat raster from the shown set and drape it. */ + + function rebuildHeatRaster() { + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + if (layerState._destroyed) return; + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + heatImagery().setRaster(layerState._heatRaster); + } + /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. + * Make sure the draped heatmap matches the shown set. The first paint in a + * far band is immediate; other builds (filter drags, refreshes, the + * preload under the markers) trail by HEAT_REBUILD_MS and keep the + * previous heatmap on screen meanwhile. + * @param {boolean} now Build at once when nothing is draped yet. + */ + + function ensureHeatRaster(now) { + if (layerState._heatRaster) return; + if (now && !heatImagery().hasRaster()) { + rebuildHeatRaster(); + return; + } + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = setTimeout( + rebuildHeatRaster, + HEAT_REBUILD_MS, + ); + } + + /** + * Keep the heatmap draped on the current map stack and current with the + * shown set in every band; the hand-off fade decides how much of it shows. + * @param {boolean} now The camera is in a far band. + */ + + function prepareHeat(now) { + const imagery = heatImagery(); + // The map stack may have changed since the last render. + imagery.rehome(); + ensureHeatRaster(now); + imagery.setShow(layerState._enabled); + } + + /** + * Far bands (global/regional): a Snap Map–style heatmap draped over the + * globe, transparent where nothing burns, plus the decluttered "N FIRES" + * cards from the coarse aggregation. The nearest band's markers are kept + * built underneath for the hand-off fade and hidden once it completes; + * cards go through the same screen-space label pipeline as detections + * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. * @param {?Object} bounds - Padded view bounds in degrees, or null. */ - function renderCells(cells, lod, bounds) { - ensureDetectionCollections(); - layerState._dataSource.entities.removeAll(); - if (layerState._billboards) layerState._billboards.removeAll(); - layerState._pickIndexById.clear(); - layerState._cullPositions.length = 0; - layerState._cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + function renderHeat(lod, bounds) { + paintMarkers(HANDOFF_MARKER_LOD, bounds); + const cells = aggregateFires(lod, bounds); + layerState._cellCount = cells.length; layerState._labelCandidates = []; layerState._labelLodDistance = lod.labelDistance; + // Cells arrive heat-sorted, so candidate order doubles as label priority. for (const cell of cells) { - const score = components.model.heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - layerState._dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees, - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ - position, + position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellCardAccent(cell), }); } @@ -202,9 +290,48 @@ export function createRendering({ */ function renderDetections(lod, bounds) { + const markers = paintMarkers(lod, bounds); + if (!markers) return; + const candidates = markers.map((marker) => marker.fire); + + layerState._cellCount = candidates.length; + // FRP-sorted walk: marker order doubles as label priority. + layerState._labelCandidates = markers; + layerState._labelLodDistance = lod.labelDistance; + + components.cards.rebuildAmbientLabels(); + components.selection.refreshContextRegistrations( + candidates.slice(0, CONTEXT_TOP_N), + ); + + if (lod.id === 'close') { + // Batched (chunked ≤200, sequential, session-cached) DEM warm for the + // rendered subset; fires are static so each coarse cell resolves once + // ever. Re-render ONLY when a floor actually landed — a failed resolve + // reports false, so this chain terminates instead of looping against a + // down proxy (the next camera-driven rebuild retries). + warmFireAnchorFloors(candidates).then((warmed) => { + if (!warmed || !layerState._enabled || !layerState._viewer) return; + const currentLod = LOD_LEVELS[layerState._currentLodIndex]; + if (!currentLod || currentLod.mode !== 'detections') return; + renderCurrentLod(true); + }); + } + } + + /** + * The glow-sprite markers for a detections band: the strongest fires in + * view, capped at the band's maxDetections, horizon-culled. + * @param {Object} lod - Detections band descriptor. + * @param {?Object} bounds - Padded view bounds in degrees, or null. + * @returns {?Array} Label candidates for the fires drawn, + * strongest first; null before the sprite collection exists. + */ + + function paintMarkers(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); - if (!layerState._billboards) return; + if (!layerState._billboards) return null; layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; @@ -226,17 +353,20 @@ export function createRendering({ candidates = layerState._firesByFrp.slice(0, lod.maxDetections); } - layerState._cellCount = candidates.length; - layerState._labelCandidates = []; - layerState._labelLodDistance = lod.labelDistance; - + const markers = []; + // Only the close band warms terrain floors (renderDetections). + const anchor = + lod.id === 'close' + ? components.model.firePosition + : components.model.fireBuiltPosition; for (const fire of candidates) { const coreSize = components.model.frpPixelSize(fire.frp); - const position = components.model.firePosition(fire); - const cullPosition = components.model.fireCullPosition(fire); + const position = anchor(fire); + const cullPosition = components.model.fireCullPosition(fire, position); const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); + markers.push({ position, cullPosition, fire, cell: null }); layerState._billboards.add({ id: pickId, position, @@ -248,37 +378,13 @@ export function createRendering({ horizontalOrigin: Cesium.HorizontalOrigin.CENTER, disableDepthTestDistance: Number.POSITIVE_INFINITY, }); - // FRP-sorted walk: candidate order doubles as label priority. - layerState._labelCandidates.push({ - position, - cullPosition, - fire, - cell: null, - }); } // Freshly-added sprites default to show=true; cull the far side now so a // rebuild never flashes through-the-planet detections while the camera // sits still (the moveEnd/preRender hooks only fire on camera motion). refreshHorizonCulling(); - components.cards.rebuildAmbientLabels(); - components.selection.refreshContextRegistrations( - candidates.slice(0, CONTEXT_TOP_N), - ); - - if (lod.id === 'close') { - // Batched (chunked ≤200, sequential, session-cached) DEM warm for the - // rendered subset; fires are static so each coarse cell resolves once - // ever. Re-render ONLY when a floor actually landed — a failed resolve - // reports false, so this chain terminates instead of looping against a - // down proxy (the next camera-driven rebuild retries). - warmFireAnchorFloors(candidates).then((warmed) => { - if (!warmed || !layerState._enabled || !layerState._viewer) return; - const currentLod = LOD_LEVELS[layerState._currentLodIndex]; - if (!currentLod || currentLod.mode !== 'detections') return; - renderCurrentLod(true); - }); - } + return markers; } function ensureDetectionCollections() { @@ -344,9 +450,11 @@ export function createRendering({ } return { renderCurrentLod, + renderBand, aggregateFires, - renderCells, + renderHeat, renderDetections, + applyHandoffFade, ensureDetectionCollections, removeDetectionCollections, fireHorizonOccluder, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..0b96c53ea 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; @@ -103,6 +131,29 @@ export function createFirmsState({ services, config }) { layerState._cellCacheByGrid = new Map(); + /** + * Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt + * on the next far render. Cleared with the cell cache on every new shown set. + */ + + layerState._heatRaster = null; + + /** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */ + + layerState._heatRebuildTimer = null; + + /** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */ + + layerState._handoffHeat = -1; + + /** Imagery adapter (heatImagery.js), created on the first far render. */ + + layerState._heatImagery = null; + + /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ + + layerState._imageryHost = null; + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; 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 b72f79d34..8ac00f51b 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -252,6 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), 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/kit/UiSegmented.test.mjs b/src/ui/kit/UiSegmented.test.mjs index f15e217c6..1f2f5e983 100644 --- a/src/ui/kit/UiSegmented.test.mjs +++ b/src/ui/kit/UiSegmented.test.mjs @@ -117,3 +117,18 @@ test('a radio group moves from the focused option, an arrows group from the sele assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]); arrows.unmount(); }); + +test("an option's own tip wins; an icon option without one is titled by its label", () => { + const wrapper = mountGroup({ + options: [ + { value: 'a', label: 'Alpha', attrs: { title: 'Alpha tip' } }, + { value: 'b', label: 'Beta', icon: 'star' }, + { value: 'c', label: 'Gamma' }, + ], + }); + const titles = wrapper + .findAll('button') + .map((b) => b.attributes('title') ?? null); + assert.deepEqual(titles, ['Alpha tip', 'Beta', null]); + wrapper.unmount(); +}); diff --git a/src/ui/kit/UiSegmented.vue b/src/ui/kit/UiSegmented.vue index c6289658e..e1eec51a3 100644 --- a/src/ui/kit/UiSegmented.vue +++ b/src/ui/kit/UiSegmented.vue @@ -8,7 +8,8 @@ import { computed } from 'vue'; // End). Each option may carry `attrs` (an id, data attributes, a tip), and // Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the // selected option's `activeClass`. An option with an `icon` shows the glyph, -// and its label becomes the button's accessible name and tooltip. +// and its label becomes the button's accessible name and tooltip, unless its +// `attrs` carry a tip of their own. const props = defineProps({ modelValue: { type: String, default: undefined }, /** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */ @@ -119,7 +120,7 @@ function onKey(event) { ]" :aria-checked="String(option.value === props.modelValue)" :aria-label="option.icon ? option.label : undefined" - :title="option.icon ? option.label : undefined" + :title="option.attrs?.title ?? (option.icon ? option.label : undefined)" :disabled="optionOff(option)" :tabindex="tabindex(index)" @click="choose(index)" 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) { diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index a7010d197..b24e8be7b 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -442,6 +442,8 @@ export class LayerPanel { this._selectChip(layerId, chipId, generation), onListItem: (layerId, itemId) => this._selectListItem(layerId, itemId, generation), + onSlider: (layerId, sliderId, value) => + this._moveSlider(layerId, sliderId, value, generation), }), id === null ? this._toggleContainer : this._groupContainers.get(id), ); @@ -471,7 +473,8 @@ export class LayerPanel { } /** - * A layer's row chips, color legend, info line and ordered list. The block + * A layer's row chips, one-of-many switch, slider, color legend, info line + * and ordered list. The block * is hidden while the layer is off (or while a dependency owner has * surrendered it), so a quiet row stays quiet. Chips and list items are * keyed, so the refresh a chip's own click triggers keeps keyboard focus. @@ -495,11 +498,20 @@ export class LayerPanel { : `${item.label} ${formatCount(item.count)}`, })); const infoText = controls?.info ? String(controls.info) : ''; + const segmented = controls?.segmented || null; + const slider = controls?.slider || null; // The info line, once shown, stays in place and hides when empty. if (infoText) row.info = true; return { - hidden: chips.length === 0 && legend.length === 0 && !infoText, + hidden: + chips.length === 0 && + legend.length === 0 && + !infoText && + !segmented && + !slider, chips, + segmented, + slider, legend, info: row.info ? { text: infoText, title: controls?.infoTitle || '' } @@ -537,15 +549,33 @@ export class LayerPanel { // listeners, so a click from a released generation is dropped. _selectChip(id, chipId, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; - const chip = this._rowControlsFor(id)?.chips?.find( - (entry) => entry.id === chipId, - ); + // A switch option carries the same fields as a chip. + const live = this._rowControlsFor(id); + const chip = [ + ...(live?.chips || []), + ...(live?.segmented?.options || []), + ].find((entry) => entry.id === chipId); if (!chip || chip.disabled || !this.isEnabled(id)) return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(id, chip.params, { origin: 'user' }); } + // A row slider (e.g. the Active Fires minimum-FRP filter) maps its position + // to params through the live descriptor on every tick. + _moveSlider(id, sliderId, value, generation = this._generation) { + if (this._destroyed || this._generation !== generation) return; + const slider = this._rowControlsFor(id)?.slider; + if ( + !slider || + slider.id !== sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(id) + ) + return; + this.setLayerParams(id, slider.params(value), { origin: 'user' }); + } + _selectListItem(id, itemId, generation = this._generation) { if (this._destroyed || this._generation !== generation) return; const item = this._rowControlsFor(id)?.list?.items?.find( diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index f404938b6..5f5d7c97e 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -1,6 +1,8 @@ @@ -55,6 +58,16 @@ defineProps({ > +import UiSlider from '../kit/UiSlider.js'; + +// A row's quantitative slider (e.g. the Active Fires minimum-FRP filter). +// `track` is a CSS background painting the runnable track, so a color ramp +// doubles as the legend; each tick sits at `at` (0..1) along it. The input +// holds its position while focused, so a refresh from its own tick never +// moves the thumb under the pointer. +defineProps({ + slider: { type: Object, required: true }, + onInput: { type: Function, default: () => {} }, +}); + + + diff --git a/src/ui/layerPanel/RowSwitch.vue b/src/ui/layerPanel/RowSwitch.vue new file mode 100644 index 000000000..821da5f96 --- /dev/null +++ b/src/ui/layerPanel/RowSwitch.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/ui/layerPanel/layerRows.test.mjs b/src/ui/layerPanel/layerRows.test.mjs index 937b35074..966c41cb6 100644 --- a/src/ui/layerPanel/layerRows.test.mjs +++ b/src/ui/layerPanel/layerRows.test.mjs @@ -39,8 +39,123 @@ test('the count-line class toggles without dropping the voice highlight', () => assert.equal(element.classList.contains('gev-voice-focus'), true); render(h(LayerRows, { items: [row(false)] }), container); - assert.equal(element.classList.contains('data-toggle-row--count-line'), false); + assert.equal( + element.classList.contains('data-toggle-row--count-line'), + false, + ); assert.equal(element.classList.contains('gev-voice-focus'), true); assert.equal(element.classList.contains('data-toggle-row'), true); render(null, container); }); + +const fireRow = (value, active = 'all') => ({ + ...row(false), + key: 'fires', + id: 'fires', + controls: { + hidden: false, + chips: [], + segmented: { + id: 'confidence', + label: 'Confidence', + ariaLabel: 'Detection confidence', + options: ['all', 'high'].map((mode) => ({ + id: `confidence-${mode}`, + label: mode.toUpperCase(), + title: `${mode} title`, + 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' }, + ], + }, + legend: [], + info: null, + list: null, + }, +}); + +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 = []; + const moves = []; + const props = { + onChip: (id, chipId) => chips.push([id, chipId]), + onSlider: (id, sliderId, value) => moves.push([id, sliderId, value]), + }; + render(h(LayerRows, { items: [fireRow(0)], ...props }), container); + + const caption = container.querySelector('.data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); + const group = container.querySelector('.gev-segmented.data-toggle-segmented'); + assert.equal(group.getAttribute('role'), 'radiogroup'); + assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); + const segments = [ + ...group.querySelectorAll('.gev-segment.data-toggle-segment'), + ]; + assert.deepEqual( + segments.map((button) => button.dataset.chipId), + ['confidence-all', 'confidence-high'], + ); + assert.equal(segments[0].classList.contains('active'), true); + assert.equal(segments[1].title, 'high title'); + segments[1].click(); + assert.deepEqual(chips, [['fires', 'confidence-high']]); + + 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)', + ); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + 'All', + ); + const input = block.querySelector('input.data-toggle-slider-input'); + assert.equal(input.classList.contains('gev-quantitative-slider'), true); + assert.equal(input.type, 'range'); + assert.equal(input.max, '1000'); + assert.equal( + input.getAttribute('aria-label'), + 'Minimum fire radiative power, megawatts', + ); + assert.equal(input.getAttribute('aria-valuetext'), 'All'); + assert.match( + input.style.getPropertyValue('--slider-track'), + /^linear-gradient/, + ); + const ticks = [...block.querySelectorAll('.data-toggle-slider-tick')]; + assert.deepEqual( + ticks.map((tick) => tick.textContent), + ['0', '30'], + ); + assert.equal(ticks[1].style.getPropertyValue('--at'), '50.00%'); + + input.value = '120'; + input.dispatchEvent(new Event('input', { bubbles: true })); + 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); + assert.equal( + container.querySelector('input.data-toggle-slider-input'), + input, + ); + assert.equal(input.getAttribute('aria-valuetext'), '≥ 120 MW'); + assert.equal(segments[1].classList.contains('active'), true); + render(null, container); +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index e491c7409..91611bf4a 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1042,6 +1042,118 @@ 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-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; + 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; 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('طُوكيو'), ['انتقل إلى طوكيو']); +});