fastplotpy 0.3.0a1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. fastplotpy/README.md +70 -0
  2. fastplotpy/__init__.py +33 -0
  3. fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
  4. fastplotpy/assets/dist/index.js +15071 -0
  5. fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
  6. fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
  7. fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
  8. fastplotpy/assets/package.json +1 -0
  9. fastplotpy/axes_validation.py +102 -0
  10. fastplotpy/camera3d.py +104 -0
  11. fastplotpy/color_scale.py +88 -0
  12. fastplotpy/defaults/area-fill.json +5 -0
  13. fastplotpy/defaults/bar.json +22 -0
  14. fastplotpy/defaults/camera3d.json +22 -0
  15. fastplotpy/defaults/cartesian-references.json +10 -0
  16. fastplotpy/defaults/control.json +3 -0
  17. fastplotpy/defaults/crosshair.json +19 -0
  18. fastplotpy/defaults/error-bars.json +7 -0
  19. fastplotpy/defaults/heatmap.json +70 -0
  20. fastplotpy/defaults/interaction.json +3 -0
  21. fastplotpy/defaults/layout.json +32 -0
  22. fastplotpy/defaults/legend.json +24 -0
  23. fastplotpy/defaults/line-axes.json +28 -0
  24. fastplotpy/defaults/line3d.json +13 -0
  25. fastplotpy/defaults/markers.json +6 -0
  26. fastplotpy/defaults/mesh3d.json +13 -0
  27. fastplotpy/defaults/pan.json +7 -0
  28. fastplotpy/defaults/pie.json +27 -0
  29. fastplotpy/defaults/pie3d.json +7 -0
  30. fastplotpy/defaults/point-labels.json +13 -0
  31. fastplotpy/defaults/pointClick.json +6 -0
  32. fastplotpy/defaults/pointSelection.json +13 -0
  33. fastplotpy/defaults/polar.json +71 -0
  34. fastplotpy/defaults/polarRotate.json +5 -0
  35. fastplotpy/defaults/scatter3d.json +39 -0
  36. fastplotpy/defaults/scene3d.json +61 -0
  37. fastplotpy/defaults/surface.json +35 -0
  38. fastplotpy/defaults/tooltip.json +18 -0
  39. fastplotpy/defaults/trace-colors.json +10 -0
  40. fastplotpy/defaults/trace-options.json +24 -0
  41. fastplotpy/defaults/wonderplot.json +58 -0
  42. fastplotpy/defaults/zoom.json +12 -0
  43. fastplotpy/defaults.py +15 -0
  44. fastplotpy/error_bars.py +118 -0
  45. fastplotpy/exports.py +153 -0
  46. fastplotpy/families2d.py +896 -0
  47. fastplotpy/heatmap_labels.py +146 -0
  48. fastplotpy/interactions.py +243 -0
  49. fastplotpy/interactive.py +1922 -0
  50. fastplotpy/js_serialize.py +47 -0
  51. fastplotpy/lighting3d.py +113 -0
  52. fastplotpy/line3d_lod.py +138 -0
  53. fastplotpy/lod3d.py +105 -0
  54. fastplotpy/mesh3d_lod.py +107 -0
  55. fastplotpy/native.py +163 -0
  56. fastplotpy/overlays.py +561 -0
  57. fastplotpy/point_labels.py +86 -0
  58. fastplotpy/polar.py +445 -0
  59. fastplotpy/polygon_clip3d.py +148 -0
  60. fastplotpy/references.py +72 -0
  61. fastplotpy/rendering.py +65 -0
  62. fastplotpy/rust_scenes.py +314 -0
  63. fastplotpy/scene.py +1185 -0
  64. fastplotpy/scene3d.py +1215 -0
  65. fastplotpy/scene3d_accessibility.py +183 -0
  66. fastplotpy/scene3d_collection.py +158 -0
  67. fastplotpy/subplots.py +115 -0
  68. fastplotpy/surface_contours.py +122 -0
  69. fastplotpy/surface_lod.py +109 -0
  70. fastplotpy/svg3d.py +235 -0
  71. fastplotpy/trendline.py +236 -0
  72. fastplotpy/triangle_wireframe.py +51 -0
  73. fastplotpy/types.py +3027 -0
  74. fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
  75. fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
  76. fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
  77. fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
  78. fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,896 @@
