diff --git a/content/quiet-corners-maps.md b/content/quiet-corners-maps.md index 4196a8e..a537c01 100644 --- a/content/quiet-corners-maps.md +++ b/content/quiet-corners-maps.md @@ -45,7 +45,7 @@ Both buttons say what they did on the status line below the map, the same line e **What these sets are held to.** Two of the three things worth measuring here are enforced by a test rather than by good intentions, and a set that exceeded either could not be built at all. The third, task completion, is not mechanical: it is watched rather than asserted, and what the code holds each set to is that it states the question it claims to answer. - **Load size.** Each layer switched on is one query of its own against its service, and a query pages up to twenty requests. The public-land set is six layers, a worst case of 120 requests for one settled view; the night set asks the vector services for nothing at all, because its surface is a picture handed straight to the map. Six is the ceiling until a wider set has been measured. -- **Clutter.** At most one continuous-colour surface at a time, and that is now enforced rather than promised: **Land, sky & light context** is a choice of one — the steepness of the ground, night lights, radar, aurora, air quality, wildlife records, the [Quiet Corners county ranking](/quiet-corners/), or **No context surface** — exactly like the base map above it. It used to be four independent checkboxes, so a reader could stack four filled surfaces over each other and nothing stopped them; the light-emissions surface is drawn at 80% and the radar at 55%, and those two alone leave under a tenth of whatever is beneath them showing. **No context surface** is where the panel starts, and it stays a row of its own so turning them all off again is always one click. On the map key, the public-land set draws nineteen distinct marks and the night set draws one. The county ranking is the one surface here with no service behind it at all — it is the dashboard's own local data, drawn on screen only, and it is never in the printable-vector fetch loop and never on the printed key: a screen-only layer switched on for the screen used to still be listed as a source on the sheet that never carried it, and the credit list is now built from what actually reaches paper rather than from what the reader had switched on. +- **Clutter.** At most one continuous-colour surface at a time, and that is enforced rather than promised: in **Land, sky & light context** the surfaces that fill the whole view as a picture — the steepness of the ground, night lights, radar, land cover, tree canopy, the [Quiet Corners county ranking](/quiet-corners/) — are a choice of one, or **No context surface**, exactly like the base map above them. It used to be four independent checkboxes, so a reader could stack four filled surfaces over each other and nothing stopped them; the light-emissions surface is drawn at 80% and the radar at 55%, and those two alone leave under a tenth of whatever is beneath them showing. What is drawn as outlines or shading on top and hides nothing beneath it — wildfire perimeters, air quality, smoke, flood zones, the aurora and wildlife records — is not part of that choice: each is its own checkbox under the surfaces and stays on whichever surface you pick, so fire perimeters and air quality can be on together, and with night lights as well. A saved view or shared link from before the split that names one of them as its surface still turns it on. **No context surface** is where the panel starts, and it stays a row of its own so turning them all off again is always one click. On the map key, the public-land set draws nineteen distinct marks and the night set draws one. The county ranking is the one surface here with no service behind it at all — it is the dashboard's own local data, drawn on screen only, and it is never in the printable-vector fetch loop and never on the printed key: a screen-only layer switched on for the screen used to still be listed as a source on the sheet that never carried it, and the credit list is now built from what actually reaches paper rather than from what the reader had switched on. ## Finding and checking a layer @@ -273,7 +273,7 @@ Keyboard and large text: Tab reaches the dock panels, the map controls and the p **Layers do not appear when I switch them on.** Switch the layer on and stop moving the map: once the view settles the page asks for what is on screen and draws it. Two views ask for nothing, on purpose — zoomed out past the level the topo base appears at, and a view covering more ground than one query can honestly answer. Both say so on the page. Building the PDF always re-asks for the printed box itself, so a sheet never draws features fetched for somewhere else. -**Which context surface am I looking at, and when is it from?** Whichever one is chosen under **Land, sky & light context** — only one can be on at a time — and the map itself carries a line naming it and the time its data refers to. That time is the source's own and never the moment it was fetched: the aurora surface shows the time it is a **forecast for**, radar and air quality show the time they were **observed**, and the night lights show their **vintage** (a 2016 composite, not tonight). The wildlife records are an aggregate over a span of years rather than a moment — a cell holds records from many different days — so that line says the time is unknown and the row beneath it names the year range the count covers. The steepness surface is arithmetic rather than a reading, and the national elevation model states no date for a given piece of ground, so its line says the time is unknown instead of borrowing the clock. The county ranking names no service and so states its own dashboard vintage rather than a fetch time, the same wording an inventory carries. A source that will not state its time says so on that line rather than being shown as current. +**Which context surface am I looking at, and when is it from?** Whichever surface is chosen under **Land, sky & light context** — only one can be on at a time — plus any overlay ticked beneath it (fire perimeters, air quality and so on, which can be on together). The map itself carries one line for each, naming it and the time its data refers to; with everything on, that box stops growing at a few lines and scrolls. That time is the source's own and never the moment it was fetched: the aurora surface shows the time it is a **forecast for**, radar and air quality show the time they were **observed**, and the night lights show their **vintage** (a 2016 composite, not tonight). The wildlife records are an aggregate over a span of years rather than a moment — a cell holds records from many different days — so that line says the time is unknown and the row beneath it names the year range the count covers. The steepness surface is arithmetic rather than a reading, and the national elevation model states no date for a given piece of ground, so its line says the time is unknown instead of borrowing the clock. The county ranking names no service and so states its own dashboard vintage rather than a fetch time, the same wording an inventory carries. A source that will not state its time says so on that line rather than being shown as current. **Can I turn the base map off, or see the aerial or relief on screen?** Yes — under **Base map** in the Layers panel, choose USGS Topo, aerial imagery, shaded relief or no base map at all; the choice is for the screen only, and the tiles are drawn at full strength rather than dimmed. The PDF still prints the topo base on every sheet, with aerial imagery and shaded relief available as companion sheets under **Extra sheets**. diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index 63f4ff6..cb89cc3 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -408,6 +408,30 @@ scripts. `QCM_SMOKE_NO_BROWSER=1` makes the CLI behave as if there were no Chrom **Not wired into deploy yet.** Running this after `deploy-production.yml` is a separate change to the workflow, made and merged by a person. Firefox and WebKit lanes are not in this slice: the checks drive Chromium over the Chrome DevTools protocol, which those browsers do not speak, so a later slice needs them installed plus a driver. +## The context group: surfaces and overlays + +**Land, sky & light context** holds two kinds of row, and which kind a row is follows how it +renders, not what it is about. A row that fills the view as a raster (`rasterSurface` in +`layer-registry.js`: night lights, radar, slope, land cover, tree canopy, the county wash, +the hillshade) hides what is under it, so those stay one radio group (`qcm-context`) with an +explicit **No context surface**. A row that draws vector ink on top (NIFC fire perimeters, +air quality, smoke, flood zones, aurora, wildlife) is an ordinary checkbox +(`qcm-layer-`) and coexists with whichever surface is chosen. Ids never change, so an old +share link or saved state that names fire or air quality as its surface (`s=`) still turns +that overlay on. + +The map caption (`#qcm-surface-caption`) carries one `.qcm-surface-caption-line` block per +thing on, keyed by layer id so a toggle adds or removes only its own line, inside a box with +a height ceiling (7.5rem) that scrolls. With a surface and every overlay on it stays within +160px at 390px and 1280px wide. + +| Proof | What it holds | +| --- | --- | +| `layer-registry.test.mjs` | The surface/overlay split is derived from `rasterSurface`; every context id is exactly one of the two. | +| `layer-presets.test.mjs` | A preset may name one raster surface plus overlays, never two surfaces. | +| `surface-choice.test.mjs` | The radio group in a browser: None at rest, one surface at a time. | +| `context-overlays-browser.test.mjs` | Fire and air quality are drawn together, with or without a surface; old links restore by id; the caption is one line per entry within its height budget. | + ## Physical print proof Layout is proven here without paper. Whether a printer honours it is a separate check: diff --git a/scripts/qcm/context-overlays-browser.test.mjs b/scripts/qcm/context-overlays-browser.test.mjs new file mode 100644 index 0000000..2edf330 --- /dev/null +++ b/scripts/qcm/context-overlays-browser.test.mjs @@ -0,0 +1,189 @@ +// QQA-06d, in a real browser: vector context overlays (NIFC fire perimeters, air quality, +// smoke, ...) are independent checkboxes that coexist with the one exclusive raster surface, +// and a state saved before the split (a share link naming fire or air quality as its `s=` +// surface) still restores by the same stable layer id. +// +// The two services are stubbed at window.fetch before any page script runs, so the proof is +// offline and deterministic: what is asserted is what the page DRAWS on the map (Leaflet path +// layers carry their ink in `options.color`), not what it requested. +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { findChromium, launch } from './helpers/browser.mjs'; +import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; +import { readyDashboard, waitInPage } from './helpers/waits.mjs'; +import { SERVICE as FIRE_SERVICE } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-fire.js'; +import { SERVICE as AQI_SERVICE } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-aqi.js'; +import { LAYER_BY_ID, CONTEXT_OVERLAY_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; + +const CHROMIUM = findChromium(); +const skip = !CHROMIUM ? 'no Chromium on this box (set CHROME_PATH to run this proof)' + : !siteIsBuilt('quiet-corners') ? 'public/quiet-corners/ not built — run `bash scripts/build-site.sh --configDir site-config --minify` first' + : false; + +// Any view's question to either service gets the stub's answer, whatever box it names. +const FIRE_URL = `${FIRE_SERVICE}/query`; +const AQI_URL = `${AQI_SERVICE}/query`; +const FIRE_INK = '#b4462e'; +const AQI_INK = '#b08a3e'; + +// A wildfire perimeter and one air-quality record, shaped like the services' own answers. +const stubSource = `(() => { + const realFetch = window.fetch.bind(window); + const json = (body) => Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })); + window.fetch = function (input, init) { + const url = String(input && input.url ? input.url : input); + if (url.startsWith(${JSON.stringify(FIRE_URL)})) { + return json({ type: 'FeatureCollection', features: [{ type: 'Feature', + properties: { attr_IncidentTypeCategory: 'WF', poly_IncidentName: 'Test Fire', poly_GISAcres: 120, + poly_PolygonDateTime: Date.now() - 3 * 86400000, attr_PercentContained: 10 }, + geometry: { type: 'Polygon', coordinates: [[[-89.6, 44.4], [-89.4, 44.4], [-89.4, 44.6], [-89.6, 44.6], [-89.6, 44.4]]] } }] }); + } + if (url.startsWith(${JSON.stringify(AQI_URL)})) { + return json({ features: [{ attributes: { OBJECTID: 1, Id: 1, gridcode: 3, Unixtime: Math.floor(Date.now() / 1000), Timestamp: Date.now() } }] }); + } + return realFetch(input, init); + }; +})()`; + +let shared = null; +async function sharedSite() { + if (!shared) { + const site = await startSiteServer(); + const page = await launch(CHROMIUM, 'about:blank', { viewport: { width: 1280, height: 900 } }); + await page.send('Page.addScriptToEvaluateOnNewDocument', { source: stubSource }); + shared = { site, page }; + } + return shared; +} +after(async () => { + if (!shared) return; + shared.page.close(); + await shared.site.close(); +}); + +const CENTER = 'window.qcMap.setView([44.5, -89.5], 9, { animate: false }); void 0;'; +async function freshPage(hash = '') { + const { site, page } = await sharedSite(); + // A hash-only change is a same-document navigation (no load event), and the view state is read + // once at start-up, so every page starts from a blank document. + await page.navigate('about:blank'); + await page.navigate(`${site.origin}/quiet-corners/${hash}`); + await readyDashboard(page, { data: false }); + await page.evaluate(CENTER); + return page; +} + +// What is on the map right now, by ink: a path layer a Leaflet group added, still attached. +const drawn = (ink) => `(() => { let n = 0; + window.qcMap.eachLayer((l) => { if (l.options && l.options.color === ${JSON.stringify(ink)} && l._map) n++; }); + return n; })()`; +const lightsDrawn = "document.querySelectorAll('.leaflet-tile-pane img[src*=\"VIIRS_Black_Marble\"]').length"; +const state = `(() => ({ + radio: (document.querySelector('input[name="qcm-context"]:checked') || {}).value || null, + fire: document.getElementById('qcm-layer-nifcFirePerimeters').checked, + aqi: document.getElementById('qcm-layer-epaAirNowAqi').checked, +}))()`; +const click = (id) => `document.getElementById(${JSON.stringify(id)}).click(); void 0;`; + +test('fire perimeters and air quality are independent checkboxes in the context group', { skip }, async () => { + const page = await freshPage(); + const r = await page.evaluate(`(() => { + const el = (id) => document.getElementById(id); + const group = [...document.querySelectorAll('.qcm-layer-group')].find((g) => g.getAttribute('aria-label') === 'Land, sky & light context'); + return { fireType: el('qcm-layer-nifcFirePerimeters').type, aqiType: el('qcm-layer-epaAirNowAqi').type, + fireInGroup: group.contains(el('qcm-layer-nifcFirePerimeters')), aqiInGroup: group.contains(el('qcm-layer-epaAirNowAqi')), + noFireRadio: !el('qcm-context-nifcFirePerimeters'), noAqiRadio: !el('qcm-context-epaAirNowAqi'), + radios: [...document.querySelectorAll('input[name="qcm-context"]')].map((x) => x.value) }; + })()`); + assert.equal(r.fireType, 'checkbox'); + assert.equal(r.aqiType, 'checkbox'); + assert.ok(r.fireInGroup && r.aqiInGroup, 'the overlays stay in the context group, not a new top-level group'); + assert.ok(r.noFireRadio && r.noAqiRadio, 'a vector overlay must not also be a radio'); + assert.ok(r.radios.includes('none') && r.radios.includes('nightLights'), 'the raster surfaces are still the radio choice'); + assert.ok(!r.radios.includes('nifcFirePerimeters') && !r.radios.includes('epaAirNowAqi')); +}); + +test('fire + air quality are drawn together, and a raster surface does not take either away', { skip }, async () => { + const page = await freshPage(); + await page.evaluate(click('qcm-layer-nifcFirePerimeters')); + await waitInPage(page, 'the fire perimeter drawn', `${drawn(FIRE_INK)} > 0`, 20000, null); + await page.evaluate(click('qcm-layer-epaAirNowAqi')); + await waitInPage(page, 'the air-quality box drawn', `${drawn(AQI_INK)} > 0`, 20000, null); + assert.ok(await page.evaluate(`${drawn(FIRE_INK)} > 0`), 'turning air quality on took the fire perimeters off'); + assert.deepEqual(await page.evaluate(state), { radio: 'none', fire: true, aqi: true }); + + // The exclusive raster group is untouched: choosing a surface keeps both overlays. + await page.evaluate(click('qcm-context-nightLights')); + await waitInPage(page, 'the night-lights tiles', `${lightsDrawn} > 0`, 20000, null); + assert.ok(await page.evaluate(`${drawn(FIRE_INK)} > 0 && ${drawn(AQI_INK)} > 0`), 'a raster surface removed an overlay'); + assert.deepEqual(await page.evaluate(state), { radio: 'nightLights', fire: true, aqi: true }); + const caption = await page.evaluate("document.getElementById('qcm-surface-caption').textContent"); + assert.ok(caption.includes(LAYER_BY_ID.nightLights.label), 'the surface still names itself'); + assert.ok(caption.includes(LAYER_BY_ID.nifcFirePerimeters.label), 'the overlay on the map names itself too'); + + // None clears the raster only. + await page.evaluate(click('qcm-context-none')); + await waitInPage(page, 'the lights gone', `${lightsDrawn} === 0`, 20000, null); + assert.ok(await page.evaluate(`${drawn(FIRE_INK)} > 0 && ${drawn(AQI_INK)} > 0`), '"No context surface" cleared the overlays'); + + // Each overlay goes off by itself. + await page.evaluate(click('qcm-layer-nifcFirePerimeters')); + await waitInPage(page, 'the fire perimeter removed', `${drawn(FIRE_INK)} === 0`, 20000, null); + assert.ok(await page.evaluate(`${drawn(AQI_INK)} > 0`), 'turning fire off took air quality with it'); +}); + +test('a link saved before the split restores by stable id: fire or air quality named as the surface', { skip }, async () => { + for (const [id, key, ink] of [['nifcFirePerimeters', 'fire', FIRE_INK], ['epaAirNowAqi', 'aqi', AQI_INK]]) { + const page = await freshPage(`#map=v1&z=9&c=44.5,-89.5&b=usgsTopo&s=${id}`); + await waitInPage(page, `${id} switched on from the old link`, `document.getElementById('qcm-layer-${id}').checked`, 20000, null); + const s = await page.evaluate(state); + assert.equal(s[key], true, `an old link naming ${id} as its surface did not turn it on`); + assert.equal(s.radio, 'none', 'the radio group is a raster choice only; the overlay is not in it'); + await waitInPage(page, `${id} drawn`, `${drawn(ink)} > 0`, 20000, null); + } +}); + +test('a saved raster surface still selects that raster, and a new link can hold a raster plus overlays', { skip }, async () => { + const old = await freshPage('#map=v1&z=9&c=44.5,-89.5&b=none&s=nightLights'); + await waitInPage(old, 'night lights chosen from the old link', "document.getElementById('qcm-context-nightLights').checked", 20000, null); + assert.deepEqual(await old.evaluate(state), { radio: 'nightLights', fire: false, aqi: false }); + + const page = await freshPage('#map=v1&z=9&c=44.5,-89.5&b=none&s=nightLights&o=nifcFirePerimeters,epaAirNowAqi'); + await waitInPage(page, 'all three restored', "document.getElementById('qcm-layer-epaAirNowAqi').checked && document.getElementById('qcm-layer-nifcFirePerimeters').checked", 20000, null); + assert.deepEqual(await page.evaluate(state), { radio: 'nightLights', fire: true, aqi: true }); + await waitInPage(page, 'both overlays drawn beside the lights', `${drawn(FIRE_INK)} > 0 && ${drawn(AQI_INK)} > 0 && ${lightsDrawn} > 0`, 20000, null); +}); + +test('Reset clears the raster surface and the overlays together', { skip }, async () => { + const page = await freshPage(); + await page.evaluate(click('qcm-layer-nifcFirePerimeters') + click('qcm-context-nightLights')); + await page.evaluate(click('qcm-preset-reset')); + assert.deepEqual(await page.evaluate(state), { radio: 'none', fire: false, aqi: false }); + await waitInPage(page, 'the fire perimeter removed', `${drawn(FIRE_INK)} === 0`, 20000, null); +}); + +// The caption is one line per thing on the map, in a box with a ceiling: a surface plus all six +// overlays used to be one run-on paragraph of ten-odd lines floated over the map. +const CAPTION_BUDGET_PX = 160; +test('the caption with every overlay on is one line each and stays inside its height budget', { skip }, async () => { + const { page } = await sharedSite(); + for (const vp of [{ width: 390, height: 844, mobile: true }, { width: 1280, height: 900 }]) { + await page.setViewport(vp); + const fresh = await freshPage(); + await fresh.evaluate(`(() => { + for (const id of ${JSON.stringify(CONTEXT_OVERLAY_IDS)}) { const el = document.getElementById('qcm-layer-' + id); if (el && !el.disabled && !el.checked) el.click(); } + document.getElementById('qcm-context-nightLights').click(); + })()`); + const want = await fresh.evaluate(`1 + ${JSON.stringify(CONTEXT_OVERLAY_IDS)}.filter((id) => { const el = document.getElementById('qcm-layer-' + id); return el && el.checked; }).length`); + assert.ok(want >= 3, `${vp.width}px: too few overlays could be switched on (${want}) for this to prove anything`); + await waitInPage(fresh, 'every caption line', `document.querySelectorAll('#qcm-surface-caption .qcm-surface-caption-line').length === ${want}`, 20000, null); + const m = await fresh.evaluate(`(() => { const el = document.getElementById('qcm-surface-caption'); + const lines = [...el.querySelectorAll('.qcm-surface-caption-line')]; + return { height: el.getBoundingClientRect().height, blocks: lines.every((l) => getComputedStyle(l).display === 'block'), + scrolls: getComputedStyle(el).overflowY, live: el.getAttribute('aria-live') }; })()`); + assert.ok(m.blocks, `${vp.width}px: each caption entry must be on its own line`); + assert.ok(m.height <= CAPTION_BUDGET_PX, `${vp.width}px: caption is ${m.height}px tall with everything on; budget ${CAPTION_BUDGET_PX}px`); + assert.equal(m.scrolls, 'auto', `${vp.width}px: a caption capped in height must scroll, not clip`); + } + await page.setViewport({ width: 1280, height: 900 }); +}); diff --git a/scripts/qcm/control-register.json b/scripts/qcm/control-register.json index 3921130..913147b 100644 --- a/scripts/qcm/control-register.json +++ b/scripts/qcm/control-register.json @@ -125,10 +125,10 @@ "derived": { "layerCheckboxes": { "family": "Basemap / context / overlays / presets", - "purpose": "A layer switch: one checkbox per non-base, non-surface row of layer-registry.js.", + "purpose": "A layer switch: one checkbox per non-base, non-surface row of layer-registry.js, context overlays included.", "keyboard": "Tab to it, then Space toggles it.", "persistence": "shared link", - "tests": ["scripts/qcm/layer-panel-compact-browser.test.mjs","scripts/qcm/layers-compact-browser.test.mjs","scripts/qcm/panel-shell-browser.test.mjs"], + "tests": ["scripts/qcm/context-overlays-browser.test.mjs","scripts/qcm/layer-panel-compact-browser.test.mjs","scripts/qcm/layers-compact-browser.test.mjs","scripts/qcm/panel-shell-browser.test.mjs"], "rows": { "mvum": {"scope":"Map overlay.","effect":"Shows or hides \"MVUM roads & trails\" on the map; the print recipe reads the same switch."}, "trails": {"scope":"Map overlay.","effect":"Shows or hides \"Hiking & non-motorized trails\" on the map; the print recipe reads the same switch."}, @@ -157,35 +157,35 @@ "parkCameras": {"scope":"Camera pins on the map.","effect":"Shows or hides the \"Park cameras (NPS parks)\" pins on the map; a camera toggle changes no print recipe.","family":"Camera / freshness / link-out / retry"}, "linkCameras": {"scope":"Camera pins on the map.","effect":"Shows or hides the \"Camera links (HPWREN, AlertWest)\" pins on the map; a camera toggle changes no print recipe.","family":"Camera / freshness / link-out / retry"}, "phenoCameras": {"scope":"Camera pins on the map.","effect":"Shows or hides the \"Research cameras (PhenoCam)\" pins on the map; a camera toggle changes no print recipe.","family":"Camera / freshness / link-out / retry"}, - "airportCameras": {"scope":"Camera pins on the map.","effect":"Shows or hides the \"Airport cameras (AviationWX)\" pins on the map; a camera toggle changes no print recipe.","family":"Camera / freshness / link-out / retry"} + "airportCameras": {"scope":"Camera pins on the map.","effect":"Shows or hides the \"Airport cameras (AviationWX)\" pins on the map; a camera toggle changes no print recipe.","family":"Camera / freshness / link-out / retry"}, + "femaNfhl": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"FEMA flood hazard map (regulatory floodplain)\" on the map, drawn on top of whichever surface is chosen."}, + "gbifOccurrence": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Wildlife records submitted here (open-licensed only)\" on the map, drawn on top of whichever surface is chosen."}, + "swpcAurora": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Aurora forecast (about an hour ahead)\" on the map, drawn on top of whichever surface is chosen."}, + "epaAirNowAqi": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Air quality category (the latest hour EPA published)\" on the map, drawn on top of whichever surface is chosen."}, + "noaaHmsSmoke": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Smoke plumes seen from orbit (NOAA analysis, aloft)\" on the map, drawn on top of whichever surface is chosen."}, + "nifcFirePerimeters": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Mapped wildfire perimeters (interagency record)\" on the map, drawn on top of whichever surface is chosen."}, + "streamGauges": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Stream gauges (flow and gage height, latest USGS reading)\" on the map, drawn on top of whichever surface is chosen."}, + "nwsObservations": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Weather stations (temperature, wind and humidity now)\" on the map, drawn on top of whichever surface is chosen."}, + "avalancheDanger": {"scope":"Map overlay, screen only; it coexists with the chosen surface.","effect":"Shows or hides \"Avalanche danger (forecast centre ratings)\" on the map, drawn on top of whichever surface is chosen; the row lists the zones in view with each rating, its window and the centre's forecast link."} } }, "contextRadios": { "family": "Basemap / context / overlays / presets", - "purpose": "A surface choice: one radio per context row of layer-registry.js, plus None.", + "purpose": "A surface choice: one radio per context row of layer-registry.js that fills the view as a picture (rasterSurface), plus None.", "keyboard": "Tab into the group, then arrow keys move the choice.", "persistence": "shared link", "tests": ["scripts/qcm/layer-count-browser.test.mjs","scripts/qcm/presets-browser.test.mjs","scripts/qcm/surface-choice.test.mjs"], "rows": { "nightLights": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Night light emissions (2016 satellite composite)\" as the surface; the surface caption names it and its data time."}, "transportNoise": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Transportation noise (modelled, 2020)\" as the surface; the surface caption names it and its data time."}, - "femaNfhl": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"FEMA flood hazard map (regulatory floodplain)\" as the surface; the surface caption names it and its data time."}, "nwsRadar": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Precipitation on radar (live)\" as the surface; the surface caption names it and its data time."}, - "gbifOccurrence": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Wildlife records submitted here (open-licensed only)\" as the surface; the surface caption names it and its data time."}, - "swpcAurora": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Aurora forecast (about an hour ahead)\" as the surface; the surface caption names it and its data time."}, - "epaAirNowAqi": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Air quality category (the latest hour EPA published)\" as the surface; the surface caption names it and its data time."}, - "noaaHmsSmoke": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Smoke plumes seen from orbit (NOAA analysis, aloft)\" as the surface; the surface caption names it and its data time."}, - "nifcFirePerimeters": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Mapped wildfire perimeters (interagency record)\" as the surface; the surface caption names it and its data time."}, "terrainSlope": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Steepness of the ground (slope, in degrees)\" as the surface; the surface caption names it and its data time."}, "nlcdLandCover": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"What the ground was mapped as (NLCD land cover, by year)\" as the surface; the surface caption names it and its data time."}, "nlcdTreeCanopy": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"How much tree crown covers the ground (NLCD canopy percent, by year)\" as the surface; the surface caption names it and its data time."}, "countyScore": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"County score\" as the surface; the surface caption names it and its data time."}, "hillshadeLight": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Hillshade (adjustable light)\" as the surface; the surface caption names it and its data time."}, - "streamGauges": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Stream gauges (flow and gage height, latest USGS reading)\" as the surface; the surface caption names it and its data time."}, - "nwsObservations": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Weather stations (temperature, wind and humidity now)\" as the surface; the surface caption names it and its data time."}, "terrainContours": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Contour lines (25 m interval)\" as the surface; the surface caption names it and its data time."}, "snowDepthModel": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Snow depth (modelled)\" as the surface; the surface caption names it and its data time."}, - "avalancheDanger": {"scope":"The one continuous-colour surface laid over the base map (only one at a time).","effect":"Chooses \"Avalanche danger (forecast centre ratings)\" as the surface; the surface caption names it, and the row lists the zones in view with each rating, its window and the centre's forecast link."}, "none": {"scope":"The one continuous-colour surface laid over the base map.","effect":"No surface: removes the current one."} } }, diff --git a/scripts/qcm/control-register.test.mjs b/scripts/qcm/control-register.test.mjs index b088607..de5be2f 100644 --- a/scripts/qcm/control-register.test.mjs +++ b/scripts/qcm/control-register.test.mjs @@ -50,11 +50,12 @@ const TEXT_FIELDS = ['id', 'purpose', 'scope', 'effect', 'keyboard']; // Ids come from layer-registry.js and source-basemaps.js, never from a copy here: a new // registry row with no register row fails, and so does a register row for a deleted one. // Which input a row renders as follows main.js: base radios from BASES, context radios -// for the context group (plus "none"), a checkbox for every other group. The usgsTopo +// for the context rows that fill the view as a picture (`rasterSurface`, plus "none"), a +// checkbox for every other row, a context overlay (fire, smoke, flood...) included. The usgsTopo // row is the required base layer: the base radios draw it, so it has no checkbox. export const derivedIds = { - layerCheckboxes: LAYERS.filter((l) => l.group !== 'context' && l.group !== 'base').map((l) => l.id), - contextRadios: [...LAYERS.filter((l) => l.group === 'context').map((l) => l.id), 'none'], + layerCheckboxes: LAYERS.filter((l) => l.group !== 'base' && !(l.group === 'context' && l.rasterSurface)).map((l) => l.id), + contextRadios: [...LAYERS.filter((l) => l.group === 'context' && l.rasterSurface).map((l) => l.id), 'none'], baseRadios: BASES.map((b) => b.id), }; const DERIVED_SELECTOR = { diff --git a/scripts/qcm/layer-count-browser.test.mjs b/scripts/qcm/layer-count-browser.test.mjs index 441d8e2..b8d532a 100644 --- a/scripts/qcm/layer-count-browser.test.mjs +++ b/scripts/qcm/layer-count-browser.test.mjs @@ -16,7 +16,7 @@ import { findChromium, launch } from './helpers/browser.mjs'; import { trustedClick } from './helpers/trusted-click.mjs'; import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; import { readyDashboard, chooseExampleArea } from './helpers/waits.mjs'; -import { CONTEXT_LAYER_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; +import { CONTEXT_SURFACE_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; const CHROMIUM = findChromium(); const skip = !CHROMIUM ? 'no Chromium on this box (set CHROME_PATH to run this proof)' @@ -129,7 +129,7 @@ test('an area drawn and sources checked (recipe set, rows rebuilt): checking Roa test('a context radio is counted, and "No context surface" takes the count back to none', { skip }, async () => { const page = await freshPage({ group: 'Land, sky & light context' }); - const id = CONTEXT_LAYER_IDS[0]; + const id = CONTEXT_SURFACE_IDS[0]; // a radio; overlays are qcm-layer- checkboxes await trustedClick(page, `#qcm-context-${id}`); assert.equal(await page.evaluate(`document.getElementById('qcm-context-${id}').checked`), true, 'the trusted click did not select the context radio'); assert.equal(await countText(page), '1 layer on', `context surface "${id}" is selected but the count does not say so`); diff --git a/scripts/qcm/layer-presets.test.mjs b/scripts/qcm/layer-presets.test.mjs index ec1a5db..6913c4e 100644 --- a/scripts/qcm/layer-presets.test.mjs +++ b/scripts/qcm/layer-presets.test.mjs @@ -129,9 +129,14 @@ test('main.js applies a preset through the controls the reader already has', () // CHANGED with the context radio group: the surfaces used to be four checkboxes, each // redrawing itself through CONTEXT_RENDER[spec.id]. One radio moving means the surface // that went OFF has to be redrawn too, so the handler runs the whole table. - const ctx = MAIN.slice(MAIN.indexOf('function setContext'), MAIN.indexOf('function renderSurfaceCaption')); - assert.ok(ctx.includes('Object.values(CONTEXT_RENDER)'), + // The slice ends at setOverlay, not renderSurfaceCaption: `CONTEXT_SURFACE_IDS` alone also + // appears in the invalidate() line, so only the loop itself proves the redraw. + const ctx = MAIN.slice(MAIN.indexOf('function setContext'), MAIN.indexOf('function setOverlay')); + assert.match(ctx, /for \(const id of CONTEXT_SURFACE_IDS\)\s*CONTEXT_RENDER\[id\]\(\)/, 'the surface that was switched off must be redrawn, not only the one switched on'); + // A vector overlay is its own checkbox: toggling it redraws that one row. + const ov = MAIN.slice(MAIN.indexOf('function setOverlay'), MAIN.indexOf('function renderSurfaceCaption')); + assert.ok(ov.includes('CONTEXT_RENDER[input.value]'), 'an overlay checkbox must redraw its own row'); }); // A preset names a SET of switches, and the context group now holds at most one of them. @@ -139,10 +144,13 @@ test('main.js applies a preset through the controls the reader already has', () // silently drop one on the floor. test('a preset cannot name two context surfaces, because only one can ever be drawn', () => { assert.doesNotThrow(() => build({ layers: ['nightLights'] })); - assert.throws(() => build({ layers: ['nightLights', 'swpcAurora'] }), /one context surface/); - assert.throws(() => build({ layers: ['swpcAurora', 'epaAirNowAqi'] }), /one context surface/); + assert.throws(() => build({ layers: ['nightLights', 'nwsRadar'] }), /one context surface/); + // QQA-06d: vector overlays (fire, air quality, aurora) are independent of the surface choice, + // so a preset may name them beside one raster surface; the budget counts only rasters. + assert.doesNotThrow(() => build({ layers: ['nightLights', 'swpcAurora', 'epaAirNowAqi', 'nifcFirePerimeters'] })); + assert.doesNotThrow(() => build({ layers: ['swpcAurora', 'epaAirNowAqi'] })); for (const p of PRESETS) { - assert.ok(p.layers.filter((id) => LAYER_BY_ID[id].group === 'context').length <= 1, + assert.ok(p.layers.filter((id) => LAYER_BY_ID[id].group === 'context' && LAYER_BY_ID[id].rasterSurface).length <= 1, `${p.id} asks for more surfaces than the map can draw`); } }); diff --git a/scripts/qcm/layer-registry.test.mjs b/scripts/qcm/layer-registry.test.mjs index 0c9771d..6bfdb17 100644 --- a/scripts/qcm/layer-registry.test.mjs +++ b/scripts/qcm/layer-registry.test.mjs @@ -4,7 +4,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { LAYERS, groupHint, shortLabel, LAYER_BY_ID, LAYER_IDS, DEFAULT_LAYERS, GROUPS, GROUP_IDS, enabledLayers, layerControlSpecs, - baseControlSpecs, contextControlSpecs, CONTEXT_LAYERS, DEFAULT_CONTEXT, surfaceCaption, LAYER_DRAPE, drapeKindFor } + baseControlSpecs, contextControlSpecs, CONTEXT_LAYERS, CONTEXT_LAYER_IDS, CONTEXT_SURFACE_LAYERS, CONTEXT_SURFACE_IDS, + CONTEXT_OVERLAY_LAYERS, CONTEXT_OVERLAY_IDS, DEFAULT_CONTEXT, surfaceCaption, LAYER_DRAPE, drapeKindFor } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; import { LAYERS_WITH_DRAPE } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; import { capabilityNotices } from '../../themes/headlessmode/static/js/quiet-corners-maps/terrain-overlays.js'; @@ -85,12 +86,12 @@ test('the screen base is a choice: four radios, topo checked by default, and the // It was four independent checkboxes, so four opaque rasters could be stacked and // nothing stopped it. Radios, plus an explicit None, are what make the sentence true. -test('the context group is a choice of one: a radio per surface, an explicit None, and None by default', () => { +test('the context surfaces are a choice of one: a radio per raster surface, an explicit None, and None by default', () => { const specs = contextControlSpecs(); assert.equal(DEFAULT_CONTEXT, 'none', 'no surface is laid over the map until a reader asks for one'); - assert.ok(CONTEXT_LAYERS.length >= 4, 'the four surfaces this group exists for'); - assert.deepEqual(specs.map((s) => s.id), [...CONTEXT_LAYERS.map((l) => l.id), 'none'], - 'every context layer, then the way back out'); + assert.ok(CONTEXT_SURFACE_LAYERS.length >= 4, 'the four surfaces this group exists for'); + assert.deepEqual(specs.map((s) => s.id), [...CONTEXT_SURFACE_LAYERS.map((l) => l.id), 'none'], + 'every raster surface, then the way back out'); for (const spec of specs) { assert.equal(spec.type, 'radio', `${spec.id} is still a checkbox — four could be on at once`); assert.equal(spec.name, 'qcm-context', `${spec.id} must share the group's name or it is not a choice`); @@ -100,14 +101,20 @@ test('the context group is a choice of one: a radio per surface, an explicit Non } // Exactly one on, always, and None is it until asked otherwise. assert.deepEqual(specs.filter((s) => s.checked).map((s) => s.id), ['none']); - for (const l of CONTEXT_LAYERS) { + for (const l of CONTEXT_SURFACE_LAYERS) { assert.deepEqual(contextControlSpecs(l.id).filter((s) => s.checked).map((s) => s.id), [l.id], `choosing ${l.id} must leave exactly one surface on`); } // Turning them all off again has to be reachable, which is the whole reason None is a row. assert.deepEqual(contextControlSpecs('none').filter((s) => s.checked).map((s) => s.id), ['none']); + // An id that used to be a radio (fire, air quality) is no longer one: asking the radio + // group for it lands on None rather than on a row that does not exist. + for (const id of CONTEXT_OVERLAY_IDS) { + assert.deepEqual(contextControlSpecs(id).filter((s) => s.checked).map((s) => s.id), ['none'], + `${id} is an overlay, not a surface choice`); + } // Each surface keeps the caveat it already had, and the report line it already writes into. - for (const l of CONTEXT_LAYERS) { + for (const l of CONTEXT_SURFACE_LAYERS) { const spec = specs.find((s) => s.id === l.id); assert.equal(spec.label, l.label); assert.equal(spec.note, l.coverageCaveat, `${l.id} lost its caveat in the move to radios`); @@ -116,6 +123,33 @@ test('the context group is a choice of one: a radio per surface, an explicit Non } }); +// QQA-06d: exclusivity follows how a row RENDERS, not its topic. A raster that fills the +// view (`rasterSurface`) paints over another and stays one radio group; a vector row draws +// ink on top and hides nothing, so it is an independent checkbox that coexists with +// whichever surface is chosen. Both lists are derived from the one flag the surface budget +// (layer-presets.js) already counts, so there is no second table to drift. +test('a context row is a radio surface iff it fills the view as a raster; vector rows are independent overlays', () => { + assert.deepEqual([...CONTEXT_SURFACE_IDS, ...CONTEXT_OVERLAY_IDS].sort(), [...CONTEXT_LAYER_IDS].sort(), + 'every context row is exactly one of surface or overlay'); + assert.equal(CONTEXT_SURFACE_IDS.filter((id) => CONTEXT_OVERLAY_IDS.includes(id)).length, 0); + for (const l of CONTEXT_SURFACE_LAYERS) assert.equal(l.rasterSurface, true, `${l.id} is a radio but is not a raster surface`); + for (const l of CONTEXT_OVERLAY_LAYERS) assert.ok(!l.rasterSurface, `${l.id} fills the view as a raster, so it cannot be an independent overlay`); + for (const id of ['nifcFirePerimeters', 'epaAirNowAqi', 'noaaHmsSmoke', 'femaNfhl', 'swpcAurora', 'gbifOccurrence']) { + assert.ok(CONTEXT_OVERLAY_IDS.includes(id), `${id} draws vector ink and must be an independent overlay`); + } + for (const id of ['nightLights', 'nwsRadar', 'terrainSlope', 'nlcdLandCover', 'nlcdTreeCanopy', 'countyScore', 'hillshadeLight']) { + assert.ok(CONTEXT_SURFACE_IDS.includes(id), `${id} is a raster surface and must stay in the exclusive group`); + } + // Stable ids: nothing was renamed or dropped, so an old link naming fire as its `s=` + // surface still parses (view-state.js validates against this list). + assert.ok(CONTEXT_LAYER_IDS.includes('nifcFirePerimeters') && CONTEXT_LAYER_IDS.includes('epaAirNowAqi')); + // An overlay's checkbox is the ordinary layer row, so share links (`o=`), presets and + // Reset reach it with no special case. + const fire = layerControlSpecs({ nifcFirePerimeters: true }).find((s) => s.id === 'nifcFirePerimeters'); + assert.equal(fire.inputId, 'qcm-layer-nifcFirePerimeters'); + assert.equal(fire.checked, true); +}); + // --- the active surface names itself, its time, and its age ------------------ // Which time is shown is the SOURCE's claim, never the retrieval time: a forecast shows // the time it is for, an observation the time it was taken, an inventory its vintage. diff --git a/scripts/qcm/layers-compact-browser.test.mjs b/scripts/qcm/layers-compact-browser.test.mjs index dbeba7b..bb4a602 100644 --- a/scripts/qcm/layers-compact-browser.test.mjs +++ b/scripts/qcm/layers-compact-browser.test.mjs @@ -44,7 +44,9 @@ const IS_VISIBLE = `(el) => el.checkVisibility({ contentVisibilityAuto: true, op // fallback for the federal/state groups). Computed from the registry itself, not // retyped, so a registry change updates this list rather than silently drifting from it. const KEPT_NOTE_IDS = [ - ...LAYERS.filter((l) => l.group === 'context').map((l) => `qcm-context-${l.id}`), + // QQA-06d: a raster surface is a `qcm-context-` radio; a vector context overlay is an ordinary + // `qcm-layer-` checkbox in the same group. Neither has an About disclosure. + ...LAYERS.filter((l) => l.group === 'context').map((l) => (l.rasterSurface ? `qcm-context-${l.id}` : `qcm-layer-${l.id}`)), ...LAYERS.filter((l) => l.group === 'reading').map((l) => `qcm-layer-${l.id}`), ...LAYERS.filter((l) => l.group === 'extras').map((l) => `qcm-layer-${l.id}`), // Road cameras (QUX-08D): no About disclosure, so the coverage caveat stays as its note. diff --git a/scripts/qcm/request-races-browser.test.mjs b/scripts/qcm/request-races-browser.test.mjs index 8960a02..9bd121a 100644 --- a/scripts/qcm/request-races-browser.test.mjs +++ b/scripts/qcm/request-races-browser.test.mjs @@ -177,10 +177,10 @@ test('aurora: switched on and off before its answer arrives, the layer is never await session.evaluate(`(async () => { const s = qcMoves(); window.qcMap.setView([40, -100], 6, { animate: false }); await s('the map to settle'); return true; })()`); await page.watchBodyReads(AURORA_URL); page.hold(AURORA_URL); - await session.evaluate(`document.getElementById('qcm-context-swpcAurora').click(); void 0;`); + await session.evaluate(`document.getElementById('qcm-layer-swpcAurora').click(); void 0;`); const asked = await until('the aurora request', () => heldFor(page, AURORA_URL)[0]); - await session.evaluate(`document.getElementById('qcm-context-none').click(); void 0;`); - assert.equal(await session.evaluate(`document.getElementById('qcm-context-swpcAurora').checked`), false); + await session.evaluate(`document.getElementById('qcm-layer-swpcAurora').click(); void 0;`); + assert.equal(await session.evaluate(`document.getElementById('qcm-layer-swpcAurora').checked`), false); await page.answer(asked, AURORA_BODY); await until('the aurora request to end', () => page.ended.get(asked.url)); @@ -193,7 +193,7 @@ test('aurora: switched on and off before its answer arrives, the layer is never // The control: the same answer, with the layer left on, does draw -- or the zero above proves nothing. page.release(); - await session.evaluate(`document.getElementById('qcm-context-swpcAurora').click(); void 0;`); + await session.evaluate(`document.getElementById('qcm-layer-swpcAurora').click(); void 0;`); await until('the aurora cell to be drawn', async () => { for (const h of heldFor(page, AURORA_URL)) if (!h.answered) await page.answer(h, AURORA_BODY); return (await session.evaluate(AURORA_CELLS)) > 0; diff --git a/scripts/qcm/site-coupling.json b/scripts/qcm/site-coupling.json index 80fb89c..8480aff 100644 --- a/scripts/qcm/site-coupling.json +++ b/scripts/qcm/site-coupling.json @@ -18,6 +18,7 @@ "scripts/qcm/check-built-html.test.mjs", "scripts/qcm/check-github-links.test.mjs", "scripts/qcm/choose-example-area-wait-browser.test.mjs", + "scripts/qcm/context-overlays-browser.test.mjs", "scripts/qcm/control-register.test.mjs", "scripts/qcm/county-fill-browser.test.mjs", "scripts/qcm/csp-headroom.test.mjs", diff --git a/scripts/qcm/surface-choice.test.mjs b/scripts/qcm/surface-choice.test.mjs index cbe1ff1..de6112b 100644 --- a/scripts/qcm/surface-choice.test.mjs +++ b/scripts/qcm/surface-choice.test.mjs @@ -9,7 +9,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { findChromium, launch } from './helpers/browser.mjs'; import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; -import { CONTEXT_LAYER_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; +import { CONTEXT_SURFACE_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; const CHROMIUM = findChromium(); const skip = !CHROMIUM ? 'no Chromium on this box (set CHROME_PATH to run this proof)' @@ -50,8 +50,10 @@ test('the context group is one surface at a time: None at rest, one drawn, and a // declares, so a surface that silently drops out of the rendered group still fails here. // A surface leaving the REGISTRY is a different regression, and the surface's own test // is what catches that. - assert.equal(r.before.count, CONTEXT_LAYER_IDS.length + 1, - `the page drew ${r.before.count} radios for ${CONTEXT_LAYER_IDS.length} registered surfaces plus None`); + // QQA-06d: only the raster surfaces are in the radio group now; vector overlays (fire, air + // quality, ...) are independent checkboxes beside it (context-overlays-browser.test.mjs). + assert.equal(r.before.count, CONTEXT_SURFACE_IDS.length + 1, + `the page drew ${r.before.count} radios for ${CONTEXT_SURFACE_IDS.length} registered surfaces plus None`); assert.ok(r.before.ids.includes('qcm-context-none'), 'turning them all off has to be reachable'); assert.deepEqual(r.before.onAtRest, ['none'], 'a reader who opens the page has no surface over the map'); assert.equal(r.before.lights, 0); diff --git a/themes/headlessmode/layouts/_default/quiet-corners-dashboard.html b/themes/headlessmode/layouts/_default/quiet-corners-dashboard.html index dc4ad52..e9ee37a 100644 --- a/themes/headlessmode/layouts/_default/quiet-corners-dashboard.html +++ b/themes/headlessmode/layouts/_default/quiet-corners-dashboard.html @@ -383,15 +383,16 @@

