Skip to content

feat: show several legend boxes at once - #1816

Merged
Azgaar merged 6 commits into
Azgaar:masterfrom
esullivan9:feat/multiple-legend-boxes
Sep 9, 2026
Merged

feat: show several legend boxes at once#1816
Azgaar merged 6 commits into
Azgaar:masterfrom
esullivan9:feat/multiple-legend-boxes

Conversation

@esullivan9

@esullivan9 esullivan9 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #1815

Each of the States, Cultures, Religions, Biomes and Zones editors has a "Toggle Legend box" button, but there is only one legend box, and every button checks whether any legend is on the map before deciding to hide it. So with a legend already up, every button reads as "turn it off" — whichever editor it belongs to.

Turning on the Zones legend while the States one is showing therefore clears States and never draws Zones, leaving the map with no legend at all.

The bug

Every editor's toggle asked the same question:

if (select("#legend").selectAll("*").size()) {
  clearLegend();
  return;
} // hide legend

#legend was the box itself, so "is anything in this group?" was the only test available. One click destroys a legend you had and puts nothing in its place.

The change

#legend becomes a container rather than the box. Each legend lives in its own group inside it, keyed by its title:

<g id="legend">                       <!-- layer, holds the shared styling -->
  <g data-legend="States" data=""></g>
  <g data-legend="Zones"  data=""></g>
</g>

The layer keeps the font, stroke and colour attributes and the boxes inherit them, so all boxes still share the single style from Style → Legend and that editor needed almost no changes.

Each editor now toggles only its own box:

if (hasLegend(LEGEND_NAME)) {
  clearLegend(LEGEND_NAME); // hide this box alone, keeping the other legends
  return;
}

Dragging and click-to-hide resolve the box under the cursor, so they act on one box too.

A new box is placed against the boxes already shown, taking the first side with room — above, below, left, right — aligned to the edge of the box it is placed against, and clamped so it never lands partly off the canvas. Per-box positions live in styles.legend.options.positions, keyed by the box title; the existing options.x/y stays as the anchor a box gets when it has no stored position, so style presets still control where legends land.

Compatibility

  • Maps saved before this keep their single legend flat inside #legend. redrawLegend detects that shape and rehouses it into a named group on load, at the position the map already had. Detection is exact rather than version-gated, so it needs no auto-update.ts entry.
  • positions is a new key on legend.options with a schema default, so old .map files, the shipped style presets and user-saved custom presets all parse unchanged and without warnings.
  • The legend rect and title moved from #legendBox/#legendLabel ids to classes, since ids can no longer be unique. styles-legacy.ts still maps #legendBox for harvesting pre-1.150 maps, which have exactly one legend by definition.

Screenshots

All five legends shown at once, auto-placed without overlapping:
legend-all-five

States and Zones together — the pair from #1815:
legend-states-and-zones

Testing

Manual, in the browser:

  • all five legends shown at once; each editor button toggles only its own box
  • clicking a box on the map hides that box alone; dragging moves only the box under the cursor
  • Style → Legend: background, opacity, column count and font size apply to every box, and a box dragged to a custom spot stays there through a restyle
  • PNG and SVG export with several boxes; SVG keeps the legend font
  • save → .map → reload with three boxes, one dragged to a custom spot: all three return in place
  • a map saved before this change loads with its legend intact, and a second legend can then be added
  • window resize keeps every box on the canvas

Manual testing on a saved map with the legend dragged to the top-left also caught a placement bug the automated tests missed: auto-placement originally searched only up and left, so with no room in either direction a second box landed exactly on top of the first. It now searches all four sides and clamps to the canvas, with two regression tests covering it.

Automated: 12 unit tests in draw-legend.test.ts and 2 e2e tests in legend-boxes.spec.ts, plus the existing suites.

Notes for review

Two decisions I'd happily change if you'd shape them differently:

  • Per-box positions in the styles store, keyed by box title. I kept them out of the DOM because 1.150 retired the data-x/data-y attrs, and there's a test asserting the legend positions from the store rather than from those attrs.
  • One shared style for all boxes. Style → Legend still styles them as a set rather than individually.

Every legend was drawn straight into the #legend layer group, so there was
only ever one box, and each editor's toggle asked "is anything in #legend?"
before deciding to hide it. Turning on the Zones legend while the States one
was up therefore cleared States and never drew Zones - one click, no boxes.

Each legend now lives in its own <g data-legend="..."> inside the layer,
keyed by its title, so the five sources (States, Cultures, Religions, Biomes,
Zones) can be shown together - useful when exporting the map as an image.
A new box is placed against the shown ones on whichever side has room, and
is clamped to stay on the canvas. Dragging, clicking to hide and the editor
buttons all act on a single box.

