codefig 0.17.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.
codefig/solids.py ADDED
@@ -0,0 +1,514 @@
1
+ """Oblique 3-D solids and level diagrams (v0.7.0).
2
+
3
+ * :class:`Cuboid` — a box drawn in oblique projection (front face + top + side face),
4
+ the usual "tensor" / "server" / "memory block" picture. Full :class:`~codefig.primitives.Box`
5
+ API (text, fill, border, category, …); the label sits on the front face, ports land on
6
+ the visible silhouette (``port("top")`` on the top face's back edge, ``port("right")`` on
7
+ the side face) and ``face_port("top" | "side" | "front")`` targets a face centre.
8
+ ``grid=(rows, cols)`` rules cells on the front face (a token map / tensor), and
9
+ ``layers=k`` rules ``k`` slices along the depth (a KV cache with layers).
10
+ * :class:`SlabStack` — a row of thin cuboid slabs (feature maps, multi-scale token maps),
11
+ labels underneath, ``slab(i)`` for edges.
12
+ * :class:`Pyramid` — N stacked levels (apex on top: a memory hierarchy), per-level labels,
13
+ side notes on either side and ``level_port(i, side)`` ports on the slanted edges.
14
+
15
+ No coordinates: sizes come from the labels and theme lengths; ``depth`` / ``angle`` set
16
+ the oblique projection (``direction="up-left"`` mirrors it).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from typing import Any, List, Optional, Sequence, Tuple, Union
23
+
24
+ from .node import Node
25
+ from .primitives import Box, Text
26
+ from .shapes import Shape, _poly_d
27
+ from .styles import shade, themed, tint
28
+
29
+ Pt = Tuple[float, float]
30
+
31
+
32
+ def _hex(c: Any) -> bool:
33
+ return isinstance(c, str) and c.startswith("#") and len(c) in (4, 7)
34
+
35
+
36
+ class Cuboid(Shape):
37
+ """Box in oblique projection: ``Cuboid("KV cache", depth=10, angle=40)``.
38
+
39
+ depth: receding edge length (theme length; default ≈ 6 pt paper).
40
+ angle: receding angle in degrees (0 = flat to the right, 90 = straight up).
41
+ direction: ``up-right`` (default) or ``up-left``.
42
+ top_fill / side_fill: face colours (default: the front fill lightened / darkened).
43
+ grid: ``(rows, cols)`` cell lines on the front face; ``layers``: slices in depth.
44
+ Width / height (``w=`` / ``h=``) are the *overall* bounding box, like every node.
45
+ """
46
+
47
+ cub_depth = themed(11.0)
48
+
49
+ def __init__(self, text: str = "", *, depth: Optional[float] = None, angle: float = 40.0,
50
+ direction: str = "up-right", top_fill: Optional[str] = None,
51
+ side_fill: Optional[str] = None, grid: Optional[Tuple[int, int]] = None,
52
+ layers: int = 0, **kwargs: Any) -> None:
53
+ if direction not in ("up-right", "up-left"):
54
+ raise ValueError("direction must be up-right|up-left")
55
+ if not 0.0 <= float(angle) <= 90.0:
56
+ raise ValueError("angle must be within 0..90 degrees")
57
+ self.cub_depth = depth
58
+ self.angle = float(angle)
59
+ self.direction = direction
60
+ self.top_fill = top_fill
61
+ self.side_fill = side_fill
62
+ self.grid = tuple(int(v) for v in grid) if grid else None
63
+ self.layers = int(layers)
64
+ super().__init__(text, **kwargs)
65
+
66
+ # -- projection ----------------------------------------------------------------
67
+ def _dxdy(self) -> Tuple[float, float]:
68
+ d = float(self.cub_depth)
69
+ a = math.radians(self.angle)
70
+ return abs(d * math.cos(a)), abs(d * math.sin(a))
71
+
72
+ def _front(self, x: float, y: float, w: float, h: float) -> Tuple[float, float, float, float]:
73
+ dx, dy = self._dxdy()
74
+ fx = x if self.direction == "up-right" else x + dx
75
+ return fx, y + dy, max(1.0, w - dx), max(1.0, h - dy)
76
+
77
+ def _frame_size(self, iw: float, ih: float) -> Tuple[float, float]:
78
+ dx, dy = self._dxdy()
79
+ return iw + dx, ih + dy
80
+
81
+ def _text_rect(self, x: float, y: float, w: float, h: float):
82
+ return self._front(x, y, w, h)
83
+
84
+ def _faces(self, x: float, y: float, w: float, h: float):
85
+ """(front, top, side) polygons."""
86
+ dx, dy = self._dxdy()
87
+ fx, fy, fw, fh = self._front(x, y, w, h)
88
+ s = 1.0 if self.direction == "up-right" else -1.0
89
+ front = [(fx, fy), (fx + fw, fy), (fx + fw, fy + fh), (fx, fy + fh)]
90
+ top = [(fx, fy), (fx + s * dx, fy - dy), (fx + fw + s * dx, fy - dy), (fx + fw, fy)]
91
+ ex = fx + fw if s > 0 else fx
92
+ side = [(ex, fy), (ex + s * dx, fy - dy), (ex + s * dx, fy + fh - dy), (ex, fy + fh)]
93
+ return front, top, side
94
+
95
+ def _outline(self, x: float, y: float, w: float, h: float) -> List[Pt]:
96
+ dx, dy = self._dxdy()
97
+ fx, fy, fw, fh = self._front(x, y, w, h)
98
+ if self.direction == "up-right":
99
+ return [(fx, fy), (fx + dx, fy - dy), (fx + fw + dx, fy - dy),
100
+ (fx + fw + dx, fy + fh - dy), (fx + fw, fy + fh), (fx, fy + fh)]
101
+ return [(fx - dx, fy - dy), (fx + fw - dx, fy - dy), (fx + fw, fy), (fx + fw, fy + fh),
102
+ (fx, fy + fh), (fx - dx, fy + fh - dy)]
103
+
104
+ # -- ports -----------------------------------------------------------------------
105
+ def face_port(self, face: str = "top", *, at: float = 0.5):
106
+ """Port at the centre of a visible face: ``top`` | ``side`` | ``front``."""
107
+ from .edges import Port
108
+
109
+ if face not in ("top", "side", "front"):
110
+ raise ValueError("face must be top|side|front")
111
+ return Port(self, kind="custom", side=face, at=at)
112
+
113
+ def resolve_custom_port(self, face: str, _i: int = 0, at: float = 0.5):
114
+ g = self.geom
115
+ front, top, side = self._faces(g.x, g.y, g.w, g.h)
116
+ poly = {"front": front, "top": top, "side": side}[face]
117
+ cx = sum(p[0] for p in poly) / 4
118
+ cy = sum(p[1] for p in poly) / 4
119
+ if face == "top":
120
+ return cx, cy, "up"
121
+ if face == "side":
122
+ return cx, cy, "right" if self.direction == "up-right" else "left"
123
+ return cx, cy, "down"
124
+
125
+ # -- drawing ---------------------------------------------------------------------
126
+ def _face_fills(self) -> Tuple[Any, Any, Any]:
127
+ f = self.fill if self.fill not in (None, "none") else "#FFFFFF"
128
+ top = self.top_fill or (tint(f, 0.45) if _hex(f) else f)
129
+ side = self.side_fill or (shade(f, 0.12) if _hex(f) else f)
130
+ return f, top, side
131
+
132
+ def _draw_frame(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
133
+ from .render_svg import draw_line, draw_path
134
+
135
+ front, top, side = self._faces(x, y, w, h)
136
+ ff, tf, sf = self._face_fills()
137
+ stroke = self._border_stroke(self.stroke)
138
+ sw = self.stroke_width
139
+ dash = self._dash(sw)
140
+ for poly, fill in ((top, tf), (side, sf)):
141
+ draw_path(buf, _poly_d(poly), fill=fill, stroke=stroke or "none", stroke_width=sw,
142
+ dasharray=dash)
143
+ fx, fy, fw, fh = self._front(x, y, w, h)
144
+ thin = self.theme.stroke_width # grid lines: same weight (lint: >= 0.5 pt)
145
+ if self.layers > 1: # slices along the depth, drawn on top + side faces
146
+ dx, dy = self._dxdy()
147
+ s = 1.0 if self.direction == "up-right" else -1.0
148
+ ex = fx + fw if s > 0 else fx
149
+ for k in range(1, self.layers):
150
+ t = k / self.layers
151
+ ox, oy = s * dx * t, -dy * t
152
+ draw_line(buf, fx + ox, fy + oy, fx + fw + ox, fy + oy, stroke=stroke, stroke_width=thin)
153
+ draw_line(buf, ex + ox, fy + oy, ex + ox, fy + fh + oy, stroke=stroke, stroke_width=thin)
154
+ draw_path(buf, _poly_d(front), fill=ff, stroke="none", stroke_width=0)
155
+ if self.pattern is not None:
156
+ self._paint_pattern(buf, fx, fy, fw, fh)
157
+ if self.grid:
158
+ r, c = self.grid
159
+ # v0.12: grid lines stop short of the label instead of striking it through
160
+ hole = None
161
+ if getattr(self, "text", None) or getattr(self, "subtitle", None):
162
+ L = self.theme.L
163
+ tx, ty, tw, tht = self._text_rect(x, y, w, h)
164
+ cw, ch = self._content_size()
165
+ hole = (tx + (tw - cw) / 2 - L(2.0), ty + (tht - ch) / 2 - L(1.0),
166
+ tx + (tw + cw) / 2 + L(2.0), ty + (tht + ch) / 2 + L(1.0))
167
+
168
+ def seg(a0: float, a1: float, lo: float, hi: float) -> List[Tuple[float, float]]:
169
+ if lo >= a1 or hi <= a0:
170
+ return [(a0, a1)]
171
+ return [(p, q) for p, q in ((a0, lo), (hi, a1)) if q - p > 0.5]
172
+
173
+ for i in range(1, max(1, r)):
174
+ yy = fy + fh * i / r
175
+ parts = ([(fx, fx + fw)] if hole is None or not (hole[1] < yy < hole[3])
176
+ else seg(fx, fx + fw, hole[0], hole[2]))
177
+ for p0, p1 in parts:
178
+ draw_line(buf, p0, yy, p1, yy, stroke=stroke, stroke_width=thin)
179
+ for j in range(1, max(1, c)):
180
+ xx = fx + fw * j / c
181
+ parts = ([(fy, fy + fh)] if hole is None or not (hole[0] < xx < hole[2])
182
+ else seg(fy, fy + fh, hole[1], hole[3]))
183
+ for p0, p1 in parts:
184
+ draw_line(buf, xx, p0, xx, p1, stroke=stroke, stroke_width=thin)
185
+ if stroke not in (None, "none") and sw > 0:
186
+ draw_path(buf, _poly_d(front), fill="none", stroke=stroke, stroke_width=sw, dasharray=dash)
187
+
188
+
189
+ class SlabStack(Node):
190
+ """A row of thin oblique slabs — feature maps, multi-scale token maps, layer stacks.
191
+
192
+ ``SlabStack(4, labels=["r1", "r2", "r3", "r4"], heights=[10, 16, 22, 28])``.
193
+ n / heights: number of slabs, or one front height per slab (growing / shrinking maps).
194
+ thickness: slab front width; depth / angle: oblique projection (as :class:`Cuboid`).
195
+ fills: one colour, or one per slab (default: the theme's first palette colour).
196
+ grid: ``(rows, cols)`` ruled on every slab's *side* (the large face) when it is a
197
+ token map. ``slab(i)`` is the i-th :class:`Cuboid` (for edges / ports).
198
+ """
199
+
200
+ slab_thickness = themed(7.0)
201
+ slab_gap = themed(9.0)
202
+ slab_height = themed(46.0)
203
+ slab_depth = themed(26.0)
204
+
205
+ def __init__(self, n: Optional[int] = None, *, heights: Optional[Sequence[float]] = None,
206
+ labels: Optional[Sequence[str]] = None, fills: Union[None, str, Sequence[str]] = None,
207
+ thickness: Optional[float] = None, depth: Optional[float] = None,
208
+ angle: float = 40.0, gap: Optional[float] = None,
209
+ align: str = "bottom", **kwargs: Any) -> None:
210
+ super().__init__(**kwargs)
211
+ if heights is None and n is None:
212
+ raise ValueError("SlabStack needs n or heights")
213
+ self._heights = list(heights) if heights is not None else None
214
+ self.n = len(self._heights) if self._heights is not None else int(n) # type: ignore[arg-type]
215
+ if self.n < 1:
216
+ raise ValueError("SlabStack needs at least one slab")
217
+ if align not in ("bottom", "center", "top"):
218
+ raise ValueError("align must be bottom|center|top")
219
+ self.labels = list(labels) if labels else []
220
+ self.slab_thickness = thickness
221
+ self.slab_depth = depth
222
+ self.slab_gap = gap
223
+ self.angle = float(angle)
224
+ self.v_align = align
225
+ if fills is None or isinstance(fills, str):
226
+ fl: List[Optional[str]] = [fills] * self.n # type: ignore[list-item]
227
+ else:
228
+ fl = list(fills) + [None] * self.n
229
+ self._slabs = [Cuboid("", fill=fl[i] or self.theme.palette[0], depth=None, angle=self.angle)
230
+ for i in range(self.n)]
231
+ self._texts = [Text(t, font_size=self.theme.small_size) for t in self.labels[: self.n]]
232
+ for k in self._slabs + self._texts:
233
+ k.parent = self
234
+
235
+ def children(self) -> Sequence[Node]:
236
+ return tuple(self._slabs) + tuple(self._texts)
237
+
238
+ def slab(self, i: int) -> Cuboid:
239
+ """The i-th slab (a :class:`Cuboid`)."""
240
+ return self._slabs[i]
241
+
242
+ def _slab_size(self, i: int) -> Tuple[float, float]:
243
+ c = self._slabs[i]
244
+ c.cub_depth = self.slab_depth
245
+ dx, dy = c._dxdy()
246
+ h = float(self._heights[i]) if self._heights is not None else float(self.slab_height)
247
+ return self.slab_thickness + dx, h + dy
248
+
249
+ def _measure(self) -> Tuple[float, float]:
250
+ sizes = [self._slab_size(i) for i in range(self.n)]
251
+ tw = [t.measure() for t in self._texts]
252
+ cols = [max(sizes[i][0], tw[i][0] if i < len(tw) else 0.0) for i in range(self.n)]
253
+ lab_h = max((h for _, h in tw), default=0.0)
254
+ gap_t = self.theme.L(3.0) if self._texts else 0.0
255
+ return (sum(cols) + self.slab_gap * (self.n - 1),
256
+ max(h for _, h in sizes) + gap_t + lab_h)
257
+
258
+ def _layout_children(self) -> None:
259
+ g = self.geom
260
+ sizes = [self._slab_size(i) for i in range(self.n)]
261
+ tw = [t.measure() for t in self._texts]
262
+ cols = [max(sizes[i][0], tw[i][0] if i < len(tw) else 0.0) for i in range(self.n)]
263
+ hmax = max(h for _, h in sizes)
264
+ gap_t = self.theme.L(3.0) if self._texts else 0.0
265
+ x = g.x + max(0.0, (g.w - self._measure()[0]) / 2)
266
+ for i in range(self.n):
267
+ w, h = sizes[i]
268
+ if self.v_align == "bottom":
269
+ y = g.y + hmax - h
270
+ elif self.v_align == "top":
271
+ y = g.y
272
+ else:
273
+ y = g.y + (hmax - h) / 2
274
+ self._slabs[i].layout(x + (cols[i] - w) / 2, y, w, h)
275
+ if i < len(self._texts):
276
+ lw, lh = tw[i]
277
+ self._texts[i].layout(x + (cols[i] - lw) / 2, g.y + hmax + gap_t, lw, lh)
278
+ x += cols[i] + self.slab_gap
279
+
280
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
281
+ for k in self.children():
282
+ k.draw(buf, ox, oy)
283
+
284
+
285
+ class Pyramid(Node):
286
+ """N stacked levels, apex on top — memory hierarchies, data pyramids.
287
+
288
+ ``Pyramid(["SRAM", "HBM", "DRAM"], notes_right=["19 TB/s", "1.5 TB/s", "12.8 GB/s"])``
289
+ levels: labels top → bottom (the top level is the apex triangle unless ``top_width``).
290
+ fills: colours per level (default: theme palette, top → bottom).
291
+ notes_left / notes_right: side annotations, one per level (``None`` skips), vertically
292
+ centred on their level and aligned in a column beside the pyramid.
293
+ top_width: width of the cut apex as a fraction of the base (0 = sharp apex).
294
+ ``level(i)`` → the level's bounding rect, ``level_port(i, side)`` → a port on the
295
+ slanted edge (left / right) or the level's top / bottom edge, ``note_port(i, side)``
296
+ → the port of a side note (for leader edges).
297
+ A label too wide for its band is placed beside the pyramid (towards ``notes_left``'s
298
+ side) instead of overflowing.
299
+ """
300
+
301
+ level_h = themed(26.0)
302
+ base_min = themed(150.0)
303
+ note_gap = themed(8.0)
304
+
305
+ def __init__(self, levels: Sequence[str], *, fills: Optional[Sequence[str]] = None,
306
+ notes_left: Optional[Sequence[Optional[str]]] = None,
307
+ notes_right: Optional[Sequence[Optional[str]]] = None,
308
+ top_width: float = 0.0, level_height: Optional[float] = None,
309
+ width: Optional[float] = None, stroke: Optional[str] = None,
310
+ stroke_width: Optional[float] = None, text_color: Optional[str] = None,
311
+ gap: float = 0.0, leaders: bool = False, **kwargs: Any) -> None:
312
+ super().__init__(**kwargs)
313
+ if not levels:
314
+ raise ValueError("Pyramid needs at least one level")
315
+ if not 0.0 <= top_width < 1.0:
316
+ raise ValueError("top_width must be within [0, 1)")
317
+ self.levels = [str(t) for t in levels]
318
+ n = len(self.levels)
319
+ pal = list(self.theme.palette)
320
+ self.fills = list(fills) if fills else [pal[i % len(pal)] for i in range(n)]
321
+ self.top_width = float(top_width)
322
+ self.level_h = level_height
323
+ self.base_w = width
324
+ self.stroke = stroke
325
+ self.stroke_width = stroke_width
326
+ self.text_color = text_color
327
+ self.band_gap = float(gap)
328
+ self.leaders = bool(leaders)
329
+ fs = self.theme.font_size
330
+ self._labels = [Text(t, font_size=fs, color=text_color) for t in self.levels]
331
+
332
+ self._nl = [None if t is None else Text(t, font_size=self.theme.small_size, align="right")
333
+ for t in (list(notes_left or []) + [None] * n)[:n]]
334
+ self._nr = [None if t is None else Text(t, font_size=self.theme.small_size, align="left")
335
+ for t in (list(notes_right or []) + [None] * n)[:n]]
336
+ for k in self.children():
337
+ k.parent = self
338
+ self._outside: List[bool] = [False] * n
339
+
340
+ def children(self) -> Sequence[Node]:
341
+ return tuple(self._labels) + tuple(t for t in self._nl + self._nr if t is not None)
342
+
343
+ # -- geometry ----------------------------------------------------------------------
344
+ def _lh(self) -> float:
345
+ th = max(t.measure()[1] for t in self._labels)
346
+ return max(float(self.level_h), th + self.theme.L(8.0))
347
+
348
+ def _width_at(self, W: float, H: float, y: float) -> float:
349
+ """Pyramid width at height ``y`` from its top (0..H)."""
350
+ t = self.top_width
351
+ return W * (t + (1 - t) * max(0.0, min(1.0, y / H)))
352
+
353
+ def _base(self) -> float:
354
+ if self.base_w is not None:
355
+ return float(self.base_w)
356
+ n = len(self.levels)
357
+ lh = self._lh()
358
+ H = n * lh
359
+ pad = self.theme.L(6.0)
360
+ W = float(self.base_min)
361
+ for i, lab in enumerate(self._labels):
362
+ if i == 0 and self.top_width == 0.0 and n > 1:
363
+ continue # the apex label may drop low in its band or go outside
364
+ need = lab.measure()[0] + 2 * pad
365
+ frac = self.top_width + (1 - self.top_width) * ((i + 0.5) * lh / H)
366
+ W = max(W, need / max(frac, 1e-6))
367
+ return W
368
+
369
+ def _note_cols(self) -> Tuple[float, float]:
370
+ wl = max((t.measure()[0] for t in self._nl if t is not None), default=0.0)
371
+ wr = max((t.measure()[0] for t in self._nr if t is not None), default=0.0)
372
+ return wl, wr
373
+
374
+ def _apex_out(self) -> bool:
375
+ """True when the apex label does not fit inside the apex band."""
376
+ n = len(self.levels)
377
+ W, lh = self._base(), self._lh()
378
+ H = n * lh
379
+ lw, lab_h = self._labels[0].measure()
380
+ pad = self.theme.L(3.0)
381
+ y_low = lh - lab_h / 2 - pad # lowest label centre inside band 0
382
+ # the label's *top* edge is where the triangle is narrowest
383
+ return self._width_at(W, H, y_low - lab_h / 2) < lw + 2 * pad and n > 1
384
+
385
+ def _measure(self) -> Tuple[float, float]:
386
+ n = len(self.levels)
387
+ W = self._base()
388
+ H = n * self._lh() + self.band_gap * (n - 1)
389
+ wl, wr = self._note_cols()
390
+ g = self.note_gap
391
+ left = wl + g if wl else 0.0
392
+ if self._apex_out() and not wl:
393
+ left = max(left, self._labels[0].measure()[0] + g)
394
+ return W + left + (wr + g if wr else 0.0), H
395
+
396
+ def _origin(self) -> Tuple[float, float, float, float]:
397
+ """(x0, y0, W, H) of the pyramid body inside the node box."""
398
+ g = self.geom
399
+ n = len(self.levels)
400
+ W = self._base()
401
+ H = n * self._lh() + self.band_gap * (n - 1)
402
+ wl, _wr = self._note_cols()
403
+ left = wl + self.note_gap if wl else 0.0
404
+ if self._apex_out() and not wl:
405
+ left = max(left, self._labels[0].measure()[0] + self.note_gap)
406
+ mw = self._measure()[0]
407
+ return g.x + (g.w - mw) / 2 + left, g.y + (g.h - H) / 2, W, H
408
+
409
+ def _band(self, i: int) -> Tuple[float, float]:
410
+ lh = self._lh()
411
+ y0 = i * (lh + self.band_gap)
412
+ return y0, y0 + lh
413
+
414
+ def _poly(self, i: int) -> List[Pt]:
415
+ x0, y0, W, H = self._origin()
416
+ a, b = self._band(i)
417
+ cx = x0 + W / 2
418
+ wa, wb = self._width_at(W, H, a), self._width_at(W, H, b)
419
+ return [(cx - wa / 2, y0 + a), (cx + wa / 2, y0 + a), (cx + wb / 2, y0 + b), (cx - wb / 2, y0 + b)]
420
+
421
+ def level(self, i: int) -> Tuple[float, float, float, float]:
422
+ """(x, y, w, h) bounding rect of level ``i`` (0 = top)."""
423
+ p = self._poly(i)
424
+ xs = [q[0] for q in p]
425
+ ys = [q[1] for q in p]
426
+ return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
427
+
428
+ def level_port(self, i: int, side: str = "right", *, at: float = 0.5):
429
+ """Port on level ``i``: left / right on the slanted edge, top / bottom on the level edge."""
430
+ from .edges import Port
431
+
432
+ if not 0 <= i < len(self.levels):
433
+ raise IndexError(f"level {i} out of range")
434
+ if side not in ("left", "right", "top", "bottom"):
435
+ raise ValueError("side must be left|right|top|bottom")
436
+ return Port(self, kind="custom", side=side, index=i, at=at)
437
+
438
+ def note_port(self, i: int, side: str = "right"):
439
+ """Port of the side note of level ``i`` (``side`` = which note: left | right)."""
440
+ t = (self._nr if side == "right" else self._nl)[i]
441
+ if t is None:
442
+ raise ValueError(f"level {i} has no {side} note")
443
+ return t.port("left" if side == "right" else "right")
444
+
445
+ def resolve_custom_port(self, side: str, i: int = 0, at: float = 0.5):
446
+ (lx, ly), (rx, ry), (rbx, rby), (lbx, lby) = self._poly(i)
447
+ if side == "top":
448
+ return lx + (rx - lx) * at, ly, "up"
449
+ if side == "bottom":
450
+ return lbx + (rbx - lbx) * at, lby, "down"
451
+ if side == "left":
452
+ return lx + (lbx - lx) * at, ly + (lby - ly) * at, "left"
453
+ return rx + (rbx - rx) * at, ry + (rby - ry) * at, "right"
454
+
455
+ def _layout_children(self) -> None:
456
+ x0, y0, W, H = self._origin()
457
+ g = self.geom
458
+ pad = self.theme.L(3.0)
459
+ n = len(self.levels)
460
+ self._outside = [False] * n
461
+ wl, wr = self._note_cols()
462
+ for i, lab in enumerate(self._labels):
463
+ lw, lh_ = lab.measure()
464
+ a, b = self._band(i)
465
+ cy = (a + b) / 2
466
+ if i == 0 and n > 1 and self.top_width == 0.0:
467
+ # drop the apex label as low as needed for it to fit
468
+ need_y = H * (lw + 2 * pad) / W # width(y) = W * y / H at the label's top
469
+ cy = min(max(cy, need_y + lh_ / 2), b - lh_ / 2 - pad)
470
+ if self._apex_out():
471
+ self._outside[0] = True
472
+ cy = (a + b) / 2
473
+ nx = x0 + W / 2 - self._width_at(W, H, cy) / 2 - self.note_gap - lw
474
+ lab.layout(nx, y0 + cy - lh_ / 2, lw, lh_)
475
+ continue
476
+ lab.layout(x0 + W / 2 - lw / 2, y0 + cy - lh_ / 2, lw, lh_)
477
+ for i in range(n):
478
+ a, b = self._band(i)
479
+ cy = y0 + (a + b) / 2
480
+ t = self._nl[i]
481
+ if t is not None:
482
+ w_, h_ = t.measure()
483
+ t.layout(x0 - self.note_gap - w_, cy - h_ / 2, w_, h_)
484
+ t = self._nr[i]
485
+ if t is not None:
486
+ w_, h_ = t.measure()
487
+ t.layout(x0 + W + self.note_gap, cy - h_ / 2, w_, h_)
488
+
489
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
490
+ from .render_svg import draw_path
491
+
492
+ stroke = self.stroke or self.theme.stroke
493
+ sw = self.stroke_width if self.stroke_width is not None else self.theme.stroke_width
494
+ for i in range(len(self.levels)):
495
+ pts = [(x + ox, y + oy) for x, y in self._poly(i)]
496
+ draw_path(buf, _poly_d(pts), fill=self.fills[i % len(self.fills)], stroke=stroke,
497
+ stroke_width=sw)
498
+ if self.leaders:
499
+ from .render_svg import draw_line
500
+
501
+ th = self.theme
502
+ for i in range(len(self.levels)):
503
+ for side, t in (("left", self._nl[i]), ("right", self._nr[i])):
504
+ if t is None:
505
+ continue
506
+ px, py, _d = self.resolve_custom_port(side, i, 0.5)
507
+ g = t.geom
508
+ tx = g.x - th.L(2.0) if side == "right" else g.right + th.L(2.0)
509
+ gap = th.L(2.0) if side == "right" else -th.L(2.0)
510
+ draw_line(buf, px + gap + ox, py + oy, tx + ox, py + oy,
511
+ stroke=th.muted_color, stroke_width=th.stroke_width,
512
+ dasharray=f"{th.L(1.0):.2f} {th.L(2.0):.2f}")
513
+ for k in self.children():
514
+ k.draw(buf, ox, oy)