Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions Examples/PlotTypes/plot_key_overlays.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""
Floating keys — IPF triangles and colour wheels
===============================================

Some plots are coloured by a *direction*, not by a magnitude, and a colorbar
cannot say what the colours mean. An orientation map is coloured by which
crystal axis points at you; a polarization map by which way the moment lies in
the plane. Both need a small picture as their legend: an inverse pole figure
triangle, or a hue wheel.

:meth:`~anyplotlib.Plot2D.add_key` pins that picture over the panel in *screen*
space — it does not pan or zoom with the data, exactly like the scale bar it is
modelled on.

This is deliberately not :meth:`~anyplotlib.Figure.add_inset`, which is a
draggable window with a title bar and its own canvas stack. That is the right
tool when the overlay is a live plot; a key is a static picture that should
read as part of the figure.
"""
import numpy as np

import anyplotlib as apl

# %%
# The IPF colour key
# ------------------
# The standard cubic stereographic triangle. Each pixel's colour is its
# barycentric distance to the three corners, which is the classic IPF key: a
# grain pointing [1 0 0] at the detector reads red, [1 1 0] green, [1 1 1] blue.
#
# The triangle is built as an ``(H, W, 4)`` RGBA array, and **alpha 0 outside
# the triangle** is what lets it sit on the map without a rectangular card
# around it.

KEY_N = 220


def ipf_triangle(n=KEY_N):
"""RGBA image of the 001–011–111 stereographic triangle."""
yy, xx = np.mgrid[0:n, 0:n]
u = xx / (n - 1)
v = 1.0 - yy / (n - 1)
inside = v <= u + 1e-9 # lower-right half

# Barycentric-ish weights: distance to each corner, normalised.
d100 = np.hypot(u, v) # corner (0, 0)
d110 = np.hypot(u - 1.0, v) # corner (1, 0)
d111 = np.hypot(u - 1.0, v - 1.0) # corner (1, 1)
far = np.maximum.reduce([d100, d110, d111])
rgb = np.stack([1 - d100 / far, 1 - d110 / far, 1 - d111 / far], -1)
rgb /= rgb.max(-1, keepdims=True) + 1e-9 # full saturation at the corners

img = np.zeros((n, n, 4), np.uint8)
img[..., :3] = np.clip(rgb, 0, 1) * 255
img[..., 3] = np.where(inside, 255, 0)
return img


# %%
# A synthetic orientation map
# ---------------------------
# Voronoi grains, each with a random orientation, coloured through the same key
# so the map and its legend agree by construction.

rng = np.random.default_rng(11)
H, W, NGRAIN = 210, 280, 40

cy, cx = rng.uniform(0, H, NGRAIN), rng.uniform(0, W, NGRAIN)
yy, xx = np.mgrid[0:H, 0:W]
grain = np.hypot(yy[..., None] - cy, xx[..., None] - cx).argmin(-1)

# Each grain gets a point in the triangle, then reads its colour off the key.
gu = rng.uniform(0, 1, NGRAIN)
gv = rng.uniform(0, 1, NGRAIN) * gu # keep it inside v <= u
key_img = ipf_triangle()
kx = np.clip((gu * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
ky = np.clip(((1 - gv) * (KEY_N - 1)).astype(int), 0, KEY_N - 1)
grain_rgb = key_img[ky, kx, :3]
ipf_map = grain_rgb[grain] # (H, W, 3) true colour

# %%
# Pinning the key
# ---------------
# ``labels`` draws text *inside* the picture, positioned as fractions of the
# key image, so the corner indices stay on the corners at any ``size``.

fig, ax = apl.subplots(1, 1, figsize=(520, 420))
vmap = ax.imshow(ipf_map)
vmap.set_title("orientation map")

vmap.add_key(
key_img,
corner="bottom-right",
size=0.34,
# `align` keeps a label inside the key: centring text on a corner would
# hang half of it off the edge, where the panel clips it.
labels=[
{"x": 0.02, "y": 0.93, "text": "[1 0 0]", "align": "left"},
{"x": 0.98, "y": 0.93, "text": "[1 1 0]", "align": "right"},
{"x": 0.98, "y": 0.08, "text": "[1 1 1]", "align": "right"},
],
name="ipf",
)

fig

# %%
# A colour wheel over a polarization map
# --------------------------------------
# Same mechanism, different legend. Here the key gets a translucent card
# (``bgcolor``) because the field underneath is saturated everywhere and a bare
# wheel would fight with it.

def hue_wheel(n=KEY_N):
"""RGBA colour wheel: hue = in-plane angle, value = magnitude."""
yy, xx = np.mgrid[0:n, 0:n]
ang = (np.arctan2(-(yy - n / 2), xx - n / 2) + np.pi) / (2 * np.pi)
rad = np.hypot(yy - n / 2, xx - n / 2) / (n / 2)
h6 = ang * 6.0
chan = np.clip(
np.abs(((h6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
img = np.zeros((n, n, 4), np.uint8)
img[..., :3] = chan.transpose(1, 2, 0) * 255 * np.clip(rad, 0, 1)[..., None]
img[..., 3] = np.where(rad <= 1.0, 255, 0)
return img


# A vortex: the moment angle winds once around the centre.
ang = np.arctan2(yy - H / 2, xx - W / 2)
mag = np.clip(np.hypot(yy - H / 2, xx - W / 2) / (0.5 * min(H, W)), 0, 1)
a6 = ((ang + np.pi) / (2 * np.pi)) * 6.0
chan = np.clip(np.abs(((a6 + np.array([0, 4, 2])[:, None, None]) % 6) - 3) - 1, 0, 1)
polar_map = (chan.transpose(1, 2, 0) * 255 * (0.3 + 0.7 * mag)[..., None]).astype(np.uint8)

fig2, ax2 = apl.subplots(1, 1, figsize=(520, 420))
vpol = ax2.imshow(polar_map)
vpol.set_title("in-plane magnetic polarization")

vpol.add_key(
hue_wheel(),
corner="top-right",
size=0.26,
bgcolor="rgba(0,0,0,0.45)", # legible over a busy field
border="#ffffff",
label="moment direction",
labels=[
(0.5, 0.06, "N"), (0.94, 0.5, "E"),
(0.5, 0.94, "S"), (0.06, 0.5, "W"),
],
name="wheel",
)

fig2

# %%
# Keeping it out of the way
# -------------------------
# ``hover_only=True`` shows the key only while the pointer is over the panel —
# a reading aid that does not sit on the data while you study it. PNG export
# renders the panel as though the pointer were there, so an exported figure
# still carries the key.
#
# Everything is live: :meth:`~anyplotlib.KeyOverlay.set` restyles a key without
# re-sending the picture, and :meth:`~anyplotlib.KeyOverlay.set_image` swaps
# the picture without disturbing the placement::
#
# key = vmap.get_key("ipf")
# key.set(size=0.4, corner="top-left")
# key.visible = False
55 changes: 28 additions & 27 deletions anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FIGURE_ESM.md — Navigator for `figure_esm.js`

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

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

> **`brush` widget (2-D)** — the one widget whose drag is *modal*, and the one
> that must NOT write the model per tick. `_ovHitTest2d` takes an extra `mods`
Expand Down
3 changes: 2 additions & 1 deletion anyplotlib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from anyplotlib.callbacks import CallbackRegistry, Event
from anyplotlib import embed
from anyplotlib.markers import MarkerRegistry, MarkerGroup
from anyplotlib.keys import KeyOverlay
from anyplotlib.widgets import (
Widget, RectangleWidget, CircleWidget, AnnularWidget,
CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, BrushWidget,
Expand Down Expand Up @@ -43,7 +44,7 @@ def get_color_cycle() -> list[str]:
"Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar",
"Line1D",
"CallbackRegistry", "Event",
"MarkerRegistry", "MarkerGroup",
"MarkerRegistry", "MarkerGroup", "KeyOverlay",
"Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
"CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget",
"BrushWidget",
Expand Down
Loading
Loading