fastplotpy 0.3.0a1__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastplotpy/README.md +70 -0
- fastplotpy/__init__.py +33 -0
- fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
- fastplotpy/assets/dist/index.js +15071 -0
- fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
- fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
- fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
- fastplotpy/assets/package.json +1 -0
- fastplotpy/axes_validation.py +102 -0
- fastplotpy/camera3d.py +104 -0
- fastplotpy/color_scale.py +88 -0
- fastplotpy/defaults/area-fill.json +5 -0
- fastplotpy/defaults/bar.json +22 -0
- fastplotpy/defaults/camera3d.json +22 -0
- fastplotpy/defaults/cartesian-references.json +10 -0
- fastplotpy/defaults/control.json +3 -0
- fastplotpy/defaults/crosshair.json +19 -0
- fastplotpy/defaults/error-bars.json +7 -0
- fastplotpy/defaults/heatmap.json +70 -0
- fastplotpy/defaults/interaction.json +3 -0
- fastplotpy/defaults/layout.json +32 -0
- fastplotpy/defaults/legend.json +24 -0
- fastplotpy/defaults/line-axes.json +28 -0
- fastplotpy/defaults/line3d.json +13 -0
- fastplotpy/defaults/markers.json +6 -0
- fastplotpy/defaults/mesh3d.json +13 -0
- fastplotpy/defaults/pan.json +7 -0
- fastplotpy/defaults/pie.json +27 -0
- fastplotpy/defaults/pie3d.json +7 -0
- fastplotpy/defaults/point-labels.json +13 -0
- fastplotpy/defaults/pointClick.json +6 -0
- fastplotpy/defaults/pointSelection.json +13 -0
- fastplotpy/defaults/polar.json +71 -0
- fastplotpy/defaults/polarRotate.json +5 -0
- fastplotpy/defaults/scatter3d.json +39 -0
- fastplotpy/defaults/scene3d.json +61 -0
- fastplotpy/defaults/surface.json +35 -0
- fastplotpy/defaults/tooltip.json +18 -0
- fastplotpy/defaults/trace-colors.json +10 -0
- fastplotpy/defaults/trace-options.json +24 -0
- fastplotpy/defaults/wonderplot.json +58 -0
- fastplotpy/defaults/zoom.json +12 -0
- fastplotpy/defaults.py +15 -0
- fastplotpy/error_bars.py +118 -0
- fastplotpy/exports.py +153 -0
- fastplotpy/families2d.py +896 -0
- fastplotpy/heatmap_labels.py +146 -0
- fastplotpy/interactions.py +243 -0
- fastplotpy/interactive.py +1922 -0
- fastplotpy/js_serialize.py +47 -0
- fastplotpy/lighting3d.py +113 -0
- fastplotpy/line3d_lod.py +138 -0
- fastplotpy/lod3d.py +105 -0
- fastplotpy/mesh3d_lod.py +107 -0
- fastplotpy/native.py +163 -0
- fastplotpy/overlays.py +561 -0
- fastplotpy/point_labels.py +86 -0
- fastplotpy/polar.py +445 -0
- fastplotpy/polygon_clip3d.py +148 -0
- fastplotpy/references.py +72 -0
- fastplotpy/rendering.py +65 -0
- fastplotpy/rust_scenes.py +314 -0
- fastplotpy/scene.py +1185 -0
- fastplotpy/scene3d.py +1215 -0
- fastplotpy/scene3d_accessibility.py +183 -0
- fastplotpy/scene3d_collection.py +158 -0
- fastplotpy/subplots.py +115 -0
- fastplotpy/surface_contours.py +122 -0
- fastplotpy/surface_lod.py +109 -0
- fastplotpy/svg3d.py +235 -0
- fastplotpy/trendline.py +236 -0
- fastplotpy/triangle_wireframe.py +51 -0
- fastplotpy/types.py +3027 -0
- fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
- fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
- fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
- fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
- fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
fastplotpy/families2d.py
ADDED
|
@@ -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}
|