From 60906899b476790eefdf2d38953126db828948ee Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sat, 24 Oct 2026 12:57:15 -0400 Subject: [PATCH 1/5] fix(qcm): context overlays sit beside the one raster surface (QQA-06d) Ported from headlessmode-archive #790 (commit 3e807b4f) onto master; conflicts in main.js and layer-registry.test.mjs resolved against master's groupHint and screen/names render maps. --- content/quiet-corners-maps.md | 4 +- scripts/qcm/README.md | 24 +++ scripts/qcm/context-overlays-browser.test.mjs | 189 ++++++++++++++++++ scripts/qcm/control-register.json | 26 +-- scripts/qcm/control-register.test.mjs | 7 +- scripts/qcm/layer-count-browser.test.mjs | 4 +- scripts/qcm/layer-presets.test.mjs | 18 +- scripts/qcm/layer-registry.test.mjs | 48 ++++- scripts/qcm/layers-compact-browser.test.mjs | 4 +- scripts/qcm/request-races-browser.test.mjs | 8 +- scripts/qcm/site-coupling.json | 1 + scripts/qcm/surface-choice.test.mjs | 8 +- .../_default/quiet-corners-dashboard.html | 9 +- .../static/css/quiet-corners-maps.css | 9 +- .../js/quiet-corners-maps/layer-presets.js | 14 +- .../js/quiet-corners-maps/layer-registry.js | 37 ++-- .../static/js/quiet-corners-maps/main.js | 78 +++++--- 17 files changed, 400 insertions(+), 88 deletions(-) create mode 100644 scripts/qcm/context-overlays-browser.test.mjs 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 From 79129cfce7ebf2a9d7214c84cff639553a81bc6e Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sat, 24 Oct 2026 13:06:31 -0400 Subject: [PATCH 2/5] feat(qcm): group the Layers panel by task, with an Active on map list (QQA-18) The Layers panel was headed by provider and rendering role. It now has ten task headings (Map style, Places & routes, Land & access, Terrain & landscape, Weather & conditions, Night sky, Cameras, Place facts, Explore counties, Print options); every layer id has exactly one home. Search also matches group purpose words and opens matching groups, restoring them when cleared. An Active on map list shows each layer that is on with Remove and Retry. Print-only pages are not counted as on the map. Presentation only: ids, input ids, radio/checkbox roles, URL state and what each layer draws are unchanged. Ported from headlessmode-archive #794 (commit 7f665250). Adapted to the shipped catalogue: the eleven layers added since get a task home, and the browser tests account for the default-on names and roads layers in the Active list. --- content/quiet-corners-maps.md | 12 +- content/quiet-corners.md | 2 +- scripts/qcm/README.md | 24 +- scripts/qcm/blm-rec-sites.test.mjs | 3 + scripts/qcm/context-overlays-browser.test.mjs | 4 +- scripts/qcm/control-register.json | 1 + scripts/qcm/layer-count-browser.test.mjs | 6 +- scripts/qcm/layer-count.test.mjs | 16 +- scripts/qcm/layer-groups-browser.test.mjs | 248 ++++++++++++++++++ .../qcm/layer-panel-compact-browser.test.mjs | 24 +- scripts/qcm/layer-registry.test.mjs | 2 +- scripts/qcm/layer-task-groups.test.mjs | 76 ++++++ scripts/qcm/layers-compact-browser.test.mjs | 2 +- scripts/qcm/site-coupling.json | 1 + .../layouts/partials/qcm-map-layers.html | 13 +- .../static/css/quiet-corners-maps.css | 9 + .../js/quiet-corners-maps/layer-panel.js | 35 ++- .../js/quiet-corners-maps/layer-registry.js | 53 +++- .../static/js/quiet-corners-maps/main.js | 171 +++++++++--- 19 files changed, 625 insertions(+), 77 deletions(-) create mode 100644 scripts/qcm/layer-groups-browser.test.mjs create mode 100644 scripts/qcm/layer-task-groups.test.mjs diff --git a/content/quiet-corners-maps.md b/content/quiet-corners-maps.md index a537c01..3b4b911 100644 --- a/content/quiet-corners-maps.md +++ b/content/quiet-corners-maps.md @@ -45,11 +45,13 @@ 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 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. +- **Clutter.** At most one continuous-colour surface at a time, and that is enforced rather than promised: 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 across the whole panel, wherever each sits under its own heading (night lights under **Night sky**, radar under **Weather & conditions**, slope and land cover under **Terrain & landscape**, the county ranking under **Explore counties**), or **No context surface**, which sits under **Map style** beside the base map — exactly like the base map. 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 its topic 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 -The layer list grows with the map, so above it is a search box and an "Only layers that are on" toggle — the search narrows the rows shown by name or description as you type, the toggle hides everything not switched on, and neither ever changes a switch itself: turn off the search or the toggle and every row, on or off, is exactly as you left it. A line above the list states how many layers are on and how many are still loading. Each federal or state layer carries a small word next to its name — **off**, **loading**, **ready**, **empty** (fetched, nothing here), **partial** (the service stopped part way through), **unavailable**, or **zoom in** (too far out for this map to ask yet) — and its own "About this layer" details: where the data comes from, its licence and coverage caveats, and whether it has been fetched for the area drawn or not yet. A layer reading unavailable or partial gets a Retry button that asks that one service again, without re-fetching anything else or moving the map; unticking a layer's switch is Clear — it removes the layer's lines and its mark from the map key immediately, and the map itself does not move. +The Layers panel is grouped by what you are trying to do, not by who publishes the data: **Map style**, **Places & routes**, **Land & access**, **Terrain & landscape**, **Weather & conditions**, **Night sky**, **Cameras**, **Place facts**, **Explore counties** and **Print options**. Every layer has exactly one home, and the groups start collapsed. Searching also matches a group's purpose and a layer's provider and alternative names (a search for webcam, weather or NOAA finds rows whose names say none of those), opens every group that has a match, and puts the groups back as they were when you clear it; a group you opened or closed yourself while searching stays as you left it. Above the groups, **Active on map** lists each layer that is on with a **Remove** button, whatever the search or the "only layers that are on" toggle is hiding, and a **Retry** where a layer failed to load. The pages under **Print options** (aerial imagery, shaded relief, the trip page) are for the PDF, not the screen, so they are never listed there. This only changes how the panel is laid out: a layer's switch, what it draws and what an older saved view or shared link restores are unchanged. + +The layer list grows with the map, so above it is a search box and an "Only layers that are on" toggle — the search narrows the rows shown by name or description as you type, the toggle hides everything not switched on, and neither ever changes a switch itself: turn off the search or the toggle and every row, on or off, is exactly as you left it. A line above the list states how many layers are on and how many are still loading; a page under **Print options** adds a page to the PDF and draws nothing, so it is not counted. Each federal or state layer carries a small word next to its name — **off**, **loading**, **ready**, **empty** (fetched, nothing here), **partial** (the service stopped part way through), **unavailable**, or **zoom in** (too far out for this map to ask yet) — and its own "About this layer" details: where the data comes from, its licence and coverage caveats, and whether it has been fetched for the area drawn or not yet. A layer reading unavailable or partial gets a Retry button that asks that one service again, without re-fetching anything else or moving the map; unticking a layer's switch is Clear — it removes the layer's lines and its mark from the map key immediately, and the map itself does not move. ## Finding a place @@ -98,7 +100,7 @@ Print at Actual size (100%) and turn off any Fit or Shrink option. Each sheet ca ### Names & landmarks -**Named places & landmarks** is a layer switch listed under the same federal group as the base map: summits, gaps, ridges, falls, springs, lakes, populated places and the rest of GNIS's vocabulary, one point per name from USGS's Geographic Names Information System. It is **on by default**, so a reader who touches nothing sees the names; untick it for a plain map, and a saved project that stored it off keeps it off. The USGS Topo base prints its own names, and these decluttered ones may repeat some of them. While it is on, the map asks for whatever area is on screen, with its own per-view pass like Road names: no drawn print area, no zoom floor beyond the lowest zoom a name can draw at (8) and no area ceiling gate it, a pan that stays inside the box already fetched asks for nothing, and a refresh that fails leaves the last labels on the map and says so. Panning or zooming out of that box asks again for the new area, and an answer for a rectangle you have since left is dropped rather than drawn, so the names on screen always match the area in front of you. A search that genuinely finds nothing prints "No named terrain found in this area" — a real answer, not a failure — and a search that did not finish (a timeout, an error) says so distinctly and never draws a silently incomplete set. The request follows the zoom: only the kinds of name that zoom can draw are asked for (places from zoom 8, peaks from 9, and so on), and when an area holds more names than one request carries, the most important kinds are fetched first and drawn, with a note naming what zooming in adds ("Showing places and peaks here -- zoom in for creeks and lakes"). A capped answer still blocks the PDF, like any incomplete one. An answer that came back incomplete is still listed under **Places in this view** below, with a notice that it is incomplete — the map itself labels none of those names until a full answer arrives. +**Named places & landmarks** is a layer switch listed under **Places & routes**: summits, gaps, ridges, falls, springs, lakes, populated places and the rest of GNIS's vocabulary, one point per name from USGS's Geographic Names Information System. It is **on by default**, so a reader who touches nothing sees the names; untick it for a plain map, and a saved project that stored it off keeps it off. The USGS Topo base prints its own names, and these decluttered ones may repeat some of them. While it is on, the map asks for whatever area is on screen, with its own per-view pass like Road names: no drawn print area, no zoom floor beyond the lowest zoom a name can draw at (8) and no area ceiling gate it, a pan that stays inside the box already fetched asks for nothing, and a refresh that fails leaves the last labels on the map and says so. Panning or zooming out of that box asks again for the new area, and an answer for a rectangle you have since left is dropped rather than drawn, so the names on screen always match the area in front of you. A search that genuinely finds nothing prints "No named terrain found in this area" — a real answer, not a failure — and a search that did not finish (a timeout, an error) says so distinctly and never draws a silently incomplete set. The request follows the zoom: only the kinds of name that zoom can draw are asked for (places from zoom 8, peaks from 9, and so on), and when an area holds more names than one request carries, the most important kinds are fetched first and drawn, with a note naming what zooming in adds ("Showing places and peaks here -- zoom in for creeks and lakes"). A capped answer still blocks the PDF, like any incomplete one. An answer that came back incomplete is still listed under **Places in this view** below, with a notice that it is incomplete — the map itself labels none of those names until a full answer arrives. Screen labels declutter automatically as you zoom: only as many names as comfortably fit are lettered, and only a labelled name gets a marker on the map. Every name loaded for the area you're looking at — labelled or not — is listed under **Places in this view** below the layer switch, with filters for the kind of place and a toggle for "Only places not labelled on the map", so nothing the map declutters is ever hidden from you. Tab to a row, or tap it, and it draws that name's marker and opens the same card a map pin opens — its kind, its state and county, its coordinates, the note that GNIS gives one published point per name and not every place the name is used, its approximate terrain elevation fetched from USGS 3DEP once you open the card, and buttons to save the place, copy the coordinates, or copy a shareable link to that place — without moving or re-centering the map. Saved places can also be downloaded as a GPX file, with **Download GPX** under **Saved places**, for a GPS app or another map. @@ -273,9 +275,9 @@ 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 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. +**Which context surface am I looking at, and when is it from?** Whichever surface is chosen (night lights, radar, slope, land cover, tree canopy, the hillshade or the county ranking, each under its own topic heading) — 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**. +**Can I turn the base map off, or see the aerial or relief on screen?** Yes — under **Map style** 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 **Print options**. **"USGS Topo must load completely before export."** At least one base-map tile did not come back. The build refuses rather than printing a sheet with a blank patch. Try again — the tile service rate-limits bursts — and if it persists, reduce the area or use 1:48,000, which needs fewer tiles. diff --git a/content/quiet-corners.md b/content/quiet-corners.md index b5228df..c5d3745 100644 --- a/content/quiet-corners.md +++ b/content/quiet-corners.md @@ -161,7 +161,7 @@ and never asks a camera operator for anything by itself; pressing a camera's name is what opens its card on the map. A camera 25 km away is not the same weather across a ridge, and the list says so. -The same map carries the [Field Maps](/quiet-corners/maps/) builder's Layers panel — base map choice, a names-and-search layer, a Places in this view list, display controls, and a set of starting presets, one of which, **Compare counties**, turns on this page's own county-ranking wash (off by default) through the same switch the Layers panel's **Land, sky & light context** group already uses for every other context surface: there is no separate toggle for it. Undo and Reset sit beside the presets for backing a shortcut out again. See the Field Maps page for the full list and what each one moves. +The same map carries the [Field Maps](/quiet-corners/maps/) builder's Layers panel — grouped by task (**Map style**, **Places & routes**, **Weather & conditions** and so on, with an **Active on map** list above them) — base map choice, a names-and-search layer, a Places in this view list, display controls, and a set of starting presets, one of which, **Compare counties**, turns on this page's own county-ranking wash (off by default) through the same switch the Layers panel uses for every other colour surface (the county ranking sits under **Explore counties**, and like night lights, radar or slope it is one choice among them, or **No context surface**): there is no separate toggle for it. Undo and Reset sit beside the presets for backing a shortcut out again. See the Field Maps page for the full list and what each one moves. On a wider screen the four panels — Explore, Conditions, Layers and Print — sit in one column to the left of the map, one open at a time, so none of them ever covers the map; the column is the same width whichever panel is open, so the map never shifts under you, and Explore's county and town tables show each place as a small card with every figure labelled instead of a wide table. On a phone, opening any panel — Explore, Conditions, Layers or Print — opens it in a sheet under the map instead of over it, so the map stays visible and never blank. Drag the sheet's handle up or down, or use its Expand/Shrink button, to see more of the panel or more of the map; Close or Escape returns you to whichever control you opened it from. diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index cb89cc3..6c0a136 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -410,11 +410,12 @@ scripts. `QCM_SMOKE_NO_BROWSER=1` makes the CLI behave as if there were no Chrom ## 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 +The context rows have no heading of their own since QQA-18: each sits under its task group +(`TASK_GROUPS` in `layer-registry.js`, see below), and they are two kinds of row, which kind +following how a row 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, +explicit **No context surface** (`qcm-context-none`, homed under Map style as the `noContextSurface` pseudo-id; the radios share one `name`, so the choice stays exclusive across headings). 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 @@ -432,6 +433,23 @@ a height ceiling (7.5rem) that scrolls. With a surface and every overlay on it s | `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. | +## Layer panel task groups + +The Layers panel headings are the plan 4.7 task groups, one primary home per layer id: +`TASK_GROUPS` and `TASK_GROUP_OF` in `layer-registry.js` (a presentation map, separate from the +registry `group`, which stays the rendering role: base, federal, state, context, reading, cameras, +extras). A new provider adds a row to an existing task group, never a new top-level group, and +`layer-task-groups.test.mjs` fails when a registry id has no home. `main.js` renders rows by id, +so no layer id, input id, exclusivity or render path moves. `activeOnMapRows` and +`groupsToCollapse` (`layer-panel.js`) decide the **Active on map** list (checked, not a base +choice, not a Print options row) and which groups a cleared search closes again. The count line agrees with that list: `COUNTED_SCREEN_GROUPS` leaves `extras` out, so a print extra never reads as "1 layer on". The list is rebuilt only when its content changes, so a focused Remove or Retry button keeps focus through a fetch re-render. Task presets +(Explore outdoors, Check conditions, Night sky) are not built yet. + +| Proof | What it holds | +| --- | --- | +| `layer-task-groups.test.mjs` | The ten headings in order; every registry id, base and the None row has exactly one home; Print options equals the extra-page rows. | +| `layer-groups-browser.test.mjs` | Journey 11: groups collapsed, search by alias, purpose and provider with the expansion restored, activate across topics, find and remove an active layer after filtering, a print extra, an old `#map=v1` link restored by id. | + ## Physical print proof Layout is proven here without paper. Whether a printer honours it is a separate check: diff --git a/scripts/qcm/blm-rec-sites.test.mjs b/scripts/qcm/blm-rec-sites.test.mjs index b02661f..4820107 100644 --- a/scripts/qcm/blm-rec-sites.test.mjs +++ b/scripts/qcm/blm-rec-sites.test.mjs @@ -103,6 +103,9 @@ const literals = (src) => [...src.matchAll(/'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]| test('no string a reader sees claims a darkness rating, or a sky at all', () => { const fromRecords = CAPTURE.features.flatMap((f) => siteLines(normalizeBlmSite(f))); for (const s of [...readerStrings(), ...fromRecords, ...literals(MODULE), ...literals(REGISTRY)]) { + // QQA-18: the registry now names the layer panel's Night sky task group (the plan's own heading); + // that exact heading is a place to find night lights and aurora, not a claim about a BLM site. + if (s === 'Night sky') continue; for (const rx of FORBIDDEN) assert.ok(!rx.test(s), `${rx} matches a reader-facing string: ${JSON.stringify(s)}`); } const l = LAYER_BY_ID.blmRecSites; diff --git a/scripts/qcm/context-overlays-browser.test.mjs b/scripts/qcm/context-overlays-browser.test.mjs index 2edf330..3f7f1bc 100644 --- a/scripts/qcm/context-overlays-browser.test.mjs +++ b/scripts/qcm/context-overlays-browser.test.mjs @@ -89,7 +89,7 @@ test('fire perimeters and air quality are independent checkboxes in the context 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'); + const group = [...document.querySelectorAll('.qcm-layer-group')].find((g) => g.getAttribute('aria-label') === 'Weather & conditions'); 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'), @@ -97,7 +97,7 @@ test('fire perimeters and air quality are independent checkboxes in the context })()`); 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.fireInGroup && r.aqiInGroup, 'the weather overlays sit under Weather & conditions, beside the radar they draw on'); 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')); diff --git a/scripts/qcm/control-register.json b/scripts/qcm/control-register.json index 913147b..1d4ea42 100644 --- a/scripts/qcm/control-register.json +++ b/scripts/qcm/control-register.json @@ -59,6 +59,7 @@ {"id":"label-size","selector":"#qc-label-size","pages":["dashboard"],"family":"Map label size / crowding / contrast / emphasis","purpose":"Label text size.","scope":"Place labels.","effect":"Redraws labels at Normal, Large or Extra large.","keyboard":"Tab to it, then arrow keys (or Alt+Down) choose an option.","persistence":"saved + shared link","tests":["scripts/qcm/label-prefs-browser.test.mjs","scripts/qcm/label-size-browser.test.mjs"]}, {"id":"layer-search","selector":"#qcm-layer-search","pages":["dashboard"],"family":"Basemap / context / overlays / presets","purpose":"Filter the layer list by name.","scope":"Which layer rows are shown; no layer is switched.","effect":"Hides rows that do not match the typed text.","keyboard":"Tab to it, then type.","persistence":"none","tests":["scripts/qcm/camera-discovery-browser.test.mjs","scripts/qcm/layer-panel-browser.test.mjs","scripts/qcm/layer-panel-compact-browser.test.mjs"]}, {"id":"layers-active-only","selector":"#qcm-layers-active-only","pages":["dashboard"],"family":"Basemap / context / overlays / presets","purpose":"Show only the layers that are switched on.","scope":"Which layer rows are shown; no layer is switched.","effect":"Hides rows that are off.","keyboard":"Tab to it, then Space toggles it.","persistence":"none","tests":["scripts/qcm/layer-panel-browser.test.mjs","scripts/qcm/layer-panel-compact-browser.test.mjs"]}, + {"id":"active-remove","selector":"button.qcm-active-remove","pages":["dashboard"],"family":"Basemap / context / overlays / presets","purpose":"Remove: switch a layer off from the Active on map list.","scope":"That layer's own switch; the map and the print recipe follow it.","effect":"Presses the layer's own control, so a context surface goes through No context surface; focus moves to the list title or the layer search.","keyboard":"Tab to it, then Enter or Space.","persistence":"shared link","transient":true,"needle":"qcm-active-remove","tests":["scripts/qcm/layer-groups-browser.test.mjs"]}, {"id":"layer-retry","selector":".qcm-layer-retry","pages":["dashboard"],"family":"Camera / freshness / link-out / retry","purpose":"Retry a layer whose data failed to load (one button per retriable layer, shown only after a failure).","scope":"That layer's fetch.","effect":"Requests the layer again and updates its status line.","keyboard":"Tab to it, then Enter or Space.","persistence":"none","needle":"qcm-layer-retry","tests":[],"untested":"No test file under scripts/qcm names this control; the gate and the unit that owns it should add one."}, {"id":"camera-goto","selector":".qcm-camera-goto","pages":["dashboard"],"family":"Camera / freshness / link-out / retry","purpose":"Go to a camera coverage region (West, California, Great Smoky Mountains).","scope":"Map view; opens the Cameras group and focuses that region's switch.","effect":"Fits the map to the region.","keyboard":"Tab to it, then Enter or Space.","persistence":"shared link","tests":["scripts/qcm/camera-discovery-browser.test.mjs"]}, {"id":"preset","selector":"button[id^=\"qcm-preset-\"]","pages":["dashboard"],"family":"Basemap / context / overlays / presets","purpose":"Preset buttons (Land access, Public land, Trails & terrain, Night sky, Compare counties, Map only, Loud or quiet) and their Undo and Reset.","scope":"The base map, the context surface and the layer switches they move.","effect":"Applies a named set of switches, says what moved in the status line; Undo restores only what the last press moved.","keyboard":"Tab to it, then Enter or Space.","persistence":"shared link","needle":"qcm-preset-","tests":["scripts/qcm/presets-browser.test.mjs","scripts/qcm/road-labels-browser.test.mjs"]}, diff --git a/scripts/qcm/layer-count-browser.test.mjs b/scripts/qcm/layer-count-browser.test.mjs index b8d532a..cafafa7 100644 --- a/scripts/qcm/layer-count-browser.test.mjs +++ b/scripts/qcm/layer-count-browser.test.mjs @@ -128,8 +128,12 @@ 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' }); + // QQA-18: the surface radios sit under their task heading (night lights: Night sky), while + // "No context surface" sits under Map style -- one `qcm-context` choice across two groups. + const page = await freshPage({ group: 'Night sky' }); const id = CONTEXT_SURFACE_IDS[0]; // a radio; overlays are qcm-layer- checkboxes + const styleSel = '.qcm-layer-group[aria-label="Map style"]'; + await trustedClick(page, `${styleSel} summary`, { until: `document.querySelector('${styleSel}').open` }); 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-count.test.mjs b/scripts/qcm/layer-count.test.mjs index cdfef63..c443d15 100644 --- a/scripts/qcm/layer-count.test.mjs +++ b/scripts/qcm/layer-count.test.mjs @@ -18,8 +18,7 @@ test('a checked camera layer is counted, never "No layers on"', () => { assert.equal(line({ roadCameras: true, parkCameras: true }), '2 layers on'); }); -test('context, reading and extras layers count the same way', () => { - assert.equal(line({ aerial: true }), '1 layer on'); +test('context and reading layers count the same way', () => { assert.equal(line({ padusAccess: true }), '1 layer on'); const context = LAYERS.find((l) => l.group === 'context'); assert.equal(line({ [context.id]: true }), '1 layer on'); @@ -49,10 +48,19 @@ test('no required row sits in a counted group, so the count needs no `required` assert.deepEqual(stray, [], 'a required row in a counted group would always read as "on"'); }); -test('every registry group is either counted here, badged by main.js, or the basemap choice', () => { +test('QQA-18: a print extra is a page in the PDF, not a layer on the map, so it never moves the count', () => { + for (const id of ['aerial', 'terrain', 'tripPage']) { + assert.equal(LAYERS.find((l) => l.id === id).group, 'extras'); + assert.equal(line({ [id]: true }), 'No layers on', `${id} is a print extra and must not read as on the map`); + } + assert.equal(line({ tripPage: true, roadCameras: true }), '1 layer on'); + assert.ok(!COUNTED_SCREEN_GROUPS.includes('extras')); +}); + +test('every registry group is either counted here, badged by main.js, the basemap choice, or a print page', () => { const badgedByMain = new Set(['federal', 'state']); for (const g of GROUPS) { - if (g.id === 'base') continue; + if (g.id === 'base' || g.id === 'extras') continue; assert.ok(badgedByMain.has(g.id) || COUNTED_SCREEN_GROUPS.includes(g.id), `group "${g.id}" is in no count: its checked rows would read "No layers on"`); } diff --git a/scripts/qcm/layer-groups-browser.test.mjs b/scripts/qcm/layer-groups-browser.test.mjs new file mode 100644 index 0000000..00ca9df --- /dev/null +++ b/scripts/qcm/layer-groups-browser.test.mjs @@ -0,0 +1,248 @@ +// QQA-18 slice 1, Journey 11 (plan 5.3): "growing layer catalogue: search by purpose/provider/alias, +// open every group, activate across topics, find/remove an active layer after filtering, select a +// print extra, restore an old shared view", on the real built Field Maps app. +// +// Presentation only: every assertion about identity (input ids, the qcm-context radio name, the +// base radios, a layer being a checkbox or a radio) is here to prove the regrouping moved rows +// between headings and changed nothing else. +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { findChromium, launch } from './helpers/browser.mjs'; +import { trustedClick } from './helpers/trusted-click.mjs'; +import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; +import { readyDashboard, waitInPage } from './helpers/waits.mjs'; +import { TASK_GROUPS } 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; + +const IS_VISIBLE = `(el) => el.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true })`; +const HEADINGS = TASK_GROUPS.map((g) => g.label); + +let shared = null; +async function sharedBrowser() { + if (!shared) { + const site = await startSiteServer(); + const page = await launch(CHROMIUM, 'about:blank', { viewport: { width: 1280, height: 900 }, args: ['--hide-scrollbars'] }); + shared = { site, page }; + } + return shared; +} +after(async () => { + if (!shared) return; + shared.page.close(); + await shared.site.close(); +}); + +async function freshPage(hash = '') { + const { site, page } = await sharedBrowser(); + await page.setViewport({ width: 1280, height: 900 }); + await page.navigate('about:blank'); + await page.navigate(`${site.origin}/quiet-corners/${hash}`); + await readyDashboard(page, { data: false }); + const isOpen = () => page.evaluate("document.getElementById('qcm-layer-panel').open"); + for (let attempt = 0; attempt < 8 && !(await isOpen()); attempt++) { + await trustedClick(page, '#qcm-layer-panel summary'); + for (let i = 0; i < 5 && !(await isOpen()); i++) await page.evaluate('new Promise(r => setTimeout(r, 100))'); + } + assert.equal(await isOpen(), true, 'the Layers panel summary did not open the panel'); + return page; +} + +const search = (page, value) => page.evaluate(`(() => { + const box = document.getElementById('qcm-layer-search'); + box.value = ${JSON.stringify(value)}; + box.dispatchEvent(new Event('input', { bubbles: true })); +})()`); +const groupState = (page) => page.evaluate(`(() => Object.fromEntries([...document.querySelectorAll('#qcm-layers > .qcm-layer-group')] + .map((g) => [g.getAttribute('aria-label'), { open: g.open, hidden: g.hidden }])))()`); +const activeIds = (page) => page.evaluate(`[...document.querySelectorAll('#qcm-active-layers [data-active-layer]')].map((li) => li.getAttribute('data-active-layer'))`); +const groupSel = (label) => `.qcm-layer-group[aria-label="${label}"]`; +async function openGroup(page, label) { + const open = () => page.evaluate(`document.querySelector(${JSON.stringify(groupSel(label))}).open`); + if (!(await open())) await trustedClick(page, `${groupSel(label)} summary`, { until: `document.querySelector(${JSON.stringify(groupSel(label))}).open` }); +} + +test('the panel headings are the task groups, all collapsed, with an Active list that is empty and takes no room on a fresh page (road and place names open on but are lettering, not a choice)', { skip }, async () => { + const page = await freshPage(); + const groups = await groupState(page); + assert.deepEqual(Object.keys(groups), HEADINGS, 'the headings, in order, are the plan 4.7 task groups'); + for (const [label, g] of Object.entries(groups)) assert.equal(g.open, false, `"${label}" must start collapsed`); + const active = await page.evaluate(`(() => { const el = document.getElementById('qcm-active-layers'); return el ? { hidden: el.hidden, visible: el.checkVisibility(), rows: el.querySelectorAll('[data-active-layer]').length } : null; })()`); + assert.ok(active, 'no #qcm-active-layers in the Layers panel'); + assert.equal(active.rows, 0, 'a fresh page lists nothing as active: road and place names open on but are not a layer the reader chose'); + assert.equal(active.visible, false, 'an empty Active list must not spend panel height'); + // Search stays above the groups, and the Active list sits between them. + const order = await page.evaluate(`(() => { + const top = (id) => document.getElementById(id).getBoundingClientRect().top; + return { search: top('qcm-layer-search'), activeOnly: top('qcm-layers-active-only'), groups: top('qcm-layers') }; + })()`); + assert.ok(order.search < order.groups && order.activeOnly < order.groups, 'search and active-only sit above the groups'); +}); + +test('every row has its one task-group home, and no row changed id, input type or radio group', { skip }, async () => { + const page = await freshPage(); + const homes = await page.evaluate(`(() => { + const out = {}; + for (const g of document.querySelectorAll('#qcm-layers > .qcm-layer-group')) { + for (const i of g.querySelectorAll('input')) out[i.id] = { group: g.getAttribute('aria-label'), type: i.type, name: i.name }; + } + return out; + })()`); + const expectIn = (id, group, type, name) => assert.deepEqual(homes[id], { group, type, name }, id); + expectIn('qcm-base-usgsTopo', 'Map style', 'radio', 'qcm-base'); + expectIn('qcm-base-none', 'Map style', 'radio', 'qcm-base'); + expectIn('qcm-context-none', 'Map style', 'radio', 'qcm-context'); + expectIn('qcm-context-nightLights', 'Night sky', 'radio', 'qcm-context'); + expectIn('qcm-context-nwsRadar', 'Weather & conditions', 'radio', 'qcm-context'); + expectIn('qcm-context-terrainSlope', 'Terrain & landscape', 'radio', 'qcm-context'); + expectIn('qcm-context-countyScore', 'Explore counties', 'radio', 'qcm-context'); + expectIn('qcm-layer-nifcFirePerimeters', 'Weather & conditions', 'checkbox', 'qcm-layer-nifcFirePerimeters'); + expectIn('qcm-layer-swpcAurora', 'Night sky', 'checkbox', 'qcm-layer-swpcAurora'); + expectIn('qcm-layer-trails', 'Places & routes', 'checkbox', 'qcm-layer-trails'); + expectIn('qcm-layer-federalLand', 'Land & access', 'checkbox', 'qcm-layer-federalLand'); + expectIn('qcm-layer-gameLands', 'Land & access', 'checkbox', 'qcm-layer-gameLands'); + expectIn('qcm-layer-padusAccess', 'Place facts', 'checkbox', 'qcm-layer-padusAccess'); + expectIn('qcm-layer-tripPage', 'Print options', 'checkbox', 'qcm-layer-tripPage'); + for (const id of ['roadCameras', 'parkCameras', 'linkCameras', 'phenoCameras', 'airportCameras']) { + expectIn(`qcm-layer-${id}`, 'Cameras', 'checkbox', `qcm-layer-${id}`); + } + // The raster surfaces are still ONE radio choice although they now sit under different headings. + const exclusive = await page.evaluate(`(() => { + const click = (id) => document.getElementById(id).click(); + const checked = () => [...document.querySelectorAll('input[name="qcm-context"]:checked')].map((i) => i.value); + click('qcm-context-nightLights'); const a = checked(); + click('qcm-context-nwsRadar'); const b = checked(); + click('qcm-layer-nifcFirePerimeters'); + click('qcm-context-terrainSlope'); const c = checked(); + return { a, b, c, fire: document.getElementById('qcm-layer-nifcFirePerimeters').checked }; + })()`); + assert.deepEqual(exclusive.a, ['nightLights']); + assert.deepEqual(exclusive.b, ['nwsRadar'], 'choosing radar under Weather must unchoose night lights under Night sky'); + assert.deepEqual(exclusive.c, ['terrainSlope']); + assert.equal(exclusive.fire, true, 'a vector overlay stays independent of the surface choice'); +}); + +test('J11 search: by alias, purpose and provider; a match opens its group and clearing the search restores the expansion', { skip }, async () => { + const page = await freshPage(); + const before = await groupState(page); + // Alias: "webcam" is a search term of the camera rows, not part of any label. + await search(page, 'webcam'); + let g = await groupState(page); + assert.equal(g.Cameras.open && !g.Cameras.hidden, true, 'a webcam search must open Cameras'); + assert.equal(g['Land & access'].hidden, true, 'a group with no match is hidden'); + // Purpose: "weather" is a heading word, not a layer label. + await search(page, 'weather'); + g = await groupState(page); + assert.equal(g['Weather & conditions'].open && !g['Weather & conditions'].hidden, true); + // (Cameras stays: the airport cameras are weather cameras by their own note.) + assert.equal(g['Land & access'].hidden, true, 'a purpose search hides a group with nothing to do with it'); + // Provider: NOAA publishes the aurora forecast (Night sky) and smoke (Weather); neither label says so. + await search(page, 'noaa'); + g = await groupState(page); + assert.equal(!g['Night sky'].hidden && !g['Weather & conditions'].hidden, true, 'a provider search must find its rows in two topics'); + assert.equal(g['Land & access'].hidden, true); + // Clearing puts every group back exactly as it was before the first search. + await search(page, ''); + g = await groupState(page); + assert.deepEqual(g, before, 'clearing the search must restore the expansion from before it'); +}); + +test('J11 search restore keeps a group the reader opened by hand, before or during the search', { skip }, async () => { + const page = await freshPage(); + await openGroup(page, 'Places & routes'); + await search(page, 'webcam'); + await trustedClick(page, `${groupSel('Cameras')} summary`); // the reader closes then reopens it during the search + await trustedClick(page, `${groupSel('Cameras')} summary`); + await search(page, ''); + const g = await groupState(page); + assert.equal(g['Places & routes'].open, true, 'a group open before the search stays open'); + assert.equal(g.Cameras.open, true, 'a group the reader touched during the search is theirs, not restored'); + assert.equal(g['Weather & conditions'].open, false); +}); + +test('J11: open every group, switch on layers across topics, find and remove one after filtering, pick a print extra', { skip }, async () => { + const page = await freshPage(); + for (const label of HEADINGS) await openGroup(page, label); + const groups = await groupState(page); + for (const label of HEADINGS) assert.equal(groups[label].open, true, `${label} did not open`); + + // Activate across topics with real clicks. + for (const id of ['qcm-layer-trails', 'qcm-layer-nifcFirePerimeters', 'qcm-context-nightLights', 'qcm-layer-roadCameras']) { + await trustedClick(page, `#${id}`); + } + assert.deepEqual((await activeIds(page)).sort(), ['nifcFirePerimeters', 'nightLights', 'roadCameras', 'trails']); + const names = await page.evaluate(`[...document.querySelectorAll('#qcm-active-layers [data-active-layer] .qcm-active-name')].map((n) => n.textContent.trim())`); + assert.ok(names.every((n) => n.length > 0) && names.some((n) => /trails/i.test(n)), `the Active list names its rows: ${JSON.stringify(names)}`); + + // A print extra is chosen, belongs to Print options, and is not "on the map". + const countBefore = await page.evaluate("document.getElementById('qcm-layer-count').textContent"); + assert.match(countBefore, /^4 layers on/, 'the count line counts the four layers the reader switched on, not the default-on place and road names'); + await trustedClick(page, '#qcm-layer-tripPage'); + assert.equal(await page.evaluate("document.getElementById('qcm-layer-tripPage').checked"), true); + assert.ok(!(await activeIds(page)).includes('tripPage'), 'a print extra is not listed as active on the map'); + // ...and the count line agrees with the list: four layers on the map, the print extra not among them. + assert.equal(await page.evaluate("document.getElementById('qcm-layer-count').textContent"), countBefore, 'the count line must not count a print extra'); + + // Filter: only layers that are on, then a search; the Active list is still the way to the active layer. + await page.evaluate(`(() => { const cb = document.getElementById('qcm-layers-active-only'); cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); })()`); + await search(page, 'fire'); + const filtered = await page.evaluate(`(() => { + const isVisible = ${IS_VISIBLE}; + return { trails: isVisible(document.getElementById('qcm-layer-trails').closest('.qcm-layer-row')), + fire: isVisible(document.getElementById('qcm-layer-nifcFirePerimeters').closest('.qcm-layer-row')) }; + })()`); + assert.equal(filtered.trails, false, 'the search hid Trails'); + assert.equal(filtered.fire, true); + assert.deepEqual((await activeIds(page)).sort(), ['nifcFirePerimeters', 'nightLights', 'roadCameras', 'trails'], 'filtering must not empty the Active list'); + + // Remove a layer the filter has hidden, from the Active list. + await trustedClick(page, '#qcm-active-layers [data-active-layer="trails"] button.qcm-active-remove'); + assert.equal(await page.evaluate("document.getElementById('qcm-layer-trails').checked"), false, 'Remove must switch the layer off'); + assert.ok(!(await activeIds(page)).includes('trails')); + // Removing a context surface goes through the same radio as everywhere else: None. + await trustedClick(page, '#qcm-active-layers [data-active-layer="nightLights"] button.qcm-active-remove'); + assert.equal(await page.evaluate("document.querySelector('input[name=\"qcm-context\"]:checked').value"), 'none'); + assert.ok(!(await activeIds(page)).includes('nightLights')); + assert.deepEqual((await activeIds(page)).sort(), ['nifcFirePerimeters', 'roadCameras']); +}); + +test('J11: an old shared view restores by stable id and the Active list shows what it turned on', { skip }, async () => { + // A link made before the regrouping: base, a surface and two overlays by id, nothing about headings. + const page = await freshPage('#map=v1&z=9&c=44.5,-89.5&b=usgsImagery&s=nightLights&o=nifcFirePerimeters,trails'); + await waitInPage(page, 'the old link to switch its layers on', "document.getElementById('qcm-layer-nifcFirePerimeters').checked && document.getElementById('qcm-layer-trails').checked", 20000, null); + const s = await page.evaluate(`({ + base: document.querySelector('input[name="qcm-base"]:checked').value, + surface: document.querySelector('input[name="qcm-context"]:checked').value, + active: [...document.querySelectorAll('#qcm-active-layers [data-active-layer]')].map((li) => li.getAttribute('data-active-layer')).sort(), + })`); + assert.equal(s.base, 'usgsImagery'); + assert.equal(s.surface, 'nightLights'); + assert.deepEqual(s.active.slice().sort(), ['nifcFirePerimeters', 'nightLights', 'trails']); +}); + +test('J11: a re-render that changes nothing leaves the Active list alone, so a focused Remove button keeps focus', { skip }, async () => { + const page = await freshPage(); + await openGroup(page, 'Places & routes'); + await trustedClick(page, '#qcm-layer-trails'); + assert.deepEqual((await activeIds(page)).sort(), ['trails']); + // Keyboard focus on the row's Remove button, and a mark on the node that holds it. + await page.evaluate(`(() => { + const li = document.querySelector('#qcm-active-layers [data-active-layer="trails"]'); + li.dataset.mark = 'kept'; + li.querySelector('button.qcm-active-remove').focus(); + })()`); + // A print extra changes a switch and runs the same panel refresh a fetch completion does, but + // the Active list's content is the same. Dispatched in the page so focus is not moved. + await page.evaluate(`(() => { const cb = document.getElementById('qcm-layer-tripPage'); cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); })()`); + const after = await page.evaluate(`({ + kept: document.querySelector('#qcm-active-layers [data-active-layer="trails"]')?.dataset.mark, + focused: document.activeElement?.closest?.('[data-active-layer]')?.getAttribute('data-active-layer') || null, + onRemove: document.activeElement?.classList?.contains('qcm-active-remove') || false, + })`); + assert.equal(after.kept, 'kept', 'the Active list was rebuilt although nothing in it changed'); + assert.equal(after.focused, 'trails', 'focus left the Remove button on a re-render that changed nothing'); + assert.equal(after.onRemove, true); +}); diff --git a/scripts/qcm/layer-panel-compact-browser.test.mjs b/scripts/qcm/layer-panel-compact-browser.test.mjs index 36b1ec2..f0c7e22 100644 --- a/scripts/qcm/layer-panel-compact-browser.test.mjs +++ b/scripts/qcm/layer-panel-compact-browser.test.mjs @@ -112,7 +112,7 @@ test('a trusted click on a group summary opens it, revealing its rows', { skip } assert.equal(before.groupOpen, false, 'the federal group should start closed'); assert.equal(before.rowVisible, false, 'a row inside a closed group must not be visible'); - await trustedClick(page, '.qcm-layer-group[aria-label="Federal overlays"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Land & access"] summary'); const after = await page.evaluate(`(() => { const isVisible = ${IS_VISIBLE}; const row = document.getElementById('qcm-layer-federalLand').closest('.qcm-layer-row'); @@ -125,7 +125,7 @@ test('a trusted click on a group summary opens it, revealing its rows', { skip } test('an off federal-group row (with an About fallback) shows no visible note; checking it reveals the note', { skip }, async () => { const page = await freshPage(); await openLayerPanel(page); - await trustedClick(page, '.qcm-layer-group[aria-label="Federal overlays"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Land & access"] summary'); const noteEl = "document.getElementById('qcm-layer-federalLand').closest('.qcm-layer-row').querySelector('.qcm-hint')"; const hasAbout = await page.evaluate( @@ -166,7 +166,7 @@ async function rowNoteSeen(page, group, inputId) { test('an unchosen context row keeps its caveat visible — it has no About disclosure to hide into', { skip }, async () => { const page = await freshPage(); await openLayerPanel(page); - const r = await rowNoteSeen(page, 'Land, sky & light context', 'qcm-context-nwsRadar'); + const r = await rowNoteSeen(page, 'Weather & conditions', 'qcm-context-nwsRadar'); assert.ok(r, 'the radar context row lost its .qcm-hint caveat'); assert.equal(r.checked, false, 'radar should be unchosen on first load (the default is No context surface)'); assert.equal(r.hasAbout, false, 'a context row carries no About disclosure'); @@ -178,7 +178,7 @@ test('an unchosen context row keeps its caveat visible — it has no About discl test('a disabled state row keeps its reason visible while off', { skip }, async () => { const page = await freshPage(); await openLayerPanel(page); - const r = await rowNoteSeen(page, 'State land', 'qcm-layer-gameLands'); + const r = await rowNoteSeen(page, 'Land & access', 'qcm-layer-gameLands'); assert.ok(r, 'the game-lands row lost its .qcm-hint reason'); assert.equal(r.disabled, true, 'with no area drawn the game-lands row should be disabled'); assert.equal(r.hasAbout, true, 'a state row carries an About disclosure — the case where an off note is hidden'); @@ -222,14 +222,14 @@ test('searching opens a matching group so the match is actually visible, not mer test('the active-only toggle also opens a matching still-closed group', { skip }, async () => { const page = await freshPage(); await openLayerPanel(page); - await trustedClick(page, '.qcm-layer-group[aria-label="Federal overlays"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Land & access"] summary'); await page.evaluate(`(() => { const cb = document.getElementById('qcm-layer-federalLand'); cb.checked = true; cb.dispatchEvent(new Event('change', { bubbles: true })); })()`); // Close the group again by hand so the toggle below has to reopen it. - await trustedClick(page, '.qcm-layer-group[aria-label="Federal overlays"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Land & access"] summary'); const closed = await page.evaluate( '!document.getElementById(\'qcm-layer-federalLand\').closest(\'.qcm-layer-group\').open'); assert.equal(closed, true, 'the federal group should be closed again after the second click'); @@ -257,7 +257,7 @@ test('the active count line still tracks a switch inside an opened group', { ski // layer-panel-browser.test.mjs does before exercising any fetch-driven row — // otherwise this is proving the precondition message, not the count line. await chooseExampleArea(page); - await trustedClick(page, '.qcm-layer-group[aria-label="Federal overlays"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Land & access"] summary'); await page.evaluate(`(() => { const cb = document.getElementById('qcm-layer-federalLand'); cb.checked = true; @@ -284,7 +284,15 @@ test('the active count line still tracks a switch inside an opened group', { ski // its status (32px). So the cap is per part instead: the title, and EACH region row, has its own // ceiling, and the entry may be no taller than those ceilings summed for the rows it really has. // Measured at 1280 with Go to in its own column and the status inline: title 36, rows 32/32/49/49/49. -const COLLAPSED_PANEL_BUDGET = 1055 + 40; +// QQA-18 (2026-10-08): the task groups are ten headings where the technical ones were seven, and +// every heading is a line the collapsed panel has to carry. Re-measured at 1440: scrollHeight=1345 +// less the Cameras entry's 192 = 1153, so the budget moves to 1153 + 40 and is again a ceiling on +// growth from there, not a fit. +// Port onto the shipped catalogue (2026-10-24): the registry has since gained eleven layers, so the +// same panel measures scrollHeight=1401 less the Cameras entry's 192 = 1209 at 1440 (the Active list +// is empty and hidden on a fresh page: road and place names are lettering, not listed). The budget +// is 1209 + 40, again a ceiling on growth. +const COLLAPSED_PANEL_BUDGET = 1209 + 40; const CAMERA_ENTRY_TITLE_MAX = 40; const CAMERA_ROW_MAX = 52; test('the Named places fieldset is off screen until the names layer has answered for this view', { skip }, async () => { diff --git a/scripts/qcm/layer-registry.test.mjs b/scripts/qcm/layer-registry.test.mjs index 6bfdb17..af0089e 100644 --- a/scripts/qcm/layer-registry.test.mjs +++ b/scripts/qcm/layer-registry.test.mjs @@ -77,7 +77,7 @@ test('the screen base is a choice: four radios, topo checked by default, and the assert.deepEqual(enabledLayers({}).map((l) => l.id), ['usgsTopo'], 'the PDF still prints topo'); assert.match(GROUPS.find((g) => g.id === 'base').hint, /on screen/i); for (const id of ['aerial', 'terrain']) { - assert.match(layerControlSpecs({}).find((s) => s.id === id).note, /under Base map/, `${id} points at the screen choice`); + assert.match(layerControlSpecs({}).find((s) => s.id === id).note, /under Map style/, `${id} points at the screen choice`); } }); diff --git a/scripts/qcm/layer-task-groups.test.mjs b/scripts/qcm/layer-task-groups.test.mjs new file mode 100644 index 0000000..33c0405 --- /dev/null +++ b/scripts/qcm/layer-task-groups.test.mjs @@ -0,0 +1,76 @@ +// QQA-18 slice 1: the Layers panel is divided by what a reader is trying to do (plan 4.7), not by +// provider or rendering role. The grouping is presentation only: `group` on a registry row stays +// the rendering role every other module reads, and these task groups are a second, separate map +// from layer id to heading. This proves the map is total (a new provider cannot add a heading), +// that each id has one home, and the pure helpers the panel's Active list and search-restore use. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LAYERS, TASK_GROUPS, TASK_GROUP_OF, NO_CONTEXT_ROW_ID, GROUPS, CONTEXT_SURFACE_IDS } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-registry.js'; +import { BASES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-basemaps.js'; +import { activeOnMapRows, groupsToCollapse } from '../../themes/headlessmode/static/js/quiet-corners-maps/layer-panel.js'; + +const HEADINGS = ['Map style', 'Places & routes', 'Land & access', 'Terrain & landscape', 'Weather & conditions', + 'Night sky', 'Cameras', 'Place facts', 'Explore counties', 'Print options']; + +test('the panel headings are the plan 4.7 task groups, in order, and none is a provider or a rendering role', () => { + assert.deepEqual(TASK_GROUPS.map((g) => g.label), HEADINGS); + const oldLabels = GROUPS.map((g) => g.label).filter((l) => l !== 'Cameras' && l !== 'Place facts'); + for (const g of TASK_GROUPS) assert.ok(!oldLabels.includes(g.label), `"${g.label}" is an old technical heading`); + for (const g of TASK_GROUPS) assert.ok(g.hint && g.hint.length > 0, `${g.id} explains itself`); +}); + +test('every registry id, every base id and the No-context row has exactly one primary group', () => { + const wanted = [...LAYERS.map((l) => l.id), ...BASES.map((b) => b.id), NO_CONTEXT_ROW_ID]; + for (const id of new Set(wanted)) { + const homes = TASK_GROUPS.filter((g) => g.ids.includes(id)); + assert.equal(homes.length, 1, `${id} has ${homes.length} task groups; a layer has exactly one primary home`); + assert.equal(TASK_GROUP_OF[id], homes[0].id); + } + const listed = TASK_GROUPS.flatMap((g) => g.ids); + assert.equal(new Set(listed).size, listed.length, 'an id is listed twice'); + for (const id of listed) assert.ok(wanted.includes(id), `${id} is mapped but is not a registry layer, base or the No-context row`); +}); + +test('the plan 4.7 table: each id lands where it says, and the print extras are exactly the extraPage rows', () => { + const home = (g) => TASK_GROUPS.find((x) => x.label === g).ids; + assert.deepEqual(home('Print options'), LAYERS.filter((l) => l.extraPage).map((l) => l.id).sort((a, b) => ['aerial', 'terrain', 'tripPage'].indexOf(a) - ['aerial', 'terrain', 'tripPage'].indexOf(b))); + assert.deepEqual(home('Cameras'), ['roadCameras', 'parkCameras', 'linkCameras', 'phenoCameras', 'airportCameras']); + for (const id of ['mvum', 'trails', 'recSites', 'blmRecSites', 'names']) assert.ok(home('Places & routes').includes(id), id); + for (const id of ['federalLand', 'surfaceOwnership', 'countyLines', 'gameLands', 'stateParks', 'managedAreas', 'parcels']) assert.ok(home('Land & access').includes(id), id); + for (const id of ['terrainSlope', 'nlcdLandCover', 'nlcdTreeCanopy', 'wetlands', 'gbifOccurrence']) assert.ok(home('Terrain & landscape').includes(id), id); + for (const id of ['nwsRadar', 'epaAirNowAqi', 'noaaHmsSmoke', 'nifcFirePerimeters', 'femaNfhl']) assert.ok(home('Weather & conditions').includes(id), id); + assert.deepEqual(home('Night sky'), ['nightLights', 'swpcAurora']); + assert.deepEqual(home('Place facts'), ['padusAccess', 'epaEcoregions']); + assert.deepEqual(home('Explore counties'), ['countyScore']); + for (const id of ['usgsTopo', 'usgsImagery', 'usgsShadedRelief', 'none']) assert.ok(home('Map style').includes(id), id); +}); + +test('raster surfaces keep their one-choice semantics wherever they live: every one has a task group', () => { + for (const id of CONTEXT_SURFACE_IDS) assert.ok(TASK_GROUP_OF[id], `${id} (a radio of the qcm-context choice) has no home`); +}); + +test('activeOnMapRows: checked layers only; the base choice, the No-context row and print extras are not "on the map"', () => { + const rows = [ + { id: 'usgsTopo', label: 'USGS Topo', checked: true, group: 'mapStyle', base: true }, + { id: 'none', label: 'No context surface', checked: true, group: 'mapStyle', base: true }, + { id: 'trails', label: 'Trails', checked: true, group: 'places' }, + { id: 'mvum', label: 'MVUM', checked: false, group: 'places' }, + { id: 'tripPage', label: 'Route summary page', checked: true, group: 'print' }, + ]; + assert.deepEqual(activeOnMapRows(rows).map((r) => r.id), ['trails']); + assert.deepEqual(activeOnMapRows([]), []); +}); + +test('activeOnMapRows: a row that opens on (road and place names) is lettering, not a layer the reader chose, so it is never listed', () => { + const rows = [ + { id: 'names', label: 'Named places & landmarks', checked: true, group: 'places', lettering: true }, + { id: 'roads', label: 'Road names', checked: true, group: 'places', lettering: true }, + { id: 'trails', label: 'Trails', checked: true, group: 'places', lettering: false }, + ]; + assert.deepEqual(activeOnMapRows(rows).map((r) => r.id), ['trails']); +}); + +test('groupsToCollapse: a group the search opened closes on clear, unless the reader touched it', () => { + assert.deepEqual(groupsToCollapse({ openedByFilter: new Set(['cameras', 'weather']), touched: new Set(['weather']) }), ['cameras']); + assert.deepEqual(groupsToCollapse({ openedByFilter: new Set(), touched: new Set(['weather']) }), []); +}); diff --git a/scripts/qcm/layers-compact-browser.test.mjs b/scripts/qcm/layers-compact-browser.test.mjs index bb4a602..b42ec69 100644 --- a/scripts/qcm/layers-compact-browser.test.mjs +++ b/scripts/qcm/layers-compact-browser.test.mjs @@ -198,7 +198,7 @@ test('the Hillshade light fieldset is hidden until hillshadeLight is chosen, the const hint = () => page.evaluate("document.getElementById('qcm-appearance-hint')?.hidden ?? 'missing'"); assert.equal(await hint(), false, 'with the light controls hidden, Map appearance must show its hint rather than open onto nothing'); - await trustedClick(page, '.qcm-layer-group[aria-label="Land, sky & light context"] summary'); + await trustedClick(page, '.qcm-layer-group[aria-label="Terrain & landscape"] summary'); await page.evaluate(`(() => { const radio = document.getElementById('qcm-context-hillshadeLight'); radio.checked = true; diff --git a/scripts/qcm/site-coupling.json b/scripts/qcm/site-coupling.json index 8480aff..6ab5d4a 100644 --- a/scripts/qcm/site-coupling.json +++ b/scripts/qcm/site-coupling.json @@ -44,6 +44,7 @@ "scripts/qcm/label-size-browser.test.mjs", "scripts/qcm/late-restore-browser.test.mjs", "scripts/qcm/layer-count-browser.test.mjs", + "scripts/qcm/layer-groups-browser.test.mjs", "scripts/qcm/layer-panel-browser.test.mjs", "scripts/qcm/layer-panel-compact-browser.test.mjs", "scripts/qcm/layers-compact-browser.test.mjs", diff --git a/themes/headlessmode/layouts/partials/qcm-map-layers.html b/themes/headlessmode/layouts/partials/qcm-map-layers.html index eff0560..7b1d54b 100644 --- a/themes/headlessmode/layouts/partials/qcm-map-layers.html +++ b/themes/headlessmode/layouts/partials/qcm-map-layers.html @@ -43,14 +43,21 @@
+ {{/* QQA-18: "Active on map" -- filled by main.js from the switches below, hidden while + nothing is on so it costs no panel length. Above the groups, outside the search and + active-only filters, so a layer filtered out of sight is still one press from off. */}} + {{/* Filled from layer-registry.js by main.js: a new layer is a registry entry, never an edit here. */}}
{{/* QUX-04b: the manual hillshade light knobs, collapsed by default — only - meaningful once "Hillshade (adjustable light)" is chosen under Land, sky & - light context above, yet they sat open in the panel whether or not a reader + meaningful once "Hillshade (adjustable light)" is chosen under Terrain & + landscape above, yet they sat open in the panel whether or not a reader had that surface on. They sit after the list, so this is panel length and clutter, not MAX_LAYERS_GAP (nothing after #qcm-legend moves it). The brief for this unit also named the Highlight fieldset below for the same wrapper; @@ -95,7 +102,7 @@ is chosen, so this disclosure never opens onto nothing, and the light controls (hidden, never removed — their ids stay wired) while "Hillshade (adjustable light)" is. */}} -

Light controls appear here once Hillshade (adjustable light) is chosen under Land, sky & light context.

+

Light controls appear here once Hillshade (adjustable light) is chosen under Terrain & landscape.