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
fastplotpy/scene.py
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
"""Python scene construction. Scene coordinates and option names mirror fastPlotJs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
7
|
+
from math import cos, isfinite, log10, pi, radians, sin, floor
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .defaults import DEFAULT_TRACE_COLORS, get_defaults
|
|
11
|
+
from .families2d import bar_scene, heatmap_scene, wonderplot_scene, resolve_bar_line_style
|
|
12
|
+
from .polar import polar_scene
|
|
13
|
+
from .native import rustCall
|
|
14
|
+
from .rust_scenes import bar_scene_rust, heatmap_scene_rust, wonderplot_scene_rust
|
|
15
|
+
from .error_bars import resolve_error_bars, error_domain_values, error_geometry
|
|
16
|
+
from .trendline import clip_polyline, resolve_trendline, sample_trendline
|
|
17
|
+
from .point_labels import build_point_labels, resolve_point_labels
|
|
18
|
+
from .scene3d import scatter3d_scene, line3d_scene, surface_scene, mesh3d_scene, pie3d_scene
|
|
19
|
+
from .subplots import resolveSubplots, resolveTraceSubplotBindings
|
|
20
|
+
from .references import validateCartesianReferences
|
|
21
|
+
from .axes_validation import validateAxisTickOptions, validateRenderableAxisSides
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FAMILIES = frozenset({"line", "bar", "polar", "pie", "heatmap", "wonderplot", "scatter3d", "line3d", "surface", "mesh3d", "pie3d"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _object(value: Any, name: str) -> dict:
|
|
28
|
+
if not isinstance(value, Mapping):
|
|
29
|
+
raise TypeError(f"{name} must be an object")
|
|
30
|
+
return dict(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dimension(value: Any, name: str) -> float:
|
|
34
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value <= 0:
|
|
35
|
+
raise ValueError(f"{name} must be a finite positive number")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _traces(options: Mapping) -> list[dict]:
|
|
40
|
+
traces = options.get("traces")
|
|
41
|
+
if traces is not None:
|
|
42
|
+
if not isinstance(traces, list):
|
|
43
|
+
raise TypeError("traces must be an array")
|
|
44
|
+
return [_object(trace, "trace") for trace in traces]
|
|
45
|
+
if "data" in options:
|
|
46
|
+
return [{"data": options["data"]}]
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _axis(options: Mapping, dimension: str, axis_id: str | None = None) -> dict:
|
|
51
|
+
axes = options.get("axes") or {}
|
|
52
|
+
axes = _object(axes, "axes")
|
|
53
|
+
axis_map = axes.get(f"{dimension}Axes") or {}
|
|
54
|
+
if not isinstance(axis_map, Mapping):
|
|
55
|
+
raise TypeError(f"axes.{dimension}Axes must be an object")
|
|
56
|
+
return _object(axis_map.get(axis_id or dimension, {}), "axis")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _number(value: Any, axis: Mapping) -> float:
|
|
60
|
+
scale = axis.get("scale", "linear")
|
|
61
|
+
if scale == "datetime":
|
|
62
|
+
if isinstance(value, datetime):
|
|
63
|
+
return value.timestamp() * 1000
|
|
64
|
+
if isinstance(value, date):
|
|
65
|
+
return datetime(value.year, value.month, value.day, tzinfo=timezone.utc).timestamp() * 1000
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
try:
|
|
68
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError(f"Invalid datetime string value: {value}") from exc
|
|
71
|
+
if isinstance(value, str) and isinstance(axis.get("categories"), list):
|
|
72
|
+
categories = axis["categories"]
|
|
73
|
+
if value not in categories:
|
|
74
|
+
raise ValueError(f"Unknown categorical axis value: {value}")
|
|
75
|
+
return float(categories.index(value) + 1)
|
|
76
|
+
if isinstance(value, (date, datetime)):
|
|
77
|
+
return _number(value, {"scale": "datetime"})
|
|
78
|
+
try:
|
|
79
|
+
number = float(value)
|
|
80
|
+
except (ValueError, TypeError) as exc:
|
|
81
|
+
raise ValueError(f"Invalid numeric value: {value}") from exc
|
|
82
|
+
if not isfinite(number):
|
|
83
|
+
raise ValueError("Invalid numeric value")
|
|
84
|
+
return number
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _scaled(value: float, axis: Mapping) -> float:
|
|
88
|
+
scale = axis.get("scale", "linear")
|
|
89
|
+
if scale == "logarithmic":
|
|
90
|
+
if value <= 0:
|
|
91
|
+
raise ValueError("Logarithmic scale requires values > 0")
|
|
92
|
+
return log10(value)
|
|
93
|
+
if scale not in ("linear", "datetime"):
|
|
94
|
+
raise ValueError(f"Unknown axis scale: {scale}")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _padding(axis: Mapping) -> tuple[float, float]:
|
|
99
|
+
value = axis.get("padding", 0)
|
|
100
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
101
|
+
return float(value), float(value)
|
|
102
|
+
if isinstance(value, Mapping):
|
|
103
|
+
return float(value.get("start", 0)), float(value.get("end", 0))
|
|
104
|
+
return 0.0, 0.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _domain(values: list[float], axis: Mapping) -> tuple[float, float] | None:
|
|
108
|
+
explicit = axis.get("range")
|
|
109
|
+
if explicit is not None:
|
|
110
|
+
explicit = _object(explicit, "axis range")
|
|
111
|
+
low = _scaled(_number(explicit["min"], axis), axis)
|
|
112
|
+
high = _scaled(_number(explicit["max"], axis), axis)
|
|
113
|
+
if not low < high:
|
|
114
|
+
raise ValueError("Axis range requires min < max")
|
|
115
|
+
return low, high
|
|
116
|
+
if not values:
|
|
117
|
+
return None
|
|
118
|
+
values = [_scaled(value, axis) for value in values]
|
|
119
|
+
low, high = min(values), max(values)
|
|
120
|
+
span = max(high - low, 2.220446049250313e-16)
|
|
121
|
+
start, end = _padding(axis)
|
|
122
|
+
return low - span * start / 100, high + span * end / 100
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _point_data(trace: Mapping, x_axis: Mapping, y_axis: Mapping) -> list[dict | None]:
|
|
126
|
+
data = trace.get("data") or []
|
|
127
|
+
if not isinstance(data, list):
|
|
128
|
+
raise TypeError("trace.data must be an array")
|
|
129
|
+
points = []
|
|
130
|
+
for value in data:
|
|
131
|
+
value = _object(value, "data point")
|
|
132
|
+
if value.get("x") is None or value.get("y") is None:
|
|
133
|
+
points.append(None)
|
|
134
|
+
else:
|
|
135
|
+
points.append({"x": _number(value["x"], x_axis), "y": _number(value["y"], y_axis)})
|
|
136
|
+
return points
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _style_string(*values, fallback=None):
|
|
140
|
+
return next((value for value in reversed(values)
|
|
141
|
+
if isinstance(value, str) and value.strip()), fallback)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _style_number(*values, fallback, low=None, high=None):
|
|
145
|
+
value = next((value for value in reversed(values)
|
|
146
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)), fallback)
|
|
147
|
+
return max(low, min(high, value)) if low is not None and high is not None else max(low, value) if low is not None else value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _line_style(options: Mapping, trace: Mapping, default_color=None) -> tuple[str, float, str, float, str]:
|
|
151
|
+
defaults = get_defaults("trace-options")
|
|
152
|
+
style = _object(trace.get("lineStyle") or {}, "lineStyle")
|
|
153
|
+
global_style = _object(((_object(options.get("style") or {}, "style").get("trace") or {}).get("line") or {}), "style.trace.line")
|
|
154
|
+
plot_line = _object(options.get("lineStyle") or {}, "lineStyle")
|
|
155
|
+
color = _style_string(global_style.get("color"), plot_line.get("color"), trace.get("color"), style.get("color"), fallback=default_color)
|
|
156
|
+
width = _style_number(global_style.get("lineWidth"), plot_line.get("lineWidth"), style.get("lineWidth"), fallback=defaults["lineWidth"], low=0)
|
|
157
|
+
interpolation = next((value for value in (style.get("interpolation"), plot_line.get("interpolation"), global_style.get("interpolation")) if value is not None), defaults["interpolation"])
|
|
158
|
+
opacity = _style_number(global_style.get("opacity"), plot_line.get("opacity"), style.get("opacity"), fallback=defaults["opacity"], low=0, high=1)
|
|
159
|
+
pattern = next((value for value in (style.get("pattern"), plot_line.get("pattern"), global_style.get("pattern")) if value is not None), defaults["pattern"])
|
|
160
|
+
return color, width, interpolation, opacity, pattern
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _line_markers(options: Mapping, trace: Mapping):
|
|
164
|
+
style = ((_object(options.get("style") or {}, "style").get("trace") or {}).get("marker"))
|
|
165
|
+
plot_marker, trace_marker = options.get("markers"), trace.get("markers")
|
|
166
|
+
if style is None and plot_marker is None and trace_marker is None:
|
|
167
|
+
return None
|
|
168
|
+
defaults = get_defaults("markers")
|
|
169
|
+
marker = {"show": defaults["show"], "size": defaults["size"], "shape": defaults["shape"]}
|
|
170
|
+
for source in (style, plot_marker, trace_marker):
|
|
171
|
+
if source is None:
|
|
172
|
+
continue
|
|
173
|
+
if isinstance(source, bool):
|
|
174
|
+
marker["show"] = source
|
|
175
|
+
continue
|
|
176
|
+
source = _object(source, "markers")
|
|
177
|
+
marker["show"] = source.get("show") if source.get("show") is not None else True
|
|
178
|
+
marker["size"] = _style_number(source.get("size"), fallback=marker["size"], low=defaults["minSize"])
|
|
179
|
+
color = _style_string(source.get("color"))
|
|
180
|
+
if color is not None:
|
|
181
|
+
marker["color"] = color
|
|
182
|
+
if source.get("shape") is not None:
|
|
183
|
+
marker["shape"] = source["shape"]
|
|
184
|
+
return marker
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _segments(points: list[dict | None], connect: bool) -> list[list[dict]]:
|
|
188
|
+
if connect:
|
|
189
|
+
return [[point for point in points if point is not None]]
|
|
190
|
+
output, current = [], []
|
|
191
|
+
for point in points:
|
|
192
|
+
if point is None:
|
|
193
|
+
if current:
|
|
194
|
+
output.append(current)
|
|
195
|
+
current = []
|
|
196
|
+
else:
|
|
197
|
+
current.append(point)
|
|
198
|
+
if current:
|
|
199
|
+
output.append(current)
|
|
200
|
+
return output
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _interpolate(points: list[dict], mode: str) -> list[dict]:
|
|
204
|
+
if mode == "linear" or len(points) < 2:
|
|
205
|
+
return points
|
|
206
|
+
if mode == "smooth":
|
|
207
|
+
# Pinned fastPlotJs core/lineInterpolation.ts and trace-options.json:
|
|
208
|
+
# twelve clamped Catmull-Rom samples per source segment.
|
|
209
|
+
samples_per_segment = 12
|
|
210
|
+
def clamp(value, start, end):
|
|
211
|
+
return min(max(value, min(start, end)), max(start, end))
|
|
212
|
+
def catmull(control_start, start, end, control_end, ratio):
|
|
213
|
+
squared = ratio * ratio
|
|
214
|
+
cubed = squared * ratio
|
|
215
|
+
return .5 * (2 * start + (-control_start + end) * ratio
|
|
216
|
+
+ (2 * control_start - 5 * start + 4 * end - control_end) * squared
|
|
217
|
+
+ (-control_start + 3 * start - 3 * end + control_end) * cubed)
|
|
218
|
+
expanded = [points[0]]
|
|
219
|
+
for index, (start, end) in enumerate(zip(points, points[1:])):
|
|
220
|
+
control_start = points[max(0, index - 1)]
|
|
221
|
+
control_end = points[min(len(points) - 1, index + 2)]
|
|
222
|
+
for sample in range(1, samples_per_segment + 1):
|
|
223
|
+
if sample == samples_per_segment:
|
|
224
|
+
expanded.append(end)
|
|
225
|
+
continue
|
|
226
|
+
ratio = sample / samples_per_segment
|
|
227
|
+
expanded.append({dimension: clamp(catmull(control_start[dimension], start[dimension],
|
|
228
|
+
end[dimension], control_end[dimension], ratio),
|
|
229
|
+
start[dimension], end[dimension])
|
|
230
|
+
for dimension in ("x", "y")})
|
|
231
|
+
return expanded
|
|
232
|
+
if mode not in ("step-before", "step-after", "step-center"):
|
|
233
|
+
raise ValueError(f"Unsupported line interpolation: {mode}")
|
|
234
|
+
output = [points[0]]
|
|
235
|
+
for left, right in zip(points, points[1:]):
|
|
236
|
+
if mode == "step-before":
|
|
237
|
+
output.append({"x": left["x"], "y": right["y"]})
|
|
238
|
+
elif mode == "step-after":
|
|
239
|
+
output.append({"x": right["x"], "y": left["y"]})
|
|
240
|
+
else:
|
|
241
|
+
mid = (left["x"] + right["x"]) / 2
|
|
242
|
+
output.extend(({"x": mid, "y": left["y"]}, {"x": mid, "y": right["y"]}))
|
|
243
|
+
output.append(right)
|
|
244
|
+
return output
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _downsample(points: list[dict], width: float) -> list[dict]:
|
|
248
|
+
# The source uses two buckets per horizontal pixel, retaining first/min/max/last.
|
|
249
|
+
target = max(int(width * 2), 2)
|
|
250
|
+
if len(points) <= target:
|
|
251
|
+
return points
|
|
252
|
+
output = []
|
|
253
|
+
for bucket in range(target):
|
|
254
|
+
start = int(bucket * len(points) / target)
|
|
255
|
+
end = min(int((bucket + 1) * len(points) / target), len(points))
|
|
256
|
+
if end <= start:
|
|
257
|
+
continue
|
|
258
|
+
minimum = min(range(start, end), key=lambda index: points[index]["y"])
|
|
259
|
+
maximum = max(range(start, end), key=lambda index: points[index]["y"])
|
|
260
|
+
for index in sorted({start, minimum, maximum, end - 1}):
|
|
261
|
+
output.append(points[index])
|
|
262
|
+
if output[-1] is not points[-1]:
|
|
263
|
+
output.append(points[-1])
|
|
264
|
+
return output
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _select_viewport(points: list[dict], x_axis: Mapping, y_axis: Mapping,
|
|
268
|
+
x_domain: tuple[float, float], y_domain: tuple[float, float]) -> list[dict]:
|
|
269
|
+
if len(points) <= 2:
|
|
270
|
+
return points
|
|
271
|
+
transformed = [{"x": _scaled(point["x"], x_axis), "y": _scaled(point["y"], y_axis)} for point in points]
|
|
272
|
+
selected = set()
|
|
273
|
+
for index, point in enumerate(transformed):
|
|
274
|
+
inside = x_domain[0] <= point["x"] <= x_domain[1] and y_domain[0] <= point["y"] <= y_domain[1]
|
|
275
|
+
if inside:
|
|
276
|
+
selected.update(neighbor for neighbor in (index - 1, index, index + 1) if 0 <= neighbor < len(points))
|
|
277
|
+
continue
|
|
278
|
+
if index == 0:
|
|
279
|
+
continue
|
|
280
|
+
start, end = transformed[index - 1], point
|
|
281
|
+
dx, dy = end["x"] - start["x"], end["y"] - start["y"]
|
|
282
|
+
left, right = 0.0, 1.0
|
|
283
|
+
intersects = True
|
|
284
|
+
for p, q in ((-dx, start["x"] - x_domain[0]), (dx, x_domain[1] - start["x"]),
|
|
285
|
+
(-dy, start["y"] - y_domain[0]), (dy, y_domain[1] - start["y"])):
|
|
286
|
+
if p == 0:
|
|
287
|
+
if q < 0:
|
|
288
|
+
intersects = False
|
|
289
|
+
break
|
|
290
|
+
continue
|
|
291
|
+
ratio = q / p
|
|
292
|
+
if p < 0:
|
|
293
|
+
if ratio > right:
|
|
294
|
+
intersects = False
|
|
295
|
+
break
|
|
296
|
+
left = max(left, ratio)
|
|
297
|
+
else:
|
|
298
|
+
if ratio < left:
|
|
299
|
+
intersects = False
|
|
300
|
+
break
|
|
301
|
+
right = min(right, ratio)
|
|
302
|
+
if intersects:
|
|
303
|
+
selected.update((index - 1, index))
|
|
304
|
+
return [points[index] for index in sorted(selected)]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _line_area_fill(options: Mapping, trace: Mapping, y_axis: Mapping,
|
|
308
|
+
domain: tuple[float, float], height: float, trace_index: int) -> dict | None:
|
|
309
|
+
defaults = get_defaults("area-fill")
|
|
310
|
+
resolved = {"show": defaults["show"], "opacity": defaults["opacity"], "baseline": defaults["baseline"]}
|
|
311
|
+
style_area = (((options.get("style") or {}).get("trace") or {}).get("area"))
|
|
312
|
+
sources = (("style.trace.area", style_area, "area" in ((options.get("style") or {}).get("trace") or {})),
|
|
313
|
+
("areaFill", options.get("areaFill"), "areaFill" in options),
|
|
314
|
+
(f"traces[{trace_index}].areaFill", trace.get("areaFill"), "areaFill" in trace))
|
|
315
|
+
for name, source, present in sources:
|
|
316
|
+
if not present:
|
|
317
|
+
continue
|
|
318
|
+
if isinstance(source, bool):
|
|
319
|
+
resolved["show"] = source
|
|
320
|
+
continue
|
|
321
|
+
if not isinstance(source, Mapping):
|
|
322
|
+
raise ValueError(f"{name} must be a boolean or object")
|
|
323
|
+
if "show" in source and not isinstance(source["show"], bool):
|
|
324
|
+
raise ValueError(f"{name}.show must be a boolean")
|
|
325
|
+
if "color" in source and not isinstance(source["color"], str):
|
|
326
|
+
raise ValueError(f"{name}.color must be a string")
|
|
327
|
+
if "opacity" in source and (isinstance(source["opacity"], bool) or not isinstance(source["opacity"], (int, float)) or not isfinite(source["opacity"]) or not 0 <= source["opacity"] <= 1):
|
|
328
|
+
raise ValueError(f"{name}.opacity must be finite and between 0 and 1")
|
|
329
|
+
resolved["show"] = source.get("show", True)
|
|
330
|
+
if isinstance(source.get("color"), str) and source["color"].strip():
|
|
331
|
+
resolved["color"] = source["color"]
|
|
332
|
+
resolved["opacity"] = source.get("opacity", resolved["opacity"])
|
|
333
|
+
resolved["baseline"] = source.get("baseline", resolved["baseline"])
|
|
334
|
+
if not resolved["show"]:
|
|
335
|
+
return None
|
|
336
|
+
baseline = resolved["baseline"]
|
|
337
|
+
if baseline == "axis-min":
|
|
338
|
+
baseline_y = height
|
|
339
|
+
elif baseline == "axis-max":
|
|
340
|
+
baseline_y = 0
|
|
341
|
+
else:
|
|
342
|
+
value = 0 if baseline == "zero" else _number(baseline, y_axis)
|
|
343
|
+
transformed = _scaled(value, y_axis)
|
|
344
|
+
baseline_y = min(height, max(0, (1 - (transformed - domain[0]) / (domain[1] - domain[0])) * height))
|
|
345
|
+
output = {"opacity": resolved["opacity"], "baselineY": baseline_y}
|
|
346
|
+
if "color" in resolved:
|
|
347
|
+
output = {"color": resolved["color"], **output}
|
|
348
|
+
return output
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _line_scene(options: Mapping, width: float, height: float,
|
|
352
|
+
forced_x_domain=None, forced_y_domain=None) -> dict:
|
|
353
|
+
traces = _traces(options)
|
|
354
|
+
x_axis, y_axis = _axis(options, "x"), _axis(options, "y")
|
|
355
|
+
viewport = options.get("viewport") or {}
|
|
356
|
+
if (viewport.get("xAxes") or {}).get("x") is not None:
|
|
357
|
+
x_axis = {**x_axis, "range": viewport["xAxes"]["x"]}
|
|
358
|
+
if (viewport.get("yAxes") or {}).get("y") is not None:
|
|
359
|
+
y_axis = {**y_axis, "range": viewport["yAxes"]["y"]}
|
|
360
|
+
if not traces:
|
|
361
|
+
_line_area_fill(options, {}, y_axis, (0, 1), height, 0)
|
|
362
|
+
for index, trace in enumerate(traces):
|
|
363
|
+
_line_area_fill(options, trace, y_axis, (0, 1), height, index)
|
|
364
|
+
normalized = [_point_data(trace, x_axis, y_axis) for trace in traces]
|
|
365
|
+
visible = [index for index, trace in enumerate(traces) if trace.get("visible") is not False]
|
|
366
|
+
all_points = [point for index in visible for point in normalized[index] if point is not None]
|
|
367
|
+
errors_by_trace = {}
|
|
368
|
+
error_x, error_y = [], []
|
|
369
|
+
for index, trace in enumerate(traces):
|
|
370
|
+
color = _line_style(options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])[0]
|
|
371
|
+
resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), color)
|
|
372
|
+
errors_by_trace[index] = resolved
|
|
373
|
+
if index in visible:
|
|
374
|
+
xs, ys = error_domain_values(trace, resolved, x_axis, y_axis)
|
|
375
|
+
error_x.extend(xs)
|
|
376
|
+
error_y.extend(ys)
|
|
377
|
+
x_domain = forced_x_domain if forced_x_domain is not None else _domain(
|
|
378
|
+
[point["x"] for point in all_points] + error_x, x_axis)
|
|
379
|
+
y_domain = forced_y_domain if forced_y_domain is not None else _domain(
|
|
380
|
+
[point["y"] for point in all_points] + error_y, y_axis)
|
|
381
|
+
primitives = []
|
|
382
|
+
for index in visible:
|
|
383
|
+
trace = traces[index]
|
|
384
|
+
points = normalized[index]
|
|
385
|
+
if not points or all(point is None for point in points):
|
|
386
|
+
continue
|
|
387
|
+
domain_x = x_domain or _domain([point["x"] for point in points if point is not None], x_axis)
|
|
388
|
+
domain_y = y_domain or _domain([point["y"] for point in points if point is not None], y_axis)
|
|
389
|
+
assert domain_x is not None and domain_y is not None
|
|
390
|
+
span_x = max(domain_x[1] - domain_x[0], 2.220446049250313e-16)
|
|
391
|
+
span_y = max(domain_y[1] - domain_y[0], 2.220446049250313e-16)
|
|
392
|
+
color, line_width, interpolation, opacity, pattern = _line_style(
|
|
393
|
+
options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])
|
|
394
|
+
markers = _line_markers(options, trace)
|
|
395
|
+
connect = trace.get("connectGaps", options.get("connectGaps", get_defaults("trace-options")["connectGaps"]))
|
|
396
|
+
label_options = resolve_point_labels(options.get("pointLabels"), trace.get("pointLabels"))
|
|
397
|
+
point_labels = build_point_labels(trace, points, label_options, markers, x_axis, y_axis,
|
|
398
|
+
domain_x, domain_y, width, height)
|
|
399
|
+
area_fill = _line_area_fill(options, trace, y_axis, domain_y, height, index)
|
|
400
|
+
error_bars = error_geometry(trace, errors_by_trace[index], x_axis, y_axis,
|
|
401
|
+
domain_x, domain_y, width, height)
|
|
402
|
+
first_primitive = len(primitives)
|
|
403
|
+
use_axis_range = bool(x_axis.get("range") or y_axis.get("range"))
|
|
404
|
+
has_gaps = any(point is None for point in points)
|
|
405
|
+
render_points = [point for point in points if point is not None]
|
|
406
|
+
if use_axis_range and not has_gaps:
|
|
407
|
+
render_points = _select_viewport(render_points, x_axis, y_axis, domain_x, domain_y)
|
|
408
|
+
if not render_points:
|
|
409
|
+
continue
|
|
410
|
+
native_points = None
|
|
411
|
+
if options.get("engine") == "rust" and not (has_gaps and not connect):
|
|
412
|
+
native = rustCall(
|
|
413
|
+
"line_domain", points=render_points, width=width, height=height,
|
|
414
|
+
xScale=x_axis.get("scale", "linear"), yScale=y_axis.get("scale", "linear"),
|
|
415
|
+
domainMinX=domain_x[0], domainMaxX=domain_x[1],
|
|
416
|
+
domainMinY=domain_y[0], domainMaxY=domain_y[1],
|
|
417
|
+
)
|
|
418
|
+
native_points = native["primitives"][0]["points"] if native["primitives"] else []
|
|
419
|
+
for segment in _segments(points, connect):
|
|
420
|
+
if not has_gaps:
|
|
421
|
+
segment = render_points if native_points is not None else _downsample(render_points, width)
|
|
422
|
+
rendered = native_points if native_points is not None else [
|
|
423
|
+
{"x": (_scaled(point["x"], x_axis) - domain_x[0]) / span_x * width,
|
|
424
|
+
"y": (1 - (_scaled(point["y"], y_axis) - domain_y[0]) / span_y) * height} for point in segment]
|
|
425
|
+
paths = clip_polyline(_interpolate(rendered, interpolation), width, height) if use_axis_range else [_interpolate(rendered, interpolation)]
|
|
426
|
+
if use_axis_range and error_bars and not paths:
|
|
427
|
+
paths = [[point for point in rendered if 0 <= point["x"] <= width and 0 <= point["y"] <= height]]
|
|
428
|
+
for path in paths:
|
|
429
|
+
base = {"type": "line", "points": path, "color": color,
|
|
430
|
+
"lineWidth": line_width}
|
|
431
|
+
if opacity != 1:
|
|
432
|
+
base["lineOpacity"] = opacity
|
|
433
|
+
if interpolation != "linear":
|
|
434
|
+
base.update({"interpolation": interpolation, "markerPoints": [point for point in rendered if not use_axis_range or 0 <= point["x"] <= width and 0 <= point["y"] <= height]})
|
|
435
|
+
if pattern != "solid":
|
|
436
|
+
base["linePattern"] = pattern
|
|
437
|
+
if area_fill is not None:
|
|
438
|
+
base["areaFill"] = area_fill
|
|
439
|
+
base.update({"subplot": "main", "traceIndex": index,
|
|
440
|
+
"bounds": {"left": 0, "top": 0, "right": width, "bottom": height}})
|
|
441
|
+
if markers is not None:
|
|
442
|
+
base["markers"] = markers
|
|
443
|
+
primitives.append(base)
|
|
444
|
+
if error_bars and len(primitives) > first_primitive:
|
|
445
|
+
primitives[first_primitive]["errorBars"] = error_bars
|
|
446
|
+
primitives[first_primitive]["errorBarStyle"] = errors_by_trace[index]["style"]
|
|
447
|
+
if point_labels and len(primitives) > first_primitive:
|
|
448
|
+
primitives[first_primitive]["linePointLabels"] = point_labels
|
|
449
|
+
trendline = resolve_trendline(options.get("trendline"), trace.get("trendline"))
|
|
450
|
+
trend_data = sample_trendline(points, trendline)
|
|
451
|
+
if trend_data is not None:
|
|
452
|
+
source_points, degree = trend_data
|
|
453
|
+
rendered = [{"x": (_scaled(point["x"], x_axis) - domain_x[0]) / span_x * width,
|
|
454
|
+
"y": (1 - (_scaled(point["y"], y_axis) - domain_y[0]) / span_y) * height}
|
|
455
|
+
for point in source_points]
|
|
456
|
+
metadata = {"sourceTraceIndex": index, "method": trendline["method"]}
|
|
457
|
+
if trendline["method"] == "polynomial":
|
|
458
|
+
metadata["degree"] = degree
|
|
459
|
+
for clipped in clip_polyline(rendered, width, height):
|
|
460
|
+
primitive = {"type": "line", "points": clipped,
|
|
461
|
+
"color": trendline.get("color") or color, "lineWidth": line_width,
|
|
462
|
+
"subplot": "main", "bounds": {"left": 0, "top": 0, "right": width, "bottom": height},
|
|
463
|
+
"trendline": metadata}
|
|
464
|
+
if opacity != 1:
|
|
465
|
+
primitive["lineOpacity"] = opacity
|
|
466
|
+
primitives.append(primitive)
|
|
467
|
+
return {"width": width, "height": height, "primitives": primitives}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _line_subplot_scene(options: Mapping, width: float, height: float) -> dict:
|
|
471
|
+
"""Compose line traces in named bounds while sharing domains by axis ID."""
|
|
472
|
+
subplots = resolveSubplots(options)
|
|
473
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
474
|
+
by_subplot = {subplot["id"]: subplot for subplot in subplots}
|
|
475
|
+
domain_values = {"x": {}, "y": {}}
|
|
476
|
+
for index, binding in enumerate(bindings):
|
|
477
|
+
trace = binding["trace"]
|
|
478
|
+
x_id, y_id = binding["xAxisId"], binding["yAxisId"]
|
|
479
|
+
x_axis, y_axis = _axis(options, "x", x_id), _axis(options, "y", y_id)
|
|
480
|
+
points = _point_data(trace, x_axis, y_axis)
|
|
481
|
+
color = _line_style(options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])[0]
|
|
482
|
+
errors = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), color)
|
|
483
|
+
if trace.get("visible") is not False:
|
|
484
|
+
for dimension, axis_id in (("x", x_id), ("y", y_id)):
|
|
485
|
+
domain_values[dimension].setdefault(axis_id, [])
|
|
486
|
+
domain_values["x"][x_id].extend(point["x"] for point in points if point is not None)
|
|
487
|
+
domain_values["y"][y_id].extend(point["y"] for point in points if point is not None)
|
|
488
|
+
error_x, error_y = error_domain_values(trace, errors, x_axis, y_axis)
|
|
489
|
+
domain_values["x"][x_id].extend(error_x)
|
|
490
|
+
domain_values["y"][y_id].extend(error_y)
|
|
491
|
+
domains = {
|
|
492
|
+
dimension: {axis_id: _domain(values, _axis(options, dimension, axis_id))
|
|
493
|
+
for axis_id, values in groups.items()}
|
|
494
|
+
for dimension, groups in domain_values.items()
|
|
495
|
+
}
|
|
496
|
+
primitives = []
|
|
497
|
+
for index, binding in enumerate(bindings):
|
|
498
|
+
subplot = by_subplot[binding["subplotId"]]
|
|
499
|
+
bounds = subplot["bounds"]
|
|
500
|
+
local_width, local_height = bounds["right"] - bounds["left"], bounds["bottom"] - bounds["top"]
|
|
501
|
+
trace = dict(binding["trace"])
|
|
502
|
+
trace["color"] = _line_style(options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])[0]
|
|
503
|
+
x_axis = _axis(options, "x", binding["xAxisId"])
|
|
504
|
+
y_axis = _axis(options, "y", binding["yAxisId"])
|
|
505
|
+
local_options = {key: value for key, value in options.items()
|
|
506
|
+
if key not in ("subplots", "data", "traces", "axes", "viewport")}
|
|
507
|
+
local_options["traces"] = [trace]
|
|
508
|
+
local_options["axes"] = {"xAxes": {"x": x_axis}, "yAxes": {"y": y_axis}}
|
|
509
|
+
local = _line_scene(local_options, local_width, local_height,
|
|
510
|
+
domains["x"].get(binding["xAxisId"]),
|
|
511
|
+
domains["y"].get(binding["yAxisId"]))
|
|
512
|
+
for primitive in local["primitives"]:
|
|
513
|
+
primitive["subplot"] = subplot["id"]
|
|
514
|
+
primitive["bounds"] = dict(bounds)
|
|
515
|
+
if "traceIndex" in primitive:
|
|
516
|
+
primitive["traceIndex"] = index
|
|
517
|
+
if primitive.get("trendline"):
|
|
518
|
+
primitive["trendline"]["sourceTraceIndex"] = index
|
|
519
|
+
primitives.append(primitive)
|
|
520
|
+
return {"width": width, "height": height, "primitives": primitives}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _wonder_subplot_scene(options: Mapping, width: float, height: float) -> dict:
|
|
524
|
+
"""Compose wonderplot scenes with shared coordinate and marker scales."""
|
|
525
|
+
from .families2d import _wonder_scale
|
|
526
|
+
|
|
527
|
+
subplots = resolveSubplots(options)
|
|
528
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
529
|
+
by_subplot = {subplot["id"]: subplot for subplot in subplots}
|
|
530
|
+
traces = [binding["trace"] for binding in bindings]
|
|
531
|
+
wonder = options.get("wonderplot") or {}
|
|
532
|
+
defaults = get_defaults("wonderplot")
|
|
533
|
+
global_scales = {key: _wonder_scale(wonder, traces, defaults, key)
|
|
534
|
+
for key in ("sizeScale", "opacityScale")}
|
|
535
|
+
color_values = [float(point.get("value", point.get("z")))
|
|
536
|
+
for trace in traces if trace.get("visible") is not False
|
|
537
|
+
for point in trace.get("data", [])
|
|
538
|
+
if point.get("x") is not None and point.get("y") is not None
|
|
539
|
+
and point.get("value", point.get("z")) is not None]
|
|
540
|
+
if not color_values:
|
|
541
|
+
return {"width": width, "height": height, "primitives": []}
|
|
542
|
+
color_config = wonder.get("colorScale") or {}
|
|
543
|
+
color_low = color_config.get("minValue", min(color_values))
|
|
544
|
+
color_high = color_config.get("maxValue", max(color_values))
|
|
545
|
+
axis_values = {"x": {}, "y": {}}
|
|
546
|
+
for binding in bindings:
|
|
547
|
+
if binding["trace"].get("visible") is False:
|
|
548
|
+
continue
|
|
549
|
+
trace = binding["trace"]
|
|
550
|
+
x_id, y_id = binding["xAxisId"], binding["yAxisId"]
|
|
551
|
+
x_axis, y_axis = _axis(options, "x", x_id), _axis(options, "y", y_id)
|
|
552
|
+
points = _point_data(trace, x_axis, y_axis)
|
|
553
|
+
axis_values["x"].setdefault(x_id, []).extend(point["x"] for point in points if point is not None)
|
|
554
|
+
axis_values["y"].setdefault(y_id, []).extend(point["y"] for point in points if point is not None)
|
|
555
|
+
resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), "")
|
|
556
|
+
error_x, error_y = error_domain_values(trace, resolved, x_axis, y_axis)
|
|
557
|
+
axis_values["x"][x_id].extend(error_x)
|
|
558
|
+
axis_values["y"][y_id].extend(error_y)
|
|
559
|
+
domains = {dimension: {axis_id: _domain(values, _axis(options, dimension, axis_id))
|
|
560
|
+
for axis_id, values in grouped.items()}
|
|
561
|
+
for dimension, grouped in axis_values.items()}
|
|
562
|
+
primitives = []
|
|
563
|
+
legend_shown = set()
|
|
564
|
+
for index, binding in enumerate(bindings):
|
|
565
|
+
trace = dict(binding["trace"])
|
|
566
|
+
subplot = by_subplot[binding["subplotId"]]
|
|
567
|
+
bounds = subplot["bounds"]
|
|
568
|
+
local_width, local_height = bounds["right"] - bounds["left"], bounds["bottom"] - bounds["top"]
|
|
569
|
+
x_domain, y_domain = (domains["x"].get(binding["xAxisId"]),
|
|
570
|
+
domains["y"].get(binding["yAxisId"]))
|
|
571
|
+
if x_domain is None or y_domain is None:
|
|
572
|
+
continue
|
|
573
|
+
x_axis = _axis(options, "x", binding["xAxisId"])
|
|
574
|
+
y_axis = _axis(options, "y", binding["yAxisId"])
|
|
575
|
+
logarithmic = x_axis.get("scale") == "logarithmic" or y_axis.get("scale") == "logarithmic"
|
|
576
|
+
if logarithmic:
|
|
577
|
+
trace["data"] = [({**point,
|
|
578
|
+
"x": _scaled(_number(point["x"], x_axis), x_axis),
|
|
579
|
+
"y": _scaled(_number(point["y"], y_axis), y_axis)}
|
|
580
|
+
if point.get("x") is not None and point.get("y") is not None else dict(point))
|
|
581
|
+
for point in trace.get("data", [])]
|
|
582
|
+
trace.pop("errorBars", None)
|
|
583
|
+
if trace.get("color") is None:
|
|
584
|
+
trace["color"] = DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)]
|
|
585
|
+
scoped_wonder = {**wonder, "colorScale": {**color_config,
|
|
586
|
+
"minValue": color_low, "maxValue": color_high}}
|
|
587
|
+
for key, scale in global_scales.items():
|
|
588
|
+
if scale is not None:
|
|
589
|
+
scoped_wonder[key] = {**(wonder.get(key) or {}),
|
|
590
|
+
"minValue": scale["min"], "maxValue": scale["max"]}
|
|
591
|
+
local_options = {key: value for key, value in options.items()
|
|
592
|
+
if key not in ("subplots", "data", "traces", "axes", "viewport")}
|
|
593
|
+
if logarithmic:
|
|
594
|
+
local_options.pop("errorBars", None)
|
|
595
|
+
local_options.update({"traces": [trace], "wonderplot": scoped_wonder,
|
|
596
|
+
"axes": {"xAxes": {"x": {"range": {"min": x_domain[0], "max": x_domain[1]}}},
|
|
597
|
+
"yAxes": {"y": {"range": {"min": y_domain[0], "max": y_domain[1]}}}}})
|
|
598
|
+
local = wonderplot_scene(local_options, local_width, local_height)
|
|
599
|
+
for primitive in local["primitives"]:
|
|
600
|
+
if logarithmic:
|
|
601
|
+
source_trace = binding["trace"]
|
|
602
|
+
resolved = resolve_error_bars(options.get("errorBars"), source_trace.get("errorBars"), primitive["color"])
|
|
603
|
+
bars = error_geometry(source_trace, resolved, x_axis, y_axis,
|
|
604
|
+
x_domain, y_domain, local_width, local_height)
|
|
605
|
+
if bars:
|
|
606
|
+
primitive["errorBars"] = bars
|
|
607
|
+
primitive["errorBarStyle"] = resolved["style"]
|
|
608
|
+
primitive["subplot"] = subplot["id"]
|
|
609
|
+
primitive["bounds"] = dict(bounds)
|
|
610
|
+
primitive["traceIndex"] = index
|
|
611
|
+
if subplot["id"] in legend_shown:
|
|
612
|
+
primitive.pop("wonderplotColorScaleLegend", None)
|
|
613
|
+
else:
|
|
614
|
+
legend_shown.add(subplot["id"])
|
|
615
|
+
primitives.append(primitive)
|
|
616
|
+
return {"width": width, "height": height, "primitives": primitives}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _bar_subplot_scene(options: Mapping, width: float, height: float) -> dict:
|
|
620
|
+
"""Compose grouped bar geometry by subplot and shared axis identities."""
|
|
621
|
+
subplots = resolveSubplots(options)
|
|
622
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
623
|
+
by_subplot = {subplot["id"]: subplot for subplot in subplots}
|
|
624
|
+
bar = options.get("bar") or {}
|
|
625
|
+
mode = bar.get("mode", get_defaults("bar")["mode"])
|
|
626
|
+
orientation = bar.get("orientation", get_defaults("bar")["orientation"])
|
|
627
|
+
slot_dimension = "x" if orientation == "vertical" else "y"
|
|
628
|
+
value_dimension = "y" if orientation == "vertical" else "x"
|
|
629
|
+
groups = {}
|
|
630
|
+
slots_by_axis = {}
|
|
631
|
+
ranges_by_axis = {}
|
|
632
|
+
for index, binding in enumerate(bindings):
|
|
633
|
+
if binding["trace"].get("visible") is False:
|
|
634
|
+
continue
|
|
635
|
+
key = (binding["subplotId"], binding["xAxisId"], binding["yAxisId"])
|
|
636
|
+
groups.setdefault(key, []).append((index, binding))
|
|
637
|
+
slot_id = binding[f"{slot_dimension}AxisId"]
|
|
638
|
+
value_id = binding[f"{value_dimension}AxisId"]
|
|
639
|
+
slot_axis = _axis(options, slot_dimension, slot_id)
|
|
640
|
+
value_axis = _axis(options, value_dimension, value_id)
|
|
641
|
+
for point in binding["trace"].get("data", []):
|
|
642
|
+
if point.get("x") is None or point.get("y") is None:
|
|
643
|
+
continue
|
|
644
|
+
slot = _number(point[slot_dimension], slot_axis)
|
|
645
|
+
value = _number(point[value_dimension], value_axis)
|
|
646
|
+
slots_by_axis.setdefault(slot_id, set()).add(slot)
|
|
647
|
+
if mode == "grouped":
|
|
648
|
+
ranges_by_axis.setdefault(value_id, []).extend((
|
|
649
|
+
_number(point.get("base") if point.get("base") is not None else 0,
|
|
650
|
+
value_axis), value))
|
|
651
|
+
if mode == "grouped":
|
|
652
|
+
resolved = resolve_error_bars(options.get("errorBars"),
|
|
653
|
+
binding["trace"].get("errorBars"), "")
|
|
654
|
+
error_x, error_y = error_domain_values(binding["trace"], resolved,
|
|
655
|
+
_axis(options, "x", binding["xAxisId"]),
|
|
656
|
+
_axis(options, "y", binding["yAxisId"]))
|
|
657
|
+
ranges_by_axis.setdefault(value_id, []).extend(
|
|
658
|
+
error_y if orientation == "vertical" else error_x)
|
|
659
|
+
if mode in ("stacked", "normalized"):
|
|
660
|
+
for key, members in groups.items():
|
|
661
|
+
value_id = key[2] if orientation == "vertical" else key[1]
|
|
662
|
+
stacks = {}
|
|
663
|
+
for _, binding in members:
|
|
664
|
+
slot_axis = _axis(options, slot_dimension, binding[f"{slot_dimension}AxisId"])
|
|
665
|
+
value_axis = _axis(options, value_dimension, value_id)
|
|
666
|
+
for point in binding["trace"].get("data", []):
|
|
667
|
+
if point.get("x") is None or point.get("y") is None:
|
|
668
|
+
continue
|
|
669
|
+
slot = _number(point[slot_dimension], slot_axis)
|
|
670
|
+
value = _number(point[value_dimension], value_axis)
|
|
671
|
+
positive, negative = stacks.get(slot, (0, 0))
|
|
672
|
+
stacks[slot] = ((positive + value, negative) if value >= 0 else
|
|
673
|
+
(positive, negative + value))
|
|
674
|
+
values = ranges_by_axis.setdefault(value_id, [0])
|
|
675
|
+
for positive, negative in stacks.values():
|
|
676
|
+
if mode == "normalized":
|
|
677
|
+
values.extend((100 if positive > 0 else 0, -100 if negative < 0 else 0))
|
|
678
|
+
else:
|
|
679
|
+
values.extend((positive, negative))
|
|
680
|
+
slot_domains = {}
|
|
681
|
+
for axis_id, values in slots_by_axis.items():
|
|
682
|
+
axis = _axis(options, slot_dimension, axis_id)
|
|
683
|
+
if axis.get("categories"):
|
|
684
|
+
values.update(range(1, len(axis["categories"]) + 1))
|
|
685
|
+
ordered = sorted(values)
|
|
686
|
+
domain = _domain(ordered, axis)
|
|
687
|
+
if domain is not None and axis.get("range") is None:
|
|
688
|
+
transformed = sorted(set(_scaled(value, axis) for value in ordered))
|
|
689
|
+
deltas = [right - left for left, right in zip(transformed, transformed[1:])
|
|
690
|
+
if right > left]
|
|
691
|
+
padding = min(deltas) / 2 if deltas else .5
|
|
692
|
+
domain = domain[0] - padding, domain[1] + padding
|
|
693
|
+
slot_domains[axis_id] = domain
|
|
694
|
+
value_domains = {}
|
|
695
|
+
for axis_id, values in ranges_by_axis.items():
|
|
696
|
+
axis = _axis(options, value_dimension, axis_id)
|
|
697
|
+
domain = _domain(values, axis)
|
|
698
|
+
if domain is not None:
|
|
699
|
+
value_domains[axis_id] = domain
|
|
700
|
+
indexed_primitives = []
|
|
701
|
+
for key, members in groups.items():
|
|
702
|
+
subplot = by_subplot[key[0]]
|
|
703
|
+
bounds = subplot["bounds"]
|
|
704
|
+
local_width, local_height = bounds["right"] - bounds["left"], bounds["bottom"] - bounds["top"]
|
|
705
|
+
selected = []
|
|
706
|
+
for global_index, binding in members:
|
|
707
|
+
trace = dict(binding["trace"])
|
|
708
|
+
trace["color"] = resolve_bar_line_style(
|
|
709
|
+
options, trace, DEFAULT_TRACE_COLORS[global_index % len(DEFAULT_TRACE_COLORS)])[0]
|
|
710
|
+
source_x = _axis(options, "x", binding["xAxisId"])
|
|
711
|
+
source_y = _axis(options, "y", binding["yAxisId"])
|
|
712
|
+
trace["data"] = [({**point,
|
|
713
|
+
"x": _number(point["x"], source_x),
|
|
714
|
+
"y": _number(point["y"], source_y),
|
|
715
|
+
**({"base": _number(point["base"],
|
|
716
|
+
source_x if orientation == "horizontal" else source_y)}
|
|
717
|
+
if point.get("base") is not None else {})}
|
|
718
|
+
if point.get("x") is not None and point.get("y") is not None else dict(point))
|
|
719
|
+
for point in trace.get("data", [])]
|
|
720
|
+
selected.append(trace)
|
|
721
|
+
local_options = {field: value for field, value in options.items()
|
|
722
|
+
if field not in ("subplots", "data", "traces", "axes", "viewport")}
|
|
723
|
+
local_options["traces"] = selected
|
|
724
|
+
local_options["axes"] = {"xAxes": {"x": {k: v for k, v in _axis(options, "x", key[1]).items()
|
|
725
|
+
if k != "range"}},
|
|
726
|
+
"yAxes": {"y": {k: v for k, v in _axis(options, "y", key[2]).items()
|
|
727
|
+
if k != "range"}}}
|
|
728
|
+
slot_id = key[1] if orientation == "vertical" else key[2]
|
|
729
|
+
value_id = key[2] if orientation == "vertical" else key[1]
|
|
730
|
+
slot_axis = _axis(options, slot_dimension, slot_id)
|
|
731
|
+
local_slots = sorted({_number(point[slot_dimension], slot_axis)
|
|
732
|
+
for _, binding in members for point in binding["trace"].get("data", [])
|
|
733
|
+
if point.get("x") is not None and point.get("y") is not None})
|
|
734
|
+
local = bar_scene(local_options, local_width, local_height,
|
|
735
|
+
local_slots, value_domains.get(value_id),
|
|
736
|
+
slot_domains.get(slot_id), slot_axis.get("scale", "linear"),
|
|
737
|
+
bool(slot_axis.get("categories")), True)
|
|
738
|
+
for primitive in local["primitives"]:
|
|
739
|
+
global_index = members[primitive["traceIndex"]][0]
|
|
740
|
+
primitive["traceIndex"] = global_index
|
|
741
|
+
primitive["subplot"] = subplot["id"]
|
|
742
|
+
primitive["bounds"] = dict(bounds)
|
|
743
|
+
indexed_primitives.append((global_index, primitive))
|
|
744
|
+
indexed_primitives.sort(key=lambda item: item[0])
|
|
745
|
+
return {"width": width, "height": height,
|
|
746
|
+
"primitives": [primitive for _, primitive in indexed_primitives]}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _pie_label_options(raw, defaults: Mapping) -> dict:
|
|
750
|
+
labels = dict(defaults)
|
|
751
|
+
if isinstance(raw, bool):
|
|
752
|
+
labels["show"] = raw
|
|
753
|
+
elif isinstance(raw, Mapping):
|
|
754
|
+
labels["show"] = raw.get("show", True)
|
|
755
|
+
if "position" in raw:
|
|
756
|
+
if raw["position"] not in ("inside", "outside"):
|
|
757
|
+
raise ValueError("pie.labels.position must be 'inside' or 'outside'")
|
|
758
|
+
labels["position"] = raw["position"]
|
|
759
|
+
if "content" in raw:
|
|
760
|
+
if raw["content"] not in ("label", "value", "fraction", "label-value", "label-fraction"):
|
|
761
|
+
raise ValueError("pie.labels.content must be 'label', 'value', 'fraction', 'label-value', or 'label-fraction'")
|
|
762
|
+
labels["content"] = raw["content"]
|
|
763
|
+
for key in ("color", "font"):
|
|
764
|
+
if isinstance(raw.get(key), str) and raw[key].strip():
|
|
765
|
+
labels[key] = raw[key]
|
|
766
|
+
for key in ("prefix", "suffix"):
|
|
767
|
+
if isinstance(raw.get(key), str):
|
|
768
|
+
labels[key] = raw[key]
|
|
769
|
+
for key in ("lineHeight", "minSectorAngleDegrees", "offset", "collisionPadding"):
|
|
770
|
+
value = raw.get(key)
|
|
771
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0:
|
|
772
|
+
labels[key] = value
|
|
773
|
+
value = raw.get("radiusRatio")
|
|
774
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value):
|
|
775
|
+
labels["radiusRatio"] = max(0, min(1, value))
|
|
776
|
+
value = raw.get("decimals")
|
|
777
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value):
|
|
778
|
+
labels["decimals"] = max(0, min(12, floor(value + .5)))
|
|
779
|
+
return labels
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _pie_fixed(value, decimals):
|
|
783
|
+
quantum = Decimal(1).scaleb(-decimals)
|
|
784
|
+
return format(Decimal(str(value)).quantize(quantum, rounding=ROUND_HALF_UP), f".{decimals}f")
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _pie_scene(options: Mapping, width: float, height: float) -> dict:
|
|
788
|
+
pie = _object(options.get("pie") or {}, "pie")
|
|
789
|
+
defaults = get_defaults("pie")
|
|
790
|
+
labels = _pie_label_options(pie.get("labels"), defaults["labels"])
|
|
791
|
+
if pie.get("traces"):
|
|
792
|
+
traces = pie["traces"]
|
|
793
|
+
elif "data" in pie:
|
|
794
|
+
traces = [{"data": pie["data"], "pie": {"hiddenDataIndexes": pie.get("hiddenDataIndexes", [])}}]
|
|
795
|
+
else:
|
|
796
|
+
traces = _traces(options)
|
|
797
|
+
has_explicit_traces = bool(options.get("traces")) or bool(pie.get("traces"))
|
|
798
|
+
normalized, counts = [], []
|
|
799
|
+
for trace_index, raw_trace in enumerate(traces):
|
|
800
|
+
trace = _object(raw_trace, "pie trace")
|
|
801
|
+
trace_pie = _object(trace.get("pie") or {}, "trace.pie")
|
|
802
|
+
if isinstance(trace_pie.get("data"), list) and (pie.get("traces") or trace_pie["data"]):
|
|
803
|
+
values = trace_pie["data"]
|
|
804
|
+
else:
|
|
805
|
+
values = trace.get("data") or []
|
|
806
|
+
if not isinstance(values, list):
|
|
807
|
+
raise TypeError("pie data must be an array")
|
|
808
|
+
counts.append(len(values))
|
|
809
|
+
if trace.get("visible") is False:
|
|
810
|
+
continue
|
|
811
|
+
raw_hidden = trace_pie.get("hiddenDataIndexes", [] if has_explicit_traces else pie.get("hiddenDataIndexes", []))
|
|
812
|
+
if raw_hidden is not None and not isinstance(raw_hidden, list):
|
|
813
|
+
raise TypeError("pie hiddenDataIndexes must be an array")
|
|
814
|
+
hidden = {value for value in (raw_hidden or [])
|
|
815
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
816
|
+
and isfinite(value) and value >= 0 and float(value).is_integer()}
|
|
817
|
+
for source_index, raw in enumerate(values):
|
|
818
|
+
if source_index in hidden:
|
|
819
|
+
continue
|
|
820
|
+
datum = _object(raw, "pie datum")
|
|
821
|
+
value = datum.get("value") if datum.get("value") is not None else datum.get("y")
|
|
822
|
+
if value is None:
|
|
823
|
+
continue
|
|
824
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
|
|
825
|
+
raise ValueError(f"pie datum {source_index} value must be a finite number")
|
|
826
|
+
if value < 0:
|
|
827
|
+
raise ValueError(f"pie datum {source_index} value must be non-negative")
|
|
828
|
+
if value == 0:
|
|
829
|
+
continue
|
|
830
|
+
label = next((datum.get(key) for key in ("label", "name", "x")
|
|
831
|
+
if isinstance(datum.get(key), str) and datum[key].strip()), f"#{source_index + 1}")
|
|
832
|
+
normalized.append({"label": label, "value": value, "sourceIndex": source_index,
|
|
833
|
+
"traceIndex": trace_index, "traceColor": trace.get("color")})
|
|
834
|
+
total = sum(item["value"] for item in normalized)
|
|
835
|
+
if not isfinite(total):
|
|
836
|
+
raise ValueError("pie total value must remain finite")
|
|
837
|
+
if not normalized or total <= 0:
|
|
838
|
+
return {"width": width, "height": height, "primitives": []}
|
|
839
|
+
colors = [color for color in pie.get("colors", defaults["colors"]) if isinstance(color, str) and color.strip()] or list(DEFAULT_TRACE_COLORS)
|
|
840
|
+
def finite(value, fallback):
|
|
841
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) else fallback
|
|
842
|
+
def nonnegative(value, fallback):
|
|
843
|
+
selected = finite(value, fallback)
|
|
844
|
+
return selected if selected >= 0 else fallback
|
|
845
|
+
def ratio(value, fallback, low, high):
|
|
846
|
+
return max(low, min(high, finite(value, fallback)))
|
|
847
|
+
start = radians(finite(pie.get("startAngleDegrees"), defaults["startAngleDegrees"]))
|
|
848
|
+
clockwise = pie.get("clockwise", defaults["clockwise"])
|
|
849
|
+
direction = 1 if clockwise else -1
|
|
850
|
+
gap = radians(nonnegative(pie.get("sectorGapDegrees"), defaults["sectorGapDegrees"]))
|
|
851
|
+
if len(normalized) > 1 and gap > 0:
|
|
852
|
+
gap = min(gap, min(item["value"] / total * 2 * pi * .95 for item in normalized))
|
|
853
|
+
else:
|
|
854
|
+
gap = 0
|
|
855
|
+
center_x, center_y = width / 2, height / 2
|
|
856
|
+
radius = min(center_x, center_y)
|
|
857
|
+
inner = radius * ratio(pie.get("innerRadiusRatio"), defaults["innerRadiusRatio"], 0, .95)
|
|
858
|
+
outer = max(inner, radius * ratio(pie.get("outerRadiusRatio"), defaults["outerRadiusRatio"], .05, 1))
|
|
859
|
+
sectors = []
|
|
860
|
+
cursor = start
|
|
861
|
+
for item in normalized:
|
|
862
|
+
fraction = item["value"] / total
|
|
863
|
+
raw_sweep = fraction * 2 * pi * direction
|
|
864
|
+
sector_start = (cursor + gap / 2 * direction) % (2 * pi)
|
|
865
|
+
sweep = raw_sweep - gap * direction
|
|
866
|
+
ordinal = sum(counts[:item["traceIndex"]]) + item["sourceIndex"]
|
|
867
|
+
trace_color = item["traceColor"]
|
|
868
|
+
color = trace_color if isinstance(trace_color, str) and trace_color.strip() and trace_color not in DEFAULT_TRACE_COLORS else colors[ordinal % len(colors)]
|
|
869
|
+
sector = {"centerX": center_x, "centerY": center_y, "innerRadius": inner,
|
|
870
|
+
"outerRadius": outer, "startAngle": sector_start, "endAngle": sector_start + sweep,
|
|
871
|
+
"sweep": sweep, "clockwise": clockwise, "angle": (sector_start + sweep / 2) % (2 * pi),
|
|
872
|
+
"value": item["value"], "fraction": fraction, "color": color, "label": item["label"],
|
|
873
|
+
"opacity": ratio(pie.get("sectorOpacity"), defaults["sectorOpacity"], 0, 1),
|
|
874
|
+
"sourceIndex": item["sourceIndex"], "traceIndex": item["traceIndex"]}
|
|
875
|
+
sectors.append(sector)
|
|
876
|
+
cursor += raw_sweep
|
|
877
|
+
border_width = nonnegative(pie.get("borderWidth"), defaults["borderWidth"])
|
|
878
|
+
label_items = []
|
|
879
|
+
if labels["show"]:
|
|
880
|
+
for index, sector in enumerate(sectors):
|
|
881
|
+
if abs(sector["sweep"] * 180 / pi) < labels["minSectorAngleDegrees"]:
|
|
882
|
+
continue
|
|
883
|
+
value = sector["value"]
|
|
884
|
+
fraction = sector["fraction"]
|
|
885
|
+
formatted_value = _pie_fixed(value, labels["decimals"])
|
|
886
|
+
formatted_fraction = _pie_fixed(fraction * 100, labels["decimals"]) + "%"
|
|
887
|
+
content = labels["content"]
|
|
888
|
+
text = {"label": sector["label"], "value": formatted_value,
|
|
889
|
+
"fraction": formatted_fraction, "label-value": f"{sector['label']} {formatted_value}",
|
|
890
|
+
"label-fraction": f"{sector['label']} {formatted_fraction}"}[content]
|
|
891
|
+
distance = inner + (outer - inner) * labels["radiusRatio"] if labels["position"] == "inside" else outer + labels["offset"]
|
|
892
|
+
angle = sector["startAngle"] + sector["sweep"] / 2
|
|
893
|
+
label_items.append({"x": center_x + cos(angle) * distance, "y": center_y + sin(angle) * distance,
|
|
894
|
+
"angle": angle % (2 * pi), "value": value, "fraction": fraction,
|
|
895
|
+
"text": f"{labels['prefix']}{text}{labels['suffix']}",
|
|
896
|
+
"color": labels["color"], "font": labels["font"],
|
|
897
|
+
"lineHeight": labels["lineHeight"], "position": labels["position"],
|
|
898
|
+
"sectorIndex": index, "collisionPadding": labels["collisionPadding"]})
|
|
899
|
+
primitive = {"type": "pie", "points": [], "color": sectors[0]["color"], "lineWidth": border_width,
|
|
900
|
+
"pieSectors": sectors, "borderColor": pie.get("borderColor", defaults["borderColor"]),
|
|
901
|
+
"borderWidth": border_width, "subplot": "main",
|
|
902
|
+
"bounds": {"left": 0, "top": 0, "right": width, "bottom": height}}
|
|
903
|
+
if label_items:
|
|
904
|
+
primitive["pieSectorLabels"] = label_items
|
|
905
|
+
return {"width": width, "height": height, "primitives": [primitive]}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _subplot_scene(options: Mapping, width: float, height: float, family: str) -> dict:
|
|
909
|
+
subplots = resolveSubplots(options)
|
|
910
|
+
bindings = resolveTraceSubplotBindings(options) if family != "pie" else []
|
|
911
|
+
primitives = []
|
|
912
|
+
for subplot in subplots:
|
|
913
|
+
bounds = subplot["bounds"]
|
|
914
|
+
local_width = bounds["right"] - bounds["left"]
|
|
915
|
+
local_height = bounds["bottom"] - bounds["top"]
|
|
916
|
+
local_options = {key: value for key, value in options.items() if key not in ("subplots", "data", "traces")}
|
|
917
|
+
source_indexes = []
|
|
918
|
+
if family != "pie":
|
|
919
|
+
selected = []
|
|
920
|
+
for index, binding in enumerate(bindings):
|
|
921
|
+
if binding["subplotId"] != subplot["id"]:
|
|
922
|
+
continue
|
|
923
|
+
trace = dict(binding["trace"])
|
|
924
|
+
if not trace.get("color") and not (family == "heatmap" and (options.get("heatmap") or {}).get("highColor")):
|
|
925
|
+
trace["color"] = DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)]
|
|
926
|
+
selected.append(trace)
|
|
927
|
+
source_indexes.append(index)
|
|
928
|
+
local_options["traces"] = selected
|
|
929
|
+
local_scene = ({"pie": _pie_scene, "heatmap": heatmap_scene, "polar": polar_scene}[family]
|
|
930
|
+
(local_options, local_width, local_height))
|
|
931
|
+
for primitive in local_scene["primitives"]:
|
|
932
|
+
primitive["subplot"] = subplot["id"]
|
|
933
|
+
primitive["bounds"] = ({"left": 0, "top": 0, "right": local_width, "bottom": local_height}
|
|
934
|
+
if family == "pie" else bounds)
|
|
935
|
+
if "traceIndex" in primitive:
|
|
936
|
+
primitive["traceIndex"] = source_indexes[primitive["traceIndex"]]
|
|
937
|
+
primitives.append(primitive)
|
|
938
|
+
return {"width": width, "height": height, "primitives": primitives}
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def plot(options: Mapping[str, Any]) -> dict:
|
|
942
|
+
"""Build a renderer-neutral scene from fastPlotJs-shaped plot options."""
|
|
943
|
+
options = _object(options, "options")
|
|
944
|
+
width, height = _dimension(options.get("width"), "width"), _dimension(options.get("height"), "height")
|
|
945
|
+
family = options.get("type")
|
|
946
|
+
if family not in FAMILIES:
|
|
947
|
+
raise ValueError(f"Unsupported chart type: {family}")
|
|
948
|
+
validateCartesianReferences(options)
|
|
949
|
+
if family not in {"line", "bar", "wonderplot"} and ("errorBars" in options or
|
|
950
|
+
any("errorBars" in trace for trace in (options.get("traces") or []))):
|
|
951
|
+
raise ValueError("errorBars are supported only for 'line', 'bar', and 'wonderplot' charts")
|
|
952
|
+
if family != "line" and ("pointLabels" in options or
|
|
953
|
+
any("pointLabels" in trace for trace in (options.get("traces") or []))):
|
|
954
|
+
raise ValueError("pointLabels are supported only for 'line' charts")
|
|
955
|
+
if family != "line" and ("areaFill" in options or
|
|
956
|
+
"area" in (((options.get("style") or {}).get("trace") or {})) or
|
|
957
|
+
any("areaFill" in trace for trace in (options.get("traces") or []))):
|
|
958
|
+
raise ValueError("areaFill is supported only for 'line' charts")
|
|
959
|
+
validateAxisTickOptions(options)
|
|
960
|
+
if family in {"line", "bar", "wonderplot"}:
|
|
961
|
+
validateRenderableAxisSides(options)
|
|
962
|
+
if options.get("engine") not in (None, "auto", "rust", "ts"):
|
|
963
|
+
raise ValueError("engine must be 'auto', 'rust', or 'ts'")
|
|
964
|
+
if options.get("viewport"):
|
|
965
|
+
from .interactions import resolveLinkedAxisViewportState, viewportStateToAxesOptions
|
|
966
|
+
linked = resolveLinkedAxisViewportState(options)
|
|
967
|
+
options = {**options, "viewport": linked,
|
|
968
|
+
"axes": viewportStateToAxesOptions(options.get("axes"), linked)}
|
|
969
|
+
if options.get("engine") == "rust":
|
|
970
|
+
if family in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
|
|
971
|
+
raise ValueError(f"{family} is experimental and currently supports engine 'auto' or 'ts'; Rust 3D scene capability is not available")
|
|
972
|
+
if family == "pie":
|
|
973
|
+
raise ValueError("Rust pie scene engine is not available in current runtime")
|
|
974
|
+
if family == "bar" and not ((options.get("subplots") or {}).get("items")):
|
|
975
|
+
return bar_scene_rust(options, bar_scene(options, width, height))
|
|
976
|
+
if family == "heatmap" and not ((options.get("subplots") or {}).get("items")):
|
|
977
|
+
return heatmap_scene_rust(options, heatmap_scene(options, width, height))
|
|
978
|
+
if family == "wonderplot" and not ((options.get("subplots") or {}).get("items")):
|
|
979
|
+
return wonderplot_scene_rust(options, wonderplot_scene(options, width, height))
|
|
980
|
+
if family == "polar" and not ((options.get("subplots") or {}).get("items")):
|
|
981
|
+
return polar_scene(options, width, height)
|
|
982
|
+
if family == "line" and not _needs_compatibility(options):
|
|
983
|
+
return _line_scene(options, width, height)
|
|
984
|
+
raise ValueError(f"Rust {family} scene engine is not available for this Python path")
|
|
985
|
+
if family in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
|
|
986
|
+
viewport = options.get("viewport") or {}
|
|
987
|
+
if viewport.get("xAxes") or viewport.get("yAxes"):
|
|
988
|
+
raise ValueError(f"experimental {family} does not support Cartesian viewport state")
|
|
989
|
+
if family in {"pie", "heatmap", "polar"} and ((options.get("subplots") or {}).get("items")):
|
|
990
|
+
return _subplot_scene(options, width, height, family)
|
|
991
|
+
if family == "line":
|
|
992
|
+
return (_line_subplot_scene(options, width, height)
|
|
993
|
+
if options.get("subplots") or any("xAxis" in trace or "yAxis" in trace
|
|
994
|
+
for trace in _traces(options))
|
|
995
|
+
else _line_scene(options, width, height))
|
|
996
|
+
if family == "pie":
|
|
997
|
+
return _pie_scene(options, width, height)
|
|
998
|
+
if family == "bar":
|
|
999
|
+
axes = options.get("axes") or {}
|
|
1000
|
+
scaled_or_categorical = any(axis.get("scale") not in (None, "linear") or axis.get("categories")
|
|
1001
|
+
for group in ("xAxes", "yAxes")
|
|
1002
|
+
for axis in (axes.get(group) or {}).values())
|
|
1003
|
+
return (_bar_subplot_scene(options, width, height)
|
|
1004
|
+
if options.get("subplots") or any("xAxis" in trace or "yAxis" in trace
|
|
1005
|
+
for trace in _traces(options)) or scaled_or_categorical
|
|
1006
|
+
else bar_scene(options, width, height))
|
|
1007
|
+
if family == "heatmap":
|
|
1008
|
+
return heatmap_scene(options, width, height)
|
|
1009
|
+
if family == "wonderplot":
|
|
1010
|
+
return (_wonder_subplot_scene(options, width, height)
|
|
1011
|
+
if options.get("subplots") else wonderplot_scene(options, width, height))
|
|
1012
|
+
if family == "polar":
|
|
1013
|
+
return polar_scene(options, width, height)
|
|
1014
|
+
if family in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
|
|
1015
|
+
from .scene3d_collection import buildScene3dCollection
|
|
1016
|
+
builder = {"scatter3d": scatter3d_scene, "line3d": line3d_scene,
|
|
1017
|
+
"surface": surface_scene, "mesh3d": mesh3d_scene,
|
|
1018
|
+
"pie3d": pie3d_scene}[family]
|
|
1019
|
+
return buildScene3dCollection(options, builder)
|
|
1020
|
+
raise ValueError(f"Unsupported chart type: {family}")
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
_RENDERER_FIELDS = frozenset({"title", "subtitle", "titleStyle", "subtitleStyle", "backgroundColor", "plotAreaBackgroundColor", "canvas", "margin", "legend"})
|
|
1024
|
+
_RENDERER_STYLE_FIELDS = frozenset({"backgroundColor", "text", "title", "subtitle", "plotArea", "axes", "legend", "tooltip"})
|
|
1025
|
+
_MARKER_STYLE_FIELDS = frozenset({"show", "size", "color", "shape"})
|
|
1026
|
+
_FAMILY_OPTION_KEYS = frozenset({"bar", "polar", "pie", "pie3d", "heatmap", "wonderplot", "scatter3d", "line3d", "surface", "mesh3d"})
|
|
1027
|
+
_TRACE_FAMILY_OPTION_KEYS = _FAMILY_OPTION_KEYS - {"pie3d"}
|
|
1028
|
+
_INERT_TRACE_FIELDS = frozenset({"connectGaps", "markers", "trendline", "polarVector", "xAxis", "yAxis"})
|
|
1029
|
+
_BASIC_FIELDS = frozenset({"type", "width", "height", "data", "traces", "engine", "lineStyle", "markers", "connectGaps", "axes", "areaFill", "style", "errorBars", "trendline", "viewport", "pointLabels", "subplots", "references", "scene3d"}) | _RENDERER_FIELDS | _FAMILY_OPTION_KEYS
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _foreign_family_fields(family: str) -> frozenset[str]:
|
|
1033
|
+
consumed = {family}
|
|
1034
|
+
if family == "pie3d":
|
|
1035
|
+
consumed.add("pie")
|
|
1036
|
+
return _FAMILY_OPTION_KEYS - consumed
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _foreign_trace_family_fields(family: str) -> frozenset[str]:
|
|
1040
|
+
return _TRACE_FAMILY_OPTION_KEYS - {family}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _native_3d_options_supported(options: Mapping) -> bool:
|
|
1044
|
+
family = options["type"]
|
|
1045
|
+
families = {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}
|
|
1046
|
+
if family not in families:
|
|
1047
|
+
return False
|
|
1048
|
+
style = options.get("style") or {}
|
|
1049
|
+
if (set(style) - (_RENDERER_STYLE_FIELDS | {"trace"})
|
|
1050
|
+
or set(style.get("trace") or {}) - {"line", "marker"}
|
|
1051
|
+
or set(((style.get("trace") or {}).get("line") or {})) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}
|
|
1052
|
+
or set(((style.get("trace") or {}).get("marker") or {})) - _MARKER_STYLE_FIELDS):
|
|
1053
|
+
return False
|
|
1054
|
+
common = {"type", "width", "height", "engine", "scene3d", "style", "axes", "viewport", "connectGaps", "markers", "trendline", "lineStyle"} | _RENDERER_FIELDS | _foreign_family_fields(family)
|
|
1055
|
+
if family == "pie3d":
|
|
1056
|
+
top = common | {"pie", "pie3d", "subplots", "data", "traces"}
|
|
1057
|
+
if set(options) - top:
|
|
1058
|
+
return False
|
|
1059
|
+
if set(options.get("pie") or {}) - {"data", "hiddenDataIndexes", "colors", "startAngleDegrees", "clockwise", "sectorGapDegrees", "sectorOpacity", "innerRadiusRatio", "outerRadiusRatio"}:
|
|
1060
|
+
return False
|
|
1061
|
+
if set(options.get("pie3d") or {}) - {"extrusionDepth", "curveTolerance", "maxCurveStepDegrees"}:
|
|
1062
|
+
return False
|
|
1063
|
+
else:
|
|
1064
|
+
top = common | {"data", "traces", family, "subplots"}
|
|
1065
|
+
if set(options) - top:
|
|
1066
|
+
return False
|
|
1067
|
+
family_fields = {
|
|
1068
|
+
"scatter3d": {"markerSize", "markerOpacity", "markerShape", "colorScale", "sizeScale", "opacityScale", "colorScaleLegend", "lod"},
|
|
1069
|
+
"line3d": {"lineWidth", "lineOpacity", "interpolation", "lod", "maxSegments"},
|
|
1070
|
+
"surface": {"lowColor", "highColor", "colorStops", "minValue", "maxValue", "opacity", "colorScaleLegend", "lod", "missingCells", "maxTriangles", "wireframe", "contours", "maxContourSegments"},
|
|
1071
|
+
"mesh3d": {"faces", "faceColors", "vertexColors", "opacity", "wireframe", "lod", "maxFaces", "backfaceCulling"},
|
|
1072
|
+
}[family]
|
|
1073
|
+
if set(options.get(family) or {}) - family_fields:
|
|
1074
|
+
return False
|
|
1075
|
+
for trace in options.get("traces") or []:
|
|
1076
|
+
if set(trace) - ({"name", "data", "visible", "color", "lineStyle", "subplot", family} | _foreign_trace_family_fields(family) | _INERT_TRACE_FIELDS):
|
|
1077
|
+
return False
|
|
1078
|
+
trace_fields = family_fields - {"lod", "maxSegments"} if family == "line3d" else family_fields
|
|
1079
|
+
if set(trace.get(family) or {}) - trace_fields:
|
|
1080
|
+
return False
|
|
1081
|
+
scene3d = options.get("scene3d") or {}
|
|
1082
|
+
scene3d_fields = {"showAxes", "showGrid", "gridSteps", "camera", "xAxis", "yAxis", "zAxis", "axisColor", "gridColor", "axisLineWidth", "gridLineWidth", "maxPoints", "accessibility", "subplots", "renderer"}
|
|
1083
|
+
scene3d_fields.add("lighting")
|
|
1084
|
+
if set(scene3d) - scene3d_fields:
|
|
1085
|
+
return False
|
|
1086
|
+
if set(scene3d.get("camera") or {}) - {"projection", "eye", "target", "up", "fieldOfViewDegrees", "orthographicScale", "near", "far"}:
|
|
1087
|
+
return False
|
|
1088
|
+
for axis in "xyz":
|
|
1089
|
+
if set(scene3d.get(f"{axis}Axis") or {}) - {"range", "padding", "show", "title", "tickCount", "showTickLabels", "tickDecimals"}:
|
|
1090
|
+
return False
|
|
1091
|
+
for scoped in (scene3d.get("subplots") or {}).values():
|
|
1092
|
+
if set(scoped) - (scene3d_fields - {"subplots"}):
|
|
1093
|
+
return False
|
|
1094
|
+
if set(scoped.get("camera") or {}) - {"projection", "eye", "target", "up", "fieldOfViewDegrees", "orthographicScale", "near", "far"}:
|
|
1095
|
+
return False
|
|
1096
|
+
for axis in "xyz":
|
|
1097
|
+
if set(scoped.get(f"{axis}Axis") or {}) - {"range", "padding", "show", "title", "tickCount", "showTickLabels", "tickDecimals"}:
|
|
1098
|
+
return False
|
|
1099
|
+
return True
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _native_bar_options_supported(options: Mapping) -> bool:
|
|
1103
|
+
if set(options) - ({"type", "width", "height", "engine", "data", "traces", "bar", "axes", "errorBars", "viewport", "references", "subplots", "style", "lineStyle", "scene3d", "connectGaps", "markers", "trendline"} | _RENDERER_FIELDS | _foreign_family_fields("bar")):
|
|
1104
|
+
return False
|
|
1105
|
+
if set(options.get("bar") or {}) - {"mode", "orientation", "barWidthRatio", "borderWidth", "borderColor", "cornerRadius", "valueLabels"}:
|
|
1106
|
+
return False
|
|
1107
|
+
if set(options.get("lineStyle") or {}) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}:
|
|
1108
|
+
return False
|
|
1109
|
+
style = options.get("style") or {}
|
|
1110
|
+
if (set(style) - (_RENDERER_STYLE_FIELDS | {"trace"})
|
|
1111
|
+
or set(style.get("trace") or {}) - {"line", "marker"}
|
|
1112
|
+
or set(((style.get("trace") or {}).get("line") or {})) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}
|
|
1113
|
+
or set(((style.get("trace") or {}).get("marker") or {})) - _MARKER_STYLE_FIELDS):
|
|
1114
|
+
return False
|
|
1115
|
+
for trace in options.get("traces") or []:
|
|
1116
|
+
if set(trace) - ({"name", "data", "visible", "color", "bar", "errorBars", "subplot", "xAxis", "yAxis", "lineStyle"} | _foreign_trace_family_fields("bar") | _INERT_TRACE_FIELDS):
|
|
1117
|
+
return False
|
|
1118
|
+
if set(trace.get("lineStyle") or {}) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}:
|
|
1119
|
+
return False
|
|
1120
|
+
if set(trace.get("bar") or {}) - {"borderWidth", "borderColor", "cornerRadius", "valueLabels"}:
|
|
1121
|
+
return False
|
|
1122
|
+
axes = options.get("axes") or {}
|
|
1123
|
+
if set(axes) - {"xAxes", "yAxes", "show", "labelStyle", "lineStyle", "gridStyle"}:
|
|
1124
|
+
return False
|
|
1125
|
+
for dimension, name in (("xAxes", "x"), ("yAxes", "y")):
|
|
1126
|
+
for axis in (axes.get(dimension) or {}).values():
|
|
1127
|
+
axis = axis or {}
|
|
1128
|
+
if set(axis) - {"range", "categories", "scale", "show", "side", "viewportGroup", "labelFormatter", "tickValues", "ticks", "tickLength", "tickDirection", "labelPrefix", "labelSuffix", "labelStyle", "lineStyle", "gridStyle", "title", "titleStyle", "grid", "minor", "minorLogLabels", "decimals", "dateTime", "labelRotationDegrees", "labelCollision", "padding"}:
|
|
1129
|
+
return False
|
|
1130
|
+
return True
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _needs_compatibility(options: Mapping) -> bool:
|
|
1134
|
+
family = options.get("type")
|
|
1135
|
+
if family in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
|
|
1136
|
+
return not _native_3d_options_supported(options)
|
|
1137
|
+
if family == "bar":
|
|
1138
|
+
return not _native_bar_options_supported(options)
|
|
1139
|
+
if family == "polar":
|
|
1140
|
+
if set(options) - _BASIC_FIELDS:
|
|
1141
|
+
return True
|
|
1142
|
+
polar_style = options.get("style") or {}
|
|
1143
|
+
if (set(polar_style) - (_RENDERER_STYLE_FIELDS | {"trace"})
|
|
1144
|
+
or set(polar_style.get("trace") or {}) - {"line", "marker"}
|
|
1145
|
+
or set(((polar_style.get("trace") or {}).get("line") or {})) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}
|
|
1146
|
+
or set(((polar_style.get("trace") or {}).get("marker") or {})) - _MARKER_STYLE_FIELDS):
|
|
1147
|
+
return True
|
|
1148
|
+
return any(set(trace) - ({"name", "data", "visible", "color", "lineStyle", "polar", "polarVector", "subplot"} | _foreign_trace_family_fields("polar") | _INERT_TRACE_FIELDS) for trace in options.get("traces") or [])
|
|
1149
|
+
if family == "pie":
|
|
1150
|
+
pie = options.get("pie") or {}
|
|
1151
|
+
if set(options) - _BASIC_FIELDS:
|
|
1152
|
+
return True
|
|
1153
|
+
style = options.get("style") or {}
|
|
1154
|
+
if (set(style) - (_RENDERER_STYLE_FIELDS | {"trace"})
|
|
1155
|
+
or set(style.get("trace") or {}) - {"line", "marker"}
|
|
1156
|
+
or set(((style.get("trace") or {}).get("line") or {})) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}
|
|
1157
|
+
or set(((style.get("trace") or {}).get("marker") or {})) - _MARKER_STYLE_FIELDS):
|
|
1158
|
+
return True
|
|
1159
|
+
if set(pie) - {"data", "traces", "hiddenDataIndexes", "colors", "startAngleDegrees", "clockwise", "sectorGapDegrees", "sectorOpacity", "borderColor", "borderWidth", "innerRadiusRatio", "outerRadiusRatio", "labels"}:
|
|
1160
|
+
return True
|
|
1161
|
+
return any(set(trace) - ({"name", "data", "visible", "color", "pie", "lineStyle", "subplot"} | _foreign_trace_family_fields("pie") | _INERT_TRACE_FIELDS) for trace in options.get("traces") or [])
|
|
1162
|
+
if set(options) - _BASIC_FIELDS:
|
|
1163
|
+
return True
|
|
1164
|
+
if options.get("traces") and any(set(trace) - ({"name", "data", "visible", "color", "lineStyle", "markers", "connectGaps", "areaFill", "errorBars", "trendline", "pointLabels", "heatmap", "subplot", "xAxis", "yAxis"} | _foreign_trace_family_fields(family) | _INERT_TRACE_FIELDS) for trace in options["traces"]):
|
|
1165
|
+
return True
|
|
1166
|
+
if family == "line":
|
|
1167
|
+
if options.get("style") and (set(options["style"]) - (_RENDERER_STYLE_FIELDS | {"trace"}) or set((options["style"].get("trace") or {})) - {"area", "line", "marker"}):
|
|
1168
|
+
return True
|
|
1169
|
+
if options.get("axes") and any(set(options["axes"]) - {"xAxes", "yAxes", "show", "labelStyle", "lineStyle", "gridStyle"} for _ in [0]):
|
|
1170
|
+
return True
|
|
1171
|
+
return False
|
|
1172
|
+
if family in {"heatmap", "wonderplot"}:
|
|
1173
|
+
style = options.get("style") or {}
|
|
1174
|
+
if (set(style) - (_RENDERER_STYLE_FIELDS | {"trace"})
|
|
1175
|
+
or set(style.get("trace") or {}) - {"line", "marker"}
|
|
1176
|
+
or set(((style.get("trace") or {}).get("line") or {})) - {"color", "lineWidth", "opacity", "interpolation", "pattern"}
|
|
1177
|
+
or set(((style.get("trace") or {}).get("marker") or {})) - _MARKER_STYLE_FIELDS):
|
|
1178
|
+
return True
|
|
1179
|
+
if family == "heatmap":
|
|
1180
|
+
return False
|
|
1181
|
+
if family == "wonderplot":
|
|
1182
|
+
if not options.get("subplots"):
|
|
1183
|
+
return False
|
|
1184
|
+
return False
|
|
1185
|
+
return True
|