Saved places

painted under the map canvas, and an inline height: 100% cannot be overridden from the stylesheet. */}} - {{/* The surface caption. One line, on the MAP, naming the continuous-colour - surface currently laid over it and the time that surface's own data refers + {{/* The surface caption. On the MAP, one line per context row that is on — + the continuous-colour surface and each overlay drawn with it — naming it + and the time that surface's own data refers to — a forecast's valid time, an observation's observation time, an inventory's vintage. Never the time it was retrieved. Filled by main.js (renderSurfaceCaption) from layer-registry.js's surfaceCaption, and hidden - whenever no surface is on, so the resting layout is unchanged. Here rather + whenever no surface or overlay is on, so the resting layout is unchanged. Here rather than inside the Layers panel because a panel the reader has closed is not where "this is a picture of two hours ago" belongs. */}} - + {{/* Share / Reset the view: a reserved #map= URL fragment carries center, zoom, base, surface, overlays and the county wash's metric/opacity. diff --git a/themes/headlessmode/static/css/quiet-corners-maps.css b/themes/headlessmode/static/css/quiet-corners-maps.css index 7876743..8e8e4e6 100644 --- a/themes/headlessmode/static/css/quiet-corners-maps.css +++ b/themes/headlessmode/static/css/quiet-corners-maps.css @@ -220,10 +220,10 @@ .qcm-layer-about summary { font-size: 0.75rem; color: #a1a1aa; cursor: pointer; } .qcm-layer-about p { margin: 0.2rem 0 0; font-size: 0.75rem; line-height: 1.45; color: #a3a3a3; } -/* The one line the map carries while a context surface is drawn. In normal flow under the +/* The lines the map carries while a context surface or overlay is drawn. In normal flow under the map on a phone — where the map itself is in normal flow — and floated onto the bottom of the map's grid column above 1024px, where the map is `absolute inset-0` in that column. - Hidden whenever no surface is on, so it costs the resting layout nothing at all. + Hidden whenever no surface or overlay is on, so it costs the resting layout nothing at all. Bottom LEFT of the map column, 1rem in: the panels are in the dock beside the map, not on it, and Leaflet's own attribution has the bottom-right corner. */ @@ -231,7 +231,12 @@ margin: 0.5rem 1rem; padding: 0.4rem 0.7rem; background: rgba(18, 18, 30, 0.96); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 0.5rem; color: #e5e5e5; font-size: 0.85rem; line-height: 1.35; + /* A surface plus six overlays is seven entries: one line each, and a ceiling that scrolls + rather than a box that grows over the map (or, in flow on a phone, shoves the sheet). */ + max-height: 7.5rem; overflow-y: auto; } +.qcm-surface-caption-line { display: block; } +.qcm-surface-caption-line + .qcm-surface-caption-line { margin-top: 0.2rem; } @media (min-width: 1024px) { .qcm-surface-caption { grid-column: 2; grid-row: 1; diff --git a/themes/headlessmode/static/js/quiet-corners-maps/layer-presets.js b/themes/headlessmode/static/js/quiet-corners-maps/layer-presets.js index 0039ee4..524c6fe 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/layer-presets.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/layer-presets.js @@ -23,7 +23,8 @@ import { BASE_BY_ID } from './source-basemaps.js'; * the base: lights draw at opacity 0.8 (source-night-lights.js leafletOptions) and radar at * 0.55 (source-nws-radar.js radarLayer), so together the base keeps 0.2 x 0.45 of its light, * under a tenth, and nothing beneath is readable. At most ONE. Aurora and air quality are - * vector rectangles drawn only where their value is non-trivial, so they may accompany it. + * vector rectangles drawn only where their value is non-trivial, so they may accompany it -- + * they are independent checkboxes in the panel, not part of the surface choice (QQA-06d). * `symbolKeys` — the distinct marks a preset can draw, which is also the rows a reader scans * in the map key beside it. Nineteen is the public-land set; the night set draws one. * Task completion, the plan's third measure, is the one that is not mechanical: it is @@ -87,11 +88,12 @@ export function definePreset({ id, label, question, base, layers }) { if (LAYER_BY_ID[l].required) bad(`${l} is required and is not a preset's to switch`); } if (new Set(layers).size !== layers.length) bad('names a layer twice'); - // The context group is a choice of ONE (layer-registry.js contextControlSpecs), so a - // preset naming two surfaces is a set the panel cannot hold. Refused here rather than - // silently dropped by applyPreset, which would leave the reader a set nobody asked for. - const surfaces = layers.filter((l) => LAYER_BY_ID[l].group === 'context'); - if (surfaces.length > 1) bad(`names ${surfaces.length} context surfaces and the map draws one context surface at a time`); + // The raster context surfaces are a choice of ONE (layer-registry.js contextControlSpecs), + // so a preset naming two is a set the panel cannot hold. Refused here rather than silently + // dropped by applyPreset, which would leave the reader a set nobody asked for. A vector + // context overlay (fire, air quality) is an independent checkbox and may accompany one. + const surfaces = layers.filter((l) => LAYER_BY_ID[l].group === 'context' && LAYER_BY_ID[l].rasterSurface); + if (surfaces.length > 1) bad(`names ${surfaces.length} raster context surfaces and the map draws one context surface at a time`); const p = Object.freeze({ id, label, question, base, layers: Object.freeze([...layers]) }); const cost = presetCost(p); for (const [unit, cap] of Object.entries(BUDGET)) { diff --git a/themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js b/themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js index 5af10da..2ef984b 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js @@ -116,7 +116,7 @@ export const GROUPS = Object.freeze([ Object.freeze({ id: 'federal', label: 'Federal overlays', hint: 'Drawn on the map anywhere in the lower 48, and on printed sheets.' }), // 'Land, sky & light' rather than 'Sky & light': the group holds a surface about the // GROUND now, and the old label would have sent a reader looking for terrain past it. - Object.freeze({ id: 'context', label: 'Land, sky & light context', hint: 'One continuous-colour surface laid over the map. Screen only \u2014 a context surface is not drawn on a printed sheet.' }), + Object.freeze({ id: 'context', label: 'Land, sky & light context', hint: 'Pick one continuous-colour surface to lay over the map, and tick any outlines or shading to draw on top of it. Screen only \u2014 context is not drawn on a printed sheet.' }), // A FOURTH kind of switch, after ink on the map, a page in the PDF and a colour surface // over it: an answer in words about the area you drew. Both rows here come from sources // whose subject is not where a line runs — usgsGovUnits already draws the boundaries — @@ -961,25 +961,38 @@ export function availableStateLayers(states = null) { return usableFamilies(states); } -/** The context group is a choice of ONE, exactly like the base group above and for a - * sharper reason. Its hint has always read "One continuous-colour surface laid over the - * map", and it was four independent checkboxes: night lights at 0.8 and radar at 0.55 - * composite to under a tenth of whatever is beneath them, and nothing in the code stopped a - * reader stacking all four. Radios make the sentence true instead of aspirational. +/** The context group's EXCLUSIVITY FOLLOWS HOW A ROW RENDERS, not what it is about (QQA-06d). * - * `none` is the default AND a row of its own, because a reader who turned one on must be - * able to turn them all off again; with radios alone there is no way back. + * A row that fills the view as a picture -- `rasterSurface`: night lights at 0.8 and radar + * at 0.55 composite to under a tenth of whatever is beneath them, and NLCD, slope, the + * county wash and the hillshade fill the same way -- hides what is under it everywhere, so + * two of them stacked leave no base readable. Those are a choice of ONE, exactly like the + * base group above: radios, plus an explicit None (a reader who turned one on must be able + * to turn it off again; with radios alone there is no way back). + * + * A row that draws vector ink on top -- NIFC fire perimeters (`L.geoJSON`), the AQI shaded + * box, smoke, flood zones, aurora, wildlife points -- hides nothing beneath it, so nothing + * makes it exclusive. Those are ordinary independent checkboxes in the same group, and they + * coexist with whichever surface is chosen: fire and air quality are visible together. + * + * Both lists come from the one flag the surface budget (layer-presets.js) already counts, so + * there is no second table to drift. `CONTEXT_LAYER_IDS` stays every context row, because + * an old link or saved state naming fire as its surface (`s=nifcFirePerimeters`) must still + * parse and turn fire on: ids never change, only the control that carries them. * * Each surface keeps the caveat it already carried and the report line it already writes - * into (`reportId`), so nothing about WHAT one draws changes here — only which can be on - * together. */ + * into (`reportId`), so nothing about WHAT one draws changes here. */ export const CONTEXT_LAYERS = Object.freeze(LAYERS.filter((l) => l.group === 'context')); export const CONTEXT_LAYER_IDS = Object.freeze(CONTEXT_LAYERS.map((l) => l.id)); +export const CONTEXT_SURFACE_LAYERS = Object.freeze(CONTEXT_LAYERS.filter((l) => l.rasterSurface)); +export const CONTEXT_SURFACE_IDS = Object.freeze(CONTEXT_SURFACE_LAYERS.map((l) => l.id)); +export const CONTEXT_OVERLAY_LAYERS = Object.freeze(CONTEXT_LAYERS.filter((l) => !l.rasterSurface)); +export const CONTEXT_OVERLAY_IDS = Object.freeze(CONTEXT_OVERLAY_LAYERS.map((l) => l.id)); export const DEFAULT_CONTEXT = 'none'; export const NO_CONTEXT_LABEL = 'No context surface'; export function contextControlSpecs(current = DEFAULT_CONTEXT) { - const chosen = CONTEXT_LAYER_IDS.includes(current) ? current : DEFAULT_CONTEXT; + const chosen = CONTEXT_SURFACE_IDS.includes(current) ? current : DEFAULT_CONTEXT; const row = (id, label, note) => ({ id, group: 'context', type: 'radio', name: 'qcm-context', inputId: `qcm-context-${id}`, // The status line each surface already writes its age into. Keeping the id means @@ -988,7 +1001,7 @@ export function contextControlSpecs(current = DEFAULT_CONTEXT) { label, checked: id === chosen, disabled: false, note: note || null, noteId: note ? `qcm-context-${id}-note` : null, }); - return [...CONTEXT_LAYERS.map((l) => row(l.id, l.label, l.coverageCaveat)), + return [...CONTEXT_SURFACE_LAYERS.map((l) => row(l.id, l.label, l.coverageCaveat)), row('none', NO_CONTEXT_LABEL, null)]; } diff --git a/themes/headlessmode/static/js/quiet-corners-maps/main.js b/themes/headlessmode/static/js/quiet-corners-maps/main.js index 6a85e7e..7d7f3d7 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/main.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/main.js @@ -34,7 +34,7 @@ import { fetchWetlands } from './source-wetlands.js'; import { fetchCountyLines } from './source-county-lines.js'; import { fetchSurfaceOwnership } from './source-surface-ownership.js'; import { LAYERS, LAYER_IDS, LAYER_BY_ID, GROUPS, layerControlSpecs, baseControlSpecs, contextControlSpecs, - CONTEXT_LAYER_IDS, DEFAULT_CONTEXT, surfaceCaption, enabledLayers, LAYERS_WITH_DRAPE, groupHint } from './layer-registry.js'; + CONTEXT_SURFACE_IDS, CONTEXT_OVERLAY_IDS, DEFAULT_CONTEXT, surfaceCaption, enabledLayers, LAYERS_WITH_DRAPE, groupHint } from './layer-registry.js'; import { filterLayerSpecs, layerBadge, layerCountLine, selectedScreenRows, layerAboutText, rowSummaryLine, groupIsCollapsedByDefault } from './layer-panel.js'; import { SOURCES, DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; import { fetchWithDeadline, createLatest } from './request.js'; @@ -862,13 +862,14 @@ function controls() { grid: checked('qcm-grid') === 'none' ? 'none' : 'utm-1km', layers: layersFromControls() }; } -/** Reads every registry layer's own checkbox; a required layer is always on. The context - * group has no checkboxes at all -- it is one radio group, so at most one of its surfaces - * can be on and the reader's single choice answers for all four. */ +/** Reads every registry layer's own checkbox; a required layer is always on. The raster + * context surfaces have no checkboxes -- they are one radio group, so at most one can be on + * and the reader's single choice answers for all of them. The vector context overlays (fire, + * air quality, ...) are ordinary checkboxes and are read like any other layer. */ function layersFromControls() { const surface = currentContext(); return Object.fromEntries(LAYERS.map((l) => { - if (CONTEXT_LAYER_IDS.includes(l.id)) return [l.id, l.id === surface]; + if (CONTEXT_SURFACE_IDS.includes(l.id)) return [l.id, l.id === surface]; const el = $(`qcm-layer-${l.id}`); return [l.id, l.required || (el ? el.checked : !!l.defaultOn)]; })); @@ -888,15 +889,27 @@ function setBase() { * ticked, it is 'none', so a reader who never opens the panel has a clear map. */ const currentContext = () => checked('qcm-context') || DEFAULT_CONTEXT; -/** One radio moved, so EVERY context surface redraws -- not only the one that came on. The +/** One radio moved, so EVERY raster surface redraws -- not only the one that came on. The * one that went off has to take itself off the map, and with four checkboxes each redrew - * only itself. `CONTEXT_RENDER` is the same table the checkboxes used; all of it runs. */ + * only itself. `CONTEXT_RENDER` is the same table the checkboxes used; every surface in it + * runs. The vector overlays are not touched: they are not in this choice (setOverlay). */ function setContext() { - invalidate({ layers: Object.fromEntries(CONTEXT_LAYER_IDS.map((id) => [id, id === currentContext()])) }); - for (const redraw of Object.values(CONTEXT_RENDER)) redraw(); + invalidate({ layers: Object.fromEntries(CONTEXT_SURFACE_IDS.map((id) => [id, id === currentContext()])) }); + for (const id of CONTEXT_SURFACE_IDS) CONTEXT_RENDER[id](); renderSurfaceCaption(); replan(); renderLegend(); syncHillshadeLightVisibility(); } +/** One vector context overlay (fire perimeters, air quality, ...) switched on or off. Its own + * checkbox, so it moves nothing else: the raster surface and every other overlay stay as + * they were, which is how fire and air quality are drawn together. Redraws itself through + * the same `CONTEXT_RENDER` table the surfaces use. */ +function setOverlay(event) { + const input = event.target; + invalidate({ layers: { [input.value]: input.checked } }); + CONTEXT_RENDER[input.value](); + renderSurfaceCaption(); replan(); renderLegend(); +} + /** QUX-04b: the manual light knobs are only meaningful once hillshadeLight is the chosen * context surface, so Map appearance hides the fieldset (never removes it — its ids stay * wired) the rest of the time and shows a one-line hint in its place, so opening Map @@ -942,15 +955,28 @@ const SURFACE_TIME = { nlcdTreeCanopy: () => ({ vintage: String(treeCanopy ? treeCanopy.year : defaultYearFor('canopy')) }), }; -/** The one line the map itself carries while a surface is drawn: which surface, and the time - * its data refers to. On the map rather than inside the Layers panel, because a panel a +/** The one line the map itself carries while a surface or context overlay is drawn: which, and + * the time its data refers to. On the map rather than inside the Layers panel, because a panel a * reader has closed is not where "you are looking at a picture of an hour ago" belongs. */ function renderSurfaceCaption() { const el = $('qcm-surface-caption'); if (!el) return; - const id = currentContext(); - const caption = surfaceCaption(id, SURFACE_TIME[id] ? SURFACE_TIME[id]() : {}); - el.textContent = caption ? caption.text : ''; - el.hidden = !caption; + // The chosen raster surface, then each context overlay that is on: all of them can be + // drawn at once, so each names itself and the time its own data refers to. + const on = layersFromControls(); + const ids = [currentContext(), ...CONTEXT_OVERLAY_IDS.filter((id) => on[id])]; + const lines = ids.map((id) => surfaceCaption(id, SURFACE_TIME[id] ? SURFACE_TIME[id]() : {})).filter(Boolean); + // One block per layer, kept by id, so a toggle adds or removes only its own line and the + // live region announces that line rather than the whole stack read out again. + const have = new Map([...el.children].map((c) => [c.dataset.layer, c])); + lines.forEach((c, i) => { + let line = have.get(c.layerId); + if (!line) { line = document.createElement('span'); line.className = 'qcm-surface-caption-line'; line.dataset.layer = c.layerId; } + if (line.textContent !== c.text) line.textContent = c.text; + if (el.children[i] !== line) el.insertBefore(line, el.children[i] || null); + have.delete(c.layerId); + }); + for (const stale of have.values()) stale.remove(); + el.hidden = !lines.length; } /** The one status a base row can need: the relief cache ends before the map does. */ @@ -1214,7 +1240,8 @@ async function retryPass(id) { /** The search box and the "only layers that are on" toggle, together: which rows * and which group headings stay visible, without touching any switch's checked * state. Base and context radios always carry `alwaysShow` (their CURRENT - * choice), so a group never reads empty under active-only. */ + * choice), so a group never reads empty under active-only; a context overlay is + * an ordinary checkbox and is hidden there while off. */ function applyLayerFilter() { const host = $('qcm-layers'); if (!host) return; const query = $('qcm-layer-search')?.value || ''; @@ -1232,7 +1259,7 @@ function applyLayerFilter() { note: row.querySelector('.qcm-hint')?.textContent || '', keywords: LAYER_BY_ID[input.value]?.searchTerms || '', checked: input.checked, - alwaysShow: group.id === 'base' || group.id === 'context', + alwaysShow: group.id === 'base' || (group.id === 'context' && input.type === 'radio'), el: row, groupBox: box, }); @@ -1331,14 +1358,15 @@ function applyPreset(p, { silent = false } = {}) { el.dispatchEvent(new Event('change')); if (el.checked) moved.push(l.label); } - // The context surface is one radio, not a row per surface: a preset naming none of them - // means None, which is a move like any other. definePreset refuses a preset naming two. - const surface = $(`qcm-context-${p.layers.find((id) => CONTEXT_LAYER_IDS.includes(id)) || 'none'}`); + // The raster context surface is one radio, not a row per surface: a preset naming none of + // them means None, which is a move like any other. definePreset refuses a preset naming two. + // A context overlay (fire, air quality) is a checkbox and was moved by the loop above. + const surface = $(`qcm-context-${p.layers.find((id) => CONTEXT_SURFACE_IDS.includes(id)) || 'none'}`); if (surface && !surface.checked) { const prevSurface = document.querySelector('input[name="qcm-context"]:checked'); if (prevSurface) undo.push({ id: prevSurface.id, checked: true }); surface.checked = true; surface.dispatchEvent(new Event('change')); - moved.push(...p.layers.filter((id) => CONTEXT_LAYER_IDS.includes(id)).map((id) => LAYER_BY_ID[id].label)); + moved.push(...p.layers.filter((id) => CONTEXT_SURFACE_IDS.includes(id)).map((id) => LAYER_BY_ID[id].label)); } const base = $(`qcm-base-${p.base}`); if (base && !base.checked) { @@ -1506,10 +1534,12 @@ function renderLayerControls() { host.replaceChildren(buildCameraEntry(), ...GROUPS.flatMap((group) => { const rows = group.id === 'base' ? baseControlSpecs(base).map((s) => layerRow(s, setBase)) - // The context group is the base group's twin: radios and an explicit None, so the - // hint's "One continuous-colour surface" is enforced rather than merely promised. + // The context group's raster surfaces are the base group's twin: radios and an explicit + // None, so only one view-filling picture is ever laid over the map. Its vector overlays + // follow as ordinary checkboxes, independent of that choice (layer-registry.js). : group.id === 'context' - ? contextControlSpecs(currentContext()).map((s) => layerRow(s, setContext)) + ? [...contextControlSpecs(currentContext()).map((s) => layerRow(s, setContext)), + ...specs.filter((s) => CONTEXT_OVERLAY_IDS.includes(s.id)).map((s) => layerRow(s, setOverlay))] : specs.filter((s) => s.group === group.id).map((s) => layerRow(s, READING_RENDER[s.id] || CAMERA_RENDER[s.id] || SCREEN_RENDER[s.id] || NAMES_RENDER[s.id], group.id === 'federal' || group.id === 'state' ? { layer: LAYER_BY_ID[s.id] } : {})); if (!rows.length) return []; // a group with no layers draws no heading