From 1dad6b658a77b80465204cd10c5247cb4c2c084c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:50:03 +0400 Subject: [PATCH] feat(fires): caption the confidence switch and name the slider "Fire intensity (FRP)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch had no visible word saying what ALL / NOMINAL+ / HIGH refer to, and "Min FRP" assumed the reader knows the acronym. The segmented row descriptor gains an optional visible `label` (a caption above the group); the fires row uses "Confidence" and "Fire intensity (FRP)", and the slider value reads "≥ N MW" so the threshold is still clear. Locales updated. Co-Authored-By: Claude Fable 5.1 --- docs/CURRENT-STATE.md | 7 ++++--- src/i18n/locales/es.json | 5 +++-- src/i18n/locales/ru.json | 5 +++-- src/i18n/locales/zh-CN.json | 5 +++-- src/i18n/same-as-english.json | 4 ++-- src/layers/firms/queries.js | 5 +++-- src/ui/rowFilterControls.js | 24 ++++++++++++++++++++++-- src/ui/rowFilterControls.test.mjs | 23 ++++++++++++++++++++--- src/ui/styles/radio.css | 10 ++++++++++ 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61699bda9..2132a910f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2062,9 +2062,10 @@ hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from cells, glow sprites and card accents read the same ramp; confidence and brightness do not affect the color. -The Active Fires row carries a minimum-FRP slider on a log scale whose track is -painted with the ramp and ticked at the same six values, an All / Nominal+ / -High confidence switch, and an "N of M shown" counter. The default is 0 MW and +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, heat cells, the shared context store, the top-fires query and the diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ffee7fb87..babea8bfb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,9 +3008,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "High confidence detections only": "Solo detecciones de confianza alta", - "Min FRP": "FRP mín.", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "{value} MW": "{value} MW", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1cd88757d..6289ad38c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3077,9 +3077,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Nominal and high confidence detections": "Детекции средней и высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности", - "Min FRP": "Мин. FRP", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "{value} MW": "{value} МВт", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3044c45f8..110f7a77c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,9 +2870,10 @@ "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Nominal and high confidence detections": "标称和高置信度的检测", "High confidence detections only": "仅高置信度的检测", - "Min FRP": "最小 FRP", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "{value} MW": "{value} MW", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5f1b14f8a..5d0313965 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -121,7 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -197,7 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 220012133..44ce8573d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -135,11 +135,11 @@ export function createQueries({ return { slider: { id: 'min-frp', - label: t('Min FRP'), + label: t('Fire intensity (FRP)'), ariaLabel: t('Minimum fire radiative power, megawatts'), valueText: minFrp > 0 - ? t('{value} MW', { value: frpLabel(minFrp) }) + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) : t('All', { context: 'fire intensity filter' }), min: 0, max: SLIDER_STEPS, @@ -156,6 +156,7 @@ export function createQueries({ }, segmented: { id: 'confidence', + label: t('Confidence'), ariaLabel: t('Detection confidence'), options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ id: `confidence-${mode}`, diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js index b0299d24b..87a3e0bc6 100644 --- a/src/ui/rowFilterControls.js +++ b/src/ui/rowFilterControls.js @@ -104,8 +104,9 @@ export function syncSliderBlock(container, slider, { before = null } = {}) { * Buttons carry `data-chip-id` like chips so the row's delegated click handler * can resolve them; the caller matches `.data-toggle-segment`. * - * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, - * disabled, params }] }`. + * Descriptor: `{ id, label, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }` — `label` is an optional visible caption above the + * buttons (the group itself is a flex item, so the caption is a sibling). * @param {HTMLElement} container The row's `.data-toggle-controls` node. * @param {?object} segmented Descriptor, or null to remove the group. * @param {{before?: ?Node}} [options] Insertion anchor for a new group. @@ -121,19 +122,38 @@ export function syncSegmentedGroup( const existing = [...container.children].find( (node) => node.dataset?.segmentedId, ); + const existingCaption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor, + ); if (!segmented) { existing?.remove(); + existingCaption?.remove(); return null; } let group = existing; if (!group || group.dataset.segmentedId !== segmented.id) { existing?.remove(); + existingCaption?.remove(); group = document.createElement('div'); group.className = 'gev-segmented data-toggle-segmented'; group.dataset.segmentedId = segmented.id; group.setAttribute('role', 'radiogroup'); container.insertBefore(group, before); } + let caption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor === segmented.id, + ); + if (segmented.label) { + if (!caption) { + caption = document.createElement('span'); + caption.className = 'data-toggle-segmented-label'; + caption.dataset.segmentedCaptionFor = segmented.id; + container.insertBefore(caption, group); + } + set(caption, 'textContent', segmented.label); + } else { + caption?.remove(); + } if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); const stale = new Map(); for (const node of [...group.children]) diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs index 9cc8fb1d4..ac5d2c1d0 100644 --- a/src/ui/rowFilterControls.test.mjs +++ b/src/ui/rowFilterControls.test.mjs @@ -24,6 +24,7 @@ const sliderDescriptor = (overrides = {}) => ({ const segmentedDescriptor = (active = 'all') => ({ id: 'confidence', + label: 'Confidence', ariaLabel: 'Detection confidence', options: ['all', 'nominal', 'high'].map((mode) => ({ id: `confidence-${mode}`, @@ -96,6 +97,9 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(group.getAttribute('role'), 'radiogroup'); assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); assert.ok(group.className.split(' ').includes('gev-segmented')); + const caption = container.children[container.children.indexOf(group) - 1]; + assert.equal(caption.className, 'data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); const buttons = group.children; assert.deepEqual( buttons.map((b) => [ @@ -118,10 +122,18 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 2, 'caption and group'); + + syncSegmentedGroup(container, { ...segmentedDescriptor('high'), label: '' }); + assert.equal( + container.querySelector('.data-toggle-segmented-label'), + null, + 'an empty label removes the caption', + ); assert.equal(container.children.length, 1); syncSegmentedGroup(container, null); - assert.equal(container.children.length, 0); + assert.equal(container.children.length, 0, 'null removes group and caption'); }); test('a layer row dispatches slider input and switch clicks as user params', async () => { @@ -163,10 +175,15 @@ test('a layer row dispatches slider input and switch clicks as user params', asy row.querySelector('.data-toggle-controls-info').textContent, '12 of 40 shown', ); - // Order: switch, slider, info. + // Order: switch caption, switch, slider, info. assert.deepEqual( container.children.map((node) => node.className.split(' ')[0]), - ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + [ + 'data-toggle-segmented-label', + 'gev-segmented', + 'data-toggle-slider', + 'data-toggle-controls-info', + ], ); const input = row.querySelector('.data-toggle-slider-input'); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5047ae6f4..e005c5288 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1043,6 +1043,16 @@ html:not([lang='en']) .data-row-list-text { max-width: 220px; } +.data-toggle-segmented-label { + flex-basis: 100%; + padding-top: 2px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + .data-toggle-controls .data-toggle-segment { min-height: 20px; padding: 3px 6px;