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/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
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}>")
|