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/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