Per-box positions live in styles.legend.options.positions, keyed by the box
title; options.x/y stays the anchor a box gets when it has no stored spot.
The legend rect and title moved from #legendBox/#legendLabel ids to classes,
since ids can no longer be unique, and redrawLegend adopts the flat single
box of maps saved before this into a named group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMcp34mUdSim2ALrFohRMr
@netlify

netlify Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deploy Preview for afmg ready!

Name Link
🔨 Latest commit e50aa44
🔍 Latest deploy log https://app.netlify.com/projects/afmg/deploys/6aa1d48b2ff19d00097df079
😎 Deploy Preview https://deploy-preview-1816--afmg.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@Azgaar
Azgaar self-requested a review September 8, 2026 16:59
@barrulus barrulus self-assigned this Sep 8, 2026
@barrulus barrulus moved this from Backlog to In review in FMG dev board Sep 8, 2026
@barrulus barrulus added the theme: ui-editors UI / UX & Editors label Sep 8, 2026
@barrulus

barrulus commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Nice work, this is a clean take on #1815 and the container-plus-named-groups shape is the right call. I pulled the branch (4a79a95 on c222645) and went through it properly. Most of what I went looking for turned out to be already handled, so first the good news:

  • Layers.drawAll() does run on load (load.ts:723) after Styles.set (:433), so adoptLegacyLegend() really does rehouse an old single legend, and it anchors on the loaded map's options.x/y so the position survives.
  • The pre-1.150 harvest (migrateStyles -> harvestStylesFromSvg, auto-update.ts:1856) reads #legendBox before adoptLegacyLegend() wipes the layer, so legacy box fill and opacity are not lost. That ordering is easy to get wrong and you got it right.
  • The d3 split is consistent: public/main.js is on vendored 5.8.0 so d3.event is correct there, while load.ts and viewbox-events.ts are on npm v7 where the event is the first argument. Replacing the v5 listener on #legend after a load works out, no double-fire.
  • positions .default({}) is reached by every store entry point, minmax/parseTransform argument order is right, and the clamp cannot reintroduce an overlap.

One thing I would want fixed before merge, then a few small ones.

1. Switching style presets walks the stack off the anchor. applyStylePreset calls Styles.set(parsed), and no shipped preset carries legend.options.positions, so the schema default resets it to {}. applyStyleWithUiRefresh then calls Layers.drawAll() -> redrawLegend(), and each box hits if (!positions[name]) positions[name] = placeNewBox(node) while the other boxes are still sitting on their stale transforms. With States and Zones up, Zones stacked above States: States gets placed above the old Zones transform, then Zones gets placed above the new States transform, so both end up a slot higher and neither returns to the preset's options.x/y. Switch presets a few times and the stack marches up the canvas until it hits the top and flips to the below/left/right branches. Single-box behaviour is unchanged, so this is purely the new path. Clearing every box's transform before the placement pass, or placing against the newly computed positions rather than the live DOM, should sort it.

2. clearLegend(name) leaves a hole in the stack (draw-legend.ts:154). It removes the group but keeps positions[name], and drawLegend only auto-places when there is no stored entry. So: show States (lands on the anchor), show Cultures (stacks above), then click the States box to hide it. Cultures stays in the upper slot with a box-sized gap below it and the anchor empty, and the next box you turn on goes above Cultures. Dropping the entry on an explicit hide, while keeping it for a box the user has dragged, would keep the stack tidy.

3. Docs do not quite match the code (docs/wiki/Knowledge Base.md:781). The copy says a new box goes "above the ones already shown, or in a new column when the stack no longer fits", but placeNewBox tries above, then below, then left, then right. Anchor the legend near the top of the canvas and the next box lands below, not in a new column. Your PR description has the four-sided search described correctly, so it is just the wiki line.

4. Latent flake in the e2e (tests/e2e/legend-boxes.spec.ts:57). #legend > g[data-legend="Cultures"] .legendBox clicks the centre of the background rect, but the item and title text nodes are siblings of that rect rather than descendants, and SVG text hit-tests on painted glyphs. When the box centre happens to land on a glyph, Playwright's hit-target check sees a non-descendant and retries to timeout, so whether it passes depends on how many cultures the seed produces. Clicking the box group, or using force or a corner position, makes it deterministic.

On the two decisions you flagged, both seem reasonable to me, with one consequence worth naming out loud: because positions lives in the styles store, addStylePreset's JSON.stringify(styles) bakes one map's box coordinates into a saved custom preset and replays them on every other map. Not fatal, and arguably fine, but worth a deliberate yes rather than finding out later. One shared style for all boxes feels like the right starting point to me, per-box styling can come later if anyone actually asks for it.

