fastplotpy 0.3.0a1__py3-none-win_amd64.whl

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