Skip to content

Commit 78b6819

Browse files
authored
Merge pull request #51 from CSSFrancis/release/v0.7.0
chore(release): v0.7.0 — land the plot-key overlays that missed main
2 parents 322029e + 37439e4 commit 78b6819

18 files changed

Lines changed: 1412 additions & 37 deletions

File tree

CHANGELOG.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ Fragment files in ``upcoming_changes/`` are assembled into this file by
1010

1111
.. towncrier release notes start
1212
13+
0.7.0 (2026-07-31)
14+
==================
15+
16+
New Features
17+
------------
18+
19+
- Added :meth:`~anyplotlib.Plot2D.add_key` for pinning a floating image *key* over
20+
a panel — an inverse pole figure triangle over an orientation map, a hue wheel
21+
over a polarization field, a phase key over a segmentation. A key is the scale
22+
bar's sibling: it floats in screen space and neither pans nor zooms with the
23+
data, it takes an RGBA image so a triangle or a disc needs no rectangular card
24+
around it, and ``labels=`` annotates the picture itself (an IPF triangle's
25+
corner indices) in fractions of the key image. Optional ``bgcolor`` /
26+
``border`` / ``alpha`` give it a card when the data underneath is busy, and
27+
``hover_only=True`` reveals it only while the pointer is over the panel.
28+
Available on every panel type, and included in PNG export.
29+
30+
1331
0.6.0 (2026-07-31)
1432
==================
1533

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""
2+
Floating keys — IPF triangles and colour wheels
3+
===============================================
4+
5+
Some plots are coloured by a *direction*, not by a magnitude, and a colorbar
6+
cannot say what the colours mean. An orientation map is coloured by which
7+
crystal axis points at you; a polarization map by which way the moment lies in
8+
the plane. Both need a small picture as their legend: an inverse pole figure
9+
triangle, or a hue wheel.
10+
11+
:meth:`~anyplotlib.Plot2D.add_key` pins that picture over the panel in *screen*
12+
space — it does not pan or zoom with the data, exactly like the scale bar it is
13+
modelled on.
14+
15+
This is deliberately not :meth:`~anyplotlib.Figure.add_inset`, which is a
16+
draggable window with a title bar and its own canvas stack. That is the right
17+
tool when the overlay is a live plot; a key is a static picture that should
18+
read as part of the figure.
19+
"""
20+
import numpy as np
21+
22+
import anyplotlib as apl
23+
24+
# %%
25+
# The IPF colour key
26+
# ------------------
27+
# The standard cubic stereographic triangle. Each pixel's colour is its
28+
# barycentric distance to the three corners, which is the classic IPF key: a
29+
# grain pointing [1 0 0] at the detector reads red, [1 1 0] green, [1 1 1] blue.
30+
#
31+
# The triangle is built as an ``(H, W, 4)`` RGBA array, and **alpha 0 outside
32+
# the triangle** is what lets it sit on the map without a rectangular card
33+
# around it.
34+
35+
KEY_N = 220
36+
37+
38+
def ipf_triangle(n=KEY_N):
39+
"""RGBA image of the 001–011–111 stereographic triangle."""
40+
yy, xx = np.mgrid[0:n, 0:n]
41+
u = xx / (n - 1)
42+
v = 1.0 - yy / (n - 1)
43+
inside = v <= u + 1e-9 # lower-right half
44+
45+
# Barycentric-ish weights: distance to each corner, normalised.
46+
d100 = np.hypot(u, v) # corner (0, 0)
47+
d110 = np.hypot(u - 1.0, v) # corner (1, 0)
48+
d111 = np.hypot(u - 1.0, v - 1.0) # corner (1, 1)
49+
far = np.maximum.reduce([d100, d110, d111])
50+
rgb = np.stack([1 - d100 / far, 1 - d110 / far, 1 - d111 / far], -1)
51+
rgb /= rgb.max(-1, keepdims=True) + 1e-9 # full saturation at the corners
52+
53+
img = np.zeros((n, n, 4), np.uint8)
54+
img[..., :3] = np.clip(rgb, 0, 1) * 255
55+
img[..., 3] = np.where(inside, 255, 0)
56+
return img
57+
58+
59+
# %%
60+
# A synthetic orientation map
61+
# ---------------------------
62+
# Voronoi grains, each with a random orientation, coloured through the same key
63+
# so the map and its legend agree by construction.
64+
65+
rng = np.random.default_rng(11)
66+
H, W, NGRAIN = 210, 280, 40
67+
68+
cy, cx = rng.uniform(0, H, NGRAIN), rng.uniform(0, W, NGRAIN)
69+
yy, xx = np.mgrid[0:H, 0:W]
70+
grain = np.hypot(yy[..., None] - cy, xx[..., None] - cx).argmin(-1)
71+
72+
# Each grain gets a point in the triangle, then reads its colour off the key.
73+
gu = rng.uniform(0, 1, NGRAIN)
74+
gv = rng.uniform(0, 1, NGRAIN) * gu # keep it inside v <= u
75+
key_img = ipf_triangle()
76+
kx = np.clip((gu * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
77+
ky = np.clip(((1 - gv) * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
78+
grain_rgb = key_img[ky, kx, :3]
79+
ipf_map = grain_rgb[grain] # (H, W, 3) true colour
80+
81+
# %%
82+
# Pinning the key
83+
# ---------------
84+
# ``labels`` draws text *inside* the picture, positioned as fractions of the
85+
# key image, so the corner indices stay on the corners at any ``size``.
86+
87+
fig, ax = apl.subplots(1, 1, figsize=(520, 420))
88+
vmap = ax.imshow(ipf_map)
89+
vmap.set_title("orientation map")
90+
91+
vmap.add_key(
92+
key_img,
93+
corner="bottom-right",
94+
size=0.34,
95+
# `align` keeps a label inside the key: centring text on a corner would
96+
# hang half of it off the edge, where the panel clips it.
97+
labels=[
98+
{"x": 0.02, "y": 0.93, "text": "[1 0 0]", "align": "left"},
99+
{"x": 0.98, "y": 0.93, "text": "[1 1 0]", "align": "right"},
100+
{"x": 0.98, "y": 0.08, "text": "[1 1 1]", "align": "right"},
101+
],
102+
name="ipf",
103+
)
104+
105+
fig
106+
107+
# %%
108+
# A colour wheel over a polarization map
109+
# --------------------------------------
110+
# Same mechanism, different legend. Here the key gets a translucent card
111+
# (``bgcolor``) because the field underneath is saturated everywhere and a bare
112+
# wheel would fight with it.
113+
114+
def hue_wheel(n=KEY_N):
115+
"""RGBA colour wheel: hue = in-plane angle, value = magnitude."""
116+
yy, xx = np.mgrid[0:n, 0:n]
117+
ang = (np.arctan2(-(yy - n / 2), xx - n / 2) + np.pi) / (2 * np.pi)
118+
rad = np.hypot(yy - n / 2, xx - n / 2) / (n / 2)
119+
h6 = ang * 6.0
120+
chan = np.clip(
121+
np.abs(((h6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
122+
img = np.zeros((n, n, 4), np.uint8)
123+
img[..., :3] = chan.transpose(1, 2, 0) * 255 * np.clip(rad, 0, 1)[..., None]
124+
img[..., 3] = np.where(rad <= 1.0, 255, 0)
125+
return img
126+
127+
128+
# A vortex: the moment angle winds once around the centre.
129+
ang = np.arctan2(yy - H / 2, xx - W / 2)
130+
mag = np.clip(np.hypot(yy - H / 2, xx - W / 2) / (0.5 * min(H, W)), 0, 1)
131+
a6 = ((ang + np.pi) / (2 * np.pi)) * 6.0
132+
chan = np.clip(np.abs(((a6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
133+
polar_map = (chan.transpose(1, 2, 0) * 255 * (0.3 + 0.7 * mag)[..., None]).astype(np.uint8)
134+
135+
fig2, ax2 = apl.subplots(1, 1, figsize=(520, 420))
136+
vpol = ax2.imshow(polar_map)
137+
vpol.set_title("in-plane magnetic polarization")
138+
139+
vpol.add_key(
140+
hue_wheel(),
141+
corner="top-right",
142+
size=0.26,
143+
bgcolor="rgba(0,0,0,0.45)", # legible over a busy field
144+
border="#ffffff",
145+
label="moment direction",
146+
labels=[
147+
(0.5, 0.06, "N"), (0.94, 0.5, "E"),
148+
(0.5, 0.94, "S"), (0.06, 0.5, "W"),
149+
],
150+
name="wheel",
151+
)
152+
153+
fig2
154+
155+
# %%
156+
# Keeping it out of the way
157+
# -------------------------
158+
# ``hover_only=True`` shows the key only while the pointer is over the panel —
159+
# a reading aid that does not sit on the data while you study it. PNG export
160+
# renders the panel as though the pointer were there, so an exported figure
161+
# still carries the key.
162+
#
163+
# Everything is live: :meth:`~anyplotlib.KeyOverlay.set` restyles a key without
164+
# re-sending the picture, and :meth:`~anyplotlib.KeyOverlay.set_image` swaps
165+
# the picture without disturbing the placement::
166+
#
167+
# key = vmap.get_key("ipf")
168+
# key.set(size=0.4, corner="top-left")
169+
# key.visible = False

anyplotlib/FIGURE_ESM.md

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# FIGURE_ESM.md — Navigator for `figure_esm.js`
22

3-
`figure_esm.js` is **~9,300 lines** and one big closure. Everything lives inside
3+
`figure_esm.js` is **~9,470 lines** and one big closure. Everything lives inside
44
`function render({ model, el })` so that all helpers share the same scope
55
(`theme`, `PAD_*`, `panels` Map, etc.). This document is a section map so you
66
can jump straight to the relevant code without reading the whole file.
@@ -55,23 +55,24 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
5555
| **2D gutter geometry**: `_cbWidth` / `_cbGap` / `_padT` / `_titlePx` | 301 / 313 / 323 / 333 |
5656
| **Layout engine** `applyLayout` | 774 |
5757
| `_buildCanvasStack` | 857 |
58-
| `_createPanelDOM` | 989 |
59-
| `_createInsetDOM` / `_applyAllInsetStates` | 1118 / 1500 |
60-
| `_resizePanelDOM` | 2213 |
61-
| **2D drawing**: `_imgFitRect` | 2372 |
62-
| `draw2d` | 2680 |
63-
| `drawScaleBar2d` / `drawColorbar2d` | 2875 / 2961 |
64-
| `_drawAxes2d` (ticks, labels, title) | 3016 |
65-
| `drawOverlay2d` / `drawMarkers2d` | 3169 / 3333 |
66-
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2500 / 2524 / 2585 |
58+
| `_createPanelDOM` | 999 |
59+
| `_createInsetDOM` / `_applyAllInsetStates` | 1129 / 1512 |
60+
| `_resizePanelDOM` | 2225 |
61+
| **2D drawing**: `_imgFitRect` | 2384 |
62+
| `draw2d` | 2692 |
63+
| `drawScaleBar2d` / `drawColorbar2d` | 2887 / 3125 |
64+
| **Floating keys**: `_keyEnsure` / `_keyRect` / `drawKeys` | 2986 / 3009 / 3022 |
65+
| `_drawAxes2d` (ticks, labels, title) | 3180 |
66+
| `drawOverlay2d` / `drawMarkers2d` | 3333 / 3497 |
67+
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2512 / 2536 / 2597 |
6768
| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 |
68-
| **3D drawing**: `draw3d` | 5072 |
69-
| Event emission `_emitEvent` | 5909 |
70-
| 3D event handlers `_attachEvents3d` | 5961 |
71-
| **1D drawing**: `draw1d` | 6182 |
72-
| `_drawLine` (1D series + markers) | 6335 |
73-
| `drawOverlay1d` / `drawMarkers1d` | 6628 / 6712 |
74-
| Marker hit-test `_markerHitTest2d` | 6980 |
69+
| **3D drawing**: `draw3d` | 5236 |
70+
| Event emission `_emitEvent` | 6073 |
71+
| 3D event handlers `_attachEvents3d` | 6125 |
72+
| **1D drawing**: `draw1d` | 6346 |
73+
| `_drawLine` (1D series + markers) | 6499 |
74+
| `drawOverlay1d` / `drawMarkers1d` | 6792 / 6876 |
75+
| Marker hit-test `_markerHitTest2d` | 7144 |
7576

7677
> **`raster` marker (1D/PlotXY)**`drawMarkers1d` has a `type==='raster'`
7778
> branch that blits a single RGBA image across data-coord `extent` (the fast
@@ -80,16 +81,16 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
8081
> redraws never re-transmit them; the decoded `OffscreenCanvas` is cached on
8182
> the marker set (`ms._rasterBmp`/`_rasterKey`). The shared `clip_path` block
8283
> clips it to a curved sector.
83-
| Panel event dispatch `_attachPanelEvents` | 7237 |
84-
| 2D events `_attachEvents2d` | 7261 |
85-
| 1D events `_attachEvents1d` | 7645 |
86-
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 7917 / 8190 |
87-
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8103 / 8117 / 8146 / 8181 |
88-
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8313 / 8386 |
89-
| Shared-axis propagation `_getShareGroups` | 8457 |
90-
| Figure resize `_applyFigResizeDOM` | 8521 |
91-
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8712 / 8775 / 9151 |
92-
| Generic redraw `_redrawPanel` | 9341 |
84+
| Panel event dispatch `_attachPanelEvents` | 7401 |
85+
| 2D events `_attachEvents2d` | 7443 |
86+
| 1D events `_attachEvents1d` | 7827 |
87+
| 2D widget drag `_ovHitTest2d` / `_doDrag2d` | 8099 / 8372 |
88+
| **Brush strokes**: `_brushLiveBegin` / `_brushCommit` / `_brushErase` / `_brushPaintAt` | 8285 / 8299 / 8328 / 8363 |
89+
| 1D widget drag `_canvasXToFrac1d` … / snapping `_snapVal` | 8495 / 8568 |
90+
| Shared-axis propagation `_getShareGroups` | 8639 |
91+
| Figure resize `_applyFigResizeDOM` | 8703 |
92+
| **Bar chart**: `_barGeom` / `drawBar` / `_attachEventsBar` | 8894 / 8957 / 9333 |
93+
| Generic redraw `_redrawPanel` | 9523 |
9394

9495
> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one
9596
> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods`

anyplotlib/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from anyplotlib.callbacks import CallbackRegistry, Event
1616
from anyplotlib import embed
1717
from anyplotlib.markers import MarkerRegistry, MarkerGroup
18+
from anyplotlib.keys import KeyOverlay
1819
from anyplotlib.widgets import (
1920
Widget, RectangleWidget, CircleWidget, AnnularWidget,
2021
CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, BrushWidget,
@@ -43,7 +44,7 @@ def get_color_cycle() -> list[str]:
4344
"Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar",
4445
"Line1D",
4546
"CallbackRegistry", "Event",
46-
"MarkerRegistry", "MarkerGroup",
47+
"MarkerRegistry", "MarkerGroup", "KeyOverlay",
4748
"Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
4849
"CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget",
4950
"BrushWidget",

0 commit comments

Comments
 (0)