Map Review: choose the disease week, and read any card at full size #323

Open
benjaminsperry wants to merge 12 commits from amiable-platypus into main

What this is

Four changes to the Map Review / Analysis pane, all driven by the same complaint: the rail is a third of the screen, the population reading was pinned to one week, and a card could not be looked at properly.

1. Choose the disease week and season

The population read always anchored at the season's last week that carried an analysed sample (week 41 on the live district), with no way to move it. A discreet Season / Week pair now sits above the population map:

  • Whole view. A chosen week crops everything the question draws: the H3 field, the bivariate class breaks and the header's five readings are taken at that week, and the season charts are drawn week 1 to that week. The fit keeps its trailing six weeks to the left, so a week chosen early in the season still has its evidence.
  • Default unchanged. With nothing chosen the read resolves exactly as before, so an existing page and an existing link open where they always did.
  • A filter overrides the selection. With a filter clause in force the view spans the filtered rows' own days (intersected with any date-range bound) and both selects are disabled, stating the window that was actually read. Geometry — zones, temporary zones, the drawn area — does not override: it narrows the estimand, not the time window, which is what keeps the population class click working.
  • It is addressable. The choice rides the existing two-tier state: week=2026-41 in the URL and the stored session analysis, so a shared link opens on the week it was taken at and a reload keeps it. Absent means the default; neither grammar version moved, because absence has a defined meaning rather than being a value the server might refuse.
  • The caption states the range whenever the view is not the untouched default: Filtered set · Feb 4 – Oct 19, 2026, or Week 30 · Jul 19 – Jul 25, 2026.

The window decision is extracted into populationWindowOf, a pure function of the reads' rows and the request, so every rule is tested without a database (population_window_test.go). The response gains year, seasons, seasonFirst, seasonLast, firstWeek, filtered, range.

2. The season list follows the trap stations

The first cut offered every season with an analysed sample, which listed Delta MVCD's seeded history back to 1954 and Placer's off-station imports from 2002. The picker now offers the seasons whose analysed samples came from a trap that sits at a station — the era a deployment actually ran — with a fallback: a district that has only ever counted off-station traps keeps every season it has rather than an empty control. A malformed sampling year (the live data carries a 4439) is neither offered nor used as the season a read falls back to.

3. Read any card at full size

Every card in the pane except the map — the population question's four cards and all sixty report panels — carries an expander. It moves that card's own live DOM into a page-level overlay (Teleport), so the card is the same component instance: its chart keeps the resize observer that redraws it at the bigger box, its handlers keep their closures, and a drill-down click inside the enlarged card still narrows the shared filter. Close with the X, Escape, or a backdrop click; the card returns to its place.

Details that cost something and are worth knowing when reviewing:

  • defer on the teleport, because a card mounts before the layer that owns the host and a teleport caches the target it resolved at mount — a missing host is cached as null and throws when enabled.
  • A host-generation counter keys the teleport, so a card that outlives a layer remount re-resolves the new host instead of moving into the removed one and vanishing.
  • The layer mounts at the page level, not inside the rail: the rail slides with a CSS transform, and a transformed ancestor is the containing block for position: fixed, which would pin a window-sized overlay to the rail's box for the quarter second of that animation.
  • Escape is ignored while a panel's explainer modal sits above the overlay, where Escape belongs to the modal.

4. Read it at a legible size, and name the colours

  • The overlay re-declares the card's type at roughly 1.3–1.45× the rail's (panel titles 12.8 → 16.8px, help prose 10.9 → 14.4px, stat values 21.6 → 27.2px), nothing that carries words below 13.6px, hierarchy unchanged.
  • Chart.js draws axis ticks and legends from JS options, which CSS cannot reach, so each chart now derives its text size from the box it is drawn into — clamp(11, min(width/40, height/11), 16). The rail stays exactly at 11px; the enlarged chart reads 16px.
  • The waste question's five-outcome key was a single row in the pane between panels, so an enlarged stack or Site list named none of its colours (and WasteTopSites had no key at all). Each stacked chart now carries its own WasteOutcomeKey.

Testing

