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/primitives.py
ADDED
|
@@ -0,0 +1,2721 @@
|
|
|
1
|
+
"""Drawable primitives: Box, Text, Arrow, Matrix, DotGrid, Strip, Ellipsis, Group, Spacer, Brace, CircleHighlight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from typing import Dict, Any, List, Optional, Sequence, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from .node import BoxGeom, Col, Container, Node, Row
|
|
12
|
+
from .styles import DEFAULT_THEME, Theme, current_theme, normalize_pad, theme_default, themed
|
|
13
|
+
from .textutil import expand_subscripts, markup_lines, measure_text, split_lines, xml_escape
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def paint_dot_grid(
|
|
17
|
+
buf: List[str],
|
|
18
|
+
x: float,
|
|
19
|
+
y: float,
|
|
20
|
+
w: float,
|
|
21
|
+
h: float,
|
|
22
|
+
*,
|
|
23
|
+
pitch: float = 8.0,
|
|
24
|
+
r: float = 1.4,
|
|
25
|
+
color: str = "#888888",
|
|
26
|
+
opacity: float = 0.55,
|
|
27
|
+
inset: float = 2.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Fill a rectangle with a regular lattice of dots (no clipPath needed)."""
|
|
30
|
+
from .render_svg import draw_circle
|
|
31
|
+
|
|
32
|
+
if w <= 0 or h <= 0 or pitch <= 0:
|
|
33
|
+
return
|
|
34
|
+
x0 = x + inset
|
|
35
|
+
y0 = y + inset
|
|
36
|
+
x1 = x + w - inset
|
|
37
|
+
y1 = y + h - inset
|
|
38
|
+
if x1 <= x0 or y1 <= y0:
|
|
39
|
+
return
|
|
40
|
+
# Center lattice so edges look even
|
|
41
|
+
nx = max(1, int((x1 - x0) / pitch) + 1)
|
|
42
|
+
ny = max(1, int((y1 - y0) / pitch) + 1)
|
|
43
|
+
# Recompute pitch to fit evenly when possible
|
|
44
|
+
span_x = x1 - x0
|
|
45
|
+
span_y = y1 - y0
|
|
46
|
+
if nx > 1:
|
|
47
|
+
dx = span_x / (nx - 1)
|
|
48
|
+
else:
|
|
49
|
+
dx = 0.0
|
|
50
|
+
x0 = (x0 + x1) / 2
|
|
51
|
+
if ny > 1:
|
|
52
|
+
dy = span_y / (ny - 1)
|
|
53
|
+
else:
|
|
54
|
+
dy = 0.0
|
|
55
|
+
y0 = (y0 + y1) / 2
|
|
56
|
+
# Prefer declared pitch when span is large enough
|
|
57
|
+
if nx > 1 and abs(dx - pitch) > 0.01:
|
|
58
|
+
# use floor count at pitch, margin-centered
|
|
59
|
+
nx = max(1, int(span_x / pitch) + 1)
|
|
60
|
+
used = (nx - 1) * pitch if nx > 1 else 0.0
|
|
61
|
+
x0 = x + inset + max(0.0, (span_x - used) / 2)
|
|
62
|
+
dx = pitch if nx > 1 else 0.0
|
|
63
|
+
if ny > 1 and abs(dy - pitch) > 0.01:
|
|
64
|
+
ny = max(1, int(span_y / pitch) + 1)
|
|
65
|
+
used = (ny - 1) * pitch if ny > 1 else 0.0
|
|
66
|
+
y0 = y + inset + max(0.0, (span_y - used) / 2)
|
|
67
|
+
dy = pitch if ny > 1 else 0.0
|
|
68
|
+
for j in range(ny):
|
|
69
|
+
cy = y0 + j * dy
|
|
70
|
+
for i in range(nx):
|
|
71
|
+
cx = x0 + i * dx
|
|
72
|
+
draw_circle(buf, cx, cy, r, fill=color, opacity=opacity)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def paint_strip(
|
|
76
|
+
buf: List[str],
|
|
77
|
+
x: float,
|
|
78
|
+
y: float,
|
|
79
|
+
w: float,
|
|
80
|
+
h: float,
|
|
81
|
+
*,
|
|
82
|
+
direction: str = "horizontal", # horizontal|vertical
|
|
83
|
+
stripe: float = 6.0,
|
|
84
|
+
colors: Sequence[str] = ("#e8e8e8", "#ffffff"),
|
|
85
|
+
opacity: float = 1.0,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Paint alternating horizontal or vertical stripes inside a rect."""
|
|
88
|
+
from .render_svg import draw_rect
|
|
89
|
+
|
|
90
|
+
if w <= 0 or h <= 0 or stripe <= 0:
|
|
91
|
+
return
|
|
92
|
+
cols = list(colors) if colors else ["#e8e8e8", "#ffffff"]
|
|
93
|
+
n = len(cols)
|
|
94
|
+
if direction in ("vertical", "v", "col"):
|
|
95
|
+
i = 0
|
|
96
|
+
cx = x
|
|
97
|
+
while cx < x + w - 0.01:
|
|
98
|
+
bw = min(stripe, x + w - cx)
|
|
99
|
+
draw_rect(
|
|
100
|
+
buf, cx, y, bw, h,
|
|
101
|
+
fill=cols[i % n], stroke="none", stroke_width=0,
|
|
102
|
+
opacity=opacity,
|
|
103
|
+
)
|
|
104
|
+
cx += stripe
|
|
105
|
+
i += 1
|
|
106
|
+
else:
|
|
107
|
+
i = 0
|
|
108
|
+
cy = y
|
|
109
|
+
while cy < y + h - 0.01:
|
|
110
|
+
bh = min(stripe, y + h - cy)
|
|
111
|
+
draw_rect(
|
|
112
|
+
buf, x, cy, w, bh,
|
|
113
|
+
fill=cols[i % n], stroke="none", stroke_width=0,
|
|
114
|
+
opacity=opacity,
|
|
115
|
+
)
|
|
116
|
+
cy += stripe
|
|
117
|
+
i += 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _paint_node_pattern(buf: List[str], pattern: Any, x: float, y: float, w: float, h: float) -> None:
|
|
121
|
+
"""Paint a DotGrid / Strip (or duck-typed) pattern into a rectangle."""
|
|
122
|
+
if pattern is None:
|
|
123
|
+
return
|
|
124
|
+
if hasattr(pattern, "paint"):
|
|
125
|
+
pattern.paint(buf, x, y, w, h)
|
|
126
|
+
return
|
|
127
|
+
name = type(pattern).__name__
|
|
128
|
+
if name == "DotGrid":
|
|
129
|
+
paint_dot_grid(
|
|
130
|
+
buf, x, y, w, h,
|
|
131
|
+
pitch=getattr(pattern, "pitch", 8.0),
|
|
132
|
+
r=getattr(pattern, "r", 1.4),
|
|
133
|
+
color=getattr(pattern, "color", "#888888"),
|
|
134
|
+
opacity=getattr(pattern, "opacity", 0.55),
|
|
135
|
+
inset=getattr(pattern, "inset", 2.0),
|
|
136
|
+
)
|
|
137
|
+
elif name == "Strip":
|
|
138
|
+
paint_strip(
|
|
139
|
+
buf, x, y, w, h,
|
|
140
|
+
direction=getattr(pattern, "direction", "horizontal"),
|
|
141
|
+
stripe=getattr(pattern, "stripe", 6.0),
|
|
142
|
+
colors=getattr(pattern, "colors", ("#e8e8e8", "#ffffff")),
|
|
143
|
+
opacity=getattr(pattern, "opacity", 1.0),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Spacer(Node):
|
|
148
|
+
"""Empty space of ``w`` × ``h`` pt inside a Row/Col: ``Row([a, Spacer(w=12), b])``."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, w: float = 0.0, h: float = 0.0, **kwargs: Any) -> None:
|
|
151
|
+
super().__init__(min_w=w, min_h=h, **kwargs)
|
|
152
|
+
self._w, self._h = w, h
|
|
153
|
+
|
|
154
|
+
def _measure(self) -> Tuple[float, float]:
|
|
155
|
+
return self._w, self._h
|
|
156
|
+
|
|
157
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_fs(fs: Any, theme: Theme, default: Optional[float] = None) -> float:
|
|
162
|
+
"""Requested size (number | callable(theme) | None → default/theme.font_size),
|
|
163
|
+
snapped to the theme's size roles."""
|
|
164
|
+
if callable(fs):
|
|
165
|
+
fs = fs(theme)
|
|
166
|
+
if fs is None:
|
|
167
|
+
fs = default if default is not None else theme.font_size
|
|
168
|
+
return theme.snap(float(fs))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Text(Node):
|
|
172
|
+
"""Plain text label (rich-text / math markup allowed); sizes snap to theme roles.
|
|
173
|
+
|
|
174
|
+
``align="left" | "center" (default) | "right"`` aligns the lines of a multi-line
|
|
175
|
+
label against each other (and the text inside a wider slot); ``rotate=`` degrees
|
|
176
|
+
clockwise (``-90`` reads bottom→top); ``font_size`` is pt at final size.
|
|
177
|
+
Scripts: ``x_{i}`` / ``x^{2}`` / ``x^+`` (an unbraced single sign), stacked
|
|
178
|
+
``x_{i}^{+}``; a literal ``*`` inside a script is ``\\*`` (``P^{\\*}``).
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
color = theme_default("text_color")
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
text: str,
|
|
186
|
+
*,
|
|
187
|
+
font_size: Optional[float] = None,
|
|
188
|
+
mono: bool = False,
|
|
189
|
+
weight: str = "normal",
|
|
190
|
+
color: Optional[str] = None,
|
|
191
|
+
align: str = "center", # left|center|right — text alignment within box
|
|
192
|
+
rotate: float = 0.0, # degrees, clockwise (SVG); -90 reads bottom→top
|
|
193
|
+
italic: bool = False,
|
|
194
|
+
**kwargs: Any,
|
|
195
|
+
) -> None:
|
|
196
|
+
super().__init__(**kwargs)
|
|
197
|
+
self.text = text
|
|
198
|
+
self.font_size = font_size
|
|
199
|
+
self.mono = mono
|
|
200
|
+
self.weight = weight
|
|
201
|
+
self.color = color
|
|
202
|
+
self.text_align = align
|
|
203
|
+
self.rotate = float(rotate or 0.0)
|
|
204
|
+
self.italic = bool(italic)
|
|
205
|
+
|
|
206
|
+
def _fs(self) -> float:
|
|
207
|
+
return _resolve_fs(self.font_size, self.theme)
|
|
208
|
+
|
|
209
|
+
def _plain_size(self) -> Tuple[float, float]:
|
|
210
|
+
return measure_text(self.text, self._fs(), mono=self.mono, weight=self.weight,
|
|
211
|
+
italic=self.italic)
|
|
212
|
+
|
|
213
|
+
def _measure(self) -> Tuple[float, float]:
|
|
214
|
+
w, h = self._plain_size()
|
|
215
|
+
if not self.rotate:
|
|
216
|
+
return w, h
|
|
217
|
+
# Layout reserves the axis-aligned bbox of the rotated text block.
|
|
218
|
+
a = math.radians(self.rotate)
|
|
219
|
+
c, s_ = abs(math.cos(a)), abs(math.sin(a))
|
|
220
|
+
return w * c + h * s_, w * s_ + h * c
|
|
221
|
+
|
|
222
|
+
def ink_box(self) -> Tuple[float, float, float, float]:
|
|
223
|
+
"""Tight page-space (x, y, w, h) around the drawn glyphs (used for knockout)."""
|
|
224
|
+
g = self.geom
|
|
225
|
+
fs = self._fs()
|
|
226
|
+
lines = markup_lines(self.text) if self.text else [""]
|
|
227
|
+
lh = fs * 1.25
|
|
228
|
+
total_h = len(lines) * lh
|
|
229
|
+
pw = max((measure_text(ln, fs, mono=self.mono, weight=self.weight, italic=self.italic)[0]
|
|
230
|
+
for ln in lines), default=0.0)
|
|
231
|
+
if self.rotate:
|
|
232
|
+
ph = self._plain_size()[1]
|
|
233
|
+
cx, cy = g.x + g.w / 2, g.y + g.h / 2
|
|
234
|
+
bx, by, bw, bh = cx - self._plain_size()[0] / 2, cy - ph / 2, self._plain_size()[0], ph
|
|
235
|
+
else:
|
|
236
|
+
bx, by, bw, bh = g.x, g.y, g.w, g.h
|
|
237
|
+
if self.text_align == "left":
|
|
238
|
+
x0 = bx
|
|
239
|
+
elif self.text_align == "right":
|
|
240
|
+
x0 = bx + bw - pw
|
|
241
|
+
else:
|
|
242
|
+
x0 = bx + (bw - pw) / 2
|
|
243
|
+
top = by + (bh - total_h) / 2 + 0.18 * fs
|
|
244
|
+
bot = by + (bh - total_h) / 2 + (len(lines) - 1) * lh + 1.22 * fs
|
|
245
|
+
if not self.rotate:
|
|
246
|
+
return x0, top, pw, bot - top
|
|
247
|
+
a = math.radians(self.rotate)
|
|
248
|
+
ca, sa = math.cos(a), math.sin(a)
|
|
249
|
+
cx, cy = g.x + g.w / 2, g.y + g.h / 2
|
|
250
|
+
xs, ys = [], []
|
|
251
|
+
for px, py in ((x0, top), (x0 + pw, top), (x0, bot), (x0 + pw, bot)):
|
|
252
|
+
dx, dy = px - cx, py - cy
|
|
253
|
+
xs.append(cx + dx * ca - dy * sa)
|
|
254
|
+
ys.append(cy + dx * sa + dy * ca)
|
|
255
|
+
return min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys)
|
|
256
|
+
|
|
257
|
+
def baseline_offset(self) -> float:
|
|
258
|
+
"""Top of measured box → first-line baseline (vertically centered text)."""
|
|
259
|
+
fs = self._fs()
|
|
260
|
+
_w, h = self.measure()
|
|
261
|
+
lines = markup_lines(self.text) if self.text else [""]
|
|
262
|
+
lh = fs * 1.25
|
|
263
|
+
total_h = max(len(lines), 1) * lh
|
|
264
|
+
return (h - total_h) / 2 + fs
|
|
265
|
+
|
|
266
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
267
|
+
from .render_svg import draw_text
|
|
268
|
+
|
|
269
|
+
g = self.geom
|
|
270
|
+
fs = self._fs()
|
|
271
|
+
lines = markup_lines(self.text)
|
|
272
|
+
lh = fs * 1.25
|
|
273
|
+
total_h = len(lines) * lh
|
|
274
|
+
if self.rotate:
|
|
275
|
+
# Draw the unrotated block centred on the node, then rotate about the centre.
|
|
276
|
+
pw, ph = self._plain_size()
|
|
277
|
+
cx, cy = g.x + ox + g.w / 2, g.y + oy + g.h / 2
|
|
278
|
+
bx, by, bw, bh = cx - pw / 2, cy - ph / 2, pw, ph
|
|
279
|
+
buf.append(f'<g transform="rotate({self.rotate:.2f} {cx:.2f} {cy:.2f})">')
|
|
280
|
+
else:
|
|
281
|
+
bx, by, bw, bh = g.x + ox, g.y + oy, g.w, g.h
|
|
282
|
+
y0 = by + (bh - total_h) / 2 + fs
|
|
283
|
+
start = len(buf)
|
|
284
|
+
for i, line in enumerate(lines):
|
|
285
|
+
tw, _ = measure_text(line, fs, mono=self.mono, weight=self.weight, italic=self.italic)
|
|
286
|
+
if self.text_align == "left":
|
|
287
|
+
tx = bx
|
|
288
|
+
elif self.text_align == "right":
|
|
289
|
+
tx = bx + bw - tw
|
|
290
|
+
else:
|
|
291
|
+
tx = bx + (bw - tw) / 2
|
|
292
|
+
draw_text(
|
|
293
|
+
buf,
|
|
294
|
+
line,
|
|
295
|
+
tx,
|
|
296
|
+
y0 + i * lh,
|
|
297
|
+
font_size=fs,
|
|
298
|
+
mono=self.mono,
|
|
299
|
+
weight=self.weight,
|
|
300
|
+
fill=self.color,
|
|
301
|
+
family=self.theme.mono_family if self.mono else None,
|
|
302
|
+
italic=self.italic,
|
|
303
|
+
)
|
|
304
|
+
if self.rotate:
|
|
305
|
+
buf.append("</g>")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Box(Node):
|
|
309
|
+
"""Labeled rectangle — the workhorse primitive.
|
|
310
|
+
|
|
311
|
+
v0.4.0: ``icon=`` ("gpu" or an :class:`~codefig.icon.Icon`) sits beside / above the
|
|
312
|
+
label (``icon_pos`` left | right | top | bottom); ``badge=`` puts a small icon in a
|
|
313
|
+
corner (``badge_pos`` top-left | top-right | bottom-left | bottom-right), e.g. a
|
|
314
|
+
snowflake for a frozen module. ``fill=`` also takes :class:`~codefig.fills.Gradient`
|
|
315
|
+
/ :class:`~codefig.fills.Split`. Subclasses in :mod:`codefig.shapes` reuse all of it.
|
|
316
|
+
|
|
317
|
+
v0.5.0: ``step=`` (int, str or :class:`~codefig.steps.Step`) puts a numbered step
|
|
318
|
+
badge (①) straddling the ``step_pos`` corner (top-left | top-right | bottom-left |
|
|
319
|
+
bottom-right). The badge is a **hard** child node, so check_overlaps() reports it
|
|
320
|
+
if it pokes into a neighbour. Example: ``Box("Retrieve", step=1)``.
|
|
321
|
+
|
|
322
|
+
v0.7.0: ``pattern=`` (``DotGrid()`` / ``Strip()``) is knocked out behind the label by
|
|
323
|
+
default (``pattern_clear=True``) so dots or stripes never run through the text; pass
|
|
324
|
+
``pattern_clear=False`` for the old full-bleed texture.
|
|
325
|
+
|
|
326
|
+
v0.12.0: ``label_rotate=90`` (reads bottom-to-top) or ``-90`` (top-to-bottom) turns
|
|
327
|
+
the label (and subtitle); the box is measured with the turned label, so a tall thin
|
|
328
|
+
box fits it. ``border=`` ``"solid" | "dashed" | "dotted" | "none"`` (or a dash
|
|
329
|
+
string like ``"4 2"``) sets the outline style; ``pad=`` takes a number, ``(v, h)``
|
|
330
|
+
or ``(top, right, bottom, left)`` as in CSS.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
text: str = "",
|
|
336
|
+
*,
|
|
337
|
+
fill: Optional[Any] = None,
|
|
338
|
+
stroke: Optional[str] = None,
|
|
339
|
+
stroke_width: Optional[float] = None,
|
|
340
|
+
rounded: Optional[bool] = None,
|
|
341
|
+
radius: Optional[float] = None,
|
|
342
|
+
pad: Optional[Any] = None,
|
|
343
|
+
padding: Optional[Any] = None,
|
|
344
|
+
text_margin: Optional[float] = None,
|
|
345
|
+
font_size: Optional[float] = None,
|
|
346
|
+
mono: bool = False,
|
|
347
|
+
weight: str = "normal",
|
|
348
|
+
text_color: Optional[str] = None,
|
|
349
|
+
min_w: float = 0.0,
|
|
350
|
+
min_h: float = 0.0,
|
|
351
|
+
width: Optional[float] = None,
|
|
352
|
+
height: Optional[float] = None,
|
|
353
|
+
w: Optional[Any] = None,
|
|
354
|
+
h: Optional[Any] = None,
|
|
355
|
+
aspect: Optional[float] = None,
|
|
356
|
+
max_w: Optional[float] = None,
|
|
357
|
+
max_h: Optional[float] = None,
|
|
358
|
+
pattern: Optional[Any] = None,
|
|
359
|
+
pattern_clear: bool = True,
|
|
360
|
+
subtitle: Optional[str] = None,
|
|
361
|
+
subtitle_size: Optional[float] = None,
|
|
362
|
+
subtitle_color: Optional[str] = None,
|
|
363
|
+
icon: Any = None,
|
|
364
|
+
icon_pos: str = "left",
|
|
365
|
+
icon_size: Optional[float] = None,
|
|
366
|
+
icon_color: Optional[str] = None,
|
|
367
|
+
icon_gap: Optional[float] = None,
|
|
368
|
+
badge: Any = None,
|
|
369
|
+
badge_pos: str = "top-left",
|
|
370
|
+
badge_size: Optional[float] = None,
|
|
371
|
+
badge_color: Optional[str] = None,
|
|
372
|
+
step: Any = None,
|
|
373
|
+
step_pos: str = "top-left",
|
|
374
|
+
label_rotate: float = 0,
|
|
375
|
+
**kwargs: Any,
|
|
376
|
+
) -> None:
|
|
377
|
+
from ._validate import check_enum
|
|
378
|
+
label_rotate = check_enum("primitives(label_rotate)", label_rotate, (0, 90, -90, 270))
|
|
379
|
+
self.label_rotate = -90 if label_rotate == 270 else int(label_rotate)
|
|
380
|
+
# subtitle: smaller secondary line(s) under the main text (title + detail)
|
|
381
|
+
self.subtitle = subtitle
|
|
382
|
+
self.subtitle_size = subtitle_size
|
|
383
|
+
self.subtitle_color = subtitle_color
|
|
384
|
+
# width/height are aliases for numeric w/h (kept for BC)
|
|
385
|
+
sw = w if w is not None else width
|
|
386
|
+
sh = h if h is not None else height
|
|
387
|
+
super().__init__(
|
|
388
|
+
min_w=min_w, min_h=min_h, max_w=max_w, max_h=max_h,
|
|
389
|
+
w=sw, h=sh, aspect=aspect, **kwargs,
|
|
390
|
+
)
|
|
391
|
+
self.text = text
|
|
392
|
+
self.fill = fill
|
|
393
|
+
self.stroke = stroke
|
|
394
|
+
self.stroke_width = stroke_width
|
|
395
|
+
self.rounded = rounded
|
|
396
|
+
self.radius = radius
|
|
397
|
+
raw = padding if padding is not None else pad
|
|
398
|
+
self._pad_raw = None if raw is None else normalize_pad(raw)
|
|
399
|
+
self.text_margin = None if text_margin is None else float(text_margin)
|
|
400
|
+
self.font_size = font_size
|
|
401
|
+
self.mono = mono
|
|
402
|
+
self.weight = weight
|
|
403
|
+
self.text_color = text_color
|
|
404
|
+
# BC aliases — preferred path is Node.size_w / size_h
|
|
405
|
+
self.fixed_w = width if width is not None else (
|
|
406
|
+
float(w) if isinstance(w, (int, float)) else None
|
|
407
|
+
)
|
|
408
|
+
self.fixed_h = height if height is not None else (
|
|
409
|
+
float(h) if isinstance(h, (int, float)) else None
|
|
410
|
+
)
|
|
411
|
+
self.pattern = pattern
|
|
412
|
+
self.pattern_clear = bool(pattern_clear)
|
|
413
|
+
from ._validate import check_enum
|
|
414
|
+
icon_pos = check_enum("primitives(icon_pos)", icon_pos, ("left", "right", "top", "bottom"))
|
|
415
|
+
from ._validate import check_enum
|
|
416
|
+
badge_pos = check_enum("primitives(badge_pos)", badge_pos, _BADGE_POS)
|
|
417
|
+
from .icon import as_icon
|
|
418
|
+
|
|
419
|
+
self.icon = as_icon(icon, size=icon_size, color=icon_color, theme=self._theme)
|
|
420
|
+
self.icon_pos = icon_pos
|
|
421
|
+
self.icon_size = icon_size
|
|
422
|
+
self.icon_color = icon_color
|
|
423
|
+
self.icon_gap = icon_gap
|
|
424
|
+
self.badge = as_icon(badge, size=badge_size, color=badge_color, theme=self._theme)
|
|
425
|
+
self.badge_pos = badge_pos
|
|
426
|
+
self.badge_size = badge_size
|
|
427
|
+
self.badge_color = badge_color
|
|
428
|
+
# v0.5.0: numbered step badge (①) straddling a corner — a hard child node
|
|
429
|
+
from ._validate import check_enum
|
|
430
|
+
step_pos = check_enum("primitives(step_pos)", step_pos, _BADGE_POS)
|
|
431
|
+
from .steps import as_step
|
|
432
|
+
|
|
433
|
+
self.step = as_step(step, theme=self._theme)
|
|
434
|
+
self.step_pos = step_pos
|
|
435
|
+
if self.step is not None:
|
|
436
|
+
self.step.parent = self
|
|
437
|
+
|
|
438
|
+
# lazy theme defaults (resolved against the active theme at layout time)
|
|
439
|
+
@property
|
|
440
|
+
def fill(self) -> Any:
|
|
441
|
+
"""Explicit ``fill=``; else the category colour; else ``theme.fill_default``."""
|
|
442
|
+
v = self.__dict__.get("_td_fill")
|
|
443
|
+
if v is not None:
|
|
444
|
+
return v
|
|
445
|
+
cat = self.__dict__.get("category")
|
|
446
|
+
if cat is not None:
|
|
447
|
+
from .styles import category_style
|
|
448
|
+
|
|
449
|
+
return category_style(str(cat), self.theme)[0]
|
|
450
|
+
return self.theme.fill_default
|
|
451
|
+
|
|
452
|
+
@fill.setter
|
|
453
|
+
def fill(self, v: Any) -> None:
|
|
454
|
+
self.__dict__["_td_fill"] = v
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def pattern(self) -> Any:
|
|
458
|
+
"""Explicit ``pattern=``; else the category's automatic hatch (≥ 5 categories)."""
|
|
459
|
+
v = self.__dict__.get("_pattern")
|
|
460
|
+
if v is not None:
|
|
461
|
+
return v
|
|
462
|
+
cat = self.__dict__.get("category")
|
|
463
|
+
if cat is not None:
|
|
464
|
+
from .legend import hatch_pattern
|
|
465
|
+
from .styles import category_style
|
|
466
|
+
|
|
467
|
+
spec = category_style(str(cat), self.theme)[1]
|
|
468
|
+
return hatch_pattern(spec) if spec else None
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
@pattern.setter
|
|
472
|
+
def pattern(self, v: Any) -> None:
|
|
473
|
+
self.__dict__["_pattern"] = v
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def rounded(self) -> bool:
|
|
477
|
+
"""Resolve the default corner shape against the active theme."""
|
|
478
|
+
value = self.__dict__.get("_rounded")
|
|
479
|
+
return self.theme.box_rounded if value is None else value
|
|
480
|
+
|
|
481
|
+
@rounded.setter
|
|
482
|
+
def rounded(self, value: Optional[bool]) -> None:
|
|
483
|
+
self.__dict__["_rounded"] = value
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def stroke(self) -> str:
|
|
487
|
+
"""Explicit outline or the category's themed shade."""
|
|
488
|
+
value = self.__dict__.get("_box_stroke")
|
|
489
|
+
if value is not None:
|
|
490
|
+
return value
|
|
491
|
+
if self.category is not None and self.theme.category_stroke is not None:
|
|
492
|
+
from .styles import shade
|
|
493
|
+
return shade(self.fill, self.theme.category_stroke)
|
|
494
|
+
return self.theme.stroke
|
|
495
|
+
|
|
496
|
+
@stroke.setter
|
|
497
|
+
def stroke(self, value: Optional[str]) -> None:
|
|
498
|
+
self.__dict__["_box_stroke"] = value
|
|
499
|
+
stroke_width = theme_default("stroke_width")
|
|
500
|
+
text_margin = theme_default("text_margin")
|
|
501
|
+
text_color = theme_default("text_color")
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def padding(self) -> Tuple[float, float, float, float]:
|
|
505
|
+
"""(top, right, bottom, left) inner padding (theme default unless given)."""
|
|
506
|
+
raw = self.__dict__.get("_pad_raw")
|
|
507
|
+
if raw is not None:
|
|
508
|
+
return raw
|
|
509
|
+
th = self.theme
|
|
510
|
+
if th.box_pad is not None:
|
|
511
|
+
v, h = th.box_pad
|
|
512
|
+
return (float(v), float(h), float(v), float(h))
|
|
513
|
+
return normalize_pad(th.pad)
|
|
514
|
+
|
|
515
|
+
@padding.setter
|
|
516
|
+
def padding(self, value: Any) -> None:
|
|
517
|
+
self.__dict__["_pad_raw"] = None if value is None else normalize_pad(value)
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def pad(self) -> float:
|
|
521
|
+
return sum(self.padding) / 4.0
|
|
522
|
+
|
|
523
|
+
@pad.setter
|
|
524
|
+
def pad(self, value: Any) -> None:
|
|
525
|
+
self.padding = value
|
|
526
|
+
|
|
527
|
+
def _fs(self) -> float:
|
|
528
|
+
return _resolve_fs(self.font_size, self.theme)
|
|
529
|
+
|
|
530
|
+
def baseline_offset(self) -> float:
|
|
531
|
+
"""Top → first-line baseline for labeled boxes (text vertically centered)."""
|
|
532
|
+
if not self.text:
|
|
533
|
+
return self.measure()[1] * 0.5
|
|
534
|
+
fs = self._fs()
|
|
535
|
+
_w, h = self.measure()
|
|
536
|
+
lines = markup_lines(self.text)
|
|
537
|
+
lh = fs * 1.25
|
|
538
|
+
total_h = len(lines) * lh
|
|
539
|
+
return (h - total_h) / 2 + fs
|
|
540
|
+
|
|
541
|
+
def optical_nudge(self) -> float:
|
|
542
|
+
"""Rounded/pill boxes look heavier — nudge up ~4% of height for optical align."""
|
|
543
|
+
if self.rounded:
|
|
544
|
+
return self.measure()[1] * 0.04
|
|
545
|
+
return 0.0
|
|
546
|
+
|
|
547
|
+
def _inset(self) -> Tuple[float, float, float, float]:
|
|
548
|
+
pt, pr, pb, pl = self.padding
|
|
549
|
+
m = self.text_margin
|
|
550
|
+
return pt + m, pr + m, pb + m, pl + m
|
|
551
|
+
|
|
552
|
+
def _sub_fs(self) -> float:
|
|
553
|
+
return _resolve_fs(self.subtitle_size, self.theme, self._fs() * 0.82)
|
|
554
|
+
|
|
555
|
+
# ---- content block: label (+ subtitle) and optional icon ----------------------
|
|
556
|
+
def _text_size(self) -> Tuple[float, float]:
|
|
557
|
+
tw, th = self._text_size_upright()
|
|
558
|
+
if getattr(self, "label_rotate", 0):
|
|
559
|
+
return th, tw
|
|
560
|
+
return tw, th
|
|
561
|
+
|
|
562
|
+
def _text_size_upright(self) -> Tuple[float, float]:
|
|
563
|
+
tw, th = measure_text(self.text, self._fs(), mono=self.mono, weight=self.weight) if self.text else (0.0, self._fs())
|
|
564
|
+
if self.subtitle:
|
|
565
|
+
sw_, sh_ = measure_text(self.subtitle, self._sub_fs(), mono=self.mono)
|
|
566
|
+
tw, th = max(tw, sw_), th + sh_ + self._fs() * 0.15
|
|
567
|
+
return tw, th
|
|
568
|
+
|
|
569
|
+
def _icon_px(self) -> float:
|
|
570
|
+
if self.icon is None:
|
|
571
|
+
return 0.0
|
|
572
|
+
if self.icon.size is not None:
|
|
573
|
+
return self.icon.size
|
|
574
|
+
has_label = bool(self.text or self.subtitle)
|
|
575
|
+
return round(self._fs() * (1.45 if has_label and self.icon_pos in ("left", "right") else 1.9), 1)
|
|
576
|
+
|
|
577
|
+
def _icon_gap(self) -> float:
|
|
578
|
+
return self.icon_gap if self.icon_gap is not None else self._fs() * 0.4
|
|
579
|
+
|
|
580
|
+
def _content_size(self) -> Tuple[float, float]:
|
|
581
|
+
tw, th = self._text_size()
|
|
582
|
+
if self.icon is None:
|
|
583
|
+
return tw, th
|
|
584
|
+
s = self._icon_px()
|
|
585
|
+
if not (self.text or self.subtitle):
|
|
586
|
+
return s, s
|
|
587
|
+
g = self._icon_gap()
|
|
588
|
+
if self.icon_pos in ("left", "right"):
|
|
589
|
+
return s + g + tw, max(s, th)
|
|
590
|
+
return max(s, tw), s + g + th
|
|
591
|
+
|
|
592
|
+
def _badge_px(self) -> float:
|
|
593
|
+
if self.badge is None:
|
|
594
|
+
return 0.0
|
|
595
|
+
return self.badge.size if self.badge.size is not None else round(self._fs() * 1.15, 1)
|
|
596
|
+
|
|
597
|
+
def _frame_size(self, iw: float, ih: float) -> Tuple[float, float]:
|
|
598
|
+
"""Outer size for an inner (label) rectangle — identity for a rectangle;
|
|
599
|
+
shapes (:mod:`codefig.shapes`) grow it so the label fits inside the outline."""
|
|
600
|
+
return iw, ih
|
|
601
|
+
|
|
602
|
+
def _text_rect(self, x: float, y: float, w: float, h: float) -> Tuple[float, float, float, float]:
|
|
603
|
+
"""Inner rectangle the label is centred in (whole box for a rectangle)."""
|
|
604
|
+
return x, y, w, h
|
|
605
|
+
|
|
606
|
+
def _measure(self) -> Tuple[float, float]:
|
|
607
|
+
tw, th = self._content_size()
|
|
608
|
+
it, ir, ib, il = self._inset()
|
|
609
|
+
b = self._badge_px()
|
|
610
|
+
reserve = (b + self.theme.L(3.0)) if b else 0.0 # symmetric, keeps the label centred
|
|
611
|
+
w, h = self._frame_size(tw + il + ir + 2 * reserve, th + it + ib)
|
|
612
|
+
if (self.text or self.subtitle) and self.theme.box_min_h:
|
|
613
|
+
h = max(h, self.theme.box_min_h)
|
|
614
|
+
if self.fixed_w is not None:
|
|
615
|
+
w = self.fixed_w
|
|
616
|
+
if self.fixed_h is not None:
|
|
617
|
+
h = self.fixed_h
|
|
618
|
+
return w, h
|
|
619
|
+
|
|
620
|
+
def _draw_frame(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
621
|
+
from .render_svg import draw_rect
|
|
622
|
+
|
|
623
|
+
r = 0.0
|
|
624
|
+
if self.rounded:
|
|
625
|
+
r = self.radius if self.radius is not None else self.theme.corner_radius
|
|
626
|
+
draw_rect(
|
|
627
|
+
buf,
|
|
628
|
+
x,
|
|
629
|
+
y,
|
|
630
|
+
w,
|
|
631
|
+
h,
|
|
632
|
+
fill=self.fill,
|
|
633
|
+
stroke=self._border_stroke(self.stroke),
|
|
634
|
+
stroke_width=self.stroke_width,
|
|
635
|
+
radius=r,
|
|
636
|
+
dasharray=self._dash(self.stroke_width),
|
|
637
|
+
)
|
|
638
|
+
if self.pattern is not None:
|
|
639
|
+
self._paint_pattern(buf, x, y, w, h)
|
|
640
|
+
|
|
641
|
+
def _pattern_hole(self, x: float, y: float, w: float, h: float):
|
|
642
|
+
"""Label rectangle (+ a small pad) the pattern leaves clear, or None."""
|
|
643
|
+
if not getattr(self, "pattern_clear", True) or not (self.text or self.subtitle):
|
|
644
|
+
return None
|
|
645
|
+
tx, ty, tw, th = self._text_rect(x, y, w, h)
|
|
646
|
+
cw, ch = self._content_size()
|
|
647
|
+
pad = self.theme.L(2.0)
|
|
648
|
+
cx, cy = tx + tw / 2, ty + th / 2
|
|
649
|
+
return (cx - cw / 2 - pad, cy - ch / 2 - pad * 0.6, cw + 2 * pad, ch + 1.2 * pad)
|
|
650
|
+
|
|
651
|
+
def _paint_pattern(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
652
|
+
"""Paint ``pattern=`` clipped away from the label (v0.7.0, ``pattern_clear``)."""
|
|
653
|
+
hole = self._pattern_hole(x, y, w, h)
|
|
654
|
+
if hole is None:
|
|
655
|
+
_paint_node_pattern(buf, self.pattern, x, y, w, h)
|
|
656
|
+
return
|
|
657
|
+
from .edges import knockout_clip
|
|
658
|
+
|
|
659
|
+
cid = knockout_clip(buf, [hole])
|
|
660
|
+
buf.append(f'<g clip-path="url(#{cid})">')
|
|
661
|
+
_paint_node_pattern(buf, self.pattern, x, y, w, h)
|
|
662
|
+
buf.append("</g>")
|
|
663
|
+
|
|
664
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
665
|
+
g = self.geom
|
|
666
|
+
x, y = g.x + ox, g.y + oy
|
|
667
|
+
self._draw_frame(buf, x, y, g.w, g.h)
|
|
668
|
+
self._draw_content(buf, *self._text_rect(x, y, g.w, g.h))
|
|
669
|
+
self._draw_step(buf, ox, oy)
|
|
670
|
+
|
|
671
|
+
# ---- v0.5.0 corner step badge -------------------------------------------------
|
|
672
|
+
def children(self) -> Sequence[Node]:
|
|
673
|
+
step = getattr(self, "step", None)
|
|
674
|
+
return (step,) if step is not None else ()
|
|
675
|
+
|
|
676
|
+
def _layout_children(self) -> None:
|
|
677
|
+
step = getattr(self, "step", None)
|
|
678
|
+
if step is None:
|
|
679
|
+
return
|
|
680
|
+
g = self.geom
|
|
681
|
+
d = step.diameter()
|
|
682
|
+
inset = d * 0.15 # ~35 % of the badge sits outside the corner
|
|
683
|
+
cx = g.x + inset if self.step_pos.endswith("left") else g.right - inset
|
|
684
|
+
cy = g.y + inset if self.step_pos.startswith("top") else g.bottom - inset
|
|
685
|
+
step.layout(cx - d / 2, cy - d / 2, d, d)
|
|
686
|
+
|
|
687
|
+
def _draw_step(self, buf: List[str], ox: float, oy: float) -> None:
|
|
688
|
+
step = getattr(self, "step", None)
|
|
689
|
+
if step is not None:
|
|
690
|
+
step.draw(buf, ox, oy)
|
|
691
|
+
|
|
692
|
+
def _draw_content(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
693
|
+
if self.badge is not None:
|
|
694
|
+
b = self._badge_px()
|
|
695
|
+
m = self.theme.L(3.0) + self.stroke_width / 2
|
|
696
|
+
bx = x + m if self.badge_pos.endswith("left") else x + w - m - b
|
|
697
|
+
by = y + m if self.badge_pos.startswith("top") else y + h - m - b
|
|
698
|
+
self.badge.paint(buf, bx, by, b, b)
|
|
699
|
+
if self.icon is not None:
|
|
700
|
+
s = self._icon_px()
|
|
701
|
+
cw, ch = self._content_size()
|
|
702
|
+
bx, by = x + (w - cw) / 2, y + (h - ch) / 2
|
|
703
|
+
has_label = bool(self.text or self.subtitle)
|
|
704
|
+
g = self._icon_gap() if has_label else 0.0
|
|
705
|
+
tw, th = self._text_size() if has_label else (0.0, 0.0)
|
|
706
|
+
col = self.icon.color or self.icon_color or self.text_color
|
|
707
|
+
if self.icon_pos == "left":
|
|
708
|
+
self.icon.paint(buf, bx, by + (ch - s) / 2, s, s, color=col)
|
|
709
|
+
x, w = bx + s + g, tw
|
|
710
|
+
elif self.icon_pos == "right":
|
|
711
|
+
self.icon.paint(buf, bx + tw + g, by + (ch - s) / 2, s, s, color=col)
|
|
712
|
+
x, w = bx, tw
|
|
713
|
+
elif self.icon_pos == "top":
|
|
714
|
+
self.icon.paint(buf, bx + (cw - s) / 2, by, s, s, color=col)
|
|
715
|
+
y, h = by + s + g, th
|
|
716
|
+
else:
|
|
717
|
+
self.icon.paint(buf, bx + (cw - s) / 2, by + th + g, s, s, color=col)
|
|
718
|
+
y, h = by, th
|
|
719
|
+
self._draw_label(buf, x, y, w, h)
|
|
720
|
+
|
|
721
|
+
def _draw_label(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
722
|
+
from .render_svg import draw_text
|
|
723
|
+
|
|
724
|
+
if not self.text:
|
|
725
|
+
return
|
|
726
|
+
rot = getattr(self, "label_rotate", 0)
|
|
727
|
+
if rot:
|
|
728
|
+
cx, cy = x + w / 2, y + h / 2
|
|
729
|
+
buf.append(f'<g transform="rotate({-rot} {cx:.2f} {cy:.2f})">')
|
|
730
|
+
x, y, w, h = cx - h / 2, cy - w / 2, h, w
|
|
731
|
+
try:
|
|
732
|
+
self._draw_label_upright(buf, x, y, w, h)
|
|
733
|
+
finally:
|
|
734
|
+
if rot:
|
|
735
|
+
buf.append("</g>")
|
|
736
|
+
|
|
737
|
+
def _draw_label_upright(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
738
|
+
from .render_svg import draw_text
|
|
739
|
+
|
|
740
|
+
fs = self._fs()
|
|
741
|
+
lines = markup_lines(self.text)
|
|
742
|
+
lh = fs * 1.25
|
|
743
|
+
total_h = len(lines) * lh
|
|
744
|
+
if self.subtitle:
|
|
745
|
+
sfs = self._sub_fs()
|
|
746
|
+
slines = markup_lines(self.subtitle)
|
|
747
|
+
sub_h = len(slines) * sfs * 1.25
|
|
748
|
+
gap = fs * 0.15
|
|
749
|
+
y0 = y + (h - total_h - gap - sub_h) / 2 + fs
|
|
750
|
+
sy0 = y0 - fs + total_h + gap + sfs
|
|
751
|
+
for i, line in enumerate(slines):
|
|
752
|
+
tw, _ = measure_text(line, sfs, mono=self.mono)
|
|
753
|
+
draw_text(buf, line, x + (w - tw) / 2, sy0 + i * sfs * 1.25, font_size=sfs,
|
|
754
|
+
mono=self.mono, fill=self.subtitle_color or self.text_color,
|
|
755
|
+
family=self.theme.mono_family if self.mono else None)
|
|
756
|
+
else:
|
|
757
|
+
y0 = y + (h - total_h) / 2 + fs
|
|
758
|
+
for i, line in enumerate(lines):
|
|
759
|
+
tw, _ = measure_text(line, fs, mono=self.mono, weight=self.weight)
|
|
760
|
+
tx = x + (w - tw) / 2
|
|
761
|
+
draw_text(
|
|
762
|
+
buf,
|
|
763
|
+
line,
|
|
764
|
+
tx,
|
|
765
|
+
y0 + i * lh,
|
|
766
|
+
font_size=fs,
|
|
767
|
+
mono=self.mono,
|
|
768
|
+
weight=self.weight,
|
|
769
|
+
fill=self.text_color,
|
|
770
|
+
family=self.theme.mono_family if self.mono else None,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
_BADGE_POS = ("top-left", "top-right", "bottom-left", "bottom-right")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class Ellipsis(Text):
|
|
778
|
+
"""An ellipsis glyph ("…", "⋯", "⋮") between repeated items."""
|
|
779
|
+
|
|
780
|
+
def __init__(self, text: str = "…", **kwargs: Any) -> None:
|
|
781
|
+
kwargs.setdefault("font_size", lambda th: th.F(18))
|
|
782
|
+
super().__init__(text, **kwargs)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
class DotGrid(Node):
|
|
787
|
+
"""
|
|
788
|
+
Dot lattice fill — standalone node or `pattern=` on Box/Group.
|
|
789
|
+
|
|
790
|
+
Useful for cache / buffer / memory regions in research figures.
|
|
791
|
+
"""
|
|
792
|
+
|
|
793
|
+
def __init__(
|
|
794
|
+
self,
|
|
795
|
+
*,
|
|
796
|
+
pitch: float = 8.0,
|
|
797
|
+
r: float = 1.4,
|
|
798
|
+
color: str = "#5dade2",
|
|
799
|
+
opacity: float = 0.55,
|
|
800
|
+
inset: float = 2.0,
|
|
801
|
+
fill: Optional[str] = None,
|
|
802
|
+
stroke: Optional[str] = None,
|
|
803
|
+
stroke_width: Optional[float] = None,
|
|
804
|
+
min_w: float = 48.0,
|
|
805
|
+
min_h: float = 36.0,
|
|
806
|
+
width: Optional[float] = None,
|
|
807
|
+
height: Optional[float] = None,
|
|
808
|
+
**kwargs: Any,
|
|
809
|
+
) -> None:
|
|
810
|
+
super().__init__(min_w=min_w, min_h=min_h, **kwargs)
|
|
811
|
+
self.pitch = float(pitch)
|
|
812
|
+
self.r = float(r)
|
|
813
|
+
self.color = color
|
|
814
|
+
self.opacity = float(opacity)
|
|
815
|
+
self.inset = float(inset)
|
|
816
|
+
self.fill = fill
|
|
817
|
+
self.stroke = stroke
|
|
818
|
+
self.stroke_width = stroke_width
|
|
819
|
+
self.fixed_w = width
|
|
820
|
+
self.fixed_h = height
|
|
821
|
+
|
|
822
|
+
def paint(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
823
|
+
paint_dot_grid(
|
|
824
|
+
buf, x, y, w, h,
|
|
825
|
+
pitch=self.pitch, r=self.r, color=self.color,
|
|
826
|
+
opacity=self.opacity, inset=self.inset,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def _measure(self) -> Tuple[float, float]:
|
|
830
|
+
w = self.fixed_w if self.fixed_w is not None else self.min_w
|
|
831
|
+
h = self.fixed_h if self.fixed_h is not None else self.min_h
|
|
832
|
+
return w, h
|
|
833
|
+
|
|
834
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
835
|
+
from .render_svg import draw_rect
|
|
836
|
+
|
|
837
|
+
g = self.geom
|
|
838
|
+
x, y = g.x + ox, g.y + oy
|
|
839
|
+
if self.fill or self.stroke:
|
|
840
|
+
sw = self.stroke_width if self.stroke_width is not None else (
|
|
841
|
+
self.theme.stroke_width if self.stroke else 0.0
|
|
842
|
+
)
|
|
843
|
+
draw_rect(
|
|
844
|
+
buf, x, y, g.w, g.h,
|
|
845
|
+
fill=self.fill or "none",
|
|
846
|
+
stroke=self.stroke or "none",
|
|
847
|
+
stroke_width=sw if self.stroke else 0,
|
|
848
|
+
)
|
|
849
|
+
self.paint(buf, x, y, g.w, g.h)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
class Strip(Node):
|
|
853
|
+
"""
|
|
854
|
+
Alternating horizontal / vertical stripe background.
|
|
855
|
+
|
|
856
|
+
Standalone node or `pattern=` on Box/Group (e.g. buffer bands).
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
def __init__(
|
|
860
|
+
self,
|
|
861
|
+
*,
|
|
862
|
+
direction: str = "horizontal", # horizontal|vertical
|
|
863
|
+
stripe: float = 6.0,
|
|
864
|
+
colors: Sequence[str] = ("#d6eaf8", "#ffffff"),
|
|
865
|
+
opacity: float = 1.0,
|
|
866
|
+
fill: Optional[str] = None,
|
|
867
|
+
stroke: Optional[str] = None,
|
|
868
|
+
stroke_width: Optional[float] = None,
|
|
869
|
+
min_w: float = 48.0,
|
|
870
|
+
min_h: float = 36.0,
|
|
871
|
+
width: Optional[float] = None,
|
|
872
|
+
height: Optional[float] = None,
|
|
873
|
+
**kwargs: Any,
|
|
874
|
+
) -> None:
|
|
875
|
+
super().__init__(min_w=min_w, min_h=min_h, **kwargs)
|
|
876
|
+
self.direction = direction
|
|
877
|
+
self.stripe = float(stripe)
|
|
878
|
+
self.colors = tuple(colors)
|
|
879
|
+
self.opacity = float(opacity)
|
|
880
|
+
self.fill = fill
|
|
881
|
+
self.stroke = stroke
|
|
882
|
+
self.stroke_width = stroke_width
|
|
883
|
+
self.fixed_w = width
|
|
884
|
+
self.fixed_h = height
|
|
885
|
+
|
|
886
|
+
def paint(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
887
|
+
paint_strip(
|
|
888
|
+
buf, x, y, w, h,
|
|
889
|
+
direction=self.direction, stripe=self.stripe,
|
|
890
|
+
colors=self.colors, opacity=self.opacity,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
def _measure(self) -> Tuple[float, float]:
|
|
894
|
+
w = self.fixed_w if self.fixed_w is not None else self.min_w
|
|
895
|
+
h = self.fixed_h if self.fixed_h is not None else self.min_h
|
|
896
|
+
return w, h
|
|
897
|
+
|
|
898
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
899
|
+
from .render_svg import draw_rect
|
|
900
|
+
|
|
901
|
+
g = self.geom
|
|
902
|
+
x, y = g.x + ox, g.y + oy
|
|
903
|
+
# Base fill under stripes if provided
|
|
904
|
+
if self.fill:
|
|
905
|
+
draw_rect(buf, x, y, g.w, g.h, fill=self.fill, stroke="none", stroke_width=0)
|
|
906
|
+
self.paint(buf, x, y, g.w, g.h)
|
|
907
|
+
if self.stroke:
|
|
908
|
+
sw = self.stroke_width if self.stroke_width is not None else self.theme.stroke_width
|
|
909
|
+
draw_rect(
|
|
910
|
+
buf, x, y, g.w, g.h,
|
|
911
|
+
fill="none", stroke=self.stroke, stroke_width=sw,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def draw_styled_line(buf: List[str], st: Any, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
916
|
+
"""Straight shaft with the resolved end / start heads of ``st`` (ResolvedEdgeStyle)."""
|
|
917
|
+
from .render_svg import angle_of, draw_head, draw_line
|
|
918
|
+
|
|
919
|
+
eh, sh = st.end_head, st.start_head
|
|
920
|
+
L = math.hypot(x2 - x1, y2 - y1) or 1.0
|
|
921
|
+
ux, uy = (x2 - x1) / L, (y2 - y1) / L
|
|
922
|
+
se = min(eh.setback(st.width), L * 0.45) if eh is not None and eh.visible else 0.0
|
|
923
|
+
ss = min(sh.setback(st.width), L * 0.45) if sh is not None and sh.visible else 0.0
|
|
924
|
+
draw_line(buf, x1 + ux * ss, y1 + uy * ss, x2 - ux * se, y2 - uy * se, stroke=st.color,
|
|
925
|
+
stroke_width=st.width, opacity=st.opacity, dasharray=st.dasharray(), marker_end=False)
|
|
926
|
+
draw_head(buf, x2, y2, angle_of(x1, y1, x2, y2), eh, color=st.color, opacity=st.opacity,
|
|
927
|
+
stroke_width=st.width)
|
|
928
|
+
draw_head(buf, x1, y1, angle_of(x2, y2, x1, y1), sh, color=st.color, opacity=st.opacity,
|
|
929
|
+
stroke_width=st.width)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class Arrow(Node):
|
|
933
|
+
"""
|
|
934
|
+
Connector arrow.
|
|
935
|
+
|
|
936
|
+
Heads: ``head=`` / ``tail=`` shape names or ``Head(...)`` (see Edge),
|
|
937
|
+
``head_size`` / ``head_length`` / ``head_width`` / ``hollow`` / ``head_fill``.
|
|
938
|
+
|
|
939
|
+
- Default: short vertical (or horizontal) shaft with arrowhead — used inside Stack/Row.
|
|
940
|
+
- loop=True: U-shaped feedback path drawn around a sibling reference box (approx).
|
|
941
|
+
- label: optional mid-shaft text.
|
|
942
|
+
"""
|
|
943
|
+
|
|
944
|
+
loop_span = themed(80.0)
|
|
945
|
+
|
|
946
|
+
def __init__(
|
|
947
|
+
self,
|
|
948
|
+
label: Optional[str] = None,
|
|
949
|
+
*,
|
|
950
|
+
direction: str = "down", # down|up|left|right
|
|
951
|
+
length: Optional[float] = None,
|
|
952
|
+
loop: bool = False,
|
|
953
|
+
loop_side: str = "right", # right|left
|
|
954
|
+
loop_span: Optional[float] = None,
|
|
955
|
+
color: Optional[str] = None,
|
|
956
|
+
stroke: Optional[str] = None,
|
|
957
|
+
opacity: Optional[float] = None,
|
|
958
|
+
alpha: Optional[float] = None,
|
|
959
|
+
width: Optional[float] = None,
|
|
960
|
+
stroke_width: Optional[float] = None,
|
|
961
|
+
head_size: Optional[float] = None,
|
|
962
|
+
style: Any = None,
|
|
963
|
+
head: Any = None,
|
|
964
|
+
tail: Any = None,
|
|
965
|
+
head_length: Optional[float] = None,
|
|
966
|
+
head_width: Optional[float] = None,
|
|
967
|
+
hollow: Optional[bool] = None,
|
|
968
|
+
head_fill: Optional[str] = None,
|
|
969
|
+
label_side: Optional[str] = None, # above|below (horizontal), left|right (vertical)
|
|
970
|
+
label_size: Optional[float] = None,
|
|
971
|
+
label_color: Optional[str] = None,
|
|
972
|
+
**kwargs: Any,
|
|
973
|
+
) -> None:
|
|
974
|
+
super().__init__(**kwargs)
|
|
975
|
+
self.tail = tail
|
|
976
|
+
self.head_length = head_length
|
|
977
|
+
self.head_width = head_width
|
|
978
|
+
self.hollow = hollow
|
|
979
|
+
self.head_fill = head_fill
|
|
980
|
+
if label_side is not None and label_side not in ("above", "below", "left", "right"):
|
|
981
|
+
raise ValueError("Arrow label_side must be above|below|left|right")
|
|
982
|
+
self.label_side = label_side
|
|
983
|
+
self.label_size = label_size
|
|
984
|
+
self.label_color = label_color
|
|
985
|
+
self.label = label
|
|
986
|
+
self.direction = direction
|
|
987
|
+
self.length = length
|
|
988
|
+
self.loop = loop
|
|
989
|
+
self.loop_side = loop_side
|
|
990
|
+
self.loop_span = loop_span
|
|
991
|
+
self.color = color
|
|
992
|
+
self.stroke = stroke
|
|
993
|
+
self.opacity = opacity
|
|
994
|
+
self.alpha = alpha
|
|
995
|
+
self.width = width
|
|
996
|
+
self.sw = stroke_width
|
|
997
|
+
self.head_size = head_size
|
|
998
|
+
self.line_style = style
|
|
999
|
+
self.head = head
|
|
1000
|
+
|
|
1001
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1002
|
+
_L = self.theme.L
|
|
1003
|
+
th = self.theme
|
|
1004
|
+
sw = self.sw if self.sw is not None else th.stroke_width
|
|
1005
|
+
if self.loop:
|
|
1006
|
+
# Thin vertical strip; pair with grow=1 in a Row for full-height feedback.
|
|
1007
|
+
return _L(28.0), self.loop_span
|
|
1008
|
+
length = self.length if self.length is not None else _L(22.0)
|
|
1009
|
+
if self.label and self.label_side is not None:
|
|
1010
|
+
lw, lh = self._label_size_wh()
|
|
1011
|
+
gap = th.edge_label_gap * 0.5
|
|
1012
|
+
if self.direction in ("down", "up"):
|
|
1013
|
+
return 12.0 + gap + lw, max(length, lh)
|
|
1014
|
+
return max(length, lw + _L(4)), lh + gap + _L(12.0)
|
|
1015
|
+
label_w, label_h = (0.0, 0.0)
|
|
1016
|
+
if self.label:
|
|
1017
|
+
label_w, label_h = measure_text(self.label, th.font_size * 0.9)
|
|
1018
|
+
if self.direction in ("down", "up"):
|
|
1019
|
+
return max(_L(12.0), label_w + _L(8)), length + (label_h if self.label else 0)
|
|
1020
|
+
return length + (label_w if self.label else 0), max(_L(12.0), label_h + _L(8))
|
|
1021
|
+
|
|
1022
|
+
def _label_fs(self) -> float:
|
|
1023
|
+
return self.label_size if self.label_size is not None else self.theme.font_size * 0.9
|
|
1024
|
+
|
|
1025
|
+
def _label_size_wh(self) -> Tuple[float, float]:
|
|
1026
|
+
return measure_text(self.label or "", self._label_fs())
|
|
1027
|
+
|
|
1028
|
+
def _draw_side_label(self, buf: List[str], x: float, y: float, st: Any) -> None:
|
|
1029
|
+
_L = self.theme.L
|
|
1030
|
+
"""Arrow with label_side: shaft + label block beside it (never crossing)."""
|
|
1031
|
+
g = self.geom
|
|
1032
|
+
lw, lh = self._label_size_wh()
|
|
1033
|
+
gap = self.theme.edge_label_gap * 0.5
|
|
1034
|
+
side = self.label_side
|
|
1035
|
+
if self.direction in ("down", "up"):
|
|
1036
|
+
sx = x + _L(6) if side == "right" else x + g.w - _L(6)
|
|
1037
|
+
ya, yb = (y + _L(2), y + g.h - _L(2)) if self.direction == "down" else (y + g.h - _L(2), y + _L(2))
|
|
1038
|
+
seg = (sx, ya, sx, yb)
|
|
1039
|
+
lx = sx + _L(6) + gap if side == "right" else sx - _L(6) - gap - lw
|
|
1040
|
+
ly = y + (g.h - lh) / 2
|
|
1041
|
+
align = "left" if side == "right" else "right"
|
|
1042
|
+
else:
|
|
1043
|
+
sy = y + g.h - _L(6) if side == "above" else y + _L(6)
|
|
1044
|
+
xa, xb = (x + _L(2), x + g.w - _L(2)) if self.direction == "right" else (x + g.w - _L(2), x + _L(2))
|
|
1045
|
+
seg = (xa, sy, xb, sy)
|
|
1046
|
+
lx = x + (g.w - lw) / 2
|
|
1047
|
+
ly = y if side == "above" else y + _L(12.0) + gap
|
|
1048
|
+
align = "center"
|
|
1049
|
+
self._shaft(buf, st, *seg)
|
|
1050
|
+
t = Text(self.label, font_size=self._label_fs(), color=self.label_color or st.color,
|
|
1051
|
+
align=align, theme=self._theme)
|
|
1052
|
+
t.geom = BoxGeom(lx, ly, lw, lh)
|
|
1053
|
+
t.draw(buf)
|
|
1054
|
+
|
|
1055
|
+
def _shaft(self, buf: List[str], st: Any, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
1056
|
+
draw_styled_line(buf, st, x1, y1, x2, y2)
|
|
1057
|
+
|
|
1058
|
+
def _style(self):
|
|
1059
|
+
from .styles import resolve_edge_style
|
|
1060
|
+
return resolve_edge_style(
|
|
1061
|
+
self.theme,
|
|
1062
|
+
color=self.color,
|
|
1063
|
+
stroke=self.stroke,
|
|
1064
|
+
opacity=self.opacity,
|
|
1065
|
+
alpha=self.alpha,
|
|
1066
|
+
width=self.width,
|
|
1067
|
+
stroke_width=self.sw,
|
|
1068
|
+
head_size=self.head_size,
|
|
1069
|
+
style=self.line_style,
|
|
1070
|
+
head=self.head,
|
|
1071
|
+
tail=self.tail,
|
|
1072
|
+
head_length=self.head_length,
|
|
1073
|
+
head_width=self.head_width,
|
|
1074
|
+
hollow=self.hollow,
|
|
1075
|
+
head_fill=self.head_fill,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1079
|
+
_L = self.theme.L
|
|
1080
|
+
from .render_svg import draw_text
|
|
1081
|
+
|
|
1082
|
+
g = self.geom
|
|
1083
|
+
th = self.theme
|
|
1084
|
+
st = self._style()
|
|
1085
|
+
x, y = g.x + ox, g.y + oy
|
|
1086
|
+
|
|
1087
|
+
def shaft(x1, y1, x2, y2):
|
|
1088
|
+
draw_styled_line(buf, st, x1, y1, x2, y2)
|
|
1089
|
+
|
|
1090
|
+
if self.loop:
|
|
1091
|
+
cx = x + g.w / 2
|
|
1092
|
+
shaft(cx, y, cx, y + g.h)
|
|
1093
|
+
return
|
|
1094
|
+
if self.label and self.label_side is not None:
|
|
1095
|
+
self._draw_side_label(buf, x, y, st)
|
|
1096
|
+
return
|
|
1097
|
+
|
|
1098
|
+
if self.direction == "down":
|
|
1099
|
+
cx = x + g.w / 2
|
|
1100
|
+
y1, y2 = y + _L(2), y + g.h - _L(2)
|
|
1101
|
+
shaft(cx, y1, cx, y2)
|
|
1102
|
+
if self.label:
|
|
1103
|
+
tw, _ = measure_text(self.label, th.font_size * 0.9)
|
|
1104
|
+
draw_text(buf, self.label, cx - tw / 2 + _L(10), (y1 + y2) / 2,
|
|
1105
|
+
font_size=th.font_size * 0.9, fill=st.color, opacity=st.opacity)
|
|
1106
|
+
elif self.direction == "up":
|
|
1107
|
+
cx = x + g.w / 2
|
|
1108
|
+
shaft(cx, y + g.h - _L(2), cx, y + _L(2))
|
|
1109
|
+
elif self.direction == "right":
|
|
1110
|
+
cy = y + g.h / 2
|
|
1111
|
+
x1, x2 = x + _L(2), x + g.w - _L(2)
|
|
1112
|
+
shaft(x1, cy, x2, cy)
|
|
1113
|
+
if self.label:
|
|
1114
|
+
tw, _ = measure_text(self.label, th.font_size * 0.9)
|
|
1115
|
+
draw_text(buf, self.label, (x1 + x2) / 2 - tw / 2, cy - _L(4),
|
|
1116
|
+
font_size=th.font_size * 0.9, fill=st.color, opacity=st.opacity)
|
|
1117
|
+
else:
|
|
1118
|
+
cy = y + g.h / 2
|
|
1119
|
+
shaft(x + g.w - _L(2), cy, x + _L(2), cy)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
class LoopArrow(Node):
|
|
1123
|
+
"""
|
|
1124
|
+
Feedback loop drawn beside content. Place as an overlay sibling via
|
|
1125
|
+
Figure annotations, or use alongside a Row([content, LoopArrow(...)]).
|
|
1126
|
+
For MVP, draws a path from bottom to top along its allocated strip.
|
|
1127
|
+
"""
|
|
1128
|
+
|
|
1129
|
+
def __init__(
|
|
1130
|
+
self,
|
|
1131
|
+
*,
|
|
1132
|
+
side: str = "right",
|
|
1133
|
+
strip: float = 36.0,
|
|
1134
|
+
width: Optional[float] = None,
|
|
1135
|
+
label: Optional[str] = None,
|
|
1136
|
+
color: Optional[str] = None,
|
|
1137
|
+
stroke: Optional[str] = None,
|
|
1138
|
+
opacity: Optional[float] = None,
|
|
1139
|
+
alpha: Optional[float] = None,
|
|
1140
|
+
stroke_width: Optional[float] = None,
|
|
1141
|
+
head_size: Optional[float] = None,
|
|
1142
|
+
style: Optional[str] = None,
|
|
1143
|
+
head: Optional[str] = None,
|
|
1144
|
+
**kwargs: Any,
|
|
1145
|
+
) -> None:
|
|
1146
|
+
# BC: width= used to mean strip; if width looks like a strip (>= 10) and
|
|
1147
|
+
# stroke_width unset, treat it as strip. Prefer strip= + width= for stroke.
|
|
1148
|
+
super().__init__(**kwargs)
|
|
1149
|
+
self.side = side
|
|
1150
|
+
strip_w = strip
|
|
1151
|
+
sw = stroke_width if stroke_width is not None else None
|
|
1152
|
+
if width is not None and sw is None and width >= 10:
|
|
1153
|
+
strip_w = width
|
|
1154
|
+
sw = None
|
|
1155
|
+
elif width is not None:
|
|
1156
|
+
sw = width if sw is None else sw
|
|
1157
|
+
self.strip_width = strip_w
|
|
1158
|
+
self.label = label
|
|
1159
|
+
self.color = color
|
|
1160
|
+
self.stroke = stroke
|
|
1161
|
+
self.opacity = opacity
|
|
1162
|
+
self.alpha = alpha
|
|
1163
|
+
self.sw = sw
|
|
1164
|
+
self.width = sw # stroke width alias for _style
|
|
1165
|
+
self.head_size = head_size
|
|
1166
|
+
self.line_style = style
|
|
1167
|
+
self.head = head
|
|
1168
|
+
self.grow = kwargs.get("grow", 1.0)
|
|
1169
|
+
|
|
1170
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1171
|
+
return self.strip_width, 40.0
|
|
1172
|
+
|
|
1173
|
+
def _label_fs(self) -> float:
|
|
1174
|
+
return self.label_size if self.label_size is not None else self.theme.font_size * 0.9
|
|
1175
|
+
|
|
1176
|
+
def _label_size_wh(self) -> Tuple[float, float]:
|
|
1177
|
+
return measure_text(self.label or "", self._label_fs())
|
|
1178
|
+
|
|
1179
|
+
def _draw_side_label(self, buf: List[str], x: float, y: float, st: Any) -> None:
|
|
1180
|
+
"""Arrow with label_side: shaft + label block beside it (never crossing)."""
|
|
1181
|
+
g = self.geom
|
|
1182
|
+
lw, lh = self._label_size_wh()
|
|
1183
|
+
gap = self.theme.edge_label_gap * 0.5
|
|
1184
|
+
side = self.label_side
|
|
1185
|
+
if self.direction in ("down", "up"):
|
|
1186
|
+
sx = x + 6 if side == "right" else x + g.w - 6
|
|
1187
|
+
ya, yb = (y + 2, y + g.h - 2) if self.direction == "down" else (y + g.h - 2, y + 2)
|
|
1188
|
+
seg = (sx, ya, sx, yb)
|
|
1189
|
+
lx = sx + 6 + gap if side == "right" else sx - 6 - gap - lw
|
|
1190
|
+
ly = y + (g.h - lh) / 2
|
|
1191
|
+
align = "left" if side == "right" else "right"
|
|
1192
|
+
else:
|
|
1193
|
+
sy = y + g.h - 6 if side == "above" else y + 6
|
|
1194
|
+
xa, xb = (x + 2, x + g.w - 2) if self.direction == "right" else (x + g.w - 2, x + 2)
|
|
1195
|
+
seg = (xa, sy, xb, sy)
|
|
1196
|
+
lx = x + (g.w - lw) / 2
|
|
1197
|
+
ly = y if side == "above" else y + 12.0 + gap
|
|
1198
|
+
align = "center"
|
|
1199
|
+
self._shaft(buf, st, *seg)
|
|
1200
|
+
t = Text(self.label, font_size=self._label_fs(), color=self.label_color or st.color,
|
|
1201
|
+
align=align, theme=self._theme)
|
|
1202
|
+
t.geom = BoxGeom(lx, ly, lw, lh)
|
|
1203
|
+
t.draw(buf)
|
|
1204
|
+
|
|
1205
|
+
def _shaft(self, buf: List[str], st: Any, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
1206
|
+
draw_styled_line(buf, st, x1, y1, x2, y2)
|
|
1207
|
+
|
|
1208
|
+
def _style(self):
|
|
1209
|
+
from .styles import resolve_edge_style
|
|
1210
|
+
return resolve_edge_style(
|
|
1211
|
+
self.theme,
|
|
1212
|
+
color=self.color,
|
|
1213
|
+
stroke=self.stroke,
|
|
1214
|
+
opacity=self.opacity,
|
|
1215
|
+
alpha=self.alpha,
|
|
1216
|
+
width=self.sw,
|
|
1217
|
+
stroke_width=self.sw,
|
|
1218
|
+
head_size=self.head_size,
|
|
1219
|
+
style=self.line_style,
|
|
1220
|
+
head=self.head,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1224
|
+
from .render_svg import angle_of, draw_arrowhead, draw_path, draw_text
|
|
1225
|
+
|
|
1226
|
+
g = self.geom
|
|
1227
|
+
th = self.theme
|
|
1228
|
+
st = self._style()
|
|
1229
|
+
x, y = g.x + ox, g.y + oy
|
|
1230
|
+
if self.side == "right":
|
|
1231
|
+
x1 = x + 4
|
|
1232
|
+
x2 = x + g.w - 6
|
|
1233
|
+
pts = [(x1, y + g.h - 4), (x2, y + g.h - 4), (x2, y + 4), (x1, y + 4)]
|
|
1234
|
+
else:
|
|
1235
|
+
x1 = x + g.w - 4
|
|
1236
|
+
x2 = x + 6
|
|
1237
|
+
pts = [(x1, y + g.h - 4), (x2, y + g.h - 4), (x2, y + 4), (x1, y + 4)]
|
|
1238
|
+
d = f"M {pts[0][0]:.2f},{pts[0][1]:.2f} " + " ".join(
|
|
1239
|
+
f"L {px:.2f},{py:.2f}" for px, py in pts[1:]
|
|
1240
|
+
)
|
|
1241
|
+
draw_path(
|
|
1242
|
+
buf, d, stroke=st.color, stroke_width=st.width,
|
|
1243
|
+
opacity=st.opacity, dasharray=st.dasharray(),
|
|
1244
|
+
)
|
|
1245
|
+
if st.head != "none":
|
|
1246
|
+
draw_arrowhead(
|
|
1247
|
+
buf, pts[-1][0], pts[-1][1],
|
|
1248
|
+
angle_of(pts[-2][0], pts[-2][1], pts[-1][0], pts[-1][1]),
|
|
1249
|
+
size=st.head_size, color=st.color, opacity=st.opacity, shape=st.head,
|
|
1250
|
+
)
|
|
1251
|
+
if self.label:
|
|
1252
|
+
tw, _ = measure_text(self.label, th.font_size * 0.85)
|
|
1253
|
+
draw_text(
|
|
1254
|
+
buf, self.label, x + (g.w - tw) / 2, y + g.h / 2,
|
|
1255
|
+
font_size=th.font_size * 0.85, fill=st.color, opacity=st.opacity,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
# ---- colour maps (heatmaps) ---------------------------------------------------
|
|
1260
|
+
|
|
1261
|
+
CMAPS: Dict[str, List[str]] = {
|
|
1262
|
+
"Blues": ["#f7fbff", "#c6dbef", "#6baed6", "#2171b5", "#08306b"],
|
|
1263
|
+
"Reds": ["#fff5f0", "#fcbba1", "#fb6a4a", "#cb181d", "#67000d"],
|
|
1264
|
+
"Greens": ["#f7fcf5", "#c7e9c0", "#74c476", "#238b45", "#00441b"],
|
|
1265
|
+
"Oranges": ["#fff5eb", "#fdd0a2", "#fd8d3c", "#d94801", "#7f2704"],
|
|
1266
|
+
"Purples": ["#fcfbfd", "#dadaeb", "#9e9ac8", "#6a51a3", "#3f007d"],
|
|
1267
|
+
"Greys": ["#ffffff", "#d9d9d9", "#969696", "#525252", "#000000"],
|
|
1268
|
+
"viridis": ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"],
|
|
1269
|
+
"magma": ["#000004", "#51127c", "#b73779", "#fc8961", "#fcfdbf"],
|
|
1270
|
+
"RdBu": ["#b2182b", "#ef8a62", "#f7f7f7", "#67a9cf", "#2166ac"],
|
|
1271
|
+
"YlOrRd": ["#ffffcc", "#fed976", "#fd8d3c", "#e31a1c", "#800026"],
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _hex_rgb(c: str) -> Tuple[float, float, float]:
|
|
1276
|
+
c = c.lstrip("#")
|
|
1277
|
+
if len(c) == 3:
|
|
1278
|
+
c = "".join(ch * 2 for ch in c)
|
|
1279
|
+
return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def cmap_color(cmap: Any, t: float) -> str:
|
|
1283
|
+
"""Colour at ``t`` ∈ [0, 1] of a named map (see ``CMAPS``) or a list of stops."""
|
|
1284
|
+
stops = CMAPS[cmap] if isinstance(cmap, str) else list(cmap)
|
|
1285
|
+
if isinstance(cmap, str) and cmap not in CMAPS:
|
|
1286
|
+
raise ValueError(f"unknown cmap {cmap!r}; one of {sorted(CMAPS)} or a list of colours")
|
|
1287
|
+
t = 0.0 if t != t else min(1.0, max(0.0, float(t)))
|
|
1288
|
+
if len(stops) == 1:
|
|
1289
|
+
return stops[0]
|
|
1290
|
+
pos = t * (len(stops) - 1)
|
|
1291
|
+
i = min(int(pos), len(stops) - 2)
|
|
1292
|
+
f = pos - i
|
|
1293
|
+
a, b = _hex_rgb(stops[i]), _hex_rgb(stops[i + 1])
|
|
1294
|
+
r, g, bl = (round(a[k] + (b[k] - a[k]) * f) for k in range(3))
|
|
1295
|
+
return f"#{r:02x}{g:02x}{bl:02x}"
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _luma(c: str) -> float:
|
|
1299
|
+
try:
|
|
1300
|
+
r, g, b = _hex_rgb(c)
|
|
1301
|
+
except Exception:
|
|
1302
|
+
return 1.0
|
|
1303
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _cell_lookup(spec: Any, r: int, c: int, rows: int, cols: int = 0) -> Any:
|
|
1307
|
+
"""Per-cell value from callable(r, c) / {(r, c): v} / 2-D list / 1-D list (a 1-row
|
|
1308
|
+
strip, or — v0.13.0 — a 1-column strip: ``Matrix(3, 1, cell_labels=["", "x", ""])``)."""
|
|
1309
|
+
if spec is None:
|
|
1310
|
+
return None
|
|
1311
|
+
if callable(spec):
|
|
1312
|
+
return spec(r, c)
|
|
1313
|
+
if isinstance(spec, dict):
|
|
1314
|
+
return spec.get((r, c))
|
|
1315
|
+
try:
|
|
1316
|
+
row = spec[r]
|
|
1317
|
+
except (IndexError, TypeError, KeyError):
|
|
1318
|
+
return None
|
|
1319
|
+
if isinstance(row, (list, tuple)):
|
|
1320
|
+
return row[c] if c < len(row) else None
|
|
1321
|
+
if cols == 1 and rows > 1: # 1-D list down a single column
|
|
1322
|
+
return row if c == 0 else None
|
|
1323
|
+
if rows == 1 and r == 0: # 1-D list for a single-row strip
|
|
1324
|
+
try:
|
|
1325
|
+
return spec[c]
|
|
1326
|
+
except (IndexError, TypeError):
|
|
1327
|
+
return None
|
|
1328
|
+
return None
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
_MATRIX_LABEL_POS = ("left", "right", "top", "bottom", "top-right", "top-left", "bottom-right")
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
class Matrix(Node):
|
|
1335
|
+
"""
|
|
1336
|
+
Grid of cells: attention maps, KV blocks, token strips, heatmaps and tables.
|
|
1337
|
+
|
|
1338
|
+
Geometry: square ``cell`` (default) or ``cell_w`` / ``cell_h`` (a number or
|
|
1339
|
+
``"content"`` — each column as wide as its widest label, e.g. token strips).
|
|
1340
|
+
v0.12.0: ``cell_w`` / ``cell_h`` may also be a list — one size per column / row
|
|
1341
|
+
(``cell_h=[12, 12, 24]``). ``row_splits=[3]`` / ``col_splits=[2, 4]`` open an
|
|
1342
|
+
extra gap before those row / column indices (block-sparse layouts);
|
|
1343
|
+
``split_style="gap"`` (default) leaves it empty, ``"line"`` / ``"dashed"`` /
|
|
1344
|
+
``"dotted"`` draw a divider through it; ``split_gap=`` sets its width (0 allowed).
|
|
1345
|
+
v0.13.0: ``continuous=True`` draws one block — no cell gaps or rounded cells, a
|
|
1346
|
+
single outer frame, internal rules only at the splits (``split_style`` defaults to
|
|
1347
|
+
``"dashed"`` there): ``Matrix(6, 2, continuous=True, row_splits=[3, 4])``.
|
|
1348
|
+
``label=`` sits at ``label_pos`` = ``"left"`` (default) | ``"right"`` | ``"top"`` |
|
|
1349
|
+
``"bottom"`` | ``"top-right"`` (a superscript-style corner label, e.g. a
|
|
1350
|
+
transpose ``label="T", label_pos="top-right"``) | ``"top-left"`` | ``"bottom-right"``.
|
|
1351
|
+
Corner labels default to the theme's script size; ``label_size=`` overrides.
|
|
1352
|
+
``row_span(i, j)`` / ``col_span(i, j)`` give anchor pairs for ``Brace(span=)`` /
|
|
1353
|
+
``Dim`` over rows / columns i..j. Labels cut with "…" to fit a cell are reported
|
|
1354
|
+
by lint CF-TXT-011.
|
|
1355
|
+
|
|
1356
|
+
Per-cell content (each spec may be a callable ``(r, c) -> v``, a dict
|
|
1357
|
+
``{(r, c): v}``, a 2-D list, or a 1-D list for a single-row or single-column strip):
|
|
1358
|
+
|
|
1359
|
+
* ``cell_labels`` — text (rich markup works: ``"**K**"``, ``"[color=#c00]x[/]"``)
|
|
1360
|
+
* ``cell_fill`` — colour; ``heat=`` values + ``cmap`` / ``vmin`` / ``vmax``
|
|
1361
|
+
colour cells by value (``heat_labels="{:.2f}"`` prints them; ``colorbar=True``
|
|
1362
|
+
adds a scale bar on the right, ``"bottom"`` below)
|
|
1363
|
+
* ``cell_marks`` — vector glyphs ``check | cross | dot | circle | dash``
|
|
1364
|
+
* ``cell_border`` — ``solid | dashed | dotted | "4 2"`` per cell (``border=`` = all)
|
|
1365
|
+
* ``cell_style`` — dict per cell overriding fill / text_color / weight / border /
|
|
1366
|
+
stroke / mark at once
|
|
1367
|
+
|
|
1368
|
+
Tables: ``header_rows`` / ``header_cols`` (counts) give the first rows / cols
|
|
1369
|
+
``header_fill`` and bold text. ``outlines=[(r0, c0, r1, c1[, colour])]`` frame
|
|
1370
|
+
cell blocks. highlight_rows / highlight_cols, row/col labels and a side label
|
|
1371
|
+
work as before. ``legend={colour: "meaning"}`` feeds an auto Legend.
|
|
1372
|
+
"""
|
|
1373
|
+
|
|
1374
|
+
cell = themed(16.0)
|
|
1375
|
+
cell_pad = themed(4.0)
|
|
1376
|
+
gap = themed(2.0)
|
|
1377
|
+
highlight_width = themed(2.5, "SW")
|
|
1378
|
+
cell_stroke_width = themed(1.2, "SW")
|
|
1379
|
+
cell_radius = themed(2.0)
|
|
1380
|
+
outline_width = themed(2.0, "SW")
|
|
1381
|
+
stroke = theme_default("stroke")
|
|
1382
|
+
highlight_stroke = theme_default("highlight")
|
|
1383
|
+
label_gap = theme_default("label_gap")
|
|
1384
|
+
cell_text_color = theme_default("text_color")
|
|
1385
|
+
|
|
1386
|
+
def __init__(
|
|
1387
|
+
self,
|
|
1388
|
+
rows: int = 3,
|
|
1389
|
+
cols: int = 3,
|
|
1390
|
+
*,
|
|
1391
|
+
cell: Optional[float] = None,
|
|
1392
|
+
cell_w: Any = None,
|
|
1393
|
+
cell_h: Any = None,
|
|
1394
|
+
cell_pad: Optional[float] = None,
|
|
1395
|
+
gap: Optional[float] = None,
|
|
1396
|
+
fill: str = "#c9b3e6",
|
|
1397
|
+
stroke: Optional[str] = None,
|
|
1398
|
+
highlight_rows: Optional[Sequence[int]] = None,
|
|
1399
|
+
highlight_cols: Optional[Sequence[int]] = None,
|
|
1400
|
+
highlight_stroke: Optional[str] = None,
|
|
1401
|
+
highlight_width: Optional[float] = None,
|
|
1402
|
+
cell_labels: Any = None,
|
|
1403
|
+
row_labels: Optional[Sequence[str]] = None,
|
|
1404
|
+
col_labels: Optional[Sequence[str]] = None,
|
|
1405
|
+
label: Optional[str] = None,
|
|
1406
|
+
label_pos: str = "left", # left|right|top|bottom
|
|
1407
|
+
label_gap: Optional[float] = None,
|
|
1408
|
+
aspect: Optional[float] = None,
|
|
1409
|
+
w: Optional[Any] = None,
|
|
1410
|
+
h: Optional[Any] = None,
|
|
1411
|
+
max_w: Optional[float] = None,
|
|
1412
|
+
max_h: Optional[float] = None,
|
|
1413
|
+
cell_fill: Any = None,
|
|
1414
|
+
cell_stroke_width: Optional[float] = None,
|
|
1415
|
+
cell_radius: Optional[float] = None,
|
|
1416
|
+
cell_font_size: Optional[float] = None,
|
|
1417
|
+
cell_text_color: Optional[str] = None,
|
|
1418
|
+
cell_weight: str = "normal",
|
|
1419
|
+
cell_mono: bool = False,
|
|
1420
|
+
cell_marks: Any = None,
|
|
1421
|
+
mark_color: Optional[str] = None,
|
|
1422
|
+
cell_border: Any = None,
|
|
1423
|
+
cell_style: Any = None,
|
|
1424
|
+
heat: Any = None,
|
|
1425
|
+
cmap: Any = "Blues",
|
|
1426
|
+
vmin: Optional[float] = None,
|
|
1427
|
+
vmax: Optional[float] = None,
|
|
1428
|
+
heat_labels: Any = None,
|
|
1429
|
+
colorbar: Any = False,
|
|
1430
|
+
colorbar_label: Optional[str] = None,
|
|
1431
|
+
colorbar_ticks: int = 3,
|
|
1432
|
+
colorbar_format: str = "{:g}",
|
|
1433
|
+
header_rows: int = 0,
|
|
1434
|
+
header_cols: int = 0,
|
|
1435
|
+
header_fill: str = "#e8e8e8",
|
|
1436
|
+
header_weight: str = "bold",
|
|
1437
|
+
outlines: Optional[Sequence[Any]] = None,
|
|
1438
|
+
outline_color: Optional[str] = None,
|
|
1439
|
+
outline_width: Optional[float] = None,
|
|
1440
|
+
outline_border: Any = None,
|
|
1441
|
+
label_weight: str = "bold",
|
|
1442
|
+
label_size: Optional[float] = None,
|
|
1443
|
+
row_splits: Optional[Sequence[int]] = None,
|
|
1444
|
+
col_splits: Optional[Sequence[int]] = None,
|
|
1445
|
+
split_style: str = "gap",
|
|
1446
|
+
split_gap: Optional[float] = None,
|
|
1447
|
+
continuous: bool = False,
|
|
1448
|
+
**kwargs: Any,
|
|
1449
|
+
) -> None:
|
|
1450
|
+
super().__init__(w=w, h=h, aspect=aspect, max_w=max_w, max_h=max_h, **kwargs)
|
|
1451
|
+
from ._validate import check_enum
|
|
1452
|
+
split_style = check_enum("primitives(split_style)", split_style, ("gap", "line", "solid", "dashed", "dotted"))
|
|
1453
|
+
from ._validate import check_enum
|
|
1454
|
+
label_pos = check_enum("primitives(label_pos)", label_pos, _MATRIX_LABEL_POS)
|
|
1455
|
+
# v0.13.0: one continuous block — no gaps, square cells, a single outer frame,
|
|
1456
|
+
# internal rules only at the splits (dashed by default)
|
|
1457
|
+
self.continuous = bool(continuous)
|
|
1458
|
+
if self.continuous:
|
|
1459
|
+
if gap is None:
|
|
1460
|
+
gap = 0.0
|
|
1461
|
+
if cell_radius is None:
|
|
1462
|
+
cell_radius = 0.0
|
|
1463
|
+
if split_gap is None:
|
|
1464
|
+
split_gap = 0.0
|
|
1465
|
+
if split_style == "gap":
|
|
1466
|
+
split_style = "dashed"
|
|
1467
|
+
self.row_splits = sorted({int(i) for i in (row_splits or []) if 0 < int(i) < int(rows)})
|
|
1468
|
+
self.col_splits = sorted({int(i) for i in (col_splits or []) if 0 < int(i) < int(cols)})
|
|
1469
|
+
self.split_style = "line" if split_style == "solid" else split_style
|
|
1470
|
+
self.split_gap = split_gap
|
|
1471
|
+
for nm, v, n in (("cell_w", cell_w, cols), ("cell_h", cell_h, rows)):
|
|
1472
|
+
if isinstance(v, (list, tuple)):
|
|
1473
|
+
if len(v) != int(n):
|
|
1474
|
+
raise ValueError(f"{nm} list needs one size per {'column' if nm == 'cell_w' else 'row'} "
|
|
1475
|
+
f"({int(n)}), got {len(v)}")
|
|
1476
|
+
if any(float(x) <= 0 for x in v):
|
|
1477
|
+
raise ValueError(f"{nm} sizes must be positive")
|
|
1478
|
+
# cell_fill: per-cell colour — callable(r, c) -> colour|None, 2-D list,
|
|
1479
|
+
# or {(r, c): colour}; None / missing entries fall back to ``fill``.
|
|
1480
|
+
if cell_fill is not None and not isinstance(cell_fill, (str, list, tuple, dict)) and not callable(cell_fill):
|
|
1481
|
+
raise ValueError("Matrix(cell_fill): expected a colour, sequence, mapping or callable")
|
|
1482
|
+
self.cell_fill = (lambda r, c: cell_fill) if isinstance(cell_fill, str) else cell_fill
|
|
1483
|
+
self.cell_stroke_width = cell_stroke_width
|
|
1484
|
+
self.cell_radius = cell_radius
|
|
1485
|
+
self.cell_font_size = cell_font_size
|
|
1486
|
+
self.cell_text_color = cell_text_color
|
|
1487
|
+
self.cell_weight = cell_weight
|
|
1488
|
+
self.cell_mono = bool(cell_mono)
|
|
1489
|
+
self.cell_marks = cell_marks
|
|
1490
|
+
self.mark_color = mark_color
|
|
1491
|
+
self.cell_border = cell_border
|
|
1492
|
+
self.cell_style = cell_style
|
|
1493
|
+
self.label_weight = label_weight
|
|
1494
|
+
self.label_size = label_size
|
|
1495
|
+
self.rows = int(rows)
|
|
1496
|
+
self.cols = int(cols)
|
|
1497
|
+
self.cell = cell
|
|
1498
|
+
self.cell_w = cell_w
|
|
1499
|
+
self.cell_h = cell_h
|
|
1500
|
+
self.cell_pad = cell_pad
|
|
1501
|
+
self.gap = gap
|
|
1502
|
+
self.fill = fill
|
|
1503
|
+
self.stroke = stroke
|
|
1504
|
+
self.highlight_rows = set(highlight_rows or [])
|
|
1505
|
+
self.highlight_cols = set(highlight_cols or [])
|
|
1506
|
+
self.highlight_stroke = highlight_stroke
|
|
1507
|
+
self.highlight_width = highlight_width
|
|
1508
|
+
self.cell_labels = cell_labels
|
|
1509
|
+
self.row_labels = row_labels
|
|
1510
|
+
self.col_labels = col_labels
|
|
1511
|
+
self.label = label
|
|
1512
|
+
self.label_pos = label_pos
|
|
1513
|
+
self.label_gap = None if label_gap is None else float(label_gap)
|
|
1514
|
+
self.heat = heat
|
|
1515
|
+
self.cmap = cmap
|
|
1516
|
+
self.heat_labels = heat_labels
|
|
1517
|
+
self.colorbar = "right" if colorbar is True else (colorbar or None)
|
|
1518
|
+
if self.colorbar not in (None, "right", "bottom"):
|
|
1519
|
+
raise ValueError("colorbar must be True, 'right', 'bottom' or False")
|
|
1520
|
+
self.colorbar_label = colorbar_label
|
|
1521
|
+
self.colorbar_ticks = max(2, int(colorbar_ticks))
|
|
1522
|
+
self.colorbar_format = colorbar_format
|
|
1523
|
+
self.header_rows = int(header_rows)
|
|
1524
|
+
self.header_cols = int(header_cols)
|
|
1525
|
+
self.header_fill = header_fill
|
|
1526
|
+
self.header_weight = header_weight
|
|
1527
|
+
self.outlines = list(outlines or [])
|
|
1528
|
+
self.outline_color = outline_color
|
|
1529
|
+
self.outline_width = outline_width
|
|
1530
|
+
self.outline_border = outline_border
|
|
1531
|
+
self.vmin, self.vmax = vmin, vmax
|
|
1532
|
+
if heat is not None:
|
|
1533
|
+
vals = [v for r in range(self.rows) for c in range(self.cols)
|
|
1534
|
+
for v in [_cell_lookup(heat, r, c, self.rows, self.cols)] if v is not None]
|
|
1535
|
+
if not vals:
|
|
1536
|
+
raise ValueError("heat= has no values for this grid")
|
|
1537
|
+
self.vmin = min(vals) if vmin is None else float(vmin)
|
|
1538
|
+
self.vmax = max(vals) if vmax is None else float(vmax)
|
|
1539
|
+
if self.cell_w == "content" or self.cell_h == "content":
|
|
1540
|
+
pass
|
|
1541
|
+
elif self.cell_w is not None and not isinstance(self.cell_w, (list, tuple)) \
|
|
1542
|
+
and float(self.cell_w) <= 0:
|
|
1543
|
+
raise ValueError("cell_w must be positive, a list or 'content'")
|
|
1544
|
+
|
|
1545
|
+
# ---- per-cell resolution -------------------------------------------------
|
|
1546
|
+
def _style(self, r: int, c: int) -> dict:
|
|
1547
|
+
st = _cell_lookup(self.cell_style, r, c, self.rows, self.cols)
|
|
1548
|
+
return dict(st) if isinstance(st, dict) else {}
|
|
1549
|
+
|
|
1550
|
+
def is_header(self, r: int, c: int) -> bool:
|
|
1551
|
+
return r < self.header_rows or c < self.header_cols
|
|
1552
|
+
|
|
1553
|
+
def heat_at(self, r: int, c: int) -> Optional[float]:
|
|
1554
|
+
return _cell_lookup(self.heat, r, c, self.rows, self.cols) if self.heat is not None else None
|
|
1555
|
+
|
|
1556
|
+
def fill_at(self, r: int, c: int) -> str:
|
|
1557
|
+
"""Resolved fill colour of cell (r, c)."""
|
|
1558
|
+
st = self._style(r, c)
|
|
1559
|
+
if st.get("fill"):
|
|
1560
|
+
return st["fill"]
|
|
1561
|
+
col = _cell_lookup(self.cell_fill, r, c, self.rows, self.cols)
|
|
1562
|
+
if col:
|
|
1563
|
+
return col
|
|
1564
|
+
v = self.heat_at(r, c)
|
|
1565
|
+
if v is not None:
|
|
1566
|
+
span = (self.vmax - self.vmin) or 1.0
|
|
1567
|
+
return cmap_color(self.cmap, (v - self.vmin) / span)
|
|
1568
|
+
if self.is_header(r, c):
|
|
1569
|
+
return self.header_fill
|
|
1570
|
+
return self.fill
|
|
1571
|
+
|
|
1572
|
+
def label_at(self, r: int, c: int) -> Optional[str]:
|
|
1573
|
+
lab = _cell_lookup(self.cell_labels, r, c, self.rows, self.cols)
|
|
1574
|
+
if lab is None and self.heat_labels and self.heat is not None:
|
|
1575
|
+
v = self.heat_at(r, c)
|
|
1576
|
+
if v is not None:
|
|
1577
|
+
fmt = self.heat_labels if isinstance(self.heat_labels, str) else "{:.2g}"
|
|
1578
|
+
lab = fmt.format(v)
|
|
1579
|
+
return None if lab is None or lab == "" else str(lab)
|
|
1580
|
+
|
|
1581
|
+
def mark_at(self, r: int, c: int) -> Optional[str]:
|
|
1582
|
+
st = self._style(r, c)
|
|
1583
|
+
if st.get("mark"):
|
|
1584
|
+
return st["mark"]
|
|
1585
|
+
m = _cell_lookup(self.cell_marks, r, c, self.rows, self.cols)
|
|
1586
|
+
if m is True:
|
|
1587
|
+
return "check"
|
|
1588
|
+
if m is False:
|
|
1589
|
+
return "cross"
|
|
1590
|
+
return m or None
|
|
1591
|
+
|
|
1592
|
+
def border_at(self, r: int, c: int) -> Any:
|
|
1593
|
+
st = self._style(r, c)
|
|
1594
|
+
if "border" in st:
|
|
1595
|
+
return st["border"]
|
|
1596
|
+
b = _cell_lookup(self.cell_border, r, c, self.rows, self.cols)
|
|
1597
|
+
return b if b is not None else self.border
|
|
1598
|
+
|
|
1599
|
+
def _cell_fs(self) -> float:
|
|
1600
|
+
if self.cell_font_size is not None:
|
|
1601
|
+
return float(self.cell_font_size)
|
|
1602
|
+
if isinstance(self.cell_h, (list, tuple)):
|
|
1603
|
+
h = min(float(v) for v in self.cell_h)
|
|
1604
|
+
else:
|
|
1605
|
+
h = self.cell if self.cell_h in (None, "content") else float(self.cell_h)
|
|
1606
|
+
if self.cell_w != "content" and self.cell_h != "content" and (
|
|
1607
|
+
"_track" in self.__dict__ or "_track_r" in self.__dict__):
|
|
1608
|
+
# v0.11.0: shared tracks resized the cells; the label font follows them
|
|
1609
|
+
ws = self._col_ws() + self._row_hs()
|
|
1610
|
+
h = max(h, min(ws)) if ws else h
|
|
1611
|
+
return min(h * 0.55, self.theme.F(11.0))
|
|
1612
|
+
|
|
1613
|
+
def _label_w(self, r: int, c: int) -> float:
|
|
1614
|
+
lab = self.label_at(r, c)
|
|
1615
|
+
if not lab:
|
|
1616
|
+
return 0.0
|
|
1617
|
+
st = self._style(r, c)
|
|
1618
|
+
weight = st.get("weight") or (self.header_weight if self.is_header(r, c) else self.cell_weight)
|
|
1619
|
+
return measure_text(lab, self._cell_fs(), mono=self.cell_mono, weight=weight)[0]
|
|
1620
|
+
|
|
1621
|
+
# ---- geometry ------------------------------------------------------------
|
|
1622
|
+
def _col_ws(self) -> List[float]:
|
|
1623
|
+
track = self.__dict__.get("_track")
|
|
1624
|
+
if track is not None: # align_columns(): shared column tracks
|
|
1625
|
+
return list(track["ws"])
|
|
1626
|
+
rtrack = self.__dict__.get("_track_r")
|
|
1627
|
+
if rtrack is not None and rtrack.get("h"): # align_rows() kept square cells square
|
|
1628
|
+
return [float(rtrack["h"])] * self.cols
|
|
1629
|
+
cached = self.__dict__.get("_cw_cache")
|
|
1630
|
+
if cached is not None and cached[0] == self._measured:
|
|
1631
|
+
return cached[1]
|
|
1632
|
+
if self.cell_w == "content":
|
|
1633
|
+
out = []
|
|
1634
|
+
for c in range(self.cols):
|
|
1635
|
+
need = max((self._label_w(r, c) for r in range(self.rows)), default=0.0)
|
|
1636
|
+
out.append(max(need + 2 * self.cell_pad, self._cell_fs() * 1.4))
|
|
1637
|
+
elif isinstance(self.cell_w, (list, tuple)):
|
|
1638
|
+
out = [float(v) for v in self.cell_w]
|
|
1639
|
+
else:
|
|
1640
|
+
out = [float(self.cell_w if self.cell_w is not None else self.cell)] * self.cols
|
|
1641
|
+
self.__dict__["_cw_cache"] = (self._measured, out)
|
|
1642
|
+
return out
|
|
1643
|
+
|
|
1644
|
+
def _row_hs(self) -> List[float]:
|
|
1645
|
+
rtrack = self.__dict__.get("_track_r")
|
|
1646
|
+
if rtrack is not None: # align_rows(): shared row tracks
|
|
1647
|
+
return list(rtrack["ws"])
|
|
1648
|
+
track = self.__dict__.get("_track")
|
|
1649
|
+
if track is not None and track.get("h"):
|
|
1650
|
+
return [float(track["h"])] * self.rows
|
|
1651
|
+
if self.cell_h == "content" or (self.cell_h is None and self.cell_w == "content"):
|
|
1652
|
+
h = self._cell_fs() * 1.25 + 1.2 * self.cell_pad
|
|
1653
|
+
elif isinstance(self.cell_h, (list, tuple)):
|
|
1654
|
+
return [float(v) for v in self.cell_h]
|
|
1655
|
+
elif self.cell_h is not None:
|
|
1656
|
+
h = float(self.cell_h)
|
|
1657
|
+
else:
|
|
1658
|
+
h = self.cell
|
|
1659
|
+
return [h] * self.rows
|
|
1660
|
+
|
|
1661
|
+
def _col_w(self, c: int) -> float:
|
|
1662
|
+
ws = self._col_ws()
|
|
1663
|
+
return ws[max(0, min(self.cols - 1, c))]
|
|
1664
|
+
|
|
1665
|
+
def _row_h(self, r: int) -> float:
|
|
1666
|
+
hs = self._row_hs()
|
|
1667
|
+
return hs[max(0, min(self.rows - 1, r))]
|
|
1668
|
+
|
|
1669
|
+
def _split_w(self) -> float:
|
|
1670
|
+
"""Extra room a row / column split adds (on top of the normal ``gap``)."""
|
|
1671
|
+
if self.split_gap is not None:
|
|
1672
|
+
return float(self.split_gap)
|
|
1673
|
+
return self.theme.L(4.0)
|
|
1674
|
+
|
|
1675
|
+
def _col_x(self, c: int) -> float:
|
|
1676
|
+
"""Offset of column ``c``'s left edge from the grid origin."""
|
|
1677
|
+
ws = self._col_ws()
|
|
1678
|
+
c = max(0, min(self.cols, c))
|
|
1679
|
+
return sum(ws[:c]) + c * self.gap + sum(1 for k in self.col_splits if k <= c) * self._split_w()
|
|
1680
|
+
|
|
1681
|
+
def _row_y(self, r: int) -> float:
|
|
1682
|
+
hs = self._row_hs()
|
|
1683
|
+
r = max(0, min(self.rows, r))
|
|
1684
|
+
return sum(hs[:r]) + r * self.gap + sum(1 for k in self.row_splits if k <= r) * self._split_w()
|
|
1685
|
+
|
|
1686
|
+
def _grid_size(self) -> Tuple[float, float]:
|
|
1687
|
+
w = sum(self._col_ws()) + max(0, self.cols - 1) * self.gap + len(self.col_splits) * self._split_w()
|
|
1688
|
+
h = sum(self._row_hs()) + max(0, self.rows - 1) * self.gap + len(self.row_splits) * self._split_w()
|
|
1689
|
+
return w, h
|
|
1690
|
+
|
|
1691
|
+
def row_span(self, i: int, j: int) -> Tuple[Any, Any]:
|
|
1692
|
+
"""Anchor pair covering rows ``i``..``j`` (for ``Brace(span=)`` / ``Dim``)."""
|
|
1693
|
+
return (self.row_anchor(min(i, j)), self.row_anchor(max(i, j)))
|
|
1694
|
+
|
|
1695
|
+
def col_span(self, i: int, j: int) -> Tuple[Any, Any]:
|
|
1696
|
+
"""Anchor pair covering columns ``i``..``j`` (for ``Brace(span=)`` / ``Dim``)."""
|
|
1697
|
+
return (self.col_anchor(min(i, j)), self.col_anchor(max(i, j)))
|
|
1698
|
+
|
|
1699
|
+
def _colorbar_space(self) -> Tuple[float, float]:
|
|
1700
|
+
"""(right, bottom) extra space for the colorbar."""
|
|
1701
|
+
if not self.colorbar or self.heat is None:
|
|
1702
|
+
return 0.0, 0.0
|
|
1703
|
+
fs = self.theme.small_size if self.theme.font_sizes else self.theme.font_size * 0.75
|
|
1704
|
+
ticks = self._colorbar_tick_values()
|
|
1705
|
+
tw = max(measure_text(self.colorbar_format.format(t), fs)[0] for t in ticks)
|
|
1706
|
+
lab_w = lab_h = 0.0
|
|
1707
|
+
if self.colorbar_label:
|
|
1708
|
+
lab_w, lab_h = measure_text(self.colorbar_label, fs)
|
|
1709
|
+
if self.colorbar == "right":
|
|
1710
|
+
L = self.theme.L
|
|
1711
|
+
return L(8 + 10 + 3) + tw + (lab_h + L(4) if self.colorbar_label else 0.0), 0.0
|
|
1712
|
+
L = self.theme.L
|
|
1713
|
+
return 0.0, L(8 + 8 + 3) + fs * 1.2 + (lab_h + L(2) if self.colorbar_label else 0.0)
|
|
1714
|
+
|
|
1715
|
+
def _colorbar_tick_values(self) -> List[float]:
|
|
1716
|
+
n = self.colorbar_ticks
|
|
1717
|
+
lo, hi = float(self.vmin), float(self.vmax)
|
|
1718
|
+
return [lo + (hi - lo) * i / (n - 1) for i in range(n)]
|
|
1719
|
+
|
|
1720
|
+
def _label_space(self) -> Tuple[float, float, float, float]:
|
|
1721
|
+
"""left, top, right, bottom padding for labels (uses label_gap)."""
|
|
1722
|
+
th = self.theme
|
|
1723
|
+
lg = self.label_gap
|
|
1724
|
+
left = top = right = bottom = 0.0
|
|
1725
|
+
fs = th.font_size * 0.9
|
|
1726
|
+
if self.row_labels:
|
|
1727
|
+
max_w = max(measure_text(s, fs)[0] for s in self.row_labels)
|
|
1728
|
+
left = max(left, max_w + lg)
|
|
1729
|
+
if self.col_labels:
|
|
1730
|
+
top = max(top, fs * 1.2 + lg)
|
|
1731
|
+
cb_r, cb_b = self._colorbar_space()
|
|
1732
|
+
if self.label:
|
|
1733
|
+
lfs = self._label_fs()
|
|
1734
|
+
tw, thh = measure_text(self.label, lfs, weight=self.label_weight)
|
|
1735
|
+
if self.label_pos in ("top-right", "top-left", "bottom-right"):
|
|
1736
|
+
cg = lg * 0.4
|
|
1737
|
+
if self.label_pos == "top-left":
|
|
1738
|
+
left = max(left, tw + cg)
|
|
1739
|
+
else:
|
|
1740
|
+
right = max(right, tw + cg)
|
|
1741
|
+
if self.label_pos == "bottom-right":
|
|
1742
|
+
bottom = max(bottom, lfs * 0.45)
|
|
1743
|
+
else:
|
|
1744
|
+
top = max(top, 0.0)
|
|
1745
|
+
elif self.label_pos == "left":
|
|
1746
|
+
left = max(left, tw + lg)
|
|
1747
|
+
elif self.label_pos == "right":
|
|
1748
|
+
right = max(right, tw + lg)
|
|
1749
|
+
elif self.label_pos == "top":
|
|
1750
|
+
top = max(top, thh + lg)
|
|
1751
|
+
else:
|
|
1752
|
+
bottom = max(bottom, thh + lg)
|
|
1753
|
+
if self.label and self.label_pos == "right":
|
|
1754
|
+
right += cb_r
|
|
1755
|
+
else:
|
|
1756
|
+
right = max(right, cb_r)
|
|
1757
|
+
if self.label and self.label_pos == "bottom":
|
|
1758
|
+
bottom += cb_b
|
|
1759
|
+
else:
|
|
1760
|
+
bottom = max(bottom, cb_b)
|
|
1761
|
+
return left, top, right, bottom
|
|
1762
|
+
|
|
1763
|
+
def _label_fs(self) -> float:
|
|
1764
|
+
th = self.theme
|
|
1765
|
+
if self.label_size is not None:
|
|
1766
|
+
return float(self.label_size)
|
|
1767
|
+
if self.label_pos in ("top-right", "top-left", "bottom-right"):
|
|
1768
|
+
return th.script_size(th.font_size)
|
|
1769
|
+
return th.font_size
|
|
1770
|
+
|
|
1771
|
+
def _measure(self) -> Tuple[float, float]:
|
|
1772
|
+
self.__dict__.pop("_cw_cache", None)
|
|
1773
|
+
gw, gh = self._grid_size()
|
|
1774
|
+
l, t, r, b = self._label_space()
|
|
1775
|
+
return gw + l + r, gh + t + b
|
|
1776
|
+
|
|
1777
|
+
def _grid_origin(self) -> Tuple[float, float]:
|
|
1778
|
+
"""Top-left of the cell grid inside geom (matches draw())."""
|
|
1779
|
+
g = self.geom
|
|
1780
|
+
l, t, r, b = self._label_space()
|
|
1781
|
+
gw, gh = self._grid_size()
|
|
1782
|
+
gx = g.x + l + max(0.0, (g.w - l - r - gw) / 2)
|
|
1783
|
+
gy = g.y + t + max(0.0, (g.h - t - b - gh) / 2)
|
|
1784
|
+
return gx, gy
|
|
1785
|
+
|
|
1786
|
+
def _row_center_y(self, row: int) -> float:
|
|
1787
|
+
_gx, gy = self._grid_origin()
|
|
1788
|
+
row = max(0, min(self.rows - 1, row))
|
|
1789
|
+
return gy + self._row_y(row) + self._row_h(row) / 2
|
|
1790
|
+
|
|
1791
|
+
def _col_center_x(self, col: int) -> float:
|
|
1792
|
+
gx, _gy = self._grid_origin()
|
|
1793
|
+
col = max(0, min(self.cols - 1, col))
|
|
1794
|
+
return gx + self._col_x(col) + self._col_w(col) / 2
|
|
1795
|
+
|
|
1796
|
+
def col_at(self, x: float) -> int:
|
|
1797
|
+
"""Index of the column whose centre is nearest to page coordinate ``x``."""
|
|
1798
|
+
return min(range(self.cols), key=lambda c: abs(self._col_center_x(c) - x))
|
|
1799
|
+
|
|
1800
|
+
def row_at(self, y: float) -> int:
|
|
1801
|
+
return min(range(self.rows), key=lambda r: abs(self._row_center_y(r) - y))
|
|
1802
|
+
|
|
1803
|
+
def resolve_row_port(self, row: int, side: str = "right") -> Tuple[float, float, str]:
|
|
1804
|
+
gx, gy = self._grid_origin()
|
|
1805
|
+
gw, _gh = self._grid_size()
|
|
1806
|
+
cy = self._row_center_y(row)
|
|
1807
|
+
half = self._row_h(row) / 2
|
|
1808
|
+
side = (side or "right").replace("-", "_")
|
|
1809
|
+
# outer_left / outer_right: the node's outer edge (beyond row labels), so
|
|
1810
|
+
# arrowheads stop before the labels instead of covering them
|
|
1811
|
+
if side == "outer_left":
|
|
1812
|
+
return self.geom.x, cy, "left"
|
|
1813
|
+
if side == "outer_right":
|
|
1814
|
+
return self.geom.right, cy, "right"
|
|
1815
|
+
if side == "left":
|
|
1816
|
+
return gx, cy, "left"
|
|
1817
|
+
if side == "right":
|
|
1818
|
+
return gx + gw, cy, "right"
|
|
1819
|
+
if side == "center":
|
|
1820
|
+
return gx + gw / 2, cy, "right"
|
|
1821
|
+
# top/bottom of that row's cell strip
|
|
1822
|
+
if side == "top":
|
|
1823
|
+
return gx + gw / 2, cy - half, "up"
|
|
1824
|
+
return gx + gw / 2, cy + half, "down"
|
|
1825
|
+
|
|
1826
|
+
def resolve_col_port(self, col: int, side: str = "top") -> Tuple[float, float, str]:
|
|
1827
|
+
gx, gy = self._grid_origin()
|
|
1828
|
+
_gw, gh = self._grid_size()
|
|
1829
|
+
cx = self._col_center_x(col)
|
|
1830
|
+
half = self._col_w(col) / 2
|
|
1831
|
+
side = side or "top"
|
|
1832
|
+
if side == "top":
|
|
1833
|
+
return cx, gy, "up"
|
|
1834
|
+
if side == "bottom":
|
|
1835
|
+
return cx, gy + gh, "down"
|
|
1836
|
+
if side == "left":
|
|
1837
|
+
return cx - half, gy + gh / 2, "left"
|
|
1838
|
+
if side == "right":
|
|
1839
|
+
return cx + half, gy + gh / 2, "right"
|
|
1840
|
+
return cx, gy + gh / 2, "down"
|
|
1841
|
+
|
|
1842
|
+
def resolve_cell_port(
|
|
1843
|
+
self, row: int, col: int, side: str = "right"
|
|
1844
|
+
) -> Tuple[float, float, str]:
|
|
1845
|
+
cx = self._col_center_x(col)
|
|
1846
|
+
cy = self._row_center_y(row)
|
|
1847
|
+
hw, hh = self._col_w(col) / 2, self._row_h(row) / 2
|
|
1848
|
+
if side == "left":
|
|
1849
|
+
return cx - hw, cy, "left"
|
|
1850
|
+
if side == "right":
|
|
1851
|
+
return cx + hw, cy, "right"
|
|
1852
|
+
if side == "top":
|
|
1853
|
+
return cx, cy - hh, "up"
|
|
1854
|
+
if side == "bottom":
|
|
1855
|
+
return cx, cy + hh, "down"
|
|
1856
|
+
return cx, cy, "right"
|
|
1857
|
+
|
|
1858
|
+
def row_port(self, row: int, side: str = "right") -> "Port":
|
|
1859
|
+
"""Anchor at the center of matrix row `row` on the given side of the grid.
|
|
1860
|
+
|
|
1861
|
+
side: left|right|center|top|bottom, or outer_left|outer_right (outside the
|
|
1862
|
+
row labels / side label)."""
|
|
1863
|
+
from .edges import Port
|
|
1864
|
+
|
|
1865
|
+
return Port(self, kind="row", side=side, index=row)
|
|
1866
|
+
|
|
1867
|
+
def col_port(self, col: int, side: str = "top") -> "Port":
|
|
1868
|
+
"""Anchor at the center of matrix column `col` on the given side of the grid."""
|
|
1869
|
+
from .edges import Port
|
|
1870
|
+
|
|
1871
|
+
return Port(self, kind="col", side=side, index=col)
|
|
1872
|
+
|
|
1873
|
+
def cell_port(self, row: int, col: int, side: str = "right") -> "Port":
|
|
1874
|
+
"""Anchor on one side of cell (row, col)."""
|
|
1875
|
+
from .edges import Port
|
|
1876
|
+
|
|
1877
|
+
return Port(self, kind="cell", side=side, index=row, col_index=col)
|
|
1878
|
+
|
|
1879
|
+
# ---- constraint anchors (coordinate-free) ---------------------------------
|
|
1880
|
+
def row_anchor(self, row: int) -> "MatrixRowRef":
|
|
1881
|
+
"""Anchor set for row ``row``: ``.cy`` / ``.top`` / ``.bottom`` (row strip),
|
|
1882
|
+
``.left`` / ``.right`` (grid edges). Use with same_y / align / place."""
|
|
1883
|
+
from .constraints import MatrixRowRef
|
|
1884
|
+
|
|
1885
|
+
return MatrixRowRef(self, row)
|
|
1886
|
+
|
|
1887
|
+
def col_anchor(self, col: int) -> "MatrixColRef":
|
|
1888
|
+
from .constraints import MatrixColRef
|
|
1889
|
+
|
|
1890
|
+
return MatrixColRef(self, col)
|
|
1891
|
+
|
|
1892
|
+
def cell_anchor(self, row: int, col: int) -> "MatrixCellRef":
|
|
1893
|
+
from .constraints import MatrixCellRef
|
|
1894
|
+
|
|
1895
|
+
return MatrixCellRef(self, row, col)
|
|
1896
|
+
|
|
1897
|
+
def resolve_cell_range_port(
|
|
1898
|
+
self, r0: int, r1: int, side: str = "right"
|
|
1899
|
+
) -> Tuple[float, float, str]:
|
|
1900
|
+
"""Midpoint of an inclusive row range on the given grid side."""
|
|
1901
|
+
r0 = max(0, min(self.rows - 1, int(r0)))
|
|
1902
|
+
r1 = max(0, min(self.rows - 1, int(r1)))
|
|
1903
|
+
if r1 < r0:
|
|
1904
|
+
r0, r1 = r1, r0
|
|
1905
|
+
y0 = self._row_center_y(r0)
|
|
1906
|
+
y1 = self._row_center_y(r1)
|
|
1907
|
+
cy = (y0 + y1) / 2.0
|
|
1908
|
+
gx, gy = self._grid_origin()
|
|
1909
|
+
gw, gh = self._grid_size()
|
|
1910
|
+
side = side or "right"
|
|
1911
|
+
if side == "left":
|
|
1912
|
+
return gx, cy, "left"
|
|
1913
|
+
if side == "right":
|
|
1914
|
+
return gx + gw, cy, "right"
|
|
1915
|
+
if side == "top":
|
|
1916
|
+
return gx + gw / 2, min(y0, y1) - self._row_h(r0) / 2, "up"
|
|
1917
|
+
if side == "bottom":
|
|
1918
|
+
return gx + gw / 2, max(y0, y1) + self._row_h(r1) / 2, "down"
|
|
1919
|
+
return gx + gw / 2, cy, "right"
|
|
1920
|
+
|
|
1921
|
+
def cell_range_port(self, r0: int, r1: int, side: str = "right") -> "Port":
|
|
1922
|
+
"""Anchor at the vertical midpoint of rows [r0, r1] on `side`."""
|
|
1923
|
+
from .edges import Port
|
|
1924
|
+
|
|
1925
|
+
return Port(self, kind="cell_range", side=side, index=r0, end_index=r1)
|
|
1926
|
+
|
|
1927
|
+
def legend_entries(self) -> List[Any]:
|
|
1928
|
+
"""Keys for an auto Legend: ``legend={colour: label | Key}``, ``legend="label"``,
|
|
1929
|
+
a ``Key`` or a list of Keys."""
|
|
1930
|
+
from .legend import Key
|
|
1931
|
+
|
|
1932
|
+
lg = self.legend
|
|
1933
|
+
if not lg:
|
|
1934
|
+
return []
|
|
1935
|
+
if isinstance(lg, str):
|
|
1936
|
+
return [Key(lg, fill=self.fill, stroke=self.stroke, border=self.border)]
|
|
1937
|
+
if isinstance(lg, Key):
|
|
1938
|
+
return [lg]
|
|
1939
|
+
if isinstance(lg, (list, tuple)):
|
|
1940
|
+
return [k for k in lg if isinstance(k, Key)]
|
|
1941
|
+
return [lab if isinstance(lab, Key) else Key(lab, fill=col, stroke=self.stroke)
|
|
1942
|
+
for col, lab in dict(lg).items()]
|
|
1943
|
+
|
|
1944
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
1945
|
+
from .render_svg import draw_mark, draw_rect, draw_text
|
|
1946
|
+
from .styles import border_dasharray
|
|
1947
|
+
|
|
1948
|
+
g = self.geom
|
|
1949
|
+
th = self.theme
|
|
1950
|
+
l, t, r, b = self._label_space()
|
|
1951
|
+
gw, gh = self._grid_size()
|
|
1952
|
+
# Center grid inside geom accounting for labels
|
|
1953
|
+
gx = g.x + ox + l + max(0, (g.w - l - r - gw) / 2)
|
|
1954
|
+
gy = g.y + oy + t + max(0, (g.h - t - b - gh) / 2)
|
|
1955
|
+
fs = self._cell_fs()
|
|
1956
|
+
|
|
1957
|
+
for i in range(self.rows):
|
|
1958
|
+
for j in range(self.cols):
|
|
1959
|
+
cx = gx + self._col_x(j)
|
|
1960
|
+
cy = gy + self._row_y(i)
|
|
1961
|
+
cw, ch = self._col_w(j), self._row_h(i)
|
|
1962
|
+
st = self._style(i, j)
|
|
1963
|
+
hi = i in self.highlight_rows or j in self.highlight_cols
|
|
1964
|
+
sw = self.highlight_width if hi else float(st.get("stroke_width", self.cell_stroke_width))
|
|
1965
|
+
stroke = self.highlight_stroke if hi else st.get("stroke", self.stroke)
|
|
1966
|
+
bd = self.border_at(i, j)
|
|
1967
|
+
if bd == "none" or (self.continuous and not hi):
|
|
1968
|
+
stroke = "none"
|
|
1969
|
+
fill = self.fill_at(i, j)
|
|
1970
|
+
draw_rect(
|
|
1971
|
+
buf,
|
|
1972
|
+
cx,
|
|
1973
|
+
cy,
|
|
1974
|
+
cw,
|
|
1975
|
+
ch,
|
|
1976
|
+
fill=fill,
|
|
1977
|
+
stroke=stroke,
|
|
1978
|
+
stroke_width=sw,
|
|
1979
|
+
radius=self.cell_radius,
|
|
1980
|
+
dasharray=border_dasharray(bd, sw) if bd not in (None, "none") else None,
|
|
1981
|
+
)
|
|
1982
|
+
mark = self.mark_at(i, j)
|
|
1983
|
+
if mark:
|
|
1984
|
+
mc = st.get("mark_color") or self.mark_color or (
|
|
1985
|
+
"#ffffff" if _luma(fill) < 0.45 else th.text_color)
|
|
1986
|
+
draw_mark(buf, mark, cx + cw / 2, cy + ch / 2, min(cw, ch) * 0.62, color=mc)
|
|
1987
|
+
lab = self.label_at(i, j)
|
|
1988
|
+
if lab and not mark:
|
|
1989
|
+
weight = st.get("weight") or (
|
|
1990
|
+
self.header_weight if self.is_header(i, j) else self.cell_weight)
|
|
1991
|
+
color = st.get("text_color") or (
|
|
1992
|
+
self.cell_text_color if (self.heat is None or self.heat_at(i, j) is None
|
|
1993
|
+
or _luma(fill) >= 0.5) else "#ffffff")
|
|
1994
|
+
full = lab
|
|
1995
|
+
lab, tw, lfs = _fit_cell_label(lab, cw, ch, fs, self.cell_mono, weight, th)
|
|
1996
|
+
if lab != full:
|
|
1997
|
+
from .notices import note
|
|
1998
|
+
|
|
1999
|
+
note(self, full, f"cut to '{lab}' to fit its cell (widen cell_w= or "
|
|
2000
|
+
"use cell_w='content')")
|
|
2001
|
+
if self.cell_font_size is None and self.cell_w is None and self.cell_h is None \
|
|
2002
|
+
and lfs == fs:
|
|
2003
|
+
ty = cy + ch * 0.72
|
|
2004
|
+
else:
|
|
2005
|
+
ty = cy + ch / 2 + lfs * 0.36
|
|
2006
|
+
draw_text(buf, lab, cx + (cw - tw) / 2, ty, font_size=lfs, fill=color,
|
|
2007
|
+
weight=weight, mono=self.cell_mono,
|
|
2008
|
+
family=th.mono_family if self.cell_mono else None)
|
|
2009
|
+
|
|
2010
|
+
for spec in self.outlines:
|
|
2011
|
+
r0, c0, r1, c1 = (int(v) for v in spec[:4])
|
|
2012
|
+
col = spec[4] if len(spec) > 4 else (self.outline_color or self.highlight_stroke)
|
|
2013
|
+
x0 = gx + self._col_x(min(c0, c1))
|
|
2014
|
+
y0 = gy + self._row_y(min(r0, r1))
|
|
2015
|
+
x1 = gx + self._col_x(max(c0, c1)) + self._col_w(max(c0, c1))
|
|
2016
|
+
y1 = gy + self._row_y(max(r0, r1)) + self._row_h(max(r0, r1))
|
|
2017
|
+
p = self.gap / 2 + self.outline_width / 2
|
|
2018
|
+
draw_rect(buf, x0 - p, y0 - p, x1 - x0 + 2 * p, y1 - y0 + 2 * p, fill="none",
|
|
2019
|
+
stroke=col, stroke_width=self.outline_width, radius=self.cell_radius + self.theme.L(1),
|
|
2020
|
+
dasharray=border_dasharray(self.outline_border, self.outline_width))
|
|
2021
|
+
|
|
2022
|
+
if self.continuous:
|
|
2023
|
+
draw_rect(buf, gx, gy, gw, gh, fill="none", stroke=self.stroke,
|
|
2024
|
+
stroke_width=self.cell_stroke_width, radius=0)
|
|
2025
|
+
if self.split_style != "gap" and (self.row_splits or self.col_splits):
|
|
2026
|
+
from .render_svg import draw_line
|
|
2027
|
+
|
|
2028
|
+
da = {"line": None, "dashed": "3,2", "dotted": "1,1.5"}[self.split_style]
|
|
2029
|
+
ssw = self.cell_stroke_width
|
|
2030
|
+
for k in self.row_splits:
|
|
2031
|
+
y = gy + self._row_y(k) - (self.gap + self._split_w()) / 2
|
|
2032
|
+
draw_line(buf, gx, y, gx + gw, y, stroke=th.muted_color, stroke_width=ssw, dasharray=da)
|
|
2033
|
+
for k in self.col_splits:
|
|
2034
|
+
x = gx + self._col_x(k) - (self.gap + self._split_w()) / 2
|
|
2035
|
+
draw_line(buf, x, gy, x, gy + gh, stroke=th.muted_color, stroke_width=ssw, dasharray=da)
|
|
2036
|
+
|
|
2037
|
+
lfs = th.font_size * 0.9
|
|
2038
|
+
if self.row_labels:
|
|
2039
|
+
for i, lab in enumerate(self.row_labels):
|
|
2040
|
+
if i >= self.rows:
|
|
2041
|
+
break
|
|
2042
|
+
tw, _ = measure_text(lab, lfs)
|
|
2043
|
+
cy = gy + self._row_y(i) + self._row_h(i) / 2 + lfs * 0.36
|
|
2044
|
+
draw_text(buf, lab, gx - self.label_gap - tw, cy, font_size=lfs)
|
|
2045
|
+
|
|
2046
|
+
if self.col_labels:
|
|
2047
|
+
for j, lab in enumerate(self.col_labels):
|
|
2048
|
+
if j >= self.cols:
|
|
2049
|
+
break
|
|
2050
|
+
tw, _ = measure_text(lab, lfs)
|
|
2051
|
+
cx = gx + self._col_x(j) + (self._col_w(j) - tw) / 2
|
|
2052
|
+
draw_text(buf, lab, cx, gy - self.label_gap * 0.5, font_size=lfs)
|
|
2053
|
+
|
|
2054
|
+
if self.colorbar and self.heat is not None:
|
|
2055
|
+
self._draw_colorbar(buf, gx, gy, gw, gh, l, r, b)
|
|
2056
|
+
|
|
2057
|
+
if self.label:
|
|
2058
|
+
lfs = self._label_fs()
|
|
2059
|
+
tw, _ = measure_text(self.label, lfs, weight=self.label_weight)
|
|
2060
|
+
cg = self.label_gap * 0.4
|
|
2061
|
+
if self.label_pos == "top-right":
|
|
2062
|
+
draw_text(buf, self.label, gx + gw + cg, gy + lfs * 0.8,
|
|
2063
|
+
font_size=lfs, weight=self.label_weight)
|
|
2064
|
+
elif self.label_pos == "top-left":
|
|
2065
|
+
draw_text(buf, self.label, gx - cg - tw, gy + lfs * 0.8,
|
|
2066
|
+
font_size=lfs, weight=self.label_weight)
|
|
2067
|
+
elif self.label_pos == "bottom-right":
|
|
2068
|
+
draw_text(buf, self.label, gx + gw + cg, gy + gh + lfs * 0.35,
|
|
2069
|
+
font_size=lfs, weight=self.label_weight)
|
|
2070
|
+
elif self.label_pos == "left":
|
|
2071
|
+
draw_text(buf, self.label, gx - self.label_gap - tw, gy + gh / 2 + lfs / 3,
|
|
2072
|
+
font_size=lfs, weight=self.label_weight)
|
|
2073
|
+
elif self.label_pos == "right":
|
|
2074
|
+
draw_text(buf, self.label, gx + gw + self.label_gap, gy + gh / 2 + lfs / 3,
|
|
2075
|
+
font_size=lfs, weight=self.label_weight)
|
|
2076
|
+
elif self.label_pos == "top":
|
|
2077
|
+
draw_text(buf, self.label, gx + (gw - tw) / 2, gy - self.label_gap,
|
|
2078
|
+
font_size=lfs, weight=self.label_weight)
|
|
2079
|
+
else:
|
|
2080
|
+
draw_text(buf, self.label, gx + (gw - tw) / 2,
|
|
2081
|
+
gy + gh + self.label_gap + lfs * 0.85,
|
|
2082
|
+
font_size=lfs, weight=self.label_weight)
|
|
2083
|
+
|
|
2084
|
+
def _draw_colorbar(self, buf, gx, gy, gw, gh, l, r, b) -> None:
|
|
2085
|
+
from .render_svg import draw_rect, draw_text
|
|
2086
|
+
|
|
2087
|
+
th = self.theme
|
|
2088
|
+
L, SW = th.L, th.SW
|
|
2089
|
+
fs = th.small_size if th.font_sizes else th.font_size * 0.75
|
|
2090
|
+
n = 24
|
|
2091
|
+
ticks = self._colorbar_tick_values()
|
|
2092
|
+
lo, hi = float(self.vmin), float(self.vmax)
|
|
2093
|
+
span = (hi - lo) or 1.0
|
|
2094
|
+
if self.colorbar == "right":
|
|
2095
|
+
extra = 0.0
|
|
2096
|
+
if self.label and self.label_pos == "right":
|
|
2097
|
+
extra = measure_text(self.label, self.label_size or self.theme.font_size,
|
|
2098
|
+
weight=self.label_weight)[0] + self.label_gap
|
|
2099
|
+
bx, by, bw, bh = gx + gw + extra + L(8), gy, L(10.0), gh
|
|
2100
|
+
for k in range(n): # top = vmax
|
|
2101
|
+
draw_rect(buf, bx, by + bh * k / n, bw, bh / n + 0.4,
|
|
2102
|
+
fill=cmap_color(self.cmap, 1 - (k + 0.5) / n), stroke="none",
|
|
2103
|
+
stroke_width=0)
|
|
2104
|
+
draw_rect(buf, bx, by, bw, bh, fill="none", stroke="#444", stroke_width=SW(0.8))
|
|
2105
|
+
for tv in ticks:
|
|
2106
|
+
ty = by + bh * (1 - (tv - lo) / span)
|
|
2107
|
+
buf.append(f'<line x1="{bx + bw:.2f}" y1="{ty:.2f}" x2="{bx + bw + L(2):.2f}" '
|
|
2108
|
+
f'y2="{ty:.2f}" stroke="#444" stroke-width="{SW(0.8):g}" />')
|
|
2109
|
+
draw_text(buf, self.colorbar_format.format(tv), bx + bw + L(3), ty + fs * 0.35,
|
|
2110
|
+
font_size=fs, fill="#333333")
|
|
2111
|
+
if self.colorbar_label:
|
|
2112
|
+
tw_ = max(measure_text(self.colorbar_format.format(t), fs)[0] for t in ticks)
|
|
2113
|
+
lw_, _ = measure_text(self.colorbar_label, fs)
|
|
2114
|
+
lx = bx + bw + L(3) + tw_ + L(4) + fs * 0.5
|
|
2115
|
+
ly = by + bh / 2
|
|
2116
|
+
buf.append(f'<g transform="rotate(90 {lx:.2f} {ly:.2f})">')
|
|
2117
|
+
draw_text(buf, self.colorbar_label, lx - lw_ / 2, ly + fs * 0.35, font_size=fs,
|
|
2118
|
+
fill="#333333")
|
|
2119
|
+
buf.append("</g>")
|
|
2120
|
+
else:
|
|
2121
|
+
extra = 0.0
|
|
2122
|
+
if self.label and self.label_pos == "bottom":
|
|
2123
|
+
extra = measure_text(self.label, self.label_size or self.theme.font_size)[1] + self.label_gap
|
|
2124
|
+
bx, by, bw, bh = gx, gy + gh + extra + L(8), gw, L(8.0)
|
|
2125
|
+
for k in range(n):
|
|
2126
|
+
draw_rect(buf, bx + bw * k / n, by, bw / n + 0.4, bh,
|
|
2127
|
+
fill=cmap_color(self.cmap, (k + 0.5) / n), stroke="none", stroke_width=0)
|
|
2128
|
+
draw_rect(buf, bx, by, bw, bh, fill="none", stroke="#444", stroke_width=SW(0.8))
|
|
2129
|
+
for tv in ticks:
|
|
2130
|
+
tx = bx + bw * (tv - lo) / span
|
|
2131
|
+
lab = self.colorbar_format.format(tv)
|
|
2132
|
+
tw_, _ = measure_text(lab, fs)
|
|
2133
|
+
draw_text(buf, lab, tx - tw_ / 2, by + bh + L(3) + fs * 0.85, font_size=fs,
|
|
2134
|
+
fill="#333333")
|
|
2135
|
+
if self.colorbar_label:
|
|
2136
|
+
lw_, _ = measure_text(self.colorbar_label, fs)
|
|
2137
|
+
draw_text(buf, self.colorbar_label, bx + (bw - lw_) / 2,
|
|
2138
|
+
by + bh + L(3) + fs * 2.1, font_size=fs, fill="#333333")
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def _fit_cell_label(lab: str, cw: float, ch: float, fs: float, mono: bool, weight: str,
|
|
2142
|
+
th: Any) -> Tuple[str, float, float]:
|
|
2143
|
+
"""v0.11.0: make a cell label fit its cell (``(label, width, font size)``).
|
|
2144
|
+
|
|
2145
|
+
A label wider than the cell, or taller than the row (shared tracks squeeze cells
|
|
2146
|
+
in both directions: ``align_columns`` narrows columns, ``align_rows`` shortens
|
|
2147
|
+
rows, and square cells follow the other axis), first shrinks to at most 75 % of
|
|
2148
|
+
the cell font, then is cut with "…". Numbers (``0.91``, ``12 %``, ``4.2B``) are
|
|
2149
|
+
never cut: they shrink to 60 % instead. Labels that fit are untouched."""
|
|
2150
|
+
lfs = fs
|
|
2151
|
+
if ch > 0 and lfs * 1.05 > ch:
|
|
2152
|
+
lfs = max(fs * 0.75, ch / 1.05)
|
|
2153
|
+
tw = measure_text(lab, lfs, mono=mono, weight=weight)[0]
|
|
2154
|
+
room = cw - th.L(1.0)
|
|
2155
|
+
if tw > cw - th.L(0.5) and room > 0:
|
|
2156
|
+
numeric = _NUMERIC.match(lab.strip()) is not None
|
|
2157
|
+
floor = fs * (0.6 if numeric else 0.75) # "0.…" would hide the value
|
|
2158
|
+
lfs = max(floor, min(lfs, lfs * room / max(tw, 1e-6)))
|
|
2159
|
+
if numeric:
|
|
2160
|
+
tw = measure_text(lab, lfs, mono=mono, weight=weight)[0]
|
|
2161
|
+
else:
|
|
2162
|
+
lab, tw = _ellipsize(lab, room, lfs, mono, weight)
|
|
2163
|
+
return lab, tw, lfs
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
_NUMERIC = re.compile(r"^[-+−]?[$€£]?\d[\d.,]*\s?[%kKMGBTx×]?$")
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _ellipsize(lab: str, room: float, fs: float, mono: bool, weight: str) -> Tuple[str, float]:
|
|
2170
|
+
"""v0.10.0: cut ``lab`` to fit ``room`` pt, ending in "…" (whole label if it fits)."""
|
|
2171
|
+
tw = measure_text(lab, fs, mono=mono, weight=weight)[0]
|
|
2172
|
+
if tw <= room:
|
|
2173
|
+
return lab, tw
|
|
2174
|
+
for k in range(len(lab) - 1, 0, -1):
|
|
2175
|
+
cand = lab[:k].rstrip() + "…"
|
|
2176
|
+
tw = measure_text(cand, fs, mono=mono, weight=weight)[0]
|
|
2177
|
+
if tw <= room:
|
|
2178
|
+
return cand, tw
|
|
2179
|
+
return "…", measure_text("…", fs, mono=mono, weight=weight)[0]
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
class TokenStrip(Matrix):
|
|
2183
|
+
"""A row of token cells sized to their text — vLLM / StreamingLLM token rows.
|
|
2184
|
+
|
|
2185
|
+
``TokenStrip(["Four", "score", "and"], fills={0: "#fde"}, marks=…)``; any Matrix
|
|
2186
|
+
option (``heat=``, ``outlines=``, ``label=``…) also works.
|
|
2187
|
+
``fills`` / ``marks`` / ``borders`` take a list or ``{index: value}``;
|
|
2188
|
+
``cell_border=`` is accepted as an alias of ``borders=`` (v0.13.0).
|
|
2189
|
+
``orientation="vertical"`` (v0.13.0) stacks the tokens top-to-bottom (a token
|
|
2190
|
+
*column*); ``token_port`` / ``token_span`` follow the orientation.
|
|
2191
|
+
An empty token (``""`` / ``None``) is as wide as a typical token in the strip
|
|
2192
|
+
(the median non-empty width) instead of a sliver; ``cell_w=[...]`` sets widths
|
|
2193
|
+
exactly.
|
|
2194
|
+
"""
|
|
2195
|
+
|
|
2196
|
+
def __init__(self, tokens: Sequence[Optional[str]], *, fills: Any = None, marks: Any = None,
|
|
2197
|
+
borders: Any = None, cell_h: Any = None, gap: float = 0.0,
|
|
2198
|
+
cell_radius: float = 0.0, fill: str = "#ffffff",
|
|
2199
|
+
orientation: str = "horizontal", **kwargs: Any) -> None:
|
|
2200
|
+
toks = ["" if t is None else str(t) for t in tokens]
|
|
2201
|
+
from ._validate import check_enum
|
|
2202
|
+
orientation = check_enum("primitives(orientation)", orientation, ("horizontal", "vertical"))
|
|
2203
|
+
if "cell_border" in kwargs:
|
|
2204
|
+
if borders is not None:
|
|
2205
|
+
raise TypeError("TokenStrip: pass borders= or its alias cell_border=, not both")
|
|
2206
|
+
borders = kwargs.pop("cell_border")
|
|
2207
|
+
vertical = orientation == "vertical"
|
|
2208
|
+
self.orientation = orientation
|
|
2209
|
+
|
|
2210
|
+
def per_index(spec: Any) -> Any:
|
|
2211
|
+
if spec is None or callable(spec):
|
|
2212
|
+
return spec
|
|
2213
|
+
if isinstance(spec, (str, tuple)): # one value for every token
|
|
2214
|
+
return lambda r, c, _v=spec: _v
|
|
2215
|
+
if isinstance(spec, dict):
|
|
2216
|
+
return {((int(k), 0) if vertical else (0, int(k))): v for k, v in spec.items()}
|
|
2217
|
+
return [[v] for v in spec] if vertical else [list(spec)]
|
|
2218
|
+
|
|
2219
|
+
kwargs.setdefault("cell_w", "content")
|
|
2220
|
+
if cell_h is None:
|
|
2221
|
+
cell_h = "content"
|
|
2222
|
+
n = len(toks)
|
|
2223
|
+
rows, cols = (n, 1) if vertical else (1, n)
|
|
2224
|
+
labels = [[t] for t in toks] if vertical else [toks]
|
|
2225
|
+
super().__init__(rows, cols, cell_labels=labels, cell_fill=per_index(fills),
|
|
2226
|
+
cell_marks=per_index(marks), cell_border=per_index(borders),
|
|
2227
|
+
cell_h=cell_h, gap=gap, cell_radius=cell_radius, fill=fill, **kwargs)
|
|
2228
|
+
self.tokens = toks
|
|
2229
|
+
|
|
2230
|
+
def _col_ws(self) -> List[float]:
|
|
2231
|
+
out = super()._col_ws()
|
|
2232
|
+
if self.orientation == "horizontal" and self.cell_w == "content" \
|
|
2233
|
+
and self.__dict__.get("_track") is None:
|
|
2234
|
+
full = sorted(w for w, t in zip(out, self.tokens) if t.strip())
|
|
2235
|
+
if full and len(full) < len(out):
|
|
2236
|
+
med = full[len(full) // 2]
|
|
2237
|
+
out = [max(w, med) if not t.strip() else w for w, t in zip(out, self.tokens)]
|
|
2238
|
+
elif not full:
|
|
2239
|
+
h = self._row_hs()[0] if self.rows else 0.0
|
|
2240
|
+
out = [max(w, h * 1.5) for w in out]
|
|
2241
|
+
return out
|
|
2242
|
+
|
|
2243
|
+
def _idx(self, i: int) -> Tuple[int, int]:
|
|
2244
|
+
return (i, 0) if self.orientation == "vertical" else (0, i)
|
|
2245
|
+
|
|
2246
|
+
def token_port(self, i: int, side: Optional[str] = None) -> "Port":
|
|
2247
|
+
if side is None:
|
|
2248
|
+
side = "left" if self.orientation == "vertical" else "top"
|
|
2249
|
+
r, c = self._idx(i)
|
|
2250
|
+
return self.cell_port(r, c, side)
|
|
2251
|
+
|
|
2252
|
+
def token_anchor(self, i: int) -> "MatrixCellRef":
|
|
2253
|
+
r, c = self._idx(i)
|
|
2254
|
+
return self.cell_anchor(r, c)
|
|
2255
|
+
|
|
2256
|
+
def token_span(self, i: int, j: int) -> Tuple[Any, Any]:
|
|
2257
|
+
"""Anchor pair covering tokens ``i``..``j`` (for ``Brace(span=)`` / ``Dim``)."""
|
|
2258
|
+
if self.orientation == "vertical":
|
|
2259
|
+
return self.row_span(i, j)
|
|
2260
|
+
return self.col_span(i, j)
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
class Table(Matrix):
|
|
2264
|
+
"""Data table with header row / column: ``Table([["", "A", "B"], ["x", 1, 2]],
|
|
2265
|
+
header_rows=1, header_cols=1)``. Cells are sized to their content; ``True`` /
|
|
2266
|
+
``False`` cells render as ✓ / ✗ marks (``marks=False`` keeps them as text)."""
|
|
2267
|
+
|
|
2268
|
+
def _cell_fs(self) -> float:
|
|
2269
|
+
if self.cell_font_size is not None:
|
|
2270
|
+
return float(self.cell_font_size)
|
|
2271
|
+
return self.theme.F(11.0)
|
|
2272
|
+
|
|
2273
|
+
def __init__(self, data: Sequence[Sequence[Any]], *, header_rows: int = 1,
|
|
2274
|
+
header_cols: int = 0, marks: bool = True, gap: float = 0.0,
|
|
2275
|
+
cell_radius: float = 0.0, fill: str = "#ffffff",
|
|
2276
|
+
cell_font_size: Optional[float] = None, **kwargs: Any) -> None:
|
|
2277
|
+
rows = [list(r) for r in data]
|
|
2278
|
+
n_rows, n_cols = len(rows), max((len(r) for r in rows), default=0)
|
|
2279
|
+
if not n_rows or not n_cols:
|
|
2280
|
+
raise ValueError("Table needs at least one non-empty row")
|
|
2281
|
+
labels = [[("" if v is None or isinstance(v, bool) and marks else str(v))
|
|
2282
|
+
for v in r] + [""] * (n_cols - len(r)) for r in rows]
|
|
2283
|
+
mark_map = {(i, j): ("check" if v else "cross") for i, r in enumerate(rows)
|
|
2284
|
+
for j, v in enumerate(r) if isinstance(v, bool) and marks}
|
|
2285
|
+
kwargs.setdefault("cell_w", "content")
|
|
2286
|
+
kwargs.setdefault("cell_h", "content")
|
|
2287
|
+
kwargs.setdefault("cell_pad", 6.0)
|
|
2288
|
+
kwargs.setdefault("cell_stroke_width", 0.8)
|
|
2289
|
+
mark_colors = {}
|
|
2290
|
+
user_style = kwargs.pop("cell_style", None)
|
|
2291
|
+
|
|
2292
|
+
def style(r: int, c: int) -> dict:
|
|
2293
|
+
st = dict(_cell_lookup(user_style, r, c, n_rows, n_cols) or {})
|
|
2294
|
+
if (r, c) in mark_map and "mark_color" not in st:
|
|
2295
|
+
st["mark_color"] = "#1e8449" if mark_map[(r, c)] == "check" else "#c0392b"
|
|
2296
|
+
return st
|
|
2297
|
+
|
|
2298
|
+
super().__init__(n_rows, n_cols, cell_labels=labels, cell_marks=mark_map or None,
|
|
2299
|
+
header_rows=header_rows, header_cols=header_cols, gap=gap,
|
|
2300
|
+
cell_radius=cell_radius, fill=fill, cell_style=style,
|
|
2301
|
+
cell_font_size=cell_font_size,
|
|
2302
|
+
**kwargs)
|
|
2303
|
+
self.data = rows
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
class Group(Container):
|
|
2307
|
+
"""Nested titled container with border — for Transformer blocks, attention heads, etc.
|
|
2308
|
+
|
|
2309
|
+
Defaults come from the theme: ``fill`` = ``theme.group_fill`` (``fill=None`` → no
|
|
2310
|
+
fill), ``stroke`` / ``stroke_width`` = the theme's group stroke, ``pad`` ≈ 10 px
|
|
2311
|
+
classic / 5.5 pt paper.
|
|
2312
|
+
|
|
2313
|
+
``border=`` (every node takes it): ``"solid"`` (default) | ``"dashed"`` | ``"dotted"`` |
|
|
2314
|
+
``"dashdot"`` | ``"none"`` | a dash pattern ``"4 2"`` / ``(4, 2)`` — e.g. a dashed
|
|
2315
|
+
"optional stage" frame: ``Group([...], title="cache", border="dashed")``.
|
|
2316
|
+
``pad=`` is a number, ``(vertical, horizontal)`` or ``(top, right, bottom, left)`` (CSS
|
|
2317
|
+
order). ``title=`` uses the theme *title* role (8 pt paper); a ``Text`` with the same
|
|
2318
|
+
``font_size`` and ``**bold**`` looks as large, so keep inner labels at 7 pt.
|
|
2319
|
+
|
|
2320
|
+
``title_pos=``: ``"top"`` (default, above the frame) | ``"bottom"`` | ``"inside-top"``;
|
|
2321
|
+
``title_align=``: ``"center"`` (default) | ``"left"`` | ``"right"`` — or both at once,
|
|
2322
|
+
``title_pos="top-left"`` (v0.13.0; also ``"bottom-right"``, ``"inside-top-left"`` …).
|
|
2323
|
+
The same two options work on every container (``Row``/``Col``/``Stack``).
|
|
2324
|
+
"""
|
|
2325
|
+
|
|
2326
|
+
fill = theme_default("group_fill")
|
|
2327
|
+
stroke = theme_default("stroke")
|
|
2328
|
+
stroke_width = theme_default(lambda th: th.group_stroke or th.stroke_width)
|
|
2329
|
+
|
|
2330
|
+
@property
|
|
2331
|
+
def padding(self): # type: ignore[override]
|
|
2332
|
+
raw = self.__dict__.get("_gpad")
|
|
2333
|
+
if raw is None:
|
|
2334
|
+
return normalize_pad(self.theme.L(10.0) * self.theme.pad_scale)
|
|
2335
|
+
return normalize_pad(raw)
|
|
2336
|
+
|
|
2337
|
+
@padding.setter
|
|
2338
|
+
def padding(self, v) -> None:
|
|
2339
|
+
self.__dict__["_gpad_set"] = v
|
|
2340
|
+
|
|
2341
|
+
@property
|
|
2342
|
+
def pad(self): # type: ignore[override]
|
|
2343
|
+
return sum(self.padding) / 4.0
|
|
2344
|
+
|
|
2345
|
+
@pad.setter
|
|
2346
|
+
def pad(self, v) -> None:
|
|
2347
|
+
pass
|
|
2348
|
+
|
|
2349
|
+
def __init__(
|
|
2350
|
+
self,
|
|
2351
|
+
kids: Optional[Sequence[Node]] = None,
|
|
2352
|
+
*,
|
|
2353
|
+
title: Optional[str] = None,
|
|
2354
|
+
title_pos: str = "top",
|
|
2355
|
+
fill: Optional[str] = "auto",
|
|
2356
|
+
stroke: Optional[str] = None,
|
|
2357
|
+
rounded: bool = True,
|
|
2358
|
+
pad: Any = None,
|
|
2359
|
+
gap: Optional[float] = None,
|
|
2360
|
+
direction: str = "column",
|
|
2361
|
+
align: str = "center",
|
|
2362
|
+
justify: str = "start",
|
|
2363
|
+
title_gap: Optional[float] = None,
|
|
2364
|
+
stroke_width: Optional[float] = None,
|
|
2365
|
+
title_align: str = "center",
|
|
2366
|
+
**kwargs: Any,
|
|
2367
|
+
) -> None:
|
|
2368
|
+
self.__dict__["_gpad"] = kwargs.pop("padding", None) if pad is None else pad
|
|
2369
|
+
kwargs["title_align"] = title_align
|
|
2370
|
+
super().__init__(
|
|
2371
|
+
kids,
|
|
2372
|
+
direction=direction,
|
|
2373
|
+
gap=gap,
|
|
2374
|
+
pad=0.0,
|
|
2375
|
+
align=align,
|
|
2376
|
+
justify=justify,
|
|
2377
|
+
title_gap=title_gap,
|
|
2378
|
+
fill=None if fill == "auto" else (fill or "none"),
|
|
2379
|
+
stroke=stroke,
|
|
2380
|
+
stroke_width=stroke_width,
|
|
2381
|
+
rounded=rounded,
|
|
2382
|
+
title=title,
|
|
2383
|
+
title_pos=title_pos,
|
|
2384
|
+
**kwargs,
|
|
2385
|
+
)
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
class Brace(Node):
|
|
2390
|
+
"""
|
|
2391
|
+
Tall curly / square / round brace — for grouping (e.g. panel (d) o_i).
|
|
2392
|
+
|
|
2393
|
+
Place beside content in a Row/Col. ``side`` is the direction the brace **tip**
|
|
2394
|
+
points: ``side="left"`` draws ``{`` (tip on the left, arms open to the right),
|
|
2395
|
+
``"right"`` draws ``}``, ``"top"`` / ``"bottom"`` the horizontal versions.
|
|
2396
|
+
Content the brace groups goes on the arms side; the tip points away from it.
|
|
2397
|
+
|
|
2398
|
+
``label``: drawn on the arms side by default (``label_at="open"``, between the
|
|
2399
|
+
brace and whatever follows it); ``label_at="tip"`` puts it beyond the tip — the
|
|
2400
|
+
usual "} label" annotation: ``Row([content, Brace("6 layers", side="right",
|
|
2401
|
+
label_at="tip")])``. ``span=(a, b)`` draws the brace over a sub-range only:
|
|
2402
|
+
two anchors, e.g. ``m.row_span(0, 2)`` / ``m.col_span(1, 3)`` on a Matrix or
|
|
2403
|
+
``strip.token_span(2, 5)`` on a TokenStrip.
|
|
2404
|
+
"""
|
|
2405
|
+
|
|
2406
|
+
strip = themed(28.0)
|
|
2407
|
+
|
|
2408
|
+
def __init__(
|
|
2409
|
+
self,
|
|
2410
|
+
label: Optional[str] = None,
|
|
2411
|
+
*,
|
|
2412
|
+
side: str = "left", # left|right|top|bottom — open toward content
|
|
2413
|
+
style: str = "curly", # curly|square|round
|
|
2414
|
+
strip: Optional[float] = None,
|
|
2415
|
+
stroke: Optional[str] = None,
|
|
2416
|
+
stroke_width: Optional[float] = None,
|
|
2417
|
+
label_gap: Optional[float] = None,
|
|
2418
|
+
grow: Optional[float] = None,
|
|
2419
|
+
span: Optional[Tuple[Any, Any]] = None,
|
|
2420
|
+
font_size: Optional[float] = None,
|
|
2421
|
+
weight: str = "bold",
|
|
2422
|
+
label_color: Optional[str] = None,
|
|
2423
|
+
label_at: str = "open",
|
|
2424
|
+
**kwargs: Any,
|
|
2425
|
+
) -> None:
|
|
2426
|
+
# v0.13.0: a span brace is drawn over its span, so its layout slot stays small
|
|
2427
|
+
# along the brace (it no longer stretches a Col / pushes captions down)
|
|
2428
|
+
kwargs.setdefault("grow", (0.0 if span is not None else 1.0) if grow is None else grow)
|
|
2429
|
+
super().__init__(**kwargs)
|
|
2430
|
+
from ._validate import check_enum
|
|
2431
|
+
side = check_enum("primitives(side)", side, ("left", "right", "top", "bottom"))
|
|
2432
|
+
from ._validate import check_enum
|
|
2433
|
+
label_at = check_enum("primitives(label_at)", label_at, ("open", "tip"))
|
|
2434
|
+
self.label_at = label_at
|
|
2435
|
+
if span is not None and len(span) != 2:
|
|
2436
|
+
raise ValueError("Brace span must be a pair of anchors")
|
|
2437
|
+
self.span = span
|
|
2438
|
+
self.font_size = font_size
|
|
2439
|
+
self.weight = weight
|
|
2440
|
+
self.label_color = label_color
|
|
2441
|
+
self.label = label
|
|
2442
|
+
self.side = side
|
|
2443
|
+
self.style = style
|
|
2444
|
+
self.strip = strip
|
|
2445
|
+
self.stroke = stroke
|
|
2446
|
+
self.sw = stroke_width
|
|
2447
|
+
self.label_gap = label_gap
|
|
2448
|
+
|
|
2449
|
+
def _fs(self) -> float:
|
|
2450
|
+
return self.font_size if self.font_size is not None else self.theme.font_size
|
|
2451
|
+
|
|
2452
|
+
def _span(self) -> Optional[Tuple[float, float]]:
|
|
2453
|
+
"""Page-space (lo, hi) of ``span`` along the brace axis, clamped to geom."""
|
|
2454
|
+
if self.span is None:
|
|
2455
|
+
return None
|
|
2456
|
+
from .constraints import as_anchor
|
|
2457
|
+
|
|
2458
|
+
axis = "x" if self.side in ("top", "bottom") else "y"
|
|
2459
|
+
a = as_anchor(self.span[0], axis, "start").value()
|
|
2460
|
+
b = as_anchor(self.span[1], axis, "end").value()
|
|
2461
|
+
lo, hi = min(a, b), max(a, b)
|
|
2462
|
+
return lo, hi
|
|
2463
|
+
|
|
2464
|
+
def _measure(self) -> Tuple[float, float]:
|
|
2465
|
+
th = self.theme
|
|
2466
|
+
fs = self._fs()
|
|
2467
|
+
lg = float(th.label_gap if self.label_gap is None else self.label_gap)
|
|
2468
|
+
label_w = label_h = 0.0
|
|
2469
|
+
if self.label:
|
|
2470
|
+
label_w, label_h = measure_text(self.label, fs, weight=self.weight)
|
|
2471
|
+
along = th.L(48.0)
|
|
2472
|
+
if self.span is not None:
|
|
2473
|
+
along = 0.0
|
|
2474
|
+
if self.side in ("left", "right"):
|
|
2475
|
+
w = self.strip + (label_w + lg if self.label else 0.0)
|
|
2476
|
+
return w, max(along, label_h + th.L(2) if self.span is not None else label_h + th.L(8))
|
|
2477
|
+
h = self.strip + (label_h + lg if self.label else 0.0)
|
|
2478
|
+
return max(along, label_w + th.L(2) if self.span is not None else label_w + th.L(8)), h
|
|
2479
|
+
|
|
2480
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
2481
|
+
from .render_svg import draw_path, draw_text
|
|
2482
|
+
|
|
2483
|
+
from .node import BoxGeom
|
|
2484
|
+
|
|
2485
|
+
g = self.geom
|
|
2486
|
+
th = self.theme
|
|
2487
|
+
fs = self._fs()
|
|
2488
|
+
sp = self._span()
|
|
2489
|
+
if sp is not None: # draw over the spanned sub-range only (label follows)
|
|
2490
|
+
if self.side in ("top", "bottom"):
|
|
2491
|
+
g = BoxGeom(sp[0], g.y, sp[1] - sp[0], g.h)
|
|
2492
|
+
else:
|
|
2493
|
+
g = BoxGeom(g.x, sp[0], g.w, sp[1] - sp[0])
|
|
2494
|
+
x, y = g.x + ox, g.y + oy
|
|
2495
|
+
stroke = self.stroke or th.stroke
|
|
2496
|
+
sw = float(self.sw if self.sw is not None else th.stroke_width)
|
|
2497
|
+
lg = float(th.label_gap if self.label_gap is None else self.label_gap)
|
|
2498
|
+
|
|
2499
|
+
# Brace body occupies `strip` along the cross axis; label outside.
|
|
2500
|
+
if self.side in ("left", "right"):
|
|
2501
|
+
label_w = 0.0
|
|
2502
|
+
if self.label:
|
|
2503
|
+
label_w, _ = measure_text(self.label, fs, weight=self.weight)
|
|
2504
|
+
if (self.side == "left") == (self.label_at == "open"):
|
|
2505
|
+
# label right of the strip
|
|
2506
|
+
bx0 = x
|
|
2507
|
+
bw = self.strip
|
|
2508
|
+
if self.label:
|
|
2509
|
+
draw_text(
|
|
2510
|
+
buf, self.label,
|
|
2511
|
+
x + bw + lg,
|
|
2512
|
+
y + g.h / 2 + fs / 3,
|
|
2513
|
+
font_size=fs, weight=self.weight, fill=self.label_color or stroke,
|
|
2514
|
+
)
|
|
2515
|
+
else:
|
|
2516
|
+
# label left of the strip
|
|
2517
|
+
bx0 = x + (label_w + lg if self.label else 0.0)
|
|
2518
|
+
bw = self.strip
|
|
2519
|
+
if self.label:
|
|
2520
|
+
draw_text(
|
|
2521
|
+
buf, self.label,
|
|
2522
|
+
x,
|
|
2523
|
+
y + g.h / 2 + fs / 3,
|
|
2524
|
+
font_size=fs, weight=self.weight, fill=self.label_color or stroke,
|
|
2525
|
+
)
|
|
2526
|
+
d = _brace_path_vertical(bx0, y, bw, g.h, open_left=(self.side == "left"), style=self.style)
|
|
2527
|
+
else:
|
|
2528
|
+
label_h = fs if self.label else 0.0
|
|
2529
|
+
if (self.side == "top") == (self.label_at == "open"):
|
|
2530
|
+
by0 = y
|
|
2531
|
+
bh = self.strip
|
|
2532
|
+
if self.label:
|
|
2533
|
+
tw, _ = measure_text(self.label, fs, weight=self.weight)
|
|
2534
|
+
draw_text(
|
|
2535
|
+
buf, self.label,
|
|
2536
|
+
x + (g.w - tw) / 2,
|
|
2537
|
+
y + bh + lg + fs,
|
|
2538
|
+
font_size=fs, weight=self.weight, fill=self.label_color or stroke,
|
|
2539
|
+
)
|
|
2540
|
+
else:
|
|
2541
|
+
by0 = y + (label_h + lg if self.label else 0.0)
|
|
2542
|
+
bh = self.strip
|
|
2543
|
+
if self.label:
|
|
2544
|
+
tw, _ = measure_text(self.label, fs, weight=self.weight)
|
|
2545
|
+
draw_text(
|
|
2546
|
+
buf, self.label,
|
|
2547
|
+
x + (g.w - tw) / 2,
|
|
2548
|
+
y + fs,
|
|
2549
|
+
font_size=fs, weight=self.weight, fill=self.label_color or stroke,
|
|
2550
|
+
)
|
|
2551
|
+
d = _brace_path_horizontal(x, by0, g.w, bh, open_up=(self.side == "top"), style=self.style)
|
|
2552
|
+
draw_path(buf, d, stroke=stroke, stroke_width=sw, fill="none")
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def _brace_path_vertical(
|
|
2556
|
+
x: float, y: float, w: float, h: float, *, open_left: bool, style: str
|
|
2557
|
+
) -> str:
|
|
2558
|
+
"""SVG path for a vertical brace in box (x,y,w,h)."""
|
|
2559
|
+
mid = y + h / 2
|
|
2560
|
+
if style == "square":
|
|
2561
|
+
if open_left:
|
|
2562
|
+
# ] shape but mirrored: tip at left mid, spine on right
|
|
2563
|
+
return (
|
|
2564
|
+
f"M {x + w:.2f},{y:.2f} L {x:.2f},{y:.2f} "
|
|
2565
|
+
f"L {x:.2f},{mid:.2f} L {x + w * 0.15:.2f},{mid:.2f} "
|
|
2566
|
+
f"M {x:.2f},{mid:.2f} L {x:.2f},{y + h:.2f} L {x + w:.2f},{y + h:.2f}"
|
|
2567
|
+
)
|
|
2568
|
+
return (
|
|
2569
|
+
f"M {x:.2f},{y:.2f} L {x + w:.2f},{y:.2f} "
|
|
2570
|
+
f"L {x + w:.2f},{mid:.2f} L {x + w * 0.85:.2f},{mid:.2f} "
|
|
2571
|
+
f"M {x + w:.2f},{mid:.2f} L {x + w:.2f},{y + h:.2f} L {x:.2f},{y + h:.2f}"
|
|
2572
|
+
)
|
|
2573
|
+
if style == "round":
|
|
2574
|
+
# simple round paren via cubic
|
|
2575
|
+
if open_left:
|
|
2576
|
+
return (
|
|
2577
|
+
f"M {x + w:.2f},{y:.2f} "
|
|
2578
|
+
f"C {x:.2f},{y:.2f} {x:.2f},{mid:.2f} {x + w * 0.2:.2f},{mid:.2f} "
|
|
2579
|
+
f"C {x:.2f},{mid:.2f} {x:.2f},{y + h:.2f} {x + w:.2f},{y + h:.2f}"
|
|
2580
|
+
)
|
|
2581
|
+
return (
|
|
2582
|
+
f"M {x:.2f},{y:.2f} "
|
|
2583
|
+
f"C {x + w:.2f},{y:.2f} {x + w:.2f},{mid:.2f} {x + w * 0.8:.2f},{mid:.2f} "
|
|
2584
|
+
f"C {x + w:.2f},{mid:.2f} {x + w:.2f},{y + h:.2f} {x:.2f},{y + h:.2f}"
|
|
2585
|
+
)
|
|
2586
|
+
# curly (default) — classic typographic brace
|
|
2587
|
+
tip = x + (0.05 * w if open_left else 0.95 * w)
|
|
2588
|
+
spine = x + (0.75 * w if open_left else 0.25 * w)
|
|
2589
|
+
outer = x + (0.95 * w if open_left else 0.05 * w)
|
|
2590
|
+
# two curls meeting at tip
|
|
2591
|
+
return (
|
|
2592
|
+
f"M {outer:.2f},{y:.2f} "
|
|
2593
|
+
f"C {spine:.2f},{y:.2f} {spine:.2f},{mid:.2f} {tip:.2f},{mid:.2f} "
|
|
2594
|
+
f"C {spine:.2f},{mid:.2f} {spine:.2f},{y + h:.2f} {outer:.2f},{y + h:.2f}"
|
|
2595
|
+
)
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def _brace_path_horizontal(
|
|
2599
|
+
x: float, y: float, w: float, h: float, *, open_up: bool, style: str
|
|
2600
|
+
) -> str:
|
|
2601
|
+
mid = x + w / 2
|
|
2602
|
+
if style == "square":
|
|
2603
|
+
if open_up:
|
|
2604
|
+
return (
|
|
2605
|
+
f"M {x:.2f},{y + h:.2f} L {x:.2f},{y:.2f} "
|
|
2606
|
+
f"L {mid:.2f},{y:.2f} L {mid:.2f},{y + h * 0.15:.2f} "
|
|
2607
|
+
f"M {mid:.2f},{y:.2f} L {x + w:.2f},{y:.2f} L {x + w:.2f},{y + h:.2f}"
|
|
2608
|
+
)
|
|
2609
|
+
return (
|
|
2610
|
+
f"M {x:.2f},{y:.2f} L {x:.2f},{y + h:.2f} "
|
|
2611
|
+
f"L {mid:.2f},{y + h:.2f} L {mid:.2f},{y + h * 0.85:.2f} "
|
|
2612
|
+
f"M {mid:.2f},{y + h:.2f} L {x + w:.2f},{y + h:.2f} L {x + w:.2f},{y:.2f}"
|
|
2613
|
+
)
|
|
2614
|
+
tip = y + (0.05 * h if open_up else 0.95 * h)
|
|
2615
|
+
spine = y + (0.75 * h if open_up else 0.25 * h)
|
|
2616
|
+
outer = y + (0.95 * h if open_up else 0.05 * h)
|
|
2617
|
+
return (
|
|
2618
|
+
f"M {x:.2f},{outer:.2f} "
|
|
2619
|
+
f"C {x:.2f},{spine:.2f} {mid:.2f},{spine:.2f} {mid:.2f},{tip:.2f} "
|
|
2620
|
+
f"C {mid:.2f},{spine:.2f} {x + w:.2f},{spine:.2f} {x + w:.2f},{outer:.2f}"
|
|
2621
|
+
)
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
class CircleHighlight(Node):
|
|
2625
|
+
"""
|
|
2626
|
+
Circle / oval / rounded-rect callout drawn around a child node.
|
|
2627
|
+
|
|
2628
|
+
Use to emphasize a box or a single probability line (paper panel (b)).
|
|
2629
|
+
Alias: Callout.
|
|
2630
|
+
|
|
2631
|
+
v0.7.0: ``ellipse`` / ``circle`` are sized to *enclose* the child's corners (a box
|
|
2632
|
+
framed by the ellipse no longer pokes through it); ``pad`` is the clearance.
|
|
2633
|
+
"""
|
|
2634
|
+
|
|
2635
|
+
hl_pad = themed(6.0)
|
|
2636
|
+
hl_sw = theme_default(lambda th: 2.0 if th.unit == 1.0 else th.emph_stroke)
|
|
2637
|
+
|
|
2638
|
+
def __init__(
|
|
2639
|
+
self,
|
|
2640
|
+
child: Node,
|
|
2641
|
+
*,
|
|
2642
|
+
shape: str = "ellipse", # ellipse|circle|rect
|
|
2643
|
+
pad: Optional[float] = None,
|
|
2644
|
+
stroke: str = "#d4a017",
|
|
2645
|
+
stroke_width: Optional[float] = None,
|
|
2646
|
+
fill: str = "none",
|
|
2647
|
+
opacity: float = 1.0,
|
|
2648
|
+
dasharray: Optional[str] = None,
|
|
2649
|
+
**kwargs: Any,
|
|
2650
|
+
) -> None:
|
|
2651
|
+
super().__init__(**kwargs)
|
|
2652
|
+
self.child = child
|
|
2653
|
+
child.parent = self
|
|
2654
|
+
self.shape = shape
|
|
2655
|
+
self.hl_pad = pad
|
|
2656
|
+
self.hl_stroke = stroke
|
|
2657
|
+
self.hl_sw = stroke_width
|
|
2658
|
+
self.hl_fill = fill
|
|
2659
|
+
self.hl_opacity = float(opacity)
|
|
2660
|
+
self.hl_dash = dasharray
|
|
2661
|
+
|
|
2662
|
+
def children(self) -> Sequence[Node]:
|
|
2663
|
+
return (self.child,)
|
|
2664
|
+
|
|
2665
|
+
def _axes(self, cw: float, ch: float) -> Tuple[float, float]:
|
|
2666
|
+
"""Half-axes (rx, ry) of the outline around a ``cw`` × ``ch`` child."""
|
|
2667
|
+
import math
|
|
2668
|
+
|
|
2669
|
+
p = self.hl_pad
|
|
2670
|
+
if self.shape == "rect":
|
|
2671
|
+
return cw / 2 + p, ch / 2 + p
|
|
2672
|
+
if self.shape == "circle":
|
|
2673
|
+
r = math.hypot(cw / 2, ch / 2) + p * 0.5
|
|
2674
|
+
return r, r
|
|
2675
|
+
a = cw / 2 + max(p, 0.12 * cw)
|
|
2676
|
+
k = max(1e-3, 1.0 - (cw / 2 / a) ** 2)
|
|
2677
|
+
b = max(ch / 2 + p, (ch / 2) / math.sqrt(k) + p * 0.5)
|
|
2678
|
+
return a, b
|
|
2679
|
+
|
|
2680
|
+
def _measure(self) -> Tuple[float, float]:
|
|
2681
|
+
cw, ch = self.child.measure()
|
|
2682
|
+
rx, ry = self._axes(cw, ch)
|
|
2683
|
+
return 2 * rx, 2 * ry
|
|
2684
|
+
|
|
2685
|
+
def _layout_children(self) -> None:
|
|
2686
|
+
g = self.geom
|
|
2687
|
+
cw, ch = self.child.measure()
|
|
2688
|
+
# center child inside the outline box
|
|
2689
|
+
self.child.layout(g.x + (g.w - cw) / 2, g.y + (g.h - ch) / 2, cw, ch)
|
|
2690
|
+
|
|
2691
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
2692
|
+
from .render_svg import draw_ellipse, draw_rect
|
|
2693
|
+
|
|
2694
|
+
self.child.draw(buf, ox, oy)
|
|
2695
|
+
cg = self.child.geom
|
|
2696
|
+
# highlight around the child's laid-out box (+ pad)
|
|
2697
|
+
rx0, ry0 = self._axes(cg.w, cg.h)
|
|
2698
|
+
x = cg.x + ox + cg.w / 2 - rx0
|
|
2699
|
+
y = cg.y + oy + cg.h / 2 - ry0
|
|
2700
|
+
w = 2 * rx0
|
|
2701
|
+
h = 2 * ry0
|
|
2702
|
+
if self.shape == "rect":
|
|
2703
|
+
draw_rect(
|
|
2704
|
+
buf, x, y, w, h,
|
|
2705
|
+
fill=self.hl_fill, stroke=self.hl_stroke,
|
|
2706
|
+
stroke_width=self.hl_sw, radius=min(w, h) * 0.2,
|
|
2707
|
+
opacity=self.hl_opacity, dasharray=self.hl_dash or self._dash(self.hl_sw),
|
|
2708
|
+
)
|
|
2709
|
+
else:
|
|
2710
|
+
cx, cy = x + w / 2, y + h / 2
|
|
2711
|
+
rx, ry = w / 2, h / 2
|
|
2712
|
+
draw_ellipse(
|
|
2713
|
+
buf, cx, cy, rx, ry,
|
|
2714
|
+
fill=self.hl_fill, stroke=self.hl_stroke,
|
|
2715
|
+
stroke_width=self.hl_sw, opacity=self.hl_opacity,
|
|
2716
|
+
dasharray=self.hl_dash or self._dash(self.hl_sw),
|
|
2717
|
+
)
|
|
2718
|
+
|
|
2719
|
+
|
|
2720
|
+
# Public alias
|
|
2721
|
+
Callout = CircleHighlight
|