"""Diagrams for the site, written as SVG from the repository's own data.
Each diagram is a function of the registry, the platform files, the
pipeline's step list or the install manifests, so it changes when the data
does and never needs redrawing by hand. Output is deterministic: sorted
inputs, fixed number formatting, ids derived from the diagram's name.
A page asks for a diagram with an HTML comment on a line of its own::
``resolve_images`` replaces the comment with the figure. Each drawing is
an SVG file shown through ````, so the page text holds its words once:
an inline SVG put every label of both layouts, its title and its desc into
what a screen reader, a copy, a text dump or a crawler reads, next to the
prose that already says them. A file reads no stylesheet of the page, so it
is written once per theme with the colours retrobios.css gives the
``rb-d-*`` classes (``THEMES``), and the stylesheet shows the one of the
current theme. Presentation attributes give the same drawing a readable
default when the SVG is opened on its own.
Every figure carries two layouts, wide and narrow, and ```` picks
one: a 720-unit drawing scaled to a phone would print 9 px text. The
drawings are ``alt=""``: the figure says what they show once, in a
screen-reader line, and the caption under them.
"""
from __future__ import annotations
import ast
import json
import re
import sys
import urllib.parse
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import siteinputs # noqa: E402
import sitemarkup as markup # noqa: E402
PLACEHOLDER = re.compile(r"(?m)^([ \t]*)[ \t]*$")
PLACEHOLDER_ANYWHERE = re.compile(r"")
FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
# The stylesheet sets the page font on every , so a path takes the
# mono font from an inline style, which outranks it.
MONO_STYLE = "font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)"
WIDE = 720
NARROW = 340
# The wide drawing is scaled to a column about 700 px wide: these sizes read
# at 14 px of body text there, the size of the prose around the figure.
COLUMN = 700
TEXT_SIZE = round(14 * WIDE / COLUMN, 1)
TITLE_SIZE = round(15 * WIDE / COLUMN, 1)
LINE = 20
PAD = 12
# A box holds a title and at most this many lines: the rest is the caption's.
MAX_BOX_LINES = 3
# Default colours for an SVG read outside the page. The stylesheet replaces
# them on the site through the rb-d-* classes.
INK = "#1f2233"
FILL = "#f5f6fa"
STROKE = "#8c92ac"
ACCENT = "#3b3f7a"
MUTED = "#546e7a"
class Mono(str):
"""A path or a name typed as is: drawn in the mono font on a line of its
own, never broken."""
def _width(text: str, size: float, bold: bool = False) -> float:
"""Rough advance of a string: 0.56 em a character, 0.6 bold or mono."""
return len(text) * size * (0.6 if bold or isinstance(text, Mono) else 0.56)
def wrap(text: str, width: float, size: float, bold: bool = False) -> list[str]:
"""Greedy word wrap against the estimated advance. A Mono line stays whole."""
if isinstance(text, Mono):
return [text]
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if current and _width(candidate, size, bold) > width:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
return lines
def _n(value: float) -> str:
text = f"{value:.1f}"
return text[:-2] if text.endswith(".0") else text
@dataclass
class Box:
title: str
lines: list[str] = field(default_factory=list)
accent: bool = False
number: str = ""
@dataclass
class Figure:
"""One diagram: what it shows, said twice (alt and caption), and drawn twice."""
image_id: str
title: str
desc: str
caption: str
wide: Callable[[], tuple[float, float, list[str]]]
narrow: Callable[[], tuple[float, float, list[str]]]
# Drawing primitives
def _text(x: float, y: float, value: str, size: float = TEXT_SIZE,
bold: bool = False, anchor: str = "start", cls: str = "") -> str:
weight = ' font-weight="600"' if bold else ""
klass = f' class="{cls}"' if cls else ""
style = f' style="{MONO_STYLE}"' if isinstance(value, Mono) else ""
fill = MUTED if cls == "rb-d-muted" else ("#ffffff" if cls == "rb-d-numtext" else INK)
return (
f'{escape(value)}'
)
def _rect(x: float, y: float, w: float, h: float, accent: bool = False) -> str:
cls = "rb-d-box rb-d-accent" if accent else "rb-d-box"
stroke = ACCENT if accent else STROKE
width = 2 if accent else 1
return (
f''
)
def _badge_side(number: str) -> float:
return max(20.0, _width(number, 11, True) + 10)
def _badge(x: float, y: float, number: str) -> list[str]:
side = _badge_side(number)
return [
f'',
_text(x + side / 2, y + 14, number, 11, True, "middle", "rb-d-numtext"),
]
def _line(points: list[tuple[float, float]], head: bool = True) -> list[str]:
"""A connector through *points*, with a drawn arrowhead at the end.
The head is a small triangle, never a text glyph.
"""
path = " ".join(
f"{'M' if index == 0 else 'L'}{_n(x)} {_n(y)}" for index, (x, y) in enumerate(points)
)
out = [
f''
]
if head and len(points) >= 2:
(x1, y1), (x2, y2) = points[-2], points[-1]
if x1 == x2:
sign = 1 if y2 > y1 else -1
tri = [(x2, y2), (x2 - 5, y2 - 8 * sign), (x2 + 5, y2 - 8 * sign)]
else:
sign = 1 if x2 > x1 else -1
tri = [(x2, y2), (x2 - 8 * sign, y2 - 5), (x2 - 8 * sign, y2 + 5)]
out.append(
''
)
return out
def box_lines(box: Box, width: float) -> tuple[list[str], list[str]]:
"""The title and body lines of *box* drawn *width* wide, as wrapped."""
inner = width - 2 * PAD
title_lines = wrap(box.title, inner - (_badge_side(box.number) + 8 if box.number else 0),
TITLE_SIZE, True)
body: list[str] = []
for line in box.lines:
body.extend(wrap(line, inner, TEXT_SIZE))
return title_lines, body
def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]:
title_lines, body = box_lines(box, width)
height = PAD + len(title_lines) * LINE + (6 if body else 0) + len(body) * LINE + PAD
if box.number:
height = max(height, PAD * 2 + 20)
return title_lines, body, height + 2
def _draw_box(box: Box, x: float, y: float, width: float, height: float,
title_lines: list[str], body: list[str]) -> list[str]:
out = [_rect(x, y, width, height, box.accent)]
tx = x + PAD
if box.number:
out.extend(_badge(x + PAD, y + PAD, box.number))
tx += _badge_side(box.number) + 8
cy = y + PAD + 15
for line in title_lines:
out.append(_text(tx, cy, line, TITLE_SIZE, True))
cy += LINE
cy += 6
for line in body:
out.append(_text(x + PAD, cy, line, TEXT_SIZE, cls="rb-d-muted"))
cy += LINE
return out
def flow(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, list[str]]:
"""Boxes joined in order: a row on a wide screen, a column on a narrow one."""
out: list[str] = []
if vertical:
width = total
y = 1.0
measured = [_box_height(box, width - 2) for box in boxes]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)):
out.extend(_draw_box(box, 1, y, width - 2, height, title_lines, body))
y += height
if index < len(boxes) - 1:
out.extend(_line([(width / 2, y), (width / 2, y + 22)]))
y += 24
return width, y + 1, out
gap = 26.0
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
measured = [_box_height(box, width) for box in boxes]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)):
x = 1 + index * (width + gap)
# One height for the row: boxes as tall as their own lines made four
# sizes side by side.
out.extend(_draw_box(box, x, 1, width, height, title_lines, body))
if index < len(boxes) - 1:
mid = 1 + height / 2
out.extend(_line([(x + width, mid), (x + width + gap - 1, mid)]))
return total, height + 2, out
def columns(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, list[str]]:
"""Side-by-side boxes with no order between them."""
out: list[str] = []
if vertical:
y = 1.0
measured = [_box_height(box, total - 2) for box in boxes]
height = max(m[2] for m in measured)
for box, (title_lines, body, _) in zip(boxes, measured):
out.extend(_draw_box(box, 1, y, total - 2, height, title_lines, body))
y += height + 12
return total, y - 11, out
gap = 16.0
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
measured = [_box_height(box, width) for box in boxes]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)):
out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, height, title_lines, body))
return total, height + 2, out
def grid(boxes: list[Box], per_row: int, total: float) -> tuple[float, float, list[str]]:
"""Ordered boxes in rows, each row joined to the next by an elbow line."""
out: list[str] = []
gap_x, gap_y = 18.0, 30.0
width = (total - gap_x * (per_row - 1) - 2) / per_row
rows = [boxes[i:i + per_row] for i in range(0, len(boxes), per_row)]
y = 1.0
previous_end: tuple[float, float] | None = None
for row in rows:
measured = [_box_height(box, width) for box in row]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, _)) in enumerate(zip(row, measured)):
x = 1 + index * (width + gap_x)
if previous_end is not None and index == 0:
px, py = previous_end
out.extend(_line([(px, py), (px, py + gap_y / 2), (x + width / 2, py + gap_y / 2),
(x + width / 2, y)]))
out.extend(_draw_box(box, x, y, width, height, title_lines, body))
if index < len(row) - 1:
out.extend(_line([(x + width, y + height / 2), (x + width + gap_x - 1, y + height / 2)]))
previous_end = (x + width / 2, y + height)
y += height + gap_y
return total, y - gap_y + 1, out
def svg_document(width: float, height: float, title: str, desc: str, body: list[str],
base: str) -> str:
"""A drawing as a standalone, accessible SVG element."""
return (
f'"
)
def svg(figure: Figure, layout: str, suffix: str = "") -> str:
"""One layout of *figure* as a standalone, accessible SVG element."""
width, height, body = (figure.wide if layout == "wide" else figure.narrow)()
return svg_document(width, height, figure.title, figure.desc, body,
f"rb-fig-{figure.image_id}{suffix}-{layout}")
# The colours retrobios.css gives a diagram, per theme: --rb-diagram-* and
# --rb-text-secondary, then the text and background Starlight resolves
# --sl-color-text and --sl-color-bg to (props.css: gray-2 and black, whose
# values swap with the theme). An SVG shown through reads none of them.
THEMES: dict[str, dict[str, str]] = {
"dark": {"text": "#c1c3c8", "bg": "#17181c", "fill": "#252838", "stroke": "#7c82a3",
"accent": "#aebcff", "muted": "#a6adc8"},
"light": {"text": "#353841", "bg": "#ffffff", "fill": "#f5f6fa", "stroke": "#8c92ac",
"accent": "#3b3f7a", "muted": "#546e7a"},
}
# Where the files are served, under the site's public folder.
DIAGRAM_DIR = "assets/diagrams"
# retrobios.css shows the narrow layout below this width.
NARROW_MEDIA = "(max-width: 37.5em)"
def themed(document: str, theme: str) -> str:
"""*document* with the theme's colours, the rules retrobios.css gives
the .rb-diagram classes on the page."""
c = THEMES[theme]
style = (
f""
)
head = document.index(">") + 1
return document[:head] + style + document[head:]
def _size(document: str) -> tuple[str, str]:
match = re.match(r'