1
+ """Python geometry for cartesian bars, heatmaps, marker plots and polar sectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from math import cos, floor, isfinite, log10, pi, radians, sin
6
+ from decimal import Decimal, ROUND_HALF_UP
7
+ from typing import Any, Mapping
8
+
9
+ from .defaults import DEFAULT_TRACE_COLORS, get_defaults
10
+ from .error_bars import endpoints, error_domain_values, error_geometry, resolve_error_bars
11
+ from .color_scale import interpolate, legend_ticks, resolve_stops, scale_ratio
12
+
13
+
14
+ def resolve_heatmap_wonderplot_color(options, trace, trace_index, family):
15
+ """Resolve pinned source heatmap/wonderplot color-scale high-color precedence."""
16
+ scale = (options.get("heatmap") or {}) if family == "heatmap" else ((options.get("wonderplot") or {}).get("colorScale") or {})
17
+ values = (DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)],
18
+ (((options.get("style") or {}).get("trace") or {}).get("line") or {}).get("color"),
19
+ (options.get("lineStyle") or {}).get("color"), scale.get("highColor"),
20
+ trace.get("color"), (trace.get("lineStyle") or {}).get("color"))
21
+ return next((value for value in reversed(values) if isinstance(value, str) and value.strip()),
22
+ DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)])
23
+
24
+
25
+ def resolve_bar_line_style(options, trace, default_color):
26
+ """Resolve source line-style color/width cascade used by bar primitives."""
27
+ style = (((options.get("style") or {}).get("trace") or {}).get("line") or {})
28
+ plot_line = options.get("lineStyle") or {}
29
+ trace_line = trace.get("lineStyle") or {}
30
+ def text(*values):
31
+ return next((value for value in reversed(values)
32
+ if isinstance(value, str) and value.strip()), default_color)
33
+ def width(*values):
34
+ selected = next((value for value in reversed(values)
35
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)),
36
+ get_defaults("trace-options")["lineWidth"])
37
+ return max(0, selected)
38
+ return (text(style.get("color"), plot_line.get("color"), trace.get("color"), trace_line.get("color")),
39
+ width(style.get("lineWidth"), plot_line.get("lineWidth"), trace_line.get("lineWidth")))
40
+ from .heatmap_labels import build_cell_labels, resolve_labels
41
+
42
+
43
+ def _traces(options: Mapping) -> list[dict]:
44
+ if options.get("traces"):
45
+ return list(options["traces"])
46
+ return [{"data": options["data"]}] if options.get("data") is not None else []
47
+
48
+
49
+ def _bounds(width: float, height: float) -> dict:
50
+ return {"left": 0, "top": 0, "right": width, "bottom": height}
51
+
52
+
53
+ def _rgb(value: float, low: float, high: float, low_color: str, high_color: str) -> str:
54
+ def parse(color):
55
+ if len(color) == 4 and color.startswith("#"):
56
+ return [int(color[index] * 2, 16) for index in (1, 2, 3)]
57
+ if len(color) == 7 and color.startswith("#"):
58
+ return [int(color[index:index + 2], 16) for index in (1, 3, 5)]
59
+ return None
60
+ colors = parse(low_color), parse(high_color)
61
+ if None in colors:
62
+ return high_color if value >= high else low_color
63
+ ratio = max(0, min(1, (value - low) / (high - low))) if high > low else 1
64
+ channels = [floor(a + (b - a) * ratio + .5) for a, b in zip(*colors)]
65
+ return f"rgb({channels[0]}, {channels[1]}, {channels[2]})"
66
+
67
+
68
+ def _bar_labels(defaults: Mapping, plot_value: Any, trace_value: Any) -> dict:
69
+ resolved = dict(defaults["valueLabels"])
70
+ for source in (plot_value, trace_value):
71
+ if source is None:
72
+ continue
73
+ if isinstance(source, bool):
74
+ resolved["show"] = source
75
+ elif isinstance(source, Mapping):
76
+ resolved.update({key: value for key, value in source.items() if value is not None})
77
+ resolved["show"] = source.get("show", True)
78
+ else:
79
+ raise TypeError("bar.valueLabels must be a boolean or an object")
80
+ if resolved["position"] not in ("center", "inside-end", "outside-end"):
81
+ raise ValueError("bar.valueLabels.position must be 'center', 'inside-end', or 'outside-end'")
82
+ return resolved
83
+
84
+
85
+ def _bar_value_labels(rects: list[dict], labels: Mapping, orientation: str, mode: str) -> list[dict]:
86
+ if not labels["show"]:
87
+ return []
88
+ output = []
89
+ for rect in rects:
90
+ if rect["width"] < labels["minBarWidth"] or rect["height"] < labels["minBarHeight"]:
91
+ continue
92
+ position = labels["position"]
93
+ positive = rect["value"] >= rect["baseline"] if mode == "grouped" else rect.get("normalizedValue", rect["value"]) >= 0
94
+ inside = position == "inside-end"
95
+ if position == "center":
96
+ x, y = rect["x"] + rect["width"] / 2, rect["y"] + rect["height"] / 2
97
+ align, baseline = "center", "middle"
98
+ elif orientation == "horizontal":
99
+ offset = min(labels["offset"], rect["width"] / 2) if inside else labels["offset"]
100
+ x = rect["x"] + rect["width"] + (-offset if inside else offset) if positive else rect["x"] + (offset if inside else -offset)
101
+ y = rect["y"] + rect["height"] / 2
102
+ align, baseline = ("right" if positive == inside else "left"), "middle"
103
+ else:
104
+ offset = min(labels["offset"], rect["height"] / 2) if inside else labels["offset"]
105
+ x = rect["x"] + rect["width"] / 2
106
+ y = rect["y"] + (offset if inside else -offset) if positive else rect["y"] + rect["height"] + (-offset if inside else offset)
107
+ align, baseline = "center", ("top" if positive == inside else "bottom")
108
+ value = rect["value"]
109
+ text = str(floor(value + .5)) if labels["decimals"] <= 0 else f"{value:.{labels['decimals']}f}"
110
+ output.append({"x": x, "y": y, "position": position, "textAlign": align,
111
+ "textBaseline": baseline, "value": value,
112
+ "text": f"{labels['prefix']}{text}{labels['suffix']}",
113
+ "color": labels["color"], "font": labels["font"],
114
+ "sourceIndex": rect["sourceIndex"]})
115
+ return output
116
+
117
+
118
+ def bar_scene(options: Mapping, width: float, height: float,
119
+ forced_slots=None, forced_value_domain=None, forced_slot_domain=None,
120
+ forced_slot_scale="linear", forced_slot_categorical=False,
121
+ forced_value_domain_scaled=False) -> dict:
122
+ defaults = get_defaults("bar")
123
+ bar = options.get("bar") or {}
124
+ mode = bar.get("mode", defaults["mode"])
125
+ if mode not in ("grouped", "stacked", "normalized"):
126
+ raise ValueError("bar.mode must be 'grouped', 'stacked', or 'normalized'")
127
+ orientation = bar.get("orientation", defaults["orientation"])
128
+ if orientation not in ("vertical", "horizontal"):
129
+ raise ValueError("bar.orientation must be 'vertical' or 'horizontal'")
130
+ traces = _traces(options)
131
+ visible = [(index, trace) for index, trace in enumerate(traces) if trace.get("visible") is not False]
132
+ slots = (list(forced_slots) if forced_slots is not None else
133
+ sorted({point["x" if orientation == "vertical" else "y"]
134
+ for _, trace in visible for point in trace.get("data", [])
135
+ if point.get("x") is not None and point.get("y") is not None}))
136
+ if not slots:
137
+ return {"width": width, "height": height, "primitives": []}
138
+ slot_axis = width if orientation == "vertical" else height
139
+ value_axis = height if orientation == "vertical" else width
140
+ if forced_slot_domain is None:
141
+ slot_width = slot_axis / len(slots)
142
+ else:
143
+ def transformed_slot(value):
144
+ return log10(value) if forced_slot_scale == "logarithmic" else value
145
+
146
+ span = max(forced_slot_domain[1] - forced_slot_domain[0], 2.220446049250313e-16)
147
+ transformed_slots = sorted(set(transformed_slot(value) for value in slots))
148
+ deltas = [right - left for left, right in zip(transformed_slots, transformed_slots[1:])
149
+ if right > left]
150
+ slot_width = ((1 / span * slot_axis) if forced_slot_categorical else
151
+ (min(deltas) / span * slot_axis) if deltas else slot_axis)
152
+ ratio = bar.get("barWidthRatio", defaults["barWidthRatio"])
153
+ group_width = slot_width * ratio
154
+ values = [float(point["y" if orientation == "vertical" else "x"]) for _, trace in visible for point in trace.get("data", []) if point.get("x") is not None and point.get("y") is not None]
155
+ if mode == "grouped":
156
+ values.extend(float(point.get("base") or 0) for _, trace in visible for point in trace.get("data", []) if point.get("x") is not None and point.get("y") is not None)
157
+ slot_dimension = "x" if orientation == "vertical" else "y"
158
+ value_dimension = "y" if orientation == "vertical" else "x"
159
+ x_axis = ((options.get("axes") or {}).get("xAxes") or {}).get("x") or {}
160
+ y_axis = ((options.get("axes") or {}).get("yAxes") or {}).get("y") or {}
161
+ for _, trace in visible:
162
+ resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), "")
163
+ if not resolved["show"]:
164
+ continue
165
+ for source_index, point in enumerate(trace.get("data", [])):
166
+ if point.get("x") is None or point.get("y") is None:
167
+ continue
168
+ if resolved[slot_dimension] and point.get(slot_dimension + "Error") is not None:
169
+ raise ValueError(f"{slot_dimension}Error is not supported on the slot axis of {orientation} bar charts")
170
+ if resolved[value_dimension] and point.get(value_dimension + "Error") is not None:
171
+ if mode != "grouped":
172
+ raise ValueError("errorBars are supported only for grouped bar charts")
173
+ values.extend(endpoints(point, value_dimension, y_axis if orientation == "vertical" else x_axis, source_index))
174
+ totals = {slot: {"positive": 0.0, "negative": 0.0} for slot in slots}
175
+ for _, trace in visible:
176
+ for point in trace.get("data", []):
177
+ if point.get("x") is None or point.get("y") is None:
178
+ continue
179
+ slot = point["x" if orientation == "vertical" else "y"]
180
+ value = float(point["y" if orientation == "vertical" else "x"])
181
+ totals[slot]["positive" if value >= 0 else "negative"] += value
182
+ if mode == "grouped":
183
+ low, high = min(values), max(values)
184
+ elif mode == "normalized":
185
+ low = -100.0 if any(total["negative"] < 0 for total in totals.values()) else 0.0
186
+ high = 100.0 if any(total["positive"] > 0 for total in totals.values()) else 0.0
187
+ else:
188
+ low = min(0.0, *(total["negative"] for total in totals.values()))
189
+ high = max(0.0, *(total["positive"] for total in totals.values()))
190
+ value_axis_option = (options.get("axes") or {}).get("yAxes" if orientation == "vertical" else "xAxes") or {}
191
+ value_axis_settings = value_axis_option.get("y" if orientation == "vertical" else "x") or {}
192
+ explicit_range = value_axis_settings.get("range")
193
+ if explicit_range:
194
+ low, high = float(explicit_range["min"]), float(explicit_range["max"])
195
+ if forced_value_domain is not None:
196
+ low, high = forced_value_domain
197
+ if high == low:
198
+ high = low + 1
199
+ scale = value_axis_settings.get("scale", "linear")
200
+ if mode != "grouped" and scale != "linear":
201
+ raise ValueError(f"Bar mode '{mode}' requires a linear value axis")
202
+ if scale not in ("linear", "datetime", "logarithmic"):
203
+ raise ValueError(f"Unknown axis scale: {scale}")
204
+ def scaled(value):
205
+ if scale == "logarithmic":
206
+ if value <= 0:
207
+ raise ValueError("Logarithmic scale requires values > 0")
208
+ return log10(value)
209
+ return value
210
+ if not forced_value_domain_scaled:
211
+ low, high = scaled(low), scaled(high)
212
+ running = {slot: {"positive": 0.0, "negative": 0.0} for slot in slots}
213
+ primitives = []
214
+ for index, trace in visible:
215
+ color, line_width = resolve_bar_line_style(options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])
216
+ trace_bar = trace.get("bar") or {}
217
+ border_width = trace_bar.get("borderWidth", bar.get("borderWidth", defaults["borderWidth"]))
218
+ border_color = trace_bar.get("borderColor", bar.get("borderColor", defaults["borderColor"]))
219
+ corner_radius = trace_bar.get("cornerRadius", bar.get("cornerRadius", defaults["cornerRadius"]))
220
+ labels = _bar_labels(defaults, bar.get("valueLabels"), trace_bar.get("valueLabels"))
221
+ points, rects, anchors = [], [], {}
222
+ for source_index, point in enumerate(trace.get("data", [])):
223
+ if point.get("x") is None or point.get("y") is None:
224
+ continue
225
+ slot_value = point["x" if orientation == "vertical" else "y"]
226
+ value = float(point["y" if orientation == "vertical" else "x"])
227
+ if mode != "grouped" and point.get("base") is not None:
228
+ raise ValueError("Bar point base is only supported in grouped mode")
229
+ if scale != "linear" and point.get("base") is None:
230
+ raise ValueError(f"Grouped bars on a {scale} value axis require an explicit point base")
231
+ baseline = float(point.get("base") or 0)
232
+ normalized_value = None
233
+ rendered = value
234
+ if mode == "normalized":
235
+ total = totals[slot_value]["positive" if value >= 0 else "negative"]
236
+ normalized_value = value / abs(total) * 100 if total else 0.0
237
+ rendered = normalized_value
238
+ if mode != "grouped":
239
+ side = "positive" if rendered >= 0 else "negative"
240
+ baseline = running[slot_value][side]
241
+ running[slot_value][side] += rendered
242
+ end = running[slot_value][side]
243
+ else:
244
+ end = value
245
+ slot_center = ((slots.index(slot_value) + .5) * slot_width
246
+ if forced_slot_domain is None else
247
+ (transformed_slot(slot_value) - forced_slot_domain[0]) /
248
+ max(forced_slot_domain[1] - forced_slot_domain[0],
249
+ 2.220446049250313e-16) * slot_axis)
250
+ if orientation == "horizontal":
251
+ slot_center = height - slot_center
252
+ group_count = len(visible) if mode == "grouped" else 1
253
+ rect_width = max(defaults["minBarWidth"], group_width / group_count)
254
+ group_index = [number for number, _ in visible].index(index) if mode == "grouped" else 0
255
+ slot_start = slot_center - group_width / 2 + group_index * rect_width
256
+ if orientation == "vertical":
257
+ end_pixel = value_axis - ((scaled(end) - low) / (high - low)) * value_axis
258
+ baseline_pixel = value_axis - ((scaled(baseline) - low) / (high - low)) * value_axis
259
+ rect = {"x": slot_start, "y": min(end_pixel, baseline_pixel), "width": rect_width,
260
+ "height": abs(end_pixel - baseline_pixel), "value": value, "baseline": baseline, "sourceIndex": source_index}
261
+ else:
262
+ end_pixel = (scaled(end) - low) / (high - low) * value_axis
263
+ baseline_pixel = (scaled(baseline) - low) / (high - low) * value_axis
264
+ rect = {"x": min(end_pixel, baseline_pixel), "y": slot_start, "width": abs(end_pixel - baseline_pixel),
265
+ "height": rect_width, "value": value, "baseline": baseline, "sourceIndex": source_index}
266
+ if normalized_value is not None:
267
+ rect["normalizedValue"] = normalized_value
268
+ left, right = max(0, rect["x"]), min(width, rect["x"] + rect["width"])
269
+ top, bottom = max(0, rect["y"]), min(height, rect["y"] + rect["height"])
270
+ if right <= left or bottom <= top:
271
+ continue
272
+ rect.update({"x": left, "y": top, "width": right - left, "height": bottom - top})
273
+ radius = min(corner_radius, rect["width"] / 2, rect["height"] / 2)
274
+ if radius > 0:
275
+ rect["cornerRadius"] = radius
276
+ if orientation == "vertical":
277
+ points.append({"x": rect["x"] + rect["width"] / 2,
278
+ "y": rect["y"] if end >= baseline else rect["y"] + rect["height"]})
279
+ else:
280
+ points.append({"x": rect["x"] + rect["width"] if end >= baseline else rect["x"],
281
+ "y": rect["y"] + rect["height"] / 2})
282
+ if forced_slot_domain is not None:
283
+ anchors[source_index] = ({"x": slot_center, "y": points[-1]["y"]}
284
+ if orientation == "vertical" else
285
+ {"x": points[-1]["x"], "y": slot_center})
286
+ else:
287
+ anchors[source_index] = points[-1]
288
+ rects.append(rect)
289
+ if not rects:
290
+ continue
291
+ primitive = {"type": "bar", "points": points, "bars": rects, "color": color,
292
+ "lineWidth": line_width,
293
+ "borderColor": border_color, "borderWidth": border_width,
294
+ "subplot": "main", "bounds": _bounds(width, height), "traceIndex": index}
295
+ label_items = _bar_value_labels(rects, labels, orientation, mode)
296
+ if label_items:
297
+ primitive["barValueLabels"] = label_items
298
+ resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), color)
299
+ value_only = {**resolved, "x": orientation == "horizontal" and resolved["x"],
300
+ "y": orientation == "vertical" and resolved["y"]}
301
+ bars = error_geometry(trace, value_only, x_axis, y_axis,
302
+ (low, high) if orientation == "horizontal" else (0, width),
303
+ (low, high) if orientation == "vertical" else (0, height),
304
+ width, height, anchors)
305
+ if bars:
306
+ primitive["errorBars"] = bars
307
+ primitive["errorBarStyle"] = resolved["style"]
308
+ primitives.append(primitive)
309
+ return {"width": width, "height": height, "primitives": primitives}
310
+
311
+
312
+ def heatmap_scene(options: Mapping, width: float, height: float) -> dict:
313
+ defaults = get_defaults("heatmap")
314
+ heatmap = options.get("heatmap") or {}
315
+ traces = _traces(options)
316
+ scale = heatmap.get("valueScale", defaults["valueScale"])
317
+ if scale not in ("linear", "logarithmic"):
318
+ raise ValueError("heatmap.valueScale must be 'linear' or 'logarithmic'")
319
+
320
+ def categories(axis):
321
+ name = f"{axis}Categories"
322
+ supplied = name in heatmap
323
+ values = heatmap.get(name) if supplied else defaults[name]
324
+ if supplied and not values:
325
+ raise ValueError(f"heatmap.{name} must contain at least one category when provided")
326
+ if not values:
327
+ return None
328
+ if any(not isinstance(value, str) or not value.strip() for value in values):
329
+ raise ValueError(f"heatmap.{name} must contain only non-empty strings")
330
+ if len(set(values)) != len(values):
331
+ raise ValueError(f"heatmap.{name} must not contain duplicate categories")
332
+ return list(values)
333
+
334
+ x_categories, y_categories = categories("x"), categories("y")
335
+
336
+ def coordinate(value, axis, category_values):
337
+ if category_values is None:
338
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
339
+ raise ValueError(f"{axis} must be a finite number")
340
+ return value
341
+ name = f"heatmap.{axis}Categories"
342
+ if not isinstance(value, str):
343
+ raise ValueError(f"{name} requires string data coordinates")
344
+ if value not in category_values:
345
+ raise ValueError(f"{name} does not contain data category '{value}'")
346
+ return category_values.index(value)
347
+
348
+ points_by_trace = []
349
+ xs = set(range(len(x_categories))) if x_categories else set()
350
+ ys = set(range(len(y_categories))) if y_categories else set()
351
+ values = []
352
+ for trace in traces:
353
+ normalized = []
354
+ if trace.get("visible") is not False:
355
+ for source_index, point in enumerate(trace.get("data", [])):
356
+ if point.get("x") is None or point.get("y") is None:
357
+ continue
358
+ value = point.get("value") if point.get("value") is not None else point.get("z")
359
+ if value is None:
360
+ raise ValueError("Heatmap points require a numeric 'value' or 'z' field")
361
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
362
+ raise ValueError("Heatmap points require a numeric 'value' or 'z' field")
363
+ if scale == "logarithmic" and value <= 0:
364
+ raise ValueError("Heatmap logarithmic valueScale requires value/z > 0")
365
+ x = coordinate(point["x"], "x", x_categories)
366
+ y = coordinate(point["y"], "y", y_categories)
367
+ normalized.append((source_index, x, y, value))
368
+ xs.add(x)
369
+ ys.add(y)
370
+ values.append(value)
371
+ points_by_trace.append(normalized)
372
+ if not values:
373
+ return {"width": width, "height": height, "primitives": []}
374
+ xs, ys = sorted(xs), sorted(ys)
375
+ for name in ("minValue", "maxValue"):
376
+ bound = heatmap.get(name)
377
+ if scale == "logarithmic" and name in heatmap and (isinstance(bound, bool) or not isinstance(bound, (int, float)) or not isfinite(bound) or bound <= 0):
378
+ raise ValueError(f"Heatmap logarithmic {name} must be finite and > 0")
379
+ low = heatmap.get("minValue") if isinstance(heatmap.get("minValue"), (int, float)) and isfinite(heatmap["minValue"]) else min(values)
380
+ high = heatmap.get("maxValue") if isinstance(heatmap.get("maxValue"), (int, float)) and isfinite(heatmap["maxValue"]) else max(values)
381
+ if low > high:
382
+ raise ValueError("Heatmap minValue must be <= maxValue")
383
+ cell_width, cell_height = width / len(xs), height / len(ys)
384
+ def grid_overlay():
385
+ def label_options(axis):
386
+ raw = heatmap.get(f"{axis}Labels")
387
+ default = defaults["labels"]
388
+ value = {**default}
389
+ if isinstance(raw, bool):
390
+ value["show"] = raw
391
+ elif isinstance(raw, list):
392
+ value["show"] = True
393
+ value["labels"] = [item for item in raw if isinstance(item, str)]
394
+ elif isinstance(raw, Mapping):
395
+ value.update(raw)
396
+ value["show"] = raw.get("show", True)
397
+ return value
398
+
399
+ x_label, y_label = label_options("x"), label_options("y")
400
+ guide = {**defaults["guides"]}
401
+ raw_guide = heatmap.get("guides")
402
+ if isinstance(raw_guide, bool):
403
+ guide["show"] = raw_guide
404
+ elif isinstance(raw_guide, Mapping):
405
+ guide.update(raw_guide)
406
+ guide["show"] = raw_guide.get("show", True)
407
+ if not (x_label["show"] or y_label["show"] or guide["show"] and guide["lineWidth"] > 0):
408
+ return None
409
+ overlay = {"xLabels": [], "yLabels": [], "guides": [],
410
+ "guideColor": guide["color"], "guideLineWidth": guide["lineWidth"]}
411
+ if guide["show"] and guide["lineWidth"] > 0:
412
+ for index in range(len(xs) + 1):
413
+ x = index * cell_width
414
+ overlay["guides"].append({"axis": "x", "x1": x, "y1": 0, "x2": x, "y2": height, "index": index})
415
+ for index in range(len(ys) + 1):
416
+ y = index * cell_height
417
+ overlay["guides"].append({"axis": "y", "x1": 0, "y1": y, "x2": width, "y2": y, "index": index})
418
+ def axis_text(value, index, config, category_values):
419
+ labels = config.get("labels")
420
+ if isinstance(labels, list) and index < len(labels):
421
+ return labels[index]
422
+ if category_values:
423
+ return category_values[index]
424
+ return str(round(value, 6)).removesuffix(".0")
425
+ if x_label["show"]:
426
+ for index, value in enumerate(xs):
427
+ overlay["xLabels"].append({"axis": "x", "x": (index + .5) * cell_width,
428
+ "y": height + x_label["offset"], "value": value,
429
+ "index": index, "text": axis_text(value, index, x_label, x_categories),
430
+ "color": x_label["color"], "font": x_label["font"]})
431
+ if y_label["show"]:
432
+ for index, value in enumerate(ys):
433
+ overlay["yLabels"].append({"axis": "y", "x": -y_label["offset"],
434
+ "y": height - (index + .5) * cell_height, "value": value,
435
+ "index": index, "text": axis_text(value, index, y_label, y_categories),
436
+ "color": y_label["color"], "font": y_label["font"]})
437
+ return overlay
438
+
439
+ def color_legend(stops):
440
+ raw = heatmap.get("colorScaleLegend")
441
+ config = raw if isinstance(raw, Mapping) else {}
442
+ show = raw if isinstance(raw, bool) else config.get("show", True) if isinstance(raw, Mapping) else defaults["colorScaleLegend"]["show"]
443
+ if not show:
444
+ return None
445
+ base = defaults["colorScaleLegend"]
446
+ position = config.get("position", base["position"])
447
+ if position not in ("top", "bottom", "left", "right"):
448
+ position = base["position"]
449
+ horizontal = position in ("top", "bottom")
450
+ title = config.get("title", base["title"])
451
+ title = title.strip() if isinstance(title, str) and title.strip() else None
452
+ legend_width = config.get("width", base["height"] if horizontal else base["width"])
453
+ legend_height = config.get("height", base["width"] if horizontal else base["height"])
454
+ offset = config.get("offset", base["offset"])
455
+ tick_size = config.get("tickSize", base["tickSize"])
456
+ label_gap = config.get("labelGap", base["labelGap"])
457
+ line_height = config.get("lineHeight", base["lineHeight"])
458
+ title_gap = config.get("titleGap", base["titleGap"])
459
+ tick_count = max(2, min(8, round(config.get("tickCount", base["tickCount"]))))
460
+ specified = sorted({value for value in config.get("tickValues", base["tickValues"])
461
+ if isinstance(value, (int, float)) and isfinite(value)})
462
+ if scale == "logarithmic" and any(value <= 0 for value in specified):
463
+ raise ValueError("heatmap.colorScaleLegend.tickValues must contain only finite values > 0 for logarithmic scales")
464
+ ticks = legend_ticks(low, high, tick_count, specified, scale)
465
+ decimals = max(0, min(6, round(config.get("decimals", base["decimals"]))))
466
+ prefix = config.get("labelPrefix", base["labelPrefix"])
467
+ suffix = config.get("labelSuffix", base["labelSuffix"])
468
+ def tick_text(value):
469
+ return f"{prefix}{value:.{decimals}f}{suffix}"
470
+ common = {"valueScale": scale, "position": position, "minValue": low, "maxValue": high,
471
+ "lowColor": stops[0]["color"], "highColor": stops[-1]["color"], "colorStops": stops,
472
+ "ticks": [], "tickSize": tick_size, "labelGap": label_gap,
473
+ "labelPrefix": prefix, "labelSuffix": suffix,
474
+ "color": config.get("color", base["color"]), "font": config.get("font", base["font"]),
475
+ "lineHeight": line_height, "titleGap": title_gap,
476
+ "borderColor": config.get("borderColor", base["borderColor"]),
477
+ "borderWidth": config.get("borderWidth", base["borderWidth"])}
478
+ if title is not None:
479
+ common["title"] = title
480
+ title_block = line_height + title_gap if title is not None else 0
481
+ if horizontal:
482
+ legend_width = min(legend_width, max(1, width))
483
+ x = max(0, (width - legend_width) / 2)
484
+ total_height = title_block + legend_height + tick_size + label_gap + line_height
485
+ top = -(offset + total_height) if position == "top" else height + offset
486
+ y = top + title_block
487
+ for index, value in enumerate(ticks):
488
+ ratio = scale_ratio(value, low, high, scale) if low != high else index / (len(ticks) - 1)
489
+ common["ticks"].append({"value": value, "text": tick_text(value), "x": x + ratio * legend_width,
490
+ "y": y + legend_height})
491
+ common.update({"x": x, "y": y, "width": legend_width, "height": legend_height})
492
+ else:
493
+ legend_height = min(legend_height, max(1, height - title_block))
494
+ y = max(0, (height - legend_height - title_block) / 2) + title_block
495
+ x = -(offset + legend_width) if position == "left" else width + offset
496
+ for index, value in enumerate(ticks):
497
+ ratio = scale_ratio(value, low, high, scale) if low != high else index / (len(ticks) - 1)
498
+ common["ticks"].append({"value": value, "text": tick_text(value),
499
+ "y": y + legend_height - ratio * legend_height})
500
+ common.update({"x": x, "y": y, "width": legend_width, "height": legend_height})
501
+ return common
502
+
503
+ primitives = []
504
+ for trace_index, trace in enumerate(traces):
505
+ if trace.get("visible") is False:
506
+ continue
507
+ color = resolve_heatmap_wonderplot_color(options, trace, trace_index, "heatmap")
508
+ low_color = heatmap.get("lowColor") or defaults["lowColor"]
509
+ stops = resolve_stops(heatmap.get("colorStops"), low_color, color, "heatmap.colorStops")
510
+ trace_options = trace.get("heatmap") or {}
511
+ def nonnegative(value, fallback):
512
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0 else fallback
513
+ plot_gap = nonnegative(heatmap.get("cellGap"), defaults["cellGap"])
514
+ plot_border = nonnegative(heatmap.get("borderWidth"), defaults["borderWidth"])
515
+ plot_radius = nonnegative(heatmap.get("cornerRadius"), defaults["cornerRadius"])
516
+ gap = min(nonnegative(trace_options.get("cellGap"), plot_gap), cell_width, cell_height)
517
+ border_width = nonnegative(trace_options.get("borderWidth"), plot_border)
518
+ border_color = trace_options.get("borderColor", heatmap.get("borderColor", defaults["borderColor"]))
519
+ corner_radius = nonnegative(trace_options.get("cornerRadius"), plot_radius)
520
+ points, cells = [], []
521
+ occupied = set()
522
+
523
+ def geometry(column, row):
524
+ left, right = width * column / len(xs), width * (column + 1) / len(xs)
525
+ reverse_row = len(ys) - row - 1
526
+ top, bottom = height * reverse_row / len(ys), height * (reverse_row + 1) / len(ys)
527
+ item = {"x": left + gap / 2, "y": top + gap / 2,
528
+ "width": max(0, right - left - gap), "height": max(0, bottom - top - gap),
529
+ "row": row, "column": column}
530
+ radius = min(corner_radius, item["width"] / 2, item["height"] / 2)
531
+ if radius > 0:
532
+ item["cornerRadius"] = radius
533
+ return item
534
+
535
+ for source_index, x_value, y_value, value in points_by_trace[trace_index]:
536
+ column, row = xs.index(x_value), ys.index(y_value)
537
+ cell = {**geometry(column, row), "value": value, "xValue": x_value,
538
+ "yValue": y_value, "sourceIndex": source_index,
539
+ "fillColor": interpolate(value, low, high, stops, scale)}
540
+ if x_categories:
541
+ cell["xCategory"] = x_categories[x_value]
542
+ if y_categories:
543
+ cell["yCategory"] = y_categories[y_value]
544
+ cells.append(cell)
545
+ occupied.add((x_value, y_value))
546
+ missing = dict(defaults["missingCells"])
547
+ for source in (heatmap.get("missingCells"), trace_options.get("missingCells")):
548
+ if isinstance(source, bool):
549
+ missing["show"] = source
550
+ elif isinstance(source, Mapping):
551
+ missing.update(source)
552
+ missing["show"] = source.get("show", True)
553
+ if cells and missing["show"]:
554
+ for row, y_value in enumerate(ys):
555
+ for column, x_value in enumerate(xs):
556
+ if (x_value, y_value) in occupied:
557
+ continue
558
+ cell = {**geometry(column, row), "value": float("nan"), "xValue": x_value,
559
+ "yValue": y_value, "fillColor": missing["fillColor"], "missing": True,
560
+ "borderColor": missing["borderColor"], "borderWidth": missing["borderWidth"]}
561
+ if x_categories:
562
+ cell["xCategory"] = x_categories[column]
563
+ if y_categories:
564
+ cell["yCategory"] = y_categories[row]
565
+ cells.append(cell)
566
+ points = [{"x": cell["x"] + cell["width"] / 2,
567
+ "y": cell["y"] + cell["height"] / 2} for cell in cells]
568
+ if cells:
569
+ primitive = {"type": "heatmap", "points": points, "heatmapCells": cells,
570
+ "color": color, "lineWidth": 0, "borderColor": border_color,
571
+ "borderWidth": border_width, "subplot": "main", "bounds": _bounds(width, height),
572
+ "traceIndex": trace_index}
573
+ label_items = build_cell_labels(cells, resolve_labels(heatmap.get("cellLabels"), trace_options.get("cellLabels")), border_width)
574
+ if label_items is not None:
575
+ primitive["heatmapCellLabels"] = label_items
576
+ if not primitives:
577
+ overlay = grid_overlay()
578
+ if overlay is not None:
579
+ primitive["heatmapGrid"] = overlay
580
+ legend = color_legend(stops)
581
+ if legend is not None:
582
+ primitive["heatmapColorScaleLegend"] = legend
583
+ primitives.append(primitive)
584
+ return {"width": width, "height": height, "primitives": primitives}
585
+
586
+
587
+ def _wonder_scale(options: Mapping, traces: list[dict], defaults: Mapping, kind: str) -> dict | None:
588
+ raw = options.get(kind)
589
+ config = raw if isinstance(raw, Mapping) else {}
590
+ enabled = raw if isinstance(raw, bool) else config.get("enabled", True) if isinstance(raw, Mapping) else defaults[kind]["enabled"]
591
+ if not enabled:
592
+ return None
593
+ if kind == "sizeScale":
594
+ lower_name, upper_name = "minSize", "maxSize"
595
+ lower = config.get(lower_name, defaults[kind][lower_name])
596
+ upper = config.get(upper_name, defaults[kind][upper_name])
597
+ if not isinstance(lower, (int, float)) or not isfinite(lower) or lower < get_defaults("markers")["minSize"]:
598
+ raise ValueError(f"wonderplot.sizeScale.minSize must be >= {get_defaults('markers')['minSize']}")
599
+ if not isinstance(upper, (int, float)) or not isfinite(upper) or upper < lower:
600
+ raise ValueError("wonderplot.sizeScale.maxSize must be >= minSize")
601
+ field, message = "size", "Wonderplot sizeScale points require a finite numeric 'size' field"
602
+ else:
603
+ lower_name, upper_name = "minOpacity", "maxOpacity"
604
+ lower = config.get(lower_name, defaults[kind][lower_name])
605
+ upper = config.get(upper_name, defaults[kind][upper_name])
606
+ if not isinstance(lower, (int, float)) or not isfinite(lower):
607
+ raise ValueError("wonderplot.opacityScale.minOpacity must be finite")
608
+ if not isinstance(upper, (int, float)) or not isfinite(upper):
609
+ raise ValueError("wonderplot.opacityScale.maxOpacity must be finite")
610
+ lower, upper = max(0, min(1, lower)), max(0, min(1, upper))
611
+ if upper < lower:
612
+ raise ValueError("wonderplot.opacityScale.maxOpacity must be >= minOpacity")
613
+ field, message = "opacity", "Wonderplot opacityScale points require a finite numeric 'opacity' field"
614
+ values = []
615
+ for trace in traces:
616
+ if trace.get("visible") is False:
617
+ continue
618
+ for point in trace.get("data", []):
619
+ if point.get("x") is None or point.get("y") is None:
620
+ continue
621
+ value = point.get(field)
622
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
623
+ raise ValueError(message)
624
+ values.append(value)
625
+ if not values:
626
+ return None
627
+ minimum = config.get("minValue") if config.get("minValue") is not None else min(values)
628
+ maximum = config.get("maxValue") if config.get("maxValue") is not None else max(values)
629
+ for name, value in (("minValue", minimum), ("maxValue", maximum)):
630
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
631
+ raise ValueError(f"wonderplot.{kind}.{name} must be finite")
632
+ if minimum > maximum:
633
+ raise ValueError(f"wonderplot.{kind}.minValue must be <= maxValue")
634
+ return {"min": minimum, "max": maximum, "low": lower, "high": upper, "field": field}
635
+
636
+
637
+ def _wonder_scaled_value(value: float, scale: Mapping) -> float:
638
+ if scale["min"] == scale["max"]:
639
+ return (scale["low"] + scale["high"]) / 2
640
+ ratio = max(0, min(1, (value - scale["min"]) / (scale["max"] - scale["min"])))
641
+ return scale["low"] + (scale["high"] - scale["low"]) * ratio
642
+
643
+
644
+ def _wonder_labels(markers: list[dict], defaults: Mapping, plot_input, trace_input) -> list[dict] | None:
645
+ resolved = dict(defaults["markerLabels"])
646
+ for source in (plot_input, trace_input):
647
+ if source is None:
648
+ continue
649
+ if isinstance(source, bool):
650
+ resolved["show"] = source
651
+ elif isinstance(source, Mapping):
652
+ resolved.update({key: value for key, value in source.items() if value is not None and key != "formatter"})
653
+ resolved["show"] = source.get("show", True)
654
+ if not resolved["show"]:
655
+ return None
656
+ decimals = resolved["decimals"]
657
+ if not isinstance(decimals, (int, float)) or not isfinite(decimals):
658
+ decimals = defaults["markerLabels"]["decimals"]
659
+ decimals = max(0, min(6, floor(decimals + .5)))
660
+ result = []
661
+ for marker in markers:
662
+ value = marker["value"]
663
+ if decimals == 0:
664
+ number = str(floor(value + .5))
665
+ else:
666
+ number = format(Decimal(str(value)).quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_HALF_UP), f".{decimals}f")
667
+ result.append({"x": marker["x"], "y": marker["y"] - marker["size"] - resolved["offsetY"],
668
+ "value": value, "sourceIndex": marker["sourceIndex"],
669
+ "text": f"{resolved['prefix']}{number}{resolved['suffix']}",
670
+ "color": resolved["color"], "font": resolved["font"],
671
+ "lineHeight": resolved["lineHeight"], "collisionPadding": resolved["collisionPadding"]})
672
+ return result or None
673
+
674
+
675
+ def _wonder_legend(raw, defaults: Mapping, stops: list[dict], low: float, high: float,
676
+ value_scale: str, width: float, height: float) -> dict | None:
677
+ source = raw if isinstance(raw, Mapping) else {}
678
+ show = raw if isinstance(raw, bool) else source.get("show", True) if isinstance(raw, Mapping) else defaults["colorScaleLegend"]["show"]
679
+ if not show:
680
+ return None
681
+ settings = {**defaults["colorScaleLegend"], **{key: value for key, value in source.items() if value is not None}}
682
+ position = settings["position"] if settings["position"] in ("left", "right", "top", "bottom") else defaults["colorScaleLegend"]["position"]
683
+ horizontal = position in ("top", "bottom")
684
+ def nonnegative(name):
685
+ value = settings[name]
686
+ return value if isinstance(value, (int, float)) and isfinite(value) and value >= 0 else defaults["colorScaleLegend"][name]
687
+ def positive(name, fallback):
688
+ value = settings[name]
689
+ return value if isinstance(value, (int, float)) and isfinite(value) and value > 0 else fallback
690
+ legend_width = positive("width", defaults["colorScaleLegend"]["height"] if horizontal else defaults["colorScaleLegend"]["width"])
691
+ legend_height = positive("height", defaults["colorScaleLegend"]["width"] if horizontal else defaults["colorScaleLegend"]["height"])
692
+ title = settings["title"].strip() if isinstance(settings["title"], str) and settings["title"].strip() else None
693
+ count = settings["tickCount"]
694
+ if not isinstance(count, (int, float)) or not isfinite(count):
695
+ count = defaults["colorScaleLegend"]["tickCount"]
696
+ count = max(2, min(8, floor(count + .5)))
697
+ decimals = settings["decimals"]
698
+ if not isinstance(decimals, (int, float)) or not isfinite(decimals):
699
+ decimals = defaults["colorScaleLegend"]["decimals"]
700
+ decimals = max(0, min(6, floor(decimals + .5)))
701
+ explicit = settings["tickValues"] if isinstance(settings["tickValues"], list) else []
702
+ if value_scale == "logarithmic" and any(isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) or value <= 0 for value in explicit):
703
+ raise ValueError("wonderplot.colorScaleLegend.tickValues must contain only finite values > 0 for logarithmic scales")
704
+ explicit = sorted({value for value in explicit if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)})
705
+ tick_values = legend_ticks(low, high, count, explicit, value_scale)
706
+ line_height = positive("lineHeight", defaults["colorScaleLegend"]["lineHeight"])
707
+ title_gap = nonnegative("titleGap")
708
+ title_block = line_height + title_gap if title else 0
709
+ if horizontal:
710
+ legend_width = min(legend_width, max(1, width))
711
+ x = max(0, (width - legend_width) / 2)
712
+ total_height = title_block + legend_height + nonnegative("tickSize") + nonnegative("labelGap") + line_height
713
+ group_top = -(nonnegative("offset") + total_height) if position == "top" else height + nonnegative("offset")
714
+ y = group_top + title_block
715
+ else:
716
+ available = max(1, height - title_block)
717
+ legend_height = min(legend_height, available)
718
+ y = max(0, (height - legend_height - title_block) / 2) + title_block
719
+ x = -(nonnegative("offset") + legend_width) if position == "left" else width + nonnegative("offset")
720
+ ticks = []
721
+ for index, value in enumerate(tick_values):
722
+ ratio = index / (len(tick_values) - 1) if low == high and len(tick_values) > 1 else scale_ratio(value, low, high, value_scale)
723
+ number = str(floor(value + .5)) if decimals == 0 else format(Decimal(str(value)).quantize(Decimal(1).scaleb(-decimals), rounding=ROUND_HALF_UP), f".{decimals}f")
724
+ tick = {"value": value, "text": f"{settings['labelPrefix']}{number}{settings['labelSuffix']}"}
725
+ if horizontal:
726
+ tick.update({"x": x + ratio * legend_width, "y": y + legend_height})
727
+ else:
728
+ tick["y"] = y + legend_height - ratio * legend_height
729
+ ticks.append(tick)
730
+ legend = {"valueScale": value_scale, "position": position, "x": x, "y": y,
731
+ "width": legend_width, "height": legend_height, "minValue": low, "maxValue": high,
732
+ "lowColor": stops[0]["color"], "highColor": stops[-1]["color"], "colorStops": stops,
733
+ "title": title, "ticks": ticks, "tickSize": nonnegative("tickSize"),
734
+ "labelGap": nonnegative("labelGap"), "labelPrefix": settings["labelPrefix"],
735
+ "labelSuffix": settings["labelSuffix"], "color": settings["color"],
736
+ "font": settings["font"], "lineHeight": line_height, "titleGap": title_gap,
737
+ "borderColor": settings["borderColor"], "borderWidth": nonnegative("borderWidth")}
738
+ if title is None:
739
+ legend.pop("title")
740
+ return legend
741
+
742
+
743
+ def wonderplot_scene(options: Mapping, width: float, height: float) -> dict:
744
+ defaults = get_defaults("wonderplot")
745
+ wonderplot = options.get("wonderplot") or {}
746
+ traces = _traces(options)
747
+ size_scale = _wonder_scale(wonderplot, traces, defaults, "sizeScale")
748
+ opacity_scale = _wonder_scale(wonderplot, traces, defaults, "opacityScale")
749
+ all_data = [point for trace in traces if trace.get("visible") is not False for point in trace.get("data", []) if point.get("x") is not None and point.get("y") is not None]
750
+ if not all_data:
751
+ return {"width": width, "height": height, "primitives": []}
752
+ color_config = wonderplot.get("colorScale") or {}
753
+ value_scale = color_config.get("valueScale", defaults["valueScale"])
754
+ if value_scale not in ("linear", "logarithmic"):
755
+ raise ValueError("wonderplot.colorScale.valueScale must be 'linear' or 'logarithmic'")
756
+ color_values = []
757
+ for point in all_data:
758
+ value = point.get("value", point.get("z"))
759
+ if value is None:
760
+ raise ValueError("Wonderplot points require a numeric 'value' or 'z' field for color-scale markers")
761
+ value = float(value)
762
+ if value_scale == "logarithmic" and value <= 0:
763
+ raise ValueError("Wonderplot logarithmic colorScale requires value/z > 0")
764
+ color_values.append(value)
765
+ for bound in ("minValue", "maxValue"):
766
+ configured = color_config.get(bound)
767
+ if value_scale == "logarithmic" and configured is not None and (isinstance(configured, bool) or not isinstance(configured, (int, float)) or not isfinite(configured) or configured <= 0):
768
+ raise ValueError(f"Wonderplot logarithmic colorScale.{bound} must be finite and > 0")
769
+ color_low = color_config.get("minValue") if isinstance(color_config.get("minValue"), (int, float)) and isfinite(color_config["minValue"]) else min(color_values)
770
+ color_high = color_config.get("maxValue") if isinstance(color_config.get("maxValue"), (int, float)) and isfinite(color_config["maxValue"]) else max(color_values)
771
+ if color_low > color_high:
772
+ raise ValueError("Wonderplot colorScale minValue must be <= maxValue")
773
+ xs = [float(point["x"]) for point in all_data]
774
+ ys = [float(point["y"]) for point in all_data]
775
+ values = [float(point.get("value", point.get("z"))) for point in all_data if point.get("value", point.get("z")) is not None]
776
+ xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
777
+ x_axis = ((options.get("axes") or {}).get("xAxes") or {}).get("x") or {}
778
+ y_axis = ((options.get("axes") or {}).get("yAxes") or {}).get("y") or {}
779
+ for trace in traces:
780
+ if trace.get("visible") is False:
781
+ continue
782
+ resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), "")
783
+ error_x, error_y = error_domain_values(trace, resolved, x_axis, y_axis)
784
+ xs.extend(error_x)
785
+ ys.extend(error_y)
786
+ if xs and not x_axis.get("range"):
787
+ xmin, xmax = min(xs), max(xs)
788
+ if ys and not y_axis.get("range"):
789
+ ymin, ymax = min(ys), max(ys)
790
+ if x_axis.get("range"):
791
+ xmin, xmax = float(x_axis["range"]["min"]), float(x_axis["range"]["max"])
792
+ if y_axis.get("range"):
793
+ ymin, ymax = float(y_axis["range"]["min"]), float(y_axis["range"]["max"])
794
+ low, high = color_low, color_high
795
+ primitives = []
796
+ legend_used = False
797
+ for trace_index, trace in enumerate(traces):
798
+ if trace.get("visible") is False:
799
+ continue
800
+ trace_options = trace.get("wonderplot") or {}
801
+ color = resolve_heatmap_wonderplot_color(options, trace, trace_index, "wonderplot")
802
+ low_color = color_config.get("lowColor") or defaults["lowColor"]
803
+ stops = resolve_stops(color_config.get("colorStops"), low_color, color, "wonderplot.colorScale.colorStops")
804
+ points, markers = [], []
805
+ for source_index, point in enumerate(trace.get("data", [])):
806
+ if point.get("x") is None or point.get("y") is None:
807
+ continue
808
+ x = (float(point["x"]) - xmin) / max(xmax - xmin, 2.220446049250313e-16) * width
809
+ y = height - ((float(point["y"]) - ymin) / max(ymax - ymin, 2.220446049250313e-16)) * height
810
+ if x < 0 or x > width or y < 0 or y > height:
811
+ continue
812
+ value = point.get("value", point.get("z"))
813
+ if value is None:
814
+ raise ValueError("Wonderplot points require a numeric 'value' or 'z' field for color-scale markers")
815
+ fixed_opacity = trace_options.get("markerOpacity", wonderplot.get("markerOpacity", defaults["markerOpacity"]))
816
+ if not isinstance(fixed_opacity, (int, float)) or not isfinite(fixed_opacity):
817
+ raise ValueError("trace.wonderplot.markerOpacity must be finite" if "markerOpacity" in trace_options else "wonderplot.markerOpacity must be finite")
818
+ marker = {"x": x, "y": y, "value": value, "sourceIndex": source_index,
819
+ "size": max(get_defaults("markers")["minSize"], trace_options.get("markerSize", wonderplot.get("markerSize", defaults["markerSize"])))}
820
+ if size_scale:
821
+ marker["sizeValue"] = point["size"]
822
+ marker["size"] = _wonder_scaled_value(point["size"], size_scale)
823
+ marker["opacity"] = max(0, min(1, fixed_opacity))
824
+ if opacity_scale:
825
+ marker["opacityValue"] = point["opacity"]
826
+ marker["opacity"] = _wonder_scaled_value(point["opacity"], opacity_scale)
827
+ if value is not None:
828
+ marker["fillColor"] = interpolate(float(value), low, high, stops, value_scale)
829
+ points.append({"x": x, "y": y})
830
+ markers.append(marker)
831
+ if markers:
832
+ primitive = {"type": "wonderplot", "points": points, "wonderPoints": markers,
833
+ "traceIndex": trace_index, "markerShape": trace_options.get("markerShape", wonderplot.get("markerShape", defaults["markerShape"])),
834
+ "color": color, "lineWidth": 0, "borderColor": trace_options.get("borderColor", wonderplot.get("borderColor", defaults["borderColor"])),
835
+ "borderWidth": trace_options.get("borderWidth", wonderplot.get("borderWidth", defaults["borderWidth"])),
836
+ "subplot": "main", "bounds": _bounds(width, height)}
837
+ resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), color)
838
+ bars = error_geometry(trace, resolved, x_axis, y_axis, (xmin, xmax), (ymin, ymax), width, height)
839
+ if bars:
840
+ primitive["errorBars"] = bars
841
+ primitive["errorBarStyle"] = resolved["style"]
842
+ labels = _wonder_labels(markers, defaults, wonderplot.get("markerLabels"), trace_options.get("markerLabels"))
843
+ if labels:
844
+ primitive["wonderplotMarkerLabels"] = labels
845
+ if not legend_used:
846
+ legend = _wonder_legend(wonderplot.get("colorScaleLegend"), defaults, stops,
847
+ low, high, value_scale, width, height)
848
+ if legend:
849
+ primitive["wonderplotColorScaleLegend"] = legend
850
+ legend_used = True
851
+ primitives.append(primitive)
852
+ return {"width": width, "height": height, "primitives": primitives}
853
+
854
+
855
+ def polar_scene(options: Mapping, width: float, height: float) -> dict:
856
+ defaults = get_defaults("polar")
857
+ polar = options.get("polar") or {}
858
+ traces = _traces(options)
859
+ all_data = [point for trace in traces if trace.get("visible") is not False for point in trace.get("data", []) if point.get("x") is not None and point.get("y") is not None]
860
+ if not all_data:
861
+ return {"width": width, "height": height, "primitives": []}
862
+ max_value = polar.get("maxRadius") or max(float(point["y"]) for point in all_data)
863
+ min_value = polar.get("minRadius", defaults["minRadius"])
864
+ max_radius = min(width, height) / 2 * polar.get("outerRadiusRatio", defaults["outerRadiusRatio"])
865
+ center_x, center_y = width / 2, height / 2
866
+ start = radians(polar.get("startAngleDegrees", defaults["startAngleDegrees"]))
867
+ direction = 1 if polar.get("clockwise", defaults["clockwise"]) else -1
868
+ width_radians = radians(polar.get("sectorWidthDegrees", defaults["sectorWidthDegrees"]))
869
+ gap_radians = radians(polar.get("sectorGapDegrees", defaults["sectorGapDegrees"]))
870
+ primitives = []
871
+ for trace_index, trace in enumerate(traces):
872
+ if trace.get("visible") is False:
873
+ continue
874
+ color = trace.get("color") or DEFAULT_TRACE_COLORS[trace_index % len(DEFAULT_TRACE_COLORS)]
875
+ trace_polar = trace.get("polar") or {}
876
+ points, sectors = [], []
877
+ for source_index, point in enumerate(trace.get("data", [])):
878
+ if point.get("x") is None or point.get("y") is None:
879
+ continue
880
+ angle = float(point["x"])
881
+ value = float(point["y"])
882
+ theta = start + radians(angle) * direction
883
+ radius = (value - min_value) / max(max_value - min_value, 2.220446049250313e-16) * max_radius
884
+ points.append({"x": center_x + cos(theta) * radius, "y": center_y + sin(theta) * radius})
885
+ half = (width_radians - gap_radians) / 2
886
+ sectors.append({"centerX": center_x, "centerY": center_y, "innerRadius": 0,
887
+ "outerRadius": radius, "startAngle": theta - half, "endAngle": theta + half,
888
+ "angle": angle, "value": value, "opacity": trace_polar.get("sectorOpacity", polar.get("sectorOpacity", defaults["sectorOpacity"])),
889
+ "sourceIndex": source_index})
890
+ if sectors:
891
+ primitives.append({"type": "polar", "points": points, "polarSectors": sectors,
892
+ "color": color, "lineWidth": trace_polar.get("borderWidth", polar.get("borderWidth", defaults["borderWidth"])),
893
+ "borderColor": trace_polar.get("borderColor", polar.get("borderColor", defaults["borderColor"])),
894
+ "borderWidth": trace_polar.get("borderWidth", polar.get("borderWidth", defaults["borderWidth"])),
895
+ "traceIndex": trace_index, "subplot": "main", "bounds": _bounds(width, height)})
896
+ return {"width": width, "height": height, "primitives": primitives}