anyplotlib 0.1.0__py3-none-any.whl → 0.2.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.
@@ -0,0 +1,78 @@
1
+ """
2
+ _binary_frame.py — a tiny length-prefixed binary wire format for shipping raw
3
+ image pixels to an Electron host without base64-in-JSON.
4
+
5
+ Used ONLY by the Electron transport (``_electron.py``); Jupyter / Pyodide /
6
+ standalone keep the base64-in-JSON path untouched. The format is a single
7
+ self-describing frame written to a byte stream (stdout), interleaved with the
8
+ existing text ``PLOTAPP:{json}\\n`` lines:
9
+
10
+ PLOTBIN:<header_len>:<payload_len>\\n<header_json_bytes><payload_bytes>
11
+
12
+ - ``PLOTBIN:`` is the ASCII marker (distinct from ``PLOTAPP:``) so a host reading
13
+ the raw stream can tell a binary frame from a text line.
14
+ - ``<header_len>`` / ``<payload_len>`` are ASCII decimal byte counts.
15
+ - ``<header_json_bytes>`` is UTF-8 JSON: ``{fig_id, key, ...metadata}`` (dims,
16
+ dtype, clim — everything the renderer needs EXCEPT the pixels).
17
+ - ``<payload_bytes>`` is the raw payload (e.g. the single-channel uint8 image),
18
+ exactly ``payload_len`` bytes, NO base64, NO JSON.
19
+
20
+ Both the Python producer and the JS host use this one definition, so the wire
21
+ format has a single source of truth and can be unit-tested in isolation (the
22
+ producer's ``encode_frame`` round-trips through ``decode_frame`` without any
23
+ Electron / stdout involved).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+
29
+ MARKER = b"PLOTBIN:"
30
+
31
+
32
+ def encode_frame(fig_id: str, key: str, header: dict, payload: bytes) -> bytes:
33
+ """Serialise one binary frame to bytes (marker + lengths + header + payload).
34
+
35
+ ``header`` is arbitrary JSON-able metadata; ``fig_id`` and ``key`` are merged
36
+ in (so the decoder always recovers them). ``payload`` is the raw bytes."""
37
+ hdr = dict(header or {})
38
+ hdr["fig_id"] = fig_id
39
+ hdr["key"] = key
40
+ hdr_bytes = json.dumps(hdr, default=str).encode("utf-8")
41
+ prefix = MARKER + f"{len(hdr_bytes)}:{len(payload)}\n".encode("ascii")
42
+ return prefix + hdr_bytes + bytes(payload)
43
+
44
+
45
+ def decode_frame(buf: bytes):
46
+ """Parse ONE complete frame from ``buf`` (as produced by ``encode_frame``).
47
+
48
+ Returns ``(header_dict, payload_bytes, n_consumed)`` where ``n_consumed`` is
49
+ the number of bytes the frame occupied. Raises ``ValueError`` if ``buf`` does
50
+ not begin with a complete frame. (A streaming host uses ``parse_prefix`` +
51
+ incremental reads instead; this is the whole-buffer convenience for tests.)"""
52
+ if not buf.startswith(MARKER):
53
+ raise ValueError("not a PLOTBIN frame")
54
+ nl = buf.find(b"\n")
55
+ if nl < 0:
56
+ raise ValueError("incomplete PLOTBIN prefix (no newline)")
57
+ prefix = buf[len(MARKER):nl].decode("ascii")
58
+ hdr_len_s, pay_len_s = prefix.split(":")
59
+ hdr_len, pay_len = int(hdr_len_s), int(pay_len_s)
60
+ start = nl + 1
61
+ end_hdr = start + hdr_len
62
+ end_pay = end_hdr + pay_len
63
+ if len(buf) < end_pay:
64
+ raise ValueError("incomplete PLOTBIN frame (truncated body)")
65
+ header = json.loads(buf[start:end_hdr].decode("utf-8"))
66
+ payload = buf[end_hdr:end_pay]
67
+ return header, payload, end_pay
68
+
69
+
70
+ def parse_prefix(line: bytes):
71
+ """Parse a ``PLOTBIN:<hlen>:<plen>`` prefix line (the bytes up to, not
72
+ including, the newline). Returns ``(header_len, payload_len)``. For a
73
+ streaming host that reads the prefix line, then exactly ``header_len +
74
+ payload_len`` more bytes."""
75
+ if not line.startswith(MARKER):
76
+ raise ValueError("not a PLOTBIN prefix")
77
+ hdr_len_s, pay_len_s = line[len(MARKER):].decode("ascii").split(":")
78
+ return int(hdr_len_s), int(pay_len_s)
anyplotlib/_electron.py CHANGED
@@ -9,12 +9,109 @@ send interaction events back to Python.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import base64
12
13
  import json
14
+ import os
13
15
  import sys
14
16
  import uuid
17
+ import zlib
18
+
19
+ from anyplotlib._binary_frame import encode_frame
15
20
 
16
21
  _figures: dict[str, object] = {} # fig_id -> Figure
17
22
 
23
+ # Opt-in binary transport for large image pixels (Electron host only). When on,
24
+ # the big ``image_b64`` / ``overlay_mask_b64`` pixel traits are shipped as a raw
25
+ # PLOTBIN binary frame (no base64, no JSON for the pixels) instead of a giant
26
+ # base64 string in the state_update JSON — the base64 encode + 5.6MB JSON line +
27
+ # JS atob cost ~200 ms/frame on a 4k movie. Off by default (base64 path) so this
28
+ # is zero-risk until the host opts in; SpyDE sets APL_BINARY_TRANSPORT=1.
29
+ _BINARY_TRANSPORT = os.environ.get("APL_BINARY_TRANSPORT") == "1"
30
+ # State keys whose value is a base64 pixel string worth sending as binary.
31
+ # detail_b64 is the zoom DETAIL TILE — it MUST be here too, else with binary
32
+ # transport on its _encode_pixels "\x00bin:<checksum>" token stays in the geom JSON
33
+ # and the real bytes are never shipped, so the renderer can't decode it and the crisp
34
+ # zoom tile never displays (you only ever see the downsampled overview base).
35
+ _BINARY_KEYS = frozenset({"image_b64", "overlay_mask_b64", "detail_b64"})
36
+
37
+
38
+ def _route_change(fig_id: str, name: str, value) -> None:
39
+ """Forward ONE trait change to the host — as a raw PLOTBIN binary frame for a
40
+ large image pixel trait (when binary transport is enabled), else as a
41
+ base64-in-JSON ``state_update``. Extracted from the observer so it's unit-
42
+ testable without a real Figure/stdout."""
43
+ # Binary fast path: ship large image pixels as a raw PLOTBIN frame (the
44
+ # renderer rebuilds the bytes from the ArrayBuffer — no atob).
45
+ #
46
+ # The pixel keys ride INSIDE a panel geometry trait — ``panel_<pid>_geom`` is
47
+ # a JSON string ``{"image_b64": <token|b64>, "colormap_data": …, …}`` (see
48
+ # Figure._push). Two producer modes:
49
+ # • RAW (Plot2D.set_data with binary transport): the geom carries a tiny
50
+ # ``"\x00bin:<n>"`` change-token, and the real uint8 bytes sit in the
51
+ # Figure's ``_raw_pixels`` side-table. We emit those bytes directly —
52
+ # NO json-parse of a megabyte string, NO base64 decode. This is the hot
53
+ # scrub path (a 4k movie frame every tick).
54
+ # • BASE64 (initial __init__ frame / non-set_data pushes / robustness): the
55
+ # geom carries a real base64 string, which we decode once here.
56
+ # Either way the REST of the geom (LUT + flags, small) goes as a normal JSON
57
+ # state_update with the pixels removed. A bare pixel trait is handled too.
58
+ if _BINARY_TRANSPORT and isinstance(value, str) and value:
59
+ if name.startswith("panel_") and name.endswith("_geom"):
60
+ try:
61
+ geom = json.loads(value)
62
+ except Exception:
63
+ geom = None
64
+ if isinstance(geom, dict) and any(k in geom for k in _BINARY_KEYS):
65
+ panel_id = name[len("panel_"):-len("_geom")]
66
+ fig = _figures.get(fig_id)
67
+ raw_tbl = getattr(fig, "_raw_pixels", None)
68
+ sent_binary = False
69
+ for k in list(geom.keys()):
70
+ if k not in _BINARY_KEYS or not isinstance(geom[k], str) or not geom[k]:
71
+ continue
72
+ raw = None
73
+ if geom[k].startswith("\x00bin:") and raw_tbl is not None:
74
+ # RAW fast path: bytes are in the side-table (no decode).
75
+ raw = raw_tbl.get((panel_id, k))
76
+ if raw is None:
77
+ # BASE64 path (initial frame / fallback): decode once.
78
+ try:
79
+ raw = base64.b64decode(geom[k])
80
+ except Exception:
81
+ continue # leave it in geom → goes via JSON below
82
+ # Replace the pixel value with a small content TOKEN instead of
83
+ # popping the key: the renderer's caches (blitCache, GPU
84
+ # texture, maskCache) key their "unchanged → skip re-upload"
85
+ # checks on this string. Popping it left st.<key> empty in JS,
86
+ # whose fallback fingerprint samples only 4 bytes of the
87
+ # buffer — two frames differing anywhere else COLLIDED and the
88
+ # skip froze the display on the old frame (found: a movie
89
+ # overview refresh that only moved a mid-frame feature).
90
+ tok = geom[k]
91
+ if not tok.startswith("\x00bin:"):
92
+ tok = f"\x00bin:{zlib.adler32(raw) & 0xFFFFFFFF}"
93
+ geom[k] = tok
94
+ # key carries which pixel field; geom= the trait name so the
95
+ # renderer routes it back into the right panel state.
96
+ emit_binary(fig_id, k,
97
+ {"nbytes": len(raw), "geom": name}, raw)
98
+ sent_binary = True
99
+ if sent_binary:
100
+ # Send the slimmed geom (LUT etc., pixels removed) as JSON.
101
+ emit({"type": "state_update", "fig_id": fig_id, "key": name,
102
+ "value": json.dumps(geom)})
103
+ return
104
+ elif name in _BINARY_KEYS:
105
+ try:
106
+ raw = base64.b64decode(value)
107
+ emit_binary(fig_id, name, {"nbytes": len(raw)}, raw)
108
+ return
109
+ except Exception:
110
+ pass # fall back to the base64-in-JSON path below
111
+ if isinstance(value, (bytes, bytearray)):
112
+ value = {"buffer": base64.b64encode(value).decode()}
113
+ emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
114
+
18
115
 
19
116
  def register(fig) -> str:
20
117
  """Register *fig* for bidirectional state sync and return its fig_id."""
@@ -22,12 +119,7 @@ def register(fig) -> str:
22
119
  _figures[fig_id] = fig
23
120
 
24
121
  def _on_change(change):
25
- name = change["name"]
26
- value = change["new"]
27
- if isinstance(value, (bytes, bytearray)):
28
- import base64
29
- value = {"buffer": base64.b64encode(value).decode()}
30
- emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
122
+ _route_change(fig_id, change["name"], change["new"])
31
123
 
32
124
  for name in fig.traits(sync=True):
33
125
  if not name.startswith("_"):
@@ -72,3 +164,25 @@ def dispatch_event(fig_id: str, event_json: str) -> None:
72
164
  def emit(obj: dict) -> None:
73
165
  sys.stdout.write(f"PLOTAPP:{json.dumps(obj, default=str)}\n")
74
166
  sys.stdout.flush()
167
+
168
+
169
+ def emit_binary(fig_id: str, key: str, header: dict, payload: bytes) -> None:
170
+ """Write a raw PLOTBIN binary frame to stdout (pixels, no base64/JSON).
171
+
172
+ Goes to the RAW ``sys.stdout.buffer`` so the payload is bytes on the wire; the
173
+ Electron host demuxes ``PLOTBIN:`` frames from the same stdout stream that
174
+ carries the text ``PLOTAPP:`` lines. Flushed so the host sees it promptly."""
175
+ frame = encode_frame(fig_id, key, header, payload)
176
+ buf = getattr(sys.stdout, "buffer", None)
177
+ if buf is None: # no binary stdout (unusual) → base64 fallback
178
+ emit({"type": "state_update", "fig_id": fig_id, "key": key,
179
+ "value": base64.b64encode(payload).decode()})
180
+ return
181
+ # NOTE: a host that redirects sys.stdout (e.g. SpyDE points sys.stdout at
182
+ # stderr to keep the protocol channel clean) MUST patch emit_binary to write
183
+ # to its real protocol stdout — otherwise these bytes go to the wrong stream.
184
+ # Flush the TEXT stream first so any pending PLOTAPP: line is on the wire
185
+ # before this binary frame — the host demuxes a single ordered byte stream.
186
+ sys.stdout.flush()
187
+ buf.write(frame)
188
+ buf.flush()
anyplotlib/_repr_utils.py CHANGED
@@ -207,11 +207,49 @@ import(blobUrl).then(mod => {{
207
207
 
208
208
  // ── Inbound state updates from parent-page Pyodide ───────────────────────────
209
209
  window.addEventListener('message', (e) => {{
210
- if (!e.data || e.data.type !== 'awi_state') return;
211
- _fromParent = true;
212
- model.set(e.data.key, e.data.value);
213
- model.save_changes();
214
- _fromParent = false;
210
+ if (!e.data) return;
211
+ if (e.data.type === 'awi_state') {{
212
+ _fromParent = true;
213
+ const _t0 = performance.now();
214
+ model.set(e.data.key, e.data.value);
215
+ model.save_changes();
216
+ if (e.data.key === 'image_b64') {{
217
+ try {{
218
+ globalThis.__apl_paint_ms = performance.now() - _t0;
219
+ globalThis.__apl_paint_n = (globalThis.__apl_paint_n || 0) + 1;
220
+ globalThis.__apl_paint_kind = 'base64';
221
+ }} catch (_) {{}}
222
+ }}
223
+ _fromParent = false;
224
+ return;
225
+ }}
226
+ // Binary image frame (raw pixels, no base64). Set the bytes under a parallel
227
+ // `<key>_bytes` trait (a Uint8Array) that the draw path uses directly — no atob.
228
+ // We also bump the base64 key to a short token so listeners keyed on it still
229
+ // fire, without carrying the (large) base64 string.
230
+ if (e.data.type === 'awi_state_binary') {{
231
+ _fromParent = true;
232
+ const _t0 = performance.now();
233
+ const hdr = e.data.header || {{}};
234
+ const bytes = e.data.buffer instanceof Uint8Array
235
+ ? e.data.buffer : new Uint8Array(e.data.buffer);
236
+ // Stash the raw bytes in a global side-table (the anywidget model does NOT
237
+ // round-trip a Uint8Array set on an ad-hoc trait — model.get returns
238
+ // undefined), then bump a small TOKEN trait to fire the ESM's change: event,
239
+ // which reads the bytes from the side-table. Key = "<geom_trait>::<pixelKey>".
240
+ const slot = (hdr.geom ? hdr.geom : 'fig') + '::' + e.data.key;
241
+ (globalThis.__apl_pixbytes || (globalThis.__apl_pixbytes = {{}}))[slot] = bytes;
242
+ model.set(slot, (bytes ? bytes.length : 0) + ':' + (globalThis.__apl_paint_n || 0));
243
+ model.save_changes(); // triggers the paint synchronously
244
+ // Paint-latency telemetry (message receipt → draw done) for the binary path.
245
+ try {{
246
+ globalThis.__apl_paint_ms = performance.now() - _t0;
247
+ globalThis.__apl_paint_n = (globalThis.__apl_paint_n || 0) + 1;
248
+ globalThis.__apl_paint_kind = 'binary';
249
+ }} catch (_) {{}}
250
+ _fromParent = false;
251
+ return;
252
+ }}
215
253
  }});
216
254
  </script>
217
255
  </body>
anyplotlib/_utils.py CHANGED
@@ -6,6 +6,8 @@ Shared low-level utilities used across plot subpackages.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import functools
10
+
9
11
  import numpy as np
10
12
 
11
13
  _LINESTYLE_ALIASES: dict[str, str] = {
@@ -79,20 +81,48 @@ def _to_rgba_u8(data: np.ndarray) -> np.ndarray:
79
81
  return np.ascontiguousarray(data)
80
82
 
81
83
 
82
- def _normalize_image(data: np.ndarray):
83
- """Normalise data to uint8, returning (img_u8, vmin, vmax)."""
84
- img = data.astype(np.float64, copy=False)
85
- vmin = float(np.nanmin(img))
86
- vmax = float(np.nanmax(img))
87
- if vmax > vmin:
88
- buf = np.empty_like(img)
89
- np.subtract(img, vmin, out=buf)
90
- np.divide(buf, vmax - vmin, out=buf)
91
- np.multiply(buf, 255.0, out=buf)
92
- img_u8 = buf.astype(np.uint8)
84
+ def _normalize_image(data: np.ndarray, clim: "tuple | None" = None):
85
+ """Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are
86
+ the QUANTISATION endpoints the 8-bit codes span (the caller stores them as
87
+ raw_min/raw_max so the renderer can reconstruct each code's value).
88
+
89
+ When ``clim=(lo, hi)`` is given, quantise over THAT range instead of the raw
90
+ data min/max, clipping outliers to 0/255. This is what makes a single hot pixel
91
+ (or the saturating zero-order beam) NOT crush the real signal: without it the
92
+ 256 codes are stretched across the full data range, so a 60000-count hot pixel
93
+ over a 0-4000 signal leaves the signal only ~12 distinct codes (≈3.5-bit) before
94
+ it ever reaches the display window; clipping to the robust display range instead
95
+ gives the signal the full 256 codes and just saturates the outlier. When no
96
+ clim is given the behaviour is unchanged (quantise over raw min/max).
97
+
98
+ Uses float32 for the rescale when the data range is small enough that float32's
99
+ ~7 significant digits don't lose low bits (the common case: uint8/uint16 movie
100
+ frames), else float64. float32 halves the memory bandwidth of the subtract/
101
+ divide/multiply passes (~60ms → ~27ms on a 2048² frame — this runs every movie
102
+ frame) with a byte-identical result. vmin/vmax are always returned as full-
103
+ precision Python floats."""
104
+ if clim is not None:
105
+ vmin, vmax = float(clim[0]), float(clim[1])
93
106
  else:
94
- img_u8 = np.zeros(data.shape, dtype=np.uint8)
95
- return img_u8, vmin, vmax
107
+ vmin = float(np.nanmin(data))
108
+ vmax = float(np.nanmax(data))
109
+ if vmax <= vmin:
110
+ return np.zeros(data.shape, dtype=np.uint8), vmin, vmax
111
+ # float32 is safe when |values| and the span are within its ~1.6e7 exact-integer
112
+ # range with headroom; a huge-magnitude float source keeps float64 to avoid
113
+ # banding. (subtract of two near-equal large float32s loses precision.)
114
+ span = vmax - vmin
115
+ use32 = (max(abs(vmin), abs(vmax)) < 1e6) and (span > 1e-6)
116
+ dt = np.float32 if use32 else np.float64
117
+ img = data.astype(dt, copy=False)
118
+ buf = np.empty_like(img)
119
+ np.subtract(img, dt(vmin), out=buf)
120
+ np.divide(buf, dt(span), out=buf)
121
+ np.multiply(buf, dt(255.0), out=buf)
122
+ # Clip into [0, 255] BEFORE the uint8 cast so out-of-clim values saturate
123
+ # instead of wrapping (a hot pixel above vmax would otherwise overflow uint8).
124
+ np.clip(buf, 0.0, 255.0, out=buf)
125
+ return buf.astype(np.uint8), vmin, vmax
96
126
 
97
127
 
98
128
  # Mapping from common matplotlib colormap names to their nearest colorcet
@@ -115,9 +145,15 @@ _CMAP_ALIASES: dict[str, str] = {
115
145
  }
116
146
 
117
147
 
148
+ @functools.lru_cache(maxsize=64)
118
149
  def _build_colormap_lut(name: str) -> list:
119
150
  """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap.
