Skip to content

feat(keys): floating image keys pinned over a panel (IPF, colour wheels) - #49

Merged
CSSFrancis merged 5 commits into
feat/3d-colormap-surfacefrom
feat/plot-key-overlays
Jul 31, 2026
Merged

feat(keys): floating image keys pinned over a panel (IPF, colour wheels)#49
CSSFrancis merged 5 commits into
feat/3d-colormap-surfacefrom
feat/plot-key-overlays

Conversation

@CSSFrancis

@CSSFrancis CSSFrancis commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Draft — the mechanism works end to end, but tests and the two examples are not written yet. Opening now so the design can be argued with before I build on it.

Stacked on #48: add_key needs _image_to_data_url (the array/bytes/path → data-URL encoder), which lands in that PR. Base retargets to main automatically once #48 merges.

What this adds

A key — the scale bar's sibling. A picture pinned in screen space over the plot area, which neither pans nor zooms with the data. For a colour legend a colorbar cannot express: an IPF triangle over an orientation map, a hue wheel over a polarization field, a phase key over a segmentation.

key   = plot.add_key(ipf_triangle, corner="bottom-right", size=0.28)
wheel = plot.add_key(hue_wheel, corner="top-left", bgcolor="rgba(0,0,0,0.45)",
                     border="#fff", label="polarization")
hint  = plot.add_key(phase_key, hover_only=True)   # only while pointing at it

key.set(size=0.4, bgcolor="none")
key.visible = False

It lives on _BasePlot, so every panel type has it — the same picture makes sense over a 2-D map and a 3-D scatter.

Why not just restyle the inset

Figure.add_inset is a draggable window: a div with a title bar, a theme.border frame, a full canvas stack, resize grips and a minimise state — and Python exposes no way to turn any of that off. That is the right tool when the overlay is a live plot you interact with, and much too heavy when it is a static picture that should read as part of the figure.

Bending it into a chrome-less badge would mean adding transparency, frame suppression, title suppression and hover logic to a component whose entire design is "window", putting 520 lines of existing inset tests at risk for no gain. A key has no chrome unless asked for, no event wiring, and one canvas shared by every key on the panel.

What it looks like

IPF key and polarization wheel

The two cases this was built for. Left: an IPF colour key over an orientation map, bare so the triangle sits directly on the data. Right: a hue wheel over a polarization field, on a translucent card because the data underneath is busy everywhere.

card styles

bgcolor / border / alpha. Left is the default — an RGBA image keeps its transparency, so a disc reads as a disc rather than a square tile.

hover_only

hover_only=True: absent until the pointer enters the panel. Exports include it either way.

gallery example

The gallery example. labels= draws text inside the key, positioned in fractions of the key image so it tracks the picture through a resize — an IPF triangle's corner indices belong on the corners, not in the caption underneath.

Design notes

The picture and the styling travel on separate channels. keys carries placement and style on the light view trait; key_images carries the data URLs and is declared in _GEOM_KEYS. Restyling a key — or a hover toggle firing on every pointer entry — never re-transmits the image. Plot1D has no geom trait by design and needed no special case: key_images rides its single trait like any other state field.

One canvas per panel, separate from plotCanvas. An ordinary data redraw therefore does not erase it, so keys redraw only when the key state, the panel size, or the hover flag changes. That is what makes hover_only cheap enough to hang off mouseenter/mouseleave.

Hover listeners live in _attachPanelEvents, not in each per-kind handler — it is the one panel behaviour identical across all four kinds, and it must not depend on a kind remembering to wire it.

Keys pin to the panel's image/plot box — the same four numbers _resizePanelDOM drives the scale bar off — not the letterbox fit-rect, so a key stays put when the data's aspect ratio changes underneath it. Worth a second opinion: it means a key hugs the axes box rather than the picture, which is visible when a square image sits in a wide panel.

Every key is baked into PNG export, hover_only included. The export renders the panel as though the pointer were over it, so what you save is what you see while reading the plot.

Getting that right surfaced a real bug that was not about export at all. Key images were decoded lazily from inside the draw loop, which only visits the keys shown this frame - so a hover_only key that had never been on screen had never begun decoding. The first hover would reveal nothing until some later redraw came along, and an export could not wait for onload at all. Every declared key now starts decoding, drawn or not.

A second trap sat next to it: a panel whose only keys are hover_only kept a display:none canvas while the pointer was away, and the export path measures against that canvas's bounding rect - 0x0 when hidden, so the key was dropped on the floor. The canvas is now sized and shown whenever any key is declared, merely left empty when none are currently visible.

