figkit 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.
figkit/core.py ADDED
@@ -0,0 +1,1021 @@
1
+ """The element model: live anchors, relative placement, groups, z-order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import copy as _copy
7
+ import itertools
8
+ from typing import Iterable
9
+
10
+ from .geom import (Affine, BBox, Point, _expand_spec, anchor_normal, to_point)
11
+ from .style import (DEFAULT_THEME, FALLBACKS, INHERITED, Style, Theme,
12
+ current_theme, normalize_key)
13
+ from .svgdoc import Node, RenderContext
14
+
15
+ __all__ = ["Anchor", "Element", "Group", "active_container",
16
+ "split_classes", "MISSING"]
17
+
18
+
19
+ class _Missing:
20
+ def __repr__(self):
21
+ return "MISSING"
22
+
23
+
24
+ MISSING = _Missing()
25
+
26
+ # Containers that auto-adopt newly created elements (populated by
27
+ # ``with Figure() as fig:``). A ContextVar rather than a module global so two
28
+ # threads — or two asyncio tasks — can build figures at the same time without
29
+ # stealing each other's elements.
30
+ _container_var: contextvars.ContextVar = contextvars.ContextVar(
31
+ "figkit_containers", default=())
32
+
33
+
34
+ def active_container():
35
+ """The innermost container currently collecting new elements."""
36
+ stack = _container_var.get()
37
+ return stack[-1] if stack else None
38
+
39
+
40
+ def push_container(container):
41
+ """Start collecting into ``container``; returns a token for :func:`pop_container`."""
42
+ return _container_var.set(_container_var.get() + (container,))
43
+
44
+
45
+ def pop_container(token=None):
46
+ if token is not None:
47
+ _container_var.reset(token)
48
+ return
49
+ stack = _container_var.get()
50
+ if stack:
51
+ _container_var.set(stack[:-1])
52
+
53
+
54
+ _id_counter = itertools.count(1)
55
+
56
+
57
+ def split_classes(value) -> tuple:
58
+ """Normalise ``"a b"`` / ``[".a", "b"]`` into ``("a", "b")``."""
59
+ if value is None:
60
+ return ()
61
+ if isinstance(value, str):
62
+ parts = value.split()
63
+ else:
64
+ parts = list(value)
65
+ return tuple(str(p).lstrip(".") for p in parts if str(p).strip())
66
+
67
+
68
+ # ==========================================================================
69
+ # Anchors
70
+ # ==========================================================================
71
+
72
+ class Anchor:
73
+ """A *live* reference to a point on an element.
74
+
75
+ Resolved on read, so moving the element updates every arrow that points at
76
+ it. Anchors support ``+``/``-`` with ``(dx, dy)`` to make offset anchors.
77
+ """
78
+
79
+ __slots__ = ("element", "name", "_uv", "_angle", "dx", "dy")
80
+
81
+ def __init__(self, element, name: str = None, uv=None, angle: float = None,
82
+ dx: float = 0.0, dy: float = 0.0):
83
+ self.element = element
84
+ self.name = name
85
+ self._uv = uv
86
+ self._angle = angle
87
+ self.dx = dx
88
+ self.dy = dy
89
+
90
+ @property
91
+ def point(self) -> Point:
92
+ bb = self.element.bbox
93
+ if self._uv is not None:
94
+ p = bb.uv(self._uv[0], self._uv[1])
95
+ elif self._angle is not None:
96
+ p = bb.at_angle(self._angle)
97
+ else:
98
+ p = bb.anchor(self.name or "center")
99
+ return Point(p.x + self.dx, p.y + self.dy)
100
+
101
+ # -- convenience ----------------------------------------------------
102
+ @property
103
+ def x(self) -> float:
104
+ return self.point.x
105
+
106
+ @property
107
+ def y(self) -> float:
108
+ return self.point.y
109
+
110
+ @property
111
+ def normal(self) -> Point:
112
+ """Outward direction at this anchor (used to shape connectors)."""
113
+ if self.name:
114
+ n = anchor_normal(self.name)
115
+ if n.length:
116
+ return n
117
+ if self._angle is not None:
118
+ import math
119
+ a = math.radians(self._angle)
120
+ return Point(math.cos(a), math.sin(a))
121
+ if self._uv is not None:
122
+ u, v = self._uv
123
+ nx = -1.0 if u <= 0.01 else (1.0 if u >= 0.99 else 0.0)
124
+ ny = -1.0 if v <= 0.01 else (1.0 if v >= 0.99 else 0.0)
125
+ p = Point(nx, ny)
126
+ if p.length:
127
+ return p.normalized()
128
+ return Point(0.0, 0.0)
129
+
130
+ def offset(self, dx: float = 0.0, dy: float = 0.0) -> "Anchor":
131
+ return Anchor(self.element, self.name, self._uv, self._angle,
132
+ self.dx + dx, self.dy + dy)
133
+
134
+ def __add__(self, delta) -> "Anchor":
135
+ d = to_point(delta)
136
+ return self.offset(d.x, d.y)
137
+
138
+ def __sub__(self, delta) -> "Anchor":
139
+ d = to_point(delta)
140
+ return self.offset(-d.x, -d.y)
141
+
142
+ def __iter__(self):
143
+ return iter(self.point)
144
+
145
+ def __getitem__(self, i):
146
+ return self.point[i]
147
+
148
+ def __repr__(self) -> str:
149
+ what = self.name or (f"uv{self._uv}" if self._uv else f"@{self._angle}")
150
+ return f"<Anchor {what} of {type(self.element).__name__} -> {self.point}>"
151
+
152
+
153
+ # ==========================================================================
154
+ # Element
155
+ # ==========================================================================
156
+
157
+ class Element:
158
+ """Base class for everything drawable.
159
+
160
+ Subclasses implement :meth:`_measure` (to set ``_w``/``_h`` when the size
161
+ is intrinsic) and :meth:`_render_content` (to emit SVG nodes).
162
+ """
163
+
164
+ role = "element"
165
+
166
+ #: line-like elements have no geometric width, so ``width=`` means stroke
167
+ STROKE_WIDTH_ALIAS = False
168
+
169
+ def __init__(self, x: float = 0.0, y: float = 0.0, w: float = None,
170
+ h: float = None, *, style=None, classes=(), theme: Theme = None,
171
+ name: str = None, z: float = 0.0, visible: bool = True,
172
+ opacity: float = None, transform: Affine = None,
173
+ clip: bool = False, audit: bool = True, add: bool = None,
174
+ **props):
175
+ self._x = float(x)
176
+ self._y = float(y)
177
+ self._w = None if w is None else float(w)
178
+ self._h = None if h is None else float(h)
179
+ self._explicit_w = w is not None
180
+ self._explicit_h = h is not None
181
+ self._transform = transform or Affine.IDENTITY
182
+ self.parent = None
183
+ self.name = name
184
+ self.z = z
185
+ self._visible = bool(visible)
186
+ self.clip = clip
187
+ #: set False to exempt this element from :meth:`Figure.audit`
188
+ self.audit_enabled = bool(audit)
189
+ self.uid = f"e{next(_id_counter)}"
190
+ self._theme = theme
191
+ self._ambient_theme = current_theme()
192
+ self._raw_style = style
193
+ self.classes: tuple = split_classes(classes)
194
+ self._style = Style()
195
+ self._extra_props = props
196
+ if opacity is not None:
197
+ props = dict(props, opacity=opacity)
198
+ self._extra_props = props
199
+ self._dirty = True
200
+ self._bbox_cache = None
201
+ self._set_style(style, props)
202
+ container = active_container()
203
+ if add is None:
204
+ add = container is not None
205
+ if add and container is not None:
206
+ container.add(self)
207
+
208
+ # -- style plumbing --------------------------------------------------
209
+ def _set_style(self, style, props: dict) -> None:
210
+ """Split ``style=`` into class names (lazy) and literal styles (eager)."""
211
+ if self.STROKE_WIDTH_ALIAS and "width" in props:
212
+ props = dict(props)
213
+ props.setdefault("stroke_width", props.pop("width"))
214
+ resolved = []
215
+ for item in (style if isinstance(style, (list, tuple)) else [style]):
216
+ if item is None:
217
+ continue
218
+ if isinstance(item, str):
219
+ self.classes = self.classes + split_classes(item)
220
+ else:
221
+ resolved.append(item)
222
+ self._style = Style(*resolved, **props)
223
+ self.invalidate()
224
+
225
+ def class_style(self, name: str) -> Style | None:
226
+ """The style a class name resolves to in this element's theme chain."""
227
+ key = str(name).lstrip(".")
228
+ for th in self.theme_chain():
229
+ if key in th.styles:
230
+ return th.styles[key]
231
+ return None
232
+
233
+ def add_class(self, *names) -> "Element":
234
+ """Add style classes (later classes win over earlier ones)."""
235
+ for name in names:
236
+ self.classes = self.classes + split_classes(name)
237
+ return self.invalidate()
238
+
239
+ def remove_class(self, *names) -> "Element":
240
+ drop = set()
241
+ for name in names:
242
+ drop.update(split_classes(name))
243
+ self.classes = tuple(c for c in self.classes if c not in drop)
244
+ return self.invalidate()
245
+
246
+ def has_class(self, name: str) -> bool:
247
+ return str(name).lstrip(".") in self.classes
248
+
249
+ def restyle(self, *styles, **props) -> "Element":
250
+ """Merge more style on top of this element's own style.
251
+
252
+ String arguments are treated as class names.
253
+ """
254
+ if self.STROKE_WIDTH_ALIAS and "width" in props:
255
+ props = dict(props)
256
+ props.setdefault("stroke_width", props.pop("width"))
257
+ merged = [self._style]
258
+ for item in styles:
259
+ if isinstance(item, str):
260
+ self.add_class(item)
261
+ elif item is not None:
262
+ merged.append(item)
263
+ self._style = Style(*merged, **props)
264
+ self.invalidate()
265
+ return self
266
+
267
+ @property
268
+ def style(self) -> Style:
269
+ return self._style
270
+
271
+ @style.setter
272
+ def style(self, value) -> None:
273
+ self._set_style(value, {})
274
+
275
+ @property
276
+ def theme(self) -> Theme:
277
+ for th in self.theme_chain():
278
+ return th
279
+ return DEFAULT_THEME
280
+
281
+ @theme.setter
282
+ def theme(self, value) -> None:
283
+ self._theme = value if isinstance(value, Theme) or value is None else Theme(value)
284
+ self.invalidate()
285
+
286
+ def theme_chain(self) -> Iterable[Theme]:
287
+ """Themes from innermost to outermost, ending with the default.
288
+
289
+ Own theme, then each ancestor's, then the theme that was ambient when
290
+ this element was constructed, then the built-in default.
291
+ """
292
+ if self._theme is not None:
293
+ yield self._theme
294
+ node = self.parent
295
+ while node is not None:
296
+ th = getattr(node, "_theme", None)
297
+ if th is not None:
298
+ yield th
299
+ node = getattr(node, "parent", None)
300
+ if self._ambient_theme is not None:
301
+ yield self._ambient_theme
302
+ yield DEFAULT_THEME
303
+
304
+ def prop(self, name: str, default=MISSING, role: str = None):
305
+ """Resolve a style property through the whole cascade."""
306
+ key = normalize_key(name)
307
+ if key in self._style:
308
+ return self._resolve_value(self._style[key])
309
+ for class_name in reversed(self.classes): # later classes win
310
+ cls = self.class_style(class_name)
311
+ if cls is not None and key in cls:
312
+ return self._resolve_value(cls[key])
313
+ if key in INHERITED:
314
+ node = self.parent
315
+ while node is not None:
316
+ st = getattr(node, "_style", None)
317
+ if st is not None and key in st:
318
+ return self._resolve_value(st[key])
319
+ node = getattr(node, "parent", None)
320
+ role = role or self.role
321
+ for th in self.theme_chain():
322
+ value, found = th.lookup(key, role)
323
+ if found:
324
+ return self._resolve_value(value)
325
+ if default is not MISSING:
326
+ return default
327
+ return FALLBACKS.get(key)
328
+
329
+ def _resolve_value(self, value):
330
+ if isinstance(value, str) and value.startswith("@"):
331
+ for th in self.theme_chain():
332
+ if value[1:] in th.palette:
333
+ return th.palette[value[1:]]
334
+ return value[1:]
335
+ return value
336
+
337
+ def padding(self) -> tuple:
338
+ return _expand_spec(self.prop("padding", 0))
339
+
340
+ # -- geometry --------------------------------------------------------
341
+ def _measure(self) -> None:
342
+ """Subclasses set ``self._w``/``self._h`` when size is intrinsic."""
343
+ if self._w is None:
344
+ self._w = 0.0
345
+ if self._h is None:
346
+ self._h = 0.0
347
+
348
+ def _ensure(self) -> None:
349
+ if self._dirty:
350
+ self._dirty = False
351
+ self._measure()
352
+
353
+ def invalidate(self) -> "Element":
354
+ self._dirty = True
355
+ self._bbox_cache = None
356
+ node = self.parent
357
+ while node is not None:
358
+ node._bbox_cache = None
359
+ node = getattr(node, "parent", None)
360
+ return self
361
+
362
+ def parent_matrix(self) -> Affine:
363
+ return self.parent.child_matrix() if self.parent is not None else Affine.IDENTITY
364
+
365
+ def child_matrix(self) -> Affine:
366
+ """Matrix that maps a child's coordinates into world space."""
367
+ return self.world_matrix()
368
+
369
+ def world_matrix(self) -> Affine:
370
+ return self.parent_matrix() @ self._transform
371
+
372
+ @property
373
+ def local_bbox(self) -> BBox:
374
+ """Bounds in the element's own (pre-transform) coordinates."""
375
+ self._ensure()
376
+ return BBox(self._x, self._y, self._w or 0.0, self._h or 0.0)
377
+
378
+ @property
379
+ def bbox(self) -> BBox:
380
+ """Bounds in world (figure) coordinates. Anchors read this."""
381
+ m = self.world_matrix()
382
+ lb = self.local_bbox
383
+ return lb if m.is_identity else m.apply_bbox(lb)
384
+
385
+ @property
386
+ def ink_bbox(self) -> BBox:
387
+ """Bounds including stroke width — used for auto-sizing the canvas."""
388
+ bb = self.bbox
389
+ sw = self.prop("stroke_width", 0) or 0
390
+ has_stroke = self.prop("stroke", None) not in (None, "none")
391
+ return bb.expand(sw / 2.0) if has_stroke and sw else bb
392
+
393
+ @property
394
+ def x(self) -> float:
395
+ return self.bbox.x
396
+
397
+ @property
398
+ def y(self) -> float:
399
+ return self.bbox.y
400
+
401
+ @property
402
+ def width(self) -> float:
403
+ return self.bbox.w
404
+
405
+ @property
406
+ def height(self) -> float:
407
+ return self.bbox.h
408
+
409
+ @property
410
+ def size(self) -> Point:
411
+ bb = self.bbox
412
+ return Point(bb.w, bb.h)
413
+
414
+ def resize(self, w: float = None, h: float = None,
415
+ anchor: str = "nw") -> "Element":
416
+ """Set an explicit size, keeping ``anchor`` fixed in place."""
417
+ keep = self.bbox.anchor(anchor)
418
+ if w is not None:
419
+ self._w = float(w)
420
+ self._explicit_w = True
421
+ if h is not None:
422
+ self._h = float(h)
423
+ self._explicit_h = True
424
+ self._dirty = True
425
+ self.invalidate()
426
+ now = self.bbox.anchor(anchor)
427
+ return self.move(keep.x - now.x, keep.y - now.y)
428
+
429
+ def grow(self, dw: float = 0.0, dh: float = 0.0,
430
+ anchor: str = "center") -> "Element":
431
+ bb = self.bbox
432
+ return self.resize(bb.w + dw, bb.h + dh, anchor=anchor)
433
+
434
+ # -- movement --------------------------------------------------------
435
+ def _to_local_delta(self, dx: float, dy: float) -> Point:
436
+ m = self.world_matrix()
437
+ if m.is_identity:
438
+ return Point(dx, dy)
439
+ inv = m.inverse()
440
+ return inv.apply((dx, dy)) - inv.apply((0.0, 0.0))
441
+
442
+ def move(self, dx: float = 0.0, dy: float = 0.0) -> "Element":
443
+ """Translate by ``(dx, dy)`` in world units."""
444
+ d = self._to_local_delta(dx, dy)
445
+ self._x += d.x
446
+ self._y += d.y
447
+ self.invalidate()
448
+ return self
449
+
450
+ def at(self, x=None, y=None, anchor: str = "nw") -> "Element":
451
+ """Place ``anchor`` of this element at world position ``(x, y)``.
452
+
453
+ ``at((x, y))`` and ``at(some_anchor)`` also work.
454
+ """
455
+ if y is None and x is not None and not isinstance(x, (int, float)):
456
+ p = to_point(x)
457
+ x, y = p.x, p.y
458
+ cur = self.bbox.anchor(anchor)
459
+ dx = 0.0 if x is None else float(x) - cur.x
460
+ dy = 0.0 if y is None else float(y) - cur.y
461
+ return self.move(dx, dy)
462
+
463
+ def center_at(self, x=None, y=None) -> "Element":
464
+ return self.at(x, y, anchor="center")
465
+
466
+ def place_local(self, x: float, y: float, anchor: str = "nw") -> "Element":
467
+ """Like :meth:`at`, but in the parent's own (pre-transform) space.
468
+
469
+ Used by containers to position their internal labels: their geometry is
470
+ computed in local coordinates, so going through world space and back
471
+ would be wrong whenever a transform is in play.
472
+ """
473
+ lb = self.local_bbox
474
+ cur = lb.anchor(anchor)
475
+ self._x += float(x) - cur.x
476
+ self._y += float(y) - cur.y
477
+ self.invalidate()
478
+ return self
479
+
480
+ def set_x(self, x: float, anchor: str = "nw") -> "Element":
481
+ return self.at(x, None, anchor=anchor)
482
+
483
+ def set_y(self, y: float, anchor: str = "nw") -> "Element":
484
+ return self.at(None, y, anchor=anchor)
485
+
486
+ # -- relative placement ----------------------------------------------
487
+ def _align_axis(self, other, align, horizontal: bool) -> float:
488
+ """Delta on the cross axis so ``align`` edges line up."""
489
+ if align is None:
490
+ return 0.0
491
+ a = str(align).lower()
492
+ mine, theirs = self.bbox, _bbox_of(other)
493
+ if horizontal: # placing left/right -> align vertically
494
+ table = {"top": (mine.y0, theirs.y0), "n": (mine.y0, theirs.y0),
495
+ "bottom": (mine.y1, theirs.y1), "s": (mine.y1, theirs.y1),
496
+ "center": (mine.cy, theirs.cy), "middle": (mine.cy, theirs.cy),
497
+ "c": (mine.cy, theirs.cy)}
498
+ else: # placing above/below -> align horizontally
499
+ table = {"left": (mine.x0, theirs.x0), "w": (mine.x0, theirs.x0),
500
+ "right": (mine.x1, theirs.x1), "e": (mine.x1, theirs.x1),
501
+ "center": (mine.cx, theirs.cx), "middle": (mine.cx, theirs.cx),
502
+ "c": (mine.cx, theirs.cx)}
503
+ if a not in table:
504
+ raise ValueError(
505
+ f"align={align!r} is not valid here; use "
506
+ f"{sorted(k for k in table if len(k) > 1)}")
507
+ cur, target = table[a]
508
+ return target - cur
509
+
510
+ def right_of(self, other, gap: float = 0.0, align: str = "center",
511
+ dx: float = 0.0, dy: float = 0.0) -> "Element":
512
+ """Put this element to the right of ``other`` with a ``gap``."""
513
+ ob = _bbox_of(other)
514
+ mine = self.bbox
515
+ d = self._align_axis(other, align, horizontal=True)
516
+ return self.move(ob.x1 + gap - mine.x0 + dx, d + dy)
517
+
518
+ def left_of(self, other, gap: float = 0.0, align: str = "center",
519
+ dx: float = 0.0, dy: float = 0.0) -> "Element":
520
+ ob = _bbox_of(other)
521
+ mine = self.bbox
522
+ d = self._align_axis(other, align, horizontal=True)
523
+ return self.move(ob.x0 - gap - mine.x1 + dx, d + dy)
524
+
525
+ def below_of(self, other, gap: float = 0.0, align: str = "center",
526
+ dx: float = 0.0, dy: float = 0.0) -> "Element":
527
+ ob = _bbox_of(other)
528
+ mine = self.bbox
529
+ d = self._align_axis(other, align, horizontal=False)
530
+ return self.move(d + dx, ob.y1 + gap - mine.y0 + dy)
531
+
532
+ def above_of(self, other, gap: float = 0.0, align: str = "center",
533
+ dx: float = 0.0, dy: float = 0.0) -> "Element":
534
+ ob = _bbox_of(other)
535
+ mine = self.bbox
536
+ d = self._align_axis(other, align, horizontal=False)
537
+ return self.move(d + dx, ob.y0 - gap - mine.y1 + dy)
538
+
539
+ below = below_of
540
+ above = above_of
541
+
542
+ def next_to(self, other, side: str = "right", gap: float = 0.0,
543
+ align: str = "center", dx: float = 0.0, dy: float = 0.0):
544
+ fn = {"right": self.right_of, "e": self.right_of,
545
+ "left": self.left_of, "w": self.left_of,
546
+ "below": self.below_of, "s": self.below_of, "under": self.below_of,
547
+ "above": self.above_of, "n": self.above_of, "over": self.above_of}
548
+ key = str(side).lower()
549
+ if key not in fn:
550
+ raise ValueError(f"side={side!r}; use right/left/above/below")
551
+ return fn[key](other, gap=gap, align=align, dx=dx, dy=dy)
552
+
553
+ def inside(self, other, anchor: str = "center", pad=0.0,
554
+ dx: float = 0.0, dy: float = 0.0) -> "Element":
555
+ """Place this element inside ``other``, aligned to ``anchor``."""
556
+ ob = _bbox_of(other).shrink(pad)
557
+ target = ob.anchor(anchor)
558
+ return self.at(target.x + dx, target.y + dy, anchor=anchor)
559
+
560
+ def align_to(self, other, edge: str = "center") -> "Element":
561
+ """Snap one edge (or centre axis) to match ``other``."""
562
+ ob = _bbox_of(other)
563
+ mine = self.bbox
564
+ e = str(edge).lower()
565
+ moves = {
566
+ "left": (ob.x0 - mine.x0, 0.0), "w": (ob.x0 - mine.x0, 0.0),
567
+ "right": (ob.x1 - mine.x1, 0.0), "e": (ob.x1 - mine.x1, 0.0),
568
+ "top": (0.0, ob.y0 - mine.y0), "n": (0.0, ob.y0 - mine.y0),
569
+ "bottom": (0.0, ob.y1 - mine.y1), "s": (0.0, ob.y1 - mine.y1),
570
+ "center_x": (ob.cx - mine.cx, 0.0), "cx": (ob.cx - mine.cx, 0.0),
571
+ "center_y": (0.0, ob.cy - mine.cy), "cy": (0.0, ob.cy - mine.cy),
572
+ "center": (ob.cx - mine.cx, ob.cy - mine.cy),
573
+ }
574
+ if e not in moves:
575
+ raise ValueError(f"edge={edge!r} is not a valid alignment edge")
576
+ return self.move(*moves[e])
577
+
578
+ def span_x(self, a, b, pad: float = 0.0) -> "Element":
579
+ """Stretch horizontally to span from ``a`` to ``b`` (elements/points)."""
580
+ x0 = min(_bbox_of(a).x0, _bbox_of(b).x0) - pad
581
+ x1 = max(_bbox_of(a).x1, _bbox_of(b).x1) + pad
582
+ self.resize(w=x1 - x0)
583
+ return self.at(x0, None, anchor="nw")
584
+
585
+ def span_y(self, a, b, pad: float = 0.0) -> "Element":
586
+ y0 = min(_bbox_of(a).y0, _bbox_of(b).y0) - pad
587
+ y1 = max(_bbox_of(a).y1, _bbox_of(b).y1) + pad
588
+ self.resize(h=y1 - y0)
589
+ return self.at(None, y0, anchor="nw")
590
+
591
+ # -- transforms ------------------------------------------------------
592
+ def rotate(self, deg: float, about=None) -> "Element":
593
+ """Rotate around ``about`` (default: the element's own centre)."""
594
+ pivot = self.bbox.center if about is None else to_point(about)
595
+ inv = self.parent_matrix().inverse()
596
+ local_pivot = inv.apply(pivot)
597
+ self._transform = Affine.rotate(deg, local_pivot) @ self._transform
598
+ return self.invalidate()
599
+
600
+ def scale_by(self, sx: float, sy: float = None, about=None) -> "Element":
601
+ pivot = self.bbox.center if about is None else to_point(about)
602
+ inv = self.parent_matrix().inverse()
603
+ p = inv.apply(pivot)
604
+ sy = sx if sy is None else sy
605
+ m = (Affine.translate(p.x, p.y) @ Affine.scale(sx, sy)
606
+ @ Affine.translate(-p.x, -p.y))
607
+ self._transform = m @ self._transform
608
+ return self.invalidate()
609
+
610
+ def flip_h(self, about=None) -> "Element":
611
+ return self.scale_by(-1, 1, about)
612
+
613
+ def flip_v(self, about=None) -> "Element":
614
+ return self.scale_by(1, -1, about)
615
+
616
+ def reset_transform(self) -> "Element":
617
+ self._transform = Affine.IDENTITY
618
+ return self.invalidate()
619
+
620
+ # -- anchors ---------------------------------------------------------
621
+ def anchor(self, name: str) -> Anchor:
622
+ return Anchor(self, name=name)
623
+
624
+ def uv(self, u: float, v: float) -> Anchor:
625
+ """Anchor at a fractional position inside the bounding box."""
626
+ return Anchor(self, uv=(float(u), float(v)))
627
+
628
+ pt = uv
629
+
630
+ def at_angle(self, deg: float) -> Anchor:
631
+ """Anchor on the border along a ray from the centre (0 = east, cw)."""
632
+ return Anchor(self, angle=float(deg))
633
+
634
+ @property
635
+ def n(self) -> Anchor:
636
+ return Anchor(self, "n")
637
+
638
+ @property
639
+ def s(self) -> Anchor:
640
+ return Anchor(self, "s")
641
+
642
+ @property
643
+ def e(self) -> Anchor:
644
+ return Anchor(self, "e")
645
+
646
+ @property
647
+ def w(self) -> Anchor:
648
+ return Anchor(self, "w")
649
+
650
+ @property
651
+ def ne(self) -> Anchor:
652
+ return Anchor(self, "ne")
653
+
654
+ @property
655
+ def nw(self) -> Anchor:
656
+ return Anchor(self, "nw")
657
+
658
+ @property
659
+ def se(self) -> Anchor:
660
+ return Anchor(self, "se")
661
+
662
+ @property
663
+ def sw(self) -> Anchor:
664
+ return Anchor(self, "sw")
665
+
666
+ @property
667
+ def center(self) -> Anchor:
668
+ return Anchor(self, "center")
669
+
670
+ c = center
671
+
672
+ @property
673
+ def top(self) -> Anchor:
674
+ return Anchor(self, "n")
675
+
676
+ @property
677
+ def bottom(self) -> Anchor:
678
+ return Anchor(self, "s")
679
+
680
+ @property
681
+ def left(self) -> Anchor:
682
+ return Anchor(self, "w")
683
+
684
+ @property
685
+ def right(self) -> Anchor:
686
+ return Anchor(self, "e")
687
+
688
+ @property
689
+ def point(self) -> Point:
690
+ """An element used where a point is expected means its centre."""
691
+ return self.bbox.center
692
+
693
+ # -- tree ------------------------------------------------------------
694
+ def to_front(self) -> "Element":
695
+ if self.parent is not None:
696
+ self.parent.raise_child(self)
697
+ return self
698
+
699
+ def to_back(self) -> "Element":
700
+ if self.parent is not None:
701
+ self.parent.lower_child(self)
702
+ return self
703
+
704
+ def remove(self) -> "Element":
705
+ if self.parent is not None:
706
+ self.parent.remove_child(self)
707
+ return self
708
+
709
+ def detach(self) -> "Element":
710
+ return self.remove()
711
+
712
+ def copy(self, **overrides) -> "Element":
713
+ """Deep copy, detached from any parent."""
714
+ clone = _copy.deepcopy(self, {id(self.parent): None})
715
+ clone.parent = None
716
+ clone.uid = f"e{next(_id_counter)}"
717
+ if overrides:
718
+ clone.restyle(**overrides)
719
+ clone.invalidate()
720
+ container = active_container()
721
+ if container is not None:
722
+ container.add(clone)
723
+ return clone
724
+
725
+ @property
726
+ def visible(self) -> bool:
727
+ return self._visible
728
+
729
+ @visible.setter
730
+ def visible(self, value) -> None:
731
+ self._visible = bool(value)
732
+ self.invalidate()
733
+
734
+ def ignore_audit(self) -> "Element":
735
+ """Exempt this element from :meth:`figkit.Figure.audit` checks."""
736
+ self.audit_enabled = False
737
+ return self
738
+
739
+ def hide(self) -> "Element":
740
+ """Keep the element but stop drawing it (and stop it affecting bounds)."""
741
+ self.visible = False
742
+ return self
743
+
744
+ def show(self) -> "Element":
745
+ self.visible = True
746
+ return self
747
+
748
+ def descendants(self):
749
+ yield self
750
+
751
+ def find(self, name: str):
752
+ for el in self.descendants():
753
+ if getattr(el, "name", None) == name:
754
+ return el
755
+ return None
756
+
757
+ # -- rendering -------------------------------------------------------
758
+ def _render_content(self, ctx: RenderContext):
759
+ raise NotImplementedError
760
+
761
+ def _wrapper_attrs(self, ctx: RenderContext) -> dict:
762
+ attrs = {}
763
+ if self.classes:
764
+ attrs["class"] = " ".join(self.classes)
765
+ if not self._transform.is_identity:
766
+ attrs["transform"] = self._transform.to_svg()
767
+ op = self.prop("opacity", None)
768
+ if op is not None and float(op) != 1.0:
769
+ attrs["opacity"] = float(op)
770
+ if self.name:
771
+ attrs["data-name"] = self.name
772
+ return attrs
773
+
774
+ def render(self, ctx: RenderContext) -> Node | None:
775
+ if not self.visible:
776
+ return None
777
+ for class_name in self.classes:
778
+ if self.class_style(class_name) is None:
779
+ ctx.warn(f"unknown style class {class_name!r} on "
780
+ f"{type(self).__name__}")
781
+ self._ensure()
782
+ content = self._render_content(ctx)
783
+ if content is None:
784
+ return None
785
+ nodes = content if isinstance(content, (list, tuple)) else [content]
786
+ nodes = [n for n in nodes if n is not None]
787
+ if not nodes:
788
+ return None
789
+ attrs = self._wrapper_attrs(ctx)
790
+ if len(nodes) == 1 and not attrs:
791
+ return nodes[0]
792
+ if len(nodes) == 1 and set(attrs) <= {"transform", "opacity", "data-name"} \
793
+ and nodes[0].tag not in ("text",):
794
+ node = nodes[0]
795
+ if "transform" in attrs and "transform" not in node.attrs:
796
+ node.attrs.update(attrs)
797
+ return node
798
+ if "transform" not in attrs:
799
+ node.attrs.update(attrs)
800
+ return node
801
+ g = Node("g", **attrs)
802
+ g.add(*nodes)
803
+ return g
804
+
805
+ # -- misc ------------------------------------------------------------
806
+ def __repr__(self) -> str:
807
+ bb = self.bbox
808
+ label = f" {self.name!r}" if self.name else ""
809
+ return (f"<{type(self).__name__}{label} x={bb.x:.4g} y={bb.y:.4g} "
810
+ f"w={bb.w:.4g} h={bb.h:.4g}>")
811
+
812
+
813
+ def _bbox_of(obj) -> BBox:
814
+ """BBox of an element, anchor, point or raw bbox."""
815
+ if isinstance(obj, BBox):
816
+ return obj
817
+ bb = getattr(obj, "bbox", None)
818
+ if isinstance(bb, BBox):
819
+ return bb
820
+ p = to_point(obj)
821
+ return BBox(p.x, p.y, 0.0, 0.0)
822
+
823
+
824
+ # ==========================================================================
825
+ # Group
826
+ # ==========================================================================
827
+
828
+ class Group(Element):
829
+ """A container whose bounds follow its children.
830
+
831
+ Groups are placeable like any other element (``group.right_of(box)``),
832
+ can carry a style that cascades text properties down, and can carry a
833
+ theme that cascades everything.
834
+ """
835
+
836
+ role = "group"
837
+
838
+ def __init__(self, *children, style=None, classes=(), theme=None, name=None,
839
+ z=0.0, visible=True, opacity=None, clip=False, audit=True,
840
+ add=None, **props):
841
+ self._children: list = []
842
+ super().__init__(0, 0, None, None, style=style, classes=classes,
843
+ theme=theme, name=name, z=z, visible=visible,
844
+ opacity=opacity, clip=clip, audit=audit, add=add,
845
+ **props)
846
+ flat = []
847
+ for c in children:
848
+ if c is None:
849
+ continue
850
+ if isinstance(c, (list, tuple)):
851
+ flat.extend(x for x in c if x is not None)
852
+ else:
853
+ flat.append(c)
854
+ for c in flat:
855
+ self.add(c)
856
+
857
+ # -- children --------------------------------------------------------
858
+ @property
859
+ def children(self) -> list:
860
+ return list(self._children)
861
+
862
+ def add(self, *elements) -> "Group":
863
+ for el in elements:
864
+ if el is None:
865
+ continue
866
+ if isinstance(el, (list, tuple)):
867
+ self.add(*el)
868
+ continue
869
+ if el is self:
870
+ raise ValueError("a group cannot contain itself")
871
+ if el.parent is not None and el.parent is not self:
872
+ el.parent.remove_child(el)
873
+ if el in self._children:
874
+ continue
875
+ el.parent = self
876
+ self._children.append(el)
877
+ el.invalidate()
878
+ self.invalidate()
879
+ return self
880
+
881
+ def extend(self, elements) -> "Group":
882
+ return self.add(*elements)
883
+
884
+ def remove_child(self, el) -> "Group":
885
+ if el in self._children:
886
+ self._children.remove(el)
887
+ el.parent = None
888
+ el.invalidate()
889
+ self.invalidate()
890
+ return self
891
+
892
+ def clear(self) -> "Group":
893
+ for el in list(self._children):
894
+ self.remove_child(el)
895
+ return self
896
+
897
+ def raise_child(self, el) -> "Group":
898
+ if el in self._children:
899
+ self._children.remove(el)
900
+ self._children.append(el)
901
+ return self
902
+
903
+ def lower_child(self, el) -> "Group":
904
+ if el in self._children:
905
+ self._children.remove(el)
906
+ self._children.insert(0, el)
907
+ return self
908
+
909
+ def __iter__(self):
910
+ return iter(self._children)
911
+
912
+ def __len__(self) -> int:
913
+ return len(self._children)
914
+
915
+ def __getitem__(self, i):
916
+ if isinstance(i, str):
917
+ found = self.find(i)
918
+ if found is None:
919
+ raise KeyError(i)
920
+ return found
921
+ return self._children[i]
922
+
923
+ def descendants(self):
924
+ yield self
925
+ for c in self._children:
926
+ yield from c.descendants()
927
+
928
+ # -- geometry --------------------------------------------------------
929
+ def child_matrix(self) -> Affine:
930
+ return self.world_matrix()
931
+
932
+ @property
933
+ def local_bbox(self) -> BBox:
934
+ """World bounds mapped back into the group's own coordinate space."""
935
+ world = self.bbox_from_children()
936
+ m = self.world_matrix()
937
+ return world if m.is_identity else m.inverse().apply_bbox(world)
938
+
939
+ def bbox_from_children(self) -> BBox:
940
+ boxes = [c.bbox for c in self._children if c.visible]
941
+ bb = BBox.union_all(boxes)
942
+ return bb if bb is not None else BBox(self._x, self._y, 0.0, 0.0)
943
+
944
+ @property
945
+ def bbox(self) -> BBox:
946
+ if self._bbox_cache is None:
947
+ self._bbox_cache = self.bbox_from_children()
948
+ return self._bbox_cache
949
+
950
+ @property
951
+ def ink_bbox(self) -> BBox:
952
+ boxes = [c.ink_bbox for c in self._children if c.visible]
953
+ bb = BBox.union_all(boxes)
954
+ return bb if bb is not None else self.bbox
955
+
956
+ def _measure(self) -> None:
957
+ pass
958
+
959
+ def clip_bbox(self) -> BBox:
960
+ """The rectangle ``clip=True`` clips to. Subclasses may narrow it."""
961
+ return self.bbox
962
+
963
+ def move(self, dx: float = 0.0, dy: float = 0.0) -> "Group":
964
+ """Move every child (keeps the group's own transform clean)."""
965
+ if dx == 0 and dy == 0:
966
+ return self
967
+ for c in self._children:
968
+ c.move(dx, dy)
969
+ self.invalidate()
970
+ return self
971
+
972
+ def resize(self, w: float = None, h: float = None, anchor: str = "nw"):
973
+ """Scale the group's contents to a target size."""
974
+ bb = self.bbox
975
+ if bb.w <= 0 or bb.h <= 0:
976
+ return self
977
+ sx = 1.0 if w is None else float(w) / bb.w
978
+ sy = 1.0 if h is None else float(h) / bb.h
979
+ if w is not None and h is None:
980
+ sy = sx
981
+ if h is not None and w is None:
982
+ sx = sy
983
+ return self.scale_by(sx, sy, about=bb.anchor(anchor))
984
+
985
+ def invalidate(self) -> "Group":
986
+ self._bbox_cache = None
987
+ return super().invalidate()
988
+
989
+ # -- rendering -------------------------------------------------------
990
+ def _render_content(self, ctx: RenderContext):
991
+ kids = sorted([c for c in self._children if c.visible],
992
+ key=lambda c: (getattr(c, "z", 0) or 0))
993
+ nodes = [c.render(ctx) for c in kids]
994
+ nodes = [n for n in nodes if n is not None]
995
+ if not nodes:
996
+ return None
997
+ g = Node("g")
998
+ g.add(*nodes)
999
+ if self.clip:
1000
+ bb = self.clip_bbox()
1001
+ clip_node = Node("clipPath").add(
1002
+ Node("rect", x=bb.x, y=bb.y, width=bb.w, height=bb.h))
1003
+ cid = ctx.add_def(clip_node)
1004
+ g.attrs["clip-path"] = f"url(#{cid})"
1005
+ return g
1006
+
1007
+ def render(self, ctx: RenderContext) -> Node | None:
1008
+ if not self.visible:
1009
+ return None
1010
+ content = self._render_content(ctx)
1011
+ if content is None:
1012
+ return None
1013
+ attrs = self._wrapper_attrs(ctx)
1014
+ content.attrs.update(attrs)
1015
+ return content
1016
+
1017
+ def __repr__(self) -> str:
1018
+ bb = self.bbox
1019
+ label = f" {self.name!r}" if self.name else ""
1020
+ return (f"<Group{label} {len(self._children)} children x={bb.x:.4g} "
1021
+ f"y={bb.y:.4g} w={bb.w:.4g} h={bb.h:.4g}>")