anyplotlib 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anyplotlib/__init__.py +4 -4
- anyplotlib/_base_plot.py +69 -0
- anyplotlib/_electron.py +15 -2
- anyplotlib/_repr_utils.py +76 -4
- anyplotlib/axes/_axes.py +2 -2
- anyplotlib/axes/_inset_axes.py +136 -8
- anyplotlib/callbacks.py +4 -0
- anyplotlib/figure/_figure.py +176 -8
- anyplotlib/figure_esm.js +1251 -52
- anyplotlib/markers.py +3 -0
- anyplotlib/plot1d/_plot1d.py +170 -12
- anyplotlib/plot2d/__init__.py +3 -2
- anyplotlib/plot2d/_layer.py +121 -0
- anyplotlib/plot2d/_plot2d.py +385 -23
- anyplotlib/widgets/__init__.py +2 -2
- anyplotlib/widgets/_widgets2d.py +69 -12
- {anyplotlib-0.2.0.dist-info → anyplotlib-0.3.0.dist-info}/METADATA +1 -1
- {anyplotlib-0.2.0.dist-info → anyplotlib-0.3.0.dist-info}/RECORD +20 -19
- {anyplotlib-0.2.0.dist-info → anyplotlib-0.3.0.dist-info}/WHEEL +0 -0
- {anyplotlib-0.2.0.dist-info → anyplotlib-0.3.0.dist-info}/licenses/LICENSE +0 -0
anyplotlib/__init__.py
CHANGED
|
@@ -17,8 +17,8 @@ from anyplotlib import embed
|
|
|
17
17
|
from anyplotlib.markers import MarkerRegistry, MarkerGroup
|
|
18
18
|
from anyplotlib.widgets import (
|
|
19
19
|
Widget, RectangleWidget, CircleWidget, AnnularWidget,
|
|
20
|
-
CrosshairWidget, PolygonWidget, LabelWidget,
|
|
21
|
-
VLineWidget, HLineWidget, RangeWidget, PlaneWidget,
|
|
20
|
+
CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget,
|
|
21
|
+
VLineWidget, HLineWidget, RangeWidget, PointWidget, PlaneWidget,
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
# ── Global help flag ──────────────────────────────────────────────────────
|
|
@@ -45,8 +45,8 @@ __all__ = [
|
|
|
45
45
|
"CallbackRegistry", "Event",
|
|
46
46
|
"MarkerRegistry", "MarkerGroup",
|
|
47
47
|
"Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
|
|
48
|
-
"CrosshairWidget", "PolygonWidget", "LabelWidget",
|
|
49
|
-
"VLineWidget", "HLineWidget", "RangeWidget", "PlaneWidget",
|
|
48
|
+
"CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget",
|
|
49
|
+
"VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget",
|
|
50
50
|
"show_help", "get_color_cycle",
|
|
51
51
|
"embed",
|
|
52
52
|
"__version__",
|
anyplotlib/_base_plot.py
CHANGED
|
@@ -179,6 +179,37 @@ class _PanelMixin:
|
|
|
179
179
|
self._push()
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
class _TextHandle:
|
|
183
|
+
"""Handle for a single text annotation created by :meth:`add_text`.
|
|
184
|
+
|
|
185
|
+
Wraps the underlying single-text :class:`~anyplotlib.markers.MarkerGroup`
|
|
186
|
+
and exposes label-oriented mutators (``set_text`` / ``set_color`` /
|
|
187
|
+
``remove``) so callers do not need to know it is backed by a texts
|
|
188
|
+
marker collection.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
__slots__ = ("_group",)
|
|
192
|
+
|
|
193
|
+
def __init__(self, group):
|
|
194
|
+
self._group = group
|
|
195
|
+
|
|
196
|
+
def set_text(self, s: str) -> None:
|
|
197
|
+
"""Replace the displayed string."""
|
|
198
|
+
self._group.set(texts=[s])
|
|
199
|
+
|
|
200
|
+
def set_color(self, color: str) -> None:
|
|
201
|
+
"""Change the text colour."""
|
|
202
|
+
self._group.set(color=color)
|
|
203
|
+
|
|
204
|
+
def remove(self) -> None:
|
|
205
|
+
"""Remove the annotation from its panel."""
|
|
206
|
+
self._group.remove()
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
209
|
+
texts = self._group._data.get("texts") or [""]
|
|
210
|
+
return f"_TextHandle(text={texts[0]!r})"
|
|
211
|
+
|
|
212
|
+
|
|
182
213
|
class _MarkerMixin:
|
|
183
214
|
"""Mixin for panels that support static marker collections.
|
|
184
215
|
|
|
@@ -197,6 +228,44 @@ class _MarkerMixin:
|
|
|
197
228
|
def _add_marker(self, mtype: str, name, **kwargs):
|
|
198
229
|
return self.markers.add(mtype, name, **kwargs)
|
|
199
230
|
|
|
231
|
+
def add_text(self, x, y, s, name=None, *, color="#ff0000",
|
|
232
|
+
fontsize=12, transform="data", **kwargs) -> "_TextHandle":
|
|
233
|
+
"""Add a single text annotation at ``(x, y)``.
|
|
234
|
+
|
|
235
|
+
A convenience wrapper over :meth:`add_texts` for the common
|
|
236
|
+
single-label case (e.g. a navigation index or scale-bar label).
|
|
237
|
+
The returned handle exposes ``set_text``, ``set_color`` and
|
|
238
|
+
``remove`` so callers can mutate the label after creation.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
x, y : float
|
|
243
|
+
Anchor position. Interpreted in the coordinate system named
|
|
244
|
+
by ``transform`` (``"data"``, ``"axes"``, or ``"display"``).
|
|
245
|
+
s : str
|
|
246
|
+
The text to display.
|
|
247
|
+
name : str, optional
|
|
248
|
+
Registry key. Auto-generated if omitted.
|
|
249
|
+
color : str, optional
|
|
250
|
+
Text colour. Default ``"#ff0000"``.
|
|
251
|
+
fontsize : int, optional
|
|
252
|
+
Font size in pixels. Default ``12``.
|
|
253
|
+
transform : str, optional
|
|
254
|
+
Coordinate system for ``(x, y)``. Default ``"data"``.
|
|
255
|
+
**kwargs : dict
|
|
256
|
+
Forwarded to :meth:`add_texts` (e.g. ``clip_display``).
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
_TextHandle
|
|
261
|
+
Handle wrapping the underlying single-text marker group.
|
|
262
|
+
"""
|
|
263
|
+
group = self.add_texts(
|
|
264
|
+
offsets=[(x, y)], texts=[s], name=name,
|
|
265
|
+
color=color, fontsize=fontsize, transform=transform, **kwargs,
|
|
266
|
+
)
|
|
267
|
+
return _TextHandle(group)
|
|
268
|
+
|
|
200
269
|
def remove_marker(self, marker_type: str, name: str) -> None:
|
|
201
270
|
"""Remove a named marker collection by type and name.
|
|
202
271
|
|
anyplotlib/_electron.py
CHANGED
|
@@ -32,9 +32,20 @@ _BINARY_TRANSPORT = os.environ.get("APL_BINARY_TRANSPORT") == "1"
|
|
|
32
32
|
# transport on its _encode_pixels "\x00bin:<checksum>" token stays in the geom JSON
|
|
33
33
|
# and the real bytes are never shipped, so the renderer can't decode it and the crisp
|
|
34
34
|
# zoom tile never displays (you only ever see the downsampled overview base).
|
|
35
|
+
#
|
|
36
|
+
# Image LAYERS add DYNAMIC pixel keys ``layer_<id>_b64`` (one per layer) that also
|
|
37
|
+
# ride the geom channel and must ship as binary — they can't be enumerated in a
|
|
38
|
+
# fixed frozenset, so ``_is_binary_pixel_key`` matches them by name pattern.
|
|
35
39
|
_BINARY_KEYS = frozenset({"image_b64", "overlay_mask_b64", "detail_b64"})
|
|
36
40
|
|
|
37
41
|
|
|
42
|
+
def _is_binary_pixel_key(k: str) -> bool:
|
|
43
|
+
"""True for a geom key whose value is raw image pixels worth shipping as a
|
|
44
|
+
PLOTBIN binary frame — the fixed base-image/mask/detail keys OR a dynamic
|
|
45
|
+
per-layer key ``layer_<id>_b64``."""
|
|
46
|
+
return k in _BINARY_KEYS or (k.startswith("layer_") and k.endswith("_b64"))
|
|
47
|
+
|
|
48
|
+
|
|
38
49
|
def _route_change(fig_id: str, name: str, value) -> None:
|
|
39
50
|
"""Forward ONE trait change to the host — as a raw PLOTBIN binary frame for a
|
|
40
51
|
large image pixel trait (when binary transport is enabled), else as a
|
|
@@ -61,13 +72,15 @@ def _route_change(fig_id: str, name: str, value) -> None:
|
|
|
61
72
|
geom = json.loads(value)
|
|
62
73
|
except Exception:
|
|
63
74
|
geom = None
|
|
64
|
-
if isinstance(geom, dict) and any(
|
|
75
|
+
if isinstance(geom, dict) and any(
|
|
76
|
+
_is_binary_pixel_key(k) for k in geom):
|
|
65
77
|
panel_id = name[len("panel_"):-len("_geom")]
|
|
66
78
|
fig = _figures.get(fig_id)
|
|
67
79
|
raw_tbl = getattr(fig, "_raw_pixels", None)
|
|
68
80
|
sent_binary = False
|
|
69
81
|
for k in list(geom.keys()):
|
|
70
|
-
if
|
|
82
|
+
if (not _is_binary_pixel_key(k)
|
|
83
|
+
or not isinstance(geom[k], str) or not geom[k]):
|
|
71
84
|
continue
|
|
72
85
|
raw = None
|
|
73
86
|
if geom[k].startswith("\x00bin:") and raw_tbl is not None:
|
anyplotlib/_repr_utils.py
CHANGED
|
@@ -144,6 +144,20 @@ function makeModel(state) {{
|
|
|
144
144
|
const _data = Object.assign({{}}, state);
|
|
145
145
|
const _cbs = {{}};
|
|
146
146
|
const _anyCbs = [];
|
|
147
|
+
// Keys set() has touched while _fromParent (an inbound awi_state push) is
|
|
148
|
+
// in effect, awaiting the matching save_changes() flush. Mirrors the
|
|
149
|
+
// dirty-set pattern figure_esm.js's own createLocalModel() uses (see
|
|
150
|
+
// mount()): the _fromParent branch below defers listener firing from
|
|
151
|
+
// set() to save_changes() (see the comment there), but save_changes() was
|
|
152
|
+
// firing EVERY registered change:* callback instead of only the key(s)
|
|
153
|
+
// that changed. Concretely: a single set('event_json', ...) (the Python
|
|
154
|
+
// targeted-widget-update side channel, _push_widget) also re-fired every
|
|
155
|
+
// panel's change:panel_<id>_json listener, which re-parses that panel's
|
|
156
|
+
// (untouched, stale) trait and overwrites p.state wholesale — silently
|
|
157
|
+
// reverting the very widget edit event_json just applied, in the same
|
|
158
|
+
// synchronous cascade. Only the _fromParent path needs tracking — the
|
|
159
|
+
// local (!_fromParent) path already fires synchronously in set() itself.
|
|
160
|
+
const _parentDirty = new Set();
|
|
147
161
|
return {{
|
|
148
162
|
get(key) {{ return _data[key]; }},
|
|
149
163
|
set(key, val) {{
|
|
@@ -155,12 +169,32 @@ function makeModel(state) {{
|
|
|
155
169
|
const ev = 'change:' + key;
|
|
156
170
|
if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}}
|
|
157
171
|
for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
|
|
172
|
+
}} else {{
|
|
173
|
+
_parentDirty.add(key);
|
|
158
174
|
}}
|
|
159
175
|
}},
|
|
160
176
|
save_changes() {{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
177
|
+
if (_parentDirty.size) {{
|
|
178
|
+
// Fire only listeners for keys an inbound awi_state set() actually
|
|
179
|
+
// touched since the last flush (each at most once) — not the whole
|
|
180
|
+
// _cbs table. Snapshot + clear BEFORE invoking callbacks, since a
|
|
181
|
+
// callback may itself call set()/save_changes() (re-entrant-safe).
|
|
182
|
+
const keys = [..._parentDirty];
|
|
183
|
+
_parentDirty.clear();
|
|
184
|
+
for (const key of keys) {{
|
|
185
|
+
const ev = 'change:' + key;
|
|
186
|
+
if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: _data[key] }}); }} catch(_) {{}}
|
|
187
|
+
}}
|
|
188
|
+
for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
|
|
189
|
+
}} else {{
|
|
190
|
+
// Local (!_fromParent) path — unchanged: set() already fired
|
|
191
|
+
// synchronously per-key, so this refires the full table (matches the
|
|
192
|
+
// pre-existing local-set behaviour; harmless since _cbs entries are
|
|
193
|
+
// typically no-ops for a key that hasn't just changed).
|
|
194
|
+
for (const [ev, cbs] of Object.entries(_cbs))
|
|
195
|
+
for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
|
|
196
|
+
for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
|
|
197
|
+
}}
|
|
164
198
|
// Forward interaction events to the parent-page Pyodide instance.
|
|
165
199
|
if (!_fromParent && FIG_ID && window.parent !== window && _eventJsonDirty) {{
|
|
166
200
|
_eventJsonDirty = false;
|
|
@@ -193,10 +227,14 @@ const blobUrl = URL.createObjectURL(blob);
|
|
|
193
227
|
const el = document.getElementById("widget-root");
|
|
194
228
|
const model = makeModel(STATE);
|
|
195
229
|
|
|
230
|
+
// render() returns an internal API ({{ panels, exportPNG, ... }}) that the PNG
|
|
231
|
+
// export protocol below needs. Held here so the message listener can reach it.
|
|
232
|
+
let _aplRenderApi = null;
|
|
233
|
+
|
|
196
234
|
import(blobUrl).then(mod => {{
|
|
197
235
|
const renderFn = mod.default?.render ?? mod.render;
|
|
198
236
|
if (typeof renderFn === "function") {{
|
|
199
|
-
renderFn({{ model, el }});
|
|
237
|
+
_aplRenderApi = renderFn({{ model, el }});
|
|
200
238
|
}} else {{
|
|
201
239
|
el.textContent = "ESM has no render() export";
|
|
202
240
|
}}
|
|
@@ -251,6 +289,40 @@ window.addEventListener('message', (e) => {{
|
|
|
251
289
|
return;
|
|
252
290
|
}}
|
|
253
291
|
}});
|
|
292
|
+
|
|
293
|
+
// ── PNG export protocol ──────────────────────────────────────────────────────
|
|
294
|
+
// Rides the same postMessage channel as the state updates above. A parent page
|
|
295
|
+
// (or the SpyDE report harvester) requests a composite PNG of the whole figure:
|
|
296
|
+
// → {{ type: 'anyplotlib_export_png', requestId, opts }}
|
|
297
|
+
// and receives back, on event.source (targetOrigin '*'):
|
|
298
|
+
// ← {{ type: 'anyplotlib_export_png_result', requestId, dataUrl, width, height }}
|
|
299
|
+
// ← {{ type: 'anyplotlib_export_png_result', requestId, error }} (on failure)
|
|
300
|
+
// `opts` is forwarded verbatim to handle.exportPNG ({{ scale?, includeWidgets? }}).
|
|
301
|
+
window.addEventListener('message', (e) => {{
|
|
302
|
+
if (!e.data || e.data.type !== 'anyplotlib_export_png') return;
|
|
303
|
+
const requestId = e.data.requestId;
|
|
304
|
+
const source = e.source;
|
|
305
|
+
const reply = (msg) => {{
|
|
306
|
+
try {{
|
|
307
|
+
if (source && typeof source.postMessage === 'function') {{
|
|
308
|
+
source.postMessage(Object.assign(
|
|
309
|
+
{{ type: 'anyplotlib_export_png_result', requestId }}, msg), '*');
|
|
310
|
+
}}
|
|
311
|
+
}} catch (_) {{}}
|
|
312
|
+
}};
|
|
313
|
+
try {{
|
|
314
|
+
if (!_aplRenderApi || typeof _aplRenderApi.exportPNG !== 'function') {{
|
|
315
|
+
reply({{ error: 'figure not ready (exportPNG unavailable)' }});
|
|
316
|
+
return;
|
|
317
|
+
}}
|
|
318
|
+
Promise.resolve(_aplRenderApi.exportPNG(e.data.opts || {{}}))
|
|
319
|
+
.then((res) => reply({{
|
|
320
|
+
dataUrl: res.dataUrl, width: res.width, height: res.height }}))
|
|
321
|
+
.catch((err) => reply({{ error: String(err && err.message || err) }}));
|
|
322
|
+
}} catch (err) {{
|
|
323
|
+
reply({{ error: String(err && err.message || err) }});
|
|
324
|
+
}}
|
|
325
|
+
}});
|
|
254
326
|
</script>
|
|
255
327
|
</body>
|
|
256
328
|
</html>
|
anyplotlib/axes/_axes.py
CHANGED
|
@@ -202,7 +202,7 @@ class Axes:
|
|
|
202
202
|
selected slice pops out of the translucent volume.
|
|
203
203
|
|
|
204
204
|
**Large volumes** With WebGPU (``gpu="auto"``, the default, active
|
|
205
|
-
above ~
|
|
205
|
+
above ~1k cubes when a GPU is present) hundreds of thousands of
|
|
206
206
|
voxels render interactively via instancing. On the Canvas2D
|
|
207
207
|
fallback the budget is ~20k cubes (~3–6 µs each); a warning is
|
|
208
208
|
emitted above that *only when* ``gpu=False``. For volumes too large
|
|
@@ -230,7 +230,7 @@ class Axes:
|
|
|
230
230
|
Fix the axes bounds instead of fitting them to the data.
|
|
231
231
|
gpu : ``"auto"`` | bool, optional
|
|
232
232
|
WebGPU acceleration policy. ``"auto"`` (default) renders cubes
|
|
233
|
-
on the GPU when available and the set exceeds ~
|
|
233
|
+
on the GPU when available and the set exceeds ~1k; ``True`` always
|
|
234
234
|
attempts GPU; ``False`` forces Canvas2D. Falls back silently when
|
|
235
235
|
WebGPU is unavailable — see :attr:`Plot3D.gpu_active`.
|
|
236
236
|
|
anyplotlib/axes/_inset_axes.py
CHANGED
|
@@ -6,6 +6,7 @@ Floating overlay inset (not in the grid).
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import math
|
|
9
10
|
import uuid as _uuid
|
|
10
11
|
|
|
11
12
|
from anyplotlib.axes._axes import Axes
|
|
@@ -48,7 +49,16 @@ class InsetAxes(Axes):
|
|
|
48
49
|
Width and height as fractions of the figure dimensions (0–1).
|
|
49
50
|
corner : str, optional
|
|
50
51
|
One of ``"top-right"``, ``"top-left"``, ``"bottom-right"``,
|
|
51
|
-
``"bottom-left"``. Default ``"top-right"``.
|
|
52
|
+
``"bottom-left"``. Default ``"top-right"``. Mutually exclusive with
|
|
53
|
+
*anchor* — pass exactly one.
|
|
54
|
+
anchor : (x_frac, y_frac), optional
|
|
55
|
+
Position of the inset's TOP-LEFT corner as fractions of the figure
|
|
56
|
+
size (0–1), measured from the figure's top-left. When given, the
|
|
57
|
+
inset floats freely at that anchor instead of snapping to a corner
|
|
58
|
+
(``corner`` is then ignored / ``None``). Minimize / maximize / restore
|
|
59
|
+
all still work: a minimized anchored inset collapses to its title bar
|
|
60
|
+
in place, a maximized one floats centred, and restore returns it to the
|
|
61
|
+
anchor.
|
|
52
62
|
title : str, optional
|
|
53
63
|
Text shown in the inset title bar. Default ``""``.
|
|
54
64
|
|
|
@@ -58,23 +68,38 @@ class InsetAxes(Axes):
|
|
|
58
68
|
>>> ax.imshow(data)
|
|
59
69
|
>>> inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom")
|
|
60
70
|
>>> inset.imshow(data[64:128, 64:128])
|
|
71
|
+
>>> # arbitrary placement:
|
|
72
|
+
>>> free = fig.add_inset(0.3, 0.25, anchor=(0.55, 0.1), title="Callout")
|
|
73
|
+
>>> free.imshow(data[64:128, 64:128])
|
|
61
74
|
"""
|
|
62
75
|
|
|
63
76
|
def __init__(self, fig, w_frac: float, h_frac: float, *,
|
|
64
|
-
corner: str = "top-right", title: str = ""):
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
corner: str = "top-right", anchor=None, title: str = ""):
|
|
78
|
+
if anchor is not None:
|
|
79
|
+
ax_, ay_ = anchor
|
|
80
|
+
self.anchor = (float(ax_), float(ay_))
|
|
81
|
+
# anchor placement supersedes corner; keep corner=None so both the
|
|
82
|
+
# layout math and the callout corner-pairing know it is free-floating.
|
|
83
|
+
self.corner = None
|
|
84
|
+
else:
|
|
85
|
+
if corner not in _VALID_CORNERS:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}"
|
|
88
|
+
)
|
|
89
|
+
self.anchor = None
|
|
90
|
+
self.corner = corner
|
|
69
91
|
# Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes
|
|
70
92
|
# never occupies a grid cell, only overlays the figure.
|
|
71
93
|
from anyplotlib.figure._gridspec import SubplotSpec
|
|
72
94
|
super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1))
|
|
73
95
|
self.w_frac = w_frac
|
|
74
96
|
self.h_frac = h_frac
|
|
75
|
-
self.corner = corner
|
|
76
97
|
self.title = title
|
|
77
98
|
self._inset_state: str = "normal"
|
|
99
|
+
# Region indication (mark_inset-style callout) tied to this inset, or
|
|
100
|
+
# None. Set via :meth:`indicate_region`, cleared via
|
|
101
|
+
# :meth:`clear_indication`. Persisted in Figure.layout_json.
|
|
102
|
+
self._indication: dict | None = None
|
|
78
103
|
|
|
79
104
|
# ── state API ─────────────────────────────────────────────────────────
|
|
80
105
|
|
|
@@ -104,6 +129,107 @@ class InsetAxes(Axes):
|
|
|
104
129
|
self._inset_state = "normal"
|
|
105
130
|
self._fig._push_layout()
|
|
106
131
|
|
|
132
|
+
# ── region indication (mark_inset-style callout) ──────────────────────
|
|
133
|
+
|
|
134
|
+
def indicate_region(self, parent_plot, region, *,
|
|
135
|
+
color: str = "#ff9800",
|
|
136
|
+
linestyle: str = "dashed",
|
|
137
|
+
linewidth: float = 1.5) -> "InsetAxes":
|
|
138
|
+
"""Draw a callout tying this inset to a region of *parent_plot*.
|
|
139
|
+
|
|
140
|
+
Renders — on the parent panel's overlay — a rectangle around *region*
|
|
141
|
+
(in the parent image's DATA coordinates) plus two leader lines joining
|
|
142
|
+
the rectangle's corners that face the inset to the inset's nearest
|
|
143
|
+
corners, the classic matplotlib ``mark_inset`` look. The rectangle
|
|
144
|
+
tracks the parent's zoom / pan and the leaders follow the inset as it
|
|
145
|
+
moves or minimizes (leaders hide while the inset is minimized).
|
|
146
|
+
|
|
147
|
+
Calling ``indicate_region`` again REPLACES any previous indication for
|
|
148
|
+
this inset. Remove it with :meth:`clear_indication`.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
parent_plot : Plot2D
|
|
153
|
+
The parent image plot the region lives on. Must be a 2-D image
|
|
154
|
+
panel in the SAME figure as this inset (typically the panel the
|
|
155
|
+
inset overlays) — a plot registered on a different ``Figure``
|
|
156
|
+
raises ``ValueError``.
|
|
157
|
+
region : tuple of float
|
|
158
|
+
The source rectangle in the parent image's data coordinates, as
|
|
159
|
+
``(x, y, w, h)``: top-left ``(x, y)`` plus width and height. The
|
|
160
|
+
values follow the same convention as the parent's axes (pixel
|
|
161
|
+
indices for an uncalibrated image; physical units when the axes
|
|
162
|
+
are calibrated).
|
|
163
|
+
All four must be finite; ``w`` and ``h`` must be strictly
|
|
164
|
+
positive. The rectangle MAY extend outside the parent's data
|
|
165
|
+
bounds (e.g. a region near an edge) — that is allowed by design
|
|
166
|
+
and simply clips visually; only degenerate/non-finite values are
|
|
167
|
+
rejected.
|
|
168
|
+
color : str, optional
|
|
169
|
+
Stroke colour of both the rectangle and the leader lines.
|
|
170
|
+
Default warm orange ``"#ff9800"``.
|
|
171
|
+
linestyle : str, optional
|
|
172
|
+
``"dashed"`` (default), ``"solid"``, or ``"dotted"``.
|
|
173
|
+
linewidth : float, optional
|
|
174
|
+
Stroke width in CSS px. Default ``1.5``.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
InsetAxes
|
|
179
|
+
``self``, for chaining.
|
|
180
|
+
|
|
181
|
+
Raises
|
|
182
|
+
------
|
|
183
|
+
ValueError
|
|
184
|
+
If ``parent_plot`` has no panel id, is not registered on this
|
|
185
|
+
inset's Figure, or ``region`` is not 4 finite numbers with
|
|
186
|
+
``w > 0`` and ``h > 0``.
|
|
187
|
+
"""
|
|
188
|
+
pid = getattr(parent_plot, "_id", None)
|
|
189
|
+
if pid is None:
|
|
190
|
+
raise ValueError("indicate_region: parent_plot has no panel id "
|
|
191
|
+
"(attach it to the figure first)")
|
|
192
|
+
if self._fig._plots_map.get(pid) is not parent_plot:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"indicate_region: parent_plot is not registered on this "
|
|
195
|
+
"inset's Figure — pass a plot created on the same figure "
|
|
196
|
+
"as this inset (fig.add_inset / fig.subplots)")
|
|
197
|
+
try:
|
|
198
|
+
x, y, w, h = (float(v) for v in region)
|
|
199
|
+
except (TypeError, ValueError):
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"indicate_region: region must be 4 numbers (x, y, w, h), "
|
|
202
|
+
f"got {region!r}") from None
|
|
203
|
+
if not all(math.isfinite(v) for v in (x, y, w, h)):
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"indicate_region: region values must be finite, got "
|
|
206
|
+
f"(x={x}, y={y}, w={w}, h={h})")
|
|
207
|
+
if not (w > 0 and h > 0):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"indicate_region: region width and height must be > 0, "
|
|
210
|
+
f"got (w={w}, h={h})")
|
|
211
|
+
self._indication = {
|
|
212
|
+
"parent_id": pid,
|
|
213
|
+
"region": [x, y, w, h],
|
|
214
|
+
"color": color,
|
|
215
|
+
"linestyle": linestyle,
|
|
216
|
+
"linewidth": float(linewidth),
|
|
217
|
+
}
|
|
218
|
+
self._fig._push_layout()
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def clear_indication(self) -> None:
|
|
222
|
+
"""Remove any region indication attached to this inset (idempotent)."""
|
|
223
|
+
if self._indication is None:
|
|
224
|
+
return
|
|
225
|
+
self._indication = None
|
|
226
|
+
self._fig._push_layout()
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def indication(self) -> "dict | None":
|
|
230
|
+
"""The current region-indication spec (``dict``) or ``None``."""
|
|
231
|
+
return self._indication
|
|
232
|
+
|
|
107
233
|
# ── internal ──────────────────────────────────────────────────────────
|
|
108
234
|
|
|
109
235
|
def _attach(self, plot) -> None:
|
|
@@ -119,8 +245,10 @@ class InsetAxes(Axes):
|
|
|
119
245
|
|
|
120
246
|
def __repr__(self) -> str:
|
|
121
247
|
kind = _plot_kind(self._plot) if self._plot else "empty"
|
|
248
|
+
pos = (f"anchor={self.anchor!r}" if self.anchor is not None
|
|
249
|
+
else f"corner={self.corner!r}")
|
|
122
250
|
return (
|
|
123
|
-
f"InsetAxes(
|
|
251
|
+
f"InsetAxes({pos}, "
|
|
124
252
|
f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), "
|
|
125
253
|
f"state={self._inset_state!r}, kind={kind!r})"
|
|
126
254
|
)
|
anyplotlib/callbacks.py
CHANGED
|
@@ -90,6 +90,10 @@ class Event:
|
|
|
90
90
|
# Key
|
|
91
91
|
key: str | None = None
|
|
92
92
|
last_widget_id: str | None = None
|
|
93
|
+
# Panel drag-swap (figure-level panel_swap events): the two panel dispatch
|
|
94
|
+
# ids the user dragged between. Set only on panel_swap; None otherwise.
|
|
95
|
+
source_panel_id: str | None = None
|
|
96
|
+
target_panel_id: str | None = None
|
|
93
97
|
# Propagation (not repr'd)
|
|
94
98
|
stop_propagation: bool = field(default=False, repr=False)
|
|
95
99
|
|