anyplotlib 0.1.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anyplotlib/__init__.py +53 -0
- anyplotlib/_base_plot.py +229 -0
- anyplotlib/_electron.py +74 -0
- anyplotlib/_repr_utils.py +345 -0
- anyplotlib/_utils.py +194 -0
- anyplotlib/axes/__init__.py +6 -0
- anyplotlib/axes/_axes.py +510 -0
- anyplotlib/axes/_inset_axes.py +126 -0
- anyplotlib/callbacks.py +328 -0
- anyplotlib/embed.py +194 -0
- anyplotlib/figure/__init__.py +7 -0
- anyplotlib/figure/_figure.py +633 -0
- anyplotlib/figure/_gridspec.py +99 -0
- anyplotlib/figure/_subplots.py +100 -0
- anyplotlib/figure_esm.js +6011 -0
- anyplotlib/markers.py +704 -0
- anyplotlib/plot1d/__init__.py +6 -0
- anyplotlib/plot1d/_plot1d.py +1376 -0
- anyplotlib/plot1d/_plotbar.py +436 -0
- anyplotlib/plot2d/__init__.py +5 -0
- anyplotlib/plot2d/_plot2d.py +726 -0
- anyplotlib/plot2d/_plotmesh.py +116 -0
- anyplotlib/plot3d/__init__.py +4 -0
- anyplotlib/plot3d/_plot3d.py +524 -0
- anyplotlib/plotxy/__init__.py +4 -0
- anyplotlib/plotxy/_plotxy.py +273 -0
- anyplotlib/sphinx_anywidget/__init__.py +177 -0
- anyplotlib/sphinx_anywidget/_directive.py +245 -0
- anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
- anyplotlib/sphinx_anywidget/_scraper.py +390 -0
- anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
- anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
- anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
- anyplotlib/widgets/__init__.py +18 -0
- anyplotlib/widgets/_base.py +218 -0
- anyplotlib/widgets/_widgets1d.py +109 -0
- anyplotlib/widgets/_widgets2d.py +141 -0
- anyplotlib/widgets/_widgets3d.py +50 -0
- anyplotlib-0.1.0b1.dist-info/METADATA +160 -0
- anyplotlib-0.1.0b1.dist-info/RECORD +42 -0
- anyplotlib-0.1.0b1.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0b1.dist-info/licenses/LICENSE +21 -0
anyplotlib/callbacks.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
callbacks.py
|
|
3
|
+
============
|
|
4
|
+
|
|
5
|
+
Event system used by all plot objects and widgets.
|
|
6
|
+
|
|
7
|
+
:class:`Event`
|
|
8
|
+
Flat dataclass carrying all event fields as typed top-level attributes.
|
|
9
|
+
|
|
10
|
+
:class:`CallbackRegistry`
|
|
11
|
+
Per-object handler store. (Full implementation added in Tasks 2-3.)
|
|
12
|
+
|
|
13
|
+
:class:`_EventMixin`
|
|
14
|
+
Mixin added to every plot class and widget. (Added in Task 4.)
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from collections import defaultdict, deque
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
VALID_EVENT_TYPES = frozenset({
|
|
25
|
+
"pointer_down", "pointer_up", "pointer_move", "pointer_settled",
|
|
26
|
+
"pointer_enter", "pointer_leave", "double_click", "wheel",
|
|
27
|
+
"key_down", "key_up", "close", "*",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Event:
|
|
33
|
+
"""A single interactive event with all payload fields as typed attributes.
|
|
34
|
+
|
|
35
|
+
Universal fields (every event):
|
|
36
|
+
event_type, source, time_stamp, modifiers
|
|
37
|
+
|
|
38
|
+
Pointer fields (pointer_* and double_click events):
|
|
39
|
+
x, y — canvas coordinates within the panel (float pixels)
|
|
40
|
+
button — 0=left 1=middle 2=right; None on move/enter/leave/settled
|
|
41
|
+
buttons — bitmask of currently held buttons
|
|
42
|
+
xdata, ydata — data-space coordinates (None for Plot3D)
|
|
43
|
+
ray — Plot3D only: {"origin": [...], "direction": [...]}
|
|
44
|
+
line_id — Plot1D only: set when pointer is over a line
|
|
45
|
+
dwell_ms — pointer_settled only: actual dwell time
|
|
46
|
+
|
|
47
|
+
PlotBar extra fields (pointer_down only):
|
|
48
|
+
bar_index, value, x_label, group_index
|
|
49
|
+
|
|
50
|
+
Wheel fields:
|
|
51
|
+
dx, dy — scroll deltas
|
|
52
|
+
|
|
53
|
+
Key fields:
|
|
54
|
+
key — key name e.g. "q", "Enter", "ArrowLeft"
|
|
55
|
+
last_widget_id — id of the last widget the user clicked, or None
|
|
56
|
+
|
|
57
|
+
Propagation:
|
|
58
|
+
stop_propagation — set True inside a handler to halt remaining handlers
|
|
59
|
+
"""
|
|
60
|
+
event_type: str
|
|
61
|
+
source: Any = None
|
|
62
|
+
time_stamp: float = field(default_factory=time.perf_counter)
|
|
63
|
+
modifiers: list[str] = field(default_factory=list)
|
|
64
|
+
# Pointer
|
|
65
|
+
x: float | None = None
|
|
66
|
+
y: float | None = None
|
|
67
|
+
button: int | None = None
|
|
68
|
+
buttons: int = 0
|
|
69
|
+
xdata: float | None = None
|
|
70
|
+
ydata: float | None = None
|
|
71
|
+
ray: dict | None = None
|
|
72
|
+
line_id: str | None = None
|
|
73
|
+
dwell_ms: float | None = None
|
|
74
|
+
# PlotBar
|
|
75
|
+
bar_index: int | None = None
|
|
76
|
+
value: float | None = None
|
|
77
|
+
x_label: str | None = None
|
|
78
|
+
group_index: int | None = None
|
|
79
|
+
# Wheel
|
|
80
|
+
dx: float | None = None
|
|
81
|
+
dy: float | None = None
|
|
82
|
+
# Key
|
|
83
|
+
key: str | None = None
|
|
84
|
+
last_widget_id: str | None = None
|
|
85
|
+
# Propagation (not repr'd)
|
|
86
|
+
stop_propagation: bool = field(default=False, repr=False)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
src = type(self.source).__name__ if self.source is not None else "None"
|
|
90
|
+
parts = [f"event_type={self.event_type!r}", f"source={src}"]
|
|
91
|
+
for fname in ("x", "y", "xdata", "ydata", "button", "key",
|
|
92
|
+
"line_id", "bar_index", "dwell_ms"):
|
|
93
|
+
v = getattr(self, fname)
|
|
94
|
+
if v is not None:
|
|
95
|
+
parts.append(f"{fname}={v!r}")
|
|
96
|
+
if self.modifiers:
|
|
97
|
+
parts.append(f"modifiers={self.modifiers!r}")
|
|
98
|
+
return "Event(" + ", ".join(parts) + ")"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CallbackRegistry:
|
|
102
|
+
"""Per-object handler store.
|
|
103
|
+
|
|
104
|
+
Supports:
|
|
105
|
+
- Priority ordering (``order`` kwarg — lower fires first)
|
|
106
|
+
- Wildcard ``"*"`` type receives every dispatched event
|
|
107
|
+
- ``stop_propagation`` on the event halts remaining handlers
|
|
108
|
+
- ``disconnect_fn(fn, *types)`` removes by callback reference
|
|
109
|
+
- ``pause_events`` / ``hold_events`` context managers (added in Task 3)
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
# {event_type: [(order, cid, fn), ...]} — sorted by order
|
|
114
|
+
self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list)
|
|
115
|
+
self._next_cid: int = 1
|
|
116
|
+
# {cid: set[str]} — which types this cid is registered under
|
|
117
|
+
self._cid_map: dict[int, set[str]] = {}
|
|
118
|
+
# {id(fn): set[int]} — which cids this fn owns
|
|
119
|
+
self._fn_map: dict[int, set[int]] = defaultdict(set)
|
|
120
|
+
# pause/hold state (populated in Task 3)
|
|
121
|
+
self._pause_counts: dict[str, int] = {}
|
|
122
|
+
self._hold_counts: dict[str, int] = {}
|
|
123
|
+
self._held: deque[Event] = deque()
|
|
124
|
+
|
|
125
|
+
# ── registration ─────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int:
|
|
128
|
+
"""Register fn for event_type. Returns integer CID."""
|
|
129
|
+
if event_type not in VALID_EVENT_TYPES:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Invalid event_type {event_type!r}. "
|
|
132
|
+
f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'"
|
|
133
|
+
)
|
|
134
|
+
cid = self._next_cid
|
|
135
|
+
self._next_cid += 1
|
|
136
|
+
self._handlers[event_type].append((order, cid, fn))
|
|
137
|
+
self._handlers[event_type].sort(key=lambda t: t[0])
|
|
138
|
+
self._cid_map.setdefault(cid, set()).add(event_type)
|
|
139
|
+
self._fn_map[id(fn)].add(cid)
|
|
140
|
+
return cid
|
|
141
|
+
|
|
142
|
+
def disconnect(self, cid: int) -> None:
|
|
143
|
+
"""Remove handler by CID. Silent if not found."""
|
|
144
|
+
types = self._cid_map.pop(cid, set())
|
|
145
|
+
for et in types:
|
|
146
|
+
self._handlers[et] = [
|
|
147
|
+
(o, c, f) for o, c, f in self._handlers[et] if c != cid
|
|
148
|
+
]
|
|
149
|
+
for fn_cids in self._fn_map.values():
|
|
150
|
+
fn_cids.discard(cid)
|
|
151
|
+
|
|
152
|
+
def disconnect_fn(self, fn: Callable, *types: str) -> None:
|
|
153
|
+
"""Remove fn from the given types (all types if none given)."""
|
|
154
|
+
for cid in list(self._fn_map.get(id(fn), set())):
|
|
155
|
+
cid_types = self._cid_map.get(cid, set())
|
|
156
|
+
if not types or cid_types & set(types):
|
|
157
|
+
self.disconnect(cid)
|
|
158
|
+
|
|
159
|
+
# ── dispatch ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def fire(self, event: Event) -> None:
|
|
162
|
+
"""Dispatch event to matching handlers (respects pause/hold)."""
|
|
163
|
+
et = event.event_type
|
|
164
|
+
if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0:
|
|
165
|
+
return
|
|
166
|
+
if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0:
|
|
167
|
+
self._held.append(event)
|
|
168
|
+
return
|
|
169
|
+
self._dispatch(event)
|
|
170
|
+
|
|
171
|
+
def _dispatch(self, event: Event) -> None:
|
|
172
|
+
et = event.event_type
|
|
173
|
+
specific = list(self._handlers.get(et, []))
|
|
174
|
+
wildcard = list(self._handlers.get("*", []))
|
|
175
|
+
merged = sorted(specific + wildcard, key=lambda t: t[0])
|
|
176
|
+
for _order, _cid, fn in merged:
|
|
177
|
+
if event.stop_propagation:
|
|
178
|
+
break
|
|
179
|
+
fn(event)
|
|
180
|
+
|
|
181
|
+
def _flush(self) -> None:
|
|
182
|
+
while self._held:
|
|
183
|
+
self._dispatch(self._held.popleft())
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def pause_events(self, *types: str):
|
|
187
|
+
"""Suppress events of the given types while inside this context.
|
|
188
|
+
All types are paused when called with no arguments.
|
|
189
|
+
Pause wins over hold for the same type."""
|
|
190
|
+
target = types if types else ("*",)
|
|
191
|
+
for t in target:
|
|
192
|
+
self._pause_counts[t] = self._pause_counts.get(t, 0) + 1
|
|
193
|
+
try:
|
|
194
|
+
yield
|
|
195
|
+
finally:
|
|
196
|
+
for t in target:
|
|
197
|
+
self._pause_counts[t] -= 1
|
|
198
|
+
if self._pause_counts[t] == 0:
|
|
199
|
+
del self._pause_counts[t]
|
|
200
|
+
|
|
201
|
+
@contextmanager
|
|
202
|
+
def hold_events(self, *types: str):
|
|
203
|
+
"""Buffer events of the given types; flush when the outermost hold exits.
|
|
204
|
+
All types are held when called with no arguments."""
|
|
205
|
+
target = types if types else ("*",)
|
|
206
|
+
for t in target:
|
|
207
|
+
self._hold_counts[t] = self._hold_counts.get(t, 0) + 1
|
|
208
|
+
try:
|
|
209
|
+
yield
|
|
210
|
+
finally:
|
|
211
|
+
for t in target:
|
|
212
|
+
self._hold_counts[t] -= 1
|
|
213
|
+
if self._hold_counts[t] == 0:
|
|
214
|
+
del self._hold_counts[t]
|
|
215
|
+
if not self._hold_counts:
|
|
216
|
+
self._flush()
|
|
217
|
+
|
|
218
|
+
def __bool__(self) -> bool:
|
|
219
|
+
return any(bool(v) for v in self._handlers.values())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _EventMixin:
|
|
223
|
+
"""Mixin for plot classes and widgets.
|
|
224
|
+
|
|
225
|
+
Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` /
|
|
226
|
+
``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()``
|
|
227
|
+
in its ``__init__``.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
callbacks: CallbackRegistry
|
|
231
|
+
|
|
232
|
+
def add_event_handler(
|
|
233
|
+
self,
|
|
234
|
+
fn_or_type,
|
|
235
|
+
*args,
|
|
236
|
+
order: float = 0,
|
|
237
|
+
ms: int = 300,
|
|
238
|
+
delta: float = 4,
|
|
239
|
+
):
|
|
240
|
+
"""Register an event handler. Works as a direct call or decorator.
|
|
241
|
+
|
|
242
|
+
Direct call::
|
|
243
|
+
|
|
244
|
+
plot.add_event_handler(fn, "pointer_down")
|
|
245
|
+
plot.add_event_handler(fn, "pointer_down", "pointer_up")
|
|
246
|
+
|
|
247
|
+
Decorator::
|
|
248
|
+
|
|
249
|
+
@plot.add_event_handler("pointer_down")
|
|
250
|
+
def handler(event): ...
|
|
251
|
+
|
|
252
|
+
@plot.add_event_handler("pointer_settled", ms=400, delta=5)
|
|
253
|
+
def on_settle(event): ...
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
fn_or_type : callable or str
|
|
258
|
+
Handler function (direct call) or first event type string (decorator).
|
|
259
|
+
*args : str
|
|
260
|
+
Remaining event type strings.
|
|
261
|
+
order : float
|
|
262
|
+
Priority. Lower fires first. Default 0.
|
|
263
|
+
ms : int
|
|
264
|
+
``pointer_settled`` dwell threshold in milliseconds. Default 300.
|
|
265
|
+
Raises ``ValueError`` if provided without ``"pointer_settled"`` in types.
|
|
266
|
+
delta : float
|
|
267
|
+
``pointer_settled`` pixel radius. Default 4.
|
|
268
|
+
Raises ``ValueError`` if provided without ``"pointer_settled"`` in types.
|
|
269
|
+
"""
|
|
270
|
+
if callable(fn_or_type):
|
|
271
|
+
return self._register(fn_or_type, args, order=order, ms=ms, delta=delta)
|
|
272
|
+
else:
|
|
273
|
+
all_types = (fn_or_type,) + args
|
|
274
|
+
def _decorator(fn: Callable) -> Callable:
|
|
275
|
+
self._register(fn, all_types, order=order, ms=ms, delta=delta)
|
|
276
|
+
return fn
|
|
277
|
+
return _decorator
|
|
278
|
+
|
|
279
|
+
def _register(
|
|
280
|
+
self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float
|
|
281
|
+
) -> Callable:
|
|
282
|
+
has_settled = "pointer_settled" in types
|
|
283
|
+
_ms_changed = ms != 300
|
|
284
|
+
_delta_changed = delta != 4
|
|
285
|
+
if (_ms_changed or _delta_changed) and not has_settled:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
"ms/delta kwargs are only valid when 'pointer_settled' is in the event types"
|
|
288
|
+
)
|
|
289
|
+
for event_type in types:
|
|
290
|
+
self.callbacks.connect(event_type, fn, order=order)
|
|
291
|
+
if has_settled:
|
|
292
|
+
self._configure_pointer_settled(ms, delta)
|
|
293
|
+
fn._event_types = getattr(fn, "_event_types", set()) | set(types)
|
|
294
|
+
return fn
|
|
295
|
+
|
|
296
|
+
def remove_handler(self, cid_or_fn, *types: str) -> None:
|
|
297
|
+
"""Remove a registered handler.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
cid_or_fn : int or callable
|
|
302
|
+
CID returned by ``callbacks.connect()`` or the handler function.
|
|
303
|
+
*types : str
|
|
304
|
+
If given, only remove from these types. If omitted, remove from all.
|
|
305
|
+
"""
|
|
306
|
+
had_settled = bool(self.callbacks._handlers.get("pointer_settled"))
|
|
307
|
+
if isinstance(cid_or_fn, int):
|
|
308
|
+
self.callbacks.disconnect(cid_or_fn)
|
|
309
|
+
else:
|
|
310
|
+
self.callbacks.disconnect_fn(cid_or_fn, *types)
|
|
311
|
+
if had_settled and not self.callbacks._handlers.get("pointer_settled"):
|
|
312
|
+
self._configure_pointer_settled(0, 0)
|
|
313
|
+
|
|
314
|
+
def _configure_pointer_settled(self, ms: int, delta: float) -> None:
|
|
315
|
+
"""Override in plot subclasses to push thresholds to JS."""
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
@contextmanager
|
|
319
|
+
def pause_events(self, *types: str):
|
|
320
|
+
"""Suppress events of the given types (all types if none given)."""
|
|
321
|
+
with self.callbacks.pause_events(*types):
|
|
322
|
+
yield
|
|
323
|
+
|
|
324
|
+
@contextmanager
|
|
325
|
+
def hold_events(self, *types: str):
|
|
326
|
+
"""Buffer events of the given types; flush when context exits."""
|
|
327
|
+
with self.callbacks.hold_events(*types):
|
|
328
|
+
yield
|
anyplotlib/embed.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
embed.py
|
|
3
|
+
========
|
|
4
|
+
|
|
5
|
+
Use anyplotlib figures **outside Jupyter** — in Electron apps, MDI
|
|
6
|
+
sub-windows, kiosk dashboards, or any plain web page. No kernel, no
|
|
7
|
+
ipywidgets, no anywidget runtime in the page.
|
|
8
|
+
|
|
9
|
+
Three levels of integration
|
|
10
|
+
---------------------------
|
|
11
|
+
|
|
12
|
+
**1. Static / self-contained (no Python at runtime)** — export a fully
|
|
13
|
+
self-contained HTML page (renderer + data inlined) and load it anywhere a
|
|
14
|
+
browser engine runs, e.g. an Electron ``BrowserWindow`` or ``<webview>``::
|
|
15
|
+
|
|
16
|
+
import anyplotlib as apl
|
|
17
|
+
fig, ax = apl.subplots(1, 1)
|
|
18
|
+
ax.imshow(data)
|
|
19
|
+
fig.save_html("plot.html") # win.loadFile('plot.html')
|
|
20
|
+
|
|
21
|
+
All client-side interactivity (pan, zoom, widgets, markers) works; Python
|
|
22
|
+
callbacks obviously do not.
|
|
23
|
+
|
|
24
|
+
**2. JS-driven (your app owns the data)** — ship ``figure_esm.js`` with your
|
|
25
|
+
app and mount figures from JavaScript using the exported ``mount()``::
|
|
26
|
+
|
|
27
|
+
import { mount } from './figure_esm.js';
|
|
28
|
+
const handle = mount(container, state, { onEvent: ev => ... });
|
|
29
|
+
handle.setPanelState(panelId, newPanelState); // live updates
|
|
30
|
+
handle.resize(w, h); handle.dispose();
|
|
31
|
+
|
|
32
|
+
``state`` is the JSON dict produced by :func:`figure_state` — generate it
|
|
33
|
+
once from Python (build time, or a one-shot script) or construct it in JS.
|
|
34
|
+
Each ``mount()`` is fully self-contained, so one window can host many
|
|
35
|
+
figures (MDI-style) by mounting into separate containers.
|
|
36
|
+
|
|
37
|
+
**3. Live Python backend** — run Python alongside your app (sidecar process,
|
|
38
|
+
local WebSocket server, …) and keep figures fully interactive with Python
|
|
39
|
+
callbacks via :class:`FigureBridge`, which is transport-agnostic::
|
|
40
|
+
|
|
41
|
+
# Python side (e.g. behind a websocket)
|
|
42
|
+
bridge = FigureBridge(fig, send=lambda key, value: ws.send(
|
|
43
|
+
json.dumps({"key": key, "value": value})))
|
|
44
|
+
ws.on_message = lambda m: bridge.receive(**json.loads(m))
|
|
45
|
+
|
|
46
|
+
// JS side
|
|
47
|
+
const handle = mount(el, snapshot, {
|
|
48
|
+
onSync: (key, value) => ws.send(JSON.stringify({key, value})),
|
|
49
|
+
});
|
|
50
|
+
ws.onmessage = (m) => { const u = JSON.parse(m.data);
|
|
51
|
+
handle.applyUpdate(u.key, u.value); };
|
|
52
|
+
|
|
53
|
+
See ``docs/embedding.rst`` for a complete Electron walkthrough.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import pathlib
|
|
59
|
+
|
|
60
|
+
from anyplotlib._repr_utils import build_standalone_html, _widget_state
|
|
61
|
+
|
|
62
|
+
__all__ = ["figure_state", "to_html", "save_html", "esm_path", "FigureBridge"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def figure_state(fig) -> dict:
|
|
66
|
+
"""Return the figure's full serialised state as a plain JSON-safe dict.
|
|
67
|
+
|
|
68
|
+
The dict contains every synced trait — ``layout_json``, ``fig_width``,
|
|
69
|
+
``fig_height``, ``event_json``, and one ``panel_<id>_json`` entry per
|
|
70
|
+
panel — and is exactly what the JS ``mount(el, state)`` entry point
|
|
71
|
+
expects.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
fig : Figure
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
dict
|
|
80
|
+
"""
|
|
81
|
+
# _widget_state also picks up ipywidgets infrastructure traits (layout,
|
|
82
|
+
# tabbable, …) whose values aren't JSON. The renderer only reads scalar
|
|
83
|
+
# traits, so keep exactly those.
|
|
84
|
+
return {k: v for k, v in _widget_state(fig).items()
|
|
85
|
+
if isinstance(v, (str, int, float, bool)) or v is None}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_html(fig, *, resizable: bool = True) -> str:
|
|
89
|
+
"""Return a fully self-contained HTML page rendering *fig*.
|
|
90
|
+
|
|
91
|
+
The page inlines the renderer and all figure data; it needs no network,
|
|
92
|
+
kernel, or Python at view time. Client-side interactivity (pan, zoom,
|
|
93
|
+
overlay widgets) is preserved.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
fig : Figure
|
|
98
|
+
resizable : bool, optional
|
|
99
|
+
Keep the figure's drag-to-resize handle. Default ``True``.
|
|
100
|
+
"""
|
|
101
|
+
return build_standalone_html(fig, resizable=resizable)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_html(fig, path, *, resizable: bool = True) -> pathlib.Path:
|
|
105
|
+
"""Write :func:`to_html` output to *path* and return it as a ``Path``."""
|
|
106
|
+
p = pathlib.Path(path)
|
|
107
|
+
p.write_text(to_html(fig, resizable=resizable), encoding="utf-8")
|
|
108
|
+
return p
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def esm_path() -> pathlib.Path:
|
|
112
|
+
"""Return the path to ``figure_esm.js`` for bundling into a JS app.
|
|
113
|
+
|
|
114
|
+
Copy (or import) this file into your Electron / web build; it exports
|
|
115
|
+
``mount`` and ``createLocalModel`` alongside the anywidget ``render``.
|
|
116
|
+
"""
|
|
117
|
+
return pathlib.Path(__file__).parent / "figure_esm.js"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class FigureBridge:
|
|
121
|
+
"""Transport-agnostic two-way sync between a live ``Figure`` and a
|
|
122
|
+
remote JS view mounted with ``mount(el, state, {onSync})``.
|
|
123
|
+
|
|
124
|
+
You supply the pipe (WebSocket, Electron IPC via a sidecar, stdio, …);
|
|
125
|
+
the bridge supplies the protocol: plain ``(key, value)`` pairs.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
fig : Figure
|
|
130
|
+
The live figure. All Python-side mutations (``plot.set_data(...)``,
|
|
131
|
+
marker/widget updates, layout changes) are forwarded automatically.
|
|
132
|
+
send : callable(key: str, value) -> None
|
|
133
|
+
Called for every outbound state change. Wire it to your transport.
|
|
134
|
+
|
|
135
|
+
Notes
|
|
136
|
+
-----
|
|
137
|
+
* **Python → JS**: any synced trait change triggers ``send(key, value)``;
|
|
138
|
+
deliver it to ``handle.applyUpdate(key, value)`` in JS.
|
|
139
|
+
* **JS → Python**: deliver each JS ``onSync(key, value)`` message to
|
|
140
|
+
:meth:`receive`. Interaction events (``event_json``) are dispatched to
|
|
141
|
+
the figure's callback registries exactly as in Jupyter, so
|
|
142
|
+
``@plot.add_event_handler(...)`` handlers fire unchanged.
|
|
143
|
+
* Echo is suppressed in both directions.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, fig, send) -> None:
|
|
147
|
+
self._fig = fig
|
|
148
|
+
self._send = send
|
|
149
|
+
self._applying = False
|
|
150
|
+
# names=traitlets.All: also covers panel traits added dynamically
|
|
151
|
+
# after the bridge is created (Figure.add_traits on new panels).
|
|
152
|
+
import traitlets
|
|
153
|
+
fig.observe(self._on_trait_change, names=traitlets.All)
|
|
154
|
+
|
|
155
|
+
# ── outbound (Python → JS) ────────────────────────────────────────────
|
|
156
|
+
def _on_trait_change(self, change) -> None:
|
|
157
|
+
if self._applying:
|
|
158
|
+
return
|
|
159
|
+
name = change["name"]
|
|
160
|
+
trait = self._fig.traits().get(name)
|
|
161
|
+
if trait is None or not trait.metadata.get("sync") or name.startswith("_"):
|
|
162
|
+
return
|
|
163
|
+
self._send(name, change["new"])
|
|
164
|
+
|
|
165
|
+
def snapshot(self) -> dict:
|
|
166
|
+
"""Full state dict for the initial ``mount()`` on the JS side."""
|
|
167
|
+
return figure_state(self._fig)
|
|
168
|
+
|
|
169
|
+
# ── inbound (JS → Python) ─────────────────────────────────────────────
|
|
170
|
+
def receive(self, key: str, value) -> None:
|
|
171
|
+
"""Apply one inbound ``(key, value)`` message from the JS view.
|
|
172
|
+
|
|
173
|
+
``event_json`` messages are dispatched to plot/widget callbacks;
|
|
174
|
+
other keys (e.g. a panel's view state after a JS-side 3D rotate)
|
|
175
|
+
are stored on the figure without echoing back.
|
|
176
|
+
"""
|
|
177
|
+
if key == "event_json":
|
|
178
|
+
self._fig._dispatch_event(value)
|
|
179
|
+
return
|
|
180
|
+
if not self._fig.has_trait(key):
|
|
181
|
+
return
|
|
182
|
+
self._applying = True
|
|
183
|
+
try:
|
|
184
|
+
setattr(self._fig, key, value)
|
|
185
|
+
finally:
|
|
186
|
+
self._applying = False
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
"""Stop forwarding (unobserve the figure)."""
|
|
190
|
+
import traitlets
|
|
191
|
+
try:
|
|
192
|
+
self._fig.unobserve(self._on_trait_change, names=traitlets.All)
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""anyplotlib.figure — Figure widget, grid spec, and subplots factory."""
|
|
2
|
+
|
|
3
|
+
from anyplotlib.figure._figure import Figure
|
|
4
|
+
from anyplotlib.figure._gridspec import GridSpec, SubplotSpec
|
|
5
|
+
from anyplotlib.figure._subplots import subplots
|
|
6
|
+
|
|
7
|
+
__all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"]
|