Open the worktree's Map Review page and sign in: /_/review/map?state=1&panel=analysis&table=species_count.

  1. Week and season. In the population question, the page opens on the season's last week with data. Pick an earlier week: the map's classes, the five header readings and the chart axis all move to it, and a caption states Week N · <dates>. Reload — the week survives. Copy the URL — it carries week=YYYY-WW and opens on that week elsewhere.
  2. Season list. The Season select offers the seasons the district's stations ran in (Delta 2021–2026, Placer 2015–2026), not the seeded 1950s history.
  3. Filter override. Apply a filter in the Filters pane (e.g. species_count.species = 1). Both selects disable, the caption reads Filtered set · <dates>, and clearing the filter puts the chosen week back in force.
  4. Expand a card. Click the expander on a population card and on a waste panel: the card fills the middle of the screen with its supporting text, the chart is visibly larger, X / Escape / backdrop close it and the card returns to its place. Click a bar or slice in the enlarged card — the filter narrows and the enlarged chart redraws.
  5. Legibility and legends. In an enlarged waste chart, the axis numbers are larger than in the rail and the five outcome colours are named inside the card.

Checks that ran here: go build -tags tiff, go test ./platform/map_review/, golangci-lint (0 issues), pnpm run typecheck, pnpm test (615), pnpm run build-sync, plus a Chromium harness for the overlay mechanics and the measured font sizes. The population window rules are covered by a table-driven test; the URL and stored-analysis carriers by mapReviewUrl.test.ts and mapReviewPrefs.test.ts.

Not included

The "open in another window" variant was deferred by choice. Its cheap form needs the sixty report panels extracted into data-driven components so the second window can render from a mirrored payload; the expander covers the need for now.

