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,146 @@
1
+ """Source-compatible heatmap cell labels and fill contrast."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from decimal import Decimal, ROUND_HALF_UP
7
+ from math import floor, isfinite
8
+ from typing import Mapping
9
+
10
+ from .defaults import get_defaults
11
+
12
+
13
+ def _number(value, fallback, positive=False):
14
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value):
15
+ return fallback
16
+ if value <= 0 if positive else value < 0:
17
+ return fallback
18
+ return value
19
+
20
+
21
+ def resolve_labels(plot_input, trace_input):
22
+ defaults = get_defaults("heatmap")["cellLabels"]
23
+ result = dict(defaults)
24
+ for source in (plot_input, trace_input):
25
+ if source is None:
26
+ continue
27
+ if isinstance(source, bool):
28
+ result["show"] = source
29
+ continue
30
+ if not isinstance(source, Mapping):
31
+ continue
32
+ result["show"] = source.get("show", True)
33
+ color = source.get("color")
34
+ if isinstance(color, str) and color.strip():
35
+ result["color"] = color
36
+ result["autoContrast"] = False
37
+ for key in ("font", "prefix", "suffix"):
38
+ if isinstance(source.get(key), str):
39
+ result[key] = source[key]
40
+ for key in ("lineHeight", "padding", "minCellWidth", "minCellHeight"):
41
+ if key in source:
42
+ result[key] = _number(source[key], result[key], key == "lineHeight")
43
+ if "decimals" in source:
44
+ value = source["decimals"]
45
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value):
46
+ result["decimals"] = max(0, min(6, floor(value + .5)))
47
+ return result
48
+
49
+
50
+ def _parse_color(value):
51
+ if not isinstance(value, str):
52
+ return None
53
+ value = value.strip().lower()
54
+ match = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", value)
55
+ if match:
56
+ digits = match.group(1)
57
+ if len(digits) == 3:
58
+ digits = "".join(digit * 2 for digit in digits)
59
+ return tuple(int(digits[index:index + 2], 16) for index in (0, 2, 4))
60
+ match = re.fullmatch(r"rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*[\d.]+)?\s*\)", value)
61
+ if match:
62
+ return tuple(max(0, min(255, float(item))) for item in match.groups()[:3])
63
+ return None
64
+
65
+
66
+ def _contrast(foreground, background):
67
+ first, second = _parse_color(foreground), _parse_color(background)
68
+ if first is None or second is None:
69
+ return None
70
+ def luminance(color):
71
+ channels = []
72
+ for value in color:
73
+ normalized = value / 255
74
+ channels.append(normalized / 12.92 if normalized <= .04045 else ((normalized + .055) / 1.055) ** 2.4)
75
+ return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]
76
+ a, b = luminance(first), luminance(second)
77
+ return (max(a, b) + .05) / (min(a, b) + .05)
78
+
79
+
80
+ def _label_color(labels, fill):
81
+ color = labels["color"]
82
+ if not labels["autoContrast"]:
83
+ return color
84
+ defaults = get_defaults("heatmap")["cellLabels"]
85
+ contrast = _contrast(color, fill)
86
+ if contrast is None or contrast >= defaults["minimumContrastRatio"]:
87
+ return color
88
+ for candidate in (defaults["lightTextColor"], defaults["darkTextColor"]):
89
+ if (_contrast(candidate, fill) or 0) > (_contrast(color, fill) or 0):
90
+ color = candidate
91
+ if (_contrast(color, fill) or 0) >= defaults["minimumContrastRatio"]:
92
+ return color
93
+ for candidate in ("#ffffff", "#000000"):
94
+ if (_contrast(candidate, fill) or 0) > (_contrast(color, fill) or 0):
95
+ color = candidate
96
+ return color
97
+
98
+
99
+ def _fixed(value, decimals):
100
+ if decimals <= 0:
101
+ return str(floor(value + .5))
102
+ quantum = Decimal(1).scaleb(-decimals)
103
+ return format(Decimal(str(value)).quantize(quantum, rounding=ROUND_HALF_UP), f".{decimals}f")
104
+
105
+
106
+ def _compact(value, prefix, suffix):
107
+ defaults = get_defaults("heatmap")["cellLabels"]
108
+ decimals = defaults["compactDecimals"]
109
+ for threshold, symbol in ((1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "k")):
110
+ if abs(value) >= threshold:
111
+ label = _fixed(value / threshold, decimals).rstrip("0").rstrip(".")
112
+ return f"{prefix}{label}{symbol}{suffix}"
113
+ if 0 < abs(value) < .001:
114
+ mantissa, exponent = f"{value:.{decimals}e}".split("e")
115
+ return f"{prefix}{mantissa}e{int(exponent):+d}{suffix}"
116
+ return f"{prefix}{_fixed(value, decimals)}{suffix}"
117
+
118
+
119
+ def build_cell_labels(cells, labels, border_width):
120
+ if not labels["show"]:
121
+ return None
122
+ output = []
123
+ for cell in cells:
124
+ if cell.get("missing"):
125
+ continue
126
+ value = cell["value"]
127
+ text = f"{labels['prefix']}{_fixed(value, labels['decimals'])}{labels['suffix']}"
128
+ compact = _compact(value, labels["prefix"], labels["suffix"])
129
+ item = {"x": cell["x"] + cell["width"] / 2, "y": cell["y"] + cell["height"] / 2,
130
+ "value": value, "xValue": cell["xValue"], "yValue": cell["yValue"],
131
+ "row": cell["row"], "column": cell["column"], "text": text, "fullText": text}
132
+ if len(compact) < len(text):
133
+ item["compactText"] = compact
134
+ for source, target in (("sourceIndex", "sourceIndex"), ("xCategory", "xCategory"),
135
+ ("yCategory", "yCategory"), ("cornerRadius", "cellCornerRadius")):
136
+ if source in cell:
137
+ item[target] = cell[source]
138
+ item.update({"cellX": cell["x"], "cellY": cell["y"], "cellWidth": cell["width"],
139
+ "cellHeight": cell["height"]})
140
+ if border_width > 0:
141
+ item["cellBorderWidth"] = border_width
142
+ item.update({"lineHeight": labels["lineHeight"], "padding": labels["padding"],
143
+ "minCellWidth": labels["minCellWidth"], "minCellHeight": labels["minCellHeight"],
144
+ "color": _label_color(labels, cell["fillColor"]), "font": labels["font"]})
145
+ output.append(item)
146
+ return output or None
@@ -0,0 +1,243 @@
1
+ """Python counterparts of the source's public viewport state helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from math import isfinite
7
+ from sys import float_info
8
+ from typing import Any, Mapping
9
+
10
+ from .subplots import resolveTraceSubplotBindings
11
+ from .scene import _domain, _number
12
+
13
+
14
+ def resolveSharedAxisInteractionPatch(options: Mapping[str, Any]) -> dict:
15
+ mode = options.get("mode", "xy")
16
+ active = [binding for binding in options["bindings"]
17
+ if binding["subplotId"] == options["activeSubplotId"]]
18
+ if not active:
19
+ raise ValueError(f"Unknown subplot id '{options['activeSubplotId']}' for interaction")
20
+ patch = {"xAxes": {}, "yAxes": {}}
21
+ for letter in "xy":
22
+ if letter in mode and options.get(f"{letter}Range"):
23
+ for binding in active:
24
+ patch[f"{letter}Axes"][binding[f"{letter}AxisId"]] = deepcopy(options[f"{letter}Range"])
25
+ return patch
26
+
27
+
28
+ def mergeAxisViewportState(current: Mapping | None, patch: Mapping) -> dict | None:
29
+ x_axes = deepcopy((current or {}).get("xAxes") or {})
30
+ y_axes = deepcopy((current or {}).get("yAxes") or {})
31
+ for axis_id in patch.get("resetXAxes") or []:
32
+ x_axes.pop(axis_id, None)
33
+ for axis_id in patch.get("resetYAxes") or []:
34
+ y_axes.pop(axis_id, None)
35
+ x_axes.update(deepcopy(patch.get("xAxes") or {}))
36
+ y_axes.update(deepcopy(patch.get("yAxes") or {}))
37
+ state = {}
38
+ if x_axes:
39
+ state["xAxes"] = x_axes
40
+ if y_axes:
41
+ state["yAxes"] = y_axes
42
+ return state or None
43
+
44
+
45
+ def viewportStateToAxesOptions(axes: Mapping | None, viewport: Mapping | None) -> dict | None:
46
+ if not viewport or not (viewport.get("xAxes") or viewport.get("yAxes")):
47
+ return deepcopy(axes)
48
+ result = deepcopy(dict(axes or {}))
49
+ for dimension in ("xAxes", "yAxes"):
50
+ result[dimension] = deepcopy(result.get(dimension) or {})
51
+ for axis_id, range_value in (viewport.get(dimension) or {}).items():
52
+ result[dimension][axis_id] = {**result[dimension].get(axis_id, {}),
53
+ "range": deepcopy(range_value)}
54
+ return result
55
+
56
+
57
+ def resolveInteractionBindingsFromPlotOptions(options: Mapping) -> list[dict]:
58
+ return [{"subplotId": binding["subplotId"], "xAxisId": binding["xAxisId"],
59
+ "yAxisId": binding["yAxisId"]}
60
+ for binding in resolveTraceSubplotBindings(options)]
61
+
62
+
63
+ def resolveLinkedAxisViewportPatch(options: Mapping, patch: Mapping) -> dict:
64
+ result = {"xAxes": deepcopy(patch.get("xAxes") or {}),
65
+ "yAxes": deepcopy(patch.get("yAxes") or {})}
66
+ for dimension, letter in (("xAxes", "x"), ("yAxes", "y")):
67
+ axes = deepcopy(((options.get("axes") or {}).get(dimension) or {}))
68
+ axes.setdefault(letter, {})
69
+ groups: dict[str, list[tuple[str, dict]]] = {}
70
+ for axis_id, axis in axes.items():
71
+ group_id = axis.get("viewportGroup")
72
+ if group_id is None:
73
+ continue
74
+ if not isinstance(group_id, str) or not group_id.strip():
75
+ raise ValueError(f"{letter}-axis '{axis_id}' viewportGroup must be a non-empty string")
76
+ groups.setdefault(group_id.strip(), []).append((axis_id, axis))
77
+ resets = list(dict.fromkeys(patch.get("reset" + letter.upper() + "Axes") or []))
78
+ for group_id, members in groups.items():
79
+ scales = {axis.get("scale", "linear") for _, axis in members}
80
+ if len(scales) > 1:
81
+ raise ValueError(f"{letter}-axis viewportGroup '{group_id}' requires matching scale types")
82
+ categories = [axis.get("categories") for _, axis in members]
83
+ if any(value != categories[0] for value in categories):
84
+ raise ValueError(f"{letter}-axis viewportGroup '{group_id}' requires matching categories")
85
+ ranges = [(result[dimension][axis_id], axis) for axis_id, axis in members
86
+ if axis_id in result[dimension]]
87
+ if ranges:
88
+ first, first_axis = ranges[0]
89
+ first_pair = (_number(first["min"], first_axis), _number(first["max"], first_axis))
90
+ if any((_number(value["min"], axis), _number(value["max"], axis)) != first_pair
91
+ for value, axis in ranges[1:]):
92
+ raise ValueError(f"viewportGroup '{group_id}' received conflicting ranges")
93
+ for axis_id, _ in members:
94
+ result[dimension][axis_id] = deepcopy(first)
95
+ if axis_id in resets:
96
+ resets.remove(axis_id)
97
+ elif any(axis_id in resets for axis_id, _ in members):
98
+ for axis_id, _ in members:
99
+ if axis_id not in resets:
100
+ resets.append(axis_id)
101
+ if resets:
102
+ result["reset" + letter.upper() + "Axes"] = resets
103
+ return result
104
+
105
+
106
+ def resolveLinkedAxisViewportState(options: Mapping) -> dict | None:
107
+ viewport = options.get("viewport") or {}
108
+ patch = resolveLinkedAxisViewportPatch(options, {
109
+ "xAxes": viewport.get("xAxes") or {}, "yAxes": viewport.get("yAxes") or {},
110
+ })
111
+ return mergeAxisViewportState(None, patch)
112
+
113
+
114
+ def applyAxisViewportState(options: Mapping, viewport: Mapping | None) -> dict:
115
+ result = deepcopy(dict(options))
116
+ linked = resolveLinkedAxisViewportState({"axes": result.get("axes"), "viewport": viewport})
117
+ result["axes"] = viewportStateToAxesOptions(result.get("axes"), linked)
118
+ return result
119
+
120
+
121
+ def _interaction_context(input_options: Mapping) -> tuple[dict, list, dict]:
122
+ options = input_options["options"]
123
+ bindings = resolveTraceSubplotBindings(options)
124
+ active = [item for item in bindings if item["subplotId"] == input_options["activeSubplotId"]]
125
+ if not active:
126
+ raise ValueError(f"Unknown subplot id '{input_options['activeSubplotId']}' for interaction")
127
+ return options, active, {"xAxes": {}, "yAxes": {}}
128
+
129
+
130
+ def _axis_domains(options: Mapping, axis_id: str, letter: str):
131
+ resolved = applyAxisViewportState(options, options.get("viewport"))
132
+ dimension = f"{letter}Axes"
133
+ current_axis = ((resolved.get("axes") or {}).get(dimension) or {}).get(axis_id) or {}
134
+ base_axis = ((options.get("axes") or {}).get(dimension) or {}).get(axis_id) or {}
135
+ values = []
136
+ for binding in resolveTraceSubplotBindings(options):
137
+ if binding["trace"].get("visible") is False or binding[f"{letter}AxisId"] != axis_id:
138
+ continue
139
+ for point in binding["trace"].get("data") or []:
140
+ if point.get(letter) is not None:
141
+ values.append(_number(point[letter], current_axis))
142
+ return _domain(values, current_axis), _domain(values, base_axis), current_axis
143
+
144
+
145
+ def _axis_ids(bindings, letter):
146
+ return list(dict.fromkeys(item[f"{letter}AxisId"] for item in bindings))
147
+
148
+
149
+ def _unit(value):
150
+ return max(0, min(1, value)) if isinstance(value, (int, float)) and isfinite(value) else .5
151
+
152
+
153
+ def _range(domain, axis):
154
+ low, high = domain
155
+ if axis.get("scale") == "logarithmic":
156
+ low, high = 10 ** low, 10 ** high
157
+ return {"min": low, "max": high}
158
+
159
+
160
+ def _same_domain(left, right):
161
+ tolerance = float_info.epsilon * max(1, *(abs(value) for value in (*left, *right))) * 16
162
+ return abs(left[0] - right[0]) <= tolerance and abs(left[1] - right[1]) <= tolerance
163
+
164
+
165
+ def resolveAxisZoomInteractionPatch(input_options: Mapping) -> dict:
166
+ options, active, patch = _interaction_context(input_options)
167
+ mode = input_options.get("mode", "xy")
168
+ scale = input_options["scale"]
169
+ for letter in "xy":
170
+ if letter not in mode:
171
+ continue
172
+ dimension = f"{letter}Axes"
173
+ ratio = input_options[f"{letter}Ratio"]
174
+ ratio = _unit(ratio if letter == "x" else 1 - ratio)
175
+ for axis_id in _axis_ids(active, letter):
176
+ domain, base, axis = _axis_domains(options, axis_id, letter)
177
+ if domain is None or not isinstance(scale, (int, float)) or not isfinite(scale) or scale <= 0:
178
+ continue
179
+ low, high = domain
180
+ span = max((high - low) * scale, float_info.epsilon)
181
+ center = low + (high - low) * ratio
182
+ zoomed = (center - span * ratio, center + span * (1 - ratio))
183
+ if scale > 1 and base is not None:
184
+ if high - low >= base[1] - base[0]:
185
+ continue
186
+ zoomed = (max(base[0], zoomed[0]), min(base[1], zoomed[1]))
187
+ if zoomed[0] >= zoomed[1]:
188
+ continue
189
+ if _same_domain(zoomed, base):
190
+ patch.setdefault("reset" + letter.upper() + "Axes", []).append(axis_id)
191
+ continue
192
+ patch[dimension][axis_id] = _range(zoomed, axis)
193
+ return resolveLinkedAxisViewportPatch(options, patch)
194
+
195
+
196
+ def resolveAxisBoxZoomInteractionPatch(input_options: Mapping) -> dict:
197
+ options, active, patch = _interaction_context(input_options)
198
+ mode = input_options.get("mode", "xy")
199
+ for letter in "xy":
200
+ if letter not in mode:
201
+ continue
202
+ for axis_id in _axis_ids(active, letter):
203
+ domain, _, axis = _axis_domains(options, axis_id, letter)
204
+ if domain is None:
205
+ continue
206
+ first = input_options[f"{letter}StartRatio"]
207
+ second = input_options[f"{letter}EndRatio"]
208
+ if letter == "y":
209
+ first, second = 1 - first, 1 - second
210
+ start, end = _unit(min(first, second)), _unit(max(first, second))
211
+ if start >= end:
212
+ continue
213
+ low, high = domain
214
+ patch[f"{letter}Axes"][axis_id] = _range((low + (high - low) * start,
215
+ low + (high - low) * end), axis)
216
+ return resolveLinkedAxisViewportPatch(options, patch)
217
+
218
+
219
+ def resolveAxisPanInteractionPatch(input_options: Mapping) -> dict:
220
+ options, active, patch = _interaction_context(input_options)
221
+ mode = input_options.get("mode", "xy")
222
+ for letter in "xy":
223
+ if letter not in mode:
224
+ continue
225
+ ratio = input_options[f"{letter}Ratio"]
226
+ if not isinstance(ratio, (int, float)) or not isfinite(ratio) or ratio == 0:
227
+ continue
228
+ for axis_id in _axis_ids(active, letter):
229
+ domain, base, axis = _axis_domains(options, axis_id, letter)
230
+ if domain is None or domain[0] >= domain[1]:
231
+ continue
232
+ span = domain[1] - domain[0]
233
+ next_range = (domain[0] + span * ratio, domain[1] + span * ratio)
234
+ if base is not None:
235
+ if span >= base[1] - base[0]:
236
+ continue
237
+ if next_range[0] < base[0]:
238
+ next_range = (base[0], base[0] + span)
239
+ elif next_range[1] > base[1]:
240
+ next_range = (base[1] - span, base[1])
241
+ if not _same_domain(next_range, domain):
242
+ patch[f"{letter}Axes"][axis_id] = _range(next_range, axis)
243
+ return resolveLinkedAxisViewportPatch(options, patch)