anyplotlib 0.4.2__py3-none-any.whl → 0.6.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 +2 -1
- anyplotlib/_base_plot.py +206 -0
- anyplotlib/_repr_utils.py +11 -1
- anyplotlib/_utils.py +97 -3
- anyplotlib/axes/_axes.py +23 -3
- anyplotlib/callbacks.py +6 -0
- anyplotlib/figure/_figure.py +27 -1
- anyplotlib/figure_esm.js +1248 -176
- anyplotlib/markers.py +24 -0
- anyplotlib/plot1d/_plot1d.py +30 -9
- anyplotlib/plot2d/_plot2d.py +262 -14
- anyplotlib/plot3d/_plot3d.py +199 -4
- anyplotlib/widgets/__init__.py +3 -1
- anyplotlib/widgets/_base.py +44 -2
- anyplotlib/widgets/_widgets1d.py +64 -7
- anyplotlib/widgets/_widgets2d.py +280 -0
- {anyplotlib-0.4.2.dist-info → anyplotlib-0.6.0.dist-info}/METADATA +3 -2
- {anyplotlib-0.4.2.dist-info → anyplotlib-0.6.0.dist-info}/RECORD +20 -20
- {anyplotlib-0.4.2.dist-info → anyplotlib-0.6.0.dist-info}/WHEEL +0 -0
- {anyplotlib-0.4.2.dist-info → anyplotlib-0.6.0.dist-info}/licenses/LICENSE +0 -0
anyplotlib/__init__.py
CHANGED
|
@@ -17,7 +17,7 @@ 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, ArrowWidget,
|
|
20
|
+
CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, BrushWidget,
|
|
21
21
|
VLineWidget, HLineWidget, RangeWidget, PointWidget, PlaneWidget,
|
|
22
22
|
)
|
|
23
23
|
|
|
@@ -46,6 +46,7 @@ __all__ = [
|
|
|
46
46
|
"MarkerRegistry", "MarkerGroup",
|
|
47
47
|
"Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
|
|
48
48
|
"CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget",
|
|
49
|
+
"BrushWidget",
|
|
49
50
|
"VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget",
|
|
50
51
|
"show_help", "get_color_cycle",
|
|
51
52
|
"embed",
|
anyplotlib/_base_plot.py
CHANGED
|
@@ -95,8 +95,13 @@ class _BasePlot(_EventMixin):
|
|
|
95
95
|
|
|
96
96
|
Replaces the repeated _tp / _targeted_push closures in every
|
|
97
97
|
add_*_widget method.
|
|
98
|
+
|
|
99
|
+
Also back-links the widget to this plot so :meth:`Widget.remove` can
|
|
100
|
+
find its owner. Every ``add_*_widget`` routes through here, so this is
|
|
101
|
+
the one place that has to know.
|
|
98
102
|
"""
|
|
99
103
|
plot_ref, wid_id = self, widget._id
|
|
104
|
+
widget._plot = self
|
|
100
105
|
def _push():
|
|
101
106
|
if plot_ref._fig is not None:
|
|
102
107
|
fields = {k: v for k, v in widget._data.items()
|
|
@@ -124,6 +129,207 @@ class _PanelMixin:
|
|
|
124
129
|
self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
|
|
125
130
|
self._fig._push(self._id)
|
|
126
131
|
|
|
132
|
+
# ── geometry / coordinate conversion ──────────────────────────────────
|
|
133
|
+
#
|
|
134
|
+
# These mirror the layout constants and letterbox math in figure_esm.js
|
|
135
|
+
# (the PAD_* block near the top, and _imgFitRect). They are here so that
|
|
136
|
+
# callers doing display-space work — pixel-sized handles, hit-test
|
|
137
|
+
# tolerances, screenshot-driven tests — do not each have to re-derive the
|
|
138
|
+
# renderer's layout in their own code and then drift from it.
|
|
139
|
+
|
|
140
|
+
#: Plot-area padding in CSS px: (left, right, top, bottom).
|
|
141
|
+
#: Matches ``PAD_L``/``PAD_R``/``PAD_T``/``PAD_B`` in ``figure_esm.js``.
|
|
142
|
+
PLOT_PADDING = (58, 12, 12, 42)
|
|
143
|
+
|
|
144
|
+
def _panel_size(self) -> tuple[float, float]:
|
|
145
|
+
"""(panel_width, panel_height) in CSS px, from the figure's layout."""
|
|
146
|
+
import json
|
|
147
|
+
|
|
148
|
+
if self._fig is None:
|
|
149
|
+
raise RuntimeError("panel is not attached to a figure")
|
|
150
|
+
try:
|
|
151
|
+
layout = json.loads(self._fig.layout_json)
|
|
152
|
+
spec = next(s for s in layout["panel_specs"] if s["id"] == self._id)
|
|
153
|
+
except (AttributeError, KeyError, StopIteration, ValueError) as exc:
|
|
154
|
+
raise RuntimeError(f"no layout entry for panel {self._id!r}") from exc
|
|
155
|
+
return float(spec["panel_width"]), float(spec["panel_height"])
|
|
156
|
+
|
|
157
|
+
def _pad_top(self) -> float:
|
|
158
|
+
"""Title-strip height. Mirrors ``_padT`` in figure_esm.js."""
|
|
159
|
+
pad_t = float(self.PLOT_PADDING[2])
|
|
160
|
+
title = self._state.get("title")
|
|
161
|
+
if not title:
|
|
162
|
+
return pad_t
|
|
163
|
+
size = float(self._state.get("title_size") or 11)
|
|
164
|
+
has_tex = "$" in str(title)
|
|
165
|
+
if size <= 11 and not has_tex:
|
|
166
|
+
return pad_t
|
|
167
|
+
import math
|
|
168
|
+
|
|
169
|
+
return max(pad_t, math.ceil(size * 1.3) + (4 if has_tex else 2))
|
|
170
|
+
|
|
171
|
+
def plot_box(self) -> dict:
|
|
172
|
+
"""Return the panel's plot area in CSS pixels.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
dict
|
|
177
|
+
``{"x", "y", "width", "height"}`` — the drawable rectangle
|
|
178
|
+
relative to the panel's top-left corner. On a 2-D image panel it
|
|
179
|
+
is the *letterboxed image* rather than the whole padded area,
|
|
180
|
+
because that is what image coordinates actually map onto.
|
|
181
|
+
|
|
182
|
+
Raises
|
|
183
|
+
------
|
|
184
|
+
RuntimeError
|
|
185
|
+
If the panel is not attached to a figure, so it has no layout.
|
|
186
|
+
|
|
187
|
+
Notes
|
|
188
|
+
-----
|
|
189
|
+
Computed from the figure's own layout, so it is exact for the sizes
|
|
190
|
+
the renderer was given. A browser resized without a re-layout will
|
|
191
|
+
disagree. Zoom and pan are *not* folded in — this is the box, not
|
|
192
|
+
the visible data window; :meth:`data_to_display` accounts for them.
|
|
193
|
+
"""
|
|
194
|
+
left, right, top, bottom = self.PLOT_PADDING
|
|
195
|
+
pw, ph = self._panel_size()
|
|
196
|
+
pad_t = self._pad_top()
|
|
197
|
+
|
|
198
|
+
# A 2-D panel drops the left/right/bottom gutters when it has no
|
|
199
|
+
# physical axes to label — the image then fills the panel width.
|
|
200
|
+
# 1-D panels always draw axes. Mirrors `hasPhysAxis` in the JS.
|
|
201
|
+
iw = self._state.get("image_width")
|
|
202
|
+
ih = self._state.get("image_height")
|
|
203
|
+
is_image = bool(iw and ih)
|
|
204
|
+
has_axes = (not is_image) or bool(
|
|
205
|
+
self._state.get("has_axes") or self._state.get("is_mesh")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
x = float(left) if has_axes else 0.0
|
|
209
|
+
y = float(pad_t)
|
|
210
|
+
width = max((pw - left - right) if has_axes else pw, 1.0)
|
|
211
|
+
height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0)
|
|
212
|
+
|
|
213
|
+
# The colorbar strip and its gap come out of the image width.
|
|
214
|
+
if self._state.get("show_colorbar") and not self._state.get("is_rgb"):
|
|
215
|
+
label = self._state.get("colorbar_label")
|
|
216
|
+
label_w = round((self._state.get("colorbar_label_size") or 10) + 8) \
|
|
217
|
+
if label else 0
|
|
218
|
+
pad = self._state.get("colorbar_pad")
|
|
219
|
+
gap = 6.0 if pad is None else max(0.0, float(pad))
|
|
220
|
+
width = max(width - (16 + label_w) - gap, 1.0)
|
|
221
|
+
|
|
222
|
+
if is_image:
|
|
223
|
+
# Images are drawn "contain" — see _imgFitRect.
|
|
224
|
+
scale = min(width / float(iw), height / float(ih))
|
|
225
|
+
fit_w, fit_h = float(iw) * scale, float(ih) * scale
|
|
226
|
+
x += (width - fit_w) / 2.0
|
|
227
|
+
y += (height - fit_h) / 2.0
|
|
228
|
+
width, height = fit_w, fit_h
|
|
229
|
+
|
|
230
|
+
return {"x": x, "y": y, "width": width, "height": height}
|
|
231
|
+
|
|
232
|
+
def _visible_image_window(self) -> tuple[float, float, float, float]:
|
|
233
|
+
"""(src_x, src_y, vis_w, vis_h) of the visible image region, in image px.
|
|
234
|
+
|
|
235
|
+
Mirrors the zoom/pan window in ``_imgToCanvas2d``.
|
|
236
|
+
"""
|
|
237
|
+
iw = float(self._state.get("image_width") or 1)
|
|
238
|
+
ih = float(self._state.get("image_height") or 1)
|
|
239
|
+
zoom = float(self._state.get("zoom") or 1.0)
|
|
240
|
+
cx = float(self._state.get("center_x", 0.5))
|
|
241
|
+
cy = float(self._state.get("center_y", 0.5))
|
|
242
|
+
if zoom < 1.0:
|
|
243
|
+
# Zoomed out: the whole image, drawn smaller and centred. The box
|
|
244
|
+
# shrinks rather than the source window growing.
|
|
245
|
+
return 0.0, 0.0, iw, ih
|
|
246
|
+
vis_w, vis_h = iw / zoom, ih / zoom
|
|
247
|
+
src_x = max(0.0, min(iw - vis_w, cx * iw - vis_w / 2.0))
|
|
248
|
+
src_y = max(0.0, min(ih - vis_h, cy * ih - vis_h / 2.0))
|
|
249
|
+
return src_x, src_y, vis_w, vis_h
|
|
250
|
+
|
|
251
|
+
def data_to_display(self, points):
|
|
252
|
+
"""Convert data coordinates to CSS pixels within the panel.
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
points : array-like
|
|
257
|
+
A single ``(x, y)`` pair or an ``(N, 2)`` sequence of them.
|
|
258
|
+
|
|
259
|
+
On a 1-D panel these are axis values. On a 2-D panel they are
|
|
260
|
+
**image-pixel coordinates** — the same space marker ``offsets``
|
|
261
|
+
and widget positions use — where integer *i* means the *centre*
|
|
262
|
+
of pixel *i*, not its leading edge.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
numpy.ndarray
|
|
267
|
+
Same shape as *points*, in panel pixels with the origin at the
|
|
268
|
+
panel's top-left corner and y increasing downwards. Zoom and pan
|
|
269
|
+
are accounted for.
|
|
270
|
+
"""
|
|
271
|
+
import numpy as np
|
|
272
|
+
|
|
273
|
+
pts = np.atleast_2d(np.asarray(points, dtype=float))
|
|
274
|
+
if pts.shape[-1] != 2:
|
|
275
|
+
raise ValueError(f"points must have shape (N, 2); got {pts.shape}")
|
|
276
|
+
box = self.plot_box()
|
|
277
|
+
|
|
278
|
+
if self._state.get("image_width") and self._state.get("image_height"):
|
|
279
|
+
zoom = float(self._state.get("zoom") or 1.0)
|
|
280
|
+
src_x, src_y, vis_w, vis_h = self._visible_image_window()
|
|
281
|
+
w = box["width"] * (zoom if zoom < 1.0 else 1.0)
|
|
282
|
+
h = box["height"] * (zoom if zoom < 1.0 else 1.0)
|
|
283
|
+
x0 = box["x"] + (box["width"] - w) / 2.0
|
|
284
|
+
y0 = box["y"] + (box["height"] - h) / 2.0
|
|
285
|
+
px = x0 + (pts[:, 0] + 0.5 - src_x) / vis_w * w
|
|
286
|
+
py = y0 + (pts[:, 1] + 0.5 - src_y) / vis_h * h
|
|
287
|
+
else:
|
|
288
|
+
xlo, xhi = self.get_xlim()
|
|
289
|
+
ylo, yhi = self.get_ylim()
|
|
290
|
+
px = box["x"] + (pts[:, 0] - xlo) / ((xhi - xlo) or 1.0) * box["width"]
|
|
291
|
+
# Screen y grows downwards, data y upwards.
|
|
292
|
+
py = box["y"] + (
|
|
293
|
+
1.0 - (pts[:, 1] - ylo) / ((yhi - ylo) or 1.0)
|
|
294
|
+
) * box["height"]
|
|
295
|
+
|
|
296
|
+
out = np.column_stack([px, py])
|
|
297
|
+
return out if np.ndim(points) == 2 else out[0]
|
|
298
|
+
|
|
299
|
+
def display_to_data(self, points):
|
|
300
|
+
"""Convert CSS pixels within the panel to data coordinates.
|
|
301
|
+
|
|
302
|
+
The inverse of :meth:`data_to_display`; same argument shapes and the
|
|
303
|
+
same per-panel-kind coordinate meaning.
|
|
304
|
+
"""
|
|
305
|
+
import numpy as np
|
|
306
|
+
|
|
307
|
+
pts = np.atleast_2d(np.asarray(points, dtype=float))
|
|
308
|
+
if pts.shape[-1] != 2:
|
|
309
|
+
raise ValueError(f"points must have shape (N, 2); got {pts.shape}")
|
|
310
|
+
box = self.plot_box()
|
|
311
|
+
|
|
312
|
+
if self._state.get("image_width") and self._state.get("image_height"):
|
|
313
|
+
zoom = float(self._state.get("zoom") or 1.0)
|
|
314
|
+
src_x, src_y, vis_w, vis_h = self._visible_image_window()
|
|
315
|
+
w = box["width"] * (zoom if zoom < 1.0 else 1.0)
|
|
316
|
+
h = box["height"] * (zoom if zoom < 1.0 else 1.0)
|
|
317
|
+
x0 = box["x"] + (box["width"] - w) / 2.0
|
|
318
|
+
y0 = box["y"] + (box["height"] - h) / 2.0
|
|
319
|
+
dx = (pts[:, 0] - x0) / (w or 1.0) * vis_w + src_x - 0.5
|
|
320
|
+
dy = (pts[:, 1] - y0) / (h or 1.0) * vis_h + src_y - 0.5
|
|
321
|
+
else:
|
|
322
|
+
xlo, xhi = self.get_xlim()
|
|
323
|
+
ylo, yhi = self.get_ylim()
|
|
324
|
+
dx = xlo + (pts[:, 0] - box["x"]) / (box["width"] or 1.0) \
|
|
325
|
+
* ((xhi - xlo) or 1.0)
|
|
326
|
+
dy = ylo + (
|
|
327
|
+
1.0 - (pts[:, 1] - box["y"]) / (box["height"] or 1.0)
|
|
328
|
+
) * ((yhi - ylo) or 1.0)
|
|
329
|
+
|
|
330
|
+
out = np.column_stack([dx, dy])
|
|
331
|
+
return out if np.ndim(points) == 2 else out[0]
|
|
332
|
+
|
|
127
333
|
def set_tick_label_size(self, size: float) -> None:
|
|
128
334
|
"""Set the font size of the tick (axis number) labels in CSS pixels.
|
|
129
335
|
|
anyplotlib/_repr_utils.py
CHANGED
|
@@ -35,7 +35,17 @@ MAX_NOTEBOOK_WIDTH = 860
|
|
|
35
35
|
# ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
def _widget_state(widget) -> dict:
|
|
38
|
-
"""Return a {name: value} dict of every synced traitlet.
|
|
38
|
+
"""Return a {name: value} dict of every synced traitlet.
|
|
39
|
+
|
|
40
|
+
A widget that keeps state outside its traits between renders may define
|
|
41
|
+
``_sync_for_export()`` to reconcile it first. ``Figure`` uses this to fold
|
|
42
|
+
widget geometry — pushed to JS as targeted events that never touch the
|
|
43
|
+
panel traits — back in, so a snapshot captures widgets where they are
|
|
44
|
+
rather than where they were created.
|
|
45
|
+
"""
|
|
46
|
+
sync = getattr(widget, "_sync_for_export", None)
|
|
47
|
+
if callable(sync):
|
|
48
|
+
sync()
|
|
39
49
|
state: dict = {}
|
|
40
50
|
for name, trait in widget.traits(sync=True).items():
|
|
41
51
|
if name.startswith("_"):
|
anyplotlib/_utils.py
CHANGED
|
@@ -7,6 +7,7 @@ Shared low-level utilities used across plot subpackages.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import functools
|
|
10
|
+
import pathlib
|
|
10
11
|
|
|
11
12
|
import numpy as np
|
|
12
13
|
|
|
@@ -21,6 +22,12 @@ _LINESTYLE_ALIASES: dict[str, str] = {
|
|
|
21
22
|
"dashdot": "dashdot",
|
|
22
23
|
"step-mid": "step-mid",
|
|
23
24
|
"steps-mid": "step-mid",
|
|
25
|
+
# "no connecting line" — draw the per-point markers only, so a matplotlib
|
|
26
|
+
# ``linestyle="None", marker="o"`` scatter ports over unchanged.
|
|
27
|
+
# matplotlib also spells this ``""`` and ``" "``; those stay errors here
|
|
28
|
+
# because an empty style string is far more often a typo than an intent.
|
|
29
|
+
"none": "none",
|
|
30
|
+
"None": "none",
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
|
|
@@ -42,7 +49,9 @@ def _norm_linestyle(ls: str) -> str:
|
|
|
42
49
|
Accepted values
|
|
43
50
|
---------------
|
|
44
51
|
``"solid"`` / ``"-"``, ``"dashed"`` / ``"--"``,
|
|
45
|
-
``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."
|
|
52
|
+
``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``,
|
|
53
|
+
``"step-mid"`` / ``"steps-mid"``, and ``"none"`` / ``"None"`` / ``""`` /
|
|
54
|
+
``" "`` for no connecting line (markers only).
|
|
46
55
|
|
|
47
56
|
Raises
|
|
48
57
|
------
|
|
@@ -53,8 +62,8 @@ def _norm_linestyle(ls: str) -> str:
|
|
|
53
62
|
if canonical is None:
|
|
54
63
|
raise ValueError(
|
|
55
64
|
f"Unknown linestyle {ls!r}. Expected one of: "
|
|
56
|
-
"'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid') "
|
|
57
|
-
"or shorthands '-', '--', ':', '-.'."
|
|
65
|
+
"'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid'), "
|
|
66
|
+
"'none' (markers only) or shorthands '-', '--', ':', '-.'."
|
|
58
67
|
)
|
|
59
68
|
return canonical
|
|
60
69
|
|
|
@@ -81,6 +90,91 @@ def _to_rgba_u8(data: np.ndarray) -> np.ndarray:
|
|
|
81
90
|
return np.ascontiguousarray(data)
|
|
82
91
|
|
|
83
92
|
|
|
93
|
+
def _encode_png(rgba: np.ndarray) -> bytes:
|
|
94
|
+
"""Encode an (H, W, 4) uint8 RGBA array as PNG bytes.
|
|
95
|
+
|
|
96
|
+
Stdlib only (``zlib`` + ``struct``) — Pillow is deliberately not a
|
|
97
|
+
dependency. Every scanline uses filter type 0 (None), which costs a
|
|
98
|
+
little compression ratio but keeps this to a handful of lines; for the
|
|
99
|
+
smooth/procedural imagery textures are made of, zlib still shrinks the
|
|
100
|
+
raw RGBA by an order of magnitude, which is what matters when the bytes
|
|
101
|
+
have to cross a notebook comm as a base-64 data URL.
|
|
102
|
+
"""
|
|
103
|
+
import struct
|
|
104
|
+
import zlib
|
|
105
|
+
|
|
106
|
+
h, w = rgba.shape[:2]
|
|
107
|
+
# One leading filter byte (0 = None) per scanline, then the RGBA row.
|
|
108
|
+
raw = np.zeros((h, w * 4 + 1), dtype=np.uint8)
|
|
109
|
+
raw[:, 1:] = rgba.reshape(h, w * 4)
|
|
110
|
+
|
|
111
|
+
def _chunk(tag: bytes, data: bytes) -> bytes:
|
|
112
|
+
return (struct.pack(">I", len(data)) + tag + data
|
|
113
|
+
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
|
114
|
+
|
|
115
|
+
return (b"\x89PNG\r\n\x1a\n"
|
|
116
|
+
+ _chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
|
|
117
|
+
+ _chunk(b"IDAT", zlib.compress(raw.tobytes(), 6))
|
|
118
|
+
+ _chunk(b"IEND", b""))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: ``(magic prefix, MIME type)`` for the raster formats every browser decodes.
|
|
122
|
+
_IMAGE_MAGIC: tuple = (
|
|
123
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
124
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
125
|
+
(b"GIF87a", "image/gif"),
|
|
126
|
+
(b"GIF89a", "image/gif"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sniff_image_mime(blob: bytes) -> "str | None":
|
|
131
|
+
"""Return the MIME type of *blob* from its magic bytes, or ``None``."""
|
|
132
|
+
for magic, mime in _IMAGE_MAGIC:
|
|
133
|
+
if blob.startswith(magic):
|
|
134
|
+
return mime
|
|
135
|
+
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
|
|
136
|
+
return "image/webp"
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _image_to_data_url(image) -> str:
|
|
141
|
+
"""Return a ``data:`` URL the browser can decode into a texture.
|
|
142
|
+
|
|
143
|
+
Accepts, in order of preference:
|
|
144
|
+
|
|
145
|
+
* an already-encoded image — ``bytes`` of a PNG/JPEG/GIF/WebP, or a
|
|
146
|
+
``str``/``os.PathLike`` path to such a file — passed through verbatim,
|
|
147
|
+
so a photo stays JPEG-compressed instead of being re-encoded;
|
|
148
|
+
* a ``data:`` URL string, returned unchanged;
|
|
149
|
+
* an ``(H, W, 3|4)`` array (uint8, or float 0–1) — anything
|
|
150
|
+
:func:`_to_rgba_u8` accepts, which includes a PIL image (it exposes the
|
|
151
|
+
array interface) — encoded as PNG.
|
|
152
|
+
"""
|
|
153
|
+
import base64
|
|
154
|
+
import os
|
|
155
|
+
|
|
156
|
+
if isinstance(image, (bytes, bytearray, memoryview)):
|
|
157
|
+
blob = bytes(image)
|
|
158
|
+
mime = _sniff_image_mime(blob)
|
|
159
|
+
if mime is None:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"texture bytes are not a recognised PNG/JPEG/GIF/WebP image")
|
|
162
|
+
return f"data:{mime};base64," + base64.b64encode(blob).decode("ascii")
|
|
163
|
+
|
|
164
|
+
if isinstance(image, (str, os.PathLike)):
|
|
165
|
+
if isinstance(image, str) and image.startswith("data:"):
|
|
166
|
+
return image
|
|
167
|
+
blob = pathlib.Path(image).read_bytes()
|
|
168
|
+
mime = _sniff_image_mime(blob)
|
|
169
|
+
if mime is None:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"{os.fspath(image)!r} is not a PNG/JPEG/GIF/WebP image")
|
|
172
|
+
return f"data:{mime};base64," + base64.b64encode(blob).decode("ascii")
|
|
173
|
+
|
|
174
|
+
png = _encode_png(_to_rgba_u8(np.asarray(image)))
|
|
175
|
+
return "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
|
176
|
+
|
|
177
|
+
|
|
84
178
|
def _normalize_image(data: np.ndarray, clim: "tuple | None" = None):
|
|
85
179
|
"""Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are
|
|
86
180
|
the QUANTISATION endpoints the 8-bit codes span (the caller stores them as
|
anyplotlib/axes/_axes.py
CHANGED
|
@@ -116,7 +116,10 @@ class Axes:
|
|
|
116
116
|
colormap: str = "viridis",
|
|
117
117
|
x_label: str = "x", y_label: str = "y", z_label: str = "z",
|
|
118
118
|
azimuth: float = -60.0, elevation: float = 30.0,
|
|
119
|
-
zoom: float = 1.0
|
|
119
|
+
zoom: float = 1.0,
|
|
120
|
+
bounds=None,
|
|
121
|
+
texture=None,
|
|
122
|
+
gpu: "str | bool" = "auto") -> "Plot3D":
|
|
120
123
|
"""Attach a 3-D surface to this axes cell.
|
|
121
124
|
|
|
122
125
|
Parameters
|
|
@@ -128,6 +131,20 @@ class Axes:
|
|
|
128
131
|
x_label, y_label, z_label : str, optional Axis labels.
|
|
129
132
|
azimuth, elevation : float, optional Initial camera angles in degrees.
|
|
130
133
|
zoom : float, optional Initial zoom factor.
|
|
134
|
+
bounds : ((xmin, xmax), (ymin, ymax), (zmin, zmax)), optional
|
|
135
|
+
Fix the axes bounds instead of fitting them to the data. Pass
|
|
136
|
+
``((-1, 1),) * 3`` for a unit sphere so it projects as a true
|
|
137
|
+
circle rather than being stretched to fill the panel.
|
|
138
|
+
texture : array-like, bytes, or path, optional
|
|
139
|
+
Image to wrap around the surface — shorthand for
|
|
140
|
+
:meth:`~anyplotlib.Plot3D.set_texture`, which takes the mapping,
|
|
141
|
+
shading, and culling options.
|
|
142
|
+
gpu : ``"auto"`` | bool, optional
|
|
143
|
+
WebGPU acceleration policy for a *textured* surface (a
|
|
144
|
+
colormapped one always renders on Canvas2D). ``"auto"`` (default)
|
|
145
|
+
uses the GPU when available above ~2k triangles; ``True`` always
|
|
146
|
+
attempts it; ``False`` forces Canvas2D. Falls back silently when
|
|
147
|
+
WebGPU is unavailable — check :attr:`Plot3D.gpu_active`.
|
|
131
148
|
|
|
132
149
|
Returns
|
|
133
150
|
-------
|
|
@@ -135,7 +152,8 @@ class Axes:
|
|
|
135
152
|
"""
|
|
136
153
|
plot = Plot3D("surface", X, Y, Z, colormap=colormap,
|
|
137
154
|
x_label=x_label, y_label=y_label, z_label=z_label,
|
|
138
|
-
azimuth=azimuth, elevation=elevation, zoom=zoom
|
|
155
|
+
azimuth=azimuth, elevation=elevation, zoom=zoom,
|
|
156
|
+
bounds=bounds, texture=texture, gpu=gpu)
|
|
139
157
|
self._attach(plot)
|
|
140
158
|
return plot
|
|
141
159
|
|
|
@@ -309,7 +327,9 @@ class Axes:
|
|
|
309
327
|
linestyle : str, optional
|
|
310
328
|
Dash pattern. Accepted values: ``"solid"`` (``"-"``),
|
|
311
329
|
``"dashed"`` (``"--"``), ``"dotted"`` (``":"``),
|
|
312
|
-
``"dashdot"`` (``"-."``)
|
|
330
|
+
``"dashdot"`` (``"-."``), or ``"none"`` to draw no connecting
|
|
331
|
+
line at all — pair it with *marker* for a scatter.
|
|
332
|
+
Default ``"solid"``.
|
|
313
333
|
ls : str, optional
|
|
314
334
|
Short alias for *linestyle*. Takes precedence if both are given.
|
|
315
335
|
alpha : float, optional
|
anyplotlib/callbacks.py
CHANGED
|
@@ -99,6 +99,12 @@ class Event:
|
|
|
99
99
|
image_height: int | None = None
|
|
100
100
|
display_width: int | None = None # panel device px (JS) → tile output resolution
|
|
101
101
|
display_height: int | None = None
|
|
102
|
+
# Camera (Plot3D orbit) — the angles the drag just produced, in degrees.
|
|
103
|
+
# The JS has always sent these alongside `zoom`; without them a handler
|
|
104
|
+
# cannot react to an orbit, because a JS-side drag does not sync back into
|
|
105
|
+
# Plot3D._state.
|
|
106
|
+
azimuth: float | None = None
|
|
107
|
+
elevation: float | None = None
|
|
102
108
|
# Key
|
|
103
109
|
key: str | None = None
|
|
104
110
|
last_widget_id: str | None = None
|
anyplotlib/figure/_figure.py
CHANGED
|
@@ -671,6 +671,8 @@ class Figure(anywidget.AnyWidget, _EventMixin):
|
|
|
671
671
|
image_height=msg.get("image_height"),
|
|
672
672
|
display_width=msg.get("display_width"),
|
|
673
673
|
display_height=msg.get("display_height"),
|
|
674
|
+
azimuth=msg.get("azimuth"),
|
|
675
|
+
elevation=msg.get("elevation"),
|
|
674
676
|
key=msg.get("key"),
|
|
675
677
|
last_widget_id=msg.get("last_widget_id"),
|
|
676
678
|
target=msg.get("target"),
|
|
@@ -786,12 +788,36 @@ class Figure(anywidget.AnyWidget, _EventMixin):
|
|
|
786
788
|
return [dict(m) for m in self._figure_markers]
|
|
787
789
|
|
|
788
790
|
def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None:
|
|
789
|
-
"""Send a targeted widget-position update to JS (no image data).
|
|
791
|
+
"""Send a targeted widget-position update to JS (no image data).
|
|
792
|
+
|
|
793
|
+
This writes ``event_json`` only — deliberately, because re-serialising
|
|
794
|
+
a whole panel (image bytes included) on every drag frame is exactly
|
|
795
|
+
what this path exists to avoid. The consequence is that
|
|
796
|
+
``panel_<id>_json`` carries stale widget geometry between plot-level
|
|
797
|
+
pushes; :meth:`_sync_for_export` reconciles it before any snapshot.
|
|
798
|
+
"""
|
|
790
799
|
payload = {"source": "python", "panel_id": panel_id,
|
|
791
800
|
"widget_id": widget_id}
|
|
792
801
|
payload.update(fields)
|
|
793
802
|
self.event_json = json.dumps(payload)
|
|
794
803
|
|
|
804
|
+
def _sync_for_export(self) -> None:
|
|
805
|
+
"""Refresh every panel trait so a snapshot sees current widget state.
|
|
806
|
+
|
|
807
|
+
Widget moves reach JS as targeted ``event_json`` updates that never
|
|
808
|
+
touch the panel traits (see :meth:`_push_widget`), so ``save_html`` /
|
|
809
|
+
``to_html`` / ``figure_state`` would otherwise capture every widget at
|
|
810
|
+
the position it was *created* at rather than where it now is. Python
|
|
811
|
+
holds the authoritative geometry — a JS-side drag writes back through
|
|
812
|
+
``_dispatch_event`` — so re-pushing each panel is enough to reconcile.
|
|
813
|
+
|
|
814
|
+
Called from ``_repr_utils._widget_state``, the one chokepoint every
|
|
815
|
+
export path goes through. The live Jupyter path deliberately keeps
|
|
816
|
+
using targeted pushes and does not pay this cost.
|
|
817
|
+
"""
|
|
818
|
+
for panel_id in list(self._plots_map):
|
|
819
|
+
self._push(panel_id)
|
|
820
|
+
|
|
795
821
|
def _push_panel_fields(self, panel_id: str, fields: dict) -> None:
|
|
796
822
|
"""Apply a small set of changed *fields* to a panel, then push once.
|
|
797
823
|
|