fastplotpy 0.3.0a1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. fastplotpy/README.md +70 -0
  2. fastplotpy/__init__.py +33 -0
  3. fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
  4. fastplotpy/assets/dist/index.js +15071 -0
  5. fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
  6. fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
  7. fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
  8. fastplotpy/assets/package.json +1 -0
  9. fastplotpy/axes_validation.py +102 -0
  10. fastplotpy/camera3d.py +104 -0
  11. fastplotpy/color_scale.py +88 -0
  12. fastplotpy/defaults/area-fill.json +5 -0
  13. fastplotpy/defaults/bar.json +22 -0
  14. fastplotpy/defaults/camera3d.json +22 -0
  15. fastplotpy/defaults/cartesian-references.json +10 -0
  16. fastplotpy/defaults/control.json +3 -0
  17. fastplotpy/defaults/crosshair.json +19 -0
  18. fastplotpy/defaults/error-bars.json +7 -0
  19. fastplotpy/defaults/heatmap.json +70 -0
  20. fastplotpy/defaults/interaction.json +3 -0
  21. fastplotpy/defaults/layout.json +32 -0
  22. fastplotpy/defaults/legend.json +24 -0
  23. fastplotpy/defaults/line-axes.json +28 -0
  24. fastplotpy/defaults/line3d.json +13 -0
  25. fastplotpy/defaults/markers.json +6 -0
  26. fastplotpy/defaults/mesh3d.json +13 -0
  27. fastplotpy/defaults/pan.json +7 -0
  28. fastplotpy/defaults/pie.json +27 -0
  29. fastplotpy/defaults/pie3d.json +7 -0
  30. fastplotpy/defaults/point-labels.json +13 -0
  31. fastplotpy/defaults/pointClick.json +6 -0
  32. fastplotpy/defaults/pointSelection.json +13 -0
  33. fastplotpy/defaults/polar.json +71 -0
  34. fastplotpy/defaults/polarRotate.json +5 -0
  35. fastplotpy/defaults/scatter3d.json +39 -0
  36. fastplotpy/defaults/scene3d.json +61 -0
  37. fastplotpy/defaults/surface.json +35 -0
  38. fastplotpy/defaults/tooltip.json +18 -0
  39. fastplotpy/defaults/trace-colors.json +10 -0
  40. fastplotpy/defaults/trace-options.json +24 -0
  41. fastplotpy/defaults/wonderplot.json +58 -0
  42. fastplotpy/defaults/zoom.json +12 -0
  43. fastplotpy/defaults.py +15 -0
  44. fastplotpy/error_bars.py +118 -0
  45. fastplotpy/exports.py +153 -0
  46. fastplotpy/families2d.py +896 -0
  47. fastplotpy/heatmap_labels.py +146 -0
  48. fastplotpy/interactions.py +243 -0
  49. fastplotpy/interactive.py +1922 -0
  50. fastplotpy/js_serialize.py +47 -0
  51. fastplotpy/lighting3d.py +113 -0
  52. fastplotpy/line3d_lod.py +138 -0
  53. fastplotpy/lod3d.py +105 -0
  54. fastplotpy/mesh3d_lod.py +107 -0
  55. fastplotpy/native.py +163 -0
  56. fastplotpy/overlays.py +561 -0
  57. fastplotpy/point_labels.py +86 -0
  58. fastplotpy/polar.py +445 -0
  59. fastplotpy/polygon_clip3d.py +148 -0
  60. fastplotpy/references.py +72 -0
  61. fastplotpy/rendering.py +65 -0
  62. fastplotpy/rust_scenes.py +314 -0
  63. fastplotpy/scene.py +1185 -0
  64. fastplotpy/scene3d.py +1215 -0
  65. fastplotpy/scene3d_accessibility.py +183 -0
  66. fastplotpy/scene3d_collection.py +158 -0
  67. fastplotpy/subplots.py +115 -0
  68. fastplotpy/surface_contours.py +122 -0
  69. fastplotpy/surface_lod.py +109 -0
  70. fastplotpy/svg3d.py +235 -0
  71. fastplotpy/trendline.py +236 -0
  72. fastplotpy/triangle_wireframe.py +51 -0
  73. fastplotpy/types.py +3027 -0
  74. fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
  75. fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
  76. fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
  77. fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
  78. fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
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