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/references.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Source-shaped Cartesian reference validation; references render as overlays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .subplots import resolveSubplots, resolveTraceSubplotBindings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validateCartesianReferences(options: Mapping) -> None:
|
|
12
|
+
references = options.get("references")
|
|
13
|
+
if references is None:
|
|
14
|
+
return
|
|
15
|
+
if options.get("type") not in ("line", "bar", "wonderplot"):
|
|
16
|
+
raise ValueError("references are supported only for 'line', 'bar', and 'wonderplot' charts")
|
|
17
|
+
if not isinstance(references, Mapping):
|
|
18
|
+
raise TypeError("references must be an object")
|
|
19
|
+
for kind in ("lines", "bands"):
|
|
20
|
+
if kind in references and not isinstance(references[kind], list):
|
|
21
|
+
raise ValueError(f"references.{kind} must be an array")
|
|
22
|
+
|
|
23
|
+
# Import after scene is initialized: the same normalization governs chart data.
|
|
24
|
+
from .scene import _axis, _number, _scaled
|
|
25
|
+
|
|
26
|
+
subplots = resolveSubplots(options)
|
|
27
|
+
bindings = resolveTraceSubplotBindings(options)
|
|
28
|
+
for kind in ("lines", "bands"):
|
|
29
|
+
for index, reference in enumerate(references.get(kind) or []):
|
|
30
|
+
label = f"references.{kind}[{index}]"
|
|
31
|
+
if not isinstance(reference, Mapping):
|
|
32
|
+
raise TypeError(f"{label} must be an object")
|
|
33
|
+
dimension = reference.get("axis")
|
|
34
|
+
if dimension not in ("x", "y"):
|
|
35
|
+
raise ValueError(f"{label}.axis must be 'x' or 'y'")
|
|
36
|
+
scoped_id = reference.get("subplot")
|
|
37
|
+
subplot = next((item for item in subplots if item["id"] == scoped_id), None)
|
|
38
|
+
if "subplot" in reference and subplot is None:
|
|
39
|
+
raise ValueError(f"{label}.subplot references unknown subplot '{scoped_id}'")
|
|
40
|
+
axis_id = reference.get("axisId")
|
|
41
|
+
axis_map = ((options.get("axes") or {}).get(f"{dimension}Axes") or {})
|
|
42
|
+
known_axes = {dimension, *axis_map.keys()}
|
|
43
|
+
if axis_id is not None and axis_id not in known_axes:
|
|
44
|
+
raise ValueError(f"{label}.axisId references unknown {dimension}-axis '{axis_id}'")
|
|
45
|
+
candidates = [subplot] if subplot is not None else subplots
|
|
46
|
+
targets = []
|
|
47
|
+
for item in candidates:
|
|
48
|
+
fallback = item[f"{dimension}AxisId"]
|
|
49
|
+
selected = axis_id if axis_id is not None else fallback
|
|
50
|
+
bound = [binding for binding in bindings if binding["subplotId"] == item["id"]]
|
|
51
|
+
used = {binding[f"{dimension}AxisId"] for binding in bound} if bound else {fallback}
|
|
52
|
+
if selected in used and selected in known_axes:
|
|
53
|
+
targets.append(_axis(options, dimension, selected))
|
|
54
|
+
if not targets:
|
|
55
|
+
selected = axis_id or (subplot[f"{dimension}AxisId"] if subplot else dimension)
|
|
56
|
+
scope = f"subplot '{subplot['id']}'" if subplot else "any subplot"
|
|
57
|
+
raise ValueError(f"{label}.axisId '{selected}' is not used by {scope}")
|
|
58
|
+
fields = ("value",) if kind == "lines" else ("from", "to")
|
|
59
|
+
for axis in targets:
|
|
60
|
+
for field in fields:
|
|
61
|
+
value = reference.get(field)
|
|
62
|
+
if value is None:
|
|
63
|
+
raise ValueError(f"{label}.{field} is required")
|
|
64
|
+
_scaled(_number(value, axis), axis)
|
|
65
|
+
if kind == "lines":
|
|
66
|
+
line_width = reference.get("lineWidth")
|
|
67
|
+
if "lineWidth" in reference and (isinstance(line_width, bool)
|
|
68
|
+
or not isinstance(line_width, (int, float))
|
|
69
|
+
or not isfinite(line_width) or line_width < 0):
|
|
70
|
+
raise ValueError(f"{label}.lineWidth must be finite and >= 0")
|
|
71
|
+
if reference.get("pattern") not in (None, "solid", "dashed", "dotted", "dash-dot"):
|
|
72
|
+
raise ValueError("lineStyle.pattern must be 'solid', 'dashed', 'dotted', or 'dash-dot'")
|
fastplotpy/rendering.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Python/browser rendering adapters for source-named DOM exports."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from threading import RLock
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
|
|
6
|
+
_diagnostics_lock = RLock()
|
|
7
|
+
_last_line_axis_diagnostics: list[dict] = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _set_last_line_axis_diagnostics(values) -> None:
|
|
11
|
+
if not isinstance(values, list) or any(not isinstance(item, Mapping) for item in values):
|
|
12
|
+
return
|
|
13
|
+
with _diagnostics_lock:
|
|
14
|
+
_last_line_axis_diagnostics[:] = deepcopy(values)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def getLastLineAxisLayoutDiagnostics() -> list[dict]:
|
|
18
|
+
"""Return the last diagnostics received from an attached browser canvas."""
|
|
19
|
+
with _diagnostics_lock:
|
|
20
|
+
return deepcopy(_last_line_axis_diagnostics)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Scene3dWebgl2CapabilityError(RuntimeError):
|
|
24
|
+
"""Browser WebGL2 capability error with the source failure-code vocabulary."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: str, message: str, cause: Exception | None = None):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.cause = cause
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def renderToCanvas(canvas, options: Mapping, style: Mapping | None = None) -> dict:
|
|
33
|
+
"""Render on a Chart-owned browser/Jupyter canvas and return its host scene.
|
|
34
|
+
|
|
35
|
+
The Python ``canvas`` argument is a Chart because CPython has no DOM canvas.
|
|
36
|
+
The attached frontend performs the actual Canvas/WebGL drawing.
|
|
37
|
+
"""
|
|
38
|
+
from .interactive import Chart
|
|
39
|
+
|
|
40
|
+
if not isinstance(canvas, Chart):
|
|
41
|
+
raise TypeError("canvas must be a fastplotpy Chart browser/Jupyter surface")
|
|
42
|
+
if not isinstance(options, Mapping) or not isinstance(style, Mapping | type(None)):
|
|
43
|
+
raise TypeError("options and style must be mappings")
|
|
44
|
+
with canvas._changed:
|
|
45
|
+
if canvas._closed:
|
|
46
|
+
raise RuntimeError("chart is destroyed")
|
|
47
|
+
if "ready" not in canvas._latest:
|
|
48
|
+
raise RuntimeError("browser canvas is not ready; display or serve the Chart first")
|
|
49
|
+
identifier = canvas.call("__renderToCanvas__", deepcopy(dict(options)), deepcopy(dict(style or {})))
|
|
50
|
+
while identifier not in canvas._command_results and not canvas._closed and "bridgeError" not in canvas._latest:
|
|
51
|
+
canvas._changed.wait()
|
|
52
|
+
if canvas._closed:
|
|
53
|
+
raise RuntimeError("chart was destroyed during renderToCanvas")
|
|
54
|
+
if "bridgeError" in canvas._latest:
|
|
55
|
+
raise RuntimeError(f"browser bridge failed: {canvas._latest['bridgeError']}")
|
|
56
|
+
outcome = canvas._command_results.pop(identifier)
|
|
57
|
+
payload = outcome["payload"]
|
|
58
|
+
if outcome["kind"] == "commandError":
|
|
59
|
+
if payload.get("name") == "Scene3dWebgl2CapabilityError":
|
|
60
|
+
raise Scene3dWebgl2CapabilityError(payload.get("code", "draw-failed"), payload.get("message", "WebGL2 rendering failed"))
|
|
61
|
+
raise RuntimeError(payload.get("message", "browser renderToCanvas failed"))
|
|
62
|
+
result = payload.get("result")
|
|
63
|
+
if not isinstance(result, Mapping) or "primitives" not in result:
|
|
64
|
+
raise RuntimeError("browser renderToCanvas returned an invalid scene")
|
|
65
|
+
return deepcopy(dict(result))
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Source-pinned Rust scene construction with Python-owned presentation state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite, log10
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
from .native import rustCall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bar_axis_value(value, axis):
|
|
13
|
+
categories = axis.get("categories") or []
|
|
14
|
+
if categories and isinstance(value, str):
|
|
15
|
+
return float(categories.index(value) + 1)
|
|
16
|
+
return float(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bar_domain(values, axis, *, slot=False):
|
|
20
|
+
from .scene import _domain
|
|
21
|
+
if axis.get("range"):
|
|
22
|
+
result = _domain([], axis)
|
|
23
|
+
else:
|
|
24
|
+
result = _domain(values, axis)
|
|
25
|
+
if result is None:
|
|
26
|
+
return None
|
|
27
|
+
low, high = result
|
|
28
|
+
if not slot or axis.get("range"):
|
|
29
|
+
return {"min": low, "max": high}
|
|
30
|
+
transformed = sorted({_bar_scale(value, axis) for value in values})
|
|
31
|
+
differences = [right - left for left, right in zip(transformed, transformed[1:]) if right > left]
|
|
32
|
+
padding = min(differences) / 2 if differences else .5
|
|
33
|
+
return {"min": low - padding, "max": high + padding}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bar_scale(value, axis):
|
|
37
|
+
return log10(value) if axis.get("scale") == "logarithmic" else value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bar_scene_rust(options: Mapping[str, Any], python_scene: dict) -> dict:
|
|
41
|
+
"""Pass Python-normalized bar groups and domains to the source Rust core."""
|
|
42
|
+
from .families2d import _bar_labels, _bar_value_labels, _traces, resolve_bar_line_style
|
|
43
|
+
from .error_bars import error_domain_values, resolve_error_bars
|
|
44
|
+
|
|
45
|
+
bar = options.get("bar") or {}
|
|
46
|
+
defaults = get_defaults("bar")
|
|
47
|
+
traces = _traces(options)
|
|
48
|
+
mode = bar.get("mode", defaults["mode"])
|
|
49
|
+
orientation = bar.get("orientation", defaults["orientation"])
|
|
50
|
+
x_axis = ((options.get("axes") or {}).get("xAxes") or {}).get("x") or {}
|
|
51
|
+
y_axis = ((options.get("axes") or {}).get("yAxes") or {}).get("y") or {}
|
|
52
|
+
visible = [(index, trace) for index, trace in enumerate(traces) if trace.get("visible") is not False]
|
|
53
|
+
normalized = []
|
|
54
|
+
x_values, y_values, slot_values = [], [], []
|
|
55
|
+
stacked = {}
|
|
56
|
+
for index, trace in visible:
|
|
57
|
+
items = []
|
|
58
|
+
for source_index, point in enumerate(trace.get("data", [])):
|
|
59
|
+
if point.get("x") is None or point.get("y") is None:
|
|
60
|
+
continue
|
|
61
|
+
x = _bar_axis_value(point["x"], x_axis)
|
|
62
|
+
y = _bar_axis_value(point["y"], y_axis)
|
|
63
|
+
value = y if orientation == "vertical" else x
|
|
64
|
+
slot = x if orientation == "vertical" else y
|
|
65
|
+
base = _bar_axis_value(point["base"], y_axis if orientation == "vertical" else x_axis) if point.get("base") is not None else 0.0
|
|
66
|
+
items.append((source_index, x, y, base, point.get("base") is not None))
|
|
67
|
+
slot_values.append(slot)
|
|
68
|
+
if orientation == "vertical":
|
|
69
|
+
x_values.append(x)
|
|
70
|
+
else:
|
|
71
|
+
y_values.append(y)
|
|
72
|
+
if mode == "grouped":
|
|
73
|
+
(y_values if orientation == "vertical" else x_values).extend((base, value))
|
|
74
|
+
elif mode == "stacked":
|
|
75
|
+
side = "positive" if value >= 0 else "negative"
|
|
76
|
+
running = stacked.setdefault((slot, side), 0.0) + value
|
|
77
|
+
stacked[(slot, side)] = running
|
|
78
|
+
(y_values if orientation == "vertical" else x_values).extend((0.0, running))
|
|
79
|
+
else:
|
|
80
|
+
(y_values if orientation == "vertical" else x_values).extend((0.0, 100.0 if value > 0 else -100.0 if value < 0 else 0.0))
|
|
81
|
+
normalized.append((index, trace, items))
|
|
82
|
+
slot_axis = x_axis if orientation == "vertical" else y_axis
|
|
83
|
+
if mode == "grouped":
|
|
84
|
+
for _, trace in visible:
|
|
85
|
+
resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), "")
|
|
86
|
+
extra_x, extra_y = error_domain_values(trace, resolved, x_axis, y_axis)
|
|
87
|
+
x_values.extend(extra_x)
|
|
88
|
+
y_values.extend(extra_y)
|
|
89
|
+
if slot_axis.get("categories"):
|
|
90
|
+
(x_values if orientation == "vertical" else y_values).extend(range(1, len(slot_axis["categories"]) + 1))
|
|
91
|
+
if not slot_values:
|
|
92
|
+
return python_scene
|
|
93
|
+
x_domain = _bar_domain(x_values, x_axis, slot=orientation == "vertical")
|
|
94
|
+
y_domain = _bar_domain(y_values, y_axis, slot=orientation == "horizontal")
|
|
95
|
+
domain = x_domain if orientation == "vertical" else y_domain
|
|
96
|
+
unique = sorted({_bar_scale(value, slot_axis) for value in slot_values})
|
|
97
|
+
delta = min((right - left for left, right in zip(unique, unique[1:]) if right > left), default=None)
|
|
98
|
+
slot_size = python_scene["width"] if orientation == "vertical" else python_scene["height"]
|
|
99
|
+
if slot_axis.get("categories") and len(slot_axis["categories"]) > 1:
|
|
100
|
+
delta = 1.0
|
|
101
|
+
slot_width = (delta / max(domain["max"] - domain["min"], 2.220446049250313e-16) * slot_size) if delta is not None else slot_size
|
|
102
|
+
rust_traces = []
|
|
103
|
+
for index, trace, items in normalized:
|
|
104
|
+
if not items:
|
|
105
|
+
continue
|
|
106
|
+
trace_bar = trace.get("bar") or {}
|
|
107
|
+
from .defaults import DEFAULT_TRACE_COLORS
|
|
108
|
+
color, line_width = resolve_bar_line_style(options, trace, DEFAULT_TRACE_COLORS[index % len(DEFAULT_TRACE_COLORS)])
|
|
109
|
+
rust_traces.append({
|
|
110
|
+
"points": [{"x": x, "y": y} for _, x, y, _, _ in items],
|
|
111
|
+
"baselines": [base for _, _, _, base, _ in items] if any(explicit for *_, explicit in items) else None,
|
|
112
|
+
"sourceIndexes": [source_index for source_index, *_ in items],
|
|
113
|
+
"groupKey": "main\0x\0y", "groupIndex": 0 if mode != "grouped" else [number for number, _ in visible].index(index),
|
|
114
|
+
"groupCount": 1 if mode != "grouped" else len(visible),
|
|
115
|
+
"slotWidth": slot_width, "subplotWidth": python_scene["width"], "subplotHeight": python_scene["height"],
|
|
116
|
+
"xDomain": x_domain, "yDomain": y_domain,
|
|
117
|
+
"xScale": x_axis.get("scale", "linear"), "yScale": y_axis.get("scale", "linear"),
|
|
118
|
+
"color": color,
|
|
119
|
+
"lineWidth": line_width,
|
|
120
|
+
"borderColor": trace_bar.get("borderColor", bar.get("borderColor", defaults["borderColor"])),
|
|
121
|
+
"borderWidth": trace_bar.get("borderWidth", bar.get("borderWidth", defaults["borderWidth"])),
|
|
122
|
+
"traceIndex": index, "subplot": "main",
|
|
123
|
+
"bounds": {"left": 0, "top": 0, "right": python_scene["width"], "bottom": python_scene["height"]},
|
|
124
|
+
})
|
|
125
|
+
native = rustCall("bar", input={
|
|
126
|
+
"width": python_scene["width"], "height": python_scene["height"],
|
|
127
|
+
"mode": mode, "orientation": orientation,
|
|
128
|
+
"barWidthRatio": bar.get("barWidthRatio", defaults["barWidthRatio"]),
|
|
129
|
+
"minBarWidth": defaults["minBarWidth"],
|
|
130
|
+
"borderColor": bar.get("borderColor", defaults["borderColor"]),
|
|
131
|
+
"borderWidth": bar.get("borderWidth", defaults["borderWidth"]),
|
|
132
|
+
"traces": rust_traces,
|
|
133
|
+
})
|
|
134
|
+
python_by_trace = {primitive["traceIndex"]: primitive for primitive in python_scene["primitives"]}
|
|
135
|
+
for primitive in native["primitives"]:
|
|
136
|
+
index = primitive["traceIndex"]
|
|
137
|
+
trace = traces[index]
|
|
138
|
+
trace_bar = trace.get("bar") or {}
|
|
139
|
+
radius = trace_bar.get("cornerRadius", bar.get("cornerRadius", defaults["cornerRadius"]))
|
|
140
|
+
for rect in primitive["bars"]:
|
|
141
|
+
if orientation == "vertical":
|
|
142
|
+
axis_domain, size = y_domain, python_scene["height"]
|
|
143
|
+
else:
|
|
144
|
+
axis_domain, size = x_domain, python_scene["width"]
|
|
145
|
+
baseline = rect["baseline"]
|
|
146
|
+
rendered = rect.get("normalizedValue", rect["value"])
|
|
147
|
+
end = rendered if mode == "grouped" else baseline + rendered
|
|
148
|
+
def position(value):
|
|
149
|
+
transformed = _bar_scale(value, y_axis if orientation == "vertical" else x_axis)
|
|
150
|
+
coordinate = ((transformed - axis_domain["min"]) /
|
|
151
|
+
max(axis_domain["max"] - axis_domain["min"], 2.220446049250313e-16)) * size
|
|
152
|
+
return size - coordinate if orientation == "vertical" else coordinate
|
|
153
|
+
lower, upper = sorted((position(baseline), position(end)))
|
|
154
|
+
clipped_lower, clipped_upper = max(0.0, lower), min(size, upper)
|
|
155
|
+
if orientation == "vertical":
|
|
156
|
+
rect["height"] = clipped_upper - clipped_lower
|
|
157
|
+
else:
|
|
158
|
+
rect["width"] = clipped_upper - clipped_lower
|
|
159
|
+
corner = min(radius, rect["width"] / 2, rect["height"] / 2)
|
|
160
|
+
if corner > 0:
|
|
161
|
+
rect["cornerRadius"] = corner
|
|
162
|
+
labels = _bar_labels(defaults, bar.get("valueLabels"), trace_bar.get("valueLabels"))
|
|
163
|
+
items = _bar_value_labels(primitive["bars"], labels, orientation, mode)
|
|
164
|
+
if items:
|
|
165
|
+
primitive["barValueLabels"] = items
|
|
166
|
+
original = python_by_trace.get(index) or {}
|
|
167
|
+
for field in ("errorBars", "errorBarStyle"):
|
|
168
|
+
if field in original:
|
|
169
|
+
primitive[field] = original[field]
|
|
170
|
+
return native
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def heatmap_scene_rust(options: Mapping[str, Any], python_scene: dict) -> dict:
|
|
174
|
+
"""Use the Rust grid geometry and retain the source's Python-built overlays.
|
|
175
|
+
|
|
176
|
+
fastPlotJs likewise resolves colors, labels, and grid presentation before
|
|
177
|
+
passing its normalized cells to the Rust core.
|
|
178
|
+
"""
|
|
179
|
+
from .families2d import _traces
|
|
180
|
+
|
|
181
|
+
heatmap = options.get("heatmap") or {}
|
|
182
|
+
defaults = get_defaults("heatmap")
|
|
183
|
+
traces = _traces(options)
|
|
184
|
+
x_categories = heatmap.get("xCategories") or defaults["xCategories"]
|
|
185
|
+
y_categories = heatmap.get("yCategories") or defaults["yCategories"]
|
|
186
|
+
x_coordinates = (set(range(len(x_categories))) if x_categories else set())
|
|
187
|
+
y_coordinates = (set(range(len(y_categories))) if y_categories else set())
|
|
188
|
+
for trace in traces:
|
|
189
|
+
if trace.get("visible") is False:
|
|
190
|
+
continue
|
|
191
|
+
for point in trace.get("data", []):
|
|
192
|
+
if point.get("x") is None or point.get("y") is None:
|
|
193
|
+
continue
|
|
194
|
+
x_coordinates.add(x_categories.index(point["x"]) if x_categories else point["x"])
|
|
195
|
+
y_coordinates.add(y_categories.index(point["y"]) if y_categories else point["y"])
|
|
196
|
+
x_count, y_count = len(x_coordinates), len(y_coordinates)
|
|
197
|
+
output = {"width": python_scene["width"], "height": python_scene["height"], "primitives": []}
|
|
198
|
+
for primitive in python_scene["primitives"]:
|
|
199
|
+
trace_index = primitive["traceIndex"]
|
|
200
|
+
trace_heatmap = traces[trace_index].get("heatmap") or {}
|
|
201
|
+
def nonnegative(value, fallback):
|
|
202
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and value >= 0 else fallback
|
|
203
|
+
gap = nonnegative(trace_heatmap.get("cellGap"), nonnegative(heatmap.get("cellGap"), defaults["cellGap"]))
|
|
204
|
+
gap = min(gap, python_scene["width"] / x_count, python_scene["height"] / y_count)
|
|
205
|
+
native_cells = []
|
|
206
|
+
for item in primitive["heatmapCells"]:
|
|
207
|
+
native_cells.append({
|
|
208
|
+
"value": None if item.get("missing") else item["value"],
|
|
209
|
+
"xValue": item["xValue"], "yValue": item["yValue"],
|
|
210
|
+
"row": item["row"], "column": item["column"],
|
|
211
|
+
"fillColor": item["fillColor"], "missing": item.get("missing", False),
|
|
212
|
+
"borderColor": item.get("borderColor"), "borderWidth": item.get("borderWidth"),
|
|
213
|
+
})
|
|
214
|
+
native = rustCall("heatmap", input={
|
|
215
|
+
"width": python_scene["width"], "height": python_scene["height"],
|
|
216
|
+
"traces": [{
|
|
217
|
+
"cells": native_cells, "xCount": x_count, "yCount": y_count,
|
|
218
|
+
"subplotWidth": python_scene["width"], "subplotHeight": python_scene["height"],
|
|
219
|
+
"cellGap": gap, "color": primitive["color"],
|
|
220
|
+
"lineWidth": primitive["lineWidth"],
|
|
221
|
+
"borderColor": primitive["borderColor"], "borderWidth": primitive["borderWidth"],
|
|
222
|
+
"subplot": primitive["subplot"], "bounds": primitive["bounds"],
|
|
223
|
+
}],
|
|
224
|
+
})
|
|
225
|
+
if not native["primitives"]:
|
|
226
|
+
continue
|
|
227
|
+
rust_primitive = native["primitives"][0]
|
|
228
|
+
for rust_cell, source_cell in zip(rust_primitive["heatmapCells"], primitive["heatmapCells"]):
|
|
229
|
+
for field in ("sourceIndex", "xCategory", "yCategory", "cornerRadius"):
|
|
230
|
+
if field in source_cell:
|
|
231
|
+
rust_cell[field] = source_cell[field]
|
|
232
|
+
# The source Node build and the Python ABI can emit a one-ULP
|
|
233
|
+
# centroid difference under their respective optimized Rust builds.
|
|
234
|
+
# Reconstructing from the Rust cells matches the source scene's
|
|
235
|
+
# serialized centroid and keeps Rust responsible for cell geometry.
|
|
236
|
+
rust_primitive["points"] = [
|
|
237
|
+
{"x": cell["x"] + cell["width"] / 2, "y": cell["y"] + cell["height"] / 2}
|
|
238
|
+
for cell in rust_primitive["heatmapCells"]
|
|
239
|
+
]
|
|
240
|
+
for field in ("heatmapCellLabels", "heatmapGrid", "heatmapColorScaleLegend"):
|
|
241
|
+
if field in primitive:
|
|
242
|
+
rust_primitive[field] = primitive[field]
|
|
243
|
+
rust_primitive["traceIndex"] = trace_index
|
|
244
|
+
output["primitives"].append(rust_primitive)
|
|
245
|
+
return output
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def wonderplot_scene_rust(options: Mapping[str, Any], python_scene: dict) -> dict:
|
|
249
|
+
"""Build marker positions in the source Rust core from Python-prepared values."""
|
|
250
|
+
from .families2d import _traces
|
|
251
|
+
from .error_bars import error_domain_values, resolve_error_bars
|
|
252
|
+
|
|
253
|
+
traces = _traces(options)
|
|
254
|
+
points = [point for trace in traces if trace.get("visible") is not False
|
|
255
|
+
for point in trace.get("data", []) if point.get("x") is not None and point.get("y") is not None]
|
|
256
|
+
if not points:
|
|
257
|
+
return python_scene
|
|
258
|
+
x_axis = ((options.get("axes") or {}).get("xAxes") or {}).get("x") or {}
|
|
259
|
+
y_axis = ((options.get("axes") or {}).get("yAxes") or {}).get("y") or {}
|
|
260
|
+
x_values = [float(point["x"]) for point in points]
|
|
261
|
+
y_values = [float(point["y"]) for point in points]
|
|
262
|
+
for trace in traces:
|
|
263
|
+
if trace.get("visible") is False:
|
|
264
|
+
continue
|
|
265
|
+
resolved = resolve_error_bars(options.get("errorBars"), trace.get("errorBars"), "")
|
|
266
|
+
extra_x, extra_y = error_domain_values(trace, resolved, x_axis, y_axis)
|
|
267
|
+
x_values.extend(extra_x)
|
|
268
|
+
y_values.extend(extra_y)
|
|
269
|
+
x_min, x_max = min(x_values), max(x_values)
|
|
270
|
+
y_min, y_max = min(y_values), max(y_values)
|
|
271
|
+
if x_axis.get("range"):
|
|
272
|
+
x_min, x_max = float(x_axis["range"]["min"]), float(x_axis["range"]["max"])
|
|
273
|
+
if y_axis.get("range"):
|
|
274
|
+
y_min, y_max = float(y_axis["range"]["min"]), float(y_axis["range"]["max"])
|
|
275
|
+
output = {"width": python_scene["width"], "height": python_scene["height"], "primitives": []}
|
|
276
|
+
for primitive in python_scene["primitives"]:
|
|
277
|
+
trace_index = primitive["traceIndex"]
|
|
278
|
+
data = traces[trace_index]["data"]
|
|
279
|
+
rust_points = []
|
|
280
|
+
for marker in primitive["wonderPoints"]:
|
|
281
|
+
original = data[marker["sourceIndex"]]
|
|
282
|
+
item = {
|
|
283
|
+
"x": float(original["x"]), "y": float(original["y"]),
|
|
284
|
+
"value": marker["value"], "sourceIndex": marker["sourceIndex"],
|
|
285
|
+
"size": marker["size"], "opacity": marker["opacity"],
|
|
286
|
+
"fillColor": marker["fillColor"],
|
|
287
|
+
"sizeValue": marker.get("sizeValue"),
|
|
288
|
+
"opacityValue": marker.get("opacityValue"),
|
|
289
|
+
}
|
|
290
|
+
rust_points.append(item)
|
|
291
|
+
native = rustCall("wonderplot", input={
|
|
292
|
+
"width": python_scene["width"], "height": python_scene["height"],
|
|
293
|
+
"traces": [{
|
|
294
|
+
"points": rust_points,
|
|
295
|
+
"subplotWidth": python_scene["width"], "subplotHeight": python_scene["height"],
|
|
296
|
+
"xDomain": {"min": x_min, "max": x_max},
|
|
297
|
+
"yDomain": {"min": y_min, "max": y_max},
|
|
298
|
+
"xScale": x_axis.get("scale", "linear"),
|
|
299
|
+
"yScale": y_axis.get("scale", "linear"),
|
|
300
|
+
"color": primitive["color"], "lineWidth": primitive["lineWidth"],
|
|
301
|
+
"borderColor": primitive["borderColor"], "borderWidth": primitive["borderWidth"],
|
|
302
|
+
"subplot": primitive["subplot"], "bounds": primitive["bounds"],
|
|
303
|
+
}],
|
|
304
|
+
})
|
|
305
|
+
if not native["primitives"]:
|
|
306
|
+
continue
|
|
307
|
+
rust_primitive = native["primitives"][0]
|
|
308
|
+
rust_primitive["traceIndex"] = trace_index
|
|
309
|
+
rust_primitive["markerShape"] = primitive["markerShape"]
|
|
310
|
+
for field in ("wonderplotMarkerLabels", "wonderplotColorScaleLegend", "errorBars", "errorBarStyle"):
|
|
311
|
+
if field in primitive:
|
|
312
|
+
rust_primitive[field] = primitive[field]
|
|
313
|
+
output["primitives"].append(rust_primitive)
|
|
314
|
+
return output
|