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.
- fastplotpy/README.md +70 -0
- fastplotpy/__init__.py +33 -0
- fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
- fastplotpy/assets/dist/index.js +15071 -0
- fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
- fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
- fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
- fastplotpy/assets/package.json +1 -0
- fastplotpy/axes_validation.py +102 -0
- fastplotpy/camera3d.py +104 -0
- fastplotpy/color_scale.py +88 -0
- fastplotpy/defaults/area-fill.json +5 -0
- fastplotpy/defaults/bar.json +22 -0
- fastplotpy/defaults/camera3d.json +22 -0
- fastplotpy/defaults/cartesian-references.json +10 -0
- fastplotpy/defaults/control.json +3 -0
- fastplotpy/defaults/crosshair.json +19 -0
- fastplotpy/defaults/error-bars.json +7 -0
- fastplotpy/defaults/heatmap.json +70 -0
- fastplotpy/defaults/interaction.json +3 -0
- fastplotpy/defaults/layout.json +32 -0
- fastplotpy/defaults/legend.json +24 -0
- fastplotpy/defaults/line-axes.json +28 -0
- fastplotpy/defaults/line3d.json +13 -0
- fastplotpy/defaults/markers.json +6 -0
- fastplotpy/defaults/mesh3d.json +13 -0
- fastplotpy/defaults/pan.json +7 -0
- fastplotpy/defaults/pie.json +27 -0
- fastplotpy/defaults/pie3d.json +7 -0
- fastplotpy/defaults/point-labels.json +13 -0
- fastplotpy/defaults/pointClick.json +6 -0
- fastplotpy/defaults/pointSelection.json +13 -0
- fastplotpy/defaults/polar.json +71 -0
- fastplotpy/defaults/polarRotate.json +5 -0
- fastplotpy/defaults/scatter3d.json +39 -0
- fastplotpy/defaults/scene3d.json +61 -0
- fastplotpy/defaults/surface.json +35 -0
- fastplotpy/defaults/tooltip.json +18 -0
- fastplotpy/defaults/trace-colors.json +10 -0
- fastplotpy/defaults/trace-options.json +24 -0
- fastplotpy/defaults/wonderplot.json +58 -0
- fastplotpy/defaults/zoom.json +12 -0
- fastplotpy/defaults.py +15 -0
- fastplotpy/error_bars.py +118 -0
- fastplotpy/exports.py +153 -0
- fastplotpy/families2d.py +896 -0
- fastplotpy/heatmap_labels.py +146 -0
- fastplotpy/interactions.py +243 -0
- fastplotpy/interactive.py +1922 -0
- fastplotpy/js_serialize.py +47 -0
- fastplotpy/lighting3d.py +113 -0
- fastplotpy/line3d_lod.py +138 -0
- fastplotpy/lod3d.py +105 -0
- fastplotpy/mesh3d_lod.py +107 -0
- fastplotpy/native.py +163 -0
- fastplotpy/overlays.py +561 -0
- fastplotpy/point_labels.py +86 -0
- fastplotpy/polar.py +445 -0
- fastplotpy/polygon_clip3d.py +148 -0
- fastplotpy/references.py +72 -0
- fastplotpy/rendering.py +65 -0
- fastplotpy/rust_scenes.py +314 -0
- fastplotpy/scene.py +1185 -0
- fastplotpy/scene3d.py +1215 -0
- fastplotpy/scene3d_accessibility.py +183 -0
- fastplotpy/scene3d_collection.py +158 -0
- fastplotpy/subplots.py +115 -0
- fastplotpy/surface_contours.py +122 -0
- fastplotpy/surface_lod.py +109 -0
- fastplotpy/svg3d.py +235 -0
- fastplotpy/trendline.py +236 -0
- fastplotpy/triangle_wireframe.py +51 -0
- fastplotpy/types.py +3027 -0
- fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
- fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
- fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
- fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
- 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)
|