Verified both ways round: with mixed keys and with hover_only keys alone, the cold export (pointer away) and the warm export (pointer over the panel) are byte-identical and both contain the key.

Card captions default to near-white. The usual card is a translucent dark slab and theme.tickText is near-black under a light theme, so the obvious default put dark text on a dark pill. With no card the label sits on the figure and takes the tick colour as before.

Verified so far

Python layer: creation, geom-channel routing, set(), attribute assignment, list/get/remove, validation (size, alpha, corner, duplicate names, unknown properties), and it works on Plot3D as well as Plot2D.

Renderer: a manual render confirms an RGBA disc draws with no card (alpha respected), a carded wheel gets its translucent slab, border, radius and legible caption, and a hover_only key is absent until the pointer enters.

No regressions: 648 passed across test_plot2d, test_layouts and test_embed, plus 59 geom-channel transport tests after the _GEOM_KEYS change.

Tests

33 tests, each pinned to a decision rather than to current output. The Python half covers lifecycle and validation; the render half covers what only a browser can answer:

  • a bare RGBA key draws no card — alpha 0 stays transparent, which is the whole reason a triangle can sit on an image
  • keys pin to the axes box, not the letterboxed picture. The panel is deliberately made much wider than its square image so the two differ, and the key must track imgX.
  • each corner, anchor override, and size scaling as area-goes-as-size-squared
  • hover_only stays dark until the pointer arrives; a plain key is unaffected by hovering
  • three export regressions: a hover_only key appears in an export taken with the pointer away; cold and hovered exports are byte-identical; and a lone hover_only key still exports — the display:none / 0x0-rect trap, which no other key is around to mask

Also adds a shared mount_page fixture to tests/conftest.py. interact_page calls renderFn directly and exposes no mount handle, so it can drive a pointer but cannot export or reach the panels map; these tests need both at once.

Still to do

  • Examples/Interactive/ — IPF key over an orientation map
  • Examples/ — magnetic polarization hue wheel
  • Changelog fragment and docs/api entry

A key is the scale bar's sibling: a small picture that floats in screen
space over the plot area and does not pan or zoom with the data. It is for
a colour legend a colorbar cannot express — an inverse pole figure triangle
over an orientation map, a hue wheel over a polarization field, a phase key
over a segmentation.

Deliberately NOT an inset axes. Figure.add_inset gives a draggable window
with a title bar, a theme border and a full canvas stack; Python exposes no
way to turn any of that off. That is right when the overlay is a live plot
you interact with, and much too heavy when it is a static picture that
should read as part of the figure. A key has no chrome unless asked for, no
event wiring, and (once the renderer lands) one canvas shared by every key
on the panel.

Lives on _BasePlot, so every panel type has it — the same picture makes
sense over a 2-D map and a 3-D scatter.

The picture and the styling travel on separate channels. `keys` carries
placement and style on the light view trait; `key_images` carries the data:
URLs and is declared in _GEOM_KEYS wherever the panel class has one, so
restyling a key — or a hover toggle, once that is wired — never
re-transmits the image. Plot1D has no geom trait by design, and needs no
special case: `key_images` simply rides its single trait as any other state
field does.

This is the Python layer only: state, validation and lifecycle. The
renderer does not draw keys yet, so add_key currently has no visible
effect; the JS follows in the next commit.
The renderer half of add_key. Every key on a panel shares ONE canvas
(p.keyCanvas, z 7, built generically in _buildCanvasStack for all three
panel kinds — each branch already ends with a positioned wrap).

Because that canvas is separate from plotCanvas, an ordinary data redraw
does not erase it. Keys are therefore redrawn only when the key state, the
panel size, or the hover flag changes, which is what makes `hover_only`
cheap enough to hang off mouseenter/mouseleave. Those two listeners are
attached in _attachPanelEvents rather than in each per-kind handler: it is
the one panel behaviour identical across all four, and it must not depend
on a kind remembering to wire it.

Keys pin to the panel's IMAGE / plot box — the same four numbers
_resizePanelDOM drives the scale bar off — not the letterbox fit-rect, so a
key stays put when the data's aspect ratio changes underneath it.

hover_only keys are kept OUT of PNG export, and that needed real work
rather than the comment I first wrote claiming it fell out for free. The
key canvas is persistent: if the pointer happens to be over the panel when
exportPNG runs, the key is already painted and composites straight through.
Verified by rendering, hovering, and re-exporting — max channel delta was
210 before the fix, 0 after. _drawPanelKeysNoHover re-renders onto a
scratch canvas with the flag suppressed, mirroring the existing
_drawPanelWidgetsNoHandles precedent.

