anyplotlib 0.1.0b1__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 +53 -0
- anyplotlib/_base_plot.py +229 -0
- anyplotlib/_electron.py +74 -0
- anyplotlib/_repr_utils.py +345 -0
- anyplotlib/_utils.py +194 -0
- anyplotlib/axes/__init__.py +6 -0
- anyplotlib/axes/_axes.py +510 -0
- anyplotlib/axes/_inset_axes.py +126 -0
- anyplotlib/callbacks.py +328 -0
- anyplotlib/embed.py +194 -0
- anyplotlib/figure/__init__.py +7 -0
- anyplotlib/figure/_figure.py +633 -0
- anyplotlib/figure/_gridspec.py +99 -0
- anyplotlib/figure/_subplots.py +100 -0
- anyplotlib/figure_esm.js +6011 -0
- anyplotlib/markers.py +704 -0
- anyplotlib/plot1d/__init__.py +6 -0
- anyplotlib/plot1d/_plot1d.py +1376 -0
- anyplotlib/plot1d/_plotbar.py +436 -0
- anyplotlib/plot2d/__init__.py +5 -0
- anyplotlib/plot2d/_plot2d.py +726 -0
- anyplotlib/plot2d/_plotmesh.py +116 -0
- anyplotlib/plot3d/__init__.py +4 -0
- anyplotlib/plot3d/_plot3d.py +524 -0
- anyplotlib/plotxy/__init__.py +4 -0
- anyplotlib/plotxy/_plotxy.py +273 -0
- anyplotlib/sphinx_anywidget/__init__.py +177 -0
- anyplotlib/sphinx_anywidget/_directive.py +245 -0
- anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
- anyplotlib/sphinx_anywidget/_scraper.py +390 -0
- anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
- anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
- anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
- anyplotlib/widgets/__init__.py +18 -0
- anyplotlib/widgets/_base.py +218 -0
- anyplotlib/widgets/_widgets1d.py +109 -0
- anyplotlib/widgets/_widgets2d.py +141 -0
- anyplotlib/widgets/_widgets3d.py +50 -0
- anyplotlib-0.1.0b1.dist-info/METADATA +160 -0
- anyplotlib-0.1.0b1.dist-info/RECORD +42 -0
- anyplotlib-0.1.0b1.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0b1.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})"
|