fastplotpy 0.3.0a1__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastplotpy/README.md +70 -0
- fastplotpy/__init__.py +33 -0
- fastplotpy/assets/dist/assets/scene.worker-CcnexaKr.js +1 -0
- fastplotpy/assets/dist/index.js +15071 -0
- fastplotpy/assets/native/NotoSans-OFL.txt +96 -0
- fastplotpy/assets/native/THIRD_PARTY_NOTICES.md +24 -0
- fastplotpy/assets/native/fastplot_python_abi.dll +0 -0
- fastplotpy/assets/package.json +1 -0
- fastplotpy/axes_validation.py +102 -0
- fastplotpy/camera3d.py +104 -0
- fastplotpy/color_scale.py +88 -0
- fastplotpy/defaults/area-fill.json +5 -0
- fastplotpy/defaults/bar.json +22 -0
- fastplotpy/defaults/camera3d.json +22 -0
- fastplotpy/defaults/cartesian-references.json +10 -0
- fastplotpy/defaults/control.json +3 -0
- fastplotpy/defaults/crosshair.json +19 -0
- fastplotpy/defaults/error-bars.json +7 -0
- fastplotpy/defaults/heatmap.json +70 -0
- fastplotpy/defaults/interaction.json +3 -0
- fastplotpy/defaults/layout.json +32 -0
- fastplotpy/defaults/legend.json +24 -0
- fastplotpy/defaults/line-axes.json +28 -0
- fastplotpy/defaults/line3d.json +13 -0
- fastplotpy/defaults/markers.json +6 -0
- fastplotpy/defaults/mesh3d.json +13 -0
- fastplotpy/defaults/pan.json +7 -0
- fastplotpy/defaults/pie.json +27 -0
- fastplotpy/defaults/pie3d.json +7 -0
- fastplotpy/defaults/point-labels.json +13 -0
- fastplotpy/defaults/pointClick.json +6 -0
- fastplotpy/defaults/pointSelection.json +13 -0
- fastplotpy/defaults/polar.json +71 -0
- fastplotpy/defaults/polarRotate.json +5 -0
- fastplotpy/defaults/scatter3d.json +39 -0
- fastplotpy/defaults/scene3d.json +61 -0
- fastplotpy/defaults/surface.json +35 -0
- fastplotpy/defaults/tooltip.json +18 -0
- fastplotpy/defaults/trace-colors.json +10 -0
- fastplotpy/defaults/trace-options.json +24 -0
- fastplotpy/defaults/wonderplot.json +58 -0
- fastplotpy/defaults/zoom.json +12 -0
- fastplotpy/defaults.py +15 -0
- fastplotpy/error_bars.py +118 -0
- fastplotpy/exports.py +153 -0
- fastplotpy/families2d.py +896 -0
- fastplotpy/heatmap_labels.py +146 -0
- fastplotpy/interactions.py +243 -0
- fastplotpy/interactive.py +1922 -0
- fastplotpy/js_serialize.py +47 -0
- fastplotpy/lighting3d.py +113 -0
- fastplotpy/line3d_lod.py +138 -0
- fastplotpy/lod3d.py +105 -0
- fastplotpy/mesh3d_lod.py +107 -0
- fastplotpy/native.py +163 -0
- fastplotpy/overlays.py +561 -0
- fastplotpy/point_labels.py +86 -0
- fastplotpy/polar.py +445 -0
- fastplotpy/polygon_clip3d.py +148 -0
- fastplotpy/references.py +72 -0
- fastplotpy/rendering.py +65 -0
- fastplotpy/rust_scenes.py +314 -0
- fastplotpy/scene.py +1185 -0
- fastplotpy/scene3d.py +1215 -0
- fastplotpy/scene3d_accessibility.py +183 -0
- fastplotpy/scene3d_collection.py +158 -0
- fastplotpy/subplots.py +115 -0
- fastplotpy/surface_contours.py +122 -0
- fastplotpy/surface_lod.py +109 -0
- fastplotpy/svg3d.py +235 -0
- fastplotpy/trendline.py +236 -0
- fastplotpy/triangle_wireframe.py +51 -0
- fastplotpy/types.py +3027 -0
- fastplotpy-0.3.0a1.dist-info/METADATA +86 -0
- fastplotpy-0.3.0a1.dist-info/RECORD +78 -0
- fastplotpy-0.3.0a1.dist-info/WHEEL +5 -0
- fastplotpy-0.3.0a1.dist-info/licenses/LICENSE +21 -0
- fastplotpy-0.3.0a1.dist-info/top_level.txt +1 -0
fastplotpy/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
|