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 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}