## What this is Four changes to the Map Review / Analysis pane, all driven by the same complaint: the rail is a third of the screen, the population reading was pinned to one week, and a card could not be looked at properly. ### 1. Choose the disease week and season The population read always anchored at the season's last week that carried an analysed sample (week 41 on the live district), with no way to move it. A discreet `Season` / `Week` pair now sits above the population map: - **Whole view.** A chosen week crops everything the question draws: the H3 field, the bivariate class breaks and the header's five readings are taken at that week, and the season charts are drawn week 1 to that week. The fit keeps its trailing six weeks to the left, so a week chosen early in the season still has its evidence. - **Default unchanged.** With nothing chosen the read resolves exactly as before, so an existing page and an existing link open where they always did. - **A filter overrides the selection.** With a filter clause in force the view spans the filtered rows' own days (intersected with any date-range bound) and both selects are disabled, stating the window that was actually read. Geometry — zones, temporary zones, the drawn area — does not override: it narrows the estimand, not the time window, which is what keeps the population class click working. - **It is addressable.** The choice rides the existing two-tier state: `week=2026-41` in the URL and the stored session analysis, so a shared link opens on the week it was taken at and a reload keeps it. Absent means the default; neither grammar version moved, because absence has a defined meaning rather than being a value the server might refuse. - **The caption states the range** whenever the view is not the untouched default: `Filtered set · Feb 4 – Oct 19, 2026`, or `Week 30 · Jul 19 – Jul 25, 2026`. The window decision is extracted into `populationWindowOf`, a pure function of the reads' rows and the request, so every rule is tested without a database (`population_window_test.go`). The response gains `year`, `seasons`, `seasonFirst`, `seasonLast`, `firstWeek`, `filtered`, `range`. ### 2. The season list follows the trap stations The first cut offered every season with an analysed sample, which listed Delta MVCD's seeded history back to 1954 and Placer's off-station imports from 2002. The picker now offers the seasons whose analysed samples came from a trap that sits at a station — the era a deployment actually ran — with a fallback: a district that has only ever counted off-station traps keeps every season it has rather than an empty control. A malformed sampling year (the live data carries a `4439`) is neither offered nor used as the season a read falls back to. ### 3. Read any card at full size Every card in the pane except the map — the population question's four cards and all sixty report panels — carries an expander. It moves **that card's own live DOM** into a page-level overlay (`Teleport`), so the card is the same component instance: its chart keeps the resize observer that redraws it at the bigger box, its handlers keep their closures, and **a drill-down click inside the enlarged card still narrows the shared filter**. Close with the X, Escape, or a backdrop click; the card returns to its place. Details that cost something and are worth knowing when reviewing: - `defer` on the teleport, because a card mounts before the layer that owns the host and a teleport caches the target it resolved at mount — a missing host is cached as null and throws when enabled. - A host-generation counter keys the teleport, so a card that outlives a layer remount re-resolves the new host instead of moving into the removed one and vanishing. - The layer mounts at the page level, not inside the rail: the rail slides with a CSS `transform`, and a transformed ancestor is the containing block for `position: fixed`, which would pin a window-sized overlay to the rail's box for the quarter second of that animation. - Escape is ignored while a panel's explainer modal sits above the overlay, where Escape belongs to the modal. ### 4. Read it at a legible size, and name the colours - The overlay re-declares the card's type at roughly 1.3–1.45× the rail's (panel titles 12.8 → 16.8px, help prose 10.9 → 14.4px, stat values 21.6 → 27.2px), nothing that carries words below 13.6px, hierarchy unchanged. - Chart.js draws axis ticks and legends from JS options, which CSS cannot reach, so each chart now derives its text size from the box it is drawn into — `clamp(11, min(width/40, height/11), 16)`. The rail stays exactly at 11px; the enlarged chart reads 16px. - The waste question's five-outcome key was a single row in the pane between panels, so an enlarged stack or Site list named none of its colours (and `WasteTopSites` had no key at all). Each stacked chart now carries its own `WasteOutcomeKey`. ## Testing Open the worktree's Map Review page and sign in: `/_/review/map?state=1&panel=analysis&table=species_count`. 1. **Week and season.** In the population question, the page opens on the season's last week with data. Pick an earlier week: the map's classes, the five header readings and the chart axis all move to it, and a caption states `Week N · <dates>`. Reload — the week survives. Copy the URL — it carries `week=YYYY-WW` and opens on that week elsewhere. 2. **Season list.** The Season select offers the seasons the district's stations ran in (Delta 2021–2026, Placer 2015–2026), not the seeded 1950s history. 3. **Filter override.** Apply a filter in the Filters pane (e.g. `species_count.species = 1`). Both selects disable, the caption reads `Filtered set · <dates>`, and clearing the filter puts the chosen week back in force. 4. **Expand a card.** Click the expander on a population card and on a waste panel: the card fills the middle of the screen with its supporting text, the chart is visibly larger, X / Escape / backdrop close it and the card returns to its place. Click a bar or slice in the enlarged card — the filter narrows and the enlarged chart redraws. 5. **Legibility and legends.** In an enlarged waste chart, the axis numbers are larger than in the rail and the five outcome colours are named inside the card. Checks that ran here: `go build -tags tiff`, `go test ./platform/map_review/`, `golangci-lint` (0 issues), `pnpm run typecheck`, `pnpm test` (615), `pnpm run build-sync`, plus a Chromium harness for the overlay mechanics and the measured font sizes. The population window rules are covered by a table-driven test; the URL and stored-analysis carriers by `mapReviewUrl.test.ts` and `mapReviewPrefs.test.ts`. ## Not included The "open in another window" variant was deferred by choice. Its cheap form needs the sixty report panels extracted into data-driven components so the second window can render from a mirrored payload; the expander covers the need for now.
The population read anchored itself at the season's last week with an
analysed sample, with no way to move it: the operator's page read as pinned
to week 41. The request now names a season and a week, and the view is a crop
of the whole thing — the field, the class breaks and the header readings are
taken at that week, and the season charts are drawn from week 1 up to it,
while the fit keeps its trailing six weeks to the left.

The window decision is extracted into populationWindowOf, a pure function of
the rows and the request, so every rule is testable without a database: the
season read, the crop, the chosen week, the chart axis, and the sampling days
the pane states. With nothing chosen it resolves exactly as before, so an
existing page and an existing link open where they always did.

A filter clause replaces the selection with the filtered set's own span. A
filtered set is read over the days it reaches, so the week is not the
operator's to set while a filter is in force, and geometry (zones, temporary
zones, the drawn area) does not trigger the override: it narrows the
estimand, not the time window. An empty filtered answer still draws the
season rather than nothing.

