anyplotlib 0.1.0b1__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.
Files changed (42) hide show
  1. anyplotlib/__init__.py +53 -0
  2. anyplotlib/_base_plot.py +229 -0
  3. anyplotlib/_electron.py +74 -0
  4. anyplotlib/_repr_utils.py +345 -0
  5. anyplotlib/_utils.py +194 -0
  6. anyplotlib/axes/__init__.py +6 -0
  7. anyplotlib/axes/_axes.py +510 -0
  8. anyplotlib/axes/_inset_axes.py +126 -0
  9. anyplotlib/callbacks.py +328 -0
  10. anyplotlib/embed.py +194 -0
  11. anyplotlib/figure/__init__.py +7 -0
  12. anyplotlib/figure/_figure.py +633 -0
  13. anyplotlib/figure/_gridspec.py +99 -0
  14. anyplotlib/figure/_subplots.py +100 -0
  15. anyplotlib/figure_esm.js +6011 -0
  16. anyplotlib/markers.py +704 -0
  17. anyplotlib/plot1d/__init__.py +6 -0
  18. anyplotlib/plot1d/_plot1d.py +1376 -0
  19. anyplotlib/plot1d/_plotbar.py +436 -0
  20. anyplotlib/plot2d/__init__.py +5 -0
  21. anyplotlib/plot2d/_plot2d.py +726 -0
  22. anyplotlib/plot2d/_plotmesh.py +116 -0
  23. anyplotlib/plot3d/__init__.py +4 -0
  24. anyplotlib/plot3d/_plot3d.py +524 -0
  25. anyplotlib/plotxy/__init__.py +4 -0
  26. anyplotlib/plotxy/_plotxy.py +273 -0
  27. anyplotlib/sphinx_anywidget/__init__.py +177 -0
  28. anyplotlib/sphinx_anywidget/_directive.py +245 -0
  29. anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
  30. anyplotlib/sphinx_anywidget/_scraper.py +390 -0
  31. anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
  32. anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
  33. anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
  34. anyplotlib/widgets/__init__.py +18 -0
  35. anyplotlib/widgets/_base.py +218 -0
  36. anyplotlib/widgets/_widgets1d.py +109 -0
  37. anyplotlib/widgets/_widgets2d.py +141 -0
  38. anyplotlib/widgets/_widgets3d.py +50 -0
  39. anyplotlib-0.1.0b1.dist-info/METADATA +160 -0
  40. anyplotlib-0.1.0b1.dist-info/RECORD +42 -0
  41. anyplotlib-0.1.0b1.dist-info/WHEEL +4 -0
  42. anyplotlib-0.1.0b1.dist-info/licenses/LICENSE +21 -0
