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/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
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)
|