anyplotlib 0.1.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.
Files changed (42) hide show
  1. anyplotlib/__init__.py +53 -0
  2. anyplotlib/_base_plot.py +229 -0
  3. anyplotlib/_electron.py +74 -0
  4. anyplotlib/_repr_utils.py +345 -0
  5. anyplotlib/_utils.py +194 -0
  6. anyplotlib/axes/__init__.py +6 -0
  7. anyplotlib/axes/_axes.py +510 -0
  8. anyplotlib/axes/_inset_axes.py +126 -0
  9. anyplotlib/callbacks.py +328 -0
  10. anyplotlib/embed.py +194 -0
  11. anyplotlib/figure/__init__.py +7 -0
  12. anyplotlib/figure/_figure.py +633 -0
  13. anyplotlib/figure/_gridspec.py +99 -0
  14. anyplotlib/figure/_subplots.py +100 -0
  15. anyplotlib/figure_esm.js +6011 -0
  16. anyplotlib/markers.py +704 -0
  17. anyplotlib/plot1d/__init__.py +6 -0
  18. anyplotlib/plot1d/_plot1d.py +1376 -0
  19. anyplotlib/plot1d/_plotbar.py +436 -0
  20. anyplotlib/plot2d/__init__.py +5 -0
  21. anyplotlib/plot2d/_plot2d.py +726 -0
  22. anyplotlib/plot2d/_plotmesh.py +116 -0
  23. anyplotlib/plot3d/__init__.py +4 -0
  24. anyplotlib/plot3d/_plot3d.py +524 -0
  25. anyplotlib/plotxy/__init__.py +4 -0
  26. anyplotlib/plotxy/_plotxy.py +273 -0
  27. anyplotlib/sphinx_anywidget/__init__.py +177 -0
  28. anyplotlib/sphinx_anywidget/_directive.py +245 -0
  29. anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
  30. anyplotlib/sphinx_anywidget/_scraper.py +390 -0
  31. anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
  32. anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
  33. anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
  34. anyplotlib/widgets/__init__.py +18 -0
  35. anyplotlib/widgets/_base.py +218 -0
  36. anyplotlib/widgets/_widgets1d.py +109 -0
  37. anyplotlib/widgets/_widgets2d.py +141 -0
  38. anyplotlib/widgets/_widgets3d.py +50 -0
  39. anyplotlib-0.1.0.dist-info/METADATA +160 -0
  40. anyplotlib-0.1.0.dist-info/RECORD +42 -0
  41. anyplotlib-0.1.0.dist-info/WHEEL +4 -0
  42. anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,726 @@