A caption on a card defaults to near-white rather than theme.tickText: the
usual card is a translucent dark slab and tickText is near-black under a
light theme, so the default put dark text on a dark pill. With no card the
label sits on the figure and takes the tick colour as before.

FIGURE_ESM.md anchors re-verified against all 9,466 lines.
Reverses the export rule: a saved figure now shows every key. The export
renders the panel as though the pointer were over it, so what you save is
what you see while reading the plot.

Fixing this surfaced a real bug that was not about export at all. Images
were decoded lazily from inside the DRAW loop, which only ever visits the
keys shown this frame — so a hover_only key that had never been on screen
had never begun decoding. The first hover would reveal nothing until some
later redraw happened to come along, and an export could not wait for
onload at all, so it silently omitted the key. Every DECLARED key now
starts decoding, whether or not it is drawn.

Second trap, in the same area: a panel whose only keys are hover_only kept
a display:none canvas while the pointer was away, and the export path
measures against that canvas's bounding rect — which is 0x0 when hidden, so
_drawEl dropped the key on the floor. The canvas is now sized and shown
whenever any key is declared, and merely left empty when none are currently
visible. `declared` and `shown` are now separate filters for exactly this
reason.

Verified both ways round: with mixed keys and with hover_only keys alone,
the cold export (pointer away) and the warm export (pointer over the panel)
are byte-identical and both contain the key.
@codecov-commenter

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.77%. Comparing base (2205f0b) to head (672e125).

Files with missing lines Patch % Lines
anyplotlib/keys.py 95.72% 5 Missing ⚠️
anyplotlib/_base_plot.py 97.29% 1 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           feat/3d-colormap-surface      #49      +/-   ##
============================================================
+ Coverage                     90.58%   90.77%   +0.19%     
============================================================
  Files                            39       40       +1     
  Lines                          4345     4500     +155     
============================================================
+ Hits                           3936     4085     +149     
- Misses                          409      415       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

33 tests. The Python half covers the lifecycle and validation; the render
half covers what only the browser can answer.

Notable cases, each pinned to a decision rather than to current output:

  * a bare RGBA key draws NO card — alpha 0 stays transparent, which is the
    whole reason a triangle or a disc can sit on an image
  * keys pin to the AXES BOX, not the letterboxed picture. The panel is made
    much wider than its square image so the two differ, and the key must
    track imgX. This is the scale bar's rule and the test says so.
  * hover_only stays dark until the pointer arrives, and a plain key is
    unaffected by hovering
  * three export regressions: a hover_only key must appear in an export
    taken with the pointer away; cold and hovered exports must be
    byte-identical; and a LONE hover_only key must still export — that last
    one is the display:none / 0x0-rect trap, which no other key is around to
    mask.

Adds a shared `mount_page` fixture to tests/conftest.py. `interact_page`
calls renderFn directly and exposes no mount handle, so it can drive a
pointer but cannot export or reach the panels map; these tests need both at
once. test_export_png.py and test_texture.py predate it and keep their own
local copies.
`labels=` draws text INSIDE the key, positioned in fractions of the key
image so it tracks the picture through a resize. This is what an IPF
triangle actually needs — its corner indices belong on the corners, not in
the caption underneath — and it reuses the existing mini-TeX `_drawTex`, so
`$[1\bar{1}0]$` works as in any other label.

Entries are `(x, y, text)` or a dict adding `size` / `color` / `align`.
`align` matters more than it looks: centring text on a corner hangs half of
it off the edge, where the panel clips it, which is exactly what the first
draft of the example did.

Glyphs are drawn white over a dark halo rather than in a theme colour. A
key's own colours are arbitrary — an IPF triangle runs through the entire
hue circle — so there is no background to key the text off, and any single
flat colour is illegible against part of it.

Adds Examples/PlotTypes/plot_key_overlays.py: an IPF colour key over a
synthetic orientation map (grains coloured by reading the key image itself,
so map and legend agree by construction) and a hue wheel over a vortex
polarization field. Plus the changelog fragment and a docs/api/keys.rst
page.

Tests: 40, up from 33. The label pair is a positive/control pair, and the
control earned its place — it caught the first version of the positive
test, which counted "near-white pixels in the red mask's bounding box" and
so was really counting the page background showing through the disc's
corners. Both now crop to the disc interior and detect glyphs by "strong
green AND blue over a pure-red key", which does not depend on antialiasing.
@CSSFrancis
CSSFrancis marked this pull request as ready for review July 31, 2026 12:57
@CSSFrancis
CSSFrancis merged commit 6eeda4c into feat/3d-colormap-surface Jul 31, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants