fastplotpy 0.3.0a1__py3-none-win_amd64.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.
Files changed (78) hide show
  1. fastplotpy/README.md +70 -0
  2. fastplotpy/__init__.py +33 -0
  3. fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
  4. fastplotpy/assets/dist/index.js +15071 -0
  5. fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
  6. fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
  7. fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
  8. fastplotpy/assets/package.json +1 -0
  9. fastplotpy/axes_validation.py +102 -0
  10. fastplotpy/camera3d.py +104 -0
  11. fastplotpy/color_scale.py +88 -0
  12. fastplotpy/defaults/area-fill.json +5 -0
  13. fastplotpy/defaults/bar.json +22 -0
  14. fastplotpy/defaults/camera3d.json +22 -0
  15. fastplotpy/defaults/cartesian-references.json +10 -0
  16. fastplotpy/defaults/control.json +3 -0
  17. fastplotpy/defaults/crosshair.json +19 -0
  18. fastplotpy/defaults/error-bars.json +7 -0
  19. fastplotpy/defaults/heatmap.json +70 -0
  20. fastplotpy/defaults/interaction.json +3 -0
  21. fastplotpy/defaults/layout.json +32 -0
  22. fastplotpy/defaults/legend.json +24 -0
  23. fastplotpy/defaults/line-axes.json +28 -0
  24. fastplotpy/defaults/line3d.json +13 -0
  25. fastplotpy/defaults/markers.json +6 -0
  26. fastplotpy/defaults/mesh3d.json +13 -0
  27. fastplotpy/defaults/pan.json +7 -0
  28. fastplotpy/defaults/pie.json +27 -0
  29. fastplotpy/defaults/pie3d.json +7 -0
  30. fastplotpy/defaults/point-labels.json +13 -0
  31. fastplotpy/defaults/pointClick.json +6 -0
  32. fastplotpy/defaults/pointSelection.json +13 -0
  33. fastplotpy/defaults/polar.json +71 -0
  34. fastplotpy/defaults/polarRotate.json +5 -0
  35. fastplotpy/defaults/scatter3d.json +39 -0
  36. fastplotpy/defaults/scene3d.json +61 -0
  37. fastplotpy/defaults/surface.json +35 -0
  38. fastplotpy/defaults/tooltip.json +18 -0
  39. fastplotpy/defaults/trace-colors.json +10 -0
  40. fastplotpy/defaults/trace-options.json +24 -0
  41. fastplotpy/defaults/wonderplot.json +58 -0
  42. fastplotpy/defaults/zoom.json +12 -0
  43. fastplotpy/defaults.py +15 -0
  44. fastplotpy/error_bars.py +118 -0
  45. fastplotpy/exports.py +153 -0
  46. fastplotpy/families2d.py +896 -0
  47. fastplotpy/heatmap_labels.py +146 -0
  48. fastplotpy/interactions.py +243 -0
  49. fastplotpy/interactive.py +1922 -0
  50. fastplotpy/js_serialize.py +47 -0
  51. fastplotpy/lighting3d.py +113 -0
  52. fastplotpy/line3d_lod.py +138 -0
  53. fastplotpy/lod3d.py +105 -0
  54. fastplotpy/mesh3d_lod.py +107 -0
  55. fastplotpy/native.py +163 -0
  56. fastplotpy/overlays.py +561 -0
  57. fastplotpy/point_labels.py +86 -0
  58. fastplotpy/polar.py +445 -0
  59. fastplotpy/polygon_clip3d.py +148 -0
  60. fastplotpy/references.py +72 -0
  61. fastplotpy/rendering.py +65 -0
  62. fastplotpy/rust_scenes.py +314 -0
  63. fastplotpy/scene.py +1185 -0
  64. fastplotpy/scene3d.py +1215 -0
  65. fastplotpy/scene3d_accessibility.py +183 -0
  66. fastplotpy/scene3d_collection.py +158 -0
  67. fastplotpy/subplots.py +115 -0
  68. fastplotpy/surface_contours.py +122 -0
  69. fastplotpy/surface_lod.py +109 -0
  70. fastplotpy/svg3d.py +235 -0
  71. fastplotpy/trendline.py +236 -0
  72. fastplotpy/triangle_wireframe.py +51 -0
  73. fastplotpy/types.py +3027 -0
  74. fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
  75. fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
  76. fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
  77. fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
  78. fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1922 @@
1
+ """Interactive browser/Jupyter chart backed by a source-pinned browser runtime.
2
+
3
+ Python owns options, local state, commands, and event delivery. The browser runtime
4
+ owns DOM, pointer, canvas, and WebGL behavior, which cannot execute in CPython.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from copy import deepcopy
10
+ from struct import pack_into
11
+ from datetime import date, datetime
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from importlib import resources
14
+ from json import dumps, loads
15
+ from math import isfinite
16
+ from threading import Condition, RLock, Thread
17
+ from typing import Any, Callable, Mapping
18
+ from urllib.parse import parse_qs, urlsplit
19
+ from uuid import uuid4
20
+
21
+ from .scene import plot
22
+
23
+
24
+ _EVENTS = (
25
+ "renderStart", "renderPhase", "renderEnd", "beforeRender", "render", "resizing", "resize", "error",
26
+ "beforeZoom", "zoomStart", "zoom", "viewportChange", "zoomEnd", "zoomCancel", "dragModeChange",
27
+ "polarRotationChange", "beforeCamera3dChange", "camera3dChange", "tooltip", "tooltipClear",
28
+ "crosshairChange", "crosshairClear", "pointClick", "pointSelectionChange", "traceVisibility",
29
+ "dataUpdate", "chartStateChange",
30
+ )
31
+ _CHART_ONLY_OPTIONS = frozenset({
32
+ "responsive", "progressiveRender", "resizeTarget", "interactions",
33
+ "onRenderStart", "onRenderPhase", "onRenderEnd", "onBeforeRender", "onRender",
34
+ "onResizing", "onResize", "onError", "onBeforeZoom", "onZoomStart", "onZoom",
35
+ "onViewportChange", "onZoomEnd", "onZoomCancel", "onDragModeChange",
36
+ "onPolarRotationChange", "onBeforeCamera3dChange", "onCamera3dChange", "onTooltip",
37
+ "onTooltipClear", "onCrosshairChange", "onCrosshairClear", "onPointClick",
38
+ "onPointSelectionChange", "onTraceVisibility", "onDataUpdate", "onChartStateChange",
39
+ })
40
+ _SCENE_COMMANDS = frozenset({
41
+ "render", "updateOptions", "setData", "setTraces", "setTraceData", "appendData",
42
+ "setDataState", "setChartState", "setViewport", "zoom", "zoomIn", "zoomOut",
43
+ "pan", "resetZoom", "setTraceVisibility", "setTraceVisibilityState",
44
+ "resetTraceVisibility", "setPolarRotation", "setCamera3d", "orbitCamera3d",
45
+ "dollyCamera3d", "panCamera3d", "resetCamera3d",
46
+ })
47
+
48
+
49
+ class _BeforeEvent(dict):
50
+ @property
51
+ def defaultPrevented(self) -> bool:
52
+ return self.get("defaultPrevented") is True
53
+
54
+ def preventDefault(self) -> None:
55
+ if self["type"] != "beforeRender":
56
+ self["defaultPrevented"] = True
57
+
58
+
59
+ def _json_default(value: Any) -> str:
60
+ if isinstance(value, (date, datetime)):
61
+ return value.isoformat()
62
+ raise TypeError(f"{type(value).__name__} cannot cross the browser bridge")
63
+
64
+
65
+ def _json(value: Any) -> str:
66
+ return dumps(value, ensure_ascii=False, separators=(",", ":"), default=_json_default)
67
+
68
+
69
+ def _script_json(value: Any) -> str:
70
+ return (_json(value).replace("<", "\\u003c").replace(">", "\\u003e")
71
+ .replace("&", "\\u0026").replace("\u2028", "\\u2028")
72
+ .replace("\u2029", "\\u2029"))
73
+
74
+
75
+ _POINT_STATE_FIELDS = ("base", "value", "z", "size", "opacity", "xError", "yError")
76
+
77
+
78
+ def _valid_scalar(value: Any) -> bool:
79
+ return (value is None or isinstance(value, str)
80
+ or isinstance(value, (date, datetime))
81
+ or isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value))
82
+
83
+
84
+ def _valid_error(value: Any) -> bool:
85
+ if value is None:
86
+ return True
87
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
88
+ return isfinite(value) and value >= 0
89
+ return (isinstance(value, Mapping) and all(key in value for key in ("minus", "plus"))
90
+ and all(isinstance(value[key], (int, float)) and not isinstance(value[key], bool)
91
+ and isfinite(value[key]) and value[key] >= 0 for key in ("minus", "plus")))
92
+
93
+
94
+ def _valid_data_point(point: Any) -> bool:
95
+ if not isinstance(point, Mapping) or "x" not in point or "y" not in point:
96
+ return False
97
+ if not _valid_scalar(point["x"]) or not _valid_scalar(point["y"]):
98
+ return False
99
+ if any(key in point and not _valid_scalar(point[key]) for key in ("base", "value", "z")):
100
+ return False
101
+ if any(key in point and point[key] is not None and
102
+ (not isinstance(point[key], (int, float)) or isinstance(point[key], bool) or not isfinite(point[key]))
103
+ for key in ("size", "opacity")):
104
+ return False
105
+ return all(key not in point or _valid_error(point[key]) for key in ("xError", "yError"))
106
+
107
+
108
+ def _snapshot_data_point(point: Mapping) -> dict:
109
+ return {key: deepcopy(point[key]) for key in ("x", "y") + _POINT_STATE_FIELDS if key in point}
110
+
111
+
112
+ def _valid_data_state(state: Any, trace_count: int) -> bool:
113
+ return (isinstance(state, Mapping) and type(state.get("traceCount")) is int
114
+ and state["traceCount"] == trace_count and isinstance(state.get("traceData"), list)
115
+ and len(state["traceData"]) == trace_count
116
+ and all(isinstance(data, list) and all(_valid_data_point(point) for point in data)
117
+ for data in state["traceData"]))
118
+
119
+
120
+ def _normalize_visibility(state: Any, trace_count: int) -> dict | None:
121
+ if (not isinstance(state, Mapping) or type(state.get("traceCount")) is not int
122
+ or state["traceCount"] != trace_count):
123
+ return None
124
+ visible, hidden = state.get("visibleTraceIndexes"), state.get("hiddenTraceIndexes")
125
+ if not isinstance(visible, list) or not isinstance(hidden, list):
126
+ return None
127
+ if any(type(index) is not int or index < 0 or index >= trace_count for index in visible + hidden):
128
+ return None
129
+ visible_set, hidden_set = set(visible), set(hidden)
130
+ if visible_set & hidden_set or len(visible_set) + len(hidden_set) != trace_count:
131
+ return None
132
+ return {"traceCount": trace_count, "visibleTraceIndexes": sorted(visible_set),
133
+ "hiddenTraceIndexes": sorted(hidden_set)}
134
+
135
+
136
+ def _normalize_selection(state: Any, counts: list[int]) -> dict | None:
137
+ if state is None:
138
+ return {"traceCount": len(counts), "selection": None}
139
+ if (not isinstance(state, Mapping) or type(state.get("traceCount")) is not int
140
+ or state["traceCount"] != len(counts)):
141
+ return None
142
+ def item(value: Any) -> dict | None:
143
+ if not isinstance(value, Mapping):
144
+ return None
145
+ trace, point = value.get("traceIndex"), value.get("pointIndex")
146
+ if (type(trace) is not int or type(point) is not int or trace < 0
147
+ or trace >= len(counts) or point < 0 or point >= counts[trace]):
148
+ return None
149
+ return {"traceIndex": trace, "pointIndex": point}
150
+ selected = None if state.get("selection") is None else item(state["selection"])
151
+ if state.get("selection") is not None and selected is None:
152
+ return None
153
+ result = {"traceCount": len(counts), "selection": selected}
154
+ if "selections" in state:
155
+ if not isinstance(state["selections"], list):
156
+ return None
157
+ selections = [item(value) for value in state["selections"]]
158
+ if (any(value is None for value in selections)
159
+ or len({(value["traceIndex"], value["pointIndex"]) for value in selections}) != len(selections)
160
+ or (selections[-1] if selections else None) != selected):
161
+ return None
162
+ result["selections"] = selections
163
+ return result
164
+
165
+
166
+ def _valid_viewport_state(viewport: Any, options: Mapping | None = None) -> bool:
167
+ if viewport is None:
168
+ return True
169
+ if not isinstance(viewport, Mapping):
170
+ return False
171
+ for dimension in ("xAxes", "yAxes"):
172
+ axes = viewport.get(dimension, {})
173
+ if not isinstance(axes, Mapping):
174
+ return False
175
+ configured = ((options or {}).get("axes") or {}).get(dimension) or {}
176
+ known = set(configured) | {"x" if dimension == "xAxes" else "y"}
177
+ for name, interval in axes.items():
178
+ if (not isinstance(name, str) or not name or name not in known or not isinstance(interval, Mapping)
179
+ or "min" not in interval or "max" not in interval):
180
+ return False
181
+ low, high = interval["min"], interval["max"]
182
+ if (isinstance(low, bool) or isinstance(high, bool)
183
+ or not isinstance(low, (int, float, date, datetime))
184
+ or not isinstance(high, (int, float, date, datetime))):
185
+ return False
186
+ try:
187
+ if not low < high:
188
+ return False
189
+ except TypeError:
190
+ return False
191
+ if ((isinstance(low, (int, float)) and not isfinite(low))
192
+ or (isinstance(high, (int, float)) and not isfinite(high))):
193
+ return False
194
+ return True
195
+
196
+
197
+ def _page(options: Mapping, prefix: str, packed_point_count: int | None = None,
198
+ packed_command_count: int = 0) -> str:
199
+ browser_options = dict(options)
200
+ if browser_options.get("engine") == "rust":
201
+ # The browser asset cannot load a CPython native library. It provides
202
+ # DOM, Canvas, and pointer behavior while the host retains Rust scenes.
203
+ browser_options["engine"] = "ts"
204
+ encoded = _script_json(browser_options)
205
+ events = _json(_EVENTS)
206
+ return f"""<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>html,body{{margin:0;width:100%;height:100%;overflow:hidden}}canvas{{display:block;width:100%;height:100%}}</style></head><body><canvas id=\"chart\"></canvas><script type=\"module\">
207
+ import {{ createChart, renderToCanvas, getLastLineAxisLayoutDiagnostics }} from '{prefix}assets/dist/index.js';
208
+ const options={encoded};
209
+ const packedPointCount={packed_point_count if packed_point_count is not None else 'null'};
210
+ const canvas=document.getElementById('chart');
211
+ const events={events};
212
+ const revive=value=>{{
213
+ if(Array.isArray(value)) return value.map(revive);
214
+ if(value && typeof value==='object') {{
215
+ if(Object.keys(value).length===1 && typeof value.__fastplotpy_callback__==='string') {{
216
+ const id=value.__fastplotpy_callback__;
217
+ return (...args)=>{{
218
+ const request=new XMLHttpRequest();
219
+ request.open('POST','{prefix}callback',false);
220
+ request.setRequestHeader('Content-Type','application/json');
221
+ request.send(JSON.stringify({{id,args}}));
222
+ if(request.status!==200) throw new Error('Python callback failed: '+request.responseText);
223
+ const response=JSON.parse(request.responseText);
224
+ if(chart) for(const command of response.commands||[]) {{
225
+ immediateCommands.add(command.id);
226
+ runCommand(command);
227
+ }}
228
+ return response.result;
229
+ }};
230
+ }}
231
+ return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,revive(item)]));
232
+ }}
233
+ return value;
234
+ }};
235
+ let executingCommandId=0;
236
+ const immediateCommands=new Set();
237
+ const beforeEvent=(type,event)=>{{
238
+ const request=new XMLHttpRequest();
239
+ request.open('POST','{prefix}before',false);
240
+ request.setRequestHeader('Content-Type','application/json');
241
+ request.send(JSON.stringify({{type,payload:event,commandId:executingCommandId}}));
242
+ if(request.status!==200) throw new Error('Python before-event failed: '+request.responseText);
243
+ const result=JSON.parse(request.responseText);
244
+ if(chart) for(const command of result.commands||[]) {{
245
+ immediateCommands.add(command.id);
246
+ runCommand(command);
247
+ }}
248
+ if(result.prevented && typeof event.preventDefault==='function') event.preventDefault();
249
+ }};
250
+ options.onBeforeZoom=event=>beforeEvent('beforeZoom',event);
251
+ options.onBeforeCamera3dChange=event=>beforeEvent('beforeCamera3dChange',event);
252
+ options.onBeforeRender=event=>beforeEvent('beforeRender',event);
253
+ let eventDelivery=Promise.resolve();
254
+ const send=(type,payload)=>{{
255
+ const body=JSON.stringify({{type,payload,commandId:executingCommandId}});
256
+ eventDelivery=eventDelivery.then(()=>fetch('{prefix}event',{{method:'POST',headers:{{'Content-Type':'application/json'}},body}})).catch(()=>{{}});
257
+ return eventDelivery;
258
+ }};
259
+ let chart;
260
+ const runCommand=command=>{{
261
+ const previousId=executingCommandId;
262
+ executingCommandId=command.id;
263
+ try {{
264
+ const args=revive(command.args||[]);
265
+ let result;
266
+ if(command.method==='__renderToCanvas__') result=renderToCanvas(canvas,...args);
267
+ else {{
268
+ if(typeof chart[command.method]!=='function') throw new Error('Unknown chart method '+command.method);
269
+ if(command.method==='updateOptions' && args[0]?.engine==='rust') args[0]={{...args[0],engine:'ts'}};
270
+ result=chart[command.method](...args);
271
+ }}
272
+ send('commandResult',{{id:command.id,method:command.method,result,lineAxisDiagnostics:getLastLineAxisLayoutDiagnostics()}});
273
+ }} catch(error) {{ send('commandError',{{id:command.id,method:command.method,message:String(error),name:error?.name,code:error?.code}}); }}
274
+ finally {{ executingCommandId=previousId; }}
275
+ }};
276
+ try {{
277
+ if(packedPointCount!==null) {{
278
+ const response=await fetch('{prefix}data.bin',{{cache:'no-store'}});
279
+ if(!response.ok) throw new Error('Python data transfer failed: '+response.status);
280
+ const bytes=await response.arrayBuffer();
281
+ if(bytes.byteLength!==packedPointCount*16) throw new Error('Python data length mismatch');
282
+ const values=new DataView(bytes);
283
+ const points=new Array(packedPointCount);
284
+ for(let index=0;index<packedPointCount;index++) {{
285
+ const offset=index*16;
286
+ points[index]={{x:values.getFloat64(offset,true),y:values.getFloat64(offset+8,true)}};
287
+ }}
288
+ options.data=points;
289
+ }}
290
+ chart=createChart(canvas,revive(options));
291
+ window.fastPlotPyChart=chart;
292
+ for(const event of events) if(!['beforeZoom','beforeCamera3dChange','beforeRender'].includes(event)) chart.on(event,payload=>send(event,payload));
293
+ const ready={{viewport:chart.getViewport(),pointSelection:chart.getPointSelection(),traceVisibility:chart.getTraceVisibility(),dragMode:chart.getDragMode(),polarRotation:chart.getPolarRotation(),camera3d:chart.getCamera3d()}};
294
+ // The host already owns the packed initial data. Sending it back through
295
+ // getDataState and getChartState would duplicate the entire input twice.
296
+ if(packedPointCount===null) {{ready.data=chart.getDataState();ready.chartState=chart.getChartState();}}
297
+ send('ready',ready);
298
+ send('lineAxisDiagnostics',getLastLineAxisLayoutDiagnostics());
299
+ }} catch(error) {{ send('bridgeError',{{message:String(error)}}); throw error; }}
300
+ let since={packed_command_count};
301
+ async function commands() {{
302
+ while(chart) {{
303
+ try {{
304
+ const response=await fetch('{prefix}commands?since='+since,{{cache:'no-store'}});
305
+ if(!response.ok) break;
306
+ const pending=await response.json();
307
+ for(const command of pending) {{
308
+ since=command.id;
309
+ if(immediateCommands.delete(command.id)) continue;
310
+ runCommand(command);
311
+ }}
312
+ }} catch(error) {{ send('bridgeError',{{message:String(error)}}); break; }}
313
+ }}
314
+ }}
315
+ commands();
316
+ </script></body></html>"""
317
+
318
+
319
+ class Chart:
320
+ """Python chart with a browser-rendered interactive view and source-shaped methods."""
321
+
322
+ def __init__(self, options: Mapping[str, Any], *, _defer_initial_scene: bool = False):
323
+ if not isinstance(options, Mapping):
324
+ raise TypeError("options must be a mapping")
325
+ if _defer_initial_scene:
326
+ # Internal gallery path. Copy each flat numeric source point once,
327
+ # preserving the same caller isolation as the public constructor.
328
+ supplied = dict(options)
329
+ data = supplied.pop("data", None)
330
+ if (supplied.get("type") != "line" or not isinstance(data, list)
331
+ or any(type(point) is not dict or len(point) != 2
332
+ or type(point.get("x")) not in (int, float)
333
+ or type(point.get("y")) not in (int, float)
334
+ or not isfinite(point["x"]) or not isfinite(point["y"])
335
+ for point in data)):
336
+ raise ValueError("deferred initial scene requires flat finite line data")
337
+ self.options = deepcopy(supplied)
338
+ self.options["data"] = [dict(point) for point in data]
339
+ if not self._can_use_progressive_skeleton(self.options):
340
+ raise ValueError("deferred initial scene requires eligible progressive line options")
341
+ else:
342
+ self.options = deepcopy(dict(options))
343
+ self._deferred_initial_scene = _defer_initial_scene
344
+ self._browser_progressive_scene_mode = _defer_initial_scene
345
+ self._packed_initial: bytes | None = None
346
+ self._packed_initial_count: int | None = None
347
+ self._packed_initial_options: dict | None = None
348
+ self._packed_initial_command_count = 0
349
+ # Keep the constructor's owned snapshot until the binary bootstrap is
350
+ # materialized. Later setters replace data/options rather than editing
351
+ # these values in place, so queued commands can replay from this base.
352
+ self._bootstrap_options = ({key: (value if key == "data" else deepcopy(value))
353
+ for key, value in self.options.items()}
354
+ if _defer_initial_scene else None)
355
+ self._lock = RLock()
356
+ self._changed = Condition(self._lock)
357
+ self._events: list[dict] = []
358
+ self._preapplied_overlay_events: dict[int, str] = {}
359
+ self._preapplied_before_events: dict[int, str] = {}
360
+ self._preapplied_zoom_start_ids: set[int] = set()
361
+ self._handlers: dict[str, list[Callable]] = {}
362
+ self._callbacks: dict[str, Callable] = {}
363
+ self._callback_ids: dict[int, str] = {}
364
+ self._commands: list[dict] = []
365
+ self._latest_scene_command_id = 0
366
+ self._server: ThreadingHTTPServer | None = None
367
+ self._thread: Thread | None = None
368
+ self._closed = False
369
+ if _defer_initial_scene:
370
+ skeleton_options = {**self._scene_options(), "data": []}
371
+ self._last_scene: dict | None = plot(skeleton_options)
372
+ else:
373
+ self._last_scene = plot(self._scene_options())
374
+ self._latest: dict[str, Any] = {}
375
+ self._command_results: dict[int, dict] = {}
376
+ self._session = uuid4().hex
377
+ self._viewport = deepcopy(self.options.get("viewport"))
378
+ self._point_selection: dict | None = None
379
+ self._data_state: dict | None = None
380
+ self._trace_visibility: dict | None = None
381
+ self._drag_mode = (self.options.get("interactions") or {}).get("dragMode", "zoom")
382
+ self._polar_rotation: dict | None = None
383
+ self._camera3d_revision = 0
384
+ self._initial_cameras3d = self._cameras_from_scene(self._last_scene)
385
+ self._tooltip: dict | None = None
386
+ self._crosshair: dict | None = None
387
+ self._chart_state: dict | None = None
388
+
389
+ def _serialize_browser(self, value: Any) -> Any:
390
+ if callable(value):
391
+ identity = id(value)
392
+ identifier = self._callback_ids.get(identity)
393
+ if identifier is None:
394
+ identifier = uuid4().hex
395
+ self._callback_ids[identity] = identifier
396
+ self._callbacks[identifier] = value
397
+ return {"__fastplotpy_callback__": identifier}
398
+ if isinstance(value, Mapping):
399
+ return {key: self._serialize_browser(item) for key, item in value.items()}
400
+ if isinstance(value, (list, tuple)):
401
+ return [self._serialize_browser(item) for item in value]
402
+ return value
403
+
404
+ def _browser_options(self) -> dict:
405
+ if self._packed_initial_options is not None:
406
+ return self._packed_initial_options
407
+ options = {key: value for key, value in self.options.items()
408
+ if key not in ("onBeforeZoom", "onBeforeCamera3dChange", "onBeforeRender")}
409
+ if options.get("engine") == "rust":
410
+ options["engine"] = "ts"
411
+ return self._serialize_browser(options)
412
+
413
+ def _scene_options(self) -> dict:
414
+ return {key: value for key, value in self.options.items() if key not in _CHART_ONLY_OPTIONS}
415
+
416
+ def _plot_candidate(self, candidate: Mapping) -> dict:
417
+ scene_options = {key: value for key, value in candidate.items()
418
+ if key not in _CHART_ONLY_OPTIONS}
419
+ if self._browser_progressive_scene_mode and self._can_use_progressive_skeleton(candidate):
420
+ # Pinned chart.ts renderWithSize returns the progressive skeleton
421
+ # synchronously; its full scene arrives on the later render event.
422
+ # Keep all original points in host options and in the browser.
423
+ return plot({**scene_options, "data": []})
424
+ return plot(scene_options)
425
+
426
+ @staticmethod
427
+ def _can_use_progressive_skeleton(candidate: Mapping) -> bool:
428
+ if (candidate.get("type") != "line" or candidate.get("engine") not in ("auto", "ts", None)
429
+ or candidate.get("progressiveRender") is not True
430
+ or candidate.get("traces") is not None
431
+ or set(candidate) - {"type", "engine", "data", "traces", "width", "height", "title",
432
+ "progressiveRender", "interactions", "style", "axes", "viewport"}):
433
+ return False
434
+ axes = candidate.get("axes") or {}
435
+ for groups in (axes.get("xAxes") or {}, axes.get("yAxes") or {}):
436
+ if any((axis or {}).get("scale", "linear") != "linear" for axis in groups.values()):
437
+ return False
438
+ data = candidate.get("data")
439
+ return (isinstance(data, list) and all(
440
+ type(point) is dict and len(point) == 2
441
+ and type(point.get("x")) in (int, float)
442
+ and type(point.get("y")) in (int, float)
443
+ and isfinite(point["x"]) and isfinite(point["y"])
444
+ for point in data))
445
+
446
+ def _clear_tooltip_for_scene_change(self) -> bool:
447
+ previous = self._tooltip
448
+ if previous is None:
449
+ return False
450
+ self._tooltip = None
451
+ self._emit_local("tooltipClear", {"type": "tooltipClear", "source": "api",
452
+ "activeSubplotId": previous["activeSubplotId"],
453
+ "pointer": deepcopy(previous["pointer"]),
454
+ "previous": deepcopy(previous)})
455
+ return True
456
+
457
+ def render(self) -> dict:
458
+ with self._lock:
459
+ if self._closed:
460
+ raise RuntimeError("chart is destroyed")
461
+ prior_scene = self._last_scene
462
+ cleared = self._clear_tooltip_for_scene_change()
463
+ if self._closed or self._last_scene is not prior_scene:
464
+ return deepcopy(self._last_scene)
465
+ self._dispatch_before("beforeRender", {"type": "beforeRender",
466
+ "size": {"width": self.options["width"],
467
+ "height": self.options["height"]}})
468
+ if self._closed or self._last_scene is not prior_scene:
469
+ return deepcopy(self._last_scene)
470
+ self._last_scene = self._plot_candidate(self.options)
471
+ identifier = self.call("render")
472
+ if cleared:
473
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
474
+ self._preapplied_before_events[identifier] = "beforeRender"
475
+ return deepcopy(self._last_scene)
476
+
477
+ def getScene(self) -> dict | None:
478
+ with self._lock:
479
+ return deepcopy(self._last_scene)
480
+
481
+ def _prepare_packed_initial(self) -> None:
482
+ if not self._deferred_initial_scene or self._packed_initial is not None:
483
+ return
484
+ initial = self._bootstrap_options
485
+ if initial is None:
486
+ return
487
+ points = initial["data"]
488
+ packed = bytearray(len(points) * 16)
489
+ for index, point in enumerate(points):
490
+ pack_into("<dd", packed, index * 16, point["x"], point["y"])
491
+ self._packed_initial = bytes(packed)
492
+ self._packed_initial_count = len(points)
493
+ # Commands queued before serve must replay after the constructor
494
+ # snapshot, including overlays and read-only calls with results.
495
+ self._packed_initial_command_count = 0
496
+ self._packed_initial_options = self._serialize_browser({
497
+ key: ("ts" if key == "engine" and value == "rust" else value)
498
+ for key, value in initial.items()
499
+ if key not in ("data", "onBeforeZoom", "onBeforeCamera3dChange", "onBeforeRender")
500
+ })
501
+ self._bootstrap_options = None
502
+
503
+ def _traces(self) -> list[dict]:
504
+ if self.options.get("traces") is not None:
505
+ return deepcopy(self.options["traces"])
506
+ return [{"data": deepcopy(self.options.get("data") or [])}]
507
+
508
+ def _trace_count(self) -> int:
509
+ traces = self.options.get("traces")
510
+ return len(traces) if traces is not None else 1
511
+
512
+ def _trace_lengths(self) -> list[int]:
513
+ traces = self.options.get("traces")
514
+ return ([len(trace.get("data") or []) for trace in traces]
515
+ if traces is not None else [len(self.options.get("data") or [])])
516
+
517
+ def _restore_point_selection(self, previous: Mapping,
518
+ trimmed_trace: int | None = None,
519
+ removed_count: int = 0) -> None:
520
+ # Selection reconciliation needs lengths, not a copy of every datum.
521
+ source_traces = self.options.get("traces")
522
+ counts = ([len(trace.get("data") or []) for trace in source_traces]
523
+ if source_traces is not None else [len(self.options.get("data") or [])])
524
+ original = previous.get("selections")
525
+ items = original if isinstance(original, list) else (
526
+ [previous["selection"]] if previous.get("selection") is not None else [])
527
+ kept = []
528
+ for item in items:
529
+ trace_index, point_index = item["traceIndex"], item["pointIndex"]
530
+ if trace_index == trimmed_trace:
531
+ point_index -= removed_count
532
+ if 0 <= trace_index < len(counts) and 0 <= point_index < counts[trace_index]:
533
+ kept.append({"traceIndex": trace_index, "pointIndex": point_index})
534
+ state = {"traceCount": len(counts), "selection": kept[-1] if kept else None}
535
+ if isinstance(original, list):
536
+ state["selections"] = kept
537
+ self._point_selection = state
538
+
539
+ def getDataState(self) -> dict:
540
+ with self._lock:
541
+ if self._closed:
542
+ return None
543
+ if self._data_state is not None:
544
+ return deepcopy(self._data_state)
545
+ traces = self._traces()
546
+ return {"traceCount": len(traces), "traceData": [
547
+ [_snapshot_data_point(point) for point in trace.get("data") or []] for trace in traces]}
548
+
549
+ def setDataState(self, state: Mapping, options: Mapping | None = None) -> dict | None:
550
+ with self._lock:
551
+ if self._closed:
552
+ return None
553
+ traces = self._traces()
554
+ if not _valid_data_state(state, len(traces)):
555
+ return None
556
+ current = self.getDataState()
557
+ next_data = [[_snapshot_data_point(point) for point in data] for data in state["traceData"]]
558
+ if current["traceData"] == next_data:
559
+ return None
560
+ candidate = deepcopy(self.options)
561
+ if candidate.get("traces") is None and len(traces) == 1:
562
+ candidate["data"] = deepcopy(next_data[0])
563
+ else:
564
+ for index, trace in enumerate(traces):
565
+ trace["data"] = deepcopy(next_data[index])
566
+ candidate["traces"] = traces
567
+ candidate["data"] = None
568
+ try:
569
+ scene = self._plot_candidate(candidate)
570
+ except (TypeError, ValueError):
571
+ return None
572
+ previous_selection = self.getPointSelection()
573
+ prior_scene = self._last_scene
574
+ cleared = self._clear_tooltip_for_scene_change()
575
+ if self._closed or self._last_scene is not prior_scene:
576
+ return deepcopy(self._last_scene)
577
+ self.options = candidate
578
+ self._last_scene = scene
579
+ self._data_state = {"traceCount": len(traces), "traceData": deepcopy(next_data)}
580
+ if previous_selection is not None:
581
+ self._restore_point_selection(previous_selection)
582
+ args = (deepcopy(dict(state)),) if options is None else (deepcopy(dict(state)), deepcopy(dict(options)))
583
+ identifier = self.call("setDataState", *args)
584
+ if cleared:
585
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
586
+ return deepcopy(scene)
587
+
588
+ def getPointSelection(self) -> dict | None:
589
+ with self._lock:
590
+ if self._point_selection is not None:
591
+ return deepcopy(self._point_selection)
592
+ state = {"traceCount": self._trace_count(), "selection": None}
593
+ selection_options = (self.options.get("interactions") or {}).get("pointSelection") or {}
594
+ if isinstance(selection_options, Mapping) and selection_options.get("mode") == "multiple":
595
+ state["selections"] = []
596
+ return state
597
+
598
+ def setPointSelection(self, state: Mapping | None,
599
+ options: Mapping | None = None) -> dict | None:
600
+ with self._lock:
601
+ if self._closed or self.options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
602
+ return None
603
+ counts = self._trace_lengths()
604
+ if state is None:
605
+ normalized = {"traceCount": len(counts), "selection": None}
606
+ elif not isinstance(state, Mapping) or type(state.get("traceCount")) is not int or state["traceCount"] != len(counts):
607
+ return None
608
+ else:
609
+ def selection(value):
610
+ if not isinstance(value, Mapping):
611
+ return None
612
+ trace_index, point_index = value.get("traceIndex"), value.get("pointIndex")
613
+ if (type(trace_index) is not int or type(point_index) is not int or
614
+ trace_index < 0 or trace_index >= len(counts) or
615
+ point_index < 0 or point_index >= counts[trace_index]):
616
+ return None
617
+ return {"traceIndex": trace_index, "pointIndex": point_index}
618
+ if "selections" in state:
619
+ values = state["selections"]
620
+ if not isinstance(values, list):
621
+ return None
622
+ selections = [selection(value) for value in values]
623
+ if (any(value is None for value in selections) or
624
+ len({(value["traceIndex"], value["pointIndex"]) for value in selections}) != len(selections)):
625
+ return None
626
+ final = selection(state.get("selection"))
627
+ if selections and final != selections[-1] or not selections and state.get("selection") is not None:
628
+ return None
629
+ normalized = {"traceCount": len(counts), "selection": final,
630
+ "selections": selections}
631
+ else:
632
+ final = selection(state.get("selection"))
633
+ if state.get("selection") is not None and final is None:
634
+ return None
635
+ normalized = {"traceCount": len(counts), "selection": final}
636
+ if normalized == self.getPointSelection():
637
+ return None
638
+ self._point_selection = normalized
639
+ self.call("setPointSelection", deepcopy(state), *(() if options is None else (deepcopy(dict(options)),)))
640
+ return self.getScene()
641
+
642
+ def clearPointSelection(self, options: Mapping | None = None) -> dict | None:
643
+ with self._lock:
644
+ if self._closed:
645
+ return None
646
+ state = {"traceCount": self._trace_count(), "selection": None}
647
+ if self.getPointSelection().get("selection") is None:
648
+ return None
649
+ self._point_selection = state
650
+ self.call("clearPointSelection", *(() if options is None else (deepcopy(dict(options)),)))
651
+ return self.getScene()
652
+
653
+ def getViewport(self) -> dict | None:
654
+ with self._lock:
655
+ if self.options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
656
+ return None
657
+ return deepcopy(self._viewport)
658
+
659
+ def _update_viewport(self, viewport: Mapping | None, method: str,
660
+ options: Mapping | None = None) -> dict | None:
661
+ with self._lock:
662
+ if self._closed or self.options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
663
+ return None
664
+ from .interactions import mergeAxisViewportState
665
+ proposed = deepcopy(viewport)
666
+ if method == "zoom" and proposed is not None:
667
+ proposed = mergeAxisViewportState(self._viewport, {
668
+ "xAxes": proposed.get("xAxes") or {}, "yAxes": proposed.get("yAxes") or {},
669
+ })
670
+ if proposed == self._viewport:
671
+ return None
672
+ previous = deepcopy(self._viewport)
673
+ patch = {"xAxes": deepcopy((proposed or {}).get("xAxes") or {}),
674
+ "yAxes": deepcopy((proposed or {}).get("yAxes") or {})}
675
+ if self._before_zoom(previous, proposed, patch, options):
676
+ return None
677
+ candidate = {**self.options, "viewport": proposed}
678
+ scene = self._plot_candidate(candidate)
679
+ prior_scene = self._last_scene
680
+ cleared = self._clear_tooltip_for_scene_change()
681
+ if self._closed or self._last_scene is not prior_scene:
682
+ return deepcopy(self._last_scene)
683
+ self.options = candidate
684
+ self._viewport = proposed
685
+ self._last_scene = scene
686
+ args = (deepcopy(viewport),) if options is None else (deepcopy(viewport), deepcopy(dict(options)))
687
+ identifier = self.call(method, *args)
688
+ if cleared:
689
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
690
+ if options is None or options.get("emit") is not False:
691
+ self._preapplied_before_events[identifier] = "beforeZoom"
692
+ self._preapplied_zoom_start_ids.add(identifier)
693
+ return deepcopy(scene)
694
+
695
+ def setViewport(self, viewport: Mapping | None, options: Mapping | None = None) -> dict | None:
696
+ return self._update_viewport(viewport, "setViewport", options)
697
+
698
+ def zoom(self, viewport: Mapping, options: Mapping | None = None) -> dict | None:
699
+ return self._update_viewport(viewport, "zoom", options)
700
+
701
+ def _programmatic_viewport(self, method: str, options: Mapping | None,
702
+ scale: float | None = None, _probe: bool = False) -> dict | bool | None:
703
+ from .interactions import (mergeAxisViewportState, resolveInteractionBindingsFromPlotOptions,
704
+ resolveAxisZoomInteractionPatch, resolveAxisPanInteractionPatch)
705
+ from .defaults import get_defaults
706
+ with self._lock:
707
+ if self._closed or self.options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
708
+ return None
709
+ settings = dict(options or {})
710
+ if method != "pan" and self.options.get("type") in {"bar", "polar"}:
711
+ return None
712
+ ids = ([settings["subplotId"]] if settings.get("subplotId") else
713
+ list(dict.fromkeys(binding["subplotId"] for binding in
714
+ resolveInteractionBindingsFromPlotOptions(self._scene_options()))))
715
+ if not ids:
716
+ return None
717
+ factor = settings.get("factor")
718
+ if type(factor) not in (int, float) or not isfinite(factor) or factor <= 1:
719
+ factor = get_defaults("zoom")["factor"]
720
+ resolved_scale = 1 / factor if scale is not None and scale < 1 else factor
721
+ patch = {"xAxes": {}, "yAxes": {}}
722
+ for subplot_id in ids:
723
+ input_options = {"options": self._scene_options(), "activeSubplotId": subplot_id,
724
+ "mode": settings.get("mode", "xy")}
725
+ if method == "pan":
726
+ input_options.update({"xRatio": settings.get("xRatio", 0),
727
+ "yRatio": settings.get("yRatio", 0)})
728
+ piece = resolveAxisPanInteractionPatch(input_options)
729
+ else:
730
+ input_options.update({"xRatio": settings.get("xRatio", .5),
731
+ "yRatio": settings.get("yRatio", .5),
732
+ "scale": resolved_scale})
733
+ piece = resolveAxisZoomInteractionPatch(input_options)
734
+ patch["xAxes"].update(piece.get("xAxes") or {})
735
+ patch["yAxes"].update(piece.get("yAxes") or {})
736
+ for key in ("resetXAxes", "resetYAxes"):
737
+ patch.setdefault(key, [])
738
+ patch[key].extend(item for item in piece.get(key) or [] if item not in patch[key])
739
+ for dimension in ("xAxes", "yAxes"):
740
+ if settings.get(dimension) is not None:
741
+ allowed = set(settings[dimension])
742
+ patch[dimension] = {key: value for key, value in patch[dimension].items() if key in allowed}
743
+ reset_key = "reset" + dimension[0].upper() + dimension[1:]
744
+ patch[reset_key] = [key for key in patch[reset_key] if key in allowed]
745
+ if not patch["xAxes"] and not patch["yAxes"] and not patch["resetXAxes"] and not patch["resetYAxes"]:
746
+ return None
747
+ if _probe:
748
+ return True
749
+ proposed = mergeAxisViewportState(self._viewport, patch)
750
+ if proposed == self._viewport:
751
+ return None
752
+ previous = deepcopy(self._viewport)
753
+ if self._before_zoom(previous, proposed, patch, settings):
754
+ return None
755
+ candidate = {**self.options, "viewport": proposed}
756
+ scene = self._plot_candidate(candidate)
757
+ prior_scene = self._last_scene
758
+ cleared = self._clear_tooltip_for_scene_change()
759
+ if self._closed or self._last_scene is not prior_scene:
760
+ return deepcopy(self._last_scene)
761
+ self.options = candidate
762
+ self._viewport = proposed
763
+ self._last_scene = scene
764
+ identifier = self.call(method, *(() if options is None else (deepcopy(dict(options)),)))
765
+ if cleared:
766
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
767
+ if settings.get("emit") is not False:
768
+ self._preapplied_before_events[identifier] = "beforeZoom"
769
+ self._preapplied_zoom_start_ids.add(identifier)
770
+ return deepcopy(scene)
771
+
772
+ def zoomIn(self, options: Mapping | None = None) -> dict | None:
773
+ return self._programmatic_viewport("zoomIn", options, .5)
774
+
775
+ def zoomOut(self, options: Mapping | None = None) -> dict | None:
776
+ return self._programmatic_viewport("zoomOut", options, 2)
777
+
778
+ def pan(self, options: Mapping | None = None) -> dict | None:
779
+ return self._programmatic_viewport("pan", options)
780
+
781
+ def getViewportControlState(self, options: Mapping | None = None) -> dict:
782
+ from .defaults import get_defaults
783
+ with self._lock:
784
+ keys = ("canZoomIn", "canZoomOut", "canPanLeft", "canPanRight",
785
+ "canPanUp", "canPanDown", "canResetZoom", "canAutoscale")
786
+ if self._closed or self.options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
787
+ return dict.fromkeys(keys, False)
788
+ settings = dict(options or {})
789
+ pan_step = settings.get("panStepRatio")
790
+ if type(pan_step) not in (int, float) or not isfinite(pan_step) or pan_step <= 0:
791
+ pan_step = get_defaults("control")["panStepRatio"]
792
+ pan = dict(settings.get("pan") or {})
793
+ controls = {
794
+ "canZoomIn": bool(self._programmatic_viewport("zoomIn", settings.get("zoom"), .5, True)),
795
+ "canZoomOut": bool(self._programmatic_viewport("zoomOut", settings.get("zoom"), 2, True)),
796
+ "canPanLeft": bool(self._programmatic_viewport("pan", {**pan, "xRatio": -pan_step, "yRatio": 0}, _probe=True)),
797
+ "canPanRight": bool(self._programmatic_viewport("pan", {**pan, "xRatio": pan_step, "yRatio": 0}, _probe=True)),
798
+ "canPanUp": bool(self._programmatic_viewport("pan", {**pan, "xRatio": 0, "yRatio": -pan_step}, _probe=True)),
799
+ "canPanDown": bool(self._programmatic_viewport("pan", {**pan, "xRatio": 0, "yRatio": pan_step}, _probe=True)),
800
+ "canResetZoom": self._viewport is not None,
801
+ "canAutoscale": self._viewport is not None,
802
+ }
803
+ return controls
804
+
805
+ def resetZoom(self, options: Mapping | None = None) -> dict | None:
806
+ with self._lock:
807
+ if self._viewport is None:
808
+ return None
809
+ candidate = {**self.options, "viewport": None}
810
+ scene = self._plot_candidate(candidate)
811
+ prior_scene = self._last_scene
812
+ cleared = self._clear_tooltip_for_scene_change()
813
+ if self._closed or self._last_scene is not prior_scene:
814
+ return deepcopy(self._last_scene)
815
+ self.options = candidate
816
+ self._viewport = None
817
+ self._last_scene = scene
818
+ identifier = self.call("resetZoom", *(() if options is None else (deepcopy(dict(options)),)))
819
+ if cleared:
820
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
821
+ return deepcopy(scene)
822
+
823
+ def autoscale(self, options: Mapping | None = None) -> dict | None:
824
+ return self.resetZoom(options)
825
+
826
+ def getDragMode(self) -> str:
827
+ with self._lock:
828
+ return self._drag_mode
829
+
830
+ def setDragMode(self, mode: str) -> None:
831
+ with self._lock:
832
+ if self._closed:
833
+ return
834
+ normalized = "pan" if mode == "pan" else "zoom"
835
+ if normalized == self._drag_mode:
836
+ return
837
+ self._drag_mode = normalized
838
+ interactions = deepcopy(self.options.get("interactions") or {})
839
+ interactions["dragMode"] = normalized
840
+ self.options["interactions"] = interactions
841
+ self.call("setDragMode", mode)
842
+
843
+ def getTraceVisibility(self) -> dict:
844
+ with self._lock:
845
+ if self._trace_visibility is not None:
846
+ return deepcopy(self._trace_visibility)
847
+ traces = self.options.get("traces")
848
+ if traces is None:
849
+ traces = ({"data": self.options.get("data") or []},)
850
+ visible = [index for index, trace in enumerate(traces) if trace.get("visible") is not False]
851
+ return {"traceCount": len(traces), "visibleTraceIndexes": visible,
852
+ "hiddenTraceIndexes": [index for index in range(len(traces)) if index not in visible]}
853
+
854
+ def _apply_visibility(self, traces: list[dict], method: str, args: tuple) -> dict | None:
855
+ candidate = {**self.options, "traces": traces, "data": None}
856
+ scene = self._plot_candidate(candidate)
857
+ prior_scene = self._last_scene
858
+ cleared = self._clear_tooltip_for_scene_change()
859
+ if self._closed or self._last_scene is not prior_scene:
860
+ return deepcopy(self._last_scene)
861
+ self.options = candidate
862
+ self._last_scene = scene
863
+ self._trace_visibility = None
864
+ identifier = self.call(method, *args)
865
+ if cleared:
866
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
867
+ return deepcopy(scene)
868
+
869
+ def setTraceVisibility(self, traceIndexes: int | list[int], visible: bool,
870
+ options: Mapping | None = None) -> dict | None:
871
+ with self._lock:
872
+ if self._closed:
873
+ return None
874
+ traces = self._traces()
875
+ indexes = traceIndexes if isinstance(traceIndexes, list) else [traceIndexes]
876
+ indexes = sorted({value for value in indexes if type(value) is int and 0 <= value < len(traces)})
877
+ if not indexes:
878
+ return None
879
+ if visible is False and sum(trace.get("visible") is not False for trace in traces) - sum(
880
+ traces[index].get("visible") is not False for index in indexes) <= 0:
881
+ return None
882
+ changed = [index for index in indexes if (traces[index].get("visible") is not False) != visible]
883
+ if not changed:
884
+ return None
885
+ for index in changed:
886
+ traces[index]["visible"] = visible
887
+ args = (traceIndexes, visible) if options is None else (traceIndexes, visible, dict(options))
888
+ return self._apply_visibility(traces, "setTraceVisibility", args)
889
+
890
+ def setTraceVisibilityState(self, state: Mapping,
891
+ options: Mapping | None = None) -> dict | None:
892
+ with self._lock:
893
+ traces = self._traces()
894
+ count = len(traces)
895
+ if (not isinstance(state, Mapping) or type(state.get("traceCount")) is not int
896
+ or state["traceCount"] != count or not isinstance(state.get("visibleTraceIndexes"), list)
897
+ or not isinstance(state.get("hiddenTraceIndexes"), list)):
898
+ return None
899
+ visible, hidden = state["visibleTraceIndexes"], state["hiddenTraceIndexes"]
900
+ if (any(type(index) is not int or index < 0 or index >= count for index in visible + hidden)
901
+ or len(set(visible)) != len(visible) or len(set(hidden)) != len(hidden)
902
+ or len(visible) + len(hidden) != count or set(visible) & set(hidden)
903
+ or count > 0 and not visible):
904
+ return None
905
+ if all((trace.get("visible") is not False) == (index in visible)
906
+ for index, trace in enumerate(traces)):
907
+ return None
908
+ for index, trace in enumerate(traces):
909
+ trace["visible"] = index in visible
910
+ args = (dict(state),) if options is None else (dict(state), dict(options))
911
+ return self._apply_visibility(traces, "setTraceVisibilityState", args)
912
+
913
+ def resetTraceVisibility(self, options: Mapping | None = None) -> dict | None:
914
+ with self._lock:
915
+ traces = self._traces()
916
+ if all(trace.get("visible") is not False for trace in traces):
917
+ return None
918
+ for trace in traces:
919
+ trace["visible"] = True
920
+ return self._apply_visibility(traces, "resetTraceVisibility",
921
+ () if options is None else (dict(options),))
922
+
923
+ def getPolarRotation(self) -> dict | None:
924
+ with self._lock:
925
+ if self._polar_rotation is not None:
926
+ return deepcopy(self._polar_rotation)
927
+ if self.options.get("type") != "polar":
928
+ return None
929
+ from .defaults import get_defaults
930
+ return {"startAngleDegrees": (self.options.get("polar") or {}).get(
931
+ "startAngleDegrees", get_defaults("polar")["startAngleDegrees"])}
932
+
933
+ def setPolarRotation(self, state: Mapping, options: Mapping | None = None) -> dict | None:
934
+ with self._lock:
935
+ if self._closed or self.options.get("type") != "polar" or not isinstance(state, Mapping):
936
+ return None
937
+ angle = state.get("startAngleDegrees")
938
+ if type(angle) not in (int, float) or not isfinite(angle):
939
+ return None
940
+ previous = self.getPolarRotation()
941
+ if previous["startAngleDegrees"] == angle:
942
+ return None
943
+ candidate = {**self.options, "polar": {**(self.options.get("polar") or {}),
944
+ "startAngleDegrees": angle}}
945
+ scene = self._plot_candidate(candidate)
946
+ prior_scene = self._last_scene
947
+ cleared = self._clear_tooltip_for_scene_change()
948
+ if self._closed or self._last_scene is not prior_scene:
949
+ return deepcopy(self._last_scene)
950
+ self.options = candidate
951
+ self._polar_rotation = {"startAngleDegrees": angle}
952
+ self._last_scene = scene
953
+ identifier = self.call("setPolarRotation", deepcopy(dict(state)),
954
+ *(() if options is None else (deepcopy(dict(options)),)))
955
+ if cleared:
956
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
957
+ return deepcopy(scene)
958
+
959
+ def getCamera3d(self, subplotId: str | None = None) -> dict | None:
960
+ with self._lock:
961
+ cameras = self._cameras_from_scene(self._last_scene)
962
+ if subplotId is None:
963
+ return deepcopy(next(iter(cameras.values()))) if len(cameras) == 1 else None
964
+ if not isinstance(subplotId, str):
965
+ return None
966
+ return deepcopy(cameras.get(subplotId))
967
+
968
+ @staticmethod
969
+ def _cameras_from_scene(scene: Mapping | None) -> dict[str, dict]:
970
+ if not scene:
971
+ return {}
972
+ metadata = scene.get("scenes3d") or ([scene["scene3d"]] if scene.get("scene3d") else [])
973
+ return {item["subplotId"]: deepcopy(item["camera"]) for item in metadata}
974
+
975
+ def _camera_subplot_id(self, subplotId: str | None) -> str | None:
976
+ cameras = self._cameras_from_scene(self._last_scene)
977
+ if subplotId is None:
978
+ return next(iter(cameras)) if len(cameras) == 1 else None
979
+ return subplotId if isinstance(subplotId, str) and subplotId in cameras else None
980
+
981
+ def _camera_candidate(self, camera: Mapping, subplot_id: str) -> dict:
982
+ scene3d = deepcopy(self.options.get("scene3d") or {})
983
+ scoped = scene3d.get("subplots") or {}
984
+ if len(self._cameras_from_scene(self._last_scene)) > 1 or subplot_id in scoped:
985
+ scoped = deepcopy(scoped)
986
+ scoped[subplot_id] = {**scoped.get(subplot_id, {}), "camera": deepcopy(dict(camera))}
987
+ scene3d["subplots"] = scoped
988
+ else:
989
+ scene3d["camera"] = deepcopy(dict(camera))
990
+ return {**self.options, "scene3d": scene3d}
991
+
992
+ def _register_camera_resets(self, scene: Mapping | None, replace: bool = False) -> None:
993
+ cameras = self._cameras_from_scene(scene)
994
+ if replace:
995
+ self._initial_cameras3d = cameras
996
+ else:
997
+ for subplot_id, camera in cameras.items():
998
+ self._initial_cameras3d.setdefault(subplot_id, camera)
999
+
1000
+ def _apply_camera3d(self, state: Mapping, method: str,
1001
+ options: Mapping | None = None) -> dict | None:
1002
+ with self._lock:
1003
+ if self._closed or self.options.get("type") not in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
1004
+ return None
1005
+ subplot_id = self._camera_subplot_id((options or {}).get("subplotId"))
1006
+ if subplot_id is None:
1007
+ return None
1008
+ required = ("projection", "eye", "target", "up", "fieldOfViewDegrees",
1009
+ "orthographicScale", "near", "far")
1010
+ if not isinstance(state, Mapping) or any(key not in state for key in required):
1011
+ return None
1012
+ from .scene3d import _camera
1013
+ try:
1014
+ normalized = _camera({"scene3d": {"camera": state}})
1015
+ except (TypeError, ValueError):
1016
+ return None
1017
+ previous = self.getCamera3d(subplot_id)
1018
+ if normalized == previous:
1019
+ return None
1020
+ reason = {"setCamera3d": "set", "orbitCamera3d": "orbit",
1021
+ "dollyCamera3d": "dolly", "panCamera3d": "pan",
1022
+ "resetCamera3d": "reset"}.get(method, "set")
1023
+ self._camera3d_revision += 1
1024
+ revision = self._camera3d_revision
1025
+ prior_scene = self._last_scene
1026
+ if not options or options.get("emit") is not False:
1027
+ before = {"type": "beforeCamera3dChange", "subplotId": subplot_id,
1028
+ "source": "api", "reason": reason,
1029
+ "previous": previous, "state": deepcopy(normalized),
1030
+ "defaultPrevented": False}
1031
+ if self._dispatch_before("beforeCamera3dChange", before):
1032
+ return None
1033
+ if self._closed or revision != self._camera3d_revision or self._last_scene is not prior_scene:
1034
+ return None
1035
+ candidate = self._camera_candidate(normalized, subplot_id)
1036
+ scene = self._plot_candidate(candidate)
1037
+ cleared = self._clear_tooltip_for_scene_change()
1038
+ if self._closed or self._last_scene is not prior_scene:
1039
+ return deepcopy(self._last_scene)
1040
+ self.options = candidate
1041
+ self._last_scene = scene
1042
+ identifier = self.call(method, *((deepcopy(dict(state)),) if method == "setCamera3d" else ()),
1043
+ *(() if options is None else (deepcopy(dict(options)),)))
1044
+ if cleared:
1045
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
1046
+ if not options or options.get("emit") is not False:
1047
+ self._preapplied_before_events[identifier] = "beforeCamera3dChange"
1048
+ return deepcopy(scene)
1049
+
1050
+ def setCamera3d(self, state: Mapping, options: Mapping | None = None) -> dict | None:
1051
+ return self._apply_camera3d(state, "setCamera3d", options)
1052
+
1053
+ def _camera3d_interaction(self) -> dict:
1054
+ from .defaults import get_defaults
1055
+ defaults = get_defaults("camera3d")
1056
+ supplied = (self.options.get("interactions") or {}).get("camera3d")
1057
+ supplied = supplied if isinstance(supplied, Mapping) else {}
1058
+ resolved = {}
1059
+ for key in ("minDistance", "maxDistance", "minOrthographicScale",
1060
+ "maxOrthographicScale", "maxPitchDegrees"):
1061
+ value = supplied.get(key)
1062
+ valid = type(value) in (int, float) and isfinite(value) and (value >= 0 if key == "maxPitchDegrees" else value > 0)
1063
+ resolved[key] = value if valid else defaults[key]
1064
+ resolved["maxDistance"] = max(resolved["minDistance"], resolved["maxDistance"])
1065
+ resolved["maxOrthographicScale"] = max(resolved["minOrthographicScale"], resolved["maxOrthographicScale"])
1066
+ resolved["maxPitchDegrees"] = max(1, min(89.9, resolved["maxPitchDegrees"]))
1067
+ return resolved
1068
+
1069
+ def orbitCamera3d(self, options: Mapping | None = None) -> dict | None:
1070
+ from .camera3d import orbitScene3dCamera
1071
+ with self._lock:
1072
+ settings = dict(options or {})
1073
+ yaw, pitch = settings.get("yawDegrees", 0), settings.get("pitchDegrees", 0)
1074
+ if any(type(value) not in (int, float) or not isfinite(value) for value in (yaw, pitch)):
1075
+ return None
1076
+ current = self.getCamera3d(settings.get("subplotId"))
1077
+ if current is None:
1078
+ return None
1079
+ next_camera = orbitScene3dCamera(current, yaw, pitch,
1080
+ self._camera3d_interaction()["maxPitchDegrees"])
1081
+ return self._apply_camera3d(next_camera, "orbitCamera3d", options) if next_camera else None
1082
+
1083
+ def dollyCamera3d(self, options: Mapping) -> dict | None:
1084
+ from .camera3d import dollyScene3dCamera
1085
+ with self._lock:
1086
+ if not isinstance(options, Mapping):
1087
+ return None
1088
+ settings = dict(options)
1089
+ factor = settings.get("factor")
1090
+ if type(factor) not in (int, float) or not isfinite(factor) or factor <= 0:
1091
+ return None
1092
+ current = self.getCamera3d(settings.get("subplotId"))
1093
+ if current is None:
1094
+ return None
1095
+ next_camera = dollyScene3dCamera(current, factor, self._camera3d_interaction())
1096
+ return self._apply_camera3d(next_camera, "dollyCamera3d", options) if next_camera else None
1097
+
1098
+ def panCamera3d(self, options: Mapping | None = None) -> dict | None:
1099
+ from .camera3d import panScene3dCamera
1100
+ with self._lock:
1101
+ settings = dict(options or {})
1102
+ x_ratio, y_ratio = settings.get("xRatio", 0), settings.get("yRatio", 0)
1103
+ if any(type(value) not in (int, float) or not isfinite(value) for value in (x_ratio, y_ratio)):
1104
+ return None
1105
+ subplot_id = self._camera_subplot_id(settings.get("subplotId"))
1106
+ current = self.getCamera3d(subplot_id) if subplot_id else None
1107
+ if current is None:
1108
+ return None
1109
+ scene = self._last_scene or {}
1110
+ metadata = scene.get("scenes3d") or ([scene["scene3d"]] if scene.get("scene3d") else [])
1111
+ selected = next((item for item in metadata if item.get("subplotId") == subplot_id), None)
1112
+ bounds = selected.get("bounds") if selected else None
1113
+ width = max(1, bounds["right"] - bounds["left"]) if bounds else self.options["width"]
1114
+ height = max(1, bounds["bottom"] - bounds["top"]) if bounds else self.options["height"]
1115
+ next_camera = panScene3dCamera(current, x_ratio, y_ratio, width / height)
1116
+ return self._apply_camera3d(next_camera, "panCamera3d", options) if next_camera else None
1117
+
1118
+ def resetCamera3d(self, options: Mapping | None = None) -> dict | None:
1119
+ with self._lock:
1120
+ subplot_id = self._camera_subplot_id((options or {}).get("subplotId"))
1121
+ reset = self._initial_cameras3d.get(subplot_id) if subplot_id else None
1122
+ return self._apply_camera3d(reset, "resetCamera3d", options) if reset else None
1123
+
1124
+ def getTooltip(self) -> dict | None:
1125
+ with self._lock:
1126
+ return deepcopy(self._tooltip)
1127
+
1128
+ def setTooltip(self, target: Mapping, options: Mapping | None = None) -> dict | None:
1129
+ from .overlays import tooltip_state
1130
+ with self._lock:
1131
+ if self._closed:
1132
+ return None
1133
+ state = tooltip_state(self.options, self._last_scene, target)
1134
+ if state is None:
1135
+ return None
1136
+ previous = self._tooltip
1137
+ changed = (previous is None or previous["activeSubplotId"] != state["activeSubplotId"]
1138
+ or previous["values"]["traceIndex"] != state["values"]["traceIndex"]
1139
+ or previous["values"]["pointIndex"] != state["values"]["pointIndex"])
1140
+ self._tooltip = deepcopy(state)
1141
+ identifier = self.call("setTooltip", deepcopy(dict(target)),
1142
+ *(() if options is None else (deepcopy(dict(options)),)))
1143
+ if changed and (options is None or options.get("emit") is not False):
1144
+ self._preapplied_overlay_events[identifier] = "tooltip"
1145
+ self._emit_local("tooltip", {"type": "tooltip", "source": "api", **deepcopy(state)})
1146
+ return deepcopy(state)
1147
+
1148
+ def clearTooltip(self, options: Mapping | None = None) -> None:
1149
+ with self._lock:
1150
+ if self._closed:
1151
+ return
1152
+ previous = self._tooltip
1153
+ self._tooltip = None
1154
+ identifier = self.call("clearTooltip", *(() if options is None else (deepcopy(dict(options)),)))
1155
+ if previous is not None and (options is None or options.get("emit") is not False):
1156
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
1157
+ self._emit_local("tooltipClear", {"type": "tooltipClear", "source": "api",
1158
+ "activeSubplotId": previous["activeSubplotId"],
1159
+ "pointer": deepcopy(previous["pointer"]),
1160
+ "previous": deepcopy(previous)})
1161
+
1162
+ def getCrosshair(self) -> dict | None:
1163
+ with self._lock:
1164
+ return deepcopy(self._crosshair)
1165
+
1166
+ def setCrosshair(self, position: Mapping, options: Mapping | None = None) -> dict | None:
1167
+ from .overlays import crosshair_state
1168
+ with self._lock:
1169
+ if self._closed:
1170
+ return None
1171
+ state = crosshair_state(self.options, self._last_scene, position)
1172
+ if state is None:
1173
+ return None
1174
+ previous = self._crosshair
1175
+ changed = (previous is None or any(previous[key] != state[key]
1176
+ for key in ("activeSubplotId", "xAxisId", "yAxisId",
1177
+ "xValue", "yValue", "pointer")))
1178
+ self._crosshair = deepcopy(state)
1179
+ identifier = self.call("setCrosshair", deepcopy(dict(position)),
1180
+ *(() if options is None else (deepcopy(dict(options)),)))
1181
+ if changed and (options is None or options.get("emit") is not False):
1182
+ self._preapplied_overlay_events[identifier] = "crosshairChange"
1183
+ self._emit_local("crosshairChange", {"type": "crosshairChange", "source": "api", **deepcopy(state)})
1184
+ return deepcopy(state)
1185
+
1186
+ def clearCrosshair(self, options: Mapping | None = None) -> None:
1187
+ with self._lock:
1188
+ if self._closed:
1189
+ return
1190
+ previous = self._crosshair
1191
+ self._crosshair = None
1192
+ identifier = self.call("clearCrosshair", *(() if options is None else (deepcopy(dict(options)),)))
1193
+ if previous is not None and (options is None or options.get("emit") is not False):
1194
+ self._preapplied_overlay_events[identifier] = "crosshairClear"
1195
+ self._emit_local("crosshairClear", {"type": "crosshairClear", "source": "api",
1196
+ "previous": deepcopy(previous)})
1197
+
1198
+ def getChartState(self) -> dict:
1199
+ with self._lock:
1200
+ if self._closed:
1201
+ return None
1202
+ state = {"chartType": self.options["type"], "data": self.getDataState(),
1203
+ "viewport": self.getViewport(), "traceVisibility": self.getTraceVisibility(),
1204
+ "polarRotation": self.getPolarRotation(), "dragMode": self.getDragMode(),
1205
+ "pointSelection": self.getPointSelection()}
1206
+ if self.options["type"] in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
1207
+ cameras = self._cameras_from_scene(self._last_scene)
1208
+ if len(cameras) > 1:
1209
+ state["cameras3d"] = cameras
1210
+ else:
1211
+ state["camera3d"] = self.getCamera3d()
1212
+ return state
1213
+
1214
+ def setChartState(self, state: Mapping, options: Mapping | None = None) -> dict | None:
1215
+ with self._lock:
1216
+ if self._closed or not isinstance(state, Mapping) or state.get("chartType") != self.options.get("type"):
1217
+ return None
1218
+ traces = self._traces()
1219
+ if not _valid_data_state(state.get("data"), len(traces)):
1220
+ return None
1221
+ visibility = _normalize_visibility(state.get("traceVisibility"), len(traces))
1222
+ if visibility is None or state.get("dragMode") not in ("zoom", "pan"):
1223
+ return None
1224
+ viewport = state.get("viewport", "<missing>")
1225
+ if viewport == "<missing>" or not _valid_viewport_state(viewport, self.options):
1226
+ return None
1227
+ counts = [len(data) for data in state["data"]["traceData"]]
1228
+ selection = _normalize_selection(state.get("pointSelection"), counts)
1229
+ if selection is None:
1230
+ return None
1231
+ family = self.options["type"]
1232
+ if family != "polar" and state.get("polarRotation", "<missing>") is not None:
1233
+ return None
1234
+ if family == "polar":
1235
+ rotation = state.get("polarRotation")
1236
+ if (not isinstance(rotation, Mapping) or isinstance(rotation.get("startAngleDegrees"), bool)
1237
+ or not isinstance(rotation.get("startAngleDegrees"), (int, float))
1238
+ or not isfinite(rotation["startAngleDegrees"])):
1239
+ return None
1240
+ else:
1241
+ rotation = None
1242
+ is_3d = family in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}
1243
+ if is_3d and (viewport is not None or selection.get("selection") is not None
1244
+ or selection.get("selections")):
1245
+ return None
1246
+ if not is_3d and (state.get("camera3d") is not None or state.get("cameras3d") is not None):
1247
+ return None
1248
+ camera = None
1249
+ cameras = None
1250
+ if is_3d:
1251
+ from .scene3d import _camera
1252
+ camera_fields = ("projection", "eye", "target", "up", "fieldOfViewDegrees",
1253
+ "orthographicScale", "near", "far")
1254
+
1255
+ def normalize_camera(value):
1256
+ if not isinstance(value, Mapping) or any(field not in value for field in camera_fields):
1257
+ return None
1258
+ try:
1259
+ return _camera({"scene3d": {"camera": value}})
1260
+ except (TypeError, ValueError):
1261
+ return None
1262
+
1263
+ current_cameras = self._cameras_from_scene(self._last_scene)
1264
+ if len(current_cameras) > 1:
1265
+ supplied = state.get("cameras3d")
1266
+ if not isinstance(supplied, Mapping) or set(supplied) != set(current_cameras):
1267
+ return None
1268
+ cameras = {}
1269
+ for subplot_id, supplied_camera in supplied.items():
1270
+ normalized_camera = normalize_camera(supplied_camera)
1271
+ if normalized_camera is None:
1272
+ return None
1273
+ cameras[subplot_id] = normalized_camera
1274
+ else:
1275
+ camera = normalize_camera(state.get("camera3d"))
1276
+ if camera is None:
1277
+ return None
1278
+ data = [[_snapshot_data_point(point) for point in trace_data]
1279
+ for trace_data in state["data"]["traceData"]]
1280
+ normalized = {"chartType": family, "data": {"traceCount": len(traces), "traceData": data},
1281
+ "viewport": deepcopy(viewport), "traceVisibility": visibility,
1282
+ "polarRotation": deepcopy(rotation), "dragMode": state["dragMode"],
1283
+ "pointSelection": selection}
1284
+ if is_3d:
1285
+ if cameras is not None:
1286
+ normalized["cameras3d"] = deepcopy(cameras)
1287
+ else:
1288
+ normalized["camera3d"] = deepcopy(dict(camera))
1289
+ if normalized == self.getChartState():
1290
+ return None
1291
+ candidate = deepcopy(self.options)
1292
+ visible = set(visibility["visibleTraceIndexes"])
1293
+ if candidate.get("traces") is None and len(traces) == 1 and 0 in visible:
1294
+ candidate["data"] = data[0]
1295
+ else:
1296
+ for index, trace in enumerate(traces):
1297
+ trace["data"] = data[index]
1298
+ trace["visible"] = index in visible
1299
+ candidate["traces"] = traces
1300
+ candidate["data"] = None
1301
+ candidate["viewport"] = deepcopy(viewport)
1302
+ interactions = deepcopy(candidate.get("interactions") or {})
1303
+ interactions["dragMode"] = state["dragMode"]
1304
+ candidate["interactions"] = interactions
1305
+ if rotation is not None:
1306
+ candidate["polar"] = {**(candidate.get("polar") or {}),
1307
+ "startAngleDegrees": rotation["startAngleDegrees"]}
1308
+ if camera is not None:
1309
+ candidate["scene3d"] = {**(candidate.get("scene3d") or {}), "camera": deepcopy(dict(camera))}
1310
+ elif cameras is not None:
1311
+ scene3d = deepcopy(candidate.get("scene3d") or {})
1312
+ scopes = deepcopy(scene3d.get("subplots") or {})
1313
+ for subplot_id, scoped_camera in cameras.items():
1314
+ scopes[subplot_id] = {**scopes.get(subplot_id, {}),
1315
+ "camera": deepcopy(scoped_camera)}
1316
+ scene3d["subplots"] = scopes
1317
+ candidate["scene3d"] = scene3d
1318
+ try:
1319
+ scene = self._plot_candidate(candidate)
1320
+ except (TypeError, ValueError):
1321
+ return None
1322
+ prior_scene = self._last_scene
1323
+ cleared = self._clear_tooltip_for_scene_change()
1324
+ if self._closed or self._last_scene is not prior_scene:
1325
+ return deepcopy(self._last_scene)
1326
+ self.options = candidate
1327
+ self._last_scene = scene
1328
+ self._data_state = deepcopy(normalized["data"])
1329
+ self._viewport = deepcopy(viewport)
1330
+ self._trace_visibility = deepcopy(visibility)
1331
+ self._polar_rotation = deepcopy(rotation)
1332
+ self._drag_mode = state["dragMode"]
1333
+ self._point_selection = deepcopy(selection)
1334
+ self._chart_state = deepcopy(normalized)
1335
+ args = (deepcopy(dict(state)),) if options is None else (deepcopy(dict(state)), deepcopy(dict(options)))
1336
+ identifier = self.call("setChartState", *args)
1337
+ if cleared:
1338
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
1339
+ return deepcopy(scene)
1340
+
1341
+ def updateOptions(self, options: Mapping) -> dict:
1342
+ with self._lock:
1343
+ if self._closed:
1344
+ raise RuntimeError("chart is destroyed")
1345
+ camera_options = options.get("scene3d") if isinstance(options, Mapping) else None
1346
+ incoming_scopes = camera_options.get("subplots") if isinstance(camera_options, Mapping) else None
1347
+ replaces_camera = (isinstance(camera_options, Mapping)
1348
+ and ("camera" in camera_options
1349
+ or any(isinstance(scope, Mapping) and "camera" in scope
1350
+ for scope in (incoming_scopes.values()
1351
+ if isinstance(incoming_scopes, Mapping) else ()))))
1352
+ patch = deepcopy(dict(options))
1353
+ if isinstance(camera_options, Mapping):
1354
+ current_scene3d = self.options.get("scene3d") or {}
1355
+ next_scene3d = deepcopy(dict(camera_options))
1356
+ if "camera" not in next_scene3d and current_scene3d.get("camera") is not None:
1357
+ next_scene3d["camera"] = deepcopy(current_scene3d["camera"])
1358
+ scopes = deepcopy(next_scene3d.get("subplots") or {})
1359
+ if isinstance(scopes, dict):
1360
+ for subplot_id, current_scope in (current_scene3d.get("subplots") or {}).items():
1361
+ if not isinstance(current_scope, Mapping) or current_scope.get("camera") is None:
1362
+ continue
1363
+ scopes.setdefault(subplot_id, {})
1364
+ if isinstance(scopes[subplot_id], Mapping) and "camera" not in scopes[subplot_id]:
1365
+ scopes[subplot_id] = {**scopes[subplot_id],
1366
+ "camera": deepcopy(current_scope["camera"])}
1367
+ if scopes:
1368
+ next_scene3d["subplots"] = scopes
1369
+ patch["scene3d"] = next_scene3d
1370
+ candidate = {**self.options, **patch}
1371
+ previous_type = self.options.get("type")
1372
+ scene = self._plot_candidate(candidate)
1373
+ prior_scene = self._last_scene
1374
+ cleared = self._clear_tooltip_for_scene_change()
1375
+ if self._closed or self._last_scene is not prior_scene:
1376
+ return deepcopy(self._last_scene)
1377
+ self._dispatch_before("beforeRender", {"type": "beforeRender",
1378
+ "size": {"width": candidate["width"],
1379
+ "height": candidate["height"]}})
1380
+ if self._closed or self._last_scene is not prior_scene:
1381
+ return deepcopy(self._last_scene)
1382
+ self.options = candidate
1383
+ self._last_scene = scene
1384
+ self._camera3d_revision += 1
1385
+ if candidate.get("type") not in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
1386
+ self._initial_cameras3d.clear()
1387
+ else:
1388
+ self._register_camera_resets(scene, replaces_camera or previous_type != candidate.get("type"))
1389
+ self._data_state = None
1390
+ self._point_selection = None
1391
+ self._trace_visibility = None
1392
+ self._chart_state = None
1393
+ identifier = self.call("updateOptions", dict(options))
1394
+ if cleared:
1395
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
1396
+ self._preapplied_before_events[identifier] = "beforeRender"
1397
+ return deepcopy(scene)
1398
+
1399
+ def resize(self, width: float, height: float) -> dict:
1400
+ return self.updateOptions({"width": width, "height": height})
1401
+
1402
+ def _apply_data_options(self, patch: Mapping, method: str, args: tuple,
1403
+ previous_selection: Mapping, trimmed_trace: int | None = None,
1404
+ removed_count: int = 0, *, owned_patch: bool = False) -> dict:
1405
+ """Commit data before render callbacks, as source applyDataUpdate does."""
1406
+ candidate = {**self.options, **(dict(patch) if owned_patch else deepcopy(dict(patch)))}
1407
+ scene = self._plot_candidate(candidate)
1408
+ prior_scene = self._last_scene
1409
+ self.options = candidate
1410
+ self._data_state = None
1411
+ self._trace_visibility = None
1412
+ self._chart_state = None
1413
+ self._restore_point_selection(previous_selection, trimmed_trace, removed_count)
1414
+ cleared = self._clear_tooltip_for_scene_change()
1415
+ # Reserve this operation's command before a callback can enqueue a nested
1416
+ # one. The lock prevents the browser polling it until the host settles.
1417
+ identifier = self._call_owned(method, *args) if owned_patch else self.call(method, *args)
1418
+ if cleared:
1419
+ self._preapplied_overlay_events[identifier] = "tooltipClear"
1420
+ self._preapplied_before_events[identifier] = "beforeRender"
1421
+ if self._closed or self._last_scene is not prior_scene:
1422
+ return deepcopy(self._last_scene)
1423
+ self._dispatch_before("beforeRender", {"type": "beforeRender",
1424
+ "size": {"width": candidate["width"],
1425
+ "height": candidate["height"]}})
1426
+ if self._closed or self._last_scene is not prior_scene:
1427
+ return deepcopy(self._last_scene)
1428
+ self._last_scene = scene
1429
+ return deepcopy(scene)
1430
+
1431
+ def setData(self, data: list) -> dict:
1432
+ with self._lock:
1433
+ if self._closed:
1434
+ raise RuntimeError("chart is destroyed")
1435
+ previous_selection = self.getPointSelection()
1436
+ if self._browser_progressive_scene_mode:
1437
+ owned_data = deepcopy(data)
1438
+ return self._apply_data_options({"data": owned_data, "traces": None},
1439
+ "setData", (owned_data,), previous_selection,
1440
+ owned_patch=True)
1441
+ return self._apply_data_options({"data": deepcopy(data), "traces": None},
1442
+ "setData", (deepcopy(data),), previous_selection)
1443
+
1444
+ def setTraces(self, traces: list) -> dict:
1445
+ with self._lock:
1446
+ if self._closed:
1447
+ raise RuntimeError("chart is destroyed")
1448
+ previous_selection = self.getPointSelection()
1449
+ return self._apply_data_options({"traces": deepcopy(traces), "data": None},
1450
+ "setTraces", (deepcopy(traces),), previous_selection)
1451
+
1452
+ def setTraceData(self, traceIndex: int, data: list) -> dict:
1453
+ with self._lock:
1454
+ if self._closed:
1455
+ raise RuntimeError("chart is destroyed")
1456
+ previous_selection = self.getPointSelection()
1457
+ traces = deepcopy(self.options.get("traces") or [{"data": self.options.get("data", [])}])
1458
+ if isinstance(traceIndex, bool) or not isinstance(traceIndex, int) or traceIndex < 0 or traceIndex >= len(traces):
1459
+ raise IndexError(f"Unknown trace index '{traceIndex}' for data update")
1460
+ traces[traceIndex]["data"] = deepcopy(data)
1461
+ if self.options.get("traces") is None and traceIndex == 0:
1462
+ patch = {"data": deepcopy(data)}
1463
+ else:
1464
+ patch = {"traces": traces}
1465
+ return self._apply_data_options(patch, "setTraceData",
1466
+ (traceIndex, deepcopy(data)), previous_selection)
1467
+
1468
+ def appendData(self, data: list, options: Mapping | None = None) -> dict:
1469
+ with self._lock:
1470
+ if self._closed:
1471
+ raise RuntimeError("chart is destroyed")
1472
+ previous_selection = self.getPointSelection()
1473
+ options = dict(options or {})
1474
+ index = options.get("traceIndex", 0)
1475
+ if isinstance(index, bool) or not isinstance(index, int):
1476
+ index = 0
1477
+ traces = deepcopy(self.options.get("traces") or [{"data": self.options.get("data", [])}])
1478
+ if index < 0 or index >= len(traces):
1479
+ raise IndexError(f"Unknown trace index '{index}' for data update")
1480
+ max_points = options.get("maxPoints")
1481
+ if isinstance(max_points, bool) or not isinstance(max_points, int) or max_points <= 0:
1482
+ max_points = None
1483
+ if not data and max_points is None:
1484
+ return None
1485
+ combined = list(traces[index].get("data") or []) + deepcopy(data)
1486
+ removed_count = max(0, len(combined) - max_points) if max_points is not None else 0
1487
+ if not data and removed_count == 0:
1488
+ return None
1489
+ traces[index]["data"] = combined
1490
+ if max_points is not None:
1491
+ traces[index]["data"] = traces[index]["data"][-max_points:]
1492
+ if self.options.get("type") == "mesh3d" and removed_count > 0:
1493
+ raise ValueError("mesh3d appendData cannot trim indexed vertices with maxPoints")
1494
+ if self.options.get("traces") is None and index == 0:
1495
+ patch = {"data": traces[0]["data"]}
1496
+ else:
1497
+ patch = {"traces": traces}
1498
+ return self._apply_data_options(patch, "appendData",
1499
+ (deepcopy(data), deepcopy(options)),
1500
+ previous_selection, index, removed_count)
1501
+
1502
+ def call(self, method: str, *args: Any) -> int:
1503
+ """Queue a source ChartInstance method for the attached browser view."""
1504
+ with self._changed:
1505
+ if self._closed:
1506
+ raise RuntimeError("chart is destroyed")
1507
+ identifier = len(self._commands) + 1
1508
+ self._commands.append({"id": identifier, "method": method,
1509
+ "args": self._serialize_browser(deepcopy(args))})
1510
+ if method in _SCENE_COMMANDS:
1511
+ self._latest_scene_command_id = identifier
1512
+ self._changed.notify_all()
1513
+ return identifier
1514
+
1515
+ def _call_owned(self, method: str, *args: Any) -> int:
1516
+ """Queue an owned, already isolated snapshot without another deep copy."""
1517
+ with self._changed:
1518
+ if self._closed:
1519
+ raise RuntimeError("chart is destroyed")
1520
+ identifier = len(self._commands) + 1
1521
+ self._commands.append({"id": identifier, "method": method,
1522
+ "args": self._serialize_browser(args)})
1523
+ if method in _SCENE_COMMANDS:
1524
+ self._latest_scene_command_id = identifier
1525
+ self._changed.notify_all()
1526
+ return identifier
1527
+
1528
+ def on(self, event: str, handler: Callable[[Any], None]) -> Callable[[], None]:
1529
+ with self._lock:
1530
+ self._handlers.setdefault(event, []).append(handler)
1531
+ return lambda: self.off(event, handler)
1532
+
1533
+ def off(self, event: str, handler: Callable[[Any], None]) -> None:
1534
+ with self._lock:
1535
+ handlers = self._handlers.get(event, [])
1536
+ if handler in handlers:
1537
+ handlers.remove(handler)
1538
+
1539
+ def events(self) -> list[dict]:
1540
+ with self._lock:
1541
+ return deepcopy(self._events)
1542
+
1543
+ def _emit_local(self, event: str, payload: dict) -> None:
1544
+ self._events.append({"type": event, "payload": deepcopy(payload)})
1545
+ for handler in list(self._handlers.get(event, [])):
1546
+ handler(deepcopy(payload))
1547
+
1548
+ def _dispatch_before(self, event: str, payload: Mapping) -> bool:
1549
+ """Run source-order option and registered handlers before an action commits."""
1550
+ candidate = _BeforeEvent(deepcopy(dict(payload)))
1551
+ with self._lock:
1552
+ callbacks = ([self.options["on" + event[0].upper() + event[1:]]]
1553
+ if callable(self.options.get("on" + event[0].upper() + event[1:])) else [])
1554
+ callbacks += list(self._handlers.get(event, []))
1555
+ for callback in callbacks:
1556
+ callback(candidate)
1557
+ safe = dict(candidate)
1558
+ with self._lock:
1559
+ self._events.append({"type": event, "payload": deepcopy(safe)})
1560
+ self._latest[event] = deepcopy(safe)
1561
+ return candidate.defaultPrevented
1562
+
1563
+ def _before_zoom(self, previous: Mapping | None, proposed: Mapping | None,
1564
+ patch: Mapping, settings: Mapping | None) -> bool:
1565
+ if settings and settings.get("emit") is False:
1566
+ return False
1567
+ mode = (settings or {}).get("mode", "xy")
1568
+ active = (settings or {}).get("activeSubplotId", "chart")
1569
+ common = {"source": "api", "mode": mode, "activeSubplotId": active,
1570
+ "viewportBefore": deepcopy(previous)}
1571
+ self._emit_local("zoomStart", {"type": "zoomStart", **common})
1572
+ payload = {"type": "beforeZoom", **common, "viewportAfter": deepcopy(proposed),
1573
+ "patch": deepcopy(dict(patch)), "defaultPrevented": False}
1574
+ prevented = self._dispatch_before("beforeZoom", payload)
1575
+ if prevented:
1576
+ self._emit_local("zoomCancel", {"type": "zoomCancel", **common, "reason": "prevented"})
1577
+ return prevented
1578
+
1579
+ def _receive(self, event: Mapping) -> None:
1580
+ with self._lock:
1581
+ if self._closed:
1582
+ return
1583
+ kind = event.get("type")
1584
+ payload = event.get("payload")
1585
+ if kind == "lineAxisDiagnostics":
1586
+ from .rendering import _set_last_line_axis_diagnostics
1587
+ _set_last_line_axis_diagnostics(payload)
1588
+ return
1589
+ command_id = event.get("commandId")
1590
+ if (kind in ("commandResult", "commandError") and command_id in (None, 0)
1591
+ and isinstance(payload, Mapping) and type(payload.get("id")) is int):
1592
+ command_id = payload["id"]
1593
+ stale_command = (type(command_id) is int and command_id > 0
1594
+ and command_id < self._latest_scene_command_id)
1595
+ newer_host_commands = (self._packed_initial is not None
1596
+ and len(self._commands) > self._packed_initial_command_count)
1597
+ # The source cancels an older progressive frame when a later render
1598
+ # begins. A commandResult is queued before that command's async
1599
+ # full-render event, so it is the acknowledgement that an untagged
1600
+ # render now belongs to the latest browser state.
1601
+ command_caught_up = (not newer_host_commands or
1602
+ self._latest_scene_command_id == 0 or
1603
+ self._latest_scene_command_id in self._command_results)
1604
+ stale_bootstrap = (newer_host_commands and command_id in (None, 0)
1605
+ and (kind == "ready" or not command_caught_up))
1606
+ if (not stale_command and kind == "camera3dChange" and isinstance(payload, Mapping)
1607
+ and not (type(command_id) is int and command_id > 0)):
1608
+ subplot_id = self._camera_subplot_id(payload.get("subplotId"))
1609
+ camera = payload.get("state")
1610
+ if subplot_id is None or not isinstance(camera, Mapping):
1611
+ raise ValueError("invalid browser camera state")
1612
+ candidate = self._camera_candidate(camera, subplot_id)
1613
+ next_scene = plot({key: value for key, value in candidate.items()
1614
+ if key not in _CHART_ONLY_OPTIONS})
1615
+ self.options = candidate
1616
+ self._last_scene = next_scene
1617
+ self._camera3d_revision += 1
1618
+ if kind == "zoomStart" and command_id in self._preapplied_zoom_start_ids:
1619
+ self._preapplied_zoom_start_ids.remove(command_id)
1620
+ return
1621
+ if self._preapplied_overlay_events.get(command_id) == kind:
1622
+ # The browser echo belongs to a host-applied command. A newer
1623
+ # command may already have replaced or cleared the overlay;
1624
+ # reapplying this echo would restore stale state or double-emit.
1625
+ self._preapplied_overlay_events.pop(command_id, None)
1626
+ return
1627
+ if (not stale_command and kind == "viewportChange" and self.options.get("engine") == "rust"
1628
+ and isinstance(payload, Mapping) and "viewportAfter" in payload):
1629
+ candidate = {**self.options, "viewport": deepcopy(payload["viewportAfter"])}
1630
+ scene_options = {key: value for key, value in candidate.items()
1631
+ if key not in _CHART_ONLY_OPTIONS}
1632
+ next_scene = plot(scene_options)
1633
+ self.options = candidate
1634
+ self._last_scene = next_scene
1635
+ if (not stale_command and kind == "traceVisibility" and self.options.get("engine") == "rust"
1636
+ and isinstance(payload, Mapping) and "visibleTraceIndexes" in payload):
1637
+ traces = self._traces()
1638
+ indexes = payload["visibleTraceIndexes"]
1639
+ if (not isinstance(indexes, list) or payload.get("traceCount") != len(traces)
1640
+ or (traces and not indexes) or len(set(indexes)) != len(indexes)
1641
+ or any(type(index) is not int or index < 0 or index >= len(traces) for index in indexes)):
1642
+ raise ValueError("invalid browser trace visibility state")
1643
+ for index, trace in enumerate(traces):
1644
+ trace["visible"] = index in indexes
1645
+ candidate = {**self.options, "traces": traces, "data": None}
1646
+ next_scene = plot({key: value for key, value in candidate.items()
1647
+ if key not in _CHART_ONLY_OPTIONS})
1648
+ self.options = candidate
1649
+ self._last_scene = next_scene
1650
+ self._events.append({"type": kind, "payload": payload})
1651
+ self._latest[kind] = deepcopy(payload)
1652
+ if kind in ("commandResult", "commandError") and isinstance(payload, Mapping):
1653
+ identifier = payload.get("id")
1654
+ if type(identifier) is int:
1655
+ self._preapplied_zoom_start_ids.discard(identifier)
1656
+ self._preapplied_before_events.pop(identifier, None)
1657
+ self._command_results[identifier] = {"kind": kind, "payload": deepcopy(payload)}
1658
+ if kind == "commandResult" and "lineAxisDiagnostics" in payload:
1659
+ from .rendering import _set_last_line_axis_diagnostics
1660
+ _set_last_line_axis_diagnostics(payload["lineAxisDiagnostics"])
1661
+ self._changed.notify_all()
1662
+ if isinstance(payload, Mapping):
1663
+ if kind == "ready" and not stale_bootstrap:
1664
+ for key, attr in (("viewport", "_viewport"), ("pointSelection", "_point_selection"),
1665
+ ("data", "_data_state"), ("traceVisibility", "_trace_visibility"),
1666
+ ("dragMode", "_drag_mode"), ("polarRotation", "_polar_rotation"),
1667
+ ("chartState", "_chart_state")):
1668
+ if key in payload:
1669
+ setattr(self, attr, deepcopy(payload[key]))
1670
+ else:
1671
+ field = {
1672
+ "viewportChange": ("viewportAfter", "_viewport"),
1673
+ "pointSelectionChange": ("state", "_point_selection"),
1674
+ "dataUpdate": ("state", "_data_state"),
1675
+ "dragModeChange": ("mode", "_drag_mode"),
1676
+ "polarRotationChange": ("state", "_polar_rotation"),
1677
+ "chartStateChange": ("state", "_chart_state"),
1678
+ }.get(kind)
1679
+ if not stale_command and field and field[0] in payload:
1680
+ setattr(self, field[1], deepcopy(payload[field[0]]))
1681
+ if kind == "dataUpdate" and "traceData" in payload[field[0]]:
1682
+ state = payload[field[0]]
1683
+ traces = self._traces()
1684
+ if _valid_data_state(state, len(traces)):
1685
+ if self.options.get("traces") is None and len(traces) == 1:
1686
+ self.options["data"] = deepcopy(state["traceData"][0])
1687
+ else:
1688
+ for index, trace in enumerate(traces):
1689
+ trace["data"] = deepcopy(state["traceData"][index])
1690
+ self.options["traces"] = traces
1691
+ self.options["data"] = None
1692
+ if self.options.get("engine") == "rust":
1693
+ self._last_scene = plot(self._scene_options())
1694
+ if kind == "chartStateChange":
1695
+ state = payload[field[0]]
1696
+ self._data_state = deepcopy(state.get("data"))
1697
+ self._viewport = deepcopy(state.get("viewport"))
1698
+ self._trace_visibility = deepcopy(state.get("traceVisibility"))
1699
+ self._polar_rotation = deepcopy(state.get("polarRotation"))
1700
+ self._drag_mode = state.get("dragMode", self._drag_mode)
1701
+ self._point_selection = deepcopy(state.get("pointSelection"))
1702
+ traces = self._traces()
1703
+ visibility = _normalize_visibility(state.get("traceVisibility"), len(traces))
1704
+ if _valid_data_state(state.get("data"), len(traces)) and visibility is not None:
1705
+ visible = set(visibility["visibleTraceIndexes"])
1706
+ data = state["data"]["traceData"]
1707
+ if self.options.get("traces") is None and len(traces) == 1 and 0 in visible:
1708
+ self.options["data"] = deepcopy(data[0])
1709
+ else:
1710
+ for index, trace in enumerate(traces):
1711
+ trace["data"] = deepcopy(data[index])
1712
+ trace["visible"] = index in visible
1713
+ self.options["traces"] = traces
1714
+ self.options["data"] = None
1715
+ self.options["viewport"] = deepcopy(state.get("viewport"))
1716
+ self.options["interactions"] = {**(self.options.get("interactions") or {}),
1717
+ "dragMode": state.get("dragMode", self._drag_mode)}
1718
+ if isinstance(state.get("polarRotation"), Mapping):
1719
+ self.options["polar"] = {**(self.options.get("polar") or {}),
1720
+ "startAngleDegrees": state["polarRotation"]["startAngleDegrees"]}
1721
+ if isinstance(state.get("camera3d"), Mapping):
1722
+ self.options["scene3d"] = {**(self.options.get("scene3d") or {}),
1723
+ "camera": deepcopy(state["camera3d"])}
1724
+ if isinstance(state.get("cameras3d"), Mapping):
1725
+ scene3d = deepcopy(self.options.get("scene3d") or {})
1726
+ scopes = deepcopy(scene3d.get("subplots") or {})
1727
+ for subplot_id, camera in state["cameras3d"].items():
1728
+ scopes[subplot_id] = {**scopes.get(subplot_id, {}),
1729
+ "camera": deepcopy(camera)}
1730
+ scene3d["subplots"] = scopes
1731
+ self.options["scene3d"] = scene3d
1732
+ if self.options.get("engine") == "rust" or state.get("cameras3d") is not None:
1733
+ self._last_scene = plot(self._scene_options())
1734
+ if kind in ("tooltipClear", "crosshairClear"):
1735
+ setattr(self, "_tooltip" if kind == "tooltipClear" else "_crosshair", None)
1736
+ if kind == "traceVisibility":
1737
+ self._trace_visibility = {key: deepcopy(payload[key]) for key in
1738
+ ("traceCount", "visibleTraceIndexes", "hiddenTraceIndexes") if key in payload}
1739
+ if kind in ("tooltip", "crosshairChange"):
1740
+ target = "_tooltip" if kind == "tooltip" else "_crosshair"
1741
+ self_state = {key: deepcopy(value) for key, value in payload.items()
1742
+ if key not in ("type", "source", "scene")}
1743
+ setattr(self, target, self_state)
1744
+ if (not stale_bootstrap and not stale_command
1745
+ and isinstance(payload.get("scene"), Mapping)
1746
+ and self.options.get("engine") != "rust"):
1747
+ self._last_scene = deepcopy(payload["scene"])
1748
+ if kind == "render":
1749
+ self._deferred_initial_scene = False
1750
+ if (not stale_command and kind == "commandResult"
1751
+ and payload.get("method") != "__renderToCanvas__"
1752
+ and self.options.get("engine") != "rust"
1753
+ and isinstance(payload.get("result"), Mapping) and "primitives" in payload["result"]):
1754
+ self._last_scene = deepcopy(payload["result"])
1755
+ self._deferred_initial_scene = False
1756
+ handlers = list(self._handlers.get(kind, []))
1757
+ for handler in handlers:
1758
+ handler(deepcopy(payload))
1759
+
1760
+ def _handler(self):
1761
+ owner = self
1762
+
1763
+ class Handler(BaseHTTPRequestHandler):
1764
+ def log_message(self, format, *args):
1765
+ return
1766
+
1767
+ def _respond(self, data: bytes, content_type: str, status: int = 200):
1768
+ try:
1769
+ self.send_response(status)
1770
+ self.send_header("Content-Type", content_type)
1771
+ self.send_header("Cache-Control", "no-store")
1772
+ self.send_header("Content-Length", str(len(data)))
1773
+ self.end_headers()
1774
+ self.wfile.write(data)
1775
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
1776
+ # A closed browser cancels its outstanding command request.
1777
+ return
1778
+
1779
+ def do_GET(self):
1780
+ parsed = urlsplit(self.path)
1781
+ prefix = f"/{owner._session}/"
1782
+ if not parsed.path.startswith(prefix):
1783
+ self._respond(b"not found", "text/plain", 404)
1784
+ return
1785
+ path = parsed.path.removeprefix(prefix)
1786
+ if path == "":
1787
+ self._respond(_page(owner._browser_options(), prefix,
1788
+ owner._packed_initial_count,
1789
+ owner._packed_initial_command_count).encode("utf-8"), "text/html; charset=utf-8")
1790
+ elif path == "data.bin" and owner._packed_initial is not None:
1791
+ origin = self.headers.get("Origin")
1792
+ expected = f"http://127.0.0.1:{owner._server.server_port}"
1793
+ if origin is not None and origin != expected:
1794
+ self._respond(b"invalid origin", "text/plain", 403)
1795
+ else:
1796
+ self._respond(owner._packed_initial, "application/octet-stream")
1797
+ elif path == "commands":
1798
+ try:
1799
+ since = int(parse_qs(parsed.query).get("since", ["0"])[0])
1800
+ except ValueError:
1801
+ self._respond(b"invalid cursor", "text/plain", 400)
1802
+ return
1803
+ with owner._changed:
1804
+ if since < 0 or since > len(owner._commands):
1805
+ self._respond(b"invalid cursor", "text/plain", 400)
1806
+ return
1807
+ owner._changed.wait_for(lambda: owner._closed or len(owner._commands) > since)
1808
+ pending = owner._commands[since:]
1809
+ self._respond(_json(pending).encode("utf-8"), "application/json")
1810
+ elif path.startswith("assets/"):
1811
+ relative = path.removeprefix("assets/")
1812
+ if ".." in relative.split("/") or "\\" in relative:
1813
+ self._respond(b"invalid path", "text/plain", 400)
1814
+ return
1815
+ asset = resources.files("fastplotpy").joinpath("assets", *relative.split("/"))
1816
+ if not asset.is_file():
1817
+ self._respond(b"not found", "text/plain", 404)
1818
+ return
1819
+ kind = "application/wasm" if relative.endswith(".wasm") else "text/javascript; charset=utf-8"
1820
+ self._respond(asset.read_bytes(), kind)
1821
+ else:
1822
+ self._respond(b"not found", "text/plain", 404)
1823
+
1824
+ def do_POST(self):
1825
+ event_path = self.path == f"/{owner._session}/event"
1826
+ callback_path = self.path == f"/{owner._session}/callback"
1827
+ before_path = self.path == f"/{owner._session}/before"
1828
+ if not event_path and not callback_path and not before_path:
1829
+ self._respond(b"not found", "text/plain", 404)
1830
+ return
1831
+ if self.headers.get("Origin") not in (None, f"http://127.0.0.1:{owner._server.server_port}"):
1832
+ self._respond(b"invalid origin", "text/plain", 403)
1833
+ return
1834
+ if self.headers.get("Content-Type", "").split(";", 1)[0] != "application/json":
1835
+ self._respond(b"json required", "text/plain", 415)
1836
+ return
1837
+ try:
1838
+ length = int(self.headers.get("Content-Length", "0"))
1839
+ if length <= 0:
1840
+ self._respond(b"invalid length", "text/plain", 413)
1841
+ return
1842
+ message = loads(self.rfile.read(length))
1843
+ if before_path:
1844
+ if (not isinstance(message, dict) or message.get("type") not in
1845
+ ("beforeZoom", "beforeCamera3dChange", "beforeRender")
1846
+ or not isinstance(message.get("payload"), dict)):
1847
+ self._respond(b"invalid before-event", "text/plain", 400)
1848
+ return
1849
+ command_id = message.get("commandId")
1850
+ with owner._lock:
1851
+ command_count = len(owner._commands)
1852
+ preapplied = owner._preapplied_before_events.get(command_id) == message["type"]
1853
+ if preapplied:
1854
+ owner._preapplied_before_events.pop(command_id, None)
1855
+ prevented = False if preapplied else owner._dispatch_before(message["type"], message["payload"])
1856
+ with owner._lock:
1857
+ immediate = deepcopy(owner._commands[command_count:])
1858
+ self._respond(_json({"prevented": prevented, "commands": immediate}).encode("utf-8"),
1859
+ "application/json")
1860
+ return
1861
+ if callback_path:
1862
+ if not isinstance(message, dict) or not isinstance(message.get("args"), list):
1863
+ self._respond(b"invalid callback", "text/plain", 400)
1864
+ return
1865
+ with owner._lock:
1866
+ callback = owner._callbacks.get(message.get("id"))
1867
+ command_count = len(owner._commands)
1868
+ if callback is None or owner._closed:
1869
+ self._respond(b"unknown callback", "text/plain", 404)
1870
+ return
1871
+ result = callback(*message["args"])
1872
+ with owner._lock:
1873
+ immediate = deepcopy(owner._commands[command_count:])
1874
+ self._respond(_json({"result": result, "commands": immediate}).encode("utf-8"),
1875
+ "application/json")
1876
+ return
1877
+ if not isinstance(message, dict) or message.get("type") not in (*_EVENTS, "ready", "bridgeError", "commandResult", "commandError", "lineAxisDiagnostics"):
1878
+ self._respond(b"invalid event", "text/plain", 400)
1879
+ return
1880
+ owner._receive(message)
1881
+ except (ValueError, TypeError) as exc:
1882
+ self._respond(str(exc).encode(), "text/plain", 400)
1883
+ return
1884
+ except Exception as exc:
1885
+ if callback_path:
1886
+ self._respond(f"callback failed: {type(exc).__name__}: {exc}".encode(), "text/plain", 500)
1887
+ return
1888
+ raise
1889
+ self._respond(b"{}", "application/json")
1890
+
1891
+ return Handler
1892
+
1893
+ def serve(self) -> str:
1894
+ """Start the chart's owned local server and return its browser URL."""
1895
+ with self._lock:
1896
+ if self._closed:
1897
+ raise RuntimeError("chart is destroyed")
1898
+ if self._server is None:
1899
+ self._prepare_packed_initial()
1900
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
1901
+ self._thread = Thread(target=self._server.serve_forever, name=f"fastplotpy-{uuid4().hex[:8]}", daemon=True)
1902
+ self._thread.start()
1903
+ return f"http://127.0.0.1:{self._server.server_port}/{self._session}/"
1904
+
1905
+ def _repr_html_(self) -> str:
1906
+ url = self.serve()
1907
+ return f'<iframe src="{url}" width="{self.options.get("width", 640)}" height="{self.options.get("height", 400)}" style="border:0"></iframe>'
1908
+
1909
+ def destroy(self) -> None:
1910
+ with self._changed:
1911
+ if self._closed:
1912
+ return
1913
+ self._closed = True
1914
+ self._changed.notify_all()
1915
+ server = self._server
1916
+ if server is not None:
1917
+ server.shutdown()
1918
+ server.server_close()
1919
+
1920
+
1921
+ def createChart(options: Mapping[str, Any]) -> Chart:
1922
+ return Chart(options)