The response gains the season actually read, the seasons with a sample, the
season's first and last analysed week, the window's first week, whether the
selection was overridden, and the sampling days the window covers. The pane
names the span from those rather than deriving a second copy of the rules.
The choice rides the two-tier state the workbench already has rather than a
third mechanism: the URL names it as week=YYYY-WW so a shared link opens on
the week it was taken at, and the session's stored analysis carries it so a
reload keeps it. Both tiers decode through one validator in mapReviewState,
so a link and a stored analysis can never disagree about what a week is, and
a pair outside the season band rejects the whole state exactly as a malformed
spec does.

Absent means the default reading, so an existing link and an existing stored
analysis still open unnarrowed — which is why neither grammar version moves:
the member is additive, and its absence has a defined meaning rather than
being a value the server might refuse.

The store holds the selection and re-reads the view when it moves. The week
is not part of the spec — it is the window the spec is read over — so nothing
else would wake the read; the body is the read's dedupe key, so re-selecting
the week already shown is a no-op rather than a second round trip.
The control sits above the map rather than inside a panel because it moves
everything beneath it: the crop is what the field, the breaks, the header
readings and the chart axis are all read at.

It names a choice, not a resolved answer. The payload already says which
season and week its readings sit at, so the picker only reports a change —
and reporting the payload's own pair emits null, so an operator who picks the
week already shown does not pin the store, the session or the URL to a choice
nobody made.

A caption states the covered range whenever the view is not the untouched
default, and the row is disabled while a filter is in force, because a
filtered set is read over its own dates and the week is not the operator's to
set then. Days parse and format in UTC so a sampling day never slides to the
neighbouring one.
A trap sample's day is whatever the import that carried it wrote, and the live
district's rows include a year 4439. The picker lists the seasons the sample
days name, so that typo would have been offered as a selectable season, and
populationLatestYear would have read it as the season to fall back to when a
requested one is empty.

A season is now bounded to 1900..next year: outside it, a week-year is a
malformed row rather than a season nobody trapped in. No season's fold changes —
a read is always for a year somebody asked for — and the band is a parameter of
the pure window helper, so the tests state which year they run in.
With a filter in force the server covers the filtered set's own span, so a
selection the operator made earlier is not what the pane is drawing. The selects
are already disabled there; they now also state the payload's season and last
week — the window that was actually read — so the row and the caption beneath it
cannot name two different spans.

Clearing the filter puts the choice back in force, which is why the selection is
kept rather than discarded: the week is still the operator's, it is simply not
the one this read covers.
The picker offered every season with an analysed sample, so it listed Delta
MVCD's seeded history back to 1954 and Placer's off-station imports from 2002.
A district's record reaches back further than its trap stations do, so that era
sat beside the seasons the workbench is actually read for.

A season is now offered when one of its analysed samples came from a trap that
sits at a station — the placement that makes a season one the map draws dots
for. The table still holds the older rows; they are simply not seasons the
picker offers.

A district that has only ever counted off-station traps keeps every season it
has, rather than an empty control: the bound trims history, it does not hide the
only data there is.
The rail is a third of the screen, and a season curve or a species band is
hard to read at that width. The expander beside a card moves that card into
an overlay over the workbench rather than opening a second window or a copy of
the report: the card is teleported into the layer's persistent host, so it
stays the same component instance. Its chart keeps the resize observer that
redraws it at the new box, its handlers keep their closures, and a drill-down
click still narrows the shared filter.

Only the backdrop takes clicks, so the enlarged card behaves exactly as it
does in the rail. Every chart here is chart.js sized by its container, so the
layer gives them taller boxes and a slightly larger caption size instead of
scaling a bitmap.

Two details of the Teleport cost a prop each. `defer` resolves the host after
the whole tree has rendered, because a card mounts before the layer that owns
the host and a Teleport caches the target it resolved at mount — a missing
host is cached as null and then throws when the teleport is enabled. The key
rebuilds the teleport when a layer is mounted again while a card survives it;
otherwise that card would move its content into the removed host and vanish.
Escape closes the overlay except while an explainer modal sits above it,
where Escape belongs to the modal.

The four population cards are wrapped here. The report panels carry their own
control in their head, added next.
Every question's panels are a ReportPanel, so the control belongs in the
panel's head beside the explainer: it gives all sixty panels the expander
without touching the pane's markup and without moving a line of the report
into a new component. `expandable` defaults on, so a caller needs no change,
and the control stays hidden unless the overlay layer is mounted — a panel
rendered where there is no host would send its content into nothing.