anyplotlib/markers.py ADDED
@@ -0,0 +1,704 @@
1
+ """
2
+ markers.py
3
+ ==========
4
+
5
+ Marker registry for Plot1D and Plot2D panels inside a Figure.
6
+
7
+ The public API mirrors matplotlib's collection kwargs:
8
+
9
+ plot.add_circles(offsets, name="group1",
10
+ facecolors="#ff0000", edgecolors="#ffffff",
11
+ radius=5, labels=[...])
12
+
13
+ plot.markers["circles"]["group1"].set(offsets=new_offsets, radius=3)
14
+
15
+ Design
16
+ ------
17
+ * ``MarkerGroup`` – a single named collection of markers (one type).
18
+ * ``MarkerTypeDict`` – dict-like for one type; mutations propagate to the plot.
19
+ * ``MarkerRegistry`` – top-level two-level dict: registry[type][name].
20
+
21
+ All state is stored as plain Python dicts; no traitlets here. The ``_push``
22
+ callback is supplied by the parent plot and is responsible for serialising
23
+ the full registry into the panel's JSON trait on the Figure widget.
24
+
25
+ Wire-format translation
26
+ -----------------------
27
+ The JS renderer uses the same internal field names as the standalone viewers
28
+ (``color``, ``fill_color``, ``fill_alpha``, ``sizes``, etc.). ``MarkerGroup``
29
+ stores matplotlib-style names and ``to_wire()`` translates before JSON
30
+ serialisation.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import numpy as np
36
+
37
+ __all__ = ["MarkerGroup", "MarkerTypeDict", "MarkerRegistry"]
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Type → wire-format converter registry
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _broadcast(val, n: int) -> list:
44
+ """Broadcast scalar or sequence to a list of length *n*."""
45
+ arr = np.asarray(val, dtype=float)
46
+ if arr.ndim == 0:
47
+ return np.full(n, float(arr)).tolist()
48
+ if arr.ndim == 1 and len(arr) == n:
49
+ return arr.tolist()
50
+ raise ValueError(f"Expected scalar or length-{n} array, got shape {arr.shape}")
51
+
52
+
53
+ def _offsets_2d(offsets) -> list:
54
+ arr = np.asarray(offsets, dtype=float)
55
+ if arr.ndim == 1 and arr.shape[0] == 2:
56
+ arr = arr[np.newaxis, :]
57
+ if arr.ndim != 2 or arr.shape[1] != 2:
58
+ raise ValueError("offsets must be shape (N, 2)")
59
+ return arr.tolist()
60
+
61
+
62
+ _VALID_TRANSFORMS = frozenset({"data", "axes", "display"})
63
+
64
+
65
+ def _apply_fill_color(wire: dict, d: dict) -> None:
66
+ """Apply facecolors/alpha fill fields to a wire dict if facecolors is set."""
67
+ fc = d.get("facecolors")
68
+ if fc is not None:
69
+ wire["fill_color"] = fc
70
+ wire["fill_alpha"] = float(d.get("alpha", 0.3))
71
+
72
+
73
+ def _offsets_1d(offsets) -> list:
74
+ """Accept (N,), (N,1) or (N,2) — return (N,1) or (N,2) list."""
75
+ arr = np.asarray(offsets, dtype=float)
76
+ if arr.ndim == 0:
77
+ return [[float(arr)]]
78
+ if arr.ndim == 1:
79
+ return arr[:, np.newaxis].tolist()
80
+ if arr.ndim == 2 and arr.shape[1] in (1, 2):
81
+ return arr.tolist()
82
+ raise ValueError("offsets must be 1-D or shape (N,1)/(N,2)")
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # MarkerGroup
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class MarkerGroup:
90
+ """A named collection of markers of one type on one plot.
91
+
92
+ Parameters
93
+ ----------
94
+ marker_type : str
95
+ One of the supported marker types (``'circles'``, ``'lines'``, …).
96
+ name : str
97
+ User-facing name (key in the parent :class:`MarkerTypeDict`).
98
+ kwargs : dict
99
+ Initial matplotlib-style kwargs for this group.
100
+ push_fn : callable
101
+ Zero-arg callback that serialises the full registry and pushes it to
102
+ the parent figure trait.
103
+ """
104
+
105
+ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn,
106
+ parent: "MarkerTypeDict | None" = None):
107
+ self._type = marker_type
108
+ self._name = name
109
+ tfm = kwargs.get("transform", "data")
110
+ if tfm not in _VALID_TRANSFORMS:
111
+ raise ValueError(
112
+ f"transform must be one of {sorted(_VALID_TRANSFORMS)}, got {tfm!r}"
113
+ )
114
+ self._data: dict = dict(kwargs)
115
+ self._push_fn = push_fn
116
+ self._parent: "MarkerTypeDict | None" = parent
117
+
118
+ # ------------------------------------------------------------------
119
+ def set(self, **kwargs) -> None:
120
+ """Update one or more properties and push the change to the plot.
121
+
122
+ Parameters
123
+ ----------
124
+ **kwargs : dict
125
+ Properties to update (e.g., offsets, radius, facecolors).
126
+ Matplotlib-style names are translated to wire format.
127
+ """
128
+ if "transform" in kwargs and kwargs["transform"] not in _VALID_TRANSFORMS:
129
+ raise ValueError(
130
+ f"transform must be one of {sorted(_VALID_TRANSFORMS)}, "
131
+ f"got {kwargs['transform']!r}"
132
+ )
133
+ self._data.update(kwargs)
134
+ self._push_fn()
135
+
136
+ def remove(self) -> None:
137
+ """Remove this group from its parent and trigger a re-render."""
138
+ if self._parent is None:
139
+ raise RuntimeError("MarkerGroup has no parent; cannot remove.")
140
+ del self._parent[self._name]
141
+
142
+ def __repr__(self) -> str: # pragma: no cover
143
+ return f"MarkerGroup(type={self._type!r}, name={self._name!r}, n={self._count()})"
144
+
145
+ def _count(self) -> int:
146
+ """Return the number of markers in this group."""
147
+ offs = self._data.get("offsets")
148
+ if offs is None:
149
+ return 0
150
+ try:
151
+ return len(np.asarray(offs))
152
+ except Exception:
153
+ return 0
154
+
155
+ # ------------------------------------------------------------------
156
+ # Wire-format serialisation
157
+ # ------------------------------------------------------------------
158
+ def to_wire(self, group_id: str) -> dict:
159
+ """Return a dict in the JS wire format for this marker group.
160
+
161
+ Parameters
162
+ ----------
163
+ group_id : str
164
+ Unique identifier for this marker group (usually UUID).
165
+
166
+ Returns
167
+ -------
168
+ dict
169
+ Wire-format dict with type-specific structure, ready for JSON
170
+ serialization and transmission to the JavaScript renderer.
171
+ """
172
+ d = self._data
173
+ t = self._type
174
+
175
+ # Build the wire dict based on marker type
176
+ if t == "circles":
177
+ offsets = _offsets_2d(d["offsets"])
178
+ n = len(offsets)
179
+ wire: dict = {
180
+ "id": group_id,
181
+ "name": self._name,
182
+ "type": "circles",
183
+ "offsets": offsets,
184
+ "sizes": _broadcast(d.get("radius", 5), n),
185
+ "color": d.get("edgecolors", "#ff0000"),
186
+ "linewidth": float(d.get("linewidths", 1.5)),
187
+ }
188
+ _apply_fill_color(wire, d)
189
+
190
+ elif t == "arrows":
191
+ offsets = _offsets_2d(d["offsets"])
192
+ n = len(offsets)
193
+ wire = {
194
+ "id": group_id,
195
+ "name": self._name,
196
+ "type": "arrows",
197
+ "offsets": offsets,
198
+ "U": _broadcast(d.get("U", 0), n),
199
+ "V": _broadcast(d.get("V", 0), n),
200
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
201
+ "linewidth": float(d.get("linewidths", 1.5)),
202
+ }
203
+
204
+ elif t == "ellipses":
205
+ offsets = _offsets_2d(d["offsets"])
206
+ n = len(offsets)
207
+ wire = {
208
+ "id": group_id,
209
+ "name": self._name,
210
+ "type": "ellipses",
211
+ "offsets": offsets,
212
+ "widths": _broadcast(d.get("widths", 10), n),
213
+ "heights": _broadcast(d.get("heights", 10), n),
214
+ "angles": _broadcast(d.get("angles", 0), n),
215
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
216
+ "linewidth": float(d.get("linewidths", 1.5)),
217
+ }
218
+ _apply_fill_color(wire, d)
219
+
220
+ elif t == "lines":
221
+ segs = np.asarray(d["segments"], dtype=float)
222
+ if segs.ndim == 2 and segs.shape == (2, 2):
223
+ segs = segs[np.newaxis]
224
+ if segs.ndim != 3 or segs.shape[1:] != (2, 2):
225
+ raise ValueError("segments must be shape (N,2,2)")
226
+ wire = {
227
+ "id": group_id,
228
+ "name": self._name,
229
+ "type": "lines",
230
+ "segments": segs.tolist(),
231
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
232
+ "linewidth": float(d.get("linewidths", 1.5)),
233
+ }
234
+
235
+ elif t == "rectangles":
236
+ offsets = _offsets_2d(d["offsets"])
237
+ n = len(offsets)
238
+ wire = {
239
+ "id": group_id,
240
+ "name": self._name,
241
+ "type": "rectangles",
242
+ "offsets": offsets,
243
+ "widths": _broadcast(d.get("widths", 10), n),
244
+ "heights": _broadcast(d.get("heights", 10), n),
245
+ "angles": _broadcast(d.get("angles", 0), n),
246
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
247
+ "linewidth": float(d.get("linewidths", 1.5)),
248
+ }
249
+ _apply_fill_color(wire, d)
250
+
251
+ elif t == "squares":
252
+ offsets = _offsets_2d(d["offsets"])
253
+ n = len(offsets)
254
+ wire = {
255
+ "id": group_id,
256
+ "name": self._name,
257
+ "type": "squares",
258
+ "offsets": offsets,
259
+ "widths": _broadcast(d.get("widths", 10), n),
260
+ "angles": _broadcast(d.get("angles", 0), n),
261
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
262
+ "linewidth": float(d.get("linewidths", 1.5)),
263
+ }
264
+ _apply_fill_color(wire, d)
265
+
266
+ elif t == "polygons":
267
+ vlist = []
268
+ for poly in d.get("vertices_list", []):
269
+ arr = np.asarray(poly, dtype=float)
270
+ if arr.ndim != 2 or arr.shape[1] != 2 or len(arr) < 3:
271
+ raise ValueError("each polygon must be (N>=3, 2)")
272
+ vlist.append(arr.tolist())
273
+ wire = {
274
+ "id": group_id,
275
+ "name": self._name,
276
+ "type": "polygons",
277
+ "vertices_list": vlist,
278
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
279
+ "linewidth": float(d.get("linewidths", 1.5)),
280
+ }
281
+ cp = d.get("clip_path")
282
+ if cp is not None:
283
+ cp_arr = np.asarray(cp, dtype=float)
284
+ if cp_arr.ndim == 2 and cp_arr.shape[1] == 2 and len(cp_arr) >= 3:
285
+ wire["clip_path"] = cp_arr.tolist()
286
+ _apply_fill_color(wire, d)
287
+
288
+ elif t == "texts":
289
+ offsets = _offsets_2d(d["offsets"])
290
+ texts = list(d.get("texts", []))
291
+ wire = {
292
+ "id": group_id,
293
+ "name": self._name,
294
+ "type": "texts",
295
+ "offsets": offsets,
296
+ "texts": texts,
297
+ "color": d.get("color", d.get("edgecolors", "#ff0000")),
298
+ "fontsize": int(d.get("fontsize", 12)),
299
+ }
300
+
301
+ # ── 1D-only types ───────────────────────────────────────────────────
302
+ elif t == "points":
303
+ offsets = _offsets_1d(d["offsets"])
304
+ n = len(offsets)
305
+ wire = {
306
+ "id": group_id,
307
+ "name": self._name,
308
+ "type": "points",
309
+ "offsets": offsets,
310
+ "sizes": _broadcast(d.get("sizes", 5), n),
311
+ "color": d.get("edgecolors", d.get("color", "#ff0000")),
312
+ "linewidth": float(d.get("linewidths", 1.5)),
313
+ }
314
+ _apply_fill_color(wire, d)
315
+
316
+ elif t == "vlines":
317
+ offsets = _offsets_1d(d["offsets"])
318
+ wire = {
319
+ "id": group_id,
320
+ "name": self._name,
321
+ "type": "vlines",
322
+ "offsets": [[row[0]] for row in offsets],
323
+ "color": d.get("color", d.get("edgecolors", "#ff0000")),
324
+ "linewidth": float(d.get("linewidths", 1.5)),
325
+ }
326
+
327
+ elif t == "hlines":
328
+ offsets = _offsets_1d(d["offsets"])
329
+ wire = {
330
+ "id": group_id,
331
+ "name": self._name,
332
+ "type": "hlines",
333
+ "offsets": [[row[0]] for row in offsets],
334
+ "color": d.get("color", d.get("edgecolors", "#ff0000")),
335
+ "linewidth": float(d.get("linewidths", 1.5)),
336
+ }
337
+
338
+ elif t == "raster":
339
+ # A single RGBA image drawn between data-coord ``extent`` corners.
340
+ # ``image_b64`` is the heavy payload — Plot1D.to_state_dict hoists it
341
+ # into the deduped geometry channel so it is sent once, not per frame.
342
+ ext = [float(v) for v in d["extent"]]
343
+ wire = {
344
+ "id": group_id,
345
+ "name": self._name,
346
+ "type": "raster",
347
+ "image_b64": d["image_b64"],
348
+ "image_width": int(d["image_width"]),
349
+ "image_height": int(d["image_height"]),
350
+ "extent": ext,
351
+ "smooth": bool(d.get("smooth", False)),
352
+ }
353
+ cp = d.get("clip_path")
354
+ if cp is not None:
355
+ cp_arr = np.asarray(cp, dtype=float)
356
+ if cp_arr.ndim == 2 and cp_arr.shape[1] == 2 and len(cp_arr) >= 3:
357
+ wire["clip_path"] = cp_arr.tolist()
358
+
359
+ else:
360
+ raise ValueError(f"Unknown marker type: {t!r}")
361
+
362
+ # ── coordinate transform (always emitted; defaults to "data") ──────
363
+ wire["transform"] = d.get("transform", "data")
364
+
365
+ # ── common optional fields ──────────────────────────────────────────
366
+ label = d.get("label")
367
+ if label is not None:
368
+ wire["label"] = str(label)
369
+ labels = d.get("labels")
370
+ if labels is not None:
371
+ wire["labels"] = [str(lb) for lb in labels]
372
+
373
+ # ── hover colours (optional) ────────────────────────────────────────
374
+ # Applied to all markers in the group when any one is hovered.
375
+ # Names mirror the base colour kwargs: edgecolors → hover_edgecolors,
376
+ # facecolors → hover_facecolors. Any CSS colour string is accepted.
377
+ hec = d.get("hover_edgecolors")
378
+ if hec is not None:
379
+ wire["hover_color"] = str(hec)
380
+ hfc = d.get("hover_facecolors")
381
+ if hfc is not None:
382
+ wire["hover_facecolor"] = str(hfc)
383
+
384
+ return wire
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # MarkerTypeDict
389
+ # ---------------------------------------------------------------------------
390
+
391
+ class MarkerTypeDict:
392
+ """Dict-like container for all named groups of one marker type.
393
+
394
+ Any modification (``__setitem__``, ``__delitem__``) automatically triggers
395
+ the ``_push_fn`` callback so the plot re-renders.
396
+
397
+ Parameters
398
+ ----------
399
+ marker_type : str
400
+ Type of markers (e.g., 'circles', 'arrows', 'lines').
401
+ push_fn : callable
402
+ Zero-arg callback to trigger re-render on mutations.
403
+ """
404
+
405
+ def __init__(self, marker_type: str, push_fn):
406
+ self._type = marker_type
407
+ self._push_fn = push_fn
408
+ self._groups: dict[str, MarkerGroup] = {}
409
+
410
+ # ------------------------------------------------------------------
411
+ # dict-like interface
412
+ def __getitem__(self, name: str) -> MarkerGroup:
413
+ """Return a MarkerGroup by name.
414
+
415
+ Parameters
416
+ ----------
417
+ name : str
418
+ Name of the marker group.
419
+
420
+ Returns
421
+ -------
422
+ MarkerGroup
423
+ The requested marker group.
424
+
425
+ Raises
426
+ ------
427
+ KeyError
428
+ If the name is not found.
429
+ """
430
+ return self._groups[name]
431
+
432
+ def __setitem__(self, name: str, group: MarkerGroup) -> None:
433
+ """Register a MarkerGroup and trigger re-render.
434
+
435
+ Parameters
436
+ ----------
437
+ name : str
438
+ Name to register the group under.
439
+ group : MarkerGroup
440
+ The marker group object.
441
+ """
442
+ self._groups[name] = group
443
+ self._push_fn()
444
+
445
+ def __delitem__(self, name: str) -> None:
446
+ """Remove a MarkerGroup by name and trigger re-render.
447
+
448
+ Parameters
449
+ ----------
450
+ name : str
451
+ Name of the group to remove.
452
+
453
+ Raises
454
+ ------
455
+ KeyError
456
+ If the name is not found.
457
+ """
458
+ del self._groups[name]
459
+ self._push_fn()
460
+
461
+ def __contains__(self, name: object) -> bool:
462
+ """Check if a named group exists.
463
+
464
+ Parameters
465
+ ----------
466
+ name : object
467
+ Name to check.
468
+
469
+ Returns
470
+ -------
471
+ bool
472
+ True if the group exists.
473
+ """
474
+ return name in self._groups
475
+
476
+ def __iter__(self):
477
+ """Iterate over group names."""
478
+ return iter(self._groups)
479
+
480
+ def __len__(self) -> int:
481
+ """Return the number of groups."""
482
+ return len(self._groups)
483
+
484
+ def __repr__(self) -> str: # pragma: no cover
485
+ return f"MarkerTypeDict(type={self._type!r}, groups={list(self._groups)})"
486
+
487
+ def keys(self):
488
+ """Return group names."""
489
+ return self._groups.keys()
490
+
491
+ def values(self):
492
+ """Return MarkerGroup objects."""
493
+ return self._groups.values()
494
+
495
+ def items(self):
496
+ """Return (name, MarkerGroup) pairs."""
497
+ return self._groups.items()
498
+
499
+ def pop(self, name: str, *args):
500
+ """Remove and return a MarkerGroup by name.
501
+
502
+ Parameters
503
+ ----------
504
+ name : str
505
+ Name of the group to remove.
506
+ *args : optional
507
+ Default value if name is not found.
508
+
509
+ Returns
510
+ -------
511
+ MarkerGroup
512
+ The removed group, or default value if provided.
513
+
514
+ Raises
515
+ ------
516
+ KeyError
517
+ If name is not found and no default is provided.
518
+ """
519
+ result = self._groups.pop(name, *args)
520
+ self._push_fn()
521
+ return result
522
+
523
+ # ------------------------------------------------------------------
524
+ def _add(self, name: str, kwargs: dict) -> "MarkerGroup":
525
+ """Internal: create and register a MarkerGroup without double-pushing."""
526
+ g = MarkerGroup(self._type, name, kwargs, self._push_fn, parent=self)
527
+ self._groups[name] = g
528
+ return g
529
+
530
+ def to_wire_list(self) -> list:
531
+ """Serialise all groups to a list of wire-format dicts."""
532
+ out = []
533
+ for gid, g in self._groups.items():
534
+ out.append(g.to_wire(gid))
535
+ return out
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # MarkerRegistry
540
+ # ---------------------------------------------------------------------------
541
+
542
+ class MarkerRegistry:
543
+ """Top-level two-level marker registry for a plot.
544
+
545
+ Usage::
546
+
547
+ plot.markers["circles"]["group1"].set(offsets=new_offsets)
548
+
549
+ ``plot.markers`` is a ``MarkerRegistry``. Indexing by type returns a
550
+ :class:`MarkerTypeDict` (auto-created on first access).
551
+ """
552
+
553
+ # Known marker types — used for validation and auto-naming.
554
+ _KNOWN_2D = frozenset({
555
+ "circles", "arrows", "ellipses", "lines",
556
+ "rectangles", "squares", "polygons", "texts",
557
+ })
558
+ _KNOWN_1D = frozenset({
559
+ "points", "vlines", "hlines", "lines", "rectangles",
560
+ "ellipses", "polygons", "texts", "arrows", "squares", "raster",
561
+ })
562
+ # pcolormesh panels only support points (circles) and line segments
563
+ _KNOWN_MESH = frozenset({"circles", "lines"})
564
+
565
+ def __init__(self, push_fn, allowed: frozenset | None = None):
566
+ """
567
+ Parameters
568
+ ----------
569
+ push_fn :
570
+ Callable that re-serialises the full registry and writes to the
571
+ Figure trait.
572
+ allowed :
573
+ Set of allowed type names. ``None`` means all known types.
574
+ """
575
+ self._push_fn = push_fn
576
+ self._allowed = allowed
577
+ self._types: dict[str, MarkerTypeDict] = {}
578
+
579
+ # ------------------------------------------------------------------
580
+ def __getitem__(self, marker_type: str) -> MarkerTypeDict:
581
+ """Return the MarkerTypeDict for a type, auto-creating if needed.
582
+
583
+ Parameters
584
+ ----------
585
+ marker_type : str
586
+ Type name (e.g., 'circles', 'lines', 'arrows').
587
+
588
+ Returns
589
+ -------
590
+ MarkerTypeDict
591
+ The dict of named groups for this type.
592
+
593
+ Raises
594
+ ------
595
+ ValueError
596
+ If the type is not in the allowed set (if one was provided).
597
+ """
598
+ if self._allowed is not None and marker_type not in self._allowed:
599
+ raise ValueError(
600
+ f"Marker type '{marker_type}' is not allowed on this panel. "
601
+ f"Allowed types: {sorted(self._allowed)}"
602
+ )
603
+ if marker_type not in self._types:
604
+ self._types[marker_type] = MarkerTypeDict(marker_type, self._push_fn)
605
+ return self._types[marker_type]
606
+
607
+ def __contains__(self, marker_type: object) -> bool:
608
+ """Check if a marker type has any registered groups."""
609
+ return marker_type in self._types
610
+
611
+ def __iter__(self):
612
+ """Iterate over marker types that have registered groups."""
613
+ return iter(self._types)
614
+
615
+ def __repr__(self) -> str: # pragma: no cover
616
+ return f"MarkerRegistry(types={list(self._types)})"
617
+
618
+ # ------------------------------------------------------------------
619
+ def _auto_name(self, marker_type: str) -> str:
620
+ """Return the next auto-generated name like ``circles_1``, ``circles_2``…
621
+
622
+ Never reuses an existing key (monotonically increasing).
623
+ """
624
+ td = self._types.get(marker_type)
625
+ if td is None or len(td) == 0:
626
+ return f"{marker_type}_1"
627
+ # Find the highest existing integer suffix
628
+ max_n = 0
629
+ for key in td.keys():
630
+ prefix = f"{marker_type}_"
631
+ if key.startswith(prefix):
632
+ try:
633
+ n = int(key[len(prefix):])
634
+ if n > max_n:
635
+ max_n = n
636
+ except ValueError:
637
+ pass
638
+ return f"{marker_type}_{max_n + 1}"
639
+
640
+ def add(self, marker_type: str, name: str | None = None, **kwargs) -> MarkerGroup:
641
+ """Add a marker group, returning the :class:`MarkerGroup`.
642
+
643
+ Parameters
644
+ ----------
645
+ marker_type : str
646
+ Type string, e.g. ``'circles'``, ``'lines'``, ``'arrows'``.
647
+ name : str, optional
648
+ Group name. Auto-generated (``'circles_1'`` etc.) if ``None``.
649
+ **kwargs : dict
650
+ Matplotlib-style kwargs for the group (offsets, radius, colors, etc.).
651
+
652
+ Returns
653
+ -------
654
+ MarkerGroup
655
+ The created marker group. Call ``.set()`` to update, or access
656
+ properties as attributes.
657
+
658
+ Examples
659
+ --------
660
+ >>> group = registry.add("circles", name="my_circles", offsets=[[10, 20]], radius=5)
661
+ >>> group.set(radius=8)
662
+ """
663
+ if name is None:
664
+ name = self._auto_name(marker_type)
665
+ td = self[marker_type] # auto-creates MarkerTypeDict
666
+ g = td._add(name, kwargs) # create without double push
667
+ self._push_fn() # single push after group is ready
668
+ return g
669
+
670
+ def remove(self, marker_type: str, name: str) -> None:
671
+ """Remove a named marker group and trigger re-render.
672
+
673
+ Parameters
674
+ ----------
675
+ marker_type : str
676
+ Type of the group.
677
+ name : str
678
+ Name of the group to remove.
679
+
680
+ Raises
681
+ ------
682
+ KeyError
683
+ If the type or name is not found.
684
+ """
685
+ del self[marker_type][name] # MarkerTypeDict.__delitem__ pushes
686
+
687
+ def clear(self) -> None:
688
+ """Remove all markers of all types and trigger re-render."""
689
+ self._types.clear()
690
+ self._push_fn()
691
+
692
+ def to_wire_list(self) -> list:
693
+ """Flatten the full registry to a list of wire-format dicts.
694
+
695
+ Returns
696
+ -------
697
+ list of dict
698
+ Wire-format dicts ready for JSON serialization and JS rendering.
699
+ """
700
+ out = []
701
+ for td in self._types.values():
702
+ out.extend(td.to_wire_list())
703
+ return out
704
+