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/layout.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Arrangement helpers: align, distribute, stack, grid and fit.
|
|
2
|
+
|
|
3
|
+
Nothing here is automatic layout — every function positions elements once,
|
|
4
|
+
when you call it, and then gets out of the way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from .components import Panel
|
|
12
|
+
from .core import Element, Group
|
|
13
|
+
from .geom import BBox, Point, to_point
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"align", "align_h", "align_v", "distribute_h", "distribute_v",
|
|
17
|
+
"spread_h", "spread_v", "hstack", "vstack", "grid", "fit", "frame_around",
|
|
18
|
+
"between", "midpoint", "center_on", "group", "bbox_of", "circular",
|
|
19
|
+
"same_width", "same_height", "same_size", "shift", "baseline_of",
|
|
20
|
+
"brace_around",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_EDGE_AXIS = {
|
|
24
|
+
"left": "x", "w": "x", "right": "x", "e": "x",
|
|
25
|
+
"center_x": "x", "cx": "x", "middle_x": "x",
|
|
26
|
+
"top": "y", "n": "y", "bottom": "y", "s": "y",
|
|
27
|
+
"center_y": "y", "cy": "y", "middle_y": "y",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def bbox_of(items) -> BBox:
|
|
32
|
+
"""Union bbox of a list of elements/anchors/points."""
|
|
33
|
+
boxes = []
|
|
34
|
+
for it in _iter(items):
|
|
35
|
+
bb = getattr(it, "bbox", None)
|
|
36
|
+
if isinstance(bb, BBox):
|
|
37
|
+
boxes.append(bb)
|
|
38
|
+
else:
|
|
39
|
+
p = to_point(it)
|
|
40
|
+
boxes.append(BBox(p.x, p.y, 0, 0))
|
|
41
|
+
return BBox.union_all(boxes) or BBox(0, 0, 0, 0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _iter(items):
|
|
45
|
+
if items is None:
|
|
46
|
+
return []
|
|
47
|
+
if isinstance(items, (list, tuple, set)):
|
|
48
|
+
out = []
|
|
49
|
+
for i in items:
|
|
50
|
+
out.extend(_iter(i))
|
|
51
|
+
return out
|
|
52
|
+
if isinstance(items, Group) and not isinstance(items, Element):
|
|
53
|
+
return list(items)
|
|
54
|
+
return [items]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ==========================================================================
|
|
58
|
+
# Alignment
|
|
59
|
+
# ==========================================================================
|
|
60
|
+
|
|
61
|
+
def align(items, edge: str = "top", to=None) -> list:
|
|
62
|
+
"""Line elements up on one edge (or centre axis).
|
|
63
|
+
|
|
64
|
+
``edge`` is one of ``top/bottom/left/right/center_x/center_y/center``.
|
|
65
|
+
``to`` is the reference (an element, point or bbox); defaults to the first
|
|
66
|
+
item.
|
|
67
|
+
|
|
68
|
+
>>> align([a, b, c], "top")
|
|
69
|
+
>>> align([a, b], "center_x", to=title)
|
|
70
|
+
"""
|
|
71
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
72
|
+
if not els:
|
|
73
|
+
return els
|
|
74
|
+
e = str(edge).lower()
|
|
75
|
+
ref = els[0] if to is None else to
|
|
76
|
+
ref_bb = ref if isinstance(ref, BBox) else bbox_of(ref)
|
|
77
|
+
if e in ("center", "middle"):
|
|
78
|
+
for el in els:
|
|
79
|
+
el.move(ref_bb.cx - el.bbox.cx, ref_bb.cy - el.bbox.cy)
|
|
80
|
+
return els
|
|
81
|
+
if e not in _EDGE_AXIS:
|
|
82
|
+
raise ValueError(f"align edge={edge!r}; use one of "
|
|
83
|
+
f"{sorted(set(_EDGE_AXIS) | {'center'})}")
|
|
84
|
+
for el in els:
|
|
85
|
+
bb = el.bbox
|
|
86
|
+
if e in ("left", "w"):
|
|
87
|
+
el.move(ref_bb.x0 - bb.x0, 0)
|
|
88
|
+
elif e in ("right", "e"):
|
|
89
|
+
el.move(ref_bb.x1 - bb.x1, 0)
|
|
90
|
+
elif e in ("center_x", "cx", "middle_x"):
|
|
91
|
+
el.move(ref_bb.cx - bb.cx, 0)
|
|
92
|
+
elif e in ("top", "n"):
|
|
93
|
+
el.move(0, ref_bb.y0 - bb.y0)
|
|
94
|
+
elif e in ("bottom", "s"):
|
|
95
|
+
el.move(0, ref_bb.y1 - bb.y1)
|
|
96
|
+
else:
|
|
97
|
+
el.move(0, ref_bb.cy - bb.cy)
|
|
98
|
+
return els
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def align_h(items, edge: str = "center_y", to=None) -> list:
|
|
102
|
+
"""Align on a horizontal line (i.e. equalise ``y``)."""
|
|
103
|
+
return align(items, edge, to)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def align_v(items, edge: str = "center_x", to=None) -> list:
|
|
107
|
+
"""Align on a vertical line (i.e. equalise ``x``)."""
|
|
108
|
+
return align(items, edge, to)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ==========================================================================
|
|
112
|
+
# Distribution
|
|
113
|
+
# ==========================================================================
|
|
114
|
+
|
|
115
|
+
def distribute_h(items, gap: float = 20.0, start=None, align_edge=None) -> list:
|
|
116
|
+
"""Lay items out left-to-right with a fixed ``gap`` between them.
|
|
117
|
+
|
|
118
|
+
Keeps the first item where it is unless ``start`` (an x coordinate) is given.
|
|
119
|
+
|
|
120
|
+
>>> distribute_h([a, b, c], gap=24)
|
|
121
|
+
"""
|
|
122
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
123
|
+
if not els:
|
|
124
|
+
return els
|
|
125
|
+
x = els[0].bbox.x0 if start is None else float(start)
|
|
126
|
+
for el in els:
|
|
127
|
+
el.move(x - el.bbox.x0, 0)
|
|
128
|
+
x = el.bbox.x1 + gap
|
|
129
|
+
if align_edge:
|
|
130
|
+
align(els, align_edge, to=els[0])
|
|
131
|
+
return els
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def distribute_v(items, gap: float = 20.0, start=None, align_edge=None) -> list:
|
|
135
|
+
"""Lay items out top-to-bottom with a fixed ``gap``."""
|
|
136
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
137
|
+
if not els:
|
|
138
|
+
return els
|
|
139
|
+
y = els[0].bbox.y0 if start is None else float(start)
|
|
140
|
+
for el in els:
|
|
141
|
+
el.move(0, y - el.bbox.y0)
|
|
142
|
+
y = el.bbox.y1 + gap
|
|
143
|
+
if align_edge:
|
|
144
|
+
align(els, align_edge, to=els[0])
|
|
145
|
+
return els
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def spread_h(items, x0=None, x1=None, mode: str = "edges") -> list:
|
|
149
|
+
"""Spread items evenly across a horizontal span.
|
|
150
|
+
|
|
151
|
+
``mode="edges"`` gives equal gaps between bounding boxes;
|
|
152
|
+
``mode="centers"`` spaces the centres evenly (good for equal-width items).
|
|
153
|
+
"""
|
|
154
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
155
|
+
if len(els) < 2:
|
|
156
|
+
return els
|
|
157
|
+
els.sort(key=lambda e: e.bbox.cx)
|
|
158
|
+
lo = els[0].bbox.x0 if x0 is None else _coord(x0, "x")
|
|
159
|
+
hi = els[-1].bbox.x1 if x1 is None else _coord(x1, "x")
|
|
160
|
+
if mode == "centers":
|
|
161
|
+
c0 = els[0].bbox.cx if x0 is None else lo
|
|
162
|
+
c1 = els[-1].bbox.cx if x1 is None else hi
|
|
163
|
+
step = (c1 - c0) / (len(els) - 1)
|
|
164
|
+
for i, el in enumerate(els):
|
|
165
|
+
el.move(c0 + i * step - el.bbox.cx, 0)
|
|
166
|
+
return els
|
|
167
|
+
total_w = sum(e.bbox.w for e in els)
|
|
168
|
+
gap = ((hi - lo) - total_w) / (len(els) - 1)
|
|
169
|
+
x = lo
|
|
170
|
+
for el in els:
|
|
171
|
+
el.move(x - el.bbox.x0, 0)
|
|
172
|
+
x = el.bbox.x1 + gap
|
|
173
|
+
return els
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def spread_v(items, y0=None, y1=None, mode: str = "edges") -> list:
|
|
177
|
+
"""Spread items evenly across a vertical span."""
|
|
178
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
179
|
+
if len(els) < 2:
|
|
180
|
+
return els
|
|
181
|
+
els.sort(key=lambda e: e.bbox.cy)
|
|
182
|
+
lo = els[0].bbox.y0 if y0 is None else _coord(y0, "y")
|
|
183
|
+
hi = els[-1].bbox.y1 if y1 is None else _coord(y1, "y")
|
|
184
|
+
if mode == "centers":
|
|
185
|
+
c0 = els[0].bbox.cy if y0 is None else lo
|
|
186
|
+
c1 = els[-1].bbox.cy if y1 is None else hi
|
|
187
|
+
step = (c1 - c0) / (len(els) - 1)
|
|
188
|
+
for i, el in enumerate(els):
|
|
189
|
+
el.move(0, c0 + i * step - el.bbox.cy)
|
|
190
|
+
return els
|
|
191
|
+
total_h = sum(e.bbox.h for e in els)
|
|
192
|
+
gap = ((hi - lo) - total_h) / (len(els) - 1)
|
|
193
|
+
y = lo
|
|
194
|
+
for el in els:
|
|
195
|
+
el.move(0, y - el.bbox.y0)
|
|
196
|
+
y = el.bbox.y1 + gap
|
|
197
|
+
return els
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coord(value, axis: str) -> float:
|
|
201
|
+
if isinstance(value, (int, float)):
|
|
202
|
+
return float(value)
|
|
203
|
+
p = to_point(value)
|
|
204
|
+
return p.x if axis == "x" else p.y
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ==========================================================================
|
|
208
|
+
# Stacks & grids
|
|
209
|
+
# ==========================================================================
|
|
210
|
+
|
|
211
|
+
def hstack(items, gap: float = 16.0, align: str = "center", at=None,
|
|
212
|
+
pad=None, panel=False, name: str = None, **panel_style) -> Group:
|
|
213
|
+
"""Place items in a row and return them as a movable :class:`Group`.
|
|
214
|
+
|
|
215
|
+
``align`` is ``center`` / ``top`` / ``bottom``, or ``"baseline"`` to set
|
|
216
|
+
the items like an equation: text baselines line up, and items without text
|
|
217
|
+
(a matrix, an image) centre on the maths axis.
|
|
218
|
+
|
|
219
|
+
>>> row = hstack([a, b, c], gap=20, align="top")
|
|
220
|
+
>>> row.right_of(title, gap=30)
|
|
221
|
+
>>> formula = hstack([Text("$C =$"), matrix, Text("$\\phi$")],
|
|
222
|
+
... gap=8, align="baseline")
|
|
223
|
+
"""
|
|
224
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
225
|
+
if not els:
|
|
226
|
+
return Group(name=name)
|
|
227
|
+
if at is not None:
|
|
228
|
+
p = to_point(at)
|
|
229
|
+
els[0].at(p.x, p.y, anchor="nw")
|
|
230
|
+
distribute_h(els, gap=gap)
|
|
231
|
+
_align_cross(els, align, horizontal=True)
|
|
232
|
+
if panel or pad is not None or panel_style:
|
|
233
|
+
return fit(els, pad=16 if pad is None else pad, name=name,
|
|
234
|
+
**panel_style)
|
|
235
|
+
return Group(*els, name=name)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def vstack(items, gap: float = 12.0, align: str = "center", at=None,
|
|
239
|
+
pad=None, panel=False, name: str = None, **panel_style) -> Group:
|
|
240
|
+
"""Place items in a column and return them as a movable :class:`Group`."""
|
|
241
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
242
|
+
if not els:
|
|
243
|
+
return Group(name=name)
|
|
244
|
+
if at is not None:
|
|
245
|
+
p = to_point(at)
|
|
246
|
+
els[0].at(p.x, p.y, anchor="nw")
|
|
247
|
+
distribute_v(els, gap=gap)
|
|
248
|
+
_align_cross(els, align, horizontal=False)
|
|
249
|
+
if panel or pad is not None or panel_style:
|
|
250
|
+
return fit(els, pad=16 if pad is None else pad, name=name,
|
|
251
|
+
**panel_style)
|
|
252
|
+
return Group(*els, name=name)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def baseline_of(element) -> float:
|
|
256
|
+
"""The first text baseline inside ``element``, or ``None`` if it has none.
|
|
257
|
+
|
|
258
|
+
Looks through shapes to their labels and through groups to their first
|
|
259
|
+
baselined descendant, so a box, a bare label and a component all answer.
|
|
260
|
+
"""
|
|
261
|
+
from .text import Text
|
|
262
|
+
if isinstance(element, Text):
|
|
263
|
+
return element.first_baseline
|
|
264
|
+
label = getattr(element, "label", None)
|
|
265
|
+
if isinstance(label, Text):
|
|
266
|
+
return label.first_baseline
|
|
267
|
+
for child in getattr(element, "_children", ()):
|
|
268
|
+
found = baseline_of(child)
|
|
269
|
+
if found is not None:
|
|
270
|
+
return found
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _math_axis(element, baseline: float) -> float:
|
|
275
|
+
"""Half an x-height above the baseline — where a formula's centre sits."""
|
|
276
|
+
from .fonts import get_font
|
|
277
|
+
from .text import Text
|
|
278
|
+
ref = element if isinstance(element, Text) else getattr(element, "label", None)
|
|
279
|
+
if ref is None:
|
|
280
|
+
return baseline
|
|
281
|
+
size = float(ref.prop("font_size", 14) or 14)
|
|
282
|
+
metrics = get_font(ref.prop("font_family"), ref.prop("font_weight"),
|
|
283
|
+
ref.prop("font_style")).metrics
|
|
284
|
+
return baseline - (metrics.x_height or 0.5) * size / 2.0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _align_baselines(els) -> None:
|
|
288
|
+
"""Line items up on their text baseline, as an equation would be set.
|
|
289
|
+
|
|
290
|
+
Items with no text of their own (a matrix, an image) are centred on the
|
|
291
|
+
maths axis instead, which is what reads correctly beside a symbol.
|
|
292
|
+
"""
|
|
293
|
+
found = [(el, baseline_of(el)) for el in els]
|
|
294
|
+
reference = next(((el, b) for el, b in found if b is not None), None)
|
|
295
|
+
if reference is None:
|
|
296
|
+
ref_box = bbox_of(els)
|
|
297
|
+
for el in els:
|
|
298
|
+
el.move(0, ref_box.cy - el.bbox.cy)
|
|
299
|
+
return
|
|
300
|
+
ref_el, ref_baseline = reference
|
|
301
|
+
axis = _math_axis(ref_el, ref_baseline)
|
|
302
|
+
for el, baseline in found:
|
|
303
|
+
if baseline is not None:
|
|
304
|
+
el.move(0, ref_baseline - baseline)
|
|
305
|
+
else:
|
|
306
|
+
el.move(0, axis - el.bbox.cy)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _align_cross(els, how: str, horizontal: bool) -> None:
|
|
310
|
+
if not how or str(how).lower() in ("none", "keep"):
|
|
311
|
+
return
|
|
312
|
+
a = str(how).lower()
|
|
313
|
+
if horizontal and a == "baseline":
|
|
314
|
+
_align_baselines(els)
|
|
315
|
+
return
|
|
316
|
+
if horizontal:
|
|
317
|
+
edge = {"top": "top", "n": "top", "bottom": "bottom", "s": "bottom",
|
|
318
|
+
"center": "center_y", "middle": "center_y"}.get(a)
|
|
319
|
+
else:
|
|
320
|
+
edge = {"left": "left", "w": "left", "right": "right", "e": "right",
|
|
321
|
+
"center": "center_x", "middle": "center_x"}.get(a)
|
|
322
|
+
if edge is None:
|
|
323
|
+
raise ValueError(f"align={how!r} is not valid for this stack")
|
|
324
|
+
ref = bbox_of(els)
|
|
325
|
+
align(els, edge, to=ref)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def grid(items, cols: int = None, rows: int = None, gap=16, at=None,
|
|
329
|
+
align: str = "center", equal: bool = True, name: str = None,
|
|
330
|
+
order: str = "row") -> Group:
|
|
331
|
+
"""Arrange items in a grid and return a :class:`Group`.
|
|
332
|
+
|
|
333
|
+
``gap`` is a scalar or ``(gap_x, gap_y)``. With ``equal=True`` every cell
|
|
334
|
+
is the size of the largest item, so columns line up.
|
|
335
|
+
"""
|
|
336
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
337
|
+
if not els:
|
|
338
|
+
return Group(name=name)
|
|
339
|
+
n = len(els)
|
|
340
|
+
if cols is None and rows is None:
|
|
341
|
+
cols = math.ceil(math.sqrt(n))
|
|
342
|
+
if cols is None:
|
|
343
|
+
cols = math.ceil(n / rows)
|
|
344
|
+
rows = rows or math.ceil(n / cols)
|
|
345
|
+
gx, gy = (gap if isinstance(gap, (tuple, list)) else (gap, gap))
|
|
346
|
+
origin = to_point(at) if at is not None else bbox_of(els).anchor("nw")
|
|
347
|
+
|
|
348
|
+
if equal:
|
|
349
|
+
cw = max(e.bbox.w for e in els)
|
|
350
|
+
ch = max(e.bbox.h for e in els)
|
|
351
|
+
col_w = [cw] * cols
|
|
352
|
+
row_h = [ch] * rows
|
|
353
|
+
else:
|
|
354
|
+
col_w = [0.0] * cols
|
|
355
|
+
row_h = [0.0] * rows
|
|
356
|
+
for idx, el in enumerate(els):
|
|
357
|
+
r, c = _rc(idx, cols, rows, order)
|
|
358
|
+
col_w[c] = max(col_w[c], el.bbox.w)
|
|
359
|
+
row_h[r] = max(row_h[r], el.bbox.h)
|
|
360
|
+
|
|
361
|
+
xs = [origin.x]
|
|
362
|
+
for w in col_w[:-1]:
|
|
363
|
+
xs.append(xs[-1] + w + gx)
|
|
364
|
+
ys = [origin.y]
|
|
365
|
+
for h in row_h[:-1]:
|
|
366
|
+
ys.append(ys[-1] + h + gy)
|
|
367
|
+
|
|
368
|
+
for idx, el in enumerate(els):
|
|
369
|
+
r, c = _rc(idx, cols, rows, order)
|
|
370
|
+
cell = BBox(xs[c], ys[r], col_w[c], row_h[r])
|
|
371
|
+
anchor = {"center": "center", "left": "w", "right": "e",
|
|
372
|
+
"top": "n", "bottom": "s", "nw": "nw", "ne": "ne",
|
|
373
|
+
"sw": "sw", "se": "se"}.get(str(align).lower(), "center")
|
|
374
|
+
target = cell.anchor(anchor)
|
|
375
|
+
el.at(target.x, target.y, anchor=anchor)
|
|
376
|
+
return Group(*els, name=name)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _rc(idx: int, cols: int, rows: int, order: str) -> tuple:
|
|
380
|
+
if str(order).startswith("col"):
|
|
381
|
+
return idx % rows, idx // rows
|
|
382
|
+
return idx // cols, idx % cols
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ==========================================================================
|
|
386
|
+
# Containers
|
|
387
|
+
# ==========================================================================
|
|
388
|
+
|
|
389
|
+
def fit(*items, pad=16, label=None, label_pos: str = "above_left",
|
|
390
|
+
name: str = None, style=None, **panel_style) -> Group:
|
|
391
|
+
"""Wrap elements in a container box sized around them.
|
|
392
|
+
|
|
393
|
+
Returns a :class:`Group` holding the panel (behind) plus the items, so the
|
|
394
|
+
whole thing moves as a unit. ``result.panel`` is the box itself.
|
|
395
|
+
|
|
396
|
+
``label_pos`` defaults to ``"above_left"`` (outside the box, where it
|
|
397
|
+
cannot collide with the contents); ``"nw"``, ``"n"``, ``"below"`` and the
|
|
398
|
+
other anchors also work.
|
|
399
|
+
|
|
400
|
+
>>> frame = fit(ipm, cmn, pad=20, label="Fused operation", dash=True)
|
|
401
|
+
>>> frame.below_of(header, gap=24)
|
|
402
|
+
"""
|
|
403
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
404
|
+
panel = Panel(els, pad=pad, label=label, label_pos=label_pos,
|
|
405
|
+
style=style, add=False, **panel_style)
|
|
406
|
+
g = Group(panel, *els, name=name)
|
|
407
|
+
g.panel = panel
|
|
408
|
+
return g
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def frame_around(items, pad=16, **style) -> Panel:
|
|
412
|
+
"""Just the container box (no group), tracking ``items`` as they move."""
|
|
413
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
414
|
+
return Panel(els, pad=pad, **style)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def brace_around(items, side: str = "top", gap: float = 8.0,
|
|
418
|
+
depth: float = 10.0, label=None, pad: float = 0.0,
|
|
419
|
+
**kw):
|
|
420
|
+
"""A :class:`~figkit.components.Brace` spanning a set of elements.
|
|
421
|
+
|
|
422
|
+
>>> brace_around([a, b, c], side="top", label="encoder")
|
|
423
|
+
"""
|
|
424
|
+
from .components import Brace
|
|
425
|
+
box = bbox_of(items).expand(pad)
|
|
426
|
+
s = str(side).lower()
|
|
427
|
+
if s in ("top", "n", "above"):
|
|
428
|
+
start, end = (box.x0, box.y0 - gap), (box.x1, box.y0 - gap)
|
|
429
|
+
facing = "up"
|
|
430
|
+
elif s in ("bottom", "s", "below"):
|
|
431
|
+
start, end = (box.x1, box.y1 + gap), (box.x0, box.y1 + gap)
|
|
432
|
+
facing = "down"
|
|
433
|
+
elif s in ("left", "w"):
|
|
434
|
+
start, end = (box.x0 - gap, box.y0), (box.x0 - gap, box.y1)
|
|
435
|
+
facing = "left"
|
|
436
|
+
elif s in ("right", "e"):
|
|
437
|
+
start, end = (box.x1 + gap, box.y1), (box.x1 + gap, box.y0)
|
|
438
|
+
facing = "right"
|
|
439
|
+
else:
|
|
440
|
+
raise ValueError(f"side={side!r}; use top/bottom/left/right")
|
|
441
|
+
return Brace(start, end, depth=depth, side=facing, label=label, **kw)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def group(*items, name: str = None, **kw) -> Group:
|
|
445
|
+
"""Bundle elements into a :class:`Group`."""
|
|
446
|
+
return Group(*[e for e in _iter(items)], name=name, **kw)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ==========================================================================
|
|
450
|
+
# Point helpers
|
|
451
|
+
# ==========================================================================
|
|
452
|
+
|
|
453
|
+
def between(a, b, t: float = 0.5) -> Point:
|
|
454
|
+
"""The point ``t`` of the way from ``a`` to ``b`` (centres by default).
|
|
455
|
+
|
|
456
|
+
>>> mid = between(fm, fn) # vertically between two boxes
|
|
457
|
+
>>> arrow(solver.e, mid)
|
|
458
|
+
"""
|
|
459
|
+
return to_point(a).lerp(to_point(b), t)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
midpoint = between
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def center_on(el: Element, target, axis: str = "both") -> Element:
|
|
466
|
+
"""Centre ``el`` on ``target`` (an element, anchor or point)."""
|
|
467
|
+
bb = bbox_of(target)
|
|
468
|
+
a = str(axis).lower()
|
|
469
|
+
if a in ("both", "xy"):
|
|
470
|
+
return el.center_at(bb.cx, bb.cy)
|
|
471
|
+
if a == "x":
|
|
472
|
+
return el.move(bb.cx - el.bbox.cx, 0)
|
|
473
|
+
if a == "y":
|
|
474
|
+
return el.move(0, bb.cy - el.bbox.cy)
|
|
475
|
+
raise ValueError(f"axis={axis!r}; use both/x/y")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def circular(items, center=(0, 0), radius: float = 120.0,
|
|
479
|
+
start_angle: float = -90.0, sweep: float = 360.0,
|
|
480
|
+
rotate_items: bool = False) -> list:
|
|
481
|
+
"""Place items evenly around a circle (cycle diagrams)."""
|
|
482
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
483
|
+
if not els:
|
|
484
|
+
return els
|
|
485
|
+
c = to_point(center)
|
|
486
|
+
n = len(els)
|
|
487
|
+
step = sweep / n if abs(sweep) >= 359.99 else sweep / max(1, n - 1)
|
|
488
|
+
for i, el in enumerate(els):
|
|
489
|
+
a = math.radians(start_angle + i * step)
|
|
490
|
+
el.center_at(c.x + math.cos(a) * radius, c.y + math.sin(a) * radius)
|
|
491
|
+
if rotate_items:
|
|
492
|
+
el.rotate(start_angle + i * step + 90)
|
|
493
|
+
return els
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def shift(items, dx: float = 0.0, dy: float = 0.0) -> list:
|
|
497
|
+
"""Move several elements at once."""
|
|
498
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
499
|
+
for el in els:
|
|
500
|
+
el.move(dx, dy)
|
|
501
|
+
return els
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def same_width(items, width: float = None, anchor: str = "center") -> list:
|
|
505
|
+
"""Give every item the same width (the widest, unless ``width`` is given)."""
|
|
506
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
507
|
+
if not els:
|
|
508
|
+
return els
|
|
509
|
+
w = width if width is not None else max(e.bbox.w for e in els)
|
|
510
|
+
for el in els:
|
|
511
|
+
el.resize(w=w, anchor=anchor)
|
|
512
|
+
return els
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def same_height(items, height: float = None, anchor: str = "center") -> list:
|
|
516
|
+
"""Give every item the same height (the tallest, unless given)."""
|
|
517
|
+
els = [e for e in _iter(items) if isinstance(e, Element)]
|
|
518
|
+
if not els:
|
|
519
|
+
return els
|
|
520
|
+
h = height if height is not None else max(e.bbox.h for e in els)
|
|
521
|
+
for el in els:
|
|
522
|
+
el.resize(h=h, anchor=anchor)
|
|
523
|
+
return els
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def same_size(items, anchor: str = "center") -> list:
|
|
527
|
+
"""Give every item the same width *and* height."""
|
|
528
|
+
same_width(items, anchor=anchor)
|
|
529
|
+
return same_height(items, anchor=anchor)
|