1
+ """
2
+ plot2d/_plot2d.py
3
+ =================
4
+ 2-D image panel (imshow).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from typing import Callable
11
+
12
+ from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin
13
+ from anyplotlib.markers import MarkerRegistry
14
+ from anyplotlib.callbacks import CallbackRegistry
15
+ from anyplotlib.widgets import (
16
+ Widget,
17
+ RectangleWidget, CircleWidget, AnnularWidget,
18
+ CrosshairWidget, PolygonWidget, LabelWidget,
19
+ )
20
+ from anyplotlib._utils import _normalize_image, _build_colormap_lut, _to_rgba_u8
21
+
22
+
23
+ class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin):
24
+ """2-D image plot panel.
25
+
26
+ Not an anywidget. Holds state in ``_state`` dict; every mutation calls
27
+ ``_push()`` which writes to the parent Figure's panel trait.
28
+
29
+ The marker API follows matplotlib conventions:
30
+ plot.add_circles(offsets, name="g1", facecolors="#f00", radius=5)
31
+ plot.markers["circles"]["g1"].set(radius=8)
32
+ """
33
+
34
+ #: Heavy state keys routed to the geometry channel (see Figure._push).
35
+ #: ``colormap_data`` is large and only changes on set_colormap.
36
+ _GEOM_KEYS = frozenset({"image_b64", "colormap_data", "overlay_mask_b64"})
37
+
38
+ def __init__(self, data: np.ndarray,
39
+ x_axis=None, y_axis=None, units: str = "px",
40
+ cmap: str | None = None,
41
+ vmin: float | None = None,
42
+ vmax: float | None = None,
43
+ origin: str = "upper"):
44
+ self._id: str = "" # assigned by Axes._attach
45
+ self._fig: object = None # assigned by Axes._attach
46
+
47
+ _valid_origins = ("upper", "lower")
48
+ if origin not in _valid_origins:
49
+ raise ValueError(
50
+ f"origin must be one of {_valid_origins!r}, got {origin!r}"
51
+ )
52
+ self._origin: str = origin
53
+
54
+ data = np.asarray(data)
55
+ # (H, W, 3|4) arrays render as true-colour RGB(A); anything else 2-D.
56
+ self._is_rgb: bool = data.ndim == 3 and data.shape[2] in (3, 4)
57
+ if data.ndim == 3 and not self._is_rgb:
58
+ raise ValueError(
59
+ f"3-D image data must have 3 (RGB) or 4 (RGBA) channels, "
60
+ f"got shape {data.shape}")
61
+ if data.ndim not in (2, 3):
62
+ raise ValueError(f"data must be 2-D (H x W) or (H x W x 3|4), "
63
+ f"got {data.shape}")
64
+
65
+ h, w = data.shape[:2]
66
+
67
+ # origin='lower' — row 0 at the bottom, matching matplotlib's matrix
68
+ # convention. Flip the data so our renderer (which always draws row 0
69
+ # at the top) shows the correct orientation, and reverse the y-axis so
70
+ # tick values increase upward.
71
+ if origin == "lower":
72
+ data = np.flipud(data)
73
+
74
+ self._data: np.ndarray = data.astype(float)
75
+
76
+ x_axis_given = x_axis is not None
77
+ y_axis_given = y_axis is not None
78
+ if x_axis is None:
79
+ x_axis = np.arange(w, dtype=float)
80
+ if y_axis is None:
81
+ y_axis = np.arange(h, dtype=float)
82
+ x_axis = np.asarray(x_axis, dtype=float)
83
+ y_axis = np.asarray(y_axis, dtype=float)
84
+
85
+ if origin == "lower":
86
+ y_axis = y_axis[::-1]
87
+
88
+ if self._is_rgb:
89
+ # True-colour path: bytes go to JS as RGBA; no LUT applies.
90
+ img_u8 = _to_rgba_u8(data)
91
+ raw_vmin, raw_vmax = 0.0, 255.0
92
+ else:
93
+ img_u8, raw_vmin, raw_vmax = _normalize_image(data)
94
+ self._raw_u8 = img_u8
95
+ self._raw_vmin = raw_vmin
96
+ self._raw_vmax = raw_vmax
97
+
98
+ cmap_name = cmap if cmap is not None else "gray"
99
+ cmap_lut = _build_colormap_lut(cmap_name)
100
+
101
+ # vmin/vmax clip the colormap in data units; default to the full range.
102
+ disp_min = float(vmin) if vmin is not None else raw_vmin
103
+ disp_max = float(vmax) if vmax is not None else raw_vmax
104
+
105
+ # Compute physical pixel scale (data-units per pixel) from axis arrays
106
+ scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0
107
+ scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0
108
+
109
+ self._state: dict = {
110
+ "kind": "2d",
111
+ "is_mesh": False,
112
+ "is_rgb": self._is_rgb,
113
+ "has_axes": x_axis_given or y_axis_given,
114
+ "image_b64": self._encode_bytes(img_u8),
115
+ "image_width": w,
116
+ "image_height": h,
117
+ "x_axis": x_axis.tolist(),
118
+ "y_axis": y_axis.tolist(),
119
+ "units": units,
120
+ "scale_x": scale_x,
121
+ "scale_y": scale_y,
122
+ "display_min": disp_min,
123
+ "display_max": disp_max,
124
+ "raw_min": raw_vmin,
125
+ "raw_max": raw_vmax,
126
+ "show_colorbar": False,
127
+ "scale_mode": "linear",
128
+ "colormap_name": cmap_name,
129
+ "colormap_data": cmap_lut,
130
+ "zoom": 1.0,
131
+ "center_x": 0.5,
132
+ "center_y": 0.5,
133
+ "overlay_widgets": [],
134
+ "markers": [],
135
+ "pointer_settled_ms": 0,
136
+ "pointer_settled_delta": 4,
137
+ # Transparent mask overlay (set via set_overlay_mask)
138
+ "overlay_mask_b64": "",
139
+ "overlay_mask_color": "#ff4444",
140
+ "overlay_mask_alpha": 0.4,
141
+ # Set True when Python explicitly changes view; JS uses it to
142
+ # decide whether to preserve the current frontend zoom/pan state.
143
+ "_view_from_python": False,
144
+ # Axis / annotation labels (rendered by JS in Phase 4)
145
+ "x_label": "",
146
+ "y_label": "",
147
+ "title": "",
148
+ "colorbar_label": "",
149
+ # Aspect ratio: None means free, float means width/height ratio
150
+ "aspect": None,
151
+ # Visibility toggles
152
+ "axis_visible": True,
153
+ "x_ticks_visible": True,
154
+ "y_ticks_visible": True,
155
+ }
156
+
157
+ self.markers = MarkerRegistry(self._push_markers,
158
+ allowed=MarkerRegistry._KNOWN_2D)
159
+ self.callbacks = CallbackRegistry()
160
+ self._widgets: dict[str, Widget] = {}
161
+
162
+ @staticmethod
163
+ def _encode_bytes(arr: np.ndarray) -> str:
164
+ import base64
165
+ return base64.b64encode(arr.tobytes()).decode("ascii")
166
+
167
+ def to_state_dict(self) -> dict:
168
+ """Return a JSON-serialisable copy of the current state."""
169
+ d = dict(self._state)
170
+ d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
171
+ d["markers"] = self.markers.to_wire_list()
172
+ return d
173
+
174
+ # ------------------------------------------------------------------
175
+ # Data
176
+ # ------------------------------------------------------------------
177
+ @property
178
+ def data(self) -> np.ndarray:
179
+ """The image data in the original user coordinate system (read-only).
180
+
181
+ Returns a float64 copy with ``writeable=False``. To replace the
182
+ data call :meth:`set_data`.
183
+ """
184
+ arr = np.flipud(self._data).copy() if self._origin == "lower" else self._data.copy()
185
+ arr.flags.writeable = False
186
+ return arr
187
+
188
+ def set_data(self, data: np.ndarray,
189
+ x_axis=None, y_axis=None, units: str | None = None,
190
+ clim: tuple | None = None) -> None:
191
+ """Replace the image data.
192
+
193
+ The ``origin`` supplied at construction is automatically re-applied
194
+ so the new data is displayed with the same orientation.
195
+
196
+ ``clim`` — optional ``(vmin, vmax)`` display range applied in the SAME
197
+ push as the new data. Without it the caller must follow with a separate
198
+ ``set_clim``, which pushes a SECOND time: the first push shows the image
199
+ stretched over its full data range (wrong contrast) and the second
200
+ corrects it, producing a one-frame flash on every update. Passing
201
+ ``clim`` here makes data + contrast a single atomic frame (no flash).
202
+ """
203
+ data = np.asarray(data)
204
+ is_rgb = data.ndim == 3 and data.shape[2] in (3, 4)
205
+ if data.ndim == 3 and not is_rgb:
206
+ raise ValueError(
207
+ f"3-D image data must have 3 (RGB) or 4 (RGBA) channels, "
208
+ f"got shape {data.shape}")
209
+ if data.ndim not in (2, 3):
210
+ raise ValueError(f"data must be 2-D or (H x W x 3|4), got {data.shape}")
211
+ h, w = data.shape[:2]
212
+
213
+ if self._origin == "lower":
214
+ data = np.flipud(data)
215
+
216
+ self._data = data.astype(float)
217
+ self._is_rgb = is_rgb
218
+ self._state["is_rgb"] = is_rgb
219
+ if is_rgb:
220
+ img_u8, vmin, vmax = _to_rgba_u8(data), 0.0, 255.0
221
+ else:
222
+ img_u8, vmin, vmax = _normalize_image(data)
223
+ self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax
224
+
225
+ if x_axis is not None:
226
+ self._state["x_axis"] = np.asarray(x_axis, float).tolist()
227
+ self._state["image_width"] = w
228
+ self._state["has_axes"] = True
229
+ if y_axis is not None:
230
+ ya = np.asarray(y_axis, float)
231
+ if self._origin == "lower":
232
+ ya = ya[::-1]
233
+ self._state["y_axis"] = ya.tolist()
234
+ self._state["image_height"] = h
235
+ self._state["has_axes"] = True
236
+ if units is not None:
237
+ self._state["units"] = units
238
+
239
+ # Apply a caller-supplied display range in the SAME push (no flash).
240
+ disp_min, disp_max = vmin, vmax
241
+ if clim is not None and not is_rgb:
242
+ try:
243
+ disp_min, disp_max = float(clim[0]), float(clim[1])
244
+ except (TypeError, ValueError, IndexError):
245
+ disp_min, disp_max = vmin, vmax
246
+ fields = {
247
+ "image_b64": self._encode_bytes(img_u8),
248
+ "image_width": w,
249
+ "image_height": h,
250
+ "display_min": disp_min,
251
+ "display_max": disp_max,
252
+ "raw_min": vmin,
253
+ "raw_max": vmax,
254
+ }
255
+ # RGB images never use the colormap LUT — skip the (costly) rebuild and
256
+ # leave the existing entry untouched. Only recompute for scalar data.
257
+ if not is_rgb:
258
+ fields["colormap_data"] = _build_colormap_lut(self._state["colormap_name"])
259
+ self._state.update(fields)
260
+ self._push()
261
+
262
+ def set_overlay_mask(self, mask: "np.ndarray | None",
263
+ color: str = "#ff4444",
264
+ alpha: float = 0.4) -> None:
265
+ """Set (or clear) a transparent boolean mask drawn over the image.
266
+
267
+ The mask is composited client-side in the browser at *alpha* opacity
268
+ using *color* for all ``True`` pixels. Call with ``mask=None`` to
269
+ remove any existing overlay.
270
+
271
+ Parameters
272
+ ----------
273
+ mask : ndarray of shape (H, W), bool or uint8, or None
274
+ Boolean array aligned to the image data. ``True`` / non-zero
275
+ pixels are filled with *color* at transparency *alpha*.
276
+ Pass ``None`` to clear the overlay.
277
+ color : str, optional
278
+ CSS hex colour for the overlay, e.g. ``"#ff4444"``. Default red.
279
+ Must be in ``#RRGGBB`` format.
280
+ alpha : float, optional
281
+ Opacity in [0, 1]. Default 0.4 (40 % opaque).
282
+ """
283
+ import base64, re
284
+ # Validate color format
285
+ if not re.fullmatch(r'#[0-9a-fA-F]{6}', color):
286
+ raise ValueError(
287
+ f"color must be a CSS hex colour in '#RRGGBB' format, got {color!r}"
288
+ )
289
+ # Clamp alpha to [0, 1]
290
+ alpha = float(alpha)
291
+ if not (0.0 <= alpha <= 1.0):
292
+ raise ValueError(f"alpha must be in [0, 1], got {alpha!r}")
293
+ if mask is None:
294
+ self._state["overlay_mask_b64"] = ""
295
+ self._state["overlay_mask_color"] = color
296
+ self._state["overlay_mask_alpha"] = alpha
297
+ else:
298
+ arr = np.asarray(mask)
299
+ if arr.shape != (self._state["image_height"], self._state["image_width"]):
300
+ raise ValueError(
301
+ f"mask shape {arr.shape} does not match image "
302
+ f"({self._state['image_height']} x {self._state['image_width']})"
303
+ )
304
+ # For origin='lower' the image data was flipped; flip mask to match.
305
+ if self._origin == "lower":
306
+ arr = np.flipud(arr)
307
+ # Convert to uint8: True/non-zero → 255, False/zero → 0
308
+ u8 = (np.asarray(arr, dtype=bool).view(np.uint8) * 255).astype(np.uint8)
309
+ self._state["overlay_mask_b64"] = base64.b64encode(u8.tobytes()).decode("ascii")
310
+ self._state["overlay_mask_color"] = color
311
+ self._state["overlay_mask_alpha"] = alpha
312
+ self._push()
313
+
314
+ # ------------------------------------------------------------------
315
+ # Display settings
316
+ # ------------------------------------------------------------------
317
+ def set_colormap(self, name: str) -> None:
318
+ self._state["colormap_name"] = name
319
+ self._state["colormap_data"] = _build_colormap_lut(name)
320
+ self._push()
321
+
322
+ def set_clim(self, vmin=None, vmax=None) -> None:
323
+ if vmin is not None:
324
+ self._state["display_min"] = float(vmin)
325
+ if vmax is not None:
326
+ self._state["display_max"] = float(vmax)
327
+ self._push()
328
+
329
+ def set_scale_mode(self, mode: str) -> None:
330
+ valid = ("linear", "log", "symlog")
331
+ if mode not in valid:
332
+ raise ValueError(f"mode must be one of {valid}")
333
+ self._state["scale_mode"] = mode
334
+ self._push()
335
+
336
+ @property
337
+ def colormap_name(self) -> str:
338
+ return self._state["colormap_name"]
339
+
340
+ @colormap_name.setter
341
+ def colormap_name(self, name: str) -> None:
342
+ self.set_colormap(name)
343
+
344
+ def set_xlabel(self, label: str, fontsize: float | None = None) -> None:
345
+ """Set the x-axis label.
346
+
347
+ Parameters
348
+ ----------
349
+ label : str
350
+ Label text. Supports the mini-TeX subset for scientific
351
+ notation, e.g. ``r"$q$ ($\\AA^{-1}$)"`` or ``r"$10^{-3}$ m"``
352
+ — see :class:`~anyplotlib._base_plot._BasePlot` notes.
353
+ fontsize : float, optional
354
+ Font size in CSS pixels. Default 11. ``None`` keeps the
355
+ current size.
356
+ """
357
+ self._set_label("x_label", label, "x_label_size", fontsize)
358
+
359
+ def set_ylabel(self, label: str, fontsize: float | None = None) -> None:
360
+ """Set the y-axis label. Same semantics as :meth:`set_xlabel`."""
361
+ self._set_label("y_label", label, "y_label_size", fontsize)
362
+
363
+ def set_xlim(self, xmin: float, xmax: float) -> None:
364
+ self.set_view(x0=xmin, x1=xmax)
365
+
366
+ def set_ylim(self, ymin: float, ymax: float) -> None:
367
+ self.set_view(y0=ymin, y1=ymax)
368
+
369
+ def get_xlim(self) -> tuple:
370
+ xarr = np.asarray(self._state["x_axis"])
371
+ return (float(xarr.min()), float(xarr.max()))
372
+
373
+ def get_ylim(self) -> tuple:
374
+ yarr = np.asarray(self._state["y_axis"])
375
+ return (float(yarr.min()), float(yarr.max()))
376
+
377
+ def get_xbound(self) -> tuple:
378
+ xarr = np.asarray(self._state["x_axis"])
379
+ return (float(xarr.min()), float(xarr.max()))
380
+
381
+ def set_extent(self, x_axis, y_axis) -> None:
382
+ x_axis = np.asarray(x_axis, dtype=float)
383
+ y_axis = np.asarray(y_axis, dtype=float)
384
+ w = self._state["image_width"]
385
+ h = self._state["image_height"]
386
+ scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0
387
+ scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0
388
+ self._state["x_axis"] = x_axis.tolist()
389
+ self._state["y_axis"] = y_axis.tolist()
390
+ self._state["scale_x"] = scale_x
391
+ self._state["scale_y"] = scale_y
392
+ self._push()
393
+
394
+ def set_colorbar_label(self, label: str, fontsize: float | None = None) -> None:
395
+ """Set the colorbar label (mini-TeX allowed; default size 10 px)."""
396
+ self._set_label("colorbar_label", label, "colorbar_label_size", fontsize)
397
+
398
+ def set_colorbar_visible(self, visible: bool) -> None:
399
+ self._state["show_colorbar"] = bool(visible)
400
+ self._push()
401
+
402
+ def set_aspect(self, ratio) -> None:
403
+ if ratio == "equal":
404
+ ratio = 1.0
405
+ self._state["aspect"] = float(ratio) if ratio is not None else None
406
+ self._push()
407
+
408
+ # ------------------------------------------------------------------
409
+ # Overlay Widgets
410
+ # ------------------------------------------------------------------
411
+ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget:
412
+ """Add an overlay widget by kind name.
413
+
414
+ Dispatches to the dedicated ``add_<kind>_widget`` method.
415
+ Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``,
416
+ ``"polygon"``, ``"crosshair"``, ``"label"``.
417
+ """
418
+ dispatch = {
419
+ "circle": self.add_circle_widget,
420
+ "rectangle": self.add_rectangle_widget,
421
+ "annular": self.add_annular_widget,
422
+ "polygon": self.add_polygon_widget,
423
+ "crosshair": self.add_crosshair_widget,
424
+ "label": self.add_label_widget,
425
+ }
426
+ key = kind.lower()
427
+ if key not in dispatch:
428
+ raise ValueError(f"kind must be one of {tuple(dispatch)}")
429
+ return dispatch[key](color=color, **kwargs)
430
+
431
+ def add_circle_widget(self, cx: float | None = None, cy: float | None = None,
432
+ r: float | None = None, color: str = "#00e5ff") -> CircleWidget:
433
+ """Add a draggable circle overlay."""
434
+ iw, ih = self._state["image_width"], self._state["image_height"]
435
+ widget = CircleWidget(lambda: None,
436
+ cx=float(cx) if cx is not None else iw / 2,
437
+ cy=float(cy) if cy is not None else ih / 2,
438
+ r=float(r) if r is not None else iw * 0.1,
439
+ color=color)
440
+ widget._push_fn = self._make_widget_push_fn(widget)
441
+ self._widgets[widget.id] = widget
442
+ self._push()
443
+ return widget
444
+
445
+ def add_rectangle_widget(self, x: float | None = None, y: float | None = None,
446
+ w: float | None = None, h: float | None = None,
447
+ color: str = "#00e5ff") -> RectangleWidget:
448
+ """Add a draggable rectangle overlay."""
449
+ iw, ih = self._state["image_width"], self._state["image_height"]
450
+ widget = RectangleWidget(lambda: None,
451
+ x=float(x) if x is not None else iw * 0.25,
452
+ y=float(y) if y is not None else ih * 0.25,
453
+ w=float(w) if w is not None else iw * 0.5,
454
+ h=float(h) if h is not None else ih * 0.5,
455
+ color=color)
456
+ widget._push_fn = self._make_widget_push_fn(widget)
457
+ self._widgets[widget.id] = widget
458
+ self._push()
459
+ return widget
460
+
461
+ def add_annular_widget(self, cx: float | None = None, cy: float | None = None,
462
+ r_outer: float | None = None, r_inner: float | None = None,
463
+ color: str = "#00e5ff") -> AnnularWidget:
464
+ """Add a draggable annular (ring) overlay."""
465
+ iw, ih = self._state["image_width"], self._state["image_height"]
466
+ widget = AnnularWidget(lambda: None,
467
+ cx=float(cx) if cx is not None else iw / 2,
468
+ cy=float(cy) if cy is not None else ih / 2,
469
+ r_outer=float(r_outer) if r_outer is not None else iw * 0.2,
470
+ r_inner=float(r_inner) if r_inner is not None else iw * 0.1,
471
+ color=color)
472
+ widget._push_fn = self._make_widget_push_fn(widget)
473
+ self._widgets[widget.id] = widget
474
+ self._push()
475
+ return widget
476
+
477
+ def add_polygon_widget(self, vertices=None, color: str = "#00e5ff") -> PolygonWidget:
478
+ """Add a draggable polygon overlay."""
479
+ iw, ih = self._state["image_width"], self._state["image_height"]
480
+ if vertices is None:
481
+ vertices = [[iw * .25, ih * .25], [iw * .75, ih * .25],
482
+ [iw * .75, ih * .75], [iw * .25, ih * .75]]
483
+ widget = PolygonWidget(lambda: None, vertices=vertices, color=color)
484
+ widget._push_fn = self._make_widget_push_fn(widget)
485
+ self._widgets[widget.id] = widget
486
+ self._push()
487
+ return widget
488
+
489
+ def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None,
490
+ color: str = "#00e5ff") -> CrosshairWidget:
491
+ """Add a draggable crosshair overlay."""
492
+ iw, ih = self._state["image_width"], self._state["image_height"]
493
+ widget = CrosshairWidget(lambda: None,
494
+ cx=float(cx) if cx is not None else iw / 2,
495
+ cy=float(cy) if cy is not None else ih / 2,
496
+ color=color)
497
+ widget._push_fn = self._make_widget_push_fn(widget)
498
+ self._widgets[widget.id] = widget
499
+ self._push()
500
+ return widget
501
+
502
+ def add_label_widget(self, x: float | None = None, y: float | None = None,
503
+ text: str = "Label", fontsize: int = 14,
504
+ color: str = "#00e5ff") -> LabelWidget:
505
+ """Add a draggable text label overlay."""
506
+ iw, ih = self._state["image_width"], self._state["image_height"]
507
+ widget = LabelWidget(lambda: None,
508
+ x=float(x) if x is not None else iw * 0.1,
509
+ y=float(y) if y is not None else ih * 0.1,
510
+ text=str(text), fontsize=int(fontsize), color=color)
511
+ widget._push_fn = self._make_widget_push_fn(widget)
512
+ self._widgets[widget.id] = widget
513
+ self._push()
514
+ return widget
515
+
516
+ # ------------------------------------------------------------------
517
+ # View control
518
+ # ------------------------------------------------------------------
519
+ def set_view(self,
520
+ x0: float | None = None, x1: float | None = None,
521
+ y0: float | None = None, y1: float | None = None) -> None:
522
+ """Set the viewport to a data-space rectangle.
523
+
524
+ Parameters
525
+ ----------
526
+ x0, x1 : float, optional
527
+ Horizontal data-space range to show. If omitted the full
528
+ x-extent is used for zoom calculation.
529
+ y0, y1 : float, optional
530
+ Vertical data-space range to show. If omitted the full
531
+ y-extent is used for zoom calculation.
532
+
533
+ Translates the requested rectangle into the ``zoom`` / ``center_x``
534
+ / ``center_y`` state values used by the 2-D JS renderer.
535
+ """
536
+ xarr = np.asarray(self._state["x_axis"])
537
+ yarr = np.asarray(self._state["y_axis"])
538
+ if len(xarr) < 2 or len(yarr) < 2:
539
+ return
540
+
541
+ xmin, xmax = float(xarr[0]), float(xarr[-1])
542
+ ymin, ymax = float(yarr[0]), float(yarr[-1])
543
+ x_span = xmax - xmin or 1.0
544
+ y_span = ymax - ymin or 1.0
545
+
546
+ zoom_candidates = []
547
+
548
+ if x0 is not None and x1 is not None:
549
+ fx0 = max(0.0, min(1.0, (float(x0) - xmin) / x_span))
550
+ fx1 = max(0.0, min(1.0, (float(x1) - xmin) / x_span))
551
+ if fx1 > fx0:
552
+ self._state["center_x"] = (fx0 + fx1) / 2.0
553
+ zoom_candidates.append(1.0 / (fx1 - fx0))
554
+
555
+ if y0 is not None and y1 is not None:
556
+ fy0 = max(0.0, min(1.0, (float(y0) - ymin) / y_span))
557
+ fy1 = max(0.0, min(1.0, (float(y1) - ymin) / y_span))
558
+ if fy1 > fy0:
559
+ self._state["center_y"] = (fy0 + fy1) / 2.0
560
+ zoom_candidates.append(1.0 / (fy1 - fy0))
561
+
562
+ with self._python_view_push():
563
+ if zoom_candidates:
564
+ self._state["zoom"] = min(zoom_candidates)
565
+
566
+ def reset_view(self) -> None:
567
+ """Reset pan and zoom to show the full image."""
568
+ with self._python_view_push():
569
+ self._state["zoom"] = 1.0
570
+ self._state["center_x"] = 0.5
571
+ self._state["center_y"] = 0.5
572
+
573
+ # ------------------------------------------------------------------
574
+ # Marker API (matplotlib-style kwargs → MarkerRegistry)
575
+ # ------------------------------------------------------------------
576
+ def add_circles(self, offsets, name=None, *, radius=5,
577
+ facecolors=None, edgecolors="#ff0000",
578
+ linewidths=1.5, alpha=0.3,
579
+ hover_edgecolors=None, hover_facecolors=None,
580
+ labels=None, label=None,
581
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
582
+ """Add circle markers at (x, y) positions in data coordinates."""
583
+ return self._add_marker("circles", name, offsets=offsets, radius=radius,
584
+ facecolors=facecolors, edgecolors=edgecolors,
585
+ linewidths=linewidths, alpha=alpha,
586
+ hover_edgecolors=hover_edgecolors,
587
+ hover_facecolors=hover_facecolors,
588
+ labels=labels, label=label,
589
+ transform=transform)
590
+
591
+ def add_points(self, offsets, name=None, *, sizes=5,
592
+ color="#ff0000", facecolors=None,
593
+ linewidths=1.5, alpha=0.3,
594
+ hover_edgecolors=None, hover_facecolors=None,
595
+ labels=None, label=None,
596
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
597
+ """Add point markers at (x, y) positions in data coordinates."""
598
+ return self._add_marker("circles", name, offsets=offsets, radius=sizes,
599
+ edgecolors=color, facecolors=facecolors,
600
+ linewidths=linewidths, alpha=alpha,
601
+ hover_edgecolors=hover_edgecolors,
602
+ hover_facecolors=hover_facecolors,
603
+ labels=labels, label=label,
604
+ transform=transform)
605
+
606
+ def add_hlines(self, y_values, name=None, *,
607
+ color="#ff0000", linewidths=1.5,
608
+ hover_edgecolors=None,
609
+ labels=None, label=None,
610
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
611
+ """Add static horizontal lines at the given y positions."""
612
+ return self._add_marker("hlines", name, offsets=y_values,
613
+ color=color, linewidths=linewidths,
614
+ hover_edgecolors=hover_edgecolors,
615
+ labels=labels, label=label,
616
+ transform=transform)
617
+
618
+ def add_vlines(self, x_values, name=None, *,
619
+ color="#ff0000", linewidths=1.5,
620
+ hover_edgecolors=None,
621
+ labels=None, label=None,
622
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
623
+ """Add static vertical lines at the given x positions."""
624
+ return self._add_marker("vlines", name, offsets=x_values,
625
+ color=color, linewidths=linewidths,
626
+ hover_edgecolors=hover_edgecolors,
627
+ labels=labels, label=label,
628
+ transform=transform)
629
+
630
+ def add_arrows(self, offsets, U, V, name=None, *,
631
+ edgecolors="#ff0000", linewidths=1.5,
632
+ hover_edgecolors=None,
633
+ labels=None, label=None,
634
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
635
+ return self._add_marker("arrows", name, offsets=offsets, U=U, V=V,
636
+ edgecolors=edgecolors, linewidths=linewidths,
637
+ hover_edgecolors=hover_edgecolors,
638
+ labels=labels, label=label,
639
+ transform=transform)
640
+
641
+ def add_ellipses(self, offsets, widths, heights, name=None, *,
642
+ angles=0, facecolors=None, edgecolors="#ff0000",
643
+ linewidths=1.5, alpha=0.3,
644
+ hover_edgecolors=None, hover_facecolors=None,
645
+ labels=None, label=None,
646
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
647
+ return self._add_marker("ellipses", name, offsets=offsets,
648
+ widths=widths, heights=heights, angles=angles,
649
+ facecolors=facecolors, edgecolors=edgecolors,
650
+ linewidths=linewidths, alpha=alpha,
651
+ hover_edgecolors=hover_edgecolors,
652
+ hover_facecolors=hover_facecolors,
653
+ labels=labels, label=label,
654
+ transform=transform)
655
+
656
+ def add_lines(self, segments, name=None, *,
657
+ edgecolors="#ff0000", linewidths=1.5,
658
+ hover_edgecolors=None,
659
+ labels=None, label=None,
660
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
661
+ return self._add_marker("lines", name, segments=segments,
662
+ edgecolors=edgecolors, linewidths=linewidths,
663
+ hover_edgecolors=hover_edgecolors,
664
+ labels=labels, label=label,
665
+ transform=transform)
666
+
667
+ def add_rectangles(self, offsets, widths, heights, name=None, *,
668
+ angles=0, facecolors=None, edgecolors="#ff0000",
669
+ linewidths=1.5, alpha=0.3,
670
+ hover_edgecolors=None, hover_facecolors=None,
671
+ labels=None, label=None,
672
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
673
+ return self._add_marker("rectangles", name, offsets=offsets,
674
+ widths=widths, heights=heights, angles=angles,
675
+ facecolors=facecolors, edgecolors=edgecolors,
676
+ linewidths=linewidths, alpha=alpha,
677
+ hover_edgecolors=hover_edgecolors,
678
+ hover_facecolors=hover_facecolors,
679
+ labels=labels, label=label,
680
+ transform=transform)
681
+
682
+ def add_squares(self, offsets, widths, name=None, *,
683
+ angles=0, facecolors=None, edgecolors="#ff0000",
684
+ linewidths=1.5, alpha=0.3,
685
+ hover_edgecolors=None, hover_facecolors=None,
686
+ labels=None, label=None,
687
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
688
+ return self._add_marker("squares", name, offsets=offsets,
689
+ widths=widths, angles=angles,
690
+ facecolors=facecolors, edgecolors=edgecolors,
691
+ linewidths=linewidths, alpha=alpha,
692
+ hover_edgecolors=hover_edgecolors,
693
+ hover_facecolors=hover_facecolors,
694
+ labels=labels, label=label,
695
+ transform=transform)
696
+
697
+ def add_polygons(self, vertices_list, name=None, *,
698
+ facecolors=None, edgecolors="#ff0000",
699
+ linewidths=1.5, alpha=0.3,
700
+ hover_edgecolors=None, hover_facecolors=None,
701
+ labels=None, label=None,
702
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
703
+ return self._add_marker("polygons", name, vertices_list=vertices_list,
704
+ facecolors=facecolors, edgecolors=edgecolors,
705
+ linewidths=linewidths, alpha=alpha,
706
+ hover_edgecolors=hover_edgecolors,
707
+ hover_facecolors=hover_facecolors,
708
+ labels=labels, label=label,
709
+ transform=transform)
710
+
711
+ def add_texts(self, offsets, texts, name=None, *,
712
+ color="#ff0000", fontsize=12,
713
+ hover_edgecolors=None,
714
+ labels=None, label=None,
715
+ transform: str = "data") -> "MarkerGroup": # noqa: F821
716
+ return self._add_marker("texts", name, offsets=offsets, texts=texts,
717
+ color=color, fontsize=fontsize,
718
+ hover_edgecolors=hover_edgecolors,
719
+ labels=labels, label=label,
720
+ transform=transform)
721
+
722
+ def __repr__(self) -> str:
723
+ w = self._state.get("image_width", "?")
724
+ h = self._state.get("image_height", "?")
725
+ cmap = self._state.get("colormap_name", "?")
726
+ return f"Plot2D({w}×{h}, cmap={cmap!r})"