Also spotted while I was in there, and definitely not yours: a legend drawn from empty data (Zones editor with everything filtered out) still round-trips a bogus undefined row through redrawLegend. That is already on master, just flagging it so nobody pins it on this PR.

Push fixes as new commits on the branch and I will take another pass.

@Azgaar

Azgaar commented Sep 9, 2026

Copy link
Copy Markdown
Owner

legend.options.positions should not be in style, it's more a browser dialog state, so we can preserve it as we preserve dialog positions in v1.152.0.

esullivan9 and others added 5 commits September 9, 2026 21:27
Where a legend box sits is closer to a dialog's position than to the map's
style, so it moves out of styles.legend.options into localStorage, keyed by
the box title. styles-schema.ts and default-styles.json go back to master.

Two things fall out of the move. Applying a style preset no longer resets the
positions, so the stack cannot march up the canvas as boxes are placed against
each other's stale transforms. And a saved custom preset no longer bakes one
map's box coordinates in to replay them on every other map.

Placement now derives its rectangles from the remembered positions instead of
reading transforms back off the dom, which still holds the previous pass's
layout during a redraw, and each candidate spot is checked for overlap rather
than assumed clear.

options.x/y stays in the style as the anchor a box falls back to, so a single
legend behaves exactly as before. The trade-off is that box positions no longer
travel with the .map file: a shared map auto-places its boxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMcp34mUdSim2ALrFohRMr
The wiki said a new box goes above the shown ones or into a new column when
the stack no longer fits, but placement tries all four sides, so a legend
anchored near the top of the canvas gets the next box below it. Also note
that where a box is dragged is remembered by the browser, not by the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMcp34mUdSim2ALrFohRMr
The item and title texts are siblings of the background rect and paint over
it, and svg text hit-tests on the glyphs, so clicking the rect's centre could
land on a glyph that is not its descendant and fail the actionability check.
Whether it did depended on how many entries the seed produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EMcp34mUdSim2ALrFohRMr
v1.152.0 removed public/main.js (the legend click handler now lives with the
renderer and load.ts), sized the legend from the viewport module, dropped the
window.* bridges and replaced the e2e map-ready flag with tests/e2e/wait-for-map.

Resolution: draw-legend.ts keeps the multi-box body on top of master's viewport
and tip imports, wires the layer click to onLegendClick inside drawLegend, and
uses viewport.width/height throughout; the unit test sets the viewport with
setViewportSize; the e2e spec waits with waitForMap; assets restamped.
Box positions are browser state, the same as where a dialog was dragged, so
they go where that lives: a dedicated "legend" key on dialogState holding the
name -> {x, y, dragged} record. The legendPositions API is unchanged, the
private fmg-legend-positions key and its load/save code are gone, and Clear
cache wipes the positions with the rest of the dialog state.
@barrulus

barrulus commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Master moved under this with v1.152.0: public/main.js is gone, the legend is sized from viewport.width/height, the window.* bridges were removed, and the e2e helper for "map is ready" is now tests/e2e/wait-for-map.ts. I pushed two commits to your branch:

  • b964810 merges master and resolves the seven conflicts — all mechanical, no design change: draw-legend.ts keeps your multi-box body on master's viewport/tip imports and wires the layer click to onLegendClick inside drawLegend; the unit test uses setViewportSize; the e2e spec waits with waitForMap; assets restamped.
  • e50aa44 follows Azgaar's note above and moves the box positions onto the 1.152 dialog store: a dedicated legend key on dialogState, same legendPositions API, so draw-legend.ts and the tests are untouched, and "Clear cache" wipes the positions with the rest. The private fmg-legend-positions key and its load/save code are gone.

On the result: tsc clean, 951 unit tests, Biome pinned and latest clean, both legend e2e tests green, plus a headless pass (two boxes stacked, preset switch keeps the spots, a drag flags the box and survives a page reload, save → reload restores the boxes, click hides one box, a 1.89 map's single legend is adopted and a second box stacks above it). Your three follow-ups cover everything from my first pass.

One optional note: auto-placed spots are remembered across maps until the box is hidden, so on the next map a box goes where it was rather than against what is shown, and a taller States box can end up under a remembered Zones slot. Releasing the non-dragged entries when a map is generated or loaded would keep placement per map and still honour drags. Happy to leave that to you or to a follow-up.

@barrulus
barrulus self-requested a review September 9, 2026 21:53

@barrulus barrulus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with this code. I made some small changes at the end because of the changes that happened in 1.152.0

@Azgaar
Azgaar merged commit e16db49 into Azgaar:master Sep 9, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in FMG dev board Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

theme: ui-editors UI / UX & Editors

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Toggling a second Legend box hides the first one and draws nothing

3 participants