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.
- 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.0.dist-info/METADATA +160 -0
- anyplotlib-0.1.0.dist-info/RECORD +42 -0
- anyplotlib-0.1.0.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plot2d/_plotmesh.py
|
|
3
|
+
===================
|
|
4
|
+
pcolormesh panel (non-uniform grid).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from anyplotlib.markers import MarkerRegistry
|
|
12
|
+
from anyplotlib.plot2d._plot2d import Plot2D
|
|
13
|
+
from anyplotlib._utils import _normalize_image, _build_colormap_lut, _resample_mesh
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PlotMesh(Plot2D):
|
|
17
|
+
"""2-D mesh plot panel created by :meth:`Axes.pcolormesh`.
|
|
18
|
+
|
|
19
|
+
Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays,
|
|
20
|
+
matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and
|
|
21
|
+
``'lines'`` markers are supported.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, data: np.ndarray,
|
|
25
|
+
x_edges=None, y_edges=None, units: str = ""):
|
|
26
|
+
data = np.asarray(data)
|
|
27
|
+
if data.ndim != 2:
|
|
28
|
+
raise ValueError(f"data must be 2-D (M x N), got {data.shape}")
|
|
29
|
+
rows, cols = data.shape
|
|
30
|
+
|
|
31
|
+
if x_edges is None:
|
|
32
|
+
x_edges = np.arange(cols + 1, dtype=float)
|
|
33
|
+
if y_edges is None:
|
|
34
|
+
y_edges = np.arange(rows + 1, dtype=float)
|
|
35
|
+
x_edges = np.asarray(x_edges, dtype=float)
|
|
36
|
+
y_edges = np.asarray(y_edges, dtype=float)
|
|
37
|
+
|
|
38
|
+
if len(x_edges) != cols + 1:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"x_edges must have length {cols + 1} for {cols} columns, "
|
|
41
|
+
f"got {len(x_edges)}")
|
|
42
|
+
if len(y_edges) != rows + 1:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"y_edges must have length {rows + 1} for {rows} rows, "
|
|
45
|
+
f"got {len(y_edges)}")
|
|
46
|
+
|
|
47
|
+
# Resample to a regular pixel grid for display
|
|
48
|
+
resampled = _resample_mesh(data, x_edges, y_edges)
|
|
49
|
+
|
|
50
|
+
# Use cell centres to initialise the parent (axes will be replaced)
|
|
51
|
+
x_c = (x_edges[:-1] + x_edges[1:]) / 2.0
|
|
52
|
+
y_c = (y_edges[:-1] + y_edges[1:]) / 2.0
|
|
53
|
+
super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units)
|
|
54
|
+
|
|
55
|
+
# Override mesh-specific state
|
|
56
|
+
self._state["is_mesh"] = True
|
|
57
|
+
self._state["has_axes"] = True
|
|
58
|
+
# Store edges (not centres) so the JS renderer can place grid lines
|
|
59
|
+
self._state["x_axis"] = x_edges.tolist()
|
|
60
|
+
self._state["y_axis"] = y_edges.tolist()
|
|
61
|
+
# Mesh panels have no fixed pixel scale
|
|
62
|
+
self._state.pop("scale_x", None)
|
|
63
|
+
self._state.pop("scale_y", None)
|
|
64
|
+
|
|
65
|
+
# Restrict markers to circles + lines only
|
|
66
|
+
self.markers = MarkerRegistry(self._push_markers,
|
|
67
|
+
allowed=MarkerRegistry._KNOWN_MESH)
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Data
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
def set_data(self, data: np.ndarray,
|
|
73
|
+
x_edges=None, y_edges=None, units: str | None = None) -> None:
|
|
74
|
+
"""Replace the mesh data (and optionally the edge arrays)."""
|
|
75
|
+
data = np.asarray(data)
|
|
76
|
+
if data.ndim != 2:
|
|
77
|
+
raise ValueError(f"data must be 2-D, got {data.shape}")
|
|
78
|
+
rows, cols = data.shape
|
|
79
|
+
|
|
80
|
+
cur_xe = np.asarray(self._state["x_axis"], dtype=float)
|
|
81
|
+
cur_ye = np.asarray(self._state["y_axis"], dtype=float)
|
|
82
|
+
xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe
|
|
83
|
+
ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye
|
|
84
|
+
|
|
85
|
+
if len(xe) != cols + 1:
|
|
86
|
+
raise ValueError(f"x_edges must have length {cols + 1}")
|
|
87
|
+
if len(ye) != rows + 1:
|
|
88
|
+
raise ValueError(f"y_edges must have length {rows + 1}")
|
|
89
|
+
|
|
90
|
+
resampled = _resample_mesh(data, xe, ye)
|
|
91
|
+
img_u8, vmin, vmax = _normalize_image(resampled)
|
|
92
|
+
self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax
|
|
93
|
+
|
|
94
|
+
self._state.update({
|
|
95
|
+
"image_b64": self._encode_bytes(img_u8),
|
|
96
|
+
"image_width": cols,
|
|
97
|
+
"image_height": rows,
|
|
98
|
+
"x_axis": xe.tolist(),
|
|
99
|
+
"y_axis": ye.tolist(),
|
|
100
|
+
"display_min": vmin,
|
|
101
|
+
"display_max": vmax,
|
|
102
|
+
"raw_min": vmin,
|
|
103
|
+
"raw_max": vmax,
|
|
104
|
+
"colormap_data": _build_colormap_lut(self._state["colormap_name"]),
|
|
105
|
+
})
|
|
106
|
+
if units is not None:
|
|
107
|
+
self._state["units"] = units
|
|
108
|
+
self._push()
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
xe = self._state.get("x_axis", [])
|
|
112
|
+
ye = self._state.get("y_axis", [])
|
|
113
|
+
cols = max(0, len(xe) - 1)
|
|
114
|
+
rows = max(0, len(ye) - 1)
|
|
115
|
+
cmap = self._state.get("colormap_name", "?")
|
|
116
|
+
return f"PlotMesh({rows}×{cols}, cmap={cmap!r})"
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plot3d/_plot3d.py
|
|
3
|
+
=================
|
|
4
|
+
3-D surface / scatter / line plot panel.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from anyplotlib._base_plot import _BasePlot
|
|
14
|
+
from anyplotlib.callbacks import CallbackRegistry
|
|
15
|
+
from anyplotlib._utils import _arr_to_b64, _build_colormap_lut
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _triangulate_grid(rows: int, cols: int) -> list:
|
|
19
|
+
"""Return a flat list of [i0, i1, i2] triangle indices for an (rows×cols) grid."""
|
|
20
|
+
faces = []
|
|
21
|
+
for r in range(rows - 1):
|
|
22
|
+
for c in range(cols - 1):
|
|
23
|
+
i = r * cols + c
|
|
24
|
+
faces.append([i, i + 1, i + cols])
|
|
25
|
+
faces.append([i + 1, i + cols + 1, i + cols])
|
|
26
|
+
return faces
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _colors_to_u8(colors, n: int) -> np.ndarray:
|
|
30
|
+
"""Convert per-point colours to an (N, 3) uint8 RGB array.
|
|
31
|
+
|
|
32
|
+
Accepts a sequence of CSS hex strings (``"#rrggbb"``) or an (N, 3)
|
|
33
|
+
numeric array (floats 0–1, or 0–255).
|
|
34
|
+
"""
|
|
35
|
+
if isinstance(colors, (list, tuple)) and colors and isinstance(colors[0], str):
|
|
36
|
+
out = np.empty((len(colors), 3), dtype=np.uint8)
|
|
37
|
+
for i, c in enumerate(colors):
|
|
38
|
+
h = c.lstrip("#")
|
|
39
|
+
if len(h) != 6:
|
|
40
|
+
raise ValueError(f"colors[{i}]: expected '#rrggbb', got {c!r}")
|
|
41
|
+
out[i] = [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]
|
|
42
|
+
else:
|
|
43
|
+
arr = np.asarray(colors)
|
|
44
|
+
if arr.ndim != 2 or arr.shape[1] != 3:
|
|
45
|
+
raise ValueError(f"colors must be (N, 3) or a list of hex strings, "
|
|
46
|
+
f"got shape {arr.shape}")
|
|
47
|
+
if arr.dtype != np.uint8:
|
|
48
|
+
arr = arr.astype(np.float64)
|
|
49
|
+
if arr.max() <= 1.0:
|
|
50
|
+
arr = arr * 255.0
|
|
51
|
+
arr = np.clip(arr, 0, 255)
|
|
52
|
+
out = arr.astype(np.uint8)
|
|
53
|
+
if len(out) != n:
|
|
54
|
+
raise ValueError(f"got {len(out)} colors for {n} points")
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _geometry_state(geom_type: str, x, y, z, bounds=None) -> dict:
|
|
59
|
+
"""Validate x/y/z for *geom_type* and return the wire-format state fields.
|
|
60
|
+
|
|
61
|
+
Shared by ``Plot3D.__init__`` and ``Plot3D.set_data`` so geometry
|
|
62
|
+
validation and encoding live in exactly one place.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
bounds : ((xmin, xmax), (ymin, ymax), (zmin, zmax)) or None
|
|
67
|
+
Override the auto-computed data bounds. The JS renderer normalises
|
|
68
|
+
geometry into these bounds, so fixing them keeps the origin and
|
|
69
|
+
scale stable — essential for direction vectors on a unit sphere
|
|
70
|
+
(use ``((-1, 1),) * 3``) or when streaming data of varying extent.
|
|
71
|
+
"""
|
|
72
|
+
x = np.asarray(x, dtype=float)
|
|
73
|
+
y = np.asarray(y, dtype=float)
|
|
74
|
+
z = np.asarray(z, dtype=float)
|
|
75
|
+
|
|
76
|
+
if geom_type == "surface":
|
|
77
|
+
# Accept 2-D grid arrays (meshgrid style) or 1-D flat arrays
|
|
78
|
+
if x.ndim == 2 and y.ndim == 2 and z.ndim == 2:
|
|
79
|
+
rows, cols = z.shape
|
|
80
|
+
xf, yf, zf = x.ravel(), y.ravel(), z.ravel()
|
|
81
|
+
elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2:
|
|
82
|
+
rows, cols = z.shape
|
|
83
|
+
if len(x) != cols or len(y) != rows:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"For surface with 1-D x/y: x must have length ncols "
|
|
86
|
+
"and y must have length nrows")
|
|
87
|
+
XX, YY = np.meshgrid(x, y)
|
|
88
|
+
xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel()
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"Surface x/y/z must be 2-D grids of the same shape, "
|
|
92
|
+
"or 1-D x/y centre arrays with 2-D z.")
|
|
93
|
+
faces_list = _triangulate_grid(rows, cols)
|
|
94
|
+
else:
|
|
95
|
+
if x.ndim != 1 or y.ndim != 1 or z.ndim != 1:
|
|
96
|
+
raise ValueError(f"{geom_type} x, y, z must be 1-D arrays")
|
|
97
|
+
if not (len(x) == len(y) == len(z)):
|
|
98
|
+
raise ValueError("x, y, z must have the same length")
|
|
99
|
+
xf, yf, zf = x, y, z
|
|
100
|
+
faces_list = []
|
|
101
|
+
|
|
102
|
+
# Encode geometry as b64 (float32 saves 50 % wire size vs float64)
|
|
103
|
+
verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) # (N, 3)
|
|
104
|
+
zvals_arr = zf.astype(np.float32) # (N,)
|
|
105
|
+
faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3)
|
|
106
|
+
if faces_list else np.empty((0, 3), dtype=np.int32))
|
|
107
|
+
|
|
108
|
+
if bounds is not None:
|
|
109
|
+
(bx0, bx1), (by0, by1), (bz0, bz1) = bounds
|
|
110
|
+
data_bounds = {"xmin": float(bx0), "xmax": float(bx1),
|
|
111
|
+
"ymin": float(by0), "ymax": float(by1),
|
|
112
|
+
"zmin": float(bz0), "zmax": float(bz1)}
|
|
113
|
+
else:
|
|
114
|
+
data_bounds = {
|
|
115
|
+
"xmin": float(xf.min()), "xmax": float(xf.max()),
|
|
116
|
+
"ymin": float(yf.min()), "ymax": float(yf.max()),
|
|
117
|
+
"zmin": float(zf.min()), "zmax": float(zf.max()),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"vertices_b64": _arr_to_b64(verts_arr, np.float32),
|
|
122
|
+
"vertices_count": len(verts_arr),
|
|
123
|
+
"faces_b64": _arr_to_b64(faces_arr, np.int32),
|
|
124
|
+
"faces_count": len(faces_arr),
|
|
125
|
+
"z_values_b64": _arr_to_b64(zvals_arr, np.float32),
|
|
126
|
+
"data_bounds": data_bounds,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Plot3D(_BasePlot):
|
|
131
|
+
"""3-D plot panel.
|
|
132
|
+
|
|
133
|
+
Supports four geometry types:
|
|
134
|
+
|
|
135
|
+
* ``'surface'`` – triangulated surface, Z-coloured via colormap.
|
|
136
|
+
* ``'scatter'`` – point cloud; single colour or per-point ``colors``.
|
|
137
|
+
* ``'line'`` – connected line through 3-D points.
|
|
138
|
+
* ``'voxels'`` – shaded translucent cubes at the given centres;
|
|
139
|
+
voxels lying on a :class:`~anyplotlib.PlaneWidget` slice render more
|
|
140
|
+
opaque.
|
|
141
|
+
|
|
142
|
+
A single point can be emphasised with :meth:`set_highlight` (e.g. the
|
|
143
|
+
"current" orientation in an IPF explorer), and ``bounds=`` fixes the
|
|
144
|
+
axes extents for origin-true geometry such as unit vectors on a sphere.
|
|
145
|
+
Draggable :class:`~anyplotlib.PlaneWidget` slice selectors are added
|
|
146
|
+
with :meth:`add_widget`.
|
|
147
|
+
|
|
148
|
+
Created by :meth:`Axes.plot_surface`, :meth:`Axes.scatter3d`,
|
|
149
|
+
and :meth:`Axes.plot3d`.
|
|
150
|
+
|
|
151
|
+
Not an anywidget. Holds state in ``_state`` dict; every mutation
|
|
152
|
+
calls ``_push()`` which writes to the parent Figure's panel trait.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
#: Heavy, rarely-changing state keys routed to the separate geometry
|
|
156
|
+
#: channel — re-sent only when their content changes, so view updates
|
|
157
|
+
#: (highlight / camera / planes) never re-transmit them.
|
|
158
|
+
_GEOM_KEYS = frozenset({
|
|
159
|
+
"vertices_b64", "faces_b64", "z_values_b64", "point_colors_b64",
|
|
160
|
+
"colormap_data",
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
def __init__(self, geom_type: str,
|
|
164
|
+
x, y, z, *,
|
|
165
|
+
colormap: str = "viridis",
|
|
166
|
+
color: str = "#4fc3f7",
|
|
167
|
+
colors=None,
|
|
168
|
+
point_size: float = 4.0,
|
|
169
|
+
linewidth: float = 1.5,
|
|
170
|
+
x_label: str = "x",
|
|
171
|
+
y_label: str = "y",
|
|
172
|
+
z_label: str = "z",
|
|
173
|
+
azimuth: float = -60.0,
|
|
174
|
+
elevation: float = 30.0,
|
|
175
|
+
zoom: float = 1.0,
|
|
176
|
+
bounds=None,
|
|
177
|
+
voxel_size: float = 1.0,
|
|
178
|
+
alpha: float | None = None,
|
|
179
|
+
gpu: str | bool = "auto"):
|
|
180
|
+
self._id: str = ""
|
|
181
|
+
self._fig: object = None
|
|
182
|
+
self._gpu_active: bool = False
|
|
183
|
+
|
|
184
|
+
geom_type = geom_type.lower()
|
|
185
|
+
if geom_type not in ("surface", "scatter", "line", "voxels"):
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"geom_type must be 'surface', 'scatter', 'line', or 'voxels'")
|
|
188
|
+
|
|
189
|
+
cmap_lut = _build_colormap_lut(colormap)
|
|
190
|
+
self._bounds = bounds
|
|
191
|
+
geom = _geometry_state(geom_type, x, y, z, bounds=bounds)
|
|
192
|
+
|
|
193
|
+
point_colors_b64 = ""
|
|
194
|
+
if colors is not None:
|
|
195
|
+
if geom_type not in ("scatter", "voxels"):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"per-point colors are only supported for scatter/voxels")
|
|
198
|
+
point_colors_b64 = _arr_to_b64(
|
|
199
|
+
_colors_to_u8(colors, geom["vertices_count"]), np.uint8)
|
|
200
|
+
|
|
201
|
+
# The canvas budget is ~20k cubes; WebGPU (gpu="auto"/True) handles
|
|
202
|
+
# far more, so only warn when GPU is explicitly disabled.
|
|
203
|
+
gpu_off = gpu is False or str(gpu) == "off"
|
|
204
|
+
if geom_type == "voxels" and gpu_off and geom["vertices_count"] > 20_000:
|
|
205
|
+
import warnings
|
|
206
|
+
warnings.warn(
|
|
207
|
+
f"Rendering {geom['vertices_count']:,} voxels with gpu=False — "
|
|
208
|
+
f"the canvas renderer budgets roughly 3–6 µs per cube, so "
|
|
209
|
+
f"interactive frame rates need ≤ ~20k. Either allow WebGPU "
|
|
210
|
+
f"(gpu='auto'), downsample the volume (stride slicing) or "
|
|
211
|
+
f"extract boundary voxels, and show full-resolution data in "
|
|
212
|
+
f"linked 2-D slice panels instead.",
|
|
213
|
+
RuntimeWarning, stacklevel=3)
|
|
214
|
+
|
|
215
|
+
self._state: dict = {
|
|
216
|
+
"kind": "3d",
|
|
217
|
+
"geom_type": geom_type,
|
|
218
|
+
**geom,
|
|
219
|
+
"colormap_name": colormap,
|
|
220
|
+
"colormap_data": cmap_lut,
|
|
221
|
+
"color": color,
|
|
222
|
+
"point_colors_b64": point_colors_b64,
|
|
223
|
+
# Highlight point: {"x","y","z","color","size"} or None
|
|
224
|
+
"highlight": None,
|
|
225
|
+
# Reference sphere: {"radius","color","alpha","wireframe"} or None
|
|
226
|
+
"sphere": None,
|
|
227
|
+
# WebGPU activation policy: 'auto' (GPU above a point threshold),
|
|
228
|
+
# 'always', or 'off'. Falls back to Canvas2D whenever the GPU is
|
|
229
|
+
# unavailable; query the result via the gpu_active property.
|
|
230
|
+
"gpu_mode": ("always" if gpu is True else
|
|
231
|
+
"off" if gpu is False else str(gpu)),
|
|
232
|
+
# Voxel rendering (geom_type == 'voxels')
|
|
233
|
+
"voxel_size": float(voxel_size),
|
|
234
|
+
"voxel_alpha": float(alpha) if alpha is not None else 0.3,
|
|
235
|
+
"voxel_slice_alpha": 0.95,
|
|
236
|
+
# Interactive overlay widgets (PlaneWidget slice selectors)
|
|
237
|
+
"overlay_widgets": [],
|
|
238
|
+
"point_size": float(point_size),
|
|
239
|
+
"linewidth": float(linewidth),
|
|
240
|
+
"title": "",
|
|
241
|
+
"x_label": x_label,
|
|
242
|
+
"y_label": y_label,
|
|
243
|
+
"z_label": z_label,
|
|
244
|
+
"axis_visible": True,
|
|
245
|
+
"azimuth": float(azimuth),
|
|
246
|
+
"elevation": float(elevation),
|
|
247
|
+
"zoom": float(zoom),
|
|
248
|
+
"_default_azimuth": float(azimuth),
|
|
249
|
+
"_default_elevation": float(elevation),
|
|
250
|
+
"_default_zoom": float(zoom),
|
|
251
|
+
"_view_from_python": False,
|
|
252
|
+
"pointer_settled_ms": 0,
|
|
253
|
+
"pointer_settled_delta": 4,
|
|
254
|
+
}
|
|
255
|
+
self.callbacks = CallbackRegistry()
|
|
256
|
+
self._widgets: dict = {}
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
def _push(self) -> None:
|
|
260
|
+
if self._fig is None:
|
|
261
|
+
return
|
|
262
|
+
self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
|
|
263
|
+
self._fig._push(self._id)
|
|
264
|
+
|
|
265
|
+
def _push_fields(self, **fields) -> None:
|
|
266
|
+
"""Targeted update of small state fields without re-sending geometry.
|
|
267
|
+
|
|
268
|
+
For a heavy voxel/scatter panel, moving the highlight or camera would
|
|
269
|
+
otherwise re-transmit hundreds of KB of unchanged ``vertices_b64`` —
|
|
270
|
+
this ships only *fields* via the lightweight event channel.
|
|
271
|
+
"""
|
|
272
|
+
self._state.update(fields)
|
|
273
|
+
if self._fig is not None:
|
|
274
|
+
self._fig._push_panel_fields(self._id, fields)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------
|
|
277
|
+
# Interactive widgets (3-D)
|
|
278
|
+
# ------------------------------------------------------------------
|
|
279
|
+
def add_widget(self, kind: str, **kwargs):
|
|
280
|
+
"""Add an interactive overlay widget to this 3-D panel.
|
|
281
|
+
|
|
282
|
+
Currently supports ``"plane"`` — a draggable axis-aligned slice
|
|
283
|
+
plane (see :class:`~anyplotlib.PlaneWidget`)::
|
|
284
|
+
|
|
285
|
+
pw = vol.add_widget("plane", axis="z", position=24)
|
|
286
|
+
|
|
287
|
+
@pw.add_event_handler("pointer_move")
|
|
288
|
+
def on_drag(event):
|
|
289
|
+
resliced(int(round(pw.position)))
|
|
290
|
+
"""
|
|
291
|
+
if kind.lower() != "plane":
|
|
292
|
+
raise ValueError("3-D panels currently support only 'plane' widgets")
|
|
293
|
+
from anyplotlib.widgets import PlaneWidget
|
|
294
|
+
widget = PlaneWidget(lambda: None, **kwargs)
|
|
295
|
+
widget._push_fn = self._make_widget_push_fn(widget)
|
|
296
|
+
self._widgets[widget.id] = widget
|
|
297
|
+
self._push()
|
|
298
|
+
return widget
|
|
299
|
+
|
|
300
|
+
def remove_widget(self, wid) -> None:
|
|
301
|
+
"""Remove a widget by ID string or Widget instance."""
|
|
302
|
+
from anyplotlib.widgets import Widget
|
|
303
|
+
if isinstance(wid, Widget):
|
|
304
|
+
wid = wid.id
|
|
305
|
+
if wid not in self._widgets:
|
|
306
|
+
raise KeyError(wid)
|
|
307
|
+
del self._widgets[wid]
|
|
308
|
+
self._push()
|
|
309
|
+
|
|
310
|
+
def list_widgets(self) -> list:
|
|
311
|
+
"""Return a list of all active widget objects on this panel."""
|
|
312
|
+
return list(self._widgets.values())
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
def set_voxel_alpha(self, alpha: float, slice_alpha: float | None = None) -> None:
|
|
316
|
+
"""Set voxel transparency (geom_type ``'voxels'``).
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
alpha : float
|
|
321
|
+
Base opacity (0–1) for voxels not on any plane widget.
|
|
322
|
+
slice_alpha : float, optional
|
|
323
|
+
Opacity for voxels lying on a :class:`~anyplotlib.PlaneWidget`
|
|
324
|
+
slice. ``None`` keeps the current value (default 0.95).
|
|
325
|
+
"""
|
|
326
|
+
self._state["voxel_alpha"] = float(alpha)
|
|
327
|
+
if slice_alpha is not None:
|
|
328
|
+
self._state["voxel_slice_alpha"] = float(slice_alpha)
|
|
329
|
+
self._push()
|
|
330
|
+
|
|
331
|
+
def to_state_dict(self) -> dict:
|
|
332
|
+
# Always serialise the live overlay widgets, so *every* push path
|
|
333
|
+
# (full _push, targeted _push_fields, batched) carries the current
|
|
334
|
+
# plane positions. Without this, a view-only push (set_highlight /
|
|
335
|
+
# set_view) re-serialises a stale overlay_widgets snapshot and clobbers
|
|
336
|
+
# an in-progress plane drag in JS — the "snap-back" symptom.
|
|
337
|
+
d = dict(self._state)
|
|
338
|
+
if self._widgets:
|
|
339
|
+
d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
|
|
340
|
+
return d
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
@property
|
|
344
|
+
def gpu_active(self) -> bool:
|
|
345
|
+
"""``True`` if this panel is currently rendering geometry on the GPU.
|
|
346
|
+
|
|
347
|
+
Reflects the JS renderer's decision after the first frame: WebGPU is
|
|
348
|
+
used only when available and when the panel's ``gpu`` policy and
|
|
349
|
+
point count call for it. Always ``False`` on the Canvas2D fallback
|
|
350
|
+
path (no ``navigator.gpu``, no adapter, device lost, or ``gpu=False``).
|
|
351
|
+
"""
|
|
352
|
+
return self._gpu_active
|
|
353
|
+
|
|
354
|
+
def _set_gpu_active(self, active: bool) -> None:
|
|
355
|
+
"""Internal: called from the Figure's gpu_status event dispatch."""
|
|
356
|
+
self._gpu_active = bool(active)
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Display settings
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
def set_colormap(self, name: str) -> None:
|
|
362
|
+
"""Set the surface colormap (ignored for scatter/line)."""
|
|
363
|
+
self._state["colormap_name"] = name
|
|
364
|
+
self._state["colormap_data"] = _build_colormap_lut(name)
|
|
365
|
+
self._push()
|
|
366
|
+
|
|
367
|
+
def set_view(self, azimuth: float | None = None,
|
|
368
|
+
elevation: float | None = None) -> None:
|
|
369
|
+
"""Set the camera azimuth (°) and/or elevation (°).
|
|
370
|
+
|
|
371
|
+
Uses a targeted field push so re-aiming the camera never re-transmits
|
|
372
|
+
the panel's geometry — important for large voxel/scatter panels.
|
|
373
|
+
"""
|
|
374
|
+
fields = {"_view_from_python": True}
|
|
375
|
+
if azimuth is not None: fields["azimuth"] = float(azimuth)
|
|
376
|
+
if elevation is not None: fields["elevation"] = float(elevation)
|
|
377
|
+
self._push_fields(**fields)
|
|
378
|
+
self._state["_view_from_python"] = False
|
|
379
|
+
|
|
380
|
+
def set_zoom(self, zoom: float) -> None:
|
|
381
|
+
self._push_fields(zoom=float(zoom), _view_from_python=True)
|
|
382
|
+
self._state["_view_from_python"] = False
|
|
383
|
+
|
|
384
|
+
def reset_view(self) -> None:
|
|
385
|
+
"""Restore the camera to the angles/zoom set at construction time."""
|
|
386
|
+
with self._python_view_push():
|
|
387
|
+
self._state["azimuth"] = self._state["_default_azimuth"]
|
|
388
|
+
self._state["elevation"] = self._state["_default_elevation"]
|
|
389
|
+
self._state["zoom"] = self._state["_default_zoom"]
|
|
390
|
+
|
|
391
|
+
def set_xlabel(self, label: str, fontsize: float | None = None) -> None:
|
|
392
|
+
"""Set the x-axis label (mini-TeX allowed; default size 11 px)."""
|
|
393
|
+
self._set_label("x_label", label, "x_label_size", fontsize)
|
|
394
|
+
|
|
395
|
+
def set_ylabel(self, label: str, fontsize: float | None = None) -> None:
|
|
396
|
+
"""Set the y-axis label (mini-TeX allowed; default size 11 px)."""
|
|
397
|
+
self._set_label("y_label", label, "y_label_size", fontsize)
|
|
398
|
+
|
|
399
|
+
def set_zlabel(self, label: str, fontsize: float | None = None) -> None:
|
|
400
|
+
"""Set the z-axis label (mini-TeX allowed; default size 11 px)."""
|
|
401
|
+
self._set_label("z_label", label, "z_label_size", fontsize)
|
|
402
|
+
|
|
403
|
+
def get_xlim(self) -> tuple:
|
|
404
|
+
"""Return the data x range as ``(xmin, xmax)``."""
|
|
405
|
+
b = self._state["data_bounds"]
|
|
406
|
+
return (b["xmin"], b["xmax"])
|
|
407
|
+
|
|
408
|
+
def get_ylim(self) -> tuple:
|
|
409
|
+
"""Return the data y range as ``(ymin, ymax)``."""
|
|
410
|
+
b = self._state["data_bounds"]
|
|
411
|
+
return (b["ymin"], b["ymax"])
|
|
412
|
+
|
|
413
|
+
def get_zlim(self) -> tuple:
|
|
414
|
+
"""Return the data z range as ``(zmin, zmax)``."""
|
|
415
|
+
b = self._state["data_bounds"]
|
|
416
|
+
return (b["zmin"], b["zmax"])
|
|
417
|
+
|
|
418
|
+
def set_data(self, x, y, z) -> None:
|
|
419
|
+
"""Replace the geometry data (same shape rules as the constructor).
|
|
420
|
+
|
|
421
|
+
Bounds given at construction time (``bounds=``) are preserved.
|
|
422
|
+
"""
|
|
423
|
+
self._state.update(_geometry_state(
|
|
424
|
+
self._state["geom_type"], x, y, z, bounds=self._bounds))
|
|
425
|
+
self._push()
|
|
426
|
+
|
|
427
|
+
def set_point_colors(self, colors) -> None:
|
|
428
|
+
"""Set (or clear) per-point colours on a scatter or voxels panel.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
colors : list of "#rrggbb" strings, (N, 3) array, or None
|
|
433
|
+
One colour per point / voxel. Floats are interpreted as 0–1 (or
|
|
434
|
+
0–255 when the max exceeds 1). ``None`` reverts to the single
|
|
435
|
+
``color`` for all elements.
|
|
436
|
+
"""
|
|
437
|
+
if self._state["geom_type"] not in ("scatter", "voxels"):
|
|
438
|
+
raise ValueError(
|
|
439
|
+
"per-point colors are only supported for scatter/voxels")
|
|
440
|
+
if colors is None:
|
|
441
|
+
self._state["point_colors_b64"] = ""
|
|
442
|
+
else:
|
|
443
|
+
n = self._state["vertices_count"]
|
|
444
|
+
self._state["point_colors_b64"] = _arr_to_b64(
|
|
445
|
+
_colors_to_u8(colors, n), np.uint8)
|
|
446
|
+
self._push()
|
|
447
|
+
|
|
448
|
+
def set_highlight(self, x: float, y: float, z: float, *,
|
|
449
|
+
color: str = "#ff1744", size: float = 7.0) -> None:
|
|
450
|
+
"""Mark one 3-D point with an emphasised dot drawn on top.
|
|
451
|
+
|
|
452
|
+
The highlight is independent of the panel's geometry — use it to
|
|
453
|
+
flag the "current" item in a point cloud or on a surface (e.g. the
|
|
454
|
+
orientation under a crosshair in an IPF explorer). Points on the
|
|
455
|
+
far side of the data are drawn semi-transparent as a depth cue.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
x, y, z : float
|
|
460
|
+
Position in data coordinates.
|
|
461
|
+
color : str, optional
|
|
462
|
+
CSS colour of the dot and ring. Default ``"#ff1744"``.
|
|
463
|
+
size : float, optional
|
|
464
|
+
Dot radius in pixels. Default 7.
|
|
465
|
+
|
|
466
|
+
See Also
|
|
467
|
+
--------
|
|
468
|
+
clear_highlight : Remove the highlight.
|
|
469
|
+
"""
|
|
470
|
+
self._push_fields(highlight={
|
|
471
|
+
"x": float(x), "y": float(y), "z": float(z),
|
|
472
|
+
"color": str(color), "size": float(size),
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
def clear_highlight(self) -> None:
|
|
476
|
+
"""Remove the highlight point set by :meth:`set_highlight`."""
|
|
477
|
+
self._push_fields(highlight=None)
|
|
478
|
+
|
|
479
|
+
def set_sphere(self, radius: float = 1.0, *,
|
|
480
|
+
color: str = "#9e9e9e",
|
|
481
|
+
alpha: float = 0.15,
|
|
482
|
+
wireframe: bool = True) -> None:
|
|
483
|
+
"""Draw an origin-centred reference sphere behind the data.
|
|
484
|
+
|
|
485
|
+
Rendered as a shaded silhouette disk plus latitude/longitude
|
|
486
|
+
wireframe arcs (far-side arcs dimmed). Scatter points on the far
|
|
487
|
+
side of the sphere are also dimmed, so a point cloud on the sphere
|
|
488
|
+
reads with correct depth — ideal for inverse-pole-figure /
|
|
489
|
+
orientation plots of unit vectors.
|
|
490
|
+
|
|
491
|
+
Assumes origin-centred, isotropic bounds — pass
|
|
492
|
+
``bounds=((-r, r),) * 3`` to the constructor so the sphere's
|
|
493
|
+
screen silhouette is a true circle.
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
radius : float, optional
|
|
498
|
+
Sphere radius in data units. Default 1 (unit sphere).
|
|
499
|
+
color : str, optional
|
|
500
|
+
Base CSS colour of the shading and wireframe. Default grey.
|
|
501
|
+
alpha : float, optional
|
|
502
|
+
Opacity of the shaded silhouette (0–1). Default 0.15.
|
|
503
|
+
wireframe : bool, optional
|
|
504
|
+
Draw latitude/longitude arcs. Default True.
|
|
505
|
+
|
|
506
|
+
See Also
|
|
507
|
+
--------
|
|
508
|
+
clear_sphere : Remove the reference sphere.
|
|
509
|
+
"""
|
|
510
|
+
self._state["sphere"] = {
|
|
511
|
+
"radius": float(radius), "color": str(color),
|
|
512
|
+
"alpha": float(alpha), "wireframe": bool(wireframe),
|
|
513
|
+
}
|
|
514
|
+
self._push()
|
|
515
|
+
|
|
516
|
+
def clear_sphere(self) -> None:
|
|
517
|
+
"""Remove the reference sphere set by :meth:`set_sphere`."""
|
|
518
|
+
self._state["sphere"] = None
|
|
519
|
+
self._push()
|
|
520
|
+
|
|
521
|
+
def __repr__(self) -> str:
|
|
522
|
+
geom = self._state.get("geom_type", "?")
|
|
523
|
+
n = self._state.get("vertices_count", 0)
|
|
524
|
+
return f"Plot3D(geom={geom!r}, n_vertices={n})"
|