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 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(k in geom for k in _BINARY_KEYS):
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 k not in _BINARY_KEYS or not isinstance(geom[k], str) or not geom[k]:
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
- for (const [ev, cbs] of Object.entries(_cbs))
162
- for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
163
- for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
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 ~8k cubes when a GPU is present) hundreds of thousands of
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 ~8k; ``True`` always
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
 
@@ -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 corner not in _VALID_CORNERS:
66
- raise ValueError(
67
- f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}"
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(corner={self.corner!r}, "
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