repelly 0.1.0__py3-none-any.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.
- repelly/__init__.py +23 -0
- repelly/_api.py +648 -0
- repelly/_figure.py +180 -0
- repelly/_runtime.py +42 -0
- repelly/_static.py +186 -0
- repelly/dash.py +41 -0
- repelly/js/runtime.js +631 -0
- repelly/js/solver.js +442 -0
- repelly-0.1.0.dist-info/METADATA +233 -0
- repelly-0.1.0.dist-info/RECORD +13 -0
- repelly-0.1.0.dist-info/WHEEL +4 -0
- repelly-0.1.0.dist-info/licenses/LICENSE +231 -0
- repelly-0.1.0.dist-info/licenses/NOTICE +42 -0
repelly/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# repelly - https://github.com/m9o8/repelly
|
|
2
|
+
# Copyright (C) 2026 Moritz Peist
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
# This program comes with ABSOLUTELY NO WARRANTY. It is free software: you can
|
|
5
|
+
# redistribute it and/or modify it under the terms of the GNU General Public
|
|
6
|
+
# License, version 3 or later. See <https://www.gnu.org/licenses/>.
|
|
7
|
+
|
|
8
|
+
"""Repelling, non-overlapping text labels for Plotly figures."""
|
|
9
|
+
|
|
10
|
+
from repelly._api import add_label_repel, add_text_repel
|
|
11
|
+
from repelly._figure import RepelFigure, as_repel_figure
|
|
12
|
+
from repelly._static import bake
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"RepelFigure",
|
|
18
|
+
"__version__",
|
|
19
|
+
"add_label_repel",
|
|
20
|
+
"add_text_repel",
|
|
21
|
+
"as_repel_figure",
|
|
22
|
+
"bake",
|
|
23
|
+
]
|
repelly/_api.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
# repelly - https://github.com/m9o8/repelly
|
|
2
|
+
# Copyright (C) 2026 Moritz Peist
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
# This program comes with ABSOLUTELY NO WARRANTY. It is free software: you can
|
|
5
|
+
# redistribute it and/or modify it under the terms of the GNU General Public
|
|
6
|
+
# License, version 3 or later. See <https://www.gnu.org/licenses/>.
|
|
7
|
+
|
|
8
|
+
"""Public functions that add repelling labels to a figure."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import numbers
|
|
17
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import plotly.graph_objects as go
|
|
21
|
+
|
|
22
|
+
from repelly._figure import RepelFigure, as_repel_figure
|
|
23
|
+
|
|
24
|
+
META_KEY = "repelly"
|
|
25
|
+
SCHEMA_VERSION = 1
|
|
26
|
+
EVENTS = ("render", "zoom", "resize", "frame")
|
|
27
|
+
SUPPORTED_TRACES = ("scatter", "scattergl")
|
|
28
|
+
|
|
29
|
+
_DEFAULT_SEGMENT = {"color": "rgba(90,90,90,0.7)", "width": 1}
|
|
30
|
+
_DEFAULT_LABEL = {
|
|
31
|
+
"bgcolor": "rgba(255,255,255,0.9)",
|
|
32
|
+
"bordercolor": "rgba(0,0,0,0.3)",
|
|
33
|
+
"borderwidth": 1,
|
|
34
|
+
"borderpad": 3,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_text_repel(
|
|
39
|
+
fig: go.Figure,
|
|
40
|
+
x: Any = None,
|
|
41
|
+
y: Any = None,
|
|
42
|
+
text: Any = None,
|
|
43
|
+
*,
|
|
44
|
+
row: int | None = None,
|
|
45
|
+
col: int | None = None,
|
|
46
|
+
secondary_y: bool | None = None,
|
|
47
|
+
xref: str | None = None,
|
|
48
|
+
yref: str | None = None,
|
|
49
|
+
selector: Any = None,
|
|
50
|
+
frame: Any = None,
|
|
51
|
+
priority: Any = None,
|
|
52
|
+
visible: Any = None,
|
|
53
|
+
force: float = 1.0,
|
|
54
|
+
force_pull: float = 1.0,
|
|
55
|
+
box_padding: float = 2.0,
|
|
56
|
+
point_padding: float = 2.0,
|
|
57
|
+
direction: str = "both",
|
|
58
|
+
max_iter: int = 400,
|
|
59
|
+
max_time_ms: float = 40.0,
|
|
60
|
+
max_overlaps: int | None = 10,
|
|
61
|
+
max_labels: int | None = None,
|
|
62
|
+
seed: int = 42,
|
|
63
|
+
min_segment_length: float = 6.0,
|
|
64
|
+
avoid_points: bool = True,
|
|
65
|
+
drop_unresolved: bool = False,
|
|
66
|
+
font: Mapping[str, Any] | None = None,
|
|
67
|
+
segment: Mapping[str, Any] | None = None,
|
|
68
|
+
on: str | Iterable[str] = EVENTS,
|
|
69
|
+
) -> RepelFigure:
|
|
70
|
+
"""Return a copy of ``fig`` with repelling text labels added.
|
|
71
|
+
|
|
72
|
+
Labels are positioned in the browser and re-solved as the viewport changes.
|
|
73
|
+
The returned figure must be kept: its ``show``, ``to_html``, ``write_html``,
|
|
74
|
+
``to_image`` and ``write_image`` methods carry the label behaviour.
|
|
75
|
+
|
|
76
|
+
Coordinates come from one of two places:
|
|
77
|
+
|
|
78
|
+
* Explicit: pass ``x``, ``y`` and ``text``. Use ``row``/``col`` or
|
|
79
|
+
``xref``/``yref`` to target a subplot.
|
|
80
|
+
* From traces: omit ``x`` and ``y`` to label the points of existing scatter
|
|
81
|
+
traces, which is what you want for ``plotly.express`` facets and
|
|
82
|
+
animations. Labels hide along with their trace. ``text`` then defaults to
|
|
83
|
+
each trace's ``text`` (or ``hovertext``), and may be a function of the
|
|
84
|
+
trace. ``selector``, ``row`` and ``col`` narrow down the traces.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
fig:
|
|
89
|
+
The figure to label. It is not modified.
|
|
90
|
+
x, y:
|
|
91
|
+
Label anchors in data coordinates.
|
|
92
|
+
text:
|
|
93
|
+
Label strings. Empty or missing entries get no label.
|
|
94
|
+
row, col, secondary_y, xref, yref:
|
|
95
|
+
Target subplot. Defaults to the primary ``x``/``y`` axes.
|
|
96
|
+
selector:
|
|
97
|
+
Trace filter, as for ``Figure.select_traces``, in from-traces mode.
|
|
98
|
+
frame:
|
|
99
|
+
Animation frame name for each label, in explicit mode. From-traces mode
|
|
100
|
+
reads ``fig.frames`` instead.
|
|
101
|
+
priority:
|
|
102
|
+
Higher values are kept first when labels are culled. A sequence, or in
|
|
103
|
+
from-traces mode a function of the trace.
|
|
104
|
+
visible:
|
|
105
|
+
Boolean mask of labels to show. Hidden points still repel other labels.
|
|
106
|
+
A sequence, or in from-traces mode a function of the trace.
|
|
107
|
+
force:
|
|
108
|
+
How strongly overlapping labels separate, from 0.05 up to 1.
|
|
109
|
+
force_pull:
|
|
110
|
+
How strongly labels are drawn back towards their anchors.
|
|
111
|
+
box_padding, point_padding:
|
|
112
|
+
Gaps in pixels around each label box and around each point.
|
|
113
|
+
direction:
|
|
114
|
+
Axes labels may move along: ``"both"``, ``"x"`` or ``"y"``.
|
|
115
|
+
max_iter, max_time_ms:
|
|
116
|
+
Iteration and time limits for one solve.
|
|
117
|
+
max_overlaps:
|
|
118
|
+
Drop a label that starts on top of more than this many higher-priority
|
|
119
|
+
labels. ``None`` keeps every label.
|
|
120
|
+
max_labels:
|
|
121
|
+
Show at most this many labels per subplot, by priority.
|
|
122
|
+
seed:
|
|
123
|
+
Random seed, which makes placement reproducible.
|
|
124
|
+
min_segment_length:
|
|
125
|
+
Hide the connector when the label is closer than this many pixels.
|
|
126
|
+
avoid_points:
|
|
127
|
+
Keep labels off every marker in the subplot, labelled or not.
|
|
128
|
+
drop_unresolved:
|
|
129
|
+
Hide labels that still overlap another label after solving.
|
|
130
|
+
font:
|
|
131
|
+
Plotly annotation font, for example ``{"size": 13, "color": "navy"}``.
|
|
132
|
+
segment:
|
|
133
|
+
Connector style: ``color``, ``width``, ``arrowhead``, ``arrowsize``.
|
|
134
|
+
on:
|
|
135
|
+
Events that re-solve: any of ``"render"``, ``"zoom"``, ``"resize"``,
|
|
136
|
+
``"frame"``. The first render is always solved.
|
|
137
|
+
"""
|
|
138
|
+
return _add_layer(
|
|
139
|
+
fig,
|
|
140
|
+
x,
|
|
141
|
+
y,
|
|
142
|
+
text,
|
|
143
|
+
label=None,
|
|
144
|
+
row=row,
|
|
145
|
+
col=col,
|
|
146
|
+
secondary_y=secondary_y,
|
|
147
|
+
xref=xref,
|
|
148
|
+
yref=yref,
|
|
149
|
+
selector=selector,
|
|
150
|
+
frame=frame,
|
|
151
|
+
priority=priority,
|
|
152
|
+
visible=visible,
|
|
153
|
+
force=force,
|
|
154
|
+
force_pull=force_pull,
|
|
155
|
+
box_padding=box_padding,
|
|
156
|
+
point_padding=point_padding,
|
|
157
|
+
direction=direction,
|
|
158
|
+
max_iter=max_iter,
|
|
159
|
+
max_time_ms=max_time_ms,
|
|
160
|
+
max_overlaps=max_overlaps,
|
|
161
|
+
max_labels=max_labels,
|
|
162
|
+
seed=seed,
|
|
163
|
+
min_segment_length=min_segment_length,
|
|
164
|
+
avoid_points=avoid_points,
|
|
165
|
+
drop_unresolved=drop_unresolved,
|
|
166
|
+
font=font,
|
|
167
|
+
segment=segment,
|
|
168
|
+
on=on,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def add_label_repel(
|
|
173
|
+
fig: go.Figure,
|
|
174
|
+
x: Any = None,
|
|
175
|
+
y: Any = None,
|
|
176
|
+
text: Any = None,
|
|
177
|
+
*,
|
|
178
|
+
label: Mapping[str, Any] | None = None,
|
|
179
|
+
**kwargs: Any,
|
|
180
|
+
) -> RepelFigure:
|
|
181
|
+
"""Like :func:`add_text_repel`, with labels drawn on a filled box.
|
|
182
|
+
|
|
183
|
+
``label`` overrides the box style with any of ``bgcolor``, ``bordercolor``,
|
|
184
|
+
``borderwidth`` and ``borderpad``.
|
|
185
|
+
"""
|
|
186
|
+
unknown = set(label or ()) - set(_DEFAULT_LABEL)
|
|
187
|
+
if unknown:
|
|
188
|
+
raise ValueError(f"unknown label style keys: {sorted(unknown)}")
|
|
189
|
+
style = {**_DEFAULT_LABEL, **dict(label or {})}
|
|
190
|
+
return _add_layer(fig, x, y, text, label=style, **kwargs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _add_layer(
|
|
194
|
+
fig: go.Figure,
|
|
195
|
+
x: Any,
|
|
196
|
+
y: Any,
|
|
197
|
+
text: Any,
|
|
198
|
+
*,
|
|
199
|
+
label: Mapping[str, Any] | None,
|
|
200
|
+
row: int | None = None,
|
|
201
|
+
col: int | None = None,
|
|
202
|
+
secondary_y: bool | None = None,
|
|
203
|
+
xref: str | None = None,
|
|
204
|
+
yref: str | None = None,
|
|
205
|
+
selector: Any = None,
|
|
206
|
+
frame: Any = None,
|
|
207
|
+
priority: Any = None,
|
|
208
|
+
visible: Any = None,
|
|
209
|
+
font: Mapping[str, Any] | None = None,
|
|
210
|
+
segment: Mapping[str, Any] | None = None,
|
|
211
|
+
on: str | Iterable[str] = EVENTS,
|
|
212
|
+
**solver: Any,
|
|
213
|
+
) -> RepelFigure:
|
|
214
|
+
if not isinstance(fig, go.Figure):
|
|
215
|
+
raise TypeError(f"fig must be a plotly Figure, got {type(fig).__name__}")
|
|
216
|
+
if (x is None) != (y is None):
|
|
217
|
+
raise ValueError("pass both x and y, or neither to label existing traces")
|
|
218
|
+
|
|
219
|
+
events = _validate_events(on)
|
|
220
|
+
solver = _validate_solver(solver)
|
|
221
|
+
result = as_repel_figure(fig, copy=True)
|
|
222
|
+
|
|
223
|
+
if x is None:
|
|
224
|
+
if frame is not None:
|
|
225
|
+
raise ValueError("frame is read from fig.frames when labelling traces")
|
|
226
|
+
if xref is not None or yref is not None or secondary_y is not None:
|
|
227
|
+
raise ValueError("xref, yref and secondary_y need explicit x and y")
|
|
228
|
+
columns = _columns_from_traces(
|
|
229
|
+
result, text, priority, visible, selector, row, col
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
if selector is not None:
|
|
233
|
+
raise ValueError("selector only applies when x and y are omitted")
|
|
234
|
+
if text is None:
|
|
235
|
+
raise ValueError("text is required when x and y are given")
|
|
236
|
+
refs = _resolve_refs(result, row, col, secondary_y, xref, yref)
|
|
237
|
+
columns = _columns_from_arrays(x, y, text, refs, frame, priority, visible)
|
|
238
|
+
|
|
239
|
+
layer = _build_layer(_drop_unlabelled(columns), label, font, segment, solver)
|
|
240
|
+
_store_layer(result, layer, events)
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _validate_events(on: str | Iterable[str]) -> list[str]:
|
|
245
|
+
events = [on] if isinstance(on, str) else list(on)
|
|
246
|
+
bad = [event for event in events if event not in EVENTS]
|
|
247
|
+
if bad:
|
|
248
|
+
raise ValueError(f"unknown events {bad}; choose from {list(EVENTS)}")
|
|
249
|
+
return events
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _validate_solver(solver: dict[str, Any]) -> dict[str, Any]:
|
|
253
|
+
known = {
|
|
254
|
+
"force": 1.0,
|
|
255
|
+
"force_pull": 1.0,
|
|
256
|
+
"box_padding": 2.0,
|
|
257
|
+
"point_padding": 2.0,
|
|
258
|
+
"direction": "both",
|
|
259
|
+
"max_iter": 400,
|
|
260
|
+
"max_time_ms": 40.0,
|
|
261
|
+
"max_overlaps": 10,
|
|
262
|
+
"max_labels": None,
|
|
263
|
+
"seed": 42,
|
|
264
|
+
"min_segment_length": 6.0,
|
|
265
|
+
"avoid_points": True,
|
|
266
|
+
"drop_unresolved": False,
|
|
267
|
+
}
|
|
268
|
+
unknown = set(solver) - set(known)
|
|
269
|
+
if unknown:
|
|
270
|
+
raise TypeError(f"unexpected keyword arguments: {sorted(unknown)}")
|
|
271
|
+
merged = {**known, **solver}
|
|
272
|
+
|
|
273
|
+
if merged["direction"] not in ("both", "x", "y"):
|
|
274
|
+
raise ValueError("direction must be 'both', 'x' or 'y'")
|
|
275
|
+
for name in (
|
|
276
|
+
"force",
|
|
277
|
+
"force_pull",
|
|
278
|
+
"box_padding",
|
|
279
|
+
"point_padding",
|
|
280
|
+
"max_time_ms",
|
|
281
|
+
"min_segment_length",
|
|
282
|
+
):
|
|
283
|
+
value = merged[name]
|
|
284
|
+
if not _is_real(value) or value < 0:
|
|
285
|
+
raise ValueError(f"{name} must be a non-negative number")
|
|
286
|
+
if not isinstance(merged["max_iter"], numbers.Integral) or merged["max_iter"] < 1:
|
|
287
|
+
raise ValueError("max_iter must be a positive integer")
|
|
288
|
+
if not isinstance(merged["seed"], numbers.Integral):
|
|
289
|
+
raise ValueError("seed must be an integer")
|
|
290
|
+
for name, minimum in (("max_overlaps", 0), ("max_labels", 1)):
|
|
291
|
+
value = merged[name]
|
|
292
|
+
if value is not None and (
|
|
293
|
+
not isinstance(value, numbers.Integral) or value < minimum
|
|
294
|
+
):
|
|
295
|
+
raise ValueError(f"{name} must be None or an integer >= {minimum}")
|
|
296
|
+
return {key: _to_native(value) for key, value in merged.items()}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_real(value: Any) -> bool:
|
|
300
|
+
return (
|
|
301
|
+
isinstance(value, numbers.Real)
|
|
302
|
+
and not isinstance(value, bool)
|
|
303
|
+
and math.isfinite(value)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _resolve_refs(
|
|
308
|
+
fig: go.Figure,
|
|
309
|
+
row: int | None,
|
|
310
|
+
col: int | None,
|
|
311
|
+
secondary_y: bool | None,
|
|
312
|
+
xref: str | None,
|
|
313
|
+
yref: str | None,
|
|
314
|
+
) -> tuple[str, str]:
|
|
315
|
+
if (row is None) != (col is None):
|
|
316
|
+
raise ValueError("row and col must be given together")
|
|
317
|
+
if row is not None:
|
|
318
|
+
if xref is not None or yref is not None:
|
|
319
|
+
raise ValueError("use either row/col or xref/yref, not both")
|
|
320
|
+
subplot = fig.get_subplot(row, col, secondary_y=bool(secondary_y))
|
|
321
|
+
if not hasattr(subplot, "xaxis") or not hasattr(subplot, "yaxis"):
|
|
322
|
+
raise ValueError(f"subplot ({row}, {col}) is not a cartesian x/y subplot")
|
|
323
|
+
return _axis_ref(subplot.xaxis.plotly_name), _axis_ref(
|
|
324
|
+
subplot.yaxis.plotly_name
|
|
325
|
+
)
|
|
326
|
+
if secondary_y is not None:
|
|
327
|
+
raise ValueError("secondary_y requires row and col")
|
|
328
|
+
|
|
329
|
+
resolved = (xref or "x", yref or "y")
|
|
330
|
+
for ref, letter in zip(resolved, "xy", strict=True):
|
|
331
|
+
if not ref.startswith(letter) or not (ref[1:] == "" or ref[1:].isdigit()):
|
|
332
|
+
raise ValueError(f"invalid {letter}ref {ref!r}")
|
|
333
|
+
return resolved
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _axis_ref(plotly_name: str) -> str:
|
|
337
|
+
return plotly_name.replace("axis", "")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _to_list(values: Any, name: str) -> list[Any]:
|
|
341
|
+
if isinstance(values, (str, bytes, Mapping)) or not isinstance(values, Iterable):
|
|
342
|
+
raise TypeError(f"{name} must be a sequence, got {type(values).__name__}")
|
|
343
|
+
dtype = getattr(values, "dtype", None)
|
|
344
|
+
if (
|
|
345
|
+
type(values).__module__.startswith("numpy")
|
|
346
|
+
and getattr(dtype, "kind", "") == "M"
|
|
347
|
+
):
|
|
348
|
+
values = values.astype("datetime64[us]")
|
|
349
|
+
return list(values.tolist() if hasattr(values, "tolist") else values)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _to_native(value: Any) -> Any:
|
|
353
|
+
"""Convert one value to something JSON can hold, or None if it is missing."""
|
|
354
|
+
if value is None or isinstance(value, (str, bool)):
|
|
355
|
+
return value
|
|
356
|
+
if isinstance(value, dt.datetime):
|
|
357
|
+
return None if value != value else value.isoformat()
|
|
358
|
+
if isinstance(value, dt.date):
|
|
359
|
+
return value.isoformat()
|
|
360
|
+
if hasattr(value, "item") and callable(value.item):
|
|
361
|
+
value = value.item()
|
|
362
|
+
if isinstance(value, (dt.date, dt.datetime)):
|
|
363
|
+
return value.isoformat()
|
|
364
|
+
if isinstance(value, numbers.Integral):
|
|
365
|
+
return int(value)
|
|
366
|
+
if isinstance(value, numbers.Real):
|
|
367
|
+
number = float(value)
|
|
368
|
+
return number if math.isfinite(number) else None
|
|
369
|
+
return None if value != value else value
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _to_text(value: Any) -> str:
|
|
373
|
+
native = _to_native(value)
|
|
374
|
+
if native is None:
|
|
375
|
+
return ""
|
|
376
|
+
if isinstance(native, float) and native.is_integer():
|
|
377
|
+
return str(int(native))
|
|
378
|
+
return str(native)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _check_length(values: list[Any], name: str, expected: int) -> None:
|
|
382
|
+
if len(values) != expected:
|
|
383
|
+
raise ValueError(f"{name} has {len(values)} values, expected {expected}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _priority_value(value: Any) -> float:
|
|
387
|
+
native = _to_native(value)
|
|
388
|
+
if native is None:
|
|
389
|
+
return 0.0
|
|
390
|
+
if not _is_real(native):
|
|
391
|
+
raise ValueError(f"priority values must be numeric, got {value!r}")
|
|
392
|
+
return float(native)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _visible_value(value: Any) -> bool:
|
|
396
|
+
native = _to_native(value)
|
|
397
|
+
return bool(native) if native is not None else False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _columns_from_arrays(
|
|
401
|
+
x: Any,
|
|
402
|
+
y: Any,
|
|
403
|
+
text: Any,
|
|
404
|
+
refs: tuple[str, str],
|
|
405
|
+
frame: Any,
|
|
406
|
+
priority: Any,
|
|
407
|
+
visible: Any,
|
|
408
|
+
) -> dict[str, list[Any]]:
|
|
409
|
+
xs = [_to_native(v) for v in _to_list(x, "x")]
|
|
410
|
+
n = len(xs)
|
|
411
|
+
ys = [_to_native(v) for v in _to_list(y, "y")]
|
|
412
|
+
texts = [_to_text(v) for v in _to_list(text, "text")]
|
|
413
|
+
_check_length(ys, "y", n)
|
|
414
|
+
_check_length(texts, "text", n)
|
|
415
|
+
|
|
416
|
+
columns: dict[str, list[Any]] = {
|
|
417
|
+
"x": xs,
|
|
418
|
+
"y": ys,
|
|
419
|
+
"text": texts,
|
|
420
|
+
"xref": [refs[0]] * n,
|
|
421
|
+
"yref": [refs[1]] * n,
|
|
422
|
+
"trace": [None] * n,
|
|
423
|
+
"frame": [None] * n,
|
|
424
|
+
"priority": [0.0] * n,
|
|
425
|
+
"visible": [True] * n,
|
|
426
|
+
}
|
|
427
|
+
if frame is not None:
|
|
428
|
+
frames = [_to_text(v) or None for v in _to_list(frame, "frame")]
|
|
429
|
+
_check_length(frames, "frame", n)
|
|
430
|
+
columns["frame"] = frames
|
|
431
|
+
if priority is not None:
|
|
432
|
+
priorities = [_priority_value(v) for v in _to_list(priority, "priority")]
|
|
433
|
+
_check_length(priorities, "priority", n)
|
|
434
|
+
columns["priority"] = priorities
|
|
435
|
+
if visible is not None:
|
|
436
|
+
mask = [_visible_value(v) for v in _to_list(visible, "visible")]
|
|
437
|
+
_check_length(mask, "visible", n)
|
|
438
|
+
columns["visible"] = mask
|
|
439
|
+
return columns
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _select_trace_indices(
|
|
443
|
+
fig: go.Figure, selector: Any, row: int | None, col: int | None
|
|
444
|
+
) -> list[int]:
|
|
445
|
+
if (row is None) != (col is None):
|
|
446
|
+
raise ValueError("row and col must be given together")
|
|
447
|
+
chosen = {id(t) for t in fig.select_traces(selector=selector, row=row, col=col)}
|
|
448
|
+
indices = [i for i, trace in enumerate(fig.data) if id(trace) in chosen]
|
|
449
|
+
if not indices:
|
|
450
|
+
raise ValueError("no traces match; pass x and y, or adjust selector/row/col")
|
|
451
|
+
for i in indices:
|
|
452
|
+
if fig.data[i].type not in SUPPORTED_TRACES:
|
|
453
|
+
raise TypeError(
|
|
454
|
+
f"trace {i} has type {fig.data[i].type!r}; "
|
|
455
|
+
f"repelly can only label {list(SUPPORTED_TRACES)} traces"
|
|
456
|
+
)
|
|
457
|
+
return indices
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _trace_coords(trace: Any, base: Any, axis: str) -> list[Any] | None:
|
|
461
|
+
values = trace[axis] if trace[axis] is not None else base[axis]
|
|
462
|
+
return None if values is None else _to_list(values, axis)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _resolve_positions(trace: Any, base: Any) -> tuple[list[Any], list[Any]]:
|
|
466
|
+
xs = _trace_coords(trace, base, "x")
|
|
467
|
+
ys = _trace_coords(trace, base, "y")
|
|
468
|
+
if xs is None and ys is None:
|
|
469
|
+
raise ValueError("cannot label a trace without x or y data")
|
|
470
|
+
if xs is None:
|
|
471
|
+
x0, dx = base["x0"] or 0, base["dx"] or 1
|
|
472
|
+
xs = [x0 + i * dx for i in range(len(ys))]
|
|
473
|
+
if ys is None:
|
|
474
|
+
y0, dy = base["y0"] or 0, base["dy"] or 1
|
|
475
|
+
ys = [y0 + i * dy for i in range(len(xs))]
|
|
476
|
+
return xs, ys
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _per_point(
|
|
480
|
+
spec: Any, trace: Any, base: Any, name: str, n: int, default: Any
|
|
481
|
+
) -> list[Any]:
|
|
482
|
+
if spec is None:
|
|
483
|
+
return [default] * n
|
|
484
|
+
values = spec(trace) if isinstance(spec, Callable) else spec
|
|
485
|
+
resolved = _to_list(values, name)
|
|
486
|
+
_check_length(resolved, name, n)
|
|
487
|
+
return resolved
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _default_text(trace: Any, base: Any, n: int) -> list[Any]:
|
|
491
|
+
for attribute in ("text", "hovertext"):
|
|
492
|
+
for source in (trace, base):
|
|
493
|
+
value = source[attribute]
|
|
494
|
+
if value is None:
|
|
495
|
+
continue
|
|
496
|
+
if isinstance(value, str):
|
|
497
|
+
return [value] * n
|
|
498
|
+
return _to_list(value, attribute)
|
|
499
|
+
raise ValueError(
|
|
500
|
+
"these traces have no text or hovertext; pass text=, for example "
|
|
501
|
+
"text=lambda trace: trace.customdata[:, 0]"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _columns_from_traces(
|
|
506
|
+
fig: go.Figure,
|
|
507
|
+
text: Any,
|
|
508
|
+
priority: Any,
|
|
509
|
+
visible: Any,
|
|
510
|
+
selector: Any,
|
|
511
|
+
row: int | None,
|
|
512
|
+
col: int | None,
|
|
513
|
+
) -> dict[str, list[Any]]:
|
|
514
|
+
for name, spec in (("text", text), ("priority", priority), ("visible", visible)):
|
|
515
|
+
if spec is not None and not callable(spec):
|
|
516
|
+
raise TypeError(f"without x and y, {name} must be a function of the trace")
|
|
517
|
+
|
|
518
|
+
indices = set(_select_trace_indices(fig, selector, row, col))
|
|
519
|
+
sources: list[tuple[int, Any, str | None]] = []
|
|
520
|
+
if fig.frames:
|
|
521
|
+
for frame in fig.frames:
|
|
522
|
+
positions = (
|
|
523
|
+
frame.traces if frame.traces is not None else range(len(frame.data))
|
|
524
|
+
)
|
|
525
|
+
for position, trace in zip(positions, frame.data, strict=False):
|
|
526
|
+
if position in indices:
|
|
527
|
+
sources.append((position, trace, frame.name))
|
|
528
|
+
else:
|
|
529
|
+
sources = [(i, fig.data[i], None) for i in sorted(indices)]
|
|
530
|
+
|
|
531
|
+
columns: dict[str, list[Any]] = {
|
|
532
|
+
key: []
|
|
533
|
+
for key in (
|
|
534
|
+
"x",
|
|
535
|
+
"y",
|
|
536
|
+
"text",
|
|
537
|
+
"xref",
|
|
538
|
+
"yref",
|
|
539
|
+
"trace",
|
|
540
|
+
"frame",
|
|
541
|
+
"priority",
|
|
542
|
+
"visible",
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
for position, trace, frame_name in sources:
|
|
546
|
+
base = fig.data[position]
|
|
547
|
+
xs, ys = _resolve_positions(trace, base)
|
|
548
|
+
n = min(len(xs), len(ys))
|
|
549
|
+
texts = (
|
|
550
|
+
_default_text(trace, base, n)
|
|
551
|
+
if text is None
|
|
552
|
+
else _per_point(text, trace, base, "text", n, "")
|
|
553
|
+
)
|
|
554
|
+
_check_length(texts, "text", n)
|
|
555
|
+
columns["x"].extend(_to_native(v) for v in xs[:n])
|
|
556
|
+
columns["y"].extend(_to_native(v) for v in ys[:n])
|
|
557
|
+
columns["text"].extend(_to_text(v) for v in texts)
|
|
558
|
+
columns["xref"].extend([base.xaxis or "x"] * n)
|
|
559
|
+
columns["yref"].extend([base.yaxis or "y"] * n)
|
|
560
|
+
columns["trace"].extend([position] * n)
|
|
561
|
+
columns["frame"].extend([frame_name] * n)
|
|
562
|
+
columns["priority"].extend(
|
|
563
|
+
_priority_value(v)
|
|
564
|
+
for v in _per_point(priority, trace, base, "priority", n, 0.0)
|
|
565
|
+
)
|
|
566
|
+
columns["visible"].extend(
|
|
567
|
+
_visible_value(v)
|
|
568
|
+
for v in _per_point(visible, trace, base, "visible", n, True)
|
|
569
|
+
)
|
|
570
|
+
return columns
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _drop_unlabelled(columns: dict[str, list[Any]]) -> dict[str, list[Any]]:
|
|
574
|
+
keep = [
|
|
575
|
+
i
|
|
576
|
+
for i in range(len(columns["x"]))
|
|
577
|
+
if columns["x"][i] is not None
|
|
578
|
+
and columns["y"][i] is not None
|
|
579
|
+
and columns["text"][i] != ""
|
|
580
|
+
]
|
|
581
|
+
if not keep:
|
|
582
|
+
raise ValueError(
|
|
583
|
+
"no labels to add: every point has an empty label or missing coordinate"
|
|
584
|
+
)
|
|
585
|
+
return {key: [values[i] for i in keep] for key, values in columns.items()}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _compact(values: Sequence[Any]) -> Any:
|
|
589
|
+
"""Collapse a constant column to one value to keep the figure JSON small."""
|
|
590
|
+
first = values[0]
|
|
591
|
+
return first if all(value == first for value in values) else list(values)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _build_layer(
|
|
595
|
+
columns: dict[str, list[Any]],
|
|
596
|
+
label: Mapping[str, Any] | None,
|
|
597
|
+
font: Mapping[str, Any] | None,
|
|
598
|
+
segment: Mapping[str, Any] | None,
|
|
599
|
+
solver: dict[str, Any],
|
|
600
|
+
) -> dict[str, Any]:
|
|
601
|
+
unknown = set(segment or ()) - {"color", "width", "arrowhead", "arrowsize"}
|
|
602
|
+
if unknown:
|
|
603
|
+
raise ValueError(f"unknown segment style keys: {sorted(unknown)}")
|
|
604
|
+
|
|
605
|
+
layer: dict[str, Any] = {
|
|
606
|
+
"x": columns["x"],
|
|
607
|
+
"y": columns["y"],
|
|
608
|
+
"text": columns["text"],
|
|
609
|
+
**{
|
|
610
|
+
key: _compact(columns[key])
|
|
611
|
+
for key in ("xref", "yref", "trace", "frame", "priority", "visible")
|
|
612
|
+
},
|
|
613
|
+
"solver": solver,
|
|
614
|
+
"segment": {**_DEFAULT_SEGMENT, **dict(segment or {})},
|
|
615
|
+
}
|
|
616
|
+
if font:
|
|
617
|
+
layer["font"] = dict(font)
|
|
618
|
+
if label:
|
|
619
|
+
layer["label"] = dict(label)
|
|
620
|
+
return layer
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _store_layer(fig: RepelFigure, layer: dict[str, Any], events: list[str]) -> None:
|
|
624
|
+
meta = fig.layout.meta
|
|
625
|
+
if meta is None:
|
|
626
|
+
meta = {}
|
|
627
|
+
elif not isinstance(meta, Mapping):
|
|
628
|
+
raise ValueError(
|
|
629
|
+
"layout.meta is already set to a non-dict value; repelly stores its "
|
|
630
|
+
"configuration in layout.meta and needs it to be a dict"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
existing = meta.get(META_KEY) or {}
|
|
634
|
+
layers = [*existing.get("layers", []), layer]
|
|
635
|
+
merged_events = sorted(set(existing.get("on", [])) | set(events))
|
|
636
|
+
config: dict[str, Any] = {
|
|
637
|
+
"schema": SCHEMA_VERSION,
|
|
638
|
+
"layers": layers,
|
|
639
|
+
"on": merged_events,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
frames = [frame.name for frame in (fig.frames or ())]
|
|
643
|
+
if frames:
|
|
644
|
+
config["first_frame"] = frames[0]
|
|
645
|
+
|
|
646
|
+
canonical = json.dumps(config, sort_keys=True, default=str).encode()
|
|
647
|
+
config["hash"] = hashlib.sha1(canonical, usedforsecurity=False).hexdigest()
|
|
648
|
+
fig.layout.meta = {**dict(meta), META_KEY: config}
|