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
fastplotpy/overlays.py ADDED
@@ -0,0 +1,561 @@
1
+ """Host-side programmatic tooltip and crosshair state."""
2
+ from copy import deepcopy
3
+ from datetime import datetime, timezone
4
+ from math import cos, hypot, isfinite, sin
5
+ from sys import float_info
6
+ from typing import Mapping
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .defaults import get_defaults
10
+ from .interactions import _axis_domains
11
+ from .subplots import resolveTraceSubplotBindings
12
+ from .scene import _number, _scaled
13
+
14
+
15
+ _CARTESIAN = {"line", "scatter", "bar", "pie", "heatmap", "wonderplot"}
16
+
17
+
18
+ def _numeric(value):
19
+ return type(value) in (int, float) and isfinite(value)
20
+
21
+
22
+ def _js_number(value):
23
+ """Match JSON.stringify's integer spelling for a finite JS Number."""
24
+ return int(value) if type(value) is float and isfinite(value) and value.is_integer() else value
25
+
26
+
27
+ def _unscaled(value, axis):
28
+ return 10 ** value if axis.get("scale") == "logarithmic" else value
29
+
30
+
31
+ def _format_axis_value(value, axis):
32
+ if axis.get("scale") == "datetime":
33
+ timezone_name = (axis.get("dateTime") or {}).get("timeZone")
34
+ zone = timezone.utc if timezone_name == "utc" else (
35
+ ZoneInfo(timezone_name) if timezone_name and timezone_name != "browser" else None)
36
+ text = datetime.fromtimestamp(value / 1000, tz=zone).strftime("%m/%d/%Y, %I:%M %p")
37
+ else:
38
+ categories = axis.get("categories") or []
39
+ index = int(value + .5) - 1
40
+ if 0 <= index < len(categories):
41
+ text = str(categories[index])
42
+ elif _numeric(axis.get("decimals")):
43
+ text = f"{value:.{int(axis['decimals'])}f}"
44
+ else:
45
+ text = str(_js_number(value))
46
+ return f"{axis.get('labelPrefix', '')}{text}{axis.get('labelSuffix', '')}"
47
+
48
+
49
+ def _style(options):
50
+ defaults = get_defaults("tooltip")
51
+ base = options.get("style") or {}
52
+ tooltip_base = base.get("tooltip") or {}
53
+ interaction = (options.get("interactions") or {}).get("tooltip")
54
+ interaction = interaction if isinstance(interaction, Mapping) else {}
55
+ overrides = interaction.get("style") or {}
56
+ text = {"font": defaults["font"], "color": defaults["textColor"],
57
+ "lineHeight": defaults["textLineHeight"]}
58
+ for layer in (base.get("text") or {}, tooltip_base.get("text") or {},
59
+ interaction.get("textStyle") or overrides.get("text") or {}):
60
+ for key in text:
61
+ if layer.get(key) is not None:
62
+ text[key] = layer[key]
63
+ result = {"text": text}
64
+ for key in ("backgroundColor", "borderColor", "borderWidth", "borderRadius", "padding"):
65
+ value = defaults[key]
66
+ for layer in (base if key == "backgroundColor" else {}, tooltip_base,
67
+ overrides, interaction):
68
+ if layer.get(key) is not None:
69
+ value = layer[key]
70
+ result[key] = value
71
+ return result
72
+
73
+
74
+ def tooltip_state(options, scene, target):
75
+ interaction = (options.get("interactions") or {}).get("tooltip")
76
+ if (not interaction or (isinstance(interaction, Mapping) and interaction.get("enabled") is False)
77
+ or not isinstance(target, Mapping)):
78
+ return None
79
+ trace_index, point_index = target.get("traceIndex"), target.get("pointIndex")
80
+ if type(trace_index) is not int or type(point_index) is not int or trace_index < 0 or point_index < 0:
81
+ return None
82
+ if options.get("type") in {"scatter3d", "line3d", "surface", "mesh3d", "pie3d"}:
83
+ return _scene3d_tooltip_state(options, scene, target, interaction)
84
+ if options.get("type") == "pie":
85
+ return _pie_tooltip_state(options, scene, target, interaction)
86
+ family = options.get("type")
87
+ if family not in ("line", "scatter", "bar", "polar", "heatmap", "wonderplot"):
88
+ return None
89
+ bindings = resolveTraceSubplotBindings(options)
90
+ if trace_index >= len(bindings):
91
+ return None
92
+ binding = bindings[trace_index]
93
+ trace = binding["trace"]
94
+ if trace.get("visible") is False:
95
+ return None
96
+ subplot_id = binding["subplotId"]
97
+ if target.get("subplotId") is not None and target["subplotId"] != subplot_id:
98
+ return None
99
+ data = trace.get("data") or []
100
+ if point_index >= len(data) or not isinstance(data[point_index], Mapping):
101
+ return None
102
+ raw = data[point_index]
103
+ if raw.get("x") is None or raw.get("y") is None:
104
+ return None
105
+ primitive = next((item for item in scene.get("primitives", [])
106
+ if item.get("traceIndex", 0) == trace_index and item.get("subplot") == subplot_id
107
+ and item.get("type") == family), None)
108
+ if primitive is None:
109
+ return None
110
+ bounds = primitive["bounds"]
111
+ x_axis, y_axis = binding["xAxisId"], binding["yAxisId"]
112
+ axes = (options.get("axes") or {})
113
+ x_options = ((axes.get("xAxes") or {}).get(x_axis) or {})
114
+ y_options = ((axes.get("yAxes") or {}).get(y_axis) or {})
115
+ x_value = _number(raw["x"], x_options)
116
+ y_value = _number(raw["y"], y_options)
117
+ point = None
118
+ extra = {}
119
+ if family in ("line", "scatter"):
120
+ # The source interaction index uses unfiltered original data identities,
121
+ # then checks transformed values against the active viewport domains.
122
+ x_domain, _, _ = _axis_domains(options, x_axis, "x")
123
+ y_domain, _, _ = _axis_domains(options, y_axis, "y")
124
+ if x_domain is None or y_domain is None:
125
+ return None
126
+ try:
127
+ transformed_x = _scaled(x_value, x_options)
128
+ transformed_y = _scaled(y_value, y_options)
129
+ except ValueError:
130
+ return None
131
+ if (not x_domain[0] <= transformed_x <= x_domain[1]
132
+ or not y_domain[0] <= transformed_y <= y_domain[1]):
133
+ return None
134
+ if point_index < len(primitive.get("points") or []) and not options.get("viewport"):
135
+ point = primitive["points"][point_index]
136
+ else:
137
+ point = {"x": (transformed_x - x_domain[0]) / (x_domain[1] - x_domain[0]) * (bounds["right"] - bounds["left"]),
138
+ "y": (1 - (transformed_y - y_domain[0]) / (y_domain[1] - y_domain[0])) * (bounds["bottom"] - bounds["top"])}
139
+ elif family == "bar":
140
+ bar = next((item for index, item in enumerate(primitive.get("bars") or [])
141
+ if item.get("sourceIndex", index) == point_index), None)
142
+ if bar is None:
143
+ return None
144
+ point = {"x": bar["x"] + bar["width"] / 2, "y": bar["y"] + bar["height"] / 2}
145
+ for key in ("normalizedValue", "baseline"):
146
+ if key in bar:
147
+ extra[key] = bar[key]
148
+ elif family == "polar":
149
+ sector = next((item for index, item in enumerate(primitive.get("polarSectors") or [])
150
+ if item.get("sourceIndex", index) == point_index), None)
151
+ if sector is None:
152
+ return None
153
+ midpoint = (sector["startAngle"] + sector["endAngle"]) / 2
154
+ radius = (sector["innerRadius"] + sector["outerRadius"]) / 2
155
+ point = {"x": sector["centerX"] + cos(midpoint) * radius,
156
+ "y": sector["centerY"] + sin(midpoint) * radius}
157
+ if (options.get("polar") or {}).get("angleCategories"):
158
+ x_value = sector["angle"]
159
+ elif family == "heatmap":
160
+ cell = next((item for index, item in enumerate(primitive.get("heatmapCells") or [])
161
+ if item.get("sourceIndex", index) == point_index and not item.get("missing")), None)
162
+ if cell is None:
163
+ return None
164
+ point = {"x": cell["x"] + cell["width"] / 2, "y": cell["y"] + cell["height"] / 2}
165
+ x_value, y_value = cell["xValue"], cell["yValue"]
166
+ for key in ("xCategory", "yCategory"):
167
+ if key in cell:
168
+ extra[key] = cell[key]
169
+ if "value" in raw:
170
+ extra["value"] = raw["value"]
171
+ if "z" in raw:
172
+ extra["z"] = raw["z"]
173
+ extra["colorValue"] = cell["value"]
174
+ elif family == "wonderplot":
175
+ wonder = next((item for index, item in enumerate(primitive.get("wonderPoints") or [])
176
+ if item.get("sourceIndex", index) == point_index), None)
177
+ if wonder is None:
178
+ return None
179
+ point = {"x": wonder["x"], "y": wonder["y"]}
180
+ for raw_key, output in (("value", "value"), ("z", "z"), ("size", "size"),
181
+ ("opacity", "opacity")):
182
+ if raw_key in raw:
183
+ extra[output] = raw[raw_key]
184
+ if "value" in raw:
185
+ extra["colorValue"] = raw["value"]
186
+ for source_key, output in (("sizeValue", "sizeValue"), ("size", "radius"),
187
+ ("opacityValue", "opacityValue"), ("opacity", "renderedOpacity")):
188
+ if source_key in wonder:
189
+ extra[output] = wonder[source_key]
190
+ values = {"subplotId": subplot_id, "traceIndex": trace_index,
191
+ "pointIndex": point_index, "xAxisId": x_axis, "yAxisId": y_axis,
192
+ "x": raw["x"], "y": raw["y"], "xValue": x_value, "yValue": y_value,
193
+ **extra}
194
+ if isinstance(trace.get("name"), str):
195
+ values["traceName"] = trace["name"]
196
+ if isinstance(primitive.get("color"), str):
197
+ values["traceColor"] = primitive["color"]
198
+ tooltip_options = interaction if isinstance(interaction, Mapping) else {}
199
+ text = f"{x_axis}: {raw['x']} | {y_axis}: {raw['y']}"
200
+ if family in ("heatmap", "wonderplot") and "value" in raw:
201
+ text += f" | value: {raw['value']}"
202
+ if trace.get("name"):
203
+ text = f"{trace['name']} | {text}"
204
+ if tooltip_options.get("mode") == "custom" and callable(tooltip_options.get("formatter")):
205
+ text = tooltip_options["formatter"](deepcopy(values))
206
+ if tooltip_options.get("shared") and family in ("line", "scatter"):
207
+ items = [values]
208
+ for other_index, other in enumerate(bindings):
209
+ if (other_index == trace_index or other["subplotId"] != subplot_id
210
+ or other["xAxisId"] != x_axis
211
+ or other["trace"].get("visible") is False):
212
+ continue
213
+ other_x_options = ((axes.get("xAxes") or {}).get(other["xAxisId"]) or {})
214
+ other_y_options = ((axes.get("yAxes") or {}).get(other["yAxisId"]) or {})
215
+ other_x_domain, _, _ = _axis_domains(options, other["xAxisId"], "x")
216
+ other_y_domain, _, _ = _axis_domains(options, other["yAxisId"], "y")
217
+ if other_x_domain is None or other_y_domain is None:
218
+ continue
219
+ other_primitive = next((item for item in scene.get("primitives", [])
220
+ if item.get("type") == family and item.get("traceIndex", 0) == other_index
221
+ and item.get("subplot") == subplot_id), None)
222
+ if other_primitive is None:
223
+ continue
224
+ best = None
225
+ for candidate_index, candidate in enumerate(other["trace"].get("data") or []):
226
+ if not isinstance(candidate, Mapping) or candidate.get("x") is None or candidate.get("y") is None:
227
+ continue
228
+ other_x = _number(candidate["x"], other_x_options)
229
+ other_y = _number(candidate["y"], other_y_options)
230
+ scale = max(1, abs(other_x), abs(x_value))
231
+ if (tooltip_options.get("sharedXMode") != "nearest"
232
+ and abs(other_x - x_value) > float_info.epsilon * scale * 16):
233
+ continue
234
+ transformed_x = _scaled(other_x, other_x_options)
235
+ transformed_y = _scaled(other_y, other_y_options)
236
+ if (not other_x_domain[0] <= transformed_x <= other_x_domain[1]
237
+ or not other_y_domain[0] <= transformed_y <= other_y_domain[1]):
238
+ continue
239
+ other_point_x = (transformed_x - other_x_domain[0]) / (other_x_domain[1] - other_x_domain[0]) * (bounds["right"] - bounds["left"])
240
+ other_point_y = (1 - (transformed_y - other_y_domain[0]) / (other_y_domain[1] - other_y_domain[0])) * (bounds["bottom"] - bounds["top"])
241
+ distance_x = abs(other_point_x - point["x"])
242
+ distance = hypot(other_point_x - point["x"], other_point_y - point["y"])
243
+ order = (distance_x, distance, candidate_index)
244
+ if best is not None and order >= best[0]:
245
+ continue
246
+ other_values = {"subplotId": subplot_id, "traceIndex": other_index,
247
+ "pointIndex": candidate_index, "xAxisId": other["xAxisId"],
248
+ "yAxisId": other["yAxisId"], "x": candidate["x"],
249
+ "y": candidate["y"], "xValue": other_x,
250
+ "yValue": other_y}
251
+ if "name" in other["trace"]:
252
+ other_values["traceName"] = other["trace"]["name"]
253
+ if "color" in other_primitive:
254
+ other_values["traceColor"] = other_primitive["color"]
255
+ best = order, other_values
256
+ if best is not None:
257
+ items.append(best[1])
258
+ items.sort(key=lambda item: item["traceIndex"])
259
+ values["items"] = deepcopy(items)
260
+ text = f"{x_axis}: {raw['x']} | " + " | ".join(
261
+ f"{item.get('traceName') or 'Trace ' + str(item['traceIndex'] + 1)} | {item['yAxisId']}: {item['y']}"
262
+ for item in items)
263
+ if tooltip_options.get("mode") == "custom" and callable(tooltip_options.get("formatter")):
264
+ text = tooltip_options["formatter"](deepcopy(values))
265
+ return {"active": True, "activeSubplotId": subplot_id,
266
+ "pointer": {"x": bounds["left"] + point["x"], "y": bounds["top"] + point["y"]},
267
+ "text": text, "style": _style(options), "values": values}
268
+
269
+
270
+ def _scene3d_tooltip_state(options, scene, target, interaction):
271
+ """Resolve source-indexed 3D primitives without browser-side calculation."""
272
+ if not isinstance(scene, Mapping):
273
+ return None
274
+ family = options.get("type")
275
+ trace_index, point_index = target["traceIndex"], target["pointIndex"]
276
+ traces = options.get("traces")
277
+ if traces is None:
278
+ traces = [{"data": options.get("data") or []}]
279
+ if trace_index >= len(traces) or traces[trace_index].get("visible") is False:
280
+ return None
281
+ trace = traces[trace_index]
282
+ for primitive in scene.get("primitives", []):
283
+ if primitive.get("type") != family or primitive.get("traceIndex", 0) != trace_index:
284
+ continue
285
+ subplot_id = primitive.get("subplot", "scene3d")
286
+ if target.get("subplotId") is not None and target["subplotId"] != subplot_id:
287
+ continue
288
+ bounds = primitive.get("bounds") or {}
289
+ values = {"subplotId": subplot_id, "traceIndex": trace_index,
290
+ "pointIndex": point_index, "xAxisId": "x", "yAxisId": "y"}
291
+ if family == "scatter3d":
292
+ point = next((item for item in primitive.get("scatter3dPoints") or []
293
+ if item.get("sourceIndex") == point_index), None)
294
+ if point is None:
295
+ continue
296
+ pointer = {"x": bounds.get("left", 0) + point["x"],
297
+ "y": bounds.get("top", 0) + point["y"]}
298
+ values.update({"x": point["sourceX"], "y": point["sourceY"],
299
+ "xValue": point["sourceX"], "yValue": point["sourceY"],
300
+ "z": point["sourceZ"], "renderedColor": point["fillColor"],
301
+ "radius": point["radius"], "renderedOpacity": point["opacity"],
302
+ "depth": point["depth"]})
303
+ for point_key, value_key in (("colorValue", "colorValue"), ("sizeValue", "size"),
304
+ ("sizeValue", "sizeValue"), ("opacityValue", "opacity"),
305
+ ("opacityValue", "opacityValue")):
306
+ if point_key in point:
307
+ values[value_key] = point[point_key]
308
+ elif family == "line3d":
309
+ segments = [item for item in primitive.get("line3dSegments") or []
310
+ if item.get("sourceIndex1") == point_index]
311
+ if not segments:
312
+ continue
313
+ segment = min(segments, key=lambda item: item.get("sourceRatio1", 0))
314
+ pointer = {"x": bounds.get("left", 0) + segment["x1"],
315
+ "y": bounds.get("top", 0) + segment["y1"]}
316
+ values.update({"x": segment["sourceX1"], "y": segment["sourceY1"],
317
+ "xValue": segment["sourceX1"], "yValue": segment["sourceY1"],
318
+ "z": segment["sourceZ1"],
319
+ "radius": max(get_defaults("line3d")["minimumHitRadius"],
320
+ primitive.get("lineWidth", 0) / 2),
321
+ "renderedOpacity": segment.get("opacity", 1),
322
+ "depth": segment["depth1"]})
323
+ else:
324
+ triangle_key = "surface3dTriangles" if family == "surface" else "mesh3dTriangles"
325
+ source_key = "sourceCell" if family == "surface" else "sourceFace"
326
+ triangle = next((item for item in primitive.get(triangle_key) or []
327
+ if item[source_key]["index"] == point_index), None)
328
+ if triangle is None:
329
+ continue
330
+ vertices = triangle["vertices"]
331
+ screen_weights = [1 / 3] * 3
332
+ metadata = next((item for item in scene.get("scenes3d") or [] if item.get("subplotId") == subplot_id),
333
+ scene.get("scene3d") or {})
334
+ camera = metadata.get("camera") or {}
335
+ source_weights = screen_weights
336
+ if camera.get("projection") == "perspective":
337
+ span = camera["far"] - camera["near"]
338
+ adjusted = [weight / max(1e-12, camera["near"] + vertex["depth"] * span)
339
+ for weight, vertex in zip(screen_weights, vertices)]
340
+ total = sum(adjusted)
341
+ source_weights = [weight / total for weight in adjusted]
342
+ pointer = {"x": bounds.get("left", 0) + sum(vertex["x"] * weight for vertex, weight in zip(vertices, screen_weights)),
343
+ "y": bounds.get("top", 0) + sum(vertex["y"] * weight for vertex, weight in zip(vertices, screen_weights))}
344
+ if family == "pie3d":
345
+ pie = triangle["pie3d"]
346
+ values.update({"x": pie["label"], "y": pie["value"],
347
+ "xValue": pie["fraction"] * 100, "yValue": pie["value"],
348
+ "value": pie["value"]})
349
+ values["traceColor"] = pie["color"]
350
+ else:
351
+ coordinates = {axis: sum(vertex["source" + axis.upper()] * weight
352
+ for vertex, weight in zip(vertices, source_weights))
353
+ for axis in "xyz"}
354
+ values.update({"x": coordinates["x"], "y": coordinates["y"],
355
+ "xValue": coordinates["x"], "yValue": coordinates["y"],
356
+ "z": coordinates["z"]})
357
+ values["traceColor"] = triangle["fillColor"]
358
+ values.update({"radius": 0, "renderedOpacity": triangle["opacity"],
359
+ "depth": sum(vertex["depth"] * weight for vertex, weight in zip(vertices, source_weights))})
360
+ if "name" in trace:
361
+ values["traceName"] = trace["name"]
362
+ if "traceColor" not in values and "color" in primitive:
363
+ values["traceColor"] = primitive["color"]
364
+ pointer = {key: _js_number(value) for key, value in pointer.items()}
365
+ values = {key: _js_number(value) for key, value in values.items()}
366
+ text = f"x: {values['xValue']} | y: {values['yValue']}"
367
+ if "z" in values:
368
+ text += f" | z: {values['z']}"
369
+ if family == "scatter3d":
370
+ for label, key in (("value", "colorValue"), ("size", "sizeValue"),
371
+ ("opacity", "opacityValue")):
372
+ if key in values:
373
+ text += f" | {label}: {values[key]}"
374
+ if trace.get("name"):
375
+ text = f"{trace['name']} | {text}"
376
+ if isinstance(interaction, Mapping) and interaction.get("mode") == "custom" and callable(interaction.get("formatter")):
377
+ text = interaction["formatter"](deepcopy(values))
378
+ return {"active": True, "activeSubplotId": subplot_id, "pointer": pointer,
379
+ "text": text, "style": _style(options), "values": values}
380
+ return None
381
+
382
+
383
+ def _pie_tooltip_state(options, scene, target, interaction):
384
+ trace_index, point_index = target["traceIndex"], target["pointIndex"]
385
+ bindings = resolveTraceSubplotBindings(options)
386
+ for primitive in scene.get("primitives", []):
387
+ if primitive.get("type") != "pie":
388
+ continue
389
+ subplot_id = primitive.get("subplot", "main")
390
+ if target.get("subplotId") is not None and target["subplotId"] != subplot_id:
391
+ continue
392
+ sector = next((item for item in primitive.get("pieSectors") or []
393
+ if item.get("traceIndex", 0) == trace_index
394
+ and item.get("sourceIndex") == point_index), None)
395
+ if sector is None:
396
+ continue
397
+ if trace_index < len(bindings) and bindings[trace_index]["trace"].get("visible") is False:
398
+ return None
399
+ midpoint = sector["startAngle"] + sector.get("sweep", sector["endAngle"] - sector["startAngle"]) / 2
400
+ radius = (sector["innerRadius"] + sector["outerRadius"]) / 2
401
+ bounds = primitive["bounds"]
402
+ pointer = {"x": bounds["left"] + sector["centerX"] + cos(midpoint) * radius,
403
+ "y": bounds["top"] + sector["centerY"] + sin(midpoint) * radius}
404
+ x_axis = bindings[trace_index]["xAxisId"] if trace_index < len(bindings) else "x"
405
+ y_axis = bindings[trace_index]["yAxisId"] if trace_index < len(bindings) else "y"
406
+ values = {"subplotId": subplot_id, "traceIndex": trace_index,
407
+ "traceColor": sector["color"], "pointIndex": point_index,
408
+ "xAxisId": x_axis, "yAxisId": y_axis, "x": sector["label"],
409
+ "y": sector["value"], "xValue": sector["fraction"] * 100,
410
+ "yValue": sector["value"], "value": sector["value"]}
411
+ if trace_index < len(bindings) and "name" in bindings[trace_index]["trace"]:
412
+ values["traceName"] = bindings[trace_index]["trace"]["name"]
413
+ text = f"{x_axis}: {values['xValue']} | {y_axis}: {values['yValue']}"
414
+ if values.get("traceName"):
415
+ text = f"{values['traceName']} | {text}"
416
+ if isinstance(interaction, Mapping) and interaction.get("mode") == "custom" and callable(interaction.get("formatter")):
417
+ text = interaction["formatter"](deepcopy(values))
418
+ return {"active": True, "activeSubplotId": subplot_id, "pointer": pointer,
419
+ "text": text, "style": _style(options), "values": values}
420
+ return None
421
+
422
+
423
+ def crosshair_state(options, scene, position):
424
+ interaction = (options.get("interactions") or {}).get("crosshair")
425
+ if not interaction or not isinstance(position, Mapping) or options.get("type") not in _CARTESIAN - {"pie"}:
426
+ return None
427
+ crosshair_options = interaction if isinstance(interaction, Mapping) else {}
428
+ if crosshair_options.get("enabled") is False:
429
+ return None
430
+ bindings = resolveTraceSubplotBindings(options)
431
+ binding = next((item for item in bindings if item["trace"].get("visible") is not False
432
+ and (position.get("activeSubplotId") is None or item["subplotId"] == position["activeSubplotId"])
433
+ and (position.get("xAxisId") is None or item["xAxisId"] == position["xAxisId"])
434
+ and (position.get("yAxisId") is None or item["yAxisId"] == position["yAxisId"])), None)
435
+ if binding is None:
436
+ return None
437
+ x_domain, _, x_axis = _axis_domains(options, binding["xAxisId"], "x")
438
+ y_domain, _, y_axis = _axis_domains(options, binding["yAxisId"], "y")
439
+ if x_domain is None or y_domain is None:
440
+ return None
441
+ if options.get("type") == "bar":
442
+ # Source crosshairs use bar axis domains, including half-slot padding
443
+ # around categorical positions instead of ordinary line domains.
444
+ orientation = (options.get("bar") or {}).get("orientation", "vertical")
445
+ slot_letter = "y" if orientation == "horizontal" else "x"
446
+ slot_axis = y_axis if slot_letter == "y" else x_axis
447
+ slot_id = binding["yAxisId"] if slot_letter == "y" else binding["xAxisId"]
448
+ if slot_axis.get("range") is None:
449
+ slot_values = []
450
+ for current in bindings:
451
+ if (current[slot_letter + "AxisId"] != slot_id
452
+ or current["trace"].get("visible") is False):
453
+ continue
454
+ for item in current["trace"].get("data") or []:
455
+ if item.get(slot_letter) is not None:
456
+ slot_values.append(_scaled(_number(item[slot_letter], slot_axis), slot_axis))
457
+ slot_values.extend(range(1, len(slot_axis.get("categories") or []) + 1))
458
+ unique = sorted(set(slot_values))
459
+ intervals = [right - left for left, right in zip(unique, unique[1:]) if right > left]
460
+ padding = min(intervals) / 2 if intervals else .5
461
+ if slot_letter == "x":
462
+ x_domain = x_domain[0] - padding, x_domain[1] + padding
463
+ else:
464
+ y_domain = y_domain[0] - padding, y_domain[1] + padding
465
+ try:
466
+ x = _number(position.get("xValue"), x_axis)
467
+ y = _number(position.get("yValue"), y_axis)
468
+ x_transformed, y_transformed = _scaled(x, x_axis), _scaled(y, y_axis)
469
+ except (TypeError, ValueError):
470
+ return None
471
+ if x_domain[1] == x_domain[0] or y_domain[1] == y_domain[0]:
472
+ return None
473
+ x_ratio = (x_transformed - x_domain[0]) / (x_domain[1] - x_domain[0])
474
+ y_ratio = (y_transformed - y_domain[0]) / (y_domain[1] - y_domain[0])
475
+ if not (0 <= x_ratio <= 1 and 0 <= y_ratio <= 1):
476
+ return None
477
+ primitive = next((item for item in scene.get("primitives", [])
478
+ if item.get("subplot") == binding["subplotId"]), None)
479
+ if primitive is None:
480
+ return None
481
+ bounds = primitive["bounds"]
482
+ mode = crosshair_options.get("mode", "xy")
483
+ if mode not in ("x", "y", "xy"):
484
+ mode = "xy"
485
+ pointer_x = bounds["left"] + x_ratio * (bounds["right"] - bounds["left"])
486
+ pointer_y = bounds["bottom"] - y_ratio * (bounds["bottom"] - bounds["top"])
487
+ if crosshair_options.get("snap") == "nearest":
488
+ best = None
489
+ best_distance = float("inf")
490
+ for trace_index, current in enumerate(bindings):
491
+ if current["subplotId"] != binding["subplotId"] or current["trace"].get("visible") is False:
492
+ continue
493
+ raw_data = current["trace"].get("data") or []
494
+ for part in scene.get("primitives", []):
495
+ if (part.get("subplot") != binding["subplotId"]
496
+ or part.get("traceIndex", 0) != trace_index
497
+ or part.get("type") != options.get("type")
498
+ or part.get("trendline") or part.get("bounds") != bounds):
499
+ continue
500
+ candidates = []
501
+ if options["type"] in ("line", "scatter"):
502
+ for index, point in enumerate(part.get("points") or []):
503
+ candidates.append((bounds["left"] + point["x"],
504
+ bounds["top"] + point["y"], None, None))
505
+ elif options["type"] == "bar":
506
+ for index, bar in enumerate(part.get("bars") or []):
507
+ source_index = bar.get("sourceIndex", index)
508
+ if source_index >= len(raw_data):
509
+ continue
510
+ candidates.append((bounds["left"] + bar["x"] + bar["width"] / 2,
511
+ bounds["top"] + bar["y"] + bar["height"] / 2,
512
+ raw_data[source_index]["x"], raw_data[source_index]["y"]))
513
+ elif options["type"] == "heatmap":
514
+ for cell in part.get("heatmapCells") or []:
515
+ if cell.get("missing"):
516
+ continue
517
+ candidates.append((bounds["left"] + cell["x"] + cell["width"] / 2,
518
+ bounds["top"] + cell["y"] + cell["height"] / 2,
519
+ cell["xValue"], cell["yValue"]))
520
+ elif options["type"] == "wonderplot":
521
+ for index, point in enumerate(part.get("wonderPoints") or []):
522
+ source_index = point.get("sourceIndex", index)
523
+ if source_index >= len(raw_data):
524
+ continue
525
+ candidates.append((bounds["left"] + point["x"], bounds["top"] + point["y"],
526
+ raw_data[source_index]["x"], raw_data[source_index]["y"]))
527
+ for candidate_x, candidate_y, raw_x, raw_y in candidates:
528
+ if not (bounds["left"] <= candidate_x <= bounds["right"]
529
+ and bounds["top"] <= candidate_y <= bounds["bottom"]):
530
+ continue
531
+ distance = (abs(pointer_x - candidate_x) if mode == "x" else
532
+ abs(pointer_y - candidate_y) if mode == "y" else
533
+ hypot(pointer_x - candidate_x, pointer_y - candidate_y))
534
+ if distance < best_distance:
535
+ best = candidate_x, candidate_y, raw_x, raw_y, current
536
+ best_distance = distance
537
+ if best is None:
538
+ return None
539
+ pointer_x, pointer_y, raw_x, raw_y, snapped_binding = best
540
+ x_axis_id, y_axis_id = snapped_binding["xAxisId"], snapped_binding["yAxisId"]
541
+ x_axis = ((options.get("axes") or {}).get("xAxes") or {}).get(x_axis_id) or {}
542
+ y_axis = ((options.get("axes") or {}).get("yAxes") or {}).get(y_axis_id) or {}
543
+ if raw_x is None or raw_y is None:
544
+ x = _unscaled(x_domain[0] + (x_domain[1] - x_domain[0])
545
+ * (pointer_x - bounds["left"]) / (bounds["right"] - bounds["left"]), x_axis)
546
+ y = _unscaled(y_domain[0] + (y_domain[1] - y_domain[0])
547
+ * (bounds["bottom"] - pointer_y) / (bounds["bottom"] - bounds["top"]), y_axis)
548
+ else:
549
+ x, y = _number(raw_x, x_axis), _number(raw_y, y_axis)
550
+ else:
551
+ x_axis_id, y_axis_id = binding["xAxisId"], binding["yAxisId"]
552
+ x_label, y_label = _format_axis_value(x, x_axis), _format_axis_value(y, y_axis)
553
+ default_label = (f"x: {x_label}" if mode == "x" else
554
+ f"y: {y_label}" if mode == "y" else
555
+ f"x: {x_label} | y: {y_label}")
556
+ return {"activeSubplotId": binding["subplotId"], "mode": mode,
557
+ "snap": crosshair_options.get("snap", "none"),
558
+ "xAxisId": x_axis_id, "yAxisId": y_axis_id,
559
+ "xValue": _js_number(x), "yValue": _js_number(y),
560
+ "xLabel": x_label, "yLabel": y_label, "defaultLabel": default_label,
561
+ "pointer": {"x": _js_number(pointer_x), "y": _js_number(pointer_y)}}
@@ -0,0 +1,86 @@
1
+ """Line point label resolution and geometry from the source contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
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 resolve_point_labels(plot_value, trace_value):
14
+ resolved = dict(get_defaults("point-labels"))
15
+ for raw in (plot_value, trace_value):
16
+ if raw is None:
17
+ continue
18
+ if isinstance(raw, bool):
19
+ resolved["show"] = raw
20
+ continue
21
+ source = raw if isinstance(raw, Mapping) else {}
22
+ resolved["show"] = source.get("show", True)
23
+ for key in ("color", "font"):
24
+ value = source.get(key)
25
+ if isinstance(value, str) and value.strip():
26
+ resolved[key] = value
27
+ for key in ("lineHeight", "offset", "collisionPadding"):
28
+ value = source.get(key)
29
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) and (value > 0 if key == "lineHeight" else value >= 0):
30
+ resolved[key] = value
31
+ position = source.get("position")
32
+ if position is not None:
33
+ if position not in ("above", "below", "auto"):
34
+ raise ValueError("pointLabels.position must be 'above', 'below', or 'auto'")
35
+ resolved["position"] = position
36
+ decimals = source.get("decimals")
37
+ if isinstance(decimals, (int, float)) and not isinstance(decimals, bool) and isfinite(decimals):
38
+ resolved["decimals"] = max(0, min(12, floor(decimals + .5)))
39
+ for key in ("prefix", "suffix"):
40
+ if isinstance(source.get(key), str):
41
+ resolved[key] = source[key]
42
+ if callable(source.get("formatter")):
43
+ resolved["formatter"] = source["formatter"]
44
+ return resolved
45
+
46
+
47
+ def _text(value, axis, labels):
48
+ if axis.get("scale") == "datetime":
49
+ date = datetime.fromtimestamp(value / 1000, tz=timezone.utc)
50
+ format_options = (axis.get("dateTime") or {}).get("format") or {}
51
+ if format_options == {"year": "numeric"}:
52
+ number = str(date.year)
53
+ else:
54
+ number = date.strftime("%m/%d/%Y, %I:%M %p")
55
+ elif labels["decimals"] == 0:
56
+ number = str(floor(value + .5))
57
+ else:
58
+ places = labels["decimals"]
59
+ number = format(Decimal(str(value)).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP), f".{places}f")
60
+ return f"{labels['prefix']}{number}{labels['suffix']}"
61
+
62
+
63
+ def build_point_labels(trace, normalized, labels, markers, x_axis, y_axis,
64
+ x_domain, y_domain, width, height):
65
+ if not labels["show"]:
66
+ return None
67
+ from .scene import _scaled
68
+ marker_offset = markers["size"] if markers and markers["show"] else 0
69
+ output = []
70
+ for index, point in enumerate(normalized):
71
+ if point is None:
72
+ continue
73
+ x = (_scaled(point["x"], x_axis) - x_domain[0]) / max(x_domain[1] - x_domain[0], 2.220446049250313e-16) * width
74
+ point_y = (1 - (_scaled(point["y"], y_axis) - y_domain[0]) / max(y_domain[1] - y_domain[0], 2.220446049250313e-16)) * height
75
+ if not (0 <= x <= width and 0 <= point_y <= height):
76
+ continue
77
+ position = labels["position"]
78
+ if position == "auto":
79
+ position = "above" if point_y - marker_offset - labels["offset"] - labels["lineHeight"] >= 0 else "below"
80
+ label = {"x": x, "y": point_y - marker_offset - labels["offset"] if position == "above" else point_y + marker_offset + labels["offset"],
81
+ "value": point["y"], "text": _text(point["y"], y_axis, labels),
82
+ "color": labels["color"], "font": labels["font"], "lineHeight": labels["lineHeight"],
83
+ "position": position, "textBaseline": "bottom" if position == "above" else "top",
84
+ "sourceIndex": index, "collisionPadding": labels["collisionPadding"]}
85
+ output.append(label)
86
+ return output or None