DIAGRAM_LIB_REVISION = "2026-09-25-flow-waypoint-routes"
// =============================================================================
// D3 IMPORT
// =============================================================================
d3 = import("../../assets/vendor/d3/d3.esm.js")
// =============================================================================
// THEME DETECTION
// =============================================================================
// Reactive value that tracks Quarto's native dark mode and re-renders diagrams
// when the theme toggles. Generators.observe makes this a live OJS dependency:
// any cell referencing isDarkMode re-runs whenever the body/html class changes.
isDarkMode = Generators.observe(notify => {
const check = () =>
document.body.classList.contains('quarto-dark') ||
document.documentElement.classList.contains('quarto-dark');
notify(check());
const observer = new MutationObserver(() => notify(check()));
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
})
// =============================================================================
// CSS VARIABLE UTILITIES
// =============================================================================
// Function to read CSS custom property values from the document.
// Reads from <body> because Quarto applies the .quarto-dark class there, so the
// dark-mode variable overrides resolve on the body element, not <html>.
getCSSVar = function(name, fallback = null) {
if (typeof document === 'undefined') return fallback;
const value = getComputedStyle(document.body).getPropertyValue(name).trim();
return value || fallback;
}
// Parse a CSS color string to {r, g, b} bytes. Understands #rgb/#rrggbb/
// #rrggbbaa, rgb()/rgba(), and var(--token) (resolved against <body>, like
// getCSSVar). Returns null for anything it can't parse — callers must fall
// back, never interpolate through NaN.
_parseColor = function(color) {
if (typeof color !== 'string') return null;
let c = color.trim();
const varMatch = c.match(/^var\(\s*(--[\w-]+)/);
if (varMatch) c = (getCSSVar(varMatch[1], '') || '').trim();
let m;
if ((m = c.match(/^#([0-9a-f]{3,8})$/i))) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16)
};
}
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
};
}
if ((m = c.match(/^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/i))) {
return {r: +m[1], g: +m[2], b: +m[3]};
}
return null;
}
// =============================================================================
// THEME OBJECT
// =============================================================================
// Object containing all diagram colors read from CSS variables
// Falls back to hardcoded values if CSS vars not available
diagramTheme = {
// Light-mode fallback values (used if CSS vars are unavailable)
const lightFallbacks = {
nodeFill: '#f5f5f4',
nodeFillHover: '#e7e5e4',
nodeStroke: '#d6d3d1',
nodeText: '#1c1917',
edgeStroke: '#78716c',
highlight: '#f97316',
highlightGlow: 'rgba(249, 115, 22, 0.3)',
highlightBg: 'rgba(249, 115, 22, 0.1)',
accent: '#0ea5e9',
accentGlow: 'rgba(14, 165, 233, 0.3)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Ink for highlight/accent-colored RUNNING text on the diagram surface
// (labels, titles, readouts — not on a saturated fill). Raw highlight
// (#f97316, 2.68:1) / accent (#0ea5e9, 2.65:1) fail AA as light-mode text;
// 70% hue (the §6 idiom's fraction) still misses at 4.2–4.4:1 for these two
// bright hues, so these mix 60% toward the theme ink
// (light: #a15525/#1473a3, 5.0–5.2:1; dark: #f9b984/#84d3f5, ~10.3:1).
// Shapes keep the raw fills — only text reads these keys.
highlightText: '#a15525',
accentText: '#1473a3',
// Scoped ink for in-cell labels on the paper→highlight heatmap on-ramp.
// The light value is a deep bronze that clears AA across the audited
// mid-band; figures use it only for those labels, never general text.
heatmapMidText: '#7c2d12',
// Primary-accent tint for chips/badges sitting behind --ph-emb ink
// (exposed as diagramTheme.primaryBg): ~10% indigo over transparent.
primaryBg: 'rgba(79, 70, 229, 0.10)',
// Text sitting on a fully-saturated *status* fill (break-red / run-green /
// any deep status hue). Unlike textOnHighlight/textOnAccent (dark in both
// themes, because the orange highlight & sky accent stay mid-tone), the
// status hues flip: DEEP in light (#dc2626/#059669 → white reads) but
// BRIGHTENED in dark (#f87171/#34d399 → dark text reads). So this token
// flips too — white in light, near-black in dark — clearing AA on the
// status fills in both themes. One token so figures stop hard-coding "#fff".
textOnFill: '#ffffff',
// Ink for text sitting ON success/error FILLS — same flip logic as
// textOnFill, scoped per status hue. Resolved from real tokens
// (--diagram-text-on-success/-error in _diagram-variables.scss) so chips,
// heatmap cells, and badges on run-green/break-red stop hand-picking ink.
textOnSuccess: '#ffffff',
textOnError: '#ffffff',
bg: '#fafaf9',
bgSecondary: '#f5f5f4',
// Semantic colors for status/feedback — aliased onto the canonical status/
// phase hues (DESIGN_LANGUAGE.md §2/pillar 2): error≡--pg-break (was
// already byte-identical), success≡--pg-run, info≡--ph-attn (2026-07-11,
// was ad-hoc #16a34a/#2563eb — a "seventh blue" and a second green).
error: '#dc2626',
errorBg: 'rgba(220, 38, 38, 0.1)',
success: '#059669',
successBg: 'rgba(5, 150, 105, 0.1)',
info: '#0284c7',
infoBg: 'rgba(2, 132, 199, 0.1)',
// Pipeline phase palette (DESIGN_LANGUAGE §2) — the site's canonical accent
// grammar, mirrored from the playground north star (playground/ui/kit.js).
// Any diagram that colors a pipeline stage reads its hue from here so lesson
// figures and the debugger share one palette. Status hues (run/break) too.
phaseTok: '#7c3aed',
phaseEmb: '#4f46e5',
phaseAttn: '#0284c7',
phaseMlp: '#0d9488',
phaseNorm: '#64748b',
phaseLogits: '#d97706',
phaseSample: '#e11d48',
run: '#059669',
break: '#dc2626'
};
// Dark-mode fallbacks + brighter semantic colors for readability on dark.
const darkFallbacks = {
nodeFill: '#292524',
nodeFillHover: '#3f3a36',
nodeStroke: '#57534e',
nodeText: '#fafaf9',
edgeStroke: '#a8a29e',
highlight: '#fb923c',
highlightGlow: 'rgba(251, 146, 60, 0.4)',
highlightBg: 'rgba(251, 146, 60, 0.18)',
accent: '#38bdf8',
accentGlow: 'rgba(56, 189, 248, 0.4)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Dark twins: brightened hues mixed 60% toward near-white ink (≈10.3:1 on
// the dark surface).
highlightText: '#f9b984',
accentText: '#84d3f5',
// Dark twin of the scoped heatmap/on-ramp label ink. Near-black umber
// clears the high side of the audited paper→highlight ramp.
heatmapMidText: '#030201',
// Dark twin of primaryBg — the brightened indigo at 18%.
primaryBg: 'rgba(129, 140, 248, 0.18)',
// Dark twin: status fills brighten on dark (#f87171/#34d399), so dark text
// reads on them — the flip described on the light textOnFill above.
textOnFill: '#1c1917',
// Dark twin: brightened status fills (#34d399/#f87171) want dark ink.
textOnSuccess: '#1c1917',
textOnError: '#1c1917',
bg: 'transparent',
bgSecondary: '#1c1917',
error: '#f87171',
errorBg: 'rgba(248, 113, 113, 0.18)',
success: '#34d399',
successBg: 'rgba(52, 211, 153, 0.18)',
info: '#38bdf8',
infoBg: 'rgba(56, 189, 248, 0.18)',
// Phase palette — dark twin (brighter for legibility on dark surfaces),
// mirroring the playground's .quarto-dark --ph-*/--pg-* values exactly.
phaseTok: '#a78bfa',
phaseEmb: '#818cf8',
phaseAttn: '#38bdf8',
phaseMlp: '#2dd4bf',
phaseNorm: '#94a3b8',
phaseLogits: '#fbbf24',
phaseSample: '#fb7185',
run: '#34d399',
break: '#f87171'
};
// Referencing isDarkMode here makes this cell reactive: it recomputes (and all
// diagrams that read it re-render) whenever the theme is toggled.
const fallbacks = isDarkMode ? darkFallbacks : lightFallbacks;
// Resolved once so `bgOpaque` can be derived from them (an object literal
// can't reference its own siblings).
const bg = getCSSVar('--diagram-bg', fallbacks.bg);
const bgSecondary = getCSSVar('--diagram-bg-secondary', fallbacks.bgSecondary);
// bgOpaque hoisted to a const so bgRGB can derive from it (an object literal
// can't reference its own siblings — same constraint as bg/bgSecondary).
const bgOpaque = (!bg || bg === 'transparent' || bg === 'none') ? bgSecondary : bg;
return {
nodeFill: getCSSVar('--diagram-node-fill', fallbacks.nodeFill),
nodeFillHover: getCSSVar('--diagram-hover-fill', fallbacks.nodeFillHover),
nodeStroke: getCSSVar('--diagram-node-stroke', fallbacks.nodeStroke),
nodeText: getCSSVar('--diagram-node-text', fallbacks.nodeText),
edgeStroke: getCSSVar('--diagram-edge-stroke', fallbacks.edgeStroke),
highlight: getCSSVar('--diagram-highlight', fallbacks.highlight),
highlightGlow: getCSSVar('--diagram-highlight-glow', fallbacks.highlightGlow),
highlightBg: getCSSVar('--diagram-highlight-bg', fallbacks.highlightBg),
accent: getCSSVar('--diagram-accent', fallbacks.accent),
accentGlow: getCSSVar('--diagram-accent-glow', fallbacks.accentGlow),
textOnHighlight: fallbacks.textOnHighlight,
textOnAccent: fallbacks.textOnAccent,
highlightText: getCSSVar('--diagram-highlight-text', fallbacks.highlightText),
accentText: getCSSVar('--diagram-accent-text', fallbacks.accentText),
heatmapMidText: getCSSVar('--diagram-heatmap-mid-text', fallbacks.heatmapMidText),
textOnFill: fallbacks.textOnFill,
textOnSuccess: getCSSVar('--diagram-text-on-success', fallbacks.textOnSuccess),
textOnError: getCSSVar('--diagram-text-on-error', fallbacks.textOnError),
bg,
bgSecondary,
// `bg`, but guaranteed PAINTABLE. `--diagram-bg` is deliberately
// `transparent` in dark mode so a figure sits *on* the article surface
// instead of stamping a light slab onto it — which is right for a figure's
// own background rect, and wrong everywhere `bg` is used as *ink*:
//
// * a mark's **halo ring** (`<circle stroke=theme.bg stroke-width=2>`),
// drawn so a point stays legible where it crosses its own line — with a
// transparent stroke there is no ring at all;
// * **knockout text** (`<text fill=theme.bg>`), the page-colored label on
// an active/filled node — with a transparent fill the label is *invisible*.
//
// Both silently broke in dark and were fine in light, which is why they
// survived eight page sweeps. This token keeps `bg` when it is paintable and
// falls back to the opaque `bgSecondary` (#f5f5f4 light / #1c1917 dark) when
// it is not, so ink survives both themes. Rule: a figure's own background
// rect fills `bg`; anything that PAINTS (stroke or text fill) uses
// `bgOpaque` — never `theme.bg`, never a bare "#fff".
bgOpaque,
// Precomputed {r,g,b} of bgOpaque, for color ramps/interpolators that
// would otherwise re-parse a CSS string — and BREAK in dark, where bg is
// 'transparent' (parse → NaN → a near-white ramp). Always the OPAQUE,
// paintable bg: interpolate from this, never from theme.bg.
// Usage: d3.interpolateRgb(d3.rgb(theme.bgRGB), d3.rgb(theme.accent))
bgRGB: _parseColor(bgOpaque) || { r: 245, g: 245, b: 244 },
// Semantic colors — now real --diagram-* tokens (2026-07-11), aliased onto
// --pg-break/--pg-run/--ph-attn at the shared layer so they're overridable
// and theme-reactive the same way every other diagram color is.
error: getCSSVar('--diagram-error', fallbacks.error),
errorBg: getCSSVar('--diagram-error-bg', fallbacks.errorBg),
success: getCSSVar('--diagram-success', fallbacks.success),
successBg: getCSSVar('--diagram-success-bg', fallbacks.successBg),
info: getCSSVar('--diagram-info', fallbacks.info),
infoBg: getCSSVar('--diagram-info-bg', fallbacks.infoBg),
// Primary accent — the site's canonical UI accent (embeddings indigo,
// DESIGN_LANGUAGE §2: `--accent-primary ≈ --ph-emb`). Several lessons
// (m01/m04/m06) already read `theme.primary` for their featured series, but
// the token was never defined here, so it resolved to `undefined` and those
// marks fell back to browser-default black in both themes. Defining it fixes
// that everywhere, theme-reactively.
primary: getCSSVar('--ph-emb', fallbacks.phaseEmb),
// Chip/badge background behind the primary accent — the resolved
// --diagram-primary-bg token (see _diagram-variables.scss).
primaryBg: getCSSVar('--diagram-primary-bg', fallbacks.primaryBg),
// Pipeline phase palette + status hues. Read from the canonical --ph-*/--pg-*
// tokens where present (they get seeded into custom.scss as the shared token
// work lands), else the phase fallbacks above — so a figure is on-palette and
// theme-reactive whether or not the site tokens exist yet. Grouped under
// `phase` (matching playground/ui/kit.js) plus flat run/break status hues.
phase: {
tok: getCSSVar('--ph-tok', fallbacks.phaseTok),
emb: getCSSVar('--ph-emb', fallbacks.phaseEmb),
attn: getCSSVar('--ph-attn', fallbacks.phaseAttn),
mlp: getCSSVar('--ph-mlp', fallbacks.phaseMlp),
norm: getCSSVar('--ph-norm', fallbacks.phaseNorm),
logits: getCSSVar('--ph-logits', fallbacks.phaseLogits),
sample: getCSSVar('--ph-sample', fallbacks.phaseSample)
},
run: getCSSVar('--pg-run', fallbacks.run),
break: getCSSVar('--pg-break', fallbacks.break),
isDark: isDarkMode
};
}
// =============================================================================
// TEXT MEASUREMENT & FITTING
// =============================================================================
// Canvas-backed text measurement shared by every fitting helper. The 2d
// context is created lazily and measured widths are memoized per (text|font)
// key. Falls back to a conservative 0.62em-per-glyph estimate when canvas
// measurement is unavailable, so callers always get a usable number.
// Holder object for the measurement machinery: OJS binds top-level assigned
// names as single-definition (const) cells, so the lazy canvas-context init
// and the broken-canvas flag must mutate PROPERTIES of this holder rather
// than reassign top-level bindings (that throws "Assignment to constant
// variable" at compile time).
// IIFE wrapper: a bare `name = {…}` object literal would parse as an OJS
// BLOCK CELL; the parens force expression parsing.
_measure = (() => ({ctx: null, broken: false, cache: new Map()}))()
// Resolve a CSS font-family that may be written as `var(--pg-mono)`: canvas
// font strings can't use custom properties, so the token is read off <body>
// (getCSSVar), exactly like every other theme read in this file.
_resolveFontFamily = function(family) {
if (!family) return null;
let f = String(family).trim();
const varMatch = f.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
if (varMatch) {
const fallback = varMatch[2] != null ? varMatch[2].replace(/["']/g, '').trim() : null;
f = getCSSVar(varMatch[1], fallback) || '';
}
f = f.replace(/!important/g, '').trim().replace(/^["']|["']$/g, '');
return f || null;
}
// Measured (or estimated) rendered width of a single-line text in px.
// Options: {fontSize = 12, fontWeight = 400, fontFamily = null, italic = false}
// fontFamily may be `var(--token)`; null measures in the body's font.
measureText = function(text, {fontSize = 12, fontWeight = 400, fontFamily = null, italic = false} = {}) {
if (text == null || text === '') return 0;
const len = String(text).length;
const family = _resolveFontFamily(fontFamily);
// Monospace chrome is the site's diagram font (--pg-mono → JetBrains Mono,
// a 0.600em-advance face). Canvas measurement races webfont loading: OJS
// cells evaluate before @font-face fonts finish, and canvas then reports
// FALLBACK-face metrics (much narrower), silently under-sizing boxes,
// gutters and measured legends. For mono families use the face's exact
// design advance — deterministic and load-independent.
if (family && /mono/i.test(family)) return 0.6 * fontSize * len;
// Proportional faces: canvas is exact ONLY if the webfont is actually
// loaded. If it isn't yet, canvas would silently return fallback metrics —
// return a conservative per-glyph estimate instead.
const firstFace = family ? family.split(',')[0].replace(/["']/g, '').trim() : null;
const fontReady = typeof document !== 'undefined' && document.fonts && document.fonts.check &&
(!firstFace || document.fonts.check(`${italic ? 'italic ' : ''}${fontWeight} ${fontSize}px "${firstFace}"`));
if (typeof document !== 'undefined' && !fontReady) return 0.55 * fontSize * len;
const key = `${text}\u0000${fontSize}\u0000${fontWeight}\u0000${fontFamily || ''}\u0000${italic ? 1 : 0}`;
if (_measure.cache.has(key)) return _measure.cache.get(key);
let width = NaN;
if (typeof document !== 'undefined' && !_measure.broken) {
try {
if (!_measure.ctx) _measure.ctx = document.createElement('canvas').getContext('2d');
if (_measure.ctx) {
const family = _resolveFontFamily(fontFamily) ||
(typeof getComputedStyle === 'function'
? _resolveFontFamily(getComputedStyle(document.body).fontFamily)
: null);
_measure.ctx.font = `${italic ? 'italic ' : ''}${fontWeight} ${fontSize}px ${family || 'sans-serif'}`;
width = _measure.ctx.measureText(String(text)).width;
} else {
_measure.broken = true;
}
} catch (e) {
_measure.broken = true;
}
}
if (!isFinite(width) || width <= 0) {
width = 0.62 * fontSize * String(text).length;
}
_measure.cache.set(key, width);
return width;
}
// Convenience wrapper with positional args: estimateTextWidth(text, fontSize,
// mono). mono=true (the default) measures in the site's mono stack — what most
// diagram chrome uses; mono=false measures in the body text face.
estimateTextWidth = function(text, fontSize = 12, mono = true) {
const family = mono
? getCSSVar('--pg-mono', 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace')
: null;
return measureText(text, {fontSize, fontFamily: family});
}
// Largest font size ≤ fontSize whose rendered width fits maxWidth. Never goes
// below minFontSize; if even that would overflow, the caller must wrap
// (wrapText/drawWrappedText) or move the text (placeLabel) instead.
fitFontSize = function(text, {maxWidth, fontSize = 12, minFontSize = 9, fontWeight, fontFamily, italic} = {}) {
const w = measureText(text, {fontSize, fontWeight, fontFamily, italic});
if (w <= 0 || w <= maxWidth) return fontSize;
return Math.max(minFontSize, Math.floor((fontSize * maxWidth / w) * 2) / 2);
}
// Greedy word-wrap into lines that each fit maxWidth (px). A single word wider
// than maxWidth is hard-broken at character level, so every returned line
// fits. Returns an array of strings ([] for empty input).
wrapText = function(text, maxWidth, {fontSize = 12, fontWeight, fontFamily, italic} = {}) {
if (text == null) return [];
const words = String(text).split(/\s+/).filter(Boolean);
if (!words.length) return [];
const lines = [];
let line = '';
const fits = (s) => !s || measureText(s, {fontSize, fontWeight, fontFamily, italic}) <= maxWidth;
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (fits(candidate)) { line = candidate; continue; }
if (line) { lines.push(line); line = ''; }
if (fits(word)) { line = word; continue; }
let piece = '';
for (const ch of word) {
if (piece && !fits(piece + ch)) { lines.push(piece); piece = ch; }
else piece += ch;
}
line = piece;
}
if (line) lines.push(line);
return lines;
}
// Character-count variant of wrapText for mono captions sized in characters.
wrapByChars = function(text, maxChars = 60) {
if (text == null) return [];
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? line + ' ' + word : word;
if (candidate.length <= maxChars) { line = candidate; continue; }
if (line) { lines.push(line); line = ''; }
let rest = word;
while (rest.length > maxChars) {
lines.push(rest.slice(0, maxChars));
rest = rest.slice(maxChars);
}
line = rest;
}
if (line) lines.push(line);
return lines;
}
// Wrap a caption and draw it as tspans on ONE <text> element — the cure for
// long single-line captions that run past the canvas edge (they clip instead
// of wrapping). Wraps by measured width when maxWidth (px) is given, else by
// maxChars; caps at maxLines with a trailing ellipsis when the text doesn't
// fit. Returns {node, lines, width, height} so callers can reserve room.
// Usage: drawWrappedText(svg, 'The token embedding E is (V × d) …', {x: cx,
// y: 250, maxWidth: width - 40, lineHeight: 14, maxLines: 2})
drawWrappedText = function(svg, text, {
x, y,
maxWidth = null, maxChars = null,
lineHeight = 14,
fontSize = 11,
fontWeight = null,
fontFamily = null,
fill = null,
anchor = 'middle',
maxLines = null,
id = null,
className = 'diagram-caption'
} = {}) {
const sel = svg && svg.node ? svg : d3.select(svg);
const str = text == null ? '' : String(text);
let lines = [];
if (str) {
lines = maxWidth != null
? wrapText(str, maxWidth, {fontSize, fontWeight, fontFamily})
: wrapByChars(str, maxChars == null ? 60 : maxChars);
if (maxLines != null && lines.length > maxLines) {
lines = lines.slice(0, maxLines);
const last = lines[lines.length - 1];
lines[lines.length - 1] = last.slice(0, Math.max(0, last.length - 1)).replace(/\s+\S*$/, '') + '…';
}
}
const t = sel.append('text')
.attr('x', x)
.attr('y', y)
.attr('text-anchor', anchor)
.attr('fill', fill != null ? fill : diagramTheme.nodeText)
.style('font-size', `${fontSize}px`)
.attr('class', className);
if (fontWeight != null) t.attr('font-weight', fontWeight);
if (fontFamily != null) t.attr('font-family', fontFamily);
if (id) t.attr('id', id);
lines.forEach((ln, i) => {
t.append('tspan')
.attr('x', x)
.attr('y', y + i * lineHeight)
.text(ln);
});
const width = lines.reduce((m, ln) => Math.max(m, measureText(ln, {fontSize, fontWeight, fontFamily})), 0);
return {node: t.node(), lines, width, height: lines.length ? (lines.length - 1) * lineHeight + fontSize * 1.25 : 0};
}
// Keep an anchored text run inside the canvas gutter. An end-anchored label at
// x=30 extends LEFT of its anchor, a start-anchored one extends RIGHT — long
// runs then start before 0 (or past the viewBox edge) and get clipped.
// Measures the text and returns the anchor x shifted so the whole run stays
// inside [min, max]. Text wider than the entire gutter is pinned to the
// nearest gutter edge (the caller can then shrink the font via fitFontSize).
// anchor: 'start' | 'middle' | 'end'.
fitGutter = function(text, x, {
anchor = 'end',
fontSize = 10,
fontWeight = null,
fontFamily = null,
min = 4,
max = null
} = {}) {
const w = measureText(text, {fontSize, fontWeight, fontFamily});
const lo = min, hi = max == null ? Infinity : max;
if (w > hi - lo) return Math.min(Math.max(x, lo), hi);
const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
const clampedLeft = Math.min(Math.max(left, lo), hi - w);
return anchor === 'end' ? clampedLeft + w
: anchor === 'middle' ? clampedLeft + w / 2
: clampedLeft;
}
// Perceptual ink choice for text sitting ON a filled shape (heatmap cells,
// status chips, badge fills): returns a near-black ink on light/bright fills
// and a white ink on dark fills, so cell values stay legible in BOTH themes.
// The 0.30 luminance threshold reproduces the site's own tokens: orange
// #f97316 and sky #0ea5e9 read as "bright" → dark ink (matching
// textOnHighlight/textOnAccent), while break-red #dc2626 reads as "dark" →
// white ink (matching textOnFill in light). Accepts #rgb/#rrggbb, rgb()/rgba()
// and var(--token) forms.
contrastInk = function(bg, {darkInk = '#1c1917', lightInk = '#ffffff', threshold = 0.30} = {}) {
const rgb = _parseColor(bg);
if (!rgb) return darkInk;
const lin = (v) => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const L = 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
return L > threshold ? darkInk : lightInk;
}
// =============================================================================
// COLLISION-AWARE LABEL PLACEMENT
// =============================================================================
// Knockout outline behind glyphs: paint-order renders the stroke first (under
// the fill), in the figure-background color, so text stays legible where an
// arrow or another label passes underneath. Color reads --diagram-halo
// (theme-aware, opaque in both modes; see _diagram-variables.scss) with
// theme.bgOpaque as fallback.
applyHalo = function(text, color = null) {
const sel = text && text.node ? text : d3.select(text);
return sel
.style('paint-order', 'stroke')
.style('stroke', color || getCSSVar('--diagram-halo', diagramTheme.bgOpaque))
.style('stroke-width', '3px')
.style('stroke-linejoin', 'round');
}
// Harvest the drawn shapes of a rendered svg as rectangles in USER units, for
// use as collision obstacles. Full-canvas background slabs are dropped
// automatically (they would push every label out of the figure); everything
// else — node boxes, other labels, arrow paths — becomes an obstacle.
// `svg` may be a d3 selection or an <svg> element. Elements can be excluded
// per-element via a `data-no-obstacle` attribute or via the `exclude`
// selector(s).
collectObstacles = function(svg, {exclude = null} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (!node || typeof node.getScreenCTM !== 'function' || typeof node.querySelectorAll !== 'function') return [];
const ctm = node.getScreenCTM();
if (!ctm) return [];
const inv = ctm.inverse();
const vb = node.viewBox && node.viewBox.baseVal;
const vbW = (vb && vb.width) || 0, vbH = (vb && vb.height) || 0;
const excludes = exclude == null ? [] : (Array.isArray(exclude) ? exclude : [exclude]);
const isExcluded = (el) => {
if (el.closest('[data-no-obstacle]')) return true;
return excludes.some(sel => {
try { return el.matches(sel) || el.closest(sel); } catch (e) { return false; }
});
};
const toUser = (clientX, clientY) => {
const pt = node.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const p = pt.matrixTransform(inv);
return {x: p.x, y: p.y};
};
const out = [];
for (const sel of ['rect', 'text', 'path']) {
for (const el of node.querySelectorAll(sel)) {
if (isExcluded(el)) continue;
const r = el.getBoundingClientRect();
if (!r.width && !r.height) continue;
const a = toUser(r.left, r.top);
const b = toUser(r.right, r.bottom);
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
if (vbW && vbH && w >= vbW * 0.9 && h >= vbH * 0.9) continue; // background slab
out.push({x, y, w, h});
}
}
return out;
}
// Box a text run into a top-left rect, given its anchor point.
_anchorBox = function(x, y, w, h, anchor) {
const left = anchor === 'start' ? x : anchor === 'end' ? x - w : x - w / 2;
return {x: left, y: y - h / 2, w, h};
}
_hitsAny = function(box, obstacles, margin = 3) {
for (const o of obstacles) {
if (box.x < o.x + o.w + margin && box.x + box.w + margin > o.x &&
box.y < o.y + o.h + margin && box.y + box.h + margin > o.y) return true;
}
return false;
}
// Approximate an SVG path with small collision boxes in user units. These are
// deliberately sampled more densely than the 10px captions: the resolver's
// own 3px collision margin then treats each arrow stroke as a narrow,
// continuous obstacle without needing a browser hit-test for every candidate.
_pathObstacleRects = function(path, spacing = 4, pad = 2) {
if (!path || typeof path.getTotalLength !== 'function' || typeof path.getPointAtLength !== 'function') return [];
let length;
try { length = path.getTotalLength(); } catch (e) { return []; }
if (!isFinite(length) || length <= 0) return [];
try {
const out = [];
let prev = path.getPointAtLength(0);
for (let d = Math.min(spacing, length); d <= length + spacing; d += spacing) {
const next = path.getPointAtLength(Math.min(d, length));
out.push({
x: Math.min(prev.x, next.x) - pad,
y: Math.min(prev.y, next.y) - pad,
w: Math.abs(next.x - prev.x) + pad * 2,
h: Math.abs(next.y - prev.y) + pad * 2
});
prev = next;
if (d >= length) break;
}
return out;
} catch (e) {
return [];
}
}
_svgBounds = function(node) {
if (!node || !node.viewBox) return null;
const vb = node.viewBox.baseVal;
if (vb && vb.width && vb.height) {
return {x1: vb.x, y1: vb.y, x2: vb.x + vb.width, y2: vb.y + vb.height};
}
const w = parseFloat(node.getAttribute('width')), h = parseFloat(node.getAttribute('height'));
if (isFinite(w) && isFinite(h)) return {x1: 0, y1: 0, x2: w, y2: h};
return null;
}
// Search a small spiral of candidate offsets for a spot where the label's box
// neither hits an obstacle nor leaves the viewBox. Deterministic: candidates
// are tried in a fixed order (base, then sideways, then vertical, outward),
// so identical input always produces identical placement. Falls back to the
// requested position when nothing is clear — the caller's halo, if any, keeps
// it legible there.
_resolveLabelPos = function({x, y, w, h, anchor = 'middle', obstacles, bounds = null, step = 12, reach = 4, margin = 3}) {
if (!obstacles || !obstacles.length) return {x, y};
const hits = (cx, cy) => _hitsAny(_anchorBox(cx, cy, w, h, anchor), obstacles, margin);
const inside = (cx, cy) => {
if (!bounds) return true;
const b = _anchorBox(cx, cy, w, h, anchor);
return b.x >= bounds.x1 - 1 && b.y >= bounds.y1 - 1 &&
b.x + b.w <= bounds.x2 + 1 && b.y + b.h <= bounds.y2 + 1;
};
if (!hits(x, y) && inside(x, y)) return {x, y};
for (let k = 1; k <= reach; k++) {
for (const d of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const cx = x + d[0] * k * step, cy = y + d[1] * k * step;
if (!hits(cx, cy) && inside(cx, cy)) return {x: cx, y: cy};
}
}
return {x, y};
}
// Create a free-standing text label with measured size, optional collision
// avoidance, and an optional knockout halo. Returns {node, x, y, width,
// height} — the FINAL placed position, so callers can draw connectors to it.
// avoid: array of {x,y,w,h} rects, or an svg (element/selection) whose drawn
// shapes are harvested as obstacles. Usage: placeLabel(svg, {x: 320, y: 210,
// text: 'softmax(dim=-1)', avoid: svg, halo: true})
placeLabel = function(svg, {
x, y, text,
dx = 0, dy = 0,
anchor = 'middle',
fontSize = 10,
fontWeight = null,
fill = null,
opacity = null,
fontFamily = null,
halo = true,
haloColor = null,
avoid = null,
exclude = null,
step = 12, reach = 4,
id = null,
className = 'diagram-label'
} = {}) {
const sel = svg && svg.node ? svg : d3.select(svg);
const node = sel.node();
const w = measureText(text, {fontSize, fontWeight, fontFamily});
const h = fontSize * 1.3;
let obstacles = null;
if (avoid) obstacles = Array.isArray(avoid) ? avoid : collectObstacles(avoid, {exclude});
const baseX = x + dx, baseY = y + dy;
const pos = obstacles
? _resolveLabelPos({x: baseX, y: baseY, w, h, anchor, obstacles, bounds: _svgBounds(node), step, reach})
: {x: baseX, y: baseY};
const t = sel.append('text')
.attr('x', pos.x)
.attr('y', pos.y)
.attr('text-anchor', anchor)
.attr('dominant-baseline', 'central')
.attr('font-size', `${fontSize}px`)
.style('font-size', `${fontSize}px`)
.attr('pointer-events', 'none')
.attr('class', className)
.text(text);
if (fontWeight != null) t.attr('font-weight', fontWeight);
t.attr('fill', fill != null ? fill : diagramTheme.nodeText);
if (opacity != null) t.attr('opacity', opacity);
if (fontFamily != null) t.attr('font-family', fontFamily);
if (id) t.attr('id', id);
if (halo) applyHalo(t, haloColor);
return {node: t.node(), x: pos.x, y: pos.y, width: w, height: h};
}
// =============================================================================
// FIGURE FIT & SCROLL (anti-clipping)
// =============================================================================
// Expand an svg's viewBox just enough to contain everything actually drawn
// (labels, markers, captions included) — the cure for content that overflows
// the authored bounds and gets clipped at the bottom or right edge. Only ever
// GROWS the viewBox (a figure that already fits is left untouched) and grows
// the width/height attributes with the new aspect so the glyph scale (px per
// user unit) is preserved. Returns true when the viewBox changed. Safe on
// detached svgs (no layout yet → returns false) and after the final render
// step of a stepped diagram.
fitContent = function(svg, {pad = 6} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (!node || typeof node.getBBox !== 'function') return false;
let b;
try { b = node.getBBox(); } catch (e) { return false; }
if (!b || (!b.width && !b.height)) return false;
const vb = node.viewBox && node.viewBox.baseVal;
const hasVB = !!(vb && vb.width && vb.height);
let vx = 0, vy = 0, vw = 0, vh = 0;
if (hasVB) { vx = vb.x; vy = vb.y; vw = vb.width; vh = vb.height; }
else {
const r = node.getBoundingClientRect();
vw = r.width || 0;
vh = r.height || 0;
}
if (!vw || !vh) return false;
const left = Math.min(vx, b.x - pad), top = Math.min(vy, b.y - pad);
const right = Math.max(vx + vw, b.x + b.width + pad);
const bottom = Math.max(vy + vh, b.y + b.height + pad);
const nw = right - left, nh = bottom - top;
if (nw <= vw + 0.5 && nh <= vh + 0.5) return false;
node.setAttribute('viewBox', `${+left.toFixed(1)} ${+top.toFixed(1)} ${+nw.toFixed(1)} ${+nh.toFixed(1)}`);
// Preserve px-per-unit so text does not shrink when the box grows: scale
// the painted size to the new aspect. CSS-sized svgs (e.g. inside
// .diagram-container) track the viewBox ratio automatically via height:auto.
const attrNum = (name) => {
const v = node.getAttribute(name);
if (!v || v.includes('%')) return NaN;
const n = parseFloat(v);
return isFinite(n) ? n : NaN;
};
const wAttr = attrNum('width'), hAttr = attrNum('height');
const scale = hasVB
? (isFinite(wAttr) ? wAttr / vw : (isFinite(hAttr) ? hAttr / vh : null))
: 1;
if (isFinite(scale) && scale > 0) {
node.setAttribute('width', +(nw * scale).toFixed(1));
node.setAttribute('height', +(nh * scale).toFixed(1));
}
return true;
}
// fitContent for every svg under root (a selector string, element, or d3
// selection). Figures can opt out with the class `diagram-no-fit`. Call once
// after a figure's last render step; returns the number of svgs adjusted.
// Usage: fitAll(cellRoot) // after the final step's draw calls
fitAll = function(root, opts = {}) {
let el = root && root.node ? root.node() : root;
if (typeof el === 'string') el = document.querySelector(el);
if (!el || !el.querySelectorAll) return 0;
let n = 0;
el.querySelectorAll('svg').forEach(s => {
if (s.classList.contains('diagram-no-fit')) return;
if (fitContent(s, opts)) n++;
});
return n;
}
// Wrap a figure for display: tags the svg `.diagram-fig` (so the site's
// blanket `svg text { fill: … !important }` resets in custom.scss stop
// flattening the figure's own theme-aware fills) and puts it inside a
// horizontally scrollable box (`.diagram-scroll`) that never exceeds the page
// column — a wide diagram scrolls INSIDE its container instead of widening
// the page or being squeezed illegibly (DESIGN_LANGUAGE.md §5). The svg keeps
// its authored pixel width (inline min-width): narrower column → the
// container scrolls; wider column → the figure sits at its natural size.
// Options: {minWidth = null, maxHeight = null, className = ''}. `maxHeight`
// caps the box height and scrolls vertically instead — for tall figures
// (heatmap stacks) that would otherwise run past the viewport.
// Usage: display(scrollWrap(svg.node())) // wide figure → x-scroll
// display(scrollWrap(svg.node(), {maxHeight: 420})) // tall → y-scroll
scrollWrap = function(svg, {minWidth = null, maxHeight = null, className = ''} = {}) {
const node = svg && svg.node ? svg.node() : svg;
if (node) {
node.classList.add('diagram-fig');
let w = minWidth;
if (w == null) {
const attr = node.getAttribute('width');
if (attr && !attr.includes('%')) w = parseFloat(attr);
else {
const vb = node.viewBox && node.viewBox.baseVal;
w = vb && vb.width ? vb.width : null;
}
}
if (w) node.style.minWidth = `${w}px`;
}
const cls = `diagram-scroll${className ? ' ' + className : ''}`;
let wrapper;
try {
wrapper = htl.html`<div class="${cls}">${node}</div>`;
} catch (e) {
wrapper = document.createElement('div');
wrapper.className = cls;
if (node) wrapper.appendChild(node);
}
// Apply the cap to the actual scroller: htl returns the root element, but
// some environments wrap it — find the .diagram-scroll either way.
const scroller = (wrapper.classList && wrapper.classList.contains('diagram-scroll'))
? wrapper
: ((wrapper.querySelector && wrapper.querySelector('.diagram-scroll')) || wrapper);
if (maxHeight != null) {
scroller.style.maxHeight = `${maxHeight}px`;
scroller.style.overflowY = 'auto';
}
return wrapper;
}
// =============================================================================
// SVG PRIMITIVES
// =============================================================================
// Creates a group with rounded rect and text.
// Options: {x, y, width, height, label, sublabel, id, theme, rx, ry, className,
// fontFamily}
// Text fitting (additive; existing options keep their meaning):
// * width/height omitted → the box auto-fits its text, never narrower/taller
// than the historical 100×50 default, so legacy call sites keep geometry.
// * width given but the label is wider than the box → the label font shrinks
// (floor 9px, sublabel proportionally) to stay INSIDE the box instead of
// spilling over its edges ("Multi-Head Attention" in a 100px node).
// The resolved box is attached as the group's datum: g.datum() → {width,
// height, fontSize, subFontSize} — read it when positioning connectors.
createNode = function(svg, options) {
const {
x = 0,
y = 0,
width = null,
height = null,
label = '',
sublabel = '',
id = null,
theme = diagramTheme,
rx = 6,
ry = 6,
className = 'diagram-node',
fontFamily = null
} = options;
// --- text fitting ---------------------------------------------------------
// Measure in the font the text will actually render in: an explicit
// fontFamily option wins; else the svg's inline font-family (often
// `var(--pg-mono)` on lesson figures — mono is wider, so measuring in the
// body face would under-size the box); else the body font.
const padX = 12;
const family = fontFamily || (svg && svg.style ? svg.style('font-family') : null) || null;
const labelW = label ? measureText(label, {fontSize: 12, fontWeight: 500, fontFamily: family}) : 0;
const subW = sublabel ? measureText(sublabel, {fontSize: 10, fontFamily: family}) : 0;
let w, h, fontSize = 12, subFontSize = 10;
if (width == null) {
// Auto-fit: grow to the text, never below the historical 100px default.
w = Math.max(100, labelW + padX * 2, subW + 16);
} else {
// Authored box: keep its geometry, shrink the type to fit inside.
w = width;
const avail = Math.max(w - padX * 2, 20);
if (labelW > avail) {
fontSize = fitFontSize(label, {maxWidth: avail, fontSize: 12, minFontSize: 9, fontWeight: 500, fontFamily: family});
subFontSize = Math.max(7.5, 10 * fontSize / 12);
}
}
h = height == null ? 50 : height;
// Create group
const g = svg.append('g')
.attr('class', className)
.attr('transform', `translate(${x}, ${y})`);
if (id) g.attr('id', id);
// Add rectangle
g.append('rect')
.attr('x', -w / 2)
.attr('y', -h / 2)
.attr('width', w)
.attr('height', h)
.attr('rx', rx)
.attr('ry', ry)
.attr('fill', theme.nodeFill)
.attr('stroke', theme.nodeStroke)
.attr('stroke-width', 1.5);
// Add main label
if (label) {
const labelY = sublabel ? -6 : 0;
g.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', `${fontSize}px`) // inline style wins over the `.diagram-node text` CSS rule when shrinking
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(label);
}
// Add sublabel
if (sublabel) {
g.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', `${subFontSize}px`)
.attr('opacity', 0.7)
.attr('pointer-events', 'none')
.text(sublabel);
}
// Publish the resolved box (additive; harmless to ignore).
g.datum({width: w, height: h, fontSize, subFontSize});
return g;
}
// Creates a path with arrowhead marker
// Options: {x1, y1, x2, y2, label, theme, curved, curvature, id, className,
// dashed, labelOffset, labelDx, labelDy, labelAnchor, halo, avoid,
// exclude}
createArrow = function(svg, options) {
const {
x1 = 0,
y1 = 0,
x2 = 100,
y2 = 0,
label = '',
theme = diagramTheme,
curved = false,
curvature = 0.3,
id = null,
className = 'diagram-edge',
dashed = false,
// Additive label options — every default preserves the original geometry:
labelOffset = 12, // perpendicular distance of the label from the line
labelDx = 0, // extra offset AFTER the perpendicular shift
labelDy = 0,
labelAnchor = 'middle',
halo = false, // knockout outline (--diagram-halo) behind the label
avoid = null, // dodge obstacles: rects[] or an svg (element/selection)
exclude = null // when harvesting, skip elements matching these selectors
} = options;
// Create unique marker ID
const markerId = `arrow-${Math.random().toString(36).substr(2, 9)}`;
// Ensure defs exists
let defs = svg.select('defs');
if (defs.empty()) {
defs = svg.append('defs');
}
// Add arrowhead marker
defs.append('marker')
.attr('id', markerId)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Create group for arrow
const g = svg.append('g')
.attr('class', className);
if (id) g.attr('id', id);
// Calculate path
let pathD;
if (curved) {
// Quadratic Bezier curve
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
// Perpendicular offset for curve
const cx = midX - dy * curvature;
const cy = midY + dx * curvature;
pathD = `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
} else {
// Straight line
pathD = `M${x1},${y1} L${x2},${y2}`;
}
// Add path
const path = g.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', theme.edgeStroke)
.attr('stroke-width', 1.5)
.attr('marker-end', `url(#${markerId})`);
if (dashed) {
path.attr('stroke-dasharray', '5,3');
}
// Add label if provided. Geometry when no additive option is given:
// midpoint of the line shifted 12px perpendicular — exactly as before.
if (label) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
// Offset label perpendicular to the line
const angle = Math.atan2(y2 - y1, x2 - x1);
const nx = Math.sin(angle), ny = -Math.cos(angle);
let lx = mx + nx * labelOffset + labelDx;
let ly = my + ny * labelOffset + labelDy;
// Collision avoidance (opt-in): nudge the label until its box clears every
// obstacle, so edge captions stop sitting on nodes, other labels, or other
// arrows. avoid: rects[] or an svg (element/selection) to harvest.
if (avoid) {
const obstacles = Array.isArray(avoid) ? avoid : collectObstacles(avoid, {exclude});
const tw = measureText(label, {fontSize: 10, fontFamily: (svg && svg.style ? svg.style('font-family') : null) || null});
const placed = _resolveLabelPos({
x: lx, y: ly, w: tw, h: 13, anchor: labelAnchor,
obstacles, bounds: _svgBounds(svg.node()), step: 12, reach: 4
});
lx = placed.x;
ly = placed.y;
}
const labelText = g.append('text')
.attr('x', lx)
.attr('y', ly)
.attr('text-anchor', labelAnchor)
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.style('font-size', '10px') // inline: wins over the `.diagram-edge text` CSS rule
.text(label);
if (halo) applyHalo(labelText);
}
return g;
}
// =============================================================================
// STEP ANIMATION CONTROLLER
// =============================================================================
// Factory function returning controller for step-through animations
// Options: {total, initialStep, speed, loop, onStepChange}
createStepController = function(options = {}) {
const {
total = 1,
initialStep = 0,
speed = 1000,
loop = true,
onStepChange = null
} = options;
let current = initialStep;
let isPlaying = false;
let intervalId = null;
let currentSpeed = speed;
const notifyChange = () => {
if (onStepChange && typeof onStepChange === 'function') {
onStepChange(current);
}
};
const controller = {
get current() { return current; },
get isPlaying() { return isPlaying; },
get total() { return total; },
get speed() { return currentSpeed; },
setStep(step) {
current = Math.max(0, Math.min(total - 1, step));
notifyChange();
return current;
},
next() {
if (current < total - 1) {
current++;
} else if (loop) {
current = 0;
}
notifyChange();
return current;
},
prev() {
if (current > 0) {
current--;
} else if (loop) {
current = total - 1;
}
notifyChange();
return current;
},
play() {
if (isPlaying) return;
isPlaying = true;
intervalId = setInterval(() => {
controller.next();
}, currentSpeed);
},
stop() {
isPlaying = false;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
},
toggle() {
if (isPlaying) {
controller.stop();
} else {
controller.play();
}
},
reset() {
controller.stop();
current = initialStep;
notifyChange();
},
setSpeed(newSpeed) {
currentSpeed = newSpeed;
if (isPlaying) {
controller.stop();
controller.play();
}
}
};
return controller;
}
// =============================================================================
// FLOW DIAGRAM COMPONENT
// =============================================================================
// Higher-level component for node/edge diagrams
// Options: {nodes, edges, width, height, activeNodes, activeEdges, theme,
// nodeWidth, nodeHeight, padding}
// Edge options: {id, source, target, label, labelHalo, labelDx, labelDy,
// dashed, curved, curvature}. Labels avoid unrelated nodes, other
// edge paths and sibling captions after the full diagram is drawn.
// Edges are trimmed at the box borders
// along their own direction (see the edge-trimming block below), and
// a curved edge's label rides the curve's midpoint.
// Text fitting (additive): when nodeWidth is OMITTED, each node's box grows to
// fit its label/sublabel (never narrower than the historical 100px default)
// instead of letting long labels spill outside the rect. When nodeWidth IS
// given, the authored geometry is respected and the label font shrinks (floor
// 9px) to stay inside the box.
FlowDiagram = function(options) {
const {
nodes = [],
edges = [],
width = 600,
height = 400,
activeNodes = [],
activeEdges = [],
theme = diagramTheme,
nodeWidth = 100,
nodeHeight = 50,
padding = 20
} = options;
const autoNodeWidth = options.nodeWidth == null;
const nodePadX = 12;
// Instrument chrome (§3): a figure's labels are chrome, not prose — mono,
// like every other figure in the book and the playground's panel. FlowDiagram
// was the one shared component that never set it, so its figures (m02, m29)
// drew their node labels, sublabels and edge captions in the body sans while
// the hand-built figures beside them were mono. The token is passed to the
// measurements too: mono's 0.6em advance is wider than the sans it replaced,
// so the auto-fit/shrink math has to measure the face that will render.
const chromeFont = 'var(--pg-mono)';
// Resolved {w, h, fontSize, subFontSize} per node (index-aligned).
const effSizes = nodes.map(node => {
const base = node.width || nodeWidth;
const labelW = node.label ? measureText(node.label, {fontSize: 12, fontWeight: 500, fontFamily: chromeFont}) : 0;
const subW = node.sublabel ? measureText(node.sublabel, {fontSize: 10, fontFamily: chromeFont}) : 0;
let w = base, fontSize = 12, subFontSize = 10;
if (autoNodeWidth) {
w = Math.max(base, labelW + nodePadX * 2, subW + 16);
} else {
const avail = Math.max(w - nodePadX * 2, 20);
if (labelW > avail) {
fontSize = fitFontSize(node.label, {maxWidth: avail, fontSize: 12, minFontSize: 9, fontWeight: 500, fontFamily: chromeFont});
subFontSize = Math.max(7.5, 10 * fontSize / 12);
}
}
return {w, h: node.height || nodeHeight, fontSize, subFontSize};
});
// Create SVG element
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.style('font-family', chromeFont)
.attr('class', 'flow-diagram diagram-fig'); // .diagram-fig: managed figure — opts out of the blanket svg-text color resets in custom.scss
// Add background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', theme.bg)
.attr('rx', 8);
// Create defs for markers
const defs = svg.append('defs');
// Standard arrow marker
defs.append('marker')
.attr('id', 'flow-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Highlighted arrow marker
defs.append('marker')
.attr('id', 'flow-arrow-highlight')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.highlight);
// --- Edge trimming --------------------------------------------------------
// An edge is trimmed at the border of the box it leaves and of the box it
// enters, so the arrowhead lands ON the target box instead of inside it.
// The trim distance must be measured along the edge's OWN direction: a
// vertical edge leaves through the box's top/bottom edge (h/2 away), not its
// side (w/2). Trimming every direction by w/2 (the old geometry) detached the
// head from the target — 17-63px of floating arrowhead on m29's platform
// figure — and, whenever the two centres were closer than w/2 + w/2 + 15, it
// pushed the start past the end and INVERTED the path, so the arrow pointed
// backwards (m29's identity→runtime and observability→memory edges).
// Ray/box exit distance: |centre → border| along unit direction (ux, uy).
const boxExit = (size, ux, uy) => {
const ax = Math.abs(ux), ay = Math.abs(uy);
const tx = ax > 1e-6 ? (size.w / 2) / ax : Infinity;
const ty = ay > 1e-6 ? (size.h / 2) / ay : Infinity;
return Math.min(tx, ty);
};
// Unit vector from (x, y), falling back to (fx, fy) for a degenerate span.
const unitDir = (x, y, fx, fy) => {
const l = Math.sqrt(x * x + y * y);
return l > 1e-6 ? [x / l, y / l] : [fx, fy];
};
const EDGE_START_GAP = 4; // breathing room where the line leaves the source
const EDGE_END_GAP = 1; // the head's tip extends ~2px past the path end
// Edges layer (draw first so nodes appear on top)
const edgesLayer = svg.append('g').attr('class', 'edges-layer');
const flowEdges = [];
const edgeLabels = [];
// Nodes layer
const nodesLayer = svg.append('g').attr('class', 'nodes-layer');
// Draw edges
edges.forEach((edge, i) => {
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const isActive = activeEdges.includes(edge.id) || activeEdges.includes(i);
const edgeColor = isActive ? theme.highlight : theme.edgeStroke;
const markerId = isActive ? 'flow-arrow-highlight' : 'flow-arrow';
// Calculate edge path
const x1 = sourceNode.x;
const y1 = sourceNode.y;
const x2 = targetNode.x;
const y2 = targetNode.y;
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const sizeS = effSizes[nodes.indexOf(sourceNode)];
const sizeT = effSizes[nodes.indexOf(targetNode)];
// Control point from the node CENTRES (the authored bulge is a property of
// the node layout, not of where the line happens to be trimmed).
const curvature = edge.curvature || 0.2;
const ctrlX = (x1 + x2) / 2 - dy * curvature;
const ctrlY = (y1 + y2) / 2 + dx * curvature;
// Optional waypoints describe an authored polyline route in diagram
// coordinates. They are useful when a long relationship must route around
// intervening node boxes; existing straight and curved edges keep their
// historical geometry when `via` is omitted.
const via = Array.isArray(edge.via) ? edge.via.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)) : [];
// A curved edge leaves toward its control point and arrives from it, so the
// trim follows those tangents — the head then meets the box square-on
// instead of sliding along the chord. Straight edges trim along the chord.
const dirStart = via.length
? unitDir(via[0].x - x1, via[0].y - y1, dx / len, dy / len)
: edge.curved
? unitDir(ctrlX - x1, ctrlY - y1, dx / len, dy / len)
: [dx / len, dy / len];
const dirEnd = via.length
? unitDir(x2 - via[via.length - 1].x, y2 - via[via.length - 1].y, dx / len, dy / len)
: edge.curved
? unitDir(x2 - ctrlX, y2 - ctrlY, dx / len, dy / len)
: [dx / len, dy / len];
let dStart = boxExit(sizeS, dirStart[0], dirStart[1]) + EDGE_START_GAP;
let dEnd = boxExit(sizeT, dirEnd[0], dirEnd[1]) + EDGE_END_GAP;
// Boxes that overlap (or nearly touch) leave no room for a line: scale both
// trims so a short stub survives instead of crossing the two anchors over.
const room = Math.max(len - 4, 0);
if (dStart + dEnd > room) {
const k = room / (dStart + dEnd);
dStart *= k;
dEnd *= k;
}
const startX = x1 + dirStart[0] * dStart;
const startY = y1 + dirStart[1] * dStart;
const endX = x2 - dirEnd[0] * dEnd;
const endY = y2 - dirEnd[1] * dEnd;
const edgeGroup = edgesLayer.append('g')
.attr('class', `edge ${isActive ? 'highlighted' : ''}`);
if (edge.id) edgeGroup.attr('id', edge.id);
// Draw path
const pathD = via.length
? [`M${startX},${startY}`, ...via.map(p => `L${p.x},${p.y}`), `L${endX},${endY}`].join(' ')
: edge.curved
? `M${startX},${startY} Q${ctrlX},${ctrlY} ${endX},${endY}`
: `M${startX},${startY} L${endX},${endY}`;
const path = edgeGroup.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', edgeColor)
.attr('stroke-width', isActive ? 2.5 : 1.5)
.attr('marker-end', `url(#${markerId})`);
flowEdges.push({path: path.node()});
if (edge.dashed) {
path.attr('stroke-dasharray', '5,3');
}
if (isActive) {
path.attr('filter', `drop-shadow(0 0 4px ${theme.highlightGlow})`);
}
// Add label if present
if (edge.label) {
// A curved edge's caption rides the CURVE, not the chord: on a bowed
// edge the chord midpoint can sit inside an unrelated node box (m29's
// "recall" label was painted underneath the Agent runtime box, i.e.
// invisible) while the curve itself is clear. The quadratic's midpoint
// is (P0 + 2C + P2)/4, and its tangent there is parallel to the chord,
// so the perpendicular offset below stays correct for both cases.
const labelX = edge.curved && !via.length
? (startX + 2 * ctrlX + endX) / 4
: (startX + endX) / 2;
const labelY = edge.curved && !via.length
? (startY + 2 * ctrlY + endY) / 4
: (startY + endY) / 2;
const labelPath = path.node();
const labelAt = labelPath.getPointAtLength(labelPath.getTotalLength() / 2);
const labelBefore = labelPath.getPointAtLength(Math.max(0, labelPath.getTotalLength() / 2 - 1));
const labelAfter = labelPath.getPointAtLength(Math.min(labelPath.getTotalLength(), labelPath.getTotalLength() / 2 + 1));
const angle = Math.atan2(labelAfter.y - labelBefore.y, labelAfter.x - labelBefore.x);
// Offset the caption perpendicular to the line, by the caption's OWN
// support along that direction: the text is horizontal, so a fixed 14px
// offset only clears the line for a horizontal edge — on a vertical edge
// the line ran straight through the caption (m29's "authorized" label sat
// on its own arrow). |nx|·w/2 + |ny|·h/2 + gap reproduces the historical
// ~14px for horizontal edges and widens as the edge turns vertical.
const perpX = Math.sin(angle);
const perpY = -Math.cos(angle);
const labelWidth = measureText(edge.label, {
fontSize: 10,
fontFamily: (svg && svg.style ? svg.style('font-family') : null) || null
});
const offset = Math.abs(perpX) * (labelWidth / 2) + Math.abs(perpY) * 6.5 + 8;
const offsetX = perpX * offset;
const offsetY = perpY * offset;
const labelXPlaced = (via.length ? labelAt.x : labelX) + offsetX + (edge.labelDx || 0);
const labelYPlaced = (via.length ? labelAt.y : labelY) + offsetY + (edge.labelDy || 0);
const labelText = edgeGroup.append('text')
.attr('x', labelXPlaced)
.attr('y', labelYPlaced)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isActive ? theme.highlightText : theme.nodeText)
.style('font-size', '10px')
.text(edge.label);
edgeLabels.push({
node: labelText.node(),
x: labelXPlaced,
y: labelYPlaced,
width: labelWidth,
sourceIndex: nodes.indexOf(sourceNode),
targetIndex: nodes.indexOf(targetNode),
path: path.node()
});
// Opt-in knockout halo (edge.labelHalo) keeps the caption legible where
// another edge or label passes underneath.
if (edge.labelHalo) applyHalo(labelText);
}
});
// Draw nodes
nodes.forEach((node, i) => {
const isActive = activeNodes.includes(node.id) || activeNodes.includes(i);
const nodeFill = isActive ? theme.highlight : theme.nodeFill;
const nodeStroke = isActive ? theme.highlight : theme.nodeStroke;
const textFill = isActive ? theme.textOnHighlight : theme.nodeText;
const {w, h, fontSize, subFontSize} = effSizes[i];
const nodeGroup = nodesLayer.append('g')
.attr('class', `node ${isActive ? 'highlighted' : ''}`)
.attr('transform', `translate(${node.x}, ${node.y})`);
if (node.id) nodeGroup.attr('id', node.id);
// Node rectangle
const rect = nodeGroup.append('rect')
.attr('x', -w / 2)
.attr('y', -h / 2)
.attr('width', w)
.attr('height', h)
.attr('rx', 6)
.attr('ry', 6)
.attr('fill', nodeFill)
.attr('stroke', nodeStroke)
.attr('stroke-width', isActive ? 2 : 1.5);
if (isActive) {
rect.attr('filter', `drop-shadow(0 0 6px ${theme.highlightGlow})`);
}
// Main label
const labelY = node.sublabel ? -6 : 0;
nodeGroup.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.style('font-size', `${fontSize}px`)
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(node.label || '');
// Sublabel
if (node.sublabel) {
nodeGroup.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.style('font-size', `${subFontSize}px`)
.attr('opacity', isActive ? 0.9 : 0.7)
.attr('pointer-events', 'none')
.text(node.sublabel);
}
});
// Resolve captions only after every node and edge exists, so every candidate
// can avoid the complete diagram. The authored perpendicular offset and
// labelDx/labelDy remain the base position; with no collision the resolver
// returns it unchanged. Endpoint boxes are excluded because captions belong
// to those edges, while unrelated nodes, other edge paths and sibling
// captions are obstacles.
if (edgeLabels.length) {
const nodeObstacles = nodes.flatMap((node, i) => {
const {w, h} = effSizes[i];
return [{x: node.x - w / 2, y: node.y - h / 2, w, h, index: i}];
});
const pathObstacles = new Map(flowEdges.map(({path}) => [path, _pathObstacleRects(path)]));
edgeLabels.forEach((label, i) => {
const obstacles = nodeObstacles
.filter(box => box.index !== label.sourceIndex && box.index !== label.targetIndex)
.map(({index, ...box}) => box);
flowEdges.forEach(({path}) => {
if (path !== label.path) obstacles.push(...(pathObstacles.get(path) || []));
});
edgeLabels.forEach((other, j) => {
if (i === j) return;
const pos = other.node;
const x = parseFloat(pos.getAttribute('x'));
const y = parseFloat(pos.getAttribute('y'));
obstacles.push(_anchorBox(x, y, other.width, 13, 'middle'));
});
const placed = _resolveLabelPos({
x: label.x,
y: label.y,
w: label.width,
h: 13,
anchor: 'middle',
obstacles,
bounds: _svgBounds(svg.node()),
step: 12,
reach: 4
});
label.node.setAttribute('x', placed.x);
label.node.setAttribute('y', placed.y);
});
}
return svg.node();
}
// =============================================================================
// EXPORTS
// =============================================================================
// Export everything as a single object for lessons to use
diagramLib = {
// Core dependencies
d3,
// Theme utilities
isDarkMode,
getCSSVar,
diagramTheme,
contrastInk,
// Text measurement & fitting
measureText,
estimateTextWidth,
fitFontSize,
wrapText,
wrapByChars,
drawWrappedText,
fitGutter,
// SVG primitives
createNode,
createArrow,
applyHalo,
placeLabel,
collectObstacles,
// Animation controller
createStepController,
// Components
FlowDiagram,
// Figure fit & scroll (anti-clipping)
fitContent,
fitAll,
scrollWrap
}