diff --git a/content/quiet-corners-maps.md b/content/quiet-corners-maps.md index ff19216..7a07423 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 b9005c6..e83221e 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -411,11 +411,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 @@ -433,6 +434,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 f722ac3..a4f828f 100644 --- a/scripts/qcm/site-coupling.json +++ b/scripts/qcm/site-coupling.json @@ -45,6 +45,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.