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 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) -> "Plot3D":
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"`` (``"-."``) . Default ``"solid"``.
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
@@ -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