The explainer is teleported to the body, where Bootstrap's z-index (1055)
would put it behind the layer (2000). While its own panel is enlarged the
modal is raised above the layer, so the prose that explains an enlarged chart
is still readable over it.
The rail slides with a CSS transform, and a transformed ancestor becomes the
containing block for position: fixed descendants. The overlay was mounted inside
the pane, so opening a card while the rail was still sliding would have pinned a
window-sized layer to the rail's own box for the quarter second of the
transition. Teleporting the layer to the body puts it where a fixed overlay is
expected to live, whatever the rail is doing.
The waste question drew its five-outcome key once, as a row in the pane
between the weekly stack and the map. Enlarging a card teleports that card's
own section into the overlay host, so a key rendered beside the cards stayed
behind: the weekly stack and the Site bars could be read at full size with
five colours and no names.

The key is now a component the cards render inside themselves, so a card is
self-contained and the same markup reads in the rail and in the overlay —
nothing is copied, and there is no second legend to drift from the charts it
keys. The pane's row, its comment and its styles go with it.

The component's row keeps the pane row's treatment (10px rounded swatch,
muted caption type, wrapping flex row) and takes the `outcome-key` class the
overlay already lifts to its full-size caption size, so the key grows with
the card it is read on. It hangs the accessible name on an explicit group
role, because `aria-label` reaches a screen reader only through a role.
chart.js states a chart's tick and legend type only in its own options, so
the enlarged card could not inherit the larger type the rest of the card
gets from CSS: its text stayed at the rail's 11px on an 1130px canvas.

The size is derived from the measured box rather than taken as a prop: the
same panel is drawn about 420x160 in the rail and about 1130x380 in the
overlay, and the teleported card keeps its own props, so no prop could
reach it. clamp(11, min(width / 40, height / 11), 16) lands on 11 at the
rail's box and 16 at the overlay's, and an unmeasured box lands on 11, so a
chart is never drawn larger than the pane's own. The legend's swatch and row
gap scale with the text, leaving the 11px baseline at exactly the 10x10 box
and 8px gap every panel has always had.

The size is its own computed, so the options are rebuilt only when it
changes: a resize that leaves it alone needs no second chart.js layout pass.
Measured against the pre-change code at 420px, the rail's chart geometry —
plot rect, both axes' ranges, tick labels, canvas size — is identical.
Read the enlarged card at full size
Some checks failed
/ golint (push) Failing after 8m39s
/ pnpm-build (push) Successful in 2m42s
/ gotest (push) Failing after 1m40s
/ pnpm-test (push) Successful in 27s
11769c6176
The overlay re-declared only a handful of the card's sizes, so its prose,
keys and table rows still read as small print in a sheet nearly three times
the rail's width. Every class that carries words is now overridden inside
#analysis-card-host at about a third larger than its rail size and floored
at 0.85rem, which is what makes the overlay a full-size version of the same
card rather than a magnified screenshot.

The rail's order is kept rather than flattened: the figure stays loudest,
then the panel titles, then the prose, then the rows and keys, then their
smallest details — and no weight is introduced anywhere. Nothing that
carried no words (the expander and info icons, the swatches, the bars) is
touched, because an icon that grew with the prose would shout beside it.

The map panels' key classes are deliberately absent: neither map is wrapped
in an expandable card, so they can never appear in the host and an override
for them would be dead weight. The overlay's own close control takes the
card's scale too — at 0.85rem it was a speck in the corner of an 1130px
sheet.
Some checks failed
/ golint (push) Failing after 8m39s
/ pnpm-build (push) Successful in 2m42s
/ gotest (push) Failing after 1m40s
/ pnpm-test (push) Successful in 27s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin amiable-platypus:amiable-platypus
git switch amiable-platypus

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff amiable-platypus
git switch amiable-platypus
git rebase main
git switch main
git merge --ff-only amiable-platypus
git switch amiable-platypus
git rebase main
git switch main
git merge --no-ff amiable-platypus
git switch main
git merge --squash amiable-platypus
git switch main
git merge --ff-only amiable-platypus
git switch main
git merge amiable-platypus
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Gleipnir/nidus-sync!323
No description provided.