120
151
 
152
+ CACHED (lru_cache) — it's a pure function of ``name`` but costs ~100 ms
153
+ (colorcet lookup + 256 hex parses), and it was being rebuilt on EVERY frame of
154
+ a movie scrub even though the colormap doesn't change. The returned list is
155
+ treated as read-only by callers (they JSON-serialise it); do NOT mutate it.
156
+
121
157
  Priority order:
122
158
 
123
159
  1. **colorcet** — preferred; common matplotlib names are remapped via
anyplotlib/axes/_axes.py CHANGED
@@ -33,13 +33,18 @@ class Axes:
33
33
  self._plot: "Plot1D | Plot2D | None" = None
34
34
 
35
35
  # ------------------------------------------------------------------
36
- def imshow(self, data: np.ndarray,
36
+ def imshow(self, data,
37
37
  axes: list | None = None,
38
38
  units: str = "px",
39
39
  cmap: str | None = None,
40
40
  vmin: float | None = None,
41
41
  vmax: float | None = None,
42
- origin: str = "upper") -> "Plot2D":
42
+ origin: str = "upper",
43
+ gpu: "str | bool" = "auto",
44
+ tile: "str | bool" = "auto",
45
+ integration_method: str = "mean",
46
+ overview_method: str = "mean",
47
+ tile_backend=None) -> "Plot2D":
43
48
  """Attach a 2-D image to this axes cell.
44
49
 
45
50
  Parameters
@@ -74,7 +79,10 @@ class Axes:
74
79
  x_axis = axes[0] if axes and len(axes) > 0 else None
75
80
  y_axis = axes[1] if axes and len(axes) > 1 else None
76
81
  plot = Plot2D(data, x_axis=x_axis, y_axis=y_axis, units=units,
77
- cmap=cmap, vmin=vmin, vmax=vmax, origin=origin)
82
+ cmap=cmap, vmin=vmin, vmax=vmax, origin=origin, gpu=gpu,
83
+ tile=tile, integration_method=integration_method,
84
+ overview_method=overview_method,
85
+ tile_backend=tile_backend)
78
86
  self._attach(plot)
79
87
  return plot
80
88
 
anyplotlib/callbacks.py CHANGED
@@ -24,7 +24,7 @@ from typing import Any, Callable
24
24
  VALID_EVENT_TYPES = frozenset({
25
25
  "pointer_down", "pointer_up", "pointer_move", "pointer_settled",
26
26
  "pointer_enter", "pointer_leave", "double_click", "wheel",
27
- "key_down", "key_up", "close", "*",
27
+ "key_down", "key_up", "close", "view_changed", "*",
28
28
  })
29
29
 
30
30
 
@@ -79,6 +79,14 @@ class Event:
79
79
  # Wheel
80
80
  dx: float | None = None
81
81
  dy: float | None = None
82
+ # View (view_changed events — the current zoom/pan viewport)
83
+ zoom: float | None = None
84
+ center_x: float | None = None
85
+ center_y: float | None = None
86
+ image_width: int | None = None
87
+ image_height: int | None = None
88
+ display_width: int | None = None # panel device px (JS) → tile output resolution
89
+ display_height: int | None = None
82
90
  # Key
83
91
  key: str | None = None
84
92
  last_widget_id: str | None = None
@@ -24,6 +24,16 @@ _HERE = pathlib.Path(__file__).parent.parent
24
24
  _ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8")
25
25
 
26
26
 
27
+ def _binary_wire() -> bool:
28
+ """True when the Electron binary pixel transport is live, so a pixel
29
+ change-token in the panel state should stay a token (the real bytes ride
30
+ PLOTBIN) rather than being resolved to inline base64. Read fresh from the
31
+ environment — the same gate ``Plot2D._encode_pixels`` uses — so producer and
32
+ serializer never disagree."""
33
+ import os
34
+ return os.environ.get("APL_BINARY_TRANSPORT") == "1"
35
+
36
+
27
37
  class Figure(anywidget.AnyWidget):
28
38
  """Multi-panel interactive figure widget.
