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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Source-compatible regular-grid allocation for experimental surface LOD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import floor, log
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_surface_lod(value) -> dict | None:
|
|
12
|
+
if value is ... or value is False:
|
|
13
|
+
return None
|
|
14
|
+
if value is None or value is not True and not isinstance(value, Mapping):
|
|
15
|
+
raise ValueError("surface.lod must be a boolean or an options object")
|
|
16
|
+
source = {} if value is True else value
|
|
17
|
+
enabled = source.get("enabled", True)
|
|
18
|
+
if type(enabled) is not bool:
|
|
19
|
+
raise ValueError("surface.lod.enabled must be a boolean")
|
|
20
|
+
defaults = get_defaults("surface")["lod"]
|
|
21
|
+
strategy = source.get("strategy", defaults["strategy"])
|
|
22
|
+
if strategy != "grid-stride":
|
|
23
|
+
raise ValueError("surface.lod.strategy must be 'grid-stride'")
|
|
24
|
+
max_cells = source.get("maxCells", defaults["maxCells"])
|
|
25
|
+
if type(max_cells) is not int or not 1 <= max_cells <= 1_000_000:
|
|
26
|
+
raise ValueError("surface.lod.maxCells must be an integer between 1 and 1000000")
|
|
27
|
+
return {"strategy": strategy, "maxCells": max_cells} if enabled else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _allocate(grids: list[dict], max_cells: int) -> list[int]:
|
|
31
|
+
allocations = [0] * len(grids)
|
|
32
|
+
active = [grid for grid in grids if grid["allocatable"] > 0]
|
|
33
|
+
candidates = sum(grid["allocatable"] for grid in active)
|
|
34
|
+
if candidates <= max_cells:
|
|
35
|
+
for grid in active:
|
|
36
|
+
allocations[grid["traceIndex"]] = grid["allocatable"]
|
|
37
|
+
return allocations
|
|
38
|
+
if max_cells < len(active):
|
|
39
|
+
for grid in active[:max_cells]:
|
|
40
|
+
allocations[grid["traceIndex"]] = 1
|
|
41
|
+
return allocations
|
|
42
|
+
for grid in active:
|
|
43
|
+
allocations[grid["traceIndex"]] = 1
|
|
44
|
+
remaining = max_cells - len(active)
|
|
45
|
+
available_extras = sum(grid["allocatable"] - 1 for grid in active)
|
|
46
|
+
remainders = []
|
|
47
|
+
for grid in active:
|
|
48
|
+
exact = remaining * (grid["allocatable"] - 1) / available_extras
|
|
49
|
+
allocated = floor(exact)
|
|
50
|
+
allocations[grid["traceIndex"]] += allocated
|
|
51
|
+
remainders.append((-(exact - allocated), grid["traceIndex"]))
|
|
52
|
+
undistributed = max_cells - sum(allocations)
|
|
53
|
+
for _, index in sorted(remainders):
|
|
54
|
+
if undistributed == 0:
|
|
55
|
+
break
|
|
56
|
+
if allocations[index] < grids[index]["allocatable"]:
|
|
57
|
+
allocations[index] += 1
|
|
58
|
+
undistributed -= 1
|
|
59
|
+
return allocations
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _dimensions(x_count: int, y_count: int, max_cells: int) -> tuple[int, int]:
|
|
63
|
+
selected_x = selected_y = selected_cells = 1
|
|
64
|
+
selected_error = float("inf")
|
|
65
|
+
source_aspect = x_count / y_count
|
|
66
|
+
for x in range(1, min(x_count, max_cells) + 1):
|
|
67
|
+
y = min(y_count, max_cells // x)
|
|
68
|
+
if y < 1:
|
|
69
|
+
continue
|
|
70
|
+
cells = x * y
|
|
71
|
+
aspect_error = abs(log((x / y) / source_aspect))
|
|
72
|
+
if cells > selected_cells or cells == selected_cells and (
|
|
73
|
+
aspect_error < selected_error or aspect_error == selected_error and x < selected_x):
|
|
74
|
+
selected_x, selected_y, selected_cells, selected_error = x, y, cells, aspect_error
|
|
75
|
+
return selected_x, selected_y
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def select_surface_lod_grids(parsed: list[list[dict]], max_cells: int) -> dict:
|
|
79
|
+
grids = []
|
|
80
|
+
for index, points in enumerate(parsed):
|
|
81
|
+
xs = sorted({point["x"] for point in points})
|
|
82
|
+
ys = sorted({point["y"] for point in points})
|
|
83
|
+
candidates = max(0, len(xs) - 1) * max(0, len(ys) - 1)
|
|
84
|
+
lookup = {(point["x"], point["y"]): point for point in points}
|
|
85
|
+
renderable = any(all(lookup[(xs[column + dx], ys[row + dy])]["z"] is not None
|
|
86
|
+
for dx, dy in ((0, 0), (1, 0), (0, 1), (1, 1)))
|
|
87
|
+
for row in range(max(0, len(ys) - 1))
|
|
88
|
+
for column in range(max(0, len(xs) - 1)))
|
|
89
|
+
grids.append({"traceIndex": index, "xs": xs, "ys": ys, "candidate": candidates,
|
|
90
|
+
"allocatable": candidates if renderable else 0, "lookup": lookup})
|
|
91
|
+
allocations = _allocate(grids, max_cells)
|
|
92
|
+
selected = []
|
|
93
|
+
for grid, allocation in zip(grids, allocations):
|
|
94
|
+
if not allocation:
|
|
95
|
+
selected.append({"xIndexes": [], "yIndexes": [], "renderable": set()})
|
|
96
|
+
continue
|
|
97
|
+
x_cells, y_cells = _dimensions(len(grid["xs"]) - 1, len(grid["ys"]) - 1, allocation)
|
|
98
|
+
x_indexes = [floor(index * (len(grid["xs"]) - 1) / x_cells + .5) for index in range(x_cells + 1)]
|
|
99
|
+
y_indexes = [floor(index * (len(grid["ys"]) - 1) / y_cells + .5) for index in range(y_cells + 1)]
|
|
100
|
+
renderable = set()
|
|
101
|
+
for row in range(y_cells):
|
|
102
|
+
for column in range(x_cells):
|
|
103
|
+
contains_gap = any(grid["lookup"][(grid["xs"][source_column], grid["ys"][source_row])]["z"] is None
|
|
104
|
+
for source_row in range(y_indexes[row], y_indexes[row + 1] + 1)
|
|
105
|
+
for source_column in range(x_indexes[column], x_indexes[column + 1] + 1))
|
|
106
|
+
if not contains_gap:
|
|
107
|
+
renderable.add((row, column))
|
|
108
|
+
selected.append({"xIndexes": x_indexes, "yIndexes": y_indexes, "renderable": renderable})
|
|
109
|
+
return {"byTrace": selected, "candidateCellCount": sum(grid["candidate"] for grid in grids)}
|
fastplotpy/svg3d.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Static SVG export of Python-built three-dimensional scenes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from math import cos, isfinite, pi, sin, sqrt
|
|
7
|
+
import re
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
from .defaults import get_defaults
|
|
11
|
+
from .exports import buildExportScene
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_TYPES = frozenset({"scatter3d", "line3d", "surface", "mesh3d", "pie3d"})
|
|
15
|
+
_HEX = re.compile(r"^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$", re.I)
|
|
16
|
+
_FUNCTION = re.compile(r"^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([0-9eE+\-.,%/\sdegatrun]*\)$", re.I)
|
|
17
|
+
_COLOR_SPACE = re.compile(r"^color\((?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65)\s+[0-9eE+\-.,%/\s]+\)$", re.I)
|
|
18
|
+
_FONTS = re.compile(r"^[a-zA-Z0-9\s,.'\"/-]+$")
|
|
19
|
+
_NAMED = set("""
|
|
20
|
+
aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown
|
|
21
|
+
burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan
|
|
22
|
+
darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred
|
|
23
|
+
darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue
|
|
24
|
+
dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray
|
|
25
|
+
green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon
|
|
26
|
+
lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon
|
|
27
|
+
lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta
|
|
28
|
+
maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen
|
|
29
|
+
mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive
|
|
30
|
+
olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru
|
|
31
|
+
pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell
|
|
32
|
+
sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato transparent
|
|
33
|
+
turquoise violet wheat white whitesmoke yellow yellowgreen
|
|
34
|
+
""".split())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _xml(value: object) -> str:
|
|
38
|
+
valid = "".join(char if ord(char) in (9, 10, 13) or 32 <= ord(char) <= 0xD7FF or 0xE000 <= ord(char) <= 0xFFFD or 0x10000 <= ord(char) <= 0x10FFFF else "\ufffd" for char in str(value))
|
|
39
|
+
return escape(valid, quote=True).replace("'", "'")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _paint(value: str) -> str:
|
|
43
|
+
value = value.strip()
|
|
44
|
+
if not (_HEX.fullmatch(value) or _FUNCTION.fullmatch(value) or _COLOR_SPACE.fullmatch(value) or value.lower() in _NAMED):
|
|
45
|
+
raise ValueError(f'3D SVG fallback does not support unsafe paint value "{value}"')
|
|
46
|
+
return _xml(value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _font(value: str) -> str:
|
|
50
|
+
value = value.strip()
|
|
51
|
+
if not value or not _FONTS.fullmatch(value):
|
|
52
|
+
raise ValueError(f'3D SVG fallback does not support unsafe font value "{value}"')
|
|
53
|
+
return _xml(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _n(value: float) -> str:
|
|
57
|
+
if not isfinite(value):
|
|
58
|
+
raise ValueError("3D SVG fallback coordinates must remain finite")
|
|
59
|
+
decimals = get_defaults("scene3d")["staticFallbackDecimals"]
|
|
60
|
+
rounded = round(value, decimals)
|
|
61
|
+
return str(int(rounded)) if rounded == int(rounded) else f"{rounded:.{decimals}f}".rstrip("0").rstrip(".")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _opacity(value: float) -> str:
|
|
65
|
+
return _n(max(0, min(1, value)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _line(x1, y1, x2, y2, color, width, alpha=1, attrs=""):
|
|
69
|
+
return f'<line x1="{_n(x1)}" y1="{_n(y1)}" x2="{_n(x2)}" y2="{_n(y2)}" stroke="{_paint(color)}" stroke-width="{_n(width)}" stroke-opacity="{_opacity(alpha)}"{attrs}/>'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _polygon(points, color, alpha, attrs=""):
|
|
73
|
+
coordinates = " ".join(f'{_n(point["x"])},{_n(point["y"])}' for point in points)
|
|
74
|
+
return f'<polygon points="{coordinates}" fill="{_paint(color)}" fill-opacity="{_opacity(alpha)}"{attrs}/>'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _marker(x, y, radius, shape, color, alpha, attrs):
|
|
78
|
+
if shape == "circle":
|
|
79
|
+
return f'<circle cx="{_n(x)}" cy="{_n(y)}" r="{_n(radius)}" fill="{_paint(color)}" fill-opacity="{_opacity(alpha)}"{attrs}/>'
|
|
80
|
+
if shape == "square":
|
|
81
|
+
half = radius / sqrt(2)
|
|
82
|
+
return f'<rect x="{_n(x-half)}" y="{_n(y-half)}" width="{_n(2*half)}" height="{_n(2*half)}" fill="{_paint(color)}" fill-opacity="{_opacity(alpha)}"{attrs}/>'
|
|
83
|
+
sides = {"diamond": 4, "triangle": 3, "pentagon": 5}.get(shape, 6)
|
|
84
|
+
points = [{"x": x + cos(-pi/2 + i*2*pi/sides)*radius,
|
|
85
|
+
"y": y + sin(-pi/2 + i*2*pi/sides)*radius} for i in range(sides)]
|
|
86
|
+
return _polygon(points, color, alpha, attrs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hash(scene: dict, label: str, description: str) -> str:
|
|
90
|
+
from .js_serialize import js_json_stringify
|
|
91
|
+
source = js_json_stringify(scene) + "\0" + label + "\0" + description
|
|
92
|
+
value = 0x811C9DC5
|
|
93
|
+
units = source.encode("utf-16-le", errors="surrogatepass")
|
|
94
|
+
for index in range(0, len(units), 2):
|
|
95
|
+
code_unit = units[index] | (units[index + 1] << 8)
|
|
96
|
+
value = ((value ^ code_unit) * 0x01000193) & 0xFFFFFFFF
|
|
97
|
+
return f"scene3d-{value:08x}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _text(x, y, color, font, content, extra=' text-anchor="middle" dominant-baseline="middle"'):
|
|
101
|
+
return f'<text x="{_n(x)}" y="{_n(y)}" fill="{_paint(color)}" style="font:{_font(font)}"{extra}>{_xml(content)}</text>'
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def renderScene3dToSVG(options: Mapping) -> str:
|
|
105
|
+
"""Render a Python scene to an accessible, self-contained static SVG."""
|
|
106
|
+
if options.get("type") not in _TYPES:
|
|
107
|
+
raise ValueError("renderScene3dToSVG supports only experimental scatter3d, line3d, surface, mesh3d, and pie3d charts")
|
|
108
|
+
scene = buildExportScene(options)
|
|
109
|
+
metadata_items = scene.get("scenes3d") or ([scene["scene3d"]] if scene.get("scene3d") else [])
|
|
110
|
+
defaults = get_defaults("scene3d")
|
|
111
|
+
labels = list(dict.fromkeys(str(metadata.get("accessibility", {}).get("label") or "").strip() for metadata in metadata_items))
|
|
112
|
+
descriptions = list(dict.fromkeys(str(metadata.get("accessibility", {}).get("description") or "").strip() for metadata in metadata_items))
|
|
113
|
+
label = "; ".join(item for item in labels if item) or str(options.get("title") or "").strip() or {
|
|
114
|
+
"scatter3d": defaults["accessibilityScatter3dLabel"], "line3d": defaults["accessibilityLine3dLabel"],
|
|
115
|
+
"surface": defaults["accessibilitySurfaceLabel"], "pie3d": "Pie3D chart",
|
|
116
|
+
"mesh3d": defaults["accessibilityMesh3dLabel"]}[options["type"]]
|
|
117
|
+
description = " ".join(item for item in descriptions if item) or str(options.get("subtitle") or "").strip() or defaults["staticFallbackDescription"]
|
|
118
|
+
prefix = _hash(scene, label, description)
|
|
119
|
+
definitions, body = [], []
|
|
120
|
+
gradients = {}
|
|
121
|
+
for i, primitive in enumerate(scene["primitives"]):
|
|
122
|
+
legend = primitive.get("scene3dColorScaleLegend") or primitive.get("surface3dColorScaleLegend")
|
|
123
|
+
if legend:
|
|
124
|
+
identifier = f"{prefix}-gradient-{i}"
|
|
125
|
+
gradients[i] = identifier
|
|
126
|
+
stops = "".join(f'<stop offset="{_n(stop["offset"])}" stop-color="{_paint(stop["color"])}"/>' for stop in legend["colorStops"])
|
|
127
|
+
definitions.append(f'<linearGradient id="{identifier}" x1="0" y1="1" x2="0" y2="0">{stops}</linearGradient>')
|
|
128
|
+
for i, metadata in enumerate(metadata_items):
|
|
129
|
+
bounds = metadata["bounds"]
|
|
130
|
+
definitions.append(f'<clipPath id="{prefix}-clip-{i}"><rect x="{_n(bounds["left"])}" y="{_n(bounds["top"])}" width="{_n(bounds["right"]-bounds["left"])}" height="{_n(bounds["bottom"]-bounds["top"])}"/></clipPath>')
|
|
131
|
+
style = options.get("style") or {}
|
|
132
|
+
background = options.get("backgroundColor") or style.get("backgroundColor")
|
|
133
|
+
if background:
|
|
134
|
+
body.append(f'<rect width="{_n(scene["width"])}" height="{_n(scene["height"])}" fill="{_paint(background)}"/>')
|
|
135
|
+
plot_background = options.get("plotAreaBackgroundColor") or (style.get("plotArea") or {}).get("backgroundColor")
|
|
136
|
+
if plot_background:
|
|
137
|
+
for metadata in metadata_items:
|
|
138
|
+
bounds = metadata["bounds"]
|
|
139
|
+
body.append(f'<rect x="{_n(bounds["left"])}" y="{_n(bounds["top"])}" width="{_n(bounds["right"]-bounds["left"])}" height="{_n(bounds["bottom"]-bounds["top"])}" fill="{_paint(plot_background)}"/>')
|
|
140
|
+
title_y = get_defaults("layout")["decorations"]["topPadding"]
|
|
141
|
+
for kind in ("title", "subtitle"):
|
|
142
|
+
value = options.get(kind)
|
|
143
|
+
if not isinstance(value, str) or not value.strip():
|
|
144
|
+
continue
|
|
145
|
+
if kind == "subtitle" and isinstance(options.get("title"), str) and options["title"].strip():
|
|
146
|
+
title_y += get_defaults("layout")["decorations"]["subtitleGap"]
|
|
147
|
+
text_defaults = get_defaults("layout")["decorations"]
|
|
148
|
+
style_options = {**(style.get("text") or {}), **(style.get(kind) or {}), **(options.get(kind + "Style") or {})}
|
|
149
|
+
color = style_options.get("color", text_defaults[kind + "Color"])
|
|
150
|
+
font = style_options.get("font", text_defaults[kind + "Font"])
|
|
151
|
+
body.append(_text(scene["width"]/2, title_y, color, font, value.strip(), ' text-anchor="middle" dominant-baseline="hanging"'))
|
|
152
|
+
title_y += style_options.get("lineHeight", text_defaults[kind + "LineHeight"])
|
|
153
|
+
for index, metadata in enumerate(metadata_items):
|
|
154
|
+
bounds = metadata["bounds"]
|
|
155
|
+
ox, oy = bounds["left"], bounds["top"]
|
|
156
|
+
parts = []
|
|
157
|
+
for kind in ("grid", "axis", "tick"):
|
|
158
|
+
segments = [segment for segment in metadata.get("segments", []) if segment["kind"] == kind]
|
|
159
|
+
if kind == "grid":
|
|
160
|
+
segments.sort(key=lambda item: -max(item["depth1"], item["depth2"]))
|
|
161
|
+
for segment in segments:
|
|
162
|
+
color = metadata["gridColor"] if kind == "grid" else metadata["axisColor"]
|
|
163
|
+
width = metadata["gridLineWidth"] if kind == "grid" else metadata["axisLineWidth"]
|
|
164
|
+
if kind == "tick":
|
|
165
|
+
width = max(defaults["axisTickMinLineWidth"], width * defaults["axisTickLineWidthScale"])
|
|
166
|
+
parts.append(_line(ox+segment["x1"], oy+segment["y1"], ox+segment["x2"], oy+segment["y2"], color, width))
|
|
167
|
+
entries = []
|
|
168
|
+
for primitive in scene["primitives"]:
|
|
169
|
+
if primitive.get("subplot") != metadata["subplotId"]:
|
|
170
|
+
continue
|
|
171
|
+
trace = primitive.get("traceIndex", 0)
|
|
172
|
+
for triangle in primitive.get("surface3dTriangles") or primitive.get("mesh3dTriangles") or []:
|
|
173
|
+
if triangle.get("pie3d") and triangle.get("opacity") == 1 and triangle.get("frontFacing") is False:
|
|
174
|
+
continue
|
|
175
|
+
source = (triangle.get("sourceCell") or triangle.get("sourceFace") or {})["index"]
|
|
176
|
+
entries.append((triangle["depth"], trace, source, "triangle", triangle))
|
|
177
|
+
for contour in primitive.get("surface3dContourSegments") or []:
|
|
178
|
+
source = contour["sourceCell"]["index"]
|
|
179
|
+
entries.append(((contour["depth1"]+contour["depth2"])/2, trace, source, "contour", contour))
|
|
180
|
+
entries.sort(key=lambda item: (-item[0], item[1], item[2], 0 if item[3] == "triangle" else 1))
|
|
181
|
+
for _, trace, source, kind, item in entries:
|
|
182
|
+
attrs = f' data-trace-index="{trace}" data-source-index="{source}"'
|
|
183
|
+
if kind == "contour":
|
|
184
|
+
parts.append(_line(ox+item["x1"], oy+item["y1"], ox+item["x2"], oy+item["y2"], item["style"]["color"], item["style"]["width"], item["style"]["opacity"], attrs))
|
|
185
|
+
else:
|
|
186
|
+
points = [{"x": ox+vertex["x"], "y": oy+vertex["y"]} for vertex in item["vertices"]]
|
|
187
|
+
pie = item.get("pie3d")
|
|
188
|
+
if pie:
|
|
189
|
+
attrs += f' data-pie3d-face="{_xml(pie["face"])}"'
|
|
190
|
+
if item["opacity"] == 1:
|
|
191
|
+
attrs += f' stroke="{_paint(item["fillColor"])}" stroke-width="1"'
|
|
192
|
+
parts.append(_polygon(points, item["fillColor"], item["opacity"], attrs))
|
|
193
|
+
for edge_index, visible in enumerate(item.get("wireframeEdges") or []):
|
|
194
|
+
if visible and item.get("wireframe"):
|
|
195
|
+
left, right = points[edge_index], points[(edge_index+1) % len(points)]
|
|
196
|
+
wire = item["wireframe"]
|
|
197
|
+
parts.append(_line(left["x"], left["y"], right["x"], right["y"], wire["color"], wire["width"], wire["opacity"], attrs))
|
|
198
|
+
line_entries, points = [], []
|
|
199
|
+
for primitive in scene["primitives"]:
|
|
200
|
+
if primitive.get("subplot") != metadata["subplotId"]:
|
|
201
|
+
continue
|
|
202
|
+
trace = primitive.get("traceIndex", 0)
|
|
203
|
+
for segment in primitive.get("line3dSegments") or []:
|
|
204
|
+
line_entries.append((segment, primitive, trace))
|
|
205
|
+
for point in primitive.get("scatter3dPoints") or []:
|
|
206
|
+
points.append((point, trace))
|
|
207
|
+
line_entries.sort(key=lambda entry: (-max(entry[0]["depth1"], entry[0]["depth2"]), entry[2], entry[0]["sourceIndex1"], entry[0].get("sourceRatio1", 0)))
|
|
208
|
+
for segment, primitive, trace in line_entries:
|
|
209
|
+
attrs = f' data-trace-index="{trace}" data-source-index="{segment["sourceIndex1"]}"'
|
|
210
|
+
parts.append(_line(ox+segment["x1"], oy+segment["y1"], ox+segment["x2"], oy+segment["y2"], primitive["color"], primitive["lineWidth"], segment["opacity"], attrs))
|
|
211
|
+
points.sort(key=lambda item: (-item[0]["depth"], item[1], item[0]["sourceIndex"]))
|
|
212
|
+
for point, trace in points:
|
|
213
|
+
attrs = f' data-trace-index="{trace}" data-source-index="{point["sourceIndex"]}"'
|
|
214
|
+
parts.append(_marker(ox+point["x"], oy+point["y"], point["radius"], point["markerShape"], point["fillColor"], point["opacity"], attrs))
|
|
215
|
+
for tick in metadata.get("tickLabels", []):
|
|
216
|
+
parts.append(_text(ox+tick["x"], oy+tick["y"], metadata["axisColor"], metadata["tickLabelFont"], tick["text"]))
|
|
217
|
+
for axis in metadata.get("labels", []):
|
|
218
|
+
parts.append(_text(ox+axis["x"], oy+axis["y"]-defaults["axisLabelOffset"], metadata["axisColor"], defaults["axisLabelFont"], axis["text"]))
|
|
219
|
+
for primitive in scene["primitives"]:
|
|
220
|
+
if primitive.get("subplot") != metadata["subplotId"]:
|
|
221
|
+
continue
|
|
222
|
+
for point in primitive.get("pie3dLabels") or []:
|
|
223
|
+
parts.append(_text(ox+point["x"], oy+point["y"], point["color"], point["font"], point["text"]))
|
|
224
|
+
legend = primitive.get("scene3dColorScaleLegend") or primitive.get("surface3dColorScaleLegend")
|
|
225
|
+
identifier = gradients.get(scene["primitives"].index(primitive))
|
|
226
|
+
if legend and identifier:
|
|
227
|
+
parts.append(f'<rect x="{_n(ox+legend["x"])}" y="{_n(oy+legend["y"])}" width="{_n(legend["width"])}" height="{_n(legend["height"])}" fill="url(#{identifier})"/>')
|
|
228
|
+
if legend.get("labelsVisible"):
|
|
229
|
+
parts.append(_text(ox+legend["x"], oy+max(0, legend["y"]-7), legend["color"], legend["font"], legend["title"], ""))
|
|
230
|
+
for tick in legend["ticks"]:
|
|
231
|
+
y = oy+legend["y"]+legend["height"]*(1-tick["y"])
|
|
232
|
+
parts.append(_text(ox+legend["x"]+legend["width"]+legend["labelGap"], y, legend["color"], legend["font"], tick["text"], ' dominant-baseline="middle"'))
|
|
233
|
+
scene_label = (metadata.get("accessibility") or {}).get("label") or label
|
|
234
|
+
body.append(f'<g clip-path="url(#{prefix}-clip-{index})" role="group" aria-label="{_xml(scene_label)}" data-subplot="{_xml(metadata["subplotId"])}">{"".join(parts)}</g>')
|
|
235
|
+
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{_n(scene["width"])}" height="{_n(scene["height"])}" viewBox="0 0 {_n(scene["width"])} {_n(scene["height"])}" role="img" aria-labelledby="{prefix}-title {prefix}-description"><title id="{prefix}-title">{_xml(label)}</title><desc id="{prefix}-description">{_xml(description)}</desc><defs>{"".join(definitions)}</defs>{"".join(body)}</svg>'
|
fastplotpy/trendline.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Python port of fastPlotJs line trend fitting and sampling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import exp, isfinite, log, sqrt
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from .defaults import get_defaults
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_METHODS = {"linear", "polynomial", "exponential", "logarithmic", "power", "gaussian", "cauchyLorentz"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_trendline(plot_value, trace_value):
|
|
15
|
+
defaults = get_defaults("trace-options")["trendline"]
|
|
16
|
+
source = trace_value if trace_value is not None else plot_value
|
|
17
|
+
if source is None or isinstance(source, bool):
|
|
18
|
+
return {"show": defaults["show"] if source is None else source,
|
|
19
|
+
"method": defaults["method"], "degree": defaults["degree"], "samples": defaults["samples"]}
|
|
20
|
+
source = source if isinstance(source, Mapping) else {}
|
|
21
|
+
def bounded_integer(key):
|
|
22
|
+
value = source.get(key)
|
|
23
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
24
|
+
value = defaults[key]
|
|
25
|
+
return max(defaults["min" + key.capitalize()], min(defaults["max" + key.capitalize()], value))
|
|
26
|
+
result = {"show": source.get("show", True),
|
|
27
|
+
"method": source.get("method") if source.get("method") in _METHODS else defaults["method"],
|
|
28
|
+
"degree": bounded_integer("degree"), "samples": bounded_integer("samples")}
|
|
29
|
+
if "color" in source:
|
|
30
|
+
result["color"] = source["color"]
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _solve(matrix, values):
|
|
35
|
+
size = len(values)
|
|
36
|
+
augmented = [row[:] + [value] for row, value in zip(matrix, values)]
|
|
37
|
+
for column in range(size):
|
|
38
|
+
pivot_row = max(range(column, size), key=lambda row: abs(augmented[row][column]))
|
|
39
|
+
if abs(augmented[pivot_row][column]) <= 1e-12: # Source singularity tolerance.
|
|
40
|
+
return None
|
|
41
|
+
augmented[column], augmented[pivot_row] = augmented[pivot_row], augmented[column]
|
|
42
|
+
pivot = augmented[column][column]
|
|
43
|
+
for index in range(column, size + 1):
|
|
44
|
+
augmented[column][index] /= pivot
|
|
45
|
+
for row in range(size):
|
|
46
|
+
if row == column:
|
|
47
|
+
continue
|
|
48
|
+
factor = augmented[row][column]
|
|
49
|
+
if factor == 0:
|
|
50
|
+
continue
|
|
51
|
+
for index in range(column, size + 1):
|
|
52
|
+
augmented[row][index] -= factor * augmented[column][index]
|
|
53
|
+
return [row[size] for row in augmented]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _least_squares(rows, values):
|
|
57
|
+
if not rows or len(rows) != len(values):
|
|
58
|
+
return None
|
|
59
|
+
size = len(rows[0])
|
|
60
|
+
matrix = [[0.0] * size for _ in range(size)]
|
|
61
|
+
vector = [0.0] * size
|
|
62
|
+
for row, value in zip(rows, values):
|
|
63
|
+
for i in range(size):
|
|
64
|
+
vector[i] += row[i] * value
|
|
65
|
+
for j in range(size):
|
|
66
|
+
matrix[i][j] += row[i] * row[j]
|
|
67
|
+
return _solve(matrix, vector)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _polynomial(points, degree):
|
|
71
|
+
minimum, maximum = min(point["x"] for point in points), max(point["x"] for point in points)
|
|
72
|
+
degree = min(degree, len(points) - 1)
|
|
73
|
+
center, span = (minimum + maximum) / 2, maximum - minimum
|
|
74
|
+
rows = []
|
|
75
|
+
for point in points:
|
|
76
|
+
x = (point["x"] - center) / span * 2
|
|
77
|
+
row = [1.0]
|
|
78
|
+
for _ in range(degree):
|
|
79
|
+
row.append(row[-1] * x)
|
|
80
|
+
rows.append(row)
|
|
81
|
+
coefficients = _least_squares(rows, [point["y"] for point in points])
|
|
82
|
+
if coefficients is None:
|
|
83
|
+
return None
|
|
84
|
+
def evaluate(x):
|
|
85
|
+
scaled = (x - center) / span * 2
|
|
86
|
+
value = 0.0
|
|
87
|
+
for coefficient in reversed(coefficients):
|
|
88
|
+
value = value * scaled + coefficient
|
|
89
|
+
return value if isfinite(value) else None
|
|
90
|
+
return evaluate, degree
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _transformed(points, x_transform, y_transform, inverse):
|
|
94
|
+
rows, values = [], []
|
|
95
|
+
for point in points:
|
|
96
|
+
x, y = x_transform(point["x"]), y_transform(point["y"])
|
|
97
|
+
if x is None or y is None or not isfinite(x) or not isfinite(y):
|
|
98
|
+
continue
|
|
99
|
+
rows.append([1, x])
|
|
100
|
+
values.append(y)
|
|
101
|
+
if len(rows) < 2:
|
|
102
|
+
return None
|
|
103
|
+
coefficients = _least_squares(rows, values)
|
|
104
|
+
if coefficients is None:
|
|
105
|
+
return None
|
|
106
|
+
def evaluate(x):
|
|
107
|
+
transformed = x_transform(x)
|
|
108
|
+
if transformed is None or not isfinite(transformed):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
value = inverse(coefficients[0] + coefficients[1] * transformed)
|
|
112
|
+
except OverflowError:
|
|
113
|
+
return None
|
|
114
|
+
return value if isfinite(value) else None
|
|
115
|
+
return evaluate, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _peak(points, method):
|
|
119
|
+
xs = [point["x"] for point in points]
|
|
120
|
+
ys = [point["y"] for point in points]
|
|
121
|
+
if len(points) < 3 or min(ys) == max(ys):
|
|
122
|
+
return None
|
|
123
|
+
mean_y = sum(ys) / len(ys)
|
|
124
|
+
peak_mode = max(ys) - mean_y >= mean_y - min(ys)
|
|
125
|
+
baseline = min(ys) if peak_mode else max(ys)
|
|
126
|
+
extreme = max(points, key=lambda point: point["y"]) if peak_mode else min(points, key=lambda point: point["y"])
|
|
127
|
+
amplitude = extreme["y"] - baseline
|
|
128
|
+
span = max(xs) - min(xs)
|
|
129
|
+
if abs(amplitude) <= 2.220446049250313e-16:
|
|
130
|
+
return None
|
|
131
|
+
weights = [max(0, (point["y"] - baseline) / amplitude) for point in points]
|
|
132
|
+
weight_sum = sum(weights)
|
|
133
|
+
center = sum(point["x"] * weight for point, weight in zip(points, weights)) / weight_sum if weight_sum else extreme["x"]
|
|
134
|
+
variance = sum((point["x"] - center) ** 2 * weight for point, weight in zip(points, weights)) / weight_sum if weight_sum else (span / 6) ** 2
|
|
135
|
+
width = sqrt(variance) if variance > 0 else span / 6
|
|
136
|
+
if method == "cauchyLorentz":
|
|
137
|
+
crossings = []
|
|
138
|
+
sorted_points = sorted(points, key=lambda point: point["x"])
|
|
139
|
+
for a, b in zip(sorted_points, sorted_points[1:]):
|
|
140
|
+
av = (a["y"] - baseline) / amplitude - .5
|
|
141
|
+
bv = (b["y"] - baseline) / amplitude - .5
|
|
142
|
+
if av == 0:
|
|
143
|
+
crossings.append(a["x"])
|
|
144
|
+
elif av * bv <= 0 and av != bv:
|
|
145
|
+
crossings.append(a["x"] + (b["x"] - a["x"]) * av / (av - bv))
|
|
146
|
+
left = min((center - x for x in crossings if x < center), default=None)
|
|
147
|
+
right = min((x - center for x in crossings if x > center), default=None)
|
|
148
|
+
if left is not None and right is not None:
|
|
149
|
+
width = (left + right) / 2
|
|
150
|
+
elif left is not None or right is not None:
|
|
151
|
+
width = left if left is not None else right
|
|
152
|
+
width = max(2.220446049250313e-16, span / 1_000_000, width)
|
|
153
|
+
def evaluate(x):
|
|
154
|
+
z = (x - center) / width
|
|
155
|
+
value = baseline + amplitude * exp(-.5 * z * z) if method == "gaussian" else baseline + amplitude / (1 + z * z)
|
|
156
|
+
return value if isfinite(value) else None
|
|
157
|
+
return evaluate, None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def sample_trendline(points, resolved):
|
|
161
|
+
points = [point for point in points if point is not None and isfinite(point["x"]) and isfinite(point["y"])]
|
|
162
|
+
if not resolved["show"] or len(points) < 2:
|
|
163
|
+
return None
|
|
164
|
+
minimum, maximum = min(point["x"] for point in points), max(point["x"] for point in points)
|
|
165
|
+
if not minimum < maximum:
|
|
166
|
+
return None
|
|
167
|
+
method = resolved["method"]
|
|
168
|
+
if method in ("linear", "polynomial"):
|
|
169
|
+
model = _polynomial(points, 1 if method == "linear" else resolved["degree"])
|
|
170
|
+
elif method == "exponential":
|
|
171
|
+
center, span = (minimum + maximum) / 2, maximum - minimum
|
|
172
|
+
model = _transformed(points, lambda x: (x - center) / span * 2,
|
|
173
|
+
lambda y: log(y) if y > 0 else None, exp)
|
|
174
|
+
elif method == "logarithmic":
|
|
175
|
+
model = _transformed(points, lambda x: log(x) if x > 0 else None,
|
|
176
|
+
lambda y: y, lambda value: value)
|
|
177
|
+
elif method == "power":
|
|
178
|
+
model = _transformed(points, lambda x: log(x) if x > 0 else None,
|
|
179
|
+
lambda y: log(y) if y > 0 else None, exp)
|
|
180
|
+
else:
|
|
181
|
+
model = _peak(points, method)
|
|
182
|
+
if model is None:
|
|
183
|
+
return None
|
|
184
|
+
evaluate, degree = model
|
|
185
|
+
samples = get_defaults("trace-options")["trendline"]["minSamples"] if method == "linear" else resolved["samples"]
|
|
186
|
+
result = []
|
|
187
|
+
for index in range(samples):
|
|
188
|
+
x = minimum + (maximum - minimum) * index / (samples - 1)
|
|
189
|
+
y = evaluate(x)
|
|
190
|
+
if y is not None and isfinite(y):
|
|
191
|
+
result.append({"x": x, "y": y})
|
|
192
|
+
return (result, degree) if len(result) >= 2 else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def clip_polyline(points, width, height):
|
|
196
|
+
"""Liang-Barsky clipping with the source continuity threshold."""
|
|
197
|
+
segments, current = [], []
|
|
198
|
+
for a, b in zip(points, points[1:]):
|
|
199
|
+
dx, dy = b["x"] - a["x"], b["y"] - a["y"]
|
|
200
|
+
start, end = 0.0, 1.0
|
|
201
|
+
clipped = True
|
|
202
|
+
for p, q in ((-dx, a["x"]), (dx, width - a["x"]), (-dy, a["y"]), (dy, height - a["y"])):
|
|
203
|
+
if p == 0:
|
|
204
|
+
if q < 0:
|
|
205
|
+
clipped = False
|
|
206
|
+
break
|
|
207
|
+
continue
|
|
208
|
+
ratio = q / p
|
|
209
|
+
if p < 0:
|
|
210
|
+
if ratio > end:
|
|
211
|
+
clipped = False
|
|
212
|
+
break
|
|
213
|
+
start = max(start, ratio)
|
|
214
|
+
else:
|
|
215
|
+
if ratio < start:
|
|
216
|
+
clipped = False
|
|
217
|
+
break
|
|
218
|
+
end = min(end, ratio)
|
|
219
|
+
if not clipped:
|
|
220
|
+
if len(current) > 1:
|
|
221
|
+
segments.append(current)
|
|
222
|
+
current = []
|
|
223
|
+
continue
|
|
224
|
+
left = {"x": a["x"] + start * dx, "y": a["y"] + start * dy}
|
|
225
|
+
right = {"x": a["x"] + end * dx, "y": a["y"] + end * dy}
|
|
226
|
+
if not current:
|
|
227
|
+
current = [left, right]
|
|
228
|
+
elif abs(current[-1]["x"] - left["x"]) < 1e-9 and abs(current[-1]["y"] - left["y"]) < 1e-9:
|
|
229
|
+
current.append(right)
|
|
230
|
+
else:
|
|
231
|
+
if len(current) > 1:
|
|
232
|
+
segments.append(current)
|
|
233
|
+
current = [left, right]
|
|
234
|
+
if len(current) > 1:
|
|
235
|
+
segments.append(current)
|
|
236
|
+
return segments
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Source-named triangle wireframe resolution for surface and mesh3d."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from re import fullmatch
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _color(value, name):
|
|
11
|
+
if not isinstance(value, str):
|
|
12
|
+
raise ValueError(f"{name} must be a hexadecimal color string")
|
|
13
|
+
if fullmatch(r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})", value.strip()) is None:
|
|
14
|
+
raise ValueError(f"{name} must use #rgb or #rrggbb hexadecimal syntax")
|
|
15
|
+
return value.strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate(value, name):
|
|
19
|
+
if value is ... or type(value) is bool:
|
|
20
|
+
return None
|
|
21
|
+
if not isinstance(value, Mapping):
|
|
22
|
+
raise ValueError(f"{name} must be boolean or an object")
|
|
23
|
+
if "show" in value and type(value["show"]) is not bool:
|
|
24
|
+
raise ValueError(f"{name}.show must be boolean")
|
|
25
|
+
if "color" in value:
|
|
26
|
+
_color(value["color"], f"{name}.color")
|
|
27
|
+
if "width" in value and (type(value["width"]) not in (int, float)
|
|
28
|
+
or not isfinite(value["width"]) or value["width"] <= 0):
|
|
29
|
+
raise ValueError(f"{name}.width must be a positive finite number")
|
|
30
|
+
if "opacity" in value and (type(value["opacity"]) not in (int, float)
|
|
31
|
+
or not isfinite(value["opacity"]) or not 0 <= value["opacity"] <= 1):
|
|
32
|
+
raise ValueError(f"{name}.opacity must be a finite number between 0 and 1")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_triangle_wireframe(plot_value, trace_value, defaults, label):
|
|
37
|
+
plot_options = _validate(plot_value, label)
|
|
38
|
+
trace_options = _validate(trace_value, f"trace.{label}")
|
|
39
|
+
selected = trace_value if trace_value is not ... else plot_value
|
|
40
|
+
if selected is ...:
|
|
41
|
+
show = defaults["wireframeShow"]
|
|
42
|
+
elif type(selected) is bool:
|
|
43
|
+
show = selected
|
|
44
|
+
else:
|
|
45
|
+
show = selected.get("show", True)
|
|
46
|
+
if not show:
|
|
47
|
+
return None
|
|
48
|
+
color = (trace_options or {}).get("color", (plot_options or {}).get("color", defaults["wireframeColor"]))
|
|
49
|
+
width = (trace_options or {}).get("width", (plot_options or {}).get("width", defaults["wireframeWidth"]))
|
|
50
|
+
opacity = (trace_options or {}).get("opacity", (plot_options or {}).get("opacity", defaults["wireframeOpacity"]))
|
|
51
|
+
return {"color": _color(color, f"{label}.color"), "width": width, "opacity": opacity}
|