29
39
 
@@ -124,6 +134,14 @@ class Figure(anywidget.AnyWidget):
124
134
  # when its values genuinely change.
125
135
  self._geom_rev: dict = {}
126
136
  self._geom_last: dict = {}
137
+ # Raw pixel side-table for the Electron BINARY transport: maps
138
+ # ``(panel_id, pixel_key)`` → the raw ``bytes`` of the current frame.
139
+ # When binary transport is active, ``Plot2D.set_data`` stashes the
140
+ # uint8 image bytes here (and puts a tiny change-token in the geom
141
+ # field instead of a 5.6 MB base64 string), so ``_electron._route_change``
142
+ # ships them straight to a PLOTBIN frame with NO base64 encode/decode
143
+ # and NO megabyte JSON. Empty (and ignored) on every non-Electron path.
144
+ self._raw_pixels: dict = {}
127
145
  with self.hold_trait_notifications():
128
146
  self.fig_width = figsize[0]
129
147
  self.fig_height = figsize[1]
@@ -279,6 +297,14 @@ class Figure(anywidget.AnyWidget):
279
297
  state = plot.to_state_dict()
280
298
  geom_keys = getattr(plot, "_GEOM_KEYS", None)
281
299
  gname = f"panel_{panel_id}_geom"
300
+ # ``state`` may carry a ``"\x00bin:…"`` pixel change-token (binary
301
+ # transport: the real bytes ride PLOTBIN via ``_route_change``). Keep
302
+ # the token when that channel is live; otherwise — a standalone /
303
+ # save_html / Jupyter figure with no binary channel — materialise the
304
+ # real base64 inline so the pixels actually travel. ``_binary_wire``
305
+ # matches the producer's gate (``Plot2D._encode_pixels``).
306
+ if geom_keys and not _binary_wire() and hasattr(plot, "resolve_pixel_tokens"):
307
+ plot.resolve_pixel_tokens(state)
282
308
  if geom_keys and self.has_trait(gname):
283
309
  # Split heavy geometry into its own channel. Detect change by
284
310
  # comparing the geom values themselves (the b64 strings / LUT
@@ -501,8 +527,21 @@ class Figure(anywidget.AnyWidget):
501
527
 
502
528
  plot = self._plots_map.get(panel_id)
503
529
  if plot is None:
530
+ if event_type == "view_changed":
531
+ import logging
532
+ # WARNING so SpyDE's log stream forwards it (it drops non-spyde.* INFO).
533
+ logging.getLogger("anyplotlib.tile").warning(
534
+ "[TILE] view_changed for UNKNOWN panel %r (have %r) — dropped",
535
+ panel_id, list(self._plots_map.keys()))
504
536
  return
505
537
 
538
+ if event_type == "view_changed":
539
+ import logging
540
+ logging.getLogger("anyplotlib.tile").warning(
541
+ "[TILE] view_changed RECEIVED panel=%s zoom=%s center=(%s,%s) disp=(%s,%s)",
542
+ panel_id, msg.get("zoom"), msg.get("center_x"), msg.get("center_y"),
543
+ msg.get("display_width"), msg.get("display_height"))
544
+
506
545
  # GPU activation status echo (WebGPU path) — not a user event.
507
546
  if event_type == "gpu_status":
508
547
  if hasattr(plot, "_set_gpu_active"):
@@ -537,6 +576,13 @@ class Figure(anywidget.AnyWidget):
537
576
  group_index=msg.get("group_index"),
538
577
  dx=msg.get("dx"),
539
578
  dy=msg.get("dy"),
579
+ zoom=msg.get("zoom"),
580
+ center_x=msg.get("center_x"),
581
+ center_y=msg.get("center_y"),
582
+ image_width=msg.get("image_width"),
583
+ image_height=msg.get("image_height"),
584
+ display_width=msg.get("display_width"),
585
+ display_height=msg.get("display_height"),
540
586
  key=msg.get("key"),
541
587
  last_widget_id=msg.get("last_widget_id"),
542
588
  )