Illustrative · MedallionATM
One dataset at several quality levels: each tier a node with its bucket (eyebrow) and three facts as field rows - tool, format, writer. The tones bronze, silver and gold name the tiers; the gold tier is the focal one.
On this page (14)
§Example
§Bronze, silver, gold
Field rows without a type are plain lines; with one (tool / NiFi) the value sits right-aligned in mono.
§Step by step
§Interactive
A click activates a box or an arrow: what it touches stays lit, everything else steps back. The buttons outside the figure drive the same activation - data-diagram-for + data-diagram-action="next|prev|clear" - and the <output data-diagram-for> names what is active. Keyboard: Tab to a box, Enter, arrows, Escape.
§Medallion, interactive
data-interactive on the figure; nothing else to write - the controls and the output are declared in markup, no script.
§Inspect and edit
Select, inspect, edit: a click hands the selection to a Property Grid in a closable Panel - df$.shadcn.diagram.properties() reads the box or the arrow as JSON, propertySchema() gives the grid its options, setProperties() writes an edit back and the diagram redraws. The × closes the panel, the Inspector button toggles it.
§Medallion, inspect and edit
Edit a name, the tone, the shape - or an arrow's label, line and ends: the diagram follows.
§Flow
Every edge flows at once: data-flow (ms a pass), data-flow-tokens (tokens on the wire) and data-flow-delay (when this wire starts) hand the stream from one connection to the next. Tokens never run under reduced motion.
§Medallion, flowing
Each edge carries data-flow, two tokens and a start offset 700 ms after the previous edge.
§Delta
§PII never reaches silver
Field rows change too: data-change + data-before on a row of the field list.
Rendered by the diagram component - plain HTML nodes, an edge list, wires drawn from the measured layout; colors from the theme tokens, so every theme and dark mode apply. Visual grammar adapted from diagram-design by Cathryn Lavery (MIT).
§CSS view file
The diagram stylesheet - every type on this page is a preset in it.
/* -- Diagram ----------------------------------------------------------------- Diagrams from plain HTML: nodes on a CSS grid (or a free / radial canvas), wires drawn by diagram.js into an SVG layer behind them. Paper + ink + one focal accent; every color comes from the theme tokens (dark mode follows), the accent and the change colors are component-local literals. Visual grammar adapted from diagram-design by Cathryn Lavery (MIT). */@layer components { /* the diagram measures its own boxes: border-box everywhere inside, zero specificity */ :where(.diagram, .diagram *, .diagram *::before, .diagram *::after) { box-sizing: border-box; } .diagram { --diagram-paper: var(--card); --diagram-ink: var(--foreground); --diagram-muted: var(--muted-foreground); --diagram-rule: var(--border); --diagram-soft: color-mix(in oklab, var(--foreground) 4%, var(--card)); --diagram-line: color-mix(in oklab, var(--foreground) 72%, var(--card)); --diagram-wire: color-mix(in oklab, var(--foreground) 52%, var(--card)); /* every color is the theme's: the accent and the change colors are chart colors */ --diagram-accent: var(--chart-2); --diagram-accent-tint: color-mix(in oklab, var(--diagram-accent) 10%, var(--diagram-paper)); --diagram-link: var(--chart-4); --diagram-added: var(--chart-3); --diagram-removed: var(--destructive); --diagram-changed: var(--chart-1); --diagram-gap-x: 4rem; --diagram-gap-y: 2.5rem; --diagram-col-min: 7.5rem; --diagram-node-w: 10.5rem; /* steps reveal: future elements are hidden, each step fades in, then holds */ --diagram-element-ms: 900ms; /* one element after the other */ --diagram-step-ms: 800ms; /* how long an element takes to appear */ --diagram-hold: 400ms; /* the rest after a step's last element */ --diagram-future-opacity: 0; --diagram-dim-opacity: 0.25; display: grid; gap: 0.875rem; min-width: 0; margin: 0; overflow-x: auto; color: var(--diagram-ink); font-family: var(--font-mono); container-type: inline-size; letter-spacing: var(--tracking-normal); } /* on a presentation slide the diagram takes the slide's palette: paper and ink of a paper slide (the deck's accent too), of an ink slide on one */ [data-slide] .diagram { --diagram-paper: var(--presentation-paper, var(--card)); --diagram-ink: var(--presentation-paper-foreground, var(--foreground)); --diagram-accent: var(--presentation-accent, var(--chart-2)); } [data-slide][data-theme="ink"] .diagram { --diagram-paper: var(--presentation-ink, var(--card)); --diagram-ink: var(--presentation-ink-foreground, var(--foreground)); --diagram-accent: var(--chart-2); } [data-slide] .diagram { --diagram-muted: color-mix(in oklab, var(--diagram-ink) 62%, var(--diagram-paper)); --diagram-rule: color-mix(in oklab, var(--diagram-ink) 22%, var(--diagram-paper)); --diagram-soft: color-mix(in oklab, var(--diagram-ink) 4%, var(--diagram-paper)); --diagram-line: color-mix(in oklab, var(--diagram-ink) 72%, var(--diagram-paper)); --diagram-wire: color-mix(in oklab, var(--diagram-ink) 52%, var(--diagram-paper)); } /* -- caption -------------------------------------------------------------- */ .diagram-caption { display: grid; gap: 0.25rem; position: sticky; left: 0; } .diagram-eyebrow { color: var(--diagram-muted); font: 500 0.625rem/1.4 var(--font-mono); letter-spacing: 0.18em; text-transform: uppercase; } .diagram-title { font: 400 1.5rem/1.15 var(--font-mono); letter-spacing: -0.015em; text-wrap: balance; } .diagram-dek { max-width: 44rem; color: var(--diagram-muted); font-size: 0.8125rem; line-height: 1.55; text-wrap: pretty; } /* -- canvas: a grid by default --------------------------------------------- */ .diagram-canvas { /* the horizontal gap and the column minimum give in a narrow figure (cqi = the figure's width): --diagram-gap-x / --diagram-col-min are the preferred sizes, 3rem / 5rem the floors - a four- or five-column diagram fits a docs column with even whitespace on both sides instead of running into (and past) its right border; a column never gets narrower than its content */ --_diagram-gap-x: clamp(min(3rem, var(--diagram-gap-x)), 6cqi, var(--diagram-gap-x)); --_diagram-col-min: clamp(min(5rem, var(--diagram-col-min)), 12cqi, var(--diagram-col-min)); position: relative; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(var(--_diagram-col-min), 1fr)); grid-auto-rows: minmax(3.25rem, auto); gap: var(--diagram-gap-y) var(--_diagram-gap-x); min-width: min-content; padding: 2rem; /* extra room on a side for absolutely placed content (free / wardley nodes, timeline ticks): diagram.ts sets it when something comes closer than its CLEARANCE to the border - padding cannot move absolute boxes */ --_inset-top: 0px; --_inset-right: 0px; --_inset-bottom: 0px; --_inset-left: 0px; background-color: var(--diagram-paper); background-image: radial-gradient(color-mix(in oklab, var(--diagram-ink) 9%, transparent) 1px, transparent 1.2px); background-size: 1rem 1rem; border: 1px solid var(--diagram-rule); border-radius: var(--radius-lg); /* grid placement - each element's own --col/--row win; nothing inherits */ & * { --col: initial; --row: initial; --span: initial; --rspan: initial; --x: initial; --y: initial; --cols: initial; } & > *, & .diagram-group > * { grid-column: var(--col, auto) / span var(--span, 1); grid-row: var(--row, auto) / span var(--rspan, 1); } } .diagram[data-paper="plain"] .diagram-canvas { background-image: none; } .diagram[data-frame="none"] .diagram-canvas { border: 0; padding-inline: 0; background-color: transparent; } /* -- nodes ------------------------------------------------------------------ */ .diagram-node { position: relative; z-index: 2; display: flex; flex-direction: column; justify-content: center; gap: 0.125rem; min-width: 0; min-block-size: 3.25rem; inline-size: min(100%, var(--w, var(--diagram-node-w))); align-self: center; justify-self: center; padding: 0.5rem 0.75rem; background: var(--diagram-paper); border: 1px solid var(--diagram-line); border-radius: var(--radius-md); text-align: start; transition: opacity var(--diagram-step-ms) ease, outline-color var(--diagram-step-ms) ease; &[data-tone="accent"] { background: var(--diagram-accent-tint); border-color: var(--diagram-accent); } &[data-tone="link"] { border-color: var(--diagram-link); } &[data-tone="muted"] { background: var(--diagram-soft); border-color: color-mix(in oklab, var(--diagram-ink) 40%, var(--diagram-paper)); } &[data-tone="external"] { background: var(--diagram-soft); border-style: dashed; border-color: color-mix(in oklab, var(--diagram-ink) 38%, var(--diagram-paper)); } &[data-tone="warn"] { border-color: var(--diagram-changed); border-style: dashed; background: color-mix(in oklab, var(--diagram-changed) 8%, var(--diagram-paper)); } &[data-tone="ok"] { border-color: var(--diagram-added); background: color-mix(in oklab, var(--diagram-added) 8%, var(--diagram-paper)); } &[data-tone="danger"] { border-color: var(--diagram-removed); background: color-mix(in oklab, var(--diagram-removed) 7%, var(--diagram-paper)); } &[data-tone="bronze"] { border-color: var(--chart-5); background: color-mix(in oklab, var(--chart-5) 10%, var(--diagram-paper)); } &[data-tone="silver"] { border-color: var(--chart-3); background: color-mix(in oklab, var(--chart-3) 12%, var(--diagram-paper)); } &[data-tone="gold"] { border-color: var(--chart-1); background: color-mix(in oklab, var(--chart-1) 14%, var(--diagram-paper)); } /* a corner badge: "×3" replicas, "4 in" fan-in, a version */ &[data-badge]::after { content: attr(data-badge); position: absolute; top: -0.5rem; right: 0.5rem; padding: 0 0.3rem; background: var(--diagram-paper); border: 1px solid currentColor; border-radius: var(--radius-sm); color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.06em; } } .diagram-node-eyebrow { color: var(--diagram-muted); font: 500 0.5625rem/1.35 var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; } .diagram-node[data-tone="accent"] > .diagram-node-eyebrow { color: color-mix(in oklab, var(--diagram-accent) 80%, var(--diagram-ink)); } .diagram-node-name { font-size: 0.8125rem; font-weight: 600; line-height: 1.25; text-wrap: balance; } .diagram-node-meta { color: var(--diagram-muted); font: 0.6875rem/1.35 var(--font-mono); text-wrap: pretty; } /* compartments: entity / table / class fields and operations */ .diagram-node:has(> .diagram-node-fields, > .diagram-node-ops) { justify-content: flex-start; gap: 0; padding: 0; overflow: visible; & > .diagram-node-eyebrow, & > .diagram-node-name, & > .diagram-node-meta { padding-inline: 0.75rem; } & > :first-child { padding-block-start: 0.5rem; } & > .diagram-node-name:has(+ .diagram-node-fields, + .diagram-node-ops), & > .diagram-node-meta:has(+ .diagram-node-fields, + .diagram-node-ops) { padding-block-end: 0.5rem; } } .diagram-node-fields, .diagram-node-ops, .diagram-node-chips { margin: 0; padding: 0.375rem 0; list-style: none; border-top: 1px solid color-mix(in oklab, var(--diagram-ink) 18%, transparent); font-size: 0.75rem; & > li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; padding: 0.1875rem 0.75rem; min-block-size: 1.375rem; box-sizing: border-box; } & > li > span:nth-child(2) { margin-inline-start: auto; color: var(--diagram-muted); font: 0.625rem/1.4 var(--font-mono); } } .diagram-node-ops > li { font-family: var(--font-mono); font-size: 0.6875rem; } .diagram-node-fields > li[data-key]::before { content: attr(data-key); flex: none; padding: 0 0.25rem; border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent); border-radius: var(--radius-sm); color: var(--diagram-muted); font: 600 0.5rem/1.5 var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; } .diagram-node-fields > li[data-key="pk"]::before { border-color: var(--diagram-accent); color: var(--diagram-accent); } .diagram-node-fields > li.diagram-node-more { color: var(--diagram-muted); font: 0.625rem/1.4 var(--font-mono); } .diagram-node-chips { display: grid; gap: 0.25rem; padding: 0.375rem 0.5rem 0.5rem; border-top: 0; & > li { padding: 0.125rem 0.5rem; background: var(--diagram-soft); border: 1px solid color-mix(in oklab, var(--diagram-ink) 22%, transparent); border-radius: calc(var(--radius-sm) + 1px); font-size: 0.6875rem; } } .diagram-node:not(:has(> .diagram-node-fields)) > .diagram-node-chips { padding-inline: 0; padding-block-end: 0; } .diagram-node[data-abstract] > .diagram-node-name { font-style: italic; } .diagram-node[data-shape="class"], .diagram-node[data-shape="entity"] { & > .diagram-node-name, & > .diagram-node-eyebrow { text-align: center; } } /* -- shapes ----------------------------------------------------------------- */ .diagram-node[data-shape="pill"] { border-radius: calc(var(--radius) * 4); align-items: center; text-align: center; padding-inline: 1.25rem; } .diagram-node[data-shape="diamond"] { align-items: center; justify-content: center; min-block-size: 5.25rem; padding: 0.75rem 1.5rem; background: transparent; border: 0; text-align: center; &::before, &::after { content: ""; position: absolute; z-index: -1; inset: 0; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); background: var(--diagram-line); } &::after { inset: 1.5px 2.5px; background: var(--diagram-paper); } &[data-tone="accent"]::before { background: var(--diagram-accent); } &[data-tone="accent"]::after { background: var(--diagram-accent-tint); } } .diagram-node[data-shape="store"] { padding-block-start: 1.375rem; border-radius: 50% / 0.6rem; &::before { content: ""; position: absolute; inset: -1px -1px auto; height: 1.2rem; border: 1px solid currentColor; border-color: inherit; border-radius: 50%; } } .diagram-node:is([data-shape="start"], [data-shape="end"], [data-shape="dot"], [data-shape="ghost"]) { min-block-size: 0; padding: 0; justify-self: center; } .diagram-node[data-shape="start"] { inline-size: 1rem; block-size: 1rem; border-radius: 50%; background: var(--diagram-ink); border-color: var(--diagram-ink); } .diagram-node[data-shape="end"] { inline-size: 1.25rem; block-size: 1.25rem; border-radius: 50%; border: 1.5px solid var(--diagram-ink); background: var(--diagram-ink); box-shadow: inset 0 0 0 3px var(--diagram-paper); } .diagram-node:is([data-shape="start"], [data-shape="end"]) > * { position: absolute; top: calc(100% + 0.25rem); left: 50%; translate: -50% 0; white-space: nowrap; } .diagram-node[data-shape="dot"] { inline-size: 0.75rem; block-size: 0.75rem; border-radius: 50%; background: var(--diagram-paper); border: 1.25px solid var(--diagram-ink); &[data-tone="accent"] { background: var(--diagram-accent); border-color: var(--diagram-accent); } & > .diagram-node-name, & > .diagram-node-eyebrow { position: absolute; bottom: calc(100% + 0.3rem); left: 50%; translate: -50% 0; white-space: nowrap; font-size: 0.75rem; } & > .diagram-node-meta { position: absolute; top: calc(100% + 0.3rem); left: 50%; translate: -50% 0; white-space: nowrap; font-size: 0.625rem; } } /* a dot's name on the side its wires leave free (set by diagram.js, or authored) */ .diagram-node[data-shape="dot"][data-label-side="below"] { & > .diagram-node-name { bottom: auto; top: calc(100% + 0.3rem); } & > .diagram-node-meta { top: calc(100% + 1.45rem); } } .diagram-node[data-shape="dot"]:is([data-label-side="left"], [data-label-side="right"]) { & > .diagram-node-name { bottom: auto; top: 50%; translate: 0 -50%; } & > .diagram-node-meta { top: calc(50% + 0.65rem); translate: 0 0; } } .diagram-node[data-shape="dot"][data-label-side="right"] > * { left: calc(100% + 0.45rem); } .diagram-node[data-shape="dot"][data-label-side="left"] > * { left: auto; right: calc(100% + 0.45rem); } .diagram-node[data-shape="ghost"] { inline-size: 1px; block-size: 1px; border: 0; background: none; overflow: hidden; } .diagram-node[data-shape="bar"] { --diagram-node-w: 100%; flex-direction: row; align-items: center; justify-content: space-between; gap: 1rem; min-block-size: 2.5rem; background: var(--diagram-soft); } .diagram-node[data-shape="note"] { border: 0; background: none; color: var(--diagram-muted); font: italic 0.9375rem/1.4 var(--font-mono); } .diagram-node[data-shape="circle"] { aspect-ratio: 1; border-radius: 50%; align-items: center; text-align: center; justify-self: center; inline-size: 7.5rem; } /* -- groups: zones, lanes, columns, containment ---------------------------- */ .diagram-group { position: relative; display: grid; align-content: start; gap: 0.75rem; min-width: 0; align-self: stretch; padding: 1.5rem 1rem 1rem; background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent); border: 1px dashed color-mix(in oklab, var(--diagram-ink) 30%, transparent); border-radius: var(--radius-lg); transition: opacity var(--diagram-step-ms) ease; &[data-tone="accent"] { border-style: solid; border-color: var(--diagram-accent); background: color-mix(in oklab, var(--diagram-accent) 5%, transparent); } &[data-tone="solid"] { border-style: solid; } } /* a zone drawn behind nodes placed on the same cells reaches into the gaps */ .diagram-group:not(:has(.diagram-node, .diagram-group)) { margin: calc(var(--diagram-gap-y) * -0.55) calc(var(--_diagram-gap-x) * -0.42); } .diagram-group-label { position: absolute; top: 0; left: 0.75rem; translate: 0 -50%; padding: 0 0.375rem; background: var(--diagram-paper); color: var(--diagram-muted); font: 500 0.5625rem/1.5 var(--font-mono); letter-spacing: 0.16em; text-transform: uppercase; white-space: nowrap; } .diagram-group[data-tone="accent"] > .diagram-group-label { color: var(--diagram-accent); } .diagram-group-meta { padding: 0 0.3rem; border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent); border-radius: var(--radius-sm); color: var(--diagram-muted); font: 500 0.5625rem/1.5 var(--font-mono); } .diagram-group[data-over] .diagram-group-meta { border-color: var(--diagram-accent); color: var(--diagram-accent); } /* swimlanes: full-width bands, the label in the first column */ .diagram-group[data-shape="lane"] { margin: calc(var(--diagram-gap-y) * -0.5) 0; border: 0; border-block-start: 1px solid var(--diagram-rule); border-radius: 0; background: none; padding: 0; &:nth-of-type(even) { background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent); } & > .diagram-group-label { top: 50%; left: 0; translate: 0 -50%; background: none; white-space: normal; max-width: calc(var(--_diagram-col-min) - 1rem); } } /* kanban columns, nested containment: groups that hold their nodes */ .diagram-group[data-shape="column"] { --diagram-node-w: 100%; padding: 0.75rem; border: 0; border-radius: var(--radius-md); background: color-mix(in oklab, var(--diagram-ink) 3%, transparent); & > .diagram-group-label { position: static; translate: none; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 0 0.5rem; background: none; border-bottom: 1px solid var(--diagram-rule); color: var(--diagram-ink); font: 600 0.8125rem/1.4 var(--font-mono); letter-spacing: 0; text-transform: none; } & > .diagram-node { align-self: stretch; } } /* -- kanban card states ------------------------------------------------------ */ .diagram-node[data-status="blocked"] { border: 1px dashed var(--diagram-accent); background: var(--diagram-accent-tint); box-shadow: inset 3px 0 0 var(--diagram-accent); } .diagram-node[data-status="waiting"] { border-style: dashed; border-color: color-mix(in oklab, var(--diagram-ink) 28%, transparent); background: var(--diagram-soft); } .diagram-node[data-status="done"] { color: var(--diagram-muted); border-color: color-mix(in oklab, var(--diagram-ink) 25%, transparent); & > .diagram-node-name::before { content: "✓ "; color: var(--diagram-added); } } /* -- policy trace: rule outcomes, never color alone ------------------------- */ .diagram-node[data-shape="status"] { flex-direction: row; align-items: center; justify-content: center; gap: 0.375rem; min-block-size: 2.25rem; justify-self: center; min-inline-size: 7.5rem; border-radius: var(--radius-sm); font: 600 0.625rem/1.3 var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; &::before { font-size: 0.8125rem; } &[data-status="pass"] { border-color: var(--diagram-added); color: color-mix(in oklab, var(--diagram-added) 75%, var(--diagram-ink)); &::before { content: "✓"; } } &[data-status="fail"] { border-color: var(--diagram-accent); background: var(--diagram-accent-tint); color: color-mix(in oklab, var(--diagram-accent) 80%, var(--diagram-ink)); &::before { content: "×"; } } &[data-status="skip"] { border-style: dashed; color: var(--diagram-muted); &::before { content: "—"; } } &[data-status="unreached"] { border-style: dotted; border-color: color-mix(in oklab, var(--diagram-ink) 30%, transparent); background: var(--diagram-soft); color: var(--diagram-muted); &::before { content: "○"; } } } /* -- phases (chevron banner), cells, tags, rules, notes, axes, legend -------- */ .diagram-phases { display: grid; grid-template-columns: subgrid; margin: 0 0 calc(var(--diagram-gap-y) * -0.4); padding: 0; list-style: none; align-self: start; & > li { grid-column: auto / span var(--span, 1); display: grid; place-items: center; min-block-size: 1.75rem; margin-inline-end: calc(var(--_diagram-gap-x) * -1 + 0.25rem); padding: 0 1.25rem; background: var(--diagram-soft); border: 0; clip-path: polygon(0 0, calc(100% - 0.75rem) 0, 100% 50%, calc(100% - 0.75rem) 100%, 0 100%, 0.75rem 50%); color: var(--diagram-muted); font: 500 0.5625rem/1.3 var(--font-mono); letter-spacing: 0.16em; text-transform: uppercase; text-align: center; } & > li:first-child { clip-path: polygon(0 0, calc(100% - 0.75rem) 0, 100% 50%, calc(100% - 0.75rem) 100%, 0 100%); } & > li:last-child { margin-inline-end: 0; } & > li[data-tone="accent"] { background: var(--diagram-accent-tint); color: var(--diagram-accent); } } .diagram-cell { align-self: center; min-width: 0; font-size: 0.75rem; line-height: 1.4; &[data-role="head"] { text-align: center; font-weight: 600; font-size: 0.8125rem; } &[data-role="label"] { color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; } &[data-role="mono"] { color: var(--diagram-muted); font: 0.6875rem/1.4 var(--font-mono); text-align: center; } &[data-role="center"] { text-align: center; } } .diagram-cell-eyebrow { display: block; color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.16em; text-transform: uppercase; } .diagram-tag { justify-self: center; align-self: start; padding: 0.0625rem 0.375rem; border: 1px dashed color-mix(in oklab, var(--diagram-accent) 70%, transparent); border-radius: var(--radius-sm); background: var(--diagram-accent-tint); color: var(--diagram-accent); font: 500 0.5625rem/1.5 var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; } .diagram-rule { position: relative; z-index: 3; grid-column: 1 / -1; align-self: end; margin-block-end: calc(var(--diagram-gap-y) * -0.5); border-top: 1.5px solid var(--diagram-accent); block-size: 0; & > span { position: absolute; right: 0; top: 0; translate: 0 -50%; padding: 0 0.375rem; background: var(--diagram-paper); color: var(--diagram-accent); font: 500 0.5625rem/1.5 var(--font-mono); letter-spacing: 0.16em; text-transform: uppercase; } &[data-tone="muted"] { border-top: 1px dashed var(--diagram-rule); & > span { color: var(--diagram-muted); } } } .diagram-note { color: var(--diagram-muted); font: italic 0.9375rem/1.45 var(--font-mono); text-wrap: pretty; } .diagram-band { z-index: 0; align-self: stretch; min-block-size: var(--band, 9rem); background: linear-gradient(var(--diagram-rule), var(--diagram-rule)) top / 100% 1px no-repeat, linear-gradient(var(--diagram-rule), var(--diagram-rule)) center / 100% 1px no-repeat, linear-gradient(var(--diagram-rule), var(--diagram-rule)) bottom / 100% 1px no-repeat; } .diagram-band-labels { display: flex; flex-direction: column; justify-content: space-between; align-self: stretch; margin-block: -0.4rem; color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; } .diagram-legend { position: sticky; left: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin: 0; padding: 0.625rem 0 0; list-style: none; border-top: 1px solid var(--diagram-rule); color: var(--diagram-muted); font: 0.6875rem/1.4 var(--font-mono); & > li { display: flex; align-items: center; gap: 0.5rem; } & > li::before { content: ""; inline-size: 1rem; block-size: 0.625rem; border: 1px solid var(--diagram-line); border-radius: var(--radius-sm); background: var(--diagram-paper); } & > li[data-swatch="accent"]::before { border-color: var(--diagram-accent); background: var(--diagram-accent-tint); } & > li[data-swatch="external"]::before { border-style: dashed; background: var(--diagram-soft); } & > li[data-swatch="muted"]::before { background: var(--diagram-soft); } & > li[data-swatch="line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-wire); } & > li[data-swatch="dashed"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-style: dashed; border-color: var(--diagram-wire); } & > li[data-swatch="accent-line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-accent); } & > li[data-swatch="link-line"]::before { block-size: 0; border-width: 1.5px 0 0; border-radius: 0; border-color: var(--diagram-link); } & > li[data-swatch="dot"]::before { inline-size: 0.625rem; border-radius: 50%; border-color: var(--diagram-ink); } & > li[data-swatch="warn"]::before { border-style: dashed; border-color: var(--diagram-changed); } } /* -- the edge list: readable without JS, a screen-reader list once wired ---- */ .diagram-edges { position: sticky; left: 0; margin: 0; padding: 0 0 0 1.25rem; color: var(--diagram-muted); font-size: 0.75rem; line-height: 1.6; & > .diagram-edge::before { content: attr(data-from) " → " attr(data-to) " "; color: var(--diagram-ink); font-family: var(--font-mono); font-size: 0.6875rem; } } .diagram:has(.diagram-wires) > .diagram-edges { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } /* -- wires (drawn by diagram.js) --------------------------------------------- */ .diagram-wires { position: absolute; z-index: 1; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } .diagram-wire { color: var(--diagram-wire); transition: opacity var(--diagram-step-ms) ease; &[data-tone="accent"] { color: var(--diagram-accent); } &[data-tone="link"] { color: var(--diagram-link); } &[data-tone="ink"] { color: var(--diagram-ink); } &[data-tone="ok"] { color: var(--diagram-added); } &[data-tone="danger"] { color: var(--diagram-removed); } &[data-line="dashed"] .diagram-wire-line { stroke-dasharray: 5 4; } &[data-line="dotted"] .diagram-wire-line { stroke-dasharray: 1.5 3.5; } &[data-line="thick"] .diagram-wire-line { stroke-width: 2.25; } &[data-lifeline] .diagram-wire-line { stroke-dasharray: 3 4; opacity: 0.55; } &[data-spine] .diagram-wire-line { stroke-width: 1.75; } &[data-bone] .diagram-wire-line { stroke-width: 1.25; } } .diagram-wire-line { fill: none; stroke: currentColor; stroke-width: 1.25; stroke-linecap: round; stroke-linejoin: round; } .diagram-wire-head { stroke-width: 1.25; stroke-linejoin: round; &[data-paint="fill"] { fill: currentColor; stroke: none; } &[data-paint="stroke"] { fill: none; stroke: currentColor; } &[data-paint="hollow"] { fill: var(--diagram-paper); stroke: currentColor; } } .diagram-wire-labels { position: absolute; z-index: 3; inset: 0; pointer-events: none; } .diagram-wire-label { position: absolute; translate: -50% -50%; padding: 0 0.3rem; background: var(--diagram-paper); border-radius: var(--radius-sm); color: var(--diagram-muted); font: 500 0.5625rem/1.45 var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; transition: opacity var(--diagram-step-ms) ease; &[data-tone="accent"] { color: var(--diagram-accent); } &[data-tone="link"] { color: var(--diagram-link); } } .diagram-wire-end { padding: 0 0.125rem; background: none; color: var(--diagram-ink); font-size: 0.625rem; letter-spacing: 0; text-transform: none; } .diagram-wire-self { translate: 0 -50%; } .diagram[data-type="sequence"] .diagram-wire-label { text-transform: none; letter-spacing: 0; font-size: 0.6875rem; color: var(--diagram-ink); } .diagram-token { position: absolute; top: 0; left: 0; inline-size: 0.5rem; block-size: 0.5rem; border-radius: 50%; background: var(--diagram-wire); box-shadow: 0 0 0 2px var(--diagram-paper); offset-rotate: 0deg; &[data-tone="accent"] { background: var(--diagram-accent); } &[data-tone="link"] { background: var(--diagram-link); } } /* -- steps (data-steps / state playing · paused) ----------------------------- */ .diagram [data-step-state="future"]:not(.diagram-edge) { opacity: var(--diagram-future-opacity); } .diagram :is(.diagram-wire, .diagram-wire-label)[data-step-state="future"] { opacity: 0; } .diagram :is(.diagram-node, .diagram-group, tr, .diagram-cell)[data-step-state="current"] { outline: 2px solid var(--diagram-accent); outline-offset: 3px; animation: diagram-enter var(--diagram-step-ms) cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--step-i, 0) * var(--diagram-element-ms)) both; } .diagram .diagram-node[data-shape="ghost"][data-step-state="current"] { outline: 0; } /* a wire appears on its beat - just after its later end - and solid ones draw in; one that already entered (data-step-entered, kept across a redraw) stays as it is */ .diagram .diagram-wire[data-step-state="current"]:not([data-step-entered]) { animation: diagram-fade calc(var(--diagram-step-ms) * 0.4) ease calc(var(--step-i, 0) * var(--diagram-element-ms)) both; } .diagram .diagram-wire[data-step-state="current"]:not([data-step-entered]) .diagram-wire-line[pathLength] { stroke-dasharray: 1; animation: diagram-draw var(--diagram-step-ms) ease-out calc(var(--step-i, 0) * var(--diagram-element-ms)) both; } .diagram .diagram-wire[data-step-state="current"] { color: var(--diagram-accent); } @keyframes diagram-enter { from { opacity: 0; translate: 0 0.5rem; } } .diagram .diagram-wire-label[data-step-state="current"]:not([data-step-entered]) { animation: diagram-fade calc(var(--diagram-step-ms) * 0.6) ease calc(var(--step-i, 0) * var(--diagram-element-ms) + var(--diagram-step-ms) * 0.6) both; } @keyframes diagram-fade { from { opacity: 0; } } /* -- activation (state 'active', data-interactive) ------------------------------------ */ .diagram :is(.diagram-node, .diagram-cell, .diagram-wire, .diagram-wire-label, tr)[data-active-state="dimmed"] { opacity: var(--diagram-dim-opacity); } .diagram .diagram-node[data-active-state="active"] { outline: 2px solid var(--diagram-accent); outline-offset: 3px; box-shadow: 0 0 0 7px color-mix(in oklab, var(--diagram-accent) 16%, transparent); } .diagram tr[data-active-state="active"] > * { background-color: color-mix(in oklab, var(--diagram-accent) 12%, var(--diagram-paper)); box-shadow: inset 0 2px 0 var(--diagram-accent), inset 0 -2px 0 var(--diagram-accent); } .diagram[data-interactive] tr[data-node] { cursor: pointer; &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } .diagram .diagram-node[data-active-state="related"] { outline: 1px dashed color-mix(in oklab, var(--diagram-accent) 70%, transparent); outline-offset: 3px; } .diagram .diagram-wire:is([data-active-state="active"], [data-active-state="related"]) { color: var(--diagram-accent); & .diagram-wire-line { stroke-width: 2; } } .diagram .diagram-wire-label:is([data-active-state="active"], [data-active-state="related"]) { color: var(--diagram-accent); } .diagram .diagram-wire-label[data-active-state="active"] { box-shadow: inset 0 0 0 1px var(--diagram-accent); } .diagram[data-interactive] { & .diagram-node[role="button"] { cursor: pointer; transition: opacity 0.3s ease, outline-color 0.3s ease, box-shadow 0.3s ease; } & .diagram-node[role="button"]:hover { border-color: var(--diagram-accent); } & .diagram-node[role="button"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; } & .diagram-wire-label[data-edge-ref] { pointer-events: auto; cursor: pointer; } } .diagram-wire-hit { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; } @keyframes diagram-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } } .diagram-controls { position: sticky; left: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; & .btn svg { inline-size: 0.875rem; block-size: 0.875rem; } } .diagram-status { margin-inline-start: auto; color: var(--diagram-muted); font: 0.6875rem/1.4 var(--font-mono); } /* -- delta (data-delta): before · changes · after ---------------------------- */ .diagram-delta { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.25rem; min-width: 0; } @container (min-width: 96rem) { .diagram[data-delta=""] .diagram-delta, .diagram[data-delta="auto"] .diagram-delta { grid-template-columns: repeat(3, minmax(0, 1fr)); } } .diagram[data-delta="columns"] .diagram-delta { grid-template-columns: repeat(3, minmax(0, 1fr)); } .diagram[data-delta="columns"] .diagram-panel .diagram-canvas { --diagram-col-min: 5.5rem; --diagram-node-w: 8rem; --diagram-gap-x: 1.75rem; --diagram-gap-y: 1.75rem; padding: 1.25rem; } .diagram[data-delta]:has(> .diagram-delta) > .diagram-canvas { display: none; } .diagram-panel { display: grid; align-content: start; gap: 0.5rem; min-width: 0; } .diagram-panel-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; color: var(--diagram-muted); font: 500 0.625rem/1.4 var(--font-mono); letter-spacing: 0.18em; text-transform: uppercase; } .diagram-panel[data-panel="changes"] .diagram-panel-title { color: var(--diagram-ink); } .diagram-panel-count { padding: 0 0.375rem; border-radius: calc(var(--radius) * 4); background: var(--diagram-accent); color: var(--diagram-paper); letter-spacing: 0; } .diagram-panel-body { min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; } [data-delta-hidden] { display: none !important; } .diagram-panel[data-panel="changes"] { & .diagram-canvas :is(.diagram-node, .diagram-group, td, th, tr, .diagram-cell, li)[data-change] { outline: 2px solid var(--diagram-changed); outline-offset: 2px; &[data-change="added"] { outline-color: var(--diagram-added); } &[data-change="removed"] { outline-color: var(--diagram-removed); outline-style: dashed; opacity: 0.62; } } & .diagram-canvas :is(li, td, th)[data-change] { outline-offset: -2px; } & .diagram-wire[data-change="added"] { color: var(--diagram-added); } & .diagram-wire[data-change="removed"] { color: var(--diagram-removed); } & .diagram-wire[data-change="removed"] .diagram-wire-line { stroke-dasharray: 5 4; } & .diagram-wire:is([data-change="changed"], [data-change="rewired"]) { color: var(--diagram-changed); } & .diagram-wire[data-change="rewired"] .diagram-wire-line { stroke-dasharray: 8 3 2 3; } & .diagram-wire-label[data-change="added"] { color: var(--diagram-added); } & .diagram-wire-label[data-change="removed"] { color: var(--diagram-removed); text-decoration: line-through; } } .diagram-badge { position: absolute; translate: -60% -40%; display: grid; place-items: center; inline-size: 1.125rem; block-size: 1.125rem; border-radius: 50%; background: var(--diagram-changed); color: var(--diagram-paper); font: 700 0.6875rem/1 var(--font-mono); &[data-change="added"] { background: var(--diagram-added); } &[data-change="removed"] { background: var(--diagram-removed); } } .diagram-ledger { display: grid; gap: 0.375rem; margin: 0.25rem 0 0; padding: 0; list-style: none; font-size: 0.75rem; } .diagram-ledger-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.125rem 0.5rem; padding: 0.375rem 0.5rem; border-inline-start: 2px solid var(--diagram-changed); background: var(--diagram-soft); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; &[data-change="added"] { border-color: var(--diagram-added); } &[data-change="removed"] { border-color: var(--diagram-removed); border-inline-start-style: dashed; } } .diagram-ledger-kind { color: var(--diagram-changed); font: 600 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; } .diagram-ledger-item[data-change="added"] .diagram-ledger-kind { color: var(--diagram-added); } .diagram-ledger-item[data-change="removed"] .diagram-ledger-kind { color: var(--diagram-removed); } .diagram-ledger-note { flex-basis: 100%; color: var(--diagram-muted); font: 0.6875rem/1.4 var(--font-mono); } /* =========================================================================== Types - each a preset over the same primitives =========================================================================== */ /* free canvas: --x / --y in percent (wardley, positioning) */ .diagram-canvas[data-layout="free"], .diagram[data-type="wardley"] .diagram-canvas { display: block; aspect-ratio: var(--ratio, 16 / 10); min-width: 36rem; & > :is(.diagram-node, .diagram-cell, .diagram-tag, .diagram-note) { position: absolute; left: calc(var(--_inset-left) + (100% - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100); top: calc(var(--_inset-top) + (100% - var(--_inset-top) - var(--_inset-bottom)) * var(--y, 50) / 100); translate: -50% -50%; } } /* radial: stations around a hub (loop, topology) */ .diagram-canvas[data-layout="radial"], .diagram[data-type="loop"] .diagram-canvas { display: block; aspect-ratio: var(--ratio, 1.55); min-width: 40rem; min-block-size: 30rem; & > .diagram-node { position: absolute; inline-size: var(--station, 9.5rem); left: calc(50% + var(--rx, 36%) * sin(calc(var(--i, 0) / var(--n, 6) * 1turn))); top: calc(50% - var(--ry, 37%) * cos(calc(var(--i, 0) / var(--n, 6) * 1turn))); translate: -50% -50%; } & > .diagram-node[data-hub] { left: 50%; top: 50%; inline-size: var(--hub, 11rem); text-align: center; align-items: center; } } /* wardley: value chain (y) × evolution (x), four bands */ .diagram[data-type="wardley"] .diagram-canvas { /* the axes sit in the left / bottom padding, 1.25rem (diagram.ts CLEARANCE) off the border */ padding: 1.5rem 1.5rem 3.5rem 4rem; background-image: linear-gradient(to right, transparent calc(25% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(25% - 0.5px) calc(25% + 0.5px), transparent calc(25% + 0.5px)), linear-gradient(to right, transparent calc(50% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(50% - 0.5px) calc(50% + 0.5px), transparent calc(50% + 0.5px)), linear-gradient(to right, transparent calc(75% - 0.5px), color-mix(in oklab, var(--diagram-ink) 14%, transparent) calc(75% - 0.5px) calc(75% + 0.5px), transparent calc(75% + 0.5px)); background-size: auto; background-repeat: no-repeat; background-origin: content-box; } .diagram-axis { position: absolute; display: flex; justify-content: space-between; color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; & > b { color: var(--diagram-ink); font-weight: 600; } &[data-axis="x"] { left: calc(4rem + var(--_inset-left)); right: calc(1.5rem + var(--_inset-right)); bottom: calc(1.25rem + var(--_inset-bottom)); padding-top: 0.375rem; border-top: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent); } &[data-axis="x"] > span { flex: 1; text-align: center; } &[data-axis="y"] { top: calc(1.5rem + var(--_inset-top)); bottom: calc(3.5rem + var(--_inset-bottom)); left: calc(1.25rem + var(--_inset-left)); inline-size: 2rem; flex-direction: column; align-items: flex-start; padding-inline-end: 0.375rem; border-inline-end: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent); font-size: 0.5rem; white-space: normal; } } /* timeline: one honest axis, events above and below */ .diagram[data-type="timeline"] .diagram-canvas { display: block; block-size: var(--diagram-height, 17rem); min-width: 40rem; padding: 0; &::before { content: ""; position: absolute; left: 1.5rem; right: 1.5rem; top: var(--axis, 55%); border-top: 1px solid var(--diagram-line); } & > .diagram-node { position: absolute; left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100); top: var(--axis, 55%); inline-size: 0.625rem; block-size: 0.625rem; min-block-size: 0; padding: 0; translate: -50% -50%; border-radius: 50%; background: var(--diagram-ink); border: 0; overflow: visible; } & > .diagram-node[data-tone="accent"] { inline-size: 0.875rem; block-size: 0.875rem; background: var(--diagram-accent); box-shadow: 0 0 0 3px var(--diagram-accent-tint); } & > .diagram-node > .diagram-node-body { position: absolute; left: 50%; bottom: 2.25rem; translate: -50% 0; display: grid; gap: 0.0625rem; inline-size: max-content; max-inline-size: 10rem; text-align: center; } & > .diagram-node > .diagram-node-body::after { content: ""; position: absolute; left: 50%; top: 100%; block-size: 1.75rem; border-inline-start: 1px solid var(--diagram-rule); } & > .diagram-node[data-side="below"] > .diagram-node-body { bottom: auto; top: 2.25rem; } & > .diagram-node[data-side="below"] > .diagram-node-body::after { top: auto; bottom: 100%; } & > .diagram-node[data-tone="accent"] .diagram-node-name { color: var(--diagram-accent); } & > .diagram-tick { position: absolute; left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 50) / 100); top: var(--axis, 55%); translate: -50% 0.375rem; color: var(--diagram-muted); font: 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.08em; white-space: nowrap; } & > .diagram-tick::before { content: ""; position: absolute; left: 50%; bottom: calc(100% + 0.125rem); block-size: 0.375rem; border-inline-start: 1px solid var(--diagram-line); } & > .diagram-span { position: absolute; left: calc(1.5rem + var(--_inset-left) + (100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--x, 0) / 100); inline-size: calc((100% - 3rem - var(--_inset-left) - var(--_inset-right)) * var(--w, 10) / 100); top: calc(var(--axis, 55%) - 0.375rem); block-size: 0.75rem; background: color-mix(in oklab, var(--diagram-accent) 18%, transparent); border-radius: var(--radius-sm); } } /* layers: full-width bands, one focal */ .diagram[data-type="layers"] .diagram-canvas { --diagram-node-w: 100%; grid-template-columns: minmax(28rem, 1fr); row-gap: 0; & > .diagram-node { display: grid; grid-template-columns: 9.5rem 1fr auto; align-items: center; align-self: stretch; gap: 1rem; min-block-size: 3.75rem; margin-block-start: -1px; border-radius: 0; border-color: var(--diagram-rule); } & > .diagram-node:first-of-type { border-start-start-radius: var(--radius-md); border-start-end-radius: var(--radius-md); } & > .diagram-node:last-of-type { border-end-start-radius: var(--radius-md); border-end-end-radius: var(--radius-md); } & > .diagram-node[data-tone="accent"] { position: relative; z-index: 3; border-color: var(--diagram-accent); } & .diagram-node-name { font-size: 0.9375rem; } } /* nested containment: groups inside groups */ .diagram[data-type="nested"] .diagram-canvas { grid-template-columns: minmax(24rem, 1fr); } .diagram[data-type="nested"] .diagram-group { padding: 2rem 1.75rem 1.75rem; border-style: solid; border-color: color-mix(in oklab, var(--diagram-ink) 22%, transparent); } .diagram[data-type="nested"] .diagram-group .diagram-group { border-color: color-mix(in oklab, var(--diagram-ink) 40%, transparent); background: color-mix(in oklab, var(--diagram-ink) 2.5%, transparent); } .diagram[data-type="nested"] .diagram-group .diagram-group .diagram-group { border-color: color-mix(in oklab, var(--diagram-ink) 65%, transparent); } .diagram[data-type="nested"] .diagram-group[data-tone="accent"] { border-color: var(--diagram-accent); background: var(--diagram-accent-tint); } .diagram-group[data-cols] { grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr)); } /* sequence: participants on top, one row per message */ .diagram[data-type="sequence"] .diagram-canvas { grid-auto-rows: auto; row-gap: 0.5rem; & > .diagram-node { justify-self: center; min-inline-size: 7.5rem; text-align: center; align-items: center; } & > .diagram-edges { grid-column: 1 / -1; margin: 0; padding: 0 0 0.5rem; list-style: none; position: static; } & > .diagram-edges > .diagram-edge { block-size: 2.5rem; font-size: 0.75rem; } } .diagram[data-type="sequence"] .diagram-canvas:has(> .diagram-wires) > .diagram-edges > .diagram-edge { color: transparent; } .diagram[data-type="sequence"] .diagram-canvas:has(> .diagram-wires) > .diagram-edges > .diagram-edge::before { color: transparent; } /* quadrant (consultant 2×2): two driver ranges, four named scenarios */ .diagram[data-type="quadrant"] .diagram-canvas { --diagram-node-w: 100%; grid-template-columns: max-content repeat(2, minmax(14rem, 1fr)); grid-template-rows: repeat(2, minmax(9rem, auto)) auto; gap: 0; & > .diagram-node { align-self: stretch; justify-content: flex-start; gap: 0.375rem; padding: 1.25rem 1.5rem; border: 0; border-radius: 0; background: transparent; } /* scenarios in reading order: top-left, top-right, bottom-left, bottom-right */ & > :nth-child(1 of .diagram-node) { grid-area: 1 / 2; } & > :nth-child(2 of .diagram-node) { grid-area: 1 / 3; } & > :nth-child(3 of .diagram-node) { grid-area: 2 / 2; } & > :nth-child(4 of .diagram-node) { grid-area: 2 / 3; } & > :is(:nth-child(1 of .diagram-node), :nth-child(3 of .diagram-node)) { border-inline-end: 1px solid var(--diagram-ink); } & > :is(:nth-child(1 of .diagram-node), :nth-child(2 of .diagram-node)) { border-block-end: 1px solid var(--diagram-ink); } & > .diagram-node[data-tone="accent"] { background: var(--diagram-accent-tint); } & .diagram-node-name { font: 400 1.25rem/1.2 var(--font-mono); } & .diagram-node[data-tone="accent"] .diagram-node-name { color: var(--diagram-accent); } & .diagram-node-meta { font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.5; } & > .diagram-axis { position: static; } & > .diagram-axis[data-axis="x"] { grid-column: 2 / span 2; grid-row: 3; padding-top: 0.5rem; border: 0; } & > .diagram-axis[data-axis="x"] > span { flex: none; } & > .diagram-axis[data-axis="y"] { grid-column: 1; grid-row: 1 / span 2; inline-size: auto; align-items: center; padding: 0 0.875rem 0 0; border: 0; text-align: center; } } /* journey: stages × sentiment band × rows */ .diagram[data-type="journey"] .diagram-canvas { grid-template-columns: 6rem repeat(var(--cols, 5), minmax(6.5rem, 1fr)); row-gap: 1rem; & .diagram-node[data-shape="dot"] { align-self: start; margin-block-start: calc((1 - var(--sentiment, 0.5)) * (var(--band, 9rem) - 0.75rem)); } } /* fishbone: categories above and below a spine into the effect */ .diagram[data-type="fishbone"] .diagram-canvas { grid-template-rows: auto 3.5rem auto; column-gap: 2rem; & > .diagram-node[data-bone] { align-self: end; margin-inline-end: 1.5rem; } & > .diagram-node[data-bone][data-side="below"] { align-self: start; } & > .diagram-node[data-bone] .diagram-node-fields { border-top: 0; padding-block-start: 0; } & > .diagram-node[data-bone] .diagram-node-fields > li::before { content: "—"; color: var(--diagram-muted); } & > .diagram-node[data-effect] { grid-row: 1 / span 3; align-self: center; } } /* story map: activities, steps, release bands, the cut */ .diagram[data-type="story-map"] .diagram-canvas { --diagram-gap-x: 1rem; --diagram-gap-y: 0.75rem; grid-template-columns: 5.5rem repeat(var(--cols, 4), minmax(8.5rem, 1fr)); & .diagram-node[data-shape="activity"] { background: var(--diagram-soft); border-color: var(--diagram-muted); } & .diagram-node { min-block-size: 2.5rem; align-self: start; } & .diagram-group[data-shape="lane"] { margin: -0.375rem -1.75rem; } & .diagram-group[data-shape="lane"] > .diagram-group-label { left: 1.75rem; } } /* matrix: a real table - permissions, RACI, probabilities, risk, scoring */ .diagram-matrix { border-collapse: separate; border-spacing: 0; inline-size: 100%; font-size: 0.75rem; & :is(th, td) { padding: 0.5rem 0.625rem; border-block-end: 1px solid var(--diagram-rule); text-align: center; transition: opacity var(--diagram-step-ms) ease; } & thead th { vertical-align: bottom; color: var(--diagram-muted); font: 500 0.5625rem/1.4 var(--font-mono); letter-spacing: 0.12em; text-transform: uppercase; border-block-end-color: var(--diagram-line); } & thead th > small { display: block; color: var(--diagram-ink); font: 600 0.75rem/1.3 var(--font-mono); letter-spacing: 0; text-transform: none; } & tbody th { text-align: start; font-weight: 600; white-space: nowrap; } & tbody th > small { display: block; color: var(--diagram-muted); font: 400 0.625rem/1.4 var(--font-mono); } & td[style*="--v"] { background: color-mix(in oklab, var(--diagram-heat, var(--diagram-accent)) calc(var(--v, 0) * 62%), var(--diagram-paper)); font-family: var(--font-mono); font-variant-numeric: tabular-nums; } & td[data-perm] { font: 600 0.625rem/1 var(--font-mono); letter-spacing: 0.08em; } & td[data-perm] > span { display: inline-grid; place-items: center; min-inline-size: 2.75rem; padding: 0.25rem 0.375rem; border-radius: var(--radius-sm); border: 1px solid color-mix(in oklab, var(--diagram-ink) 30%, transparent); } & td[data-perm="full"] > span { background: var(--diagram-ink); border-color: var(--diagram-ink); color: var(--diagram-paper); } & td[data-perm="write"] > span { background: color-mix(in oklab, var(--diagram-ink) 18%, var(--diagram-paper)); } & td[data-perm="read"] > span { border-style: solid; } & td[data-perm="none"] > span { border-style: dotted; color: var(--diagram-muted); } & td[data-perm][data-tone="accent"] > span { background: var(--diagram-accent-tint); border: 1px solid var(--diagram-accent); color: var(--diagram-accent); } & :is(td, th)[data-tone="accent"]:not([data-perm]) { box-shadow: inset 0 0 0 1.5px var(--diagram-accent); font-weight: 700; } & tfoot :is(th, td) { border-block-end: 0; border-block-start: 1px solid var(--diagram-line); font-weight: 600; } } .diagram-canvas:has(> .diagram-matrix) { grid-template-columns: minmax(0, 1fr); } .diagram-canvas:has(> .diagram-matrix:only-child) { background-image: none; } /* -- accessibility -------------------------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .diagram *, .diagram *::before, .diagram *::after { animation: none !important; transition: none !important; } .diagram-token { display: none; } } @media (prefers-contrast: more) { .diagram { --diagram-line: var(--foreground); --diagram-wire: color-mix(in oklab, var(--foreground) 85%, var(--card)); --diagram-muted: color-mix(in oklab, var(--foreground) 85%, var(--card)); --diagram-future-opacity: 0.2; } .diagram-group { border-color: var(--foreground); } } @media (forced-colors: active) { .diagram { --diagram-paper: Canvas; --diagram-ink: CanvasText; --diagram-line: CanvasText; --diagram-wire: CanvasText; --diagram-muted: CanvasText; --diagram-accent: Highlight; --diagram-link: LinkText; --diagram-added: Highlight; --diagram-removed: Mark; --diagram-changed: Highlight; } .diagram-canvas { background-image: none; } .diagram-node, .diagram-group { forced-color-adjust: none; background: Canvas; color: CanvasText; border-color: CanvasText; } .diagram-wire-label, .diagram-group-label { forced-color-adjust: none; background: Canvas; color: CanvasText; } .diagram-node[data-shape="diamond"]::before { background: CanvasText; } .diagram-node[data-shape="diamond"]::after { background: Canvas; } }}§JavaScript view file
The engine every example converges on: wire routing, steps, delta panels, specs and the State API.
// -- Diagram --------------------------------------------------// Diagrams from plain HTML: nodes and groups are elements on a CSS grid (or a// free / radial canvas), their relationships are an ordered list of edges -// readable as text without JavaScript. The component measures the laid-out// nodes and draws the wires (rounded orthogonal elbows, straight lines,// curves, self-loops, cycle back-edges, fishbone bones, sequence messages)// into an SVG layer behind them, with masked labels and typed ends (arrows,// UML triangles and diamonds, crow's feet). Every diagram can be played step// by step (data-steps) and shown as a delta - before / changes / after -// from one annotated source (data-delta + data-change). A JSON spec builds// the same markup (df$.shadcn.diagram.build) - the parametric path.//// The visual grammar (paper + ink + one focal accent, mono eyebrows, masked// connector labels, elbow connectors, the before/changes/after ledger, the// policy-evaluation trace) is adapted from diagram-design by Cathryn Lavery// (MIT, https://github.com/cathrynlavery/diagram-design) - re-implemented// here as a standalone component; nothing of that project is loaded.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** A tone: a node's or an edge's color role (none: the ink). */type DiagramTone = 'none' | 'accent' | 'link' | 'ink' | 'muted' | 'external' | 'warn' | 'ok' | 'danger';/** What a delta (diff) marks on a node, a group or an edge. */type DiagramChange = 'added' | 'removed' | 'changed' | 'moved' | 'rewired';/** Where an element sits: grid cells, or free / radial positions. */interface DiagramPlace { /** grid: the first column (1-based) */ col?: number; /** grid: the first row (1-based) */ row?: number; /** grid: how many columns it spans */ span?: number; /** grid: how many rows it spans */ rspan?: number; /** free canvas: the center's left, % of the canvas */ x?: number; /** free canvas: the center's top, % of the canvas */ y?: number; /** radial canvas: its index around the hub */ i?: number; /** more inline CSS */ style?: string;}/** One field row of a node (an entity's column, a class's attribute). */interface DiagramField { /** its name */ name: string; /** a key marker ('pk', 'fk', ...) - a wire to a field leaves and enters at its row */ key?: string; /** its type, shown at the right */ type?: string; /** a delta mark */ change?: DiagramChange;}/** A node of a spec. */interface DiagramNodeSpec extends DiagramPlace { /** its data-node: what edges and activation name it by */ id: string; /** the main text */ name?: string; /** the small text above the name */ eyebrow?: string; /** the small text under the name */ meta?: string; /** its shape (default 'box') */ shape?: 'box' | 'pill' | 'diamond' | 'store' | 'circle' | 'dot' | 'bar' | 'note' | 'activity' | 'class' | 'start' | 'end' | 'ghost'; /** its color role */ tone?: DiagramTone; /** a status word (data-status) */ status?: string; /** a corner badge */ badge?: string; /** field rows (a string is a field name) */ fields?: Array<string | DiagramField>; /** operation rows (a class's methods) */ ops?: string[]; /** the step it appears in (data-steps) */ step?: number; /** the group it sits in */ parent?: string; /** a delta mark */ change?: DiagramChange; /** the delta's note on the change */ note?: string; /** a delta: the text and place it had before */ before?: Partial<Pick<DiagramNodeSpec, 'name' | 'eyebrow' | 'meta' | 'col' | 'row' | 'span' | 'rspan' | 'x' | 'y'>>;}/** A group (a zone, a lane, a column) of a spec. */interface DiagramGroupSpec extends DiagramPlace { /** its data-node: what nodes name as their parent */ id: string; /** its label */ label: string; /** its shape ('column' for a kanban column, ...) */ shape?: string; /** its color role */ tone?: DiagramTone; /** the step it appears in */ step?: number; /** the group it sits in */ parent?: string; /** a delta mark */ change?: DiagramChange;}/** An edge of a spec. */interface DiagramEdgeSpec { /** its data-edge (default "from->to") */ id?: string; /** the node it leaves ("node" or "node.field") */ from: string; /** the node it reaches */ to: string; /** its label */ label?: string; /** its stroke (default 'solid') */ line?: 'solid' | 'dashed' | 'dotted' | 'thick'; /** its color role */ tone?: DiagramTone; /** the end at `to` (default 'arrow') */ head?: 'arrow' | 'open' | 'triangle' | 'diamond' | 'dot' | 'one' | 'many' | 'none'; /** the end at `from` (default 'none') */ tail?: 'arrow' | 'open' | 'triangle' | 'diamond' | 'dot' | 'one' | 'many' | 'none'; /** its route (default 'elbow') */ curve?: 'elbow' | 'straight' | 'curve' | 'around'; /** the step it appears in */ step?: number; /** data moving along it: true, or the number of tokens */ flow?: boolean | string; /** a label at the `from` end (a cardinality) */ fromLabel?: string; /** a label at the `to` end */ toLabel?: string; /** a delta mark */ change?: DiagramChange; /** a delta: its label or ends before */ before?: { label?: string; from?: string; to?: string };}/** A diagram as JSON - the same figure an author writes as markup. */interface DiagramSpec { /** the diagram type (data-type: architecture, flow, state, er, sequence, ...) */ type?: string; /** the caption's title */ title?: string; /** the caption's eyebrow */ eyebrow?: string; /** the caption's text under the title */ caption?: string; /** grid columns */ cols?: number; /** more inline CSS on the canvas */ style?: string; /** a phase header row (a string is a phase name) */ phases?: Array<string | { name: string; span?: number }>; /** the groups */ groups?: DiagramGroupSpec[]; /** the nodes */ nodes?: DiagramNodeSpec[]; /** the edges */ edges?: DiagramEdgeSpec[]; /** reveal it step by step (data-steps; a number: the step time in ms) */ steps?: boolean | number; /** play the steps on its own (data-autoplay; a number: the delay in ms) */ autoplay?: boolean | number; /** clickable: boxes and wires activate (data-interactive) */ interactive?: boolean;}/** Two specs - build() and markup() render the delta between them. */interface DiagramDelta { /** the diagram before */ before: DiagramSpec; /** the diagram after */ after: DiagramSpec;}/** What is active - active() returns it, diagram-activate carries it (null fields when cleared). */interface DiagramActivation { /** the reference: a node's data-node, an edge's reference */ ref: string | null; /** what it is */ kind: 'node' | 'edge' | null; /** its element */ element: HTMLElement | null; /** its name (an edge: "from → to · label") */ label: string; /** its data-detail, else a node's meta */ detail: string;}/** What diagram-drawn carries. */interface DiagramDrawnDetail { /** how many edges were drawn */ edges: number; /** the delta panel drawn, null outside a delta */ panel: 'before' | 'changes' | 'after' | null;}/** What diagram-step carries. */interface DiagramStepDetail { /** the step shown now, 1-based */ step: number; /** the number of steps */ max: number; /** the step's label (data-step-label), '' when it has none */ label: string; /** whether the steps are playing or paused */ state: 'playing' | 'paused';}const diagramStates = ['default', 'playing', 'paused', 'active'];/** setState() configs per state. */export interface DiagramStateConfigs { /** The complete, static figure. */ default: {}; /** Playing the steps: the clock moves on once a step's elements have appeared. */ playing: { /** the step on screen, 1-based (default 1) */ step?: number; }; /** Stopped on a step. */ paused: { /** the step shown, 1-based (default: the last) */ step?: number; }; /** A node or an edge is active - the complete figure, the rest dimmed. */ active: { /** the node's data-node, or the edge's data-edge (else "from->to") */ ref: string; };}const SVG_NS = 'http://www.w3.org/2000/svg';const RUNTIME = '.diagram-delta, .diagram-controls, .diagram-wire-labels, .diagram-wires';const reducedMotion = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;// -- reading the source ---------------------------------------------------------------------/** authored (not runtime-made) descendants of a scope matching a selector */const authored = (scope, selector) => dfDollar(scope).find(selector).toArray().filter((n) => !n.closest(RUNTIME));/** "api", "api:left", "orders#customer_id", "orders#customer_id:right" */function parseEnd(value) { const [ref, side] = String(value || '').split(':'); const [id, field] = ref.split('#'); return { id, field: field || null, side: side || null };}/** the edge list a canvas draws: its own (a sequence keeps its messages inside) or the figure's */function edgeListOf(root, canvas) { return dfDollar(canvas).children('.diagram-edges').get(0) ?? dfDollar(root).children('.diagram-edges').get(0) ?? null;}const edgesIn = (list) => (list ? dfDollar(list).find('.diagram-edge').toArray() : []);/** the readable name of a node, group, edge or any changed element */function nameOf(el) { if (!el) return ''; if (el.dataset.changeLabel) return el.dataset.changeLabel; const named = dfDollar(el).find('.diagram-node-name, .diagram-group-label').get(0) ?? (el.tagName === 'LI' && el.children.length ? el.firstElementChild : null); const text = (named ?? el).textContent.replace(/\s+/g, ' ').trim(); return text.length > 48 ? `${text.slice(0, 47)}…` : text;}// -- steps -----------------------------------------------------------------------------------/** * The step plan of a diagram - which step each element enters at - read from * the markup alone, so render() computes it on a detached copy exactly like * the live element. Explicit: every [data-step] element (descendants follow * their ancestor). Automatic: nodes in document order, one a step (table rows * when there are no nodes); a group enters with its first node (scaffolding * without nodes is always shown); an edge enters with its later endpoint. */function stepPlan(root) { const stepOf = new Map(); const explicit = authored(root, '[data-step]'); const nodes = authored(root, '.diagram-node'); if (explicit.length) { explicit.forEach((n) => stepOf.set(n, Math.max(0, parseInt(n.dataset.step, 10) || 0))); } else { let k = 0; const sequence = root.dataset.type === 'sequence'; const units = sequence ? [] : nodes.length ? nodes : authored(root, 'tbody > tr'); if (sequence) nodes.forEach((n) => stepOf.set(n, 0)); units.forEach((n) => stepOf.set(n, ++k)); authored(root, '.diagram-group').forEach((g) => { const inner = units.filter((n) => g.contains(n)).map((n) => stepOf.get(n)); stepOf.set(g, inner.length ? Math.min(...inner) : 0); }); } const byId = new Map(authored(root, '[data-node]').map((n) => [n.dataset.node, n])); const stepOfEl = (el) => { for (let n = el; n && n !== root; n = n.parentElement) if (stepOf.has(n)) return stepOf.get(n); return 0; }; authored(root, '.diagram-edge').forEach((edge, i) => { if (stepOf.has(edge)) return; const a = byId.get(parseEnd(edge.dataset.from).id); const b = byId.get(parseEnd(edge.dataset.to).id); // a sequence's messages play in list order stepOf.set(edge, root.dataset.type === 'sequence' ? i + 1 : Math.max(stepOfEl(a), stepOfEl(b))); }); const max = Math.max(0, ...stepOf.values()); return { stepOf, max };}/** * The markup of a state, for render() AND the live element: in 'playing' / * 'paused' the figure carries data-step-current and every stepped element a * data-step-state of past / current / future; 'default' is the complete, * static figure (no step attributes). */function applyMarkup(root, state) { const { stepOf, max } = stepPlan(root); applyActivation(root, state.name === 'active' ? state.config?.ref ?? null : null); if (state.name === 'default' || state.name === 'active') { dfDollar(root).attr('data-step-current', null); stepOf.forEach((_s, el) => { dfDollar(el).attr('data-step-state', null); beat(el, null); }); return; } const current = stateStep(state, max); dfDollar(root).attr('data-step-current', String(current)); stepOf.forEach((s, el) => { dfDollar(el).attr('data-step-state', s <= 0 || s < current ? 'past' : s === current ? 'current' : 'future'); }); beats(root, stepOf, current);}/** an element's beat inside its step (--step-i), or none */function beat(el, i) { if (i == null) el.style.removeProperty('--step-i'); else el.style.setProperty('--step-i', String(i)); if (el.getAttribute('style') === '') el.removeAttribute('style');}/** * Inside a step every element gets its own beat: boxes one after another in * markup order (--step-i 0, 1, 2 ..., each --diagram-element-ms after the last), * an edge just after its later end has appeared. Returns how many beats the * step takes - the clock holds the step that long. */function beats(root, stepOf, current) { const items = []; stepOf.forEach((s, el) => { if (s === current && current > 0) items.push(el); else beat(el, null); }); const boxes = items.filter((el) => !el.classList.contains('diagram-edge')); const index = new Map(boxes.map((el, i) => [el, i])); boxes.forEach((el, i) => beat(el, i)); const nodeOf = (end) => authored(root, '[data-node]').find((n) => n.dataset.node === parseEnd(end).id); const at = (el) => { for (let n = el; n && n !== root; n = n.parentElement) if (index.has(n)) return index.get(n); return -1; }; for (const edge of items.filter((el) => el.classList.contains('diagram-edge'))) { beat(edge, Math.max(at(nodeOf(edge.dataset.from)), at(nodeOf(edge.dataset.to)), -0.4) + 0.6); } return Math.max(1, boxes.length);}// -- activation (state 'active') -----------------------------------------------------------/** an edge's reference: its data-edge, else "from->to" */const edgeRef = (edge) => edge.dataset.edge || `${edge.dataset.from}->${edge.dataset.to}`;/** the nodes and edges an activation can land on (groups and ghosts cannot) */const activatable = (root) => authored(root, '[data-node]').filter((n) => !n.classList.contains('diagram-group') && n.dataset.shape !== 'ghost');/** the element a reference names: a node (data-node) or an edge (edgeRef) */function refTarget(root, ref): { kind: 'node' | 'edge'; el: HTMLElement } | null { if (ref == null || ref === '') return null; const node = activatable(root).find((n) => n.dataset.node === ref); if (node) return { kind: 'node', el: node }; const edge = authored(root, '.diagram-edge').find((e) => edgeRef(e) === String(ref)); return edge ? { kind: 'edge', el: edge } : null;}/** * The markup of an activation: the figure names it (data-active), the active * node or edge is 'active', what it touches 'related' (a node's edges and * their other ends, an edge's two ends), everything else 'dimmed'. Without a * reference every mark is removed. Interactive figures mirror it as * aria-pressed on their nodes. */function applyActivation(root, ref) { const nodes = activatable(root); const edges = authored(root, '.diagram-edge'); const target = refTarget(root, ref); dfDollar(root).attr('data-active', target ? String(ref) : null); const interactive = root.hasAttribute('data-interactive'); if (!target) { for (const el of [...nodes, ...edges]) dfDollar(el).attr('data-active-state', null); if (interactive) for (const n of nodes) dfDollar(n).attr(pressedAttr(n), 'false'); return; } const related = new Set(); const idOf = (end) => parseEnd(end).id; if (target.kind === 'node') { const id = target.el.dataset.node; for (const e of edges) { if (idOf(e.dataset.from) !== id && idOf(e.dataset.to) !== id) continue; related.add(e); for (const end of [idOf(e.dataset.from), idOf(e.dataset.to)]) related.add(nodes.find((n) => n.dataset.node === end)); } } else { for (const end of [idOf(target.el.dataset.from), idOf(target.el.dataset.to)]) related.add(nodes.find((n) => n.dataset.node === end)); } for (const el of [...nodes, ...edges]) { dfDollar(el).attr('data-active-state', el === target.el ? 'active' : related.has(el) ? 'related' : 'dimmed'); } if (interactive) for (const n of nodes) dfDollar(n).attr(pressedAttr(n), n === target.el ? 'true' : 'false');}// a table row (a matrix) keeps its row semantics: it is selected, not pressedconst tablePart = (el) => /^(TR|TD|TH)$/.test(el.tagName);const pressedAttr = (el) => (tablePart(el) ? 'aria-selected' : 'aria-pressed');/** what an activation is about - for outputs and the diagram-activate event */function describe(root, ref): DiagramActivation | null { const target = refTarget(root, ref); if (!target) return null; const { kind, el } = target; if (el.tagName === 'TR') { // a matrix row: its header's own text, then every cell under its column's name const cells = dfDollar(el).children('th, td').toArray(); const own = (c) => [...c.childNodes].filter((n) => n.nodeType === 3 || !/^(SMALL)$/.test(n.nodeName)).map((n) => n.textContent).join('').trim(); const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray(); const detail = cells.slice(1).map((c, i) => `${heads[i + 1] ? own(heads[i + 1]) : ''} ${c.textContent.trim()}`.trim()).join(' · '); return { ref: String(ref), kind, element: el, label: own(cells[0]), detail }; } if (kind === 'node') { const meta = dfDollar(el).find('.diagram-node-meta').get(0)?.textContent.trim() ?? ''; return { ref: String(ref), kind, element: el, label: nameOf(el), detail: el.dataset.detail || meta }; } const name = (end) => { const n = activatable(root).find((x) => x.dataset.node === parseEnd(end).id); return n ? nameOf(n) : parseEnd(end).id; }; const text = el.textContent.trim(); return { ref: String(ref), kind, element: el, label: `${name(el.dataset.from)} → ${name(el.dataset.to)}${text ? ` · ${text}` : ''}`, detail: el.dataset.detail || '' };}/** the activation order: nodes in step order, then document order */function activationOrder(root) { const { stepOf } = stepPlan(root); const stepFor = (n) => { for (let x = n; x && x !== root; x = x.parentElement) if (stepOf.has(x)) return stepOf.get(x); return 0; }; return activatable(root).map((n, i) => ({ n, i, s: stepFor(n) })).sort((a, b) => a.s - b.s || a.i - b.i).map((x) => x.n.dataset.node);}/** move the activation by one node (wraps around); from nothing, the first or the last */function stepActivation(root, by) { const order = activationOrder(root); if (!order.length) return; const state = root._shown ?? root.store?.value ?? { name: 'default' }; const at = state.name === 'active' ? order.indexOf(String(state.config?.ref)) : -1; const next = at < 0 ? (by > 0 ? 0 : order.length - 1) : (at + by + order.length) % order.length; root.api.setState('active', { ref: order[next] });}/** is a state one of the step states (playing / paused)? */const stepping = (state) => state.name === 'playing' || state.name === 'paused';const clampStep = (step, max) => Math.min(max, Math.max(0, Math.round(+step)));/** the step a state shows: its config.step - without one, playing starts at 1 and paused stops on the last */const stateStep = (state, max) => { const step = state.config?.step; if (step != null && step !== '' && Number.isFinite(+step)) return clampStep(step, max); return state.name === 'playing' ? Math.min(1, max) : max;};/** the label of a step: its first element's data-step-label or name */function stepLabel(root, step) { const { stepOf } = stepPlan(root); for (const [el, s] of stepOf) if (s === step) return el.dataset.stepLabel || nameOf(el); return '';}// -- geometry --------------------------------------------------------------------------------const NORMAL = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };const horizontal = (side) => side === 'left' || side === 'right';const ROUND = new Set(['dot', 'circle', 'start', 'end', 'ring', 'event']);const r1 = (n) => Math.round(n * 10) / 10;/** a point on a side of a rect; t in 0..1 along the side */function sidePoint(r, side, t = 0.5) { if (side === 'top') return { x: r.x + r.w * t, y: r.y }; if (side === 'bottom') return { x: r.x + r.w * t, y: r.y + r.h }; if (side === 'left') return { x: r.x, y: r.y + r.h * t }; return { x: r.x + r.w, y: r.y + r.h * t };}const center = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 });/** where the line from a rect's center toward a point leaves it */function boundary(r, toward, round) { const c = center(r); const dx = toward.x - c.x; const dy = toward.y - c.y; if (!dx && !dy) return c; if (round) { const l = Math.hypot(dx, dy); const rad = Math.min(r.w, r.h) / 2; return { x: c.x + (dx / l) * rad, y: c.y + (dy / l) * rad }; } const t = Math.min(r.w / 2 / Math.abs(dx || 1e-9), r.h / 2 / Math.abs(dy || 1e-9)); return { x: c.x + dx * t, y: c.y + dy * t };}/** the sides two rects face each other with */function facingSides(a, b) { const ca = center(a); const cb = center(b); const gapX = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w)); const gapY = Math.max(b.y - (a.y + a.h), a.y - (b.y + b.h)); const useX = gapX > 0 || gapY > 0 ? gapX >= gapY : Math.abs(cb.x - ca.x) >= Math.abs(cb.y - ca.y); if (useX) return cb.x >= ca.x ? ['right', 'left'] : ['left', 'right']; return cb.y >= ca.y ? ['bottom', 'top'] : ['top', 'bottom'];}/** does a polyline cross a rect (shrunk by 2px)? */function crosses(pts, r) { const x0 = r.x + 2; const x1 = r.x + r.w - 2; const y0 = r.y + 2; const y1 = r.y + r.h - 2; for (let i = 1; i < pts.length; i++) { const a = pts[i - 1]; const b = pts[i]; if (Math.max(a.x, b.x) < x0 || Math.min(a.x, b.x) > x1 || Math.max(a.y, b.y) < y0 || Math.min(a.y, b.y) > y1) continue; return true; // axis-aligned segments: a bounding-box overlap is a crossing } return false;}/** * The sides an elbow leaves and enters by: every side pair is routed and * scored - crossing a node costs most, then bends and length; the pair the * rects face each other with wins ties. Fixed sides (api:left) are kept. */function bestSides(a, b, fixedA, fixedB, obstacles, texts = []) { const [fa, fb] = facingSides(a.rect, b.rect); const sides = ['top', 'right', 'bottom', 'left']; let best = null; const pa0 = (sa) => sidePoint(a.rect, sa); const pb0 = (sb) => sidePoint(b.rect, sb); // a same-side route can also clear everything between the two ends: its outer line just past their extreme const clearing = (sa, sb) => { if (sa !== sb) return [null]; const lo = Math.min(a.rect.x, b.rect.x) - 8; const hi = Math.max(a.rect.x + a.rect.w, b.rect.x + b.rect.w) + 8; const top = Math.min(a.rect.y, b.rect.y) - 8; const bot = Math.max(a.rect.y + a.rect.h, b.rect.y + b.rect.h) + 8; const between = obstacles.filter((o) => o !== a && o !== b && (horizontal(sa) ? o.rect.y < bot && o.rect.y + o.rect.h > top : o.rect.x < hi && o.rect.x + o.rect.w > lo)); if (!between.length) return [null]; if (sa === 'top') return [null, Math.min(...between.map((o) => o.rect.y), a.rect.y, b.rect.y) - 18]; if (sa === 'bottom') return [null, Math.max(...between.map((o) => o.rect.y + o.rect.h), a.rect.y + a.rect.h, b.rect.y + b.rect.h) + 18]; if (sa === 'left') return [null, Math.min(...between.map((o) => o.rect.x), a.rect.x, b.rect.x) - 18]; return [null, Math.max(...between.map((o) => o.rect.x + o.rect.w), a.rect.x + a.rect.w, b.rect.x + b.rect.w) + 18]; }; for (const sa of fixedA ? [fixedA] : sides) { for (const sb of fixedB ? [fixedB] : sides) { for (const outer of clearing(sa, sb)) { const pts = elbow(pa0(sa), sa, pb0(sb), sb, 16, outer); let score = 0; for (let i = 1; i < pts.length; i++) score += Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y); score += (pts.length - 2) * 30; if (sa !== fa || sb !== fb) score += 12; for (const o of obstacles) { if (o === a || o === b || o.el.contains(a.el) || o.el.contains(b.el)) continue; const r = o.rect; if (crosses(pts, { x: r.x - 8, y: r.y - 8, w: r.w + 16, h: r.h + 16 })) score += 10000; } // nor through text (group labels, cells, a dot's label ...) for (const t of texts) if (crosses(pts, t)) score += 4000; // the route must not run back through its own ends if (crosses(pts.slice(1), a.rect) || crosses(pts.slice(0, -1), b.rect)) score += 5000; if (!best || score < best.score) best = { score, sa, sb, outer }; } } } return [best.sa, best.sb, best.outer ?? null];}/** drop repeated and collinear points */function simplify(points) { const out = []; for (const p of points) { const last = out[out.length - 1]; if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5) continue; out.push(p); while (out.length >= 3) { const [a, b, c] = out.slice(-3); const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); if (Math.abs(cross) > 0.5) break; out.splice(out.length - 2, 1); } } return out;}/** orthogonal route between two anchored points (sides give the exit direction) */function elbow(a, sa, b, sb, gap = 16, outside = null) { const na = NORMAL[sa]; const nb = NORMAL[sb]; const pts = [a]; if (horizontal(sa) && horizontal(sb)) { if (outside != null) { pts.push({ x: outside, y: a.y }, { x: outside, y: b.y }); } else if (sa !== sb && (b.x - a.x) * na[0] > gap) { const mx = (a.x + b.x) / 2; pts.push({ x: mx, y: a.y }, { x: mx, y: b.y }); } else if (sa === sb) { const x = sa === 'right' ? Math.max(a.x, b.x) + gap * 1.5 : Math.min(a.x, b.x) - gap * 1.5; pts.push({ x, y: a.y }, { x, y: b.y }); } else { const x1 = a.x + na[0] * gap; const x2 = b.x + nb[0] * gap; const my = (a.y + b.y) / 2; pts.push({ x: x1, y: a.y }, { x: x1, y: my }, { x: x2, y: my }, { x: x2, y: b.y }); } } else if (!horizontal(sa) && !horizontal(sb)) { if (outside != null) { pts.push({ x: a.x, y: outside }, { x: b.x, y: outside }); } else if (sa !== sb && (b.y - a.y) * na[1] > gap) { const my = (a.y + b.y) / 2; pts.push({ x: a.x, y: my }, { x: b.x, y: my }); } else if (sa === sb) { const y = sa === 'bottom' ? Math.max(a.y, b.y) + gap * 1.5 : Math.min(a.y, b.y) - gap * 1.5; pts.push({ x: a.x, y }, { x: b.x, y }); } else { const y1 = a.y + na[1] * gap; const y2 = b.y + nb[1] * gap; const mx = (a.x + b.x) / 2; pts.push({ x: a.x, y: y1 }, { x: mx, y: y1 }, { x: mx, y: y2 }, { x: b.x, y: y2 }); } } else if (horizontal(sa)) { const corner = { x: b.x, y: a.y }; if ((corner.x - a.x) * na[0] > 0 && (corner.y - b.y) * nb[1] > 0) pts.push(corner); else { const a1 = { x: a.x + na[0] * gap, y: a.y }; const b1 = { x: b.x, y: b.y + nb[1] * gap }; pts.push(a1, { x: a1.x, y: b1.y }, b1); } } else { const corner = { x: a.x, y: b.y }; if ((corner.y - a.y) * na[1] > 0 && (corner.x - b.x) * nb[0] > 0) pts.push(corner); else { const a1 = { x: a.x, y: a.y + na[1] * gap }; const b1 = { x: b.x + nb[0] * gap, y: b.y }; pts.push(a1, { x: b1.x, y: a1.y }, b1); } } pts.push(b); return simplify(pts);}// the elbow corner radius - set per draw from the theme's own roundinglet corner = 8;/** an SVG path through points with rounded corners (radius r, default: the theme's) */function roundedPath(pts, r = corner) { let d = `M${r1(pts[0].x)} ${r1(pts[0].y)}`; for (let i = 1; i < pts.length - 1; i++) { const p0 = pts[i - 1]; const p = pts[i]; const p1 = pts[i + 1]; const l0 = Math.hypot(p.x - p0.x, p.y - p0.y); const l1 = Math.hypot(p1.x - p.x, p1.y - p.y); const rr = Math.min(r, l0 / 2, l1 / 2); if (rr < 0.5) { // a square theme: a plain corner d += ` L${r1(p.x)} ${r1(p.y)}`; continue; } const a = { x: p.x + ((p0.x - p.x) / l0) * rr, y: p.y + ((p0.y - p.y) / l0) * rr }; const b = { x: p.x + ((p1.x - p.x) / l1) * rr, y: p.y + ((p1.y - p.y) / l1) * rr }; d += ` L${r1(a.x)} ${r1(a.y)} Q${r1(p.x)} ${r1(p.y)} ${r1(b.x)} ${r1(b.y)}`; } const last = pts[pts.length - 1]; return `${d} L${r1(last.x)} ${r1(last.y)}`;}/** the midpoint of a polyline's longest segment - where its label sits - and that segment's direction */function labelSpot(pts) { let best = 0; let at = { x: pts[0].x, y: pts[0].y }; let along: { x: number; y: number; len?: number } = { x: 1, y: 0 }; for (let i = 1; i < pts.length; i++) { const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); if (l > best) { best = l; at = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 }; along = { x: Math.abs(pts[i].x - pts[i - 1].x) > 0.5 ? 1 : 0, y: Math.abs(pts[i].y - pts[i - 1].y) > 0.5 ? 1 : 0, len: l }; } } return { at, along };}const unit = (from, to) => { const l = Math.hypot(to.x - from.x, to.y - from.y) || 1; return { x: (to.x - from.x) / l, y: (to.y - from.y) / l };};// -- ends ------------------------------------------------------------------------------------// tip at 0,0 pointing +x; each part: [path, paint] - fill / stroke / hollow (paper fill + stroke)const CROW = ['M-11 0L0 -6M-11 0L0 6M-11 0H0', 'stroke'];const HEADS = { arrow: { parts: [['M0 0L-9 -4.5L-9 4.5Z', 'fill']], inset: 3 }, open: { parts: [['M-8 -4.5L0 0L-8 4.5', 'stroke']], inset: 0 }, triangle: { parts: [['M0 0L-12 -6.5L-12 6.5Z', 'hollow']], inset: 12 }, 'triangle-filled': { parts: [['M0 0L-12 -6.5L-12 6.5Z', 'fill']], inset: 4 }, diamond: { parts: [['M0 0L-7 -4.5L-14 0L-7 4.5Z', 'fill']], inset: 4 }, 'diamond-open': { parts: [['M0 0L-7 -4.5L-14 0L-7 4.5Z', 'hollow']], inset: 14 }, dot: { parts: [['M-7 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0', 'fill']], inset: 3 }, one: { parts: [['M-7 -6V6', 'stroke']], inset: 0 }, 'one-one': { parts: [['M-7 -6V6M-12 -6V6', 'stroke']], inset: 0 }, many: { parts: [CROW], inset: 0 }, 'one-many': { parts: [CROW, ['M-15 -6V6', 'stroke']], inset: 0 }, 'zero-many': { parts: [CROW, ['M-23 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'hollow']], inset: 0 }, 'zero-one': { parts: [['M-7 -6V6', 'stroke'], ['M-19 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'hollow']], inset: 0 }, none: { parts: [], inset: 0 },};function svg(tag, attrs = {}) { const el = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, String(v)); return el;}function drawHead(group, kind, tip, dir) { const head = HEADS[kind] ?? HEADS.arrow; const angle = r1((Math.atan2(dir.y, dir.x) * 180) / Math.PI); for (const [d, paint] of head.parts) { group.append(svg('path', { class: 'diagram-wire-head', 'data-paint': paint, d, transform: `translate(${r1(tip.x)} ${r1(tip.y)}) rotate(${angle})` })); }}/** pull a polyline's end back along its last segment (the line stops at a hollow head) */function inset(pts, by, atStart) { if (!by) return pts; const out = pts.map((p) => ({ ...p })); const [i, j] = atStart ? [0, 1] : [out.length - 1, out.length - 2]; const u = unit(out[i], out[j]); const l = Math.hypot(out[j].x - out[i].x, out[j].y - out[i].y); const d = Math.min(by, l - 1); out[i] = { x: out[i].x + u.x * d, y: out[i].y + u.y * d }; return out;}// -- drawing ---------------------------------------------------------------------------------/** the panel a canvas belongs to ('before' / 'changes' / 'after'), or null */const panelOf = (canvas) => canvas.closest('.diagram-panel')?.dataset.panel ?? null;/** is an edge drawn in a panel, and between which ends */function edgeIn(edge, panel) { const change = edge.dataset.change; if (panel === 'before' && change === 'added') return null; if (panel === 'after' && change === 'removed') return null; const before = panel === 'before'; return { from: (before && edge.dataset.beforeFrom) || edge.dataset.from, to: (before && edge.dataset.beforeTo) || edge.dataset.to, };}function layer(canvas, cls, make, keep = false) { const existing = dfDollar(canvas).children(`.${cls}`).get(0); if (existing) { // keep: the children stay, marked stale, until reconcileWires() decides which survive if (keep) for (const c of dfDollar(existing).children().toArray()) c.setAttribute('data-stale', ''); else dfDollar(existing).empty(); return existing; } const el = make(); dfDollar(canvas).append(el); return el;}/** measure every node of a canvas (canvas-local, unscaled px) */function measure(canvas) { const box = canvas.getBoundingClientRect(); const scale = box.width / (canvas.offsetWidth || box.width || 1) || 1; const ox = box.left + canvas.clientLeft * scale; const oy = box.top + canvas.clientTop * scale; const rectOf = (el) => { const r = el.getBoundingClientRect(); return { x: (r.left - ox) / scale, y: (r.top - oy) / scale, w: r.width / scale, h: r.height / scale }; }; // a node mid enter-animation is painted 0.5rem low (diagram-enter translates it in); a wire // measured then ended low and jumped on the next draw - horizontal wires showed it. The stylesheet's // static translates (a dot's name, a tick) are layout and must count, so only that animation is // sampled at its end while measuring, then put back where it was - it keeps playing. // VERIFIED: (diagram.e2e "a node mid enter-animation") a wire drawn during the animation equals the one after it. const settling = []; for (const el of dfDollar(canvas).find('[data-step-state="current"]').toArray()) { for (const a of el.getAnimations()) { if ((a as CSSAnimation).animationName !== 'diagram-enter' || a.playState !== 'running' || !a.effect) continue; settling.push([a, a.currentTime]); a.currentTime = a.effect.getComputedTiming().endTime ?? 0; } } const nodes = new Map(); for (const el of dfDollar(canvas).find('[data-node]').toArray()) { if (el.closest('.diagram-edges') || !el.getClientRects().length) continue; nodes.set(el.dataset.node, { el, rect: rectOf(el), round: ROUND.has(el.dataset.shape) }); } for (const [a, t] of settling) a.currentTime = t; return { nodes, rectOf };}/** * A figure narrower than its diagram scales the canvas down to fit - never * below 0.7 (narrower still, the figure scrolls as before); data-fit="none" * keeps it 1:1. measure() already reads through a zoom, so the wires follow. */const FIT_MIN = 0.7;function fit(root, canvas) { if (canvas.parentElement !== root) return; canvas.style.zoom = ''; if (root.dataset.fit === 'none') return; const natural = canvas.offsetWidth; // min-width: min-content - the diagram's own width const avail = root.clientWidth; const z = natural > avail + 1 ? Math.max(FIT_MIN, Math.floor((avail / natural) * 1000) / 1000) : 1; if (z < 1) canvas.style.zoom = String(z);}/** * Draw a canvas's wires: an SVG layer behind the nodes (z-index between groups * and nodes) and an HTML label layer above them. Re-run on every resize - the * layers are absolutely positioned, so drawing never changes the layout. */function draw(root, canvas) { if (!canvas.isConnected || !canvas.getClientRects().length) return; // every draw starts from the stylesheet's padding: clear() below may grow it again if (!canvas._clearPass) { for (const side of SIDES) { canvas.style.removeProperty(`padding-${side}`); canvas.style.removeProperty(`--_inset-${side}`); } } fit(root, canvas); const { nodes, rectOf } = measure(canvas); const panel = panelOf(canvas); // what already entered: a wire or label that was 'current' before this draw. A draw can run // again within a step (a clearance pass, a resize, fonts loading) - a rebuilt element would // restart its enter animation (the wire drew twice, flickered); carrying data-step-entered // keeps it still. VERIFIED: (diagram.e2e "a redraw within a step") a redraw mid-step leaves // the current wire's animation count at zero and the wire fully drawn. const entered = new Set<string>(); for (const el of dfDollar(canvas).find(':scope > .diagram-wires > .diagram-wire[data-step-state="current"], :scope > .diagram-wire-labels > [data-step-state="current"]').toArray()) { entered.add(`${el.tagName.toLowerCase()}|${el.dataset.edgeRef ?? ''}|${el.textContent ?? ''}`); } const wires = layer(canvas, 'diagram-wires', () => { const el = svg('svg', { class: 'diagram-wires', 'aria-hidden': 'true', focusable: 'false' }); return el; }, true); const labels = layer(canvas, 'diagram-wire-labels', () => { const el = document.createElement('div'); el.className = 'diagram-wire-labels'; el.setAttribute('aria-hidden', 'true'); return el; }); wires.setAttribute('width', String(canvas.clientWidth)); wires.setAttribute('height', String(canvas.clientHeight)); const type = root.dataset.type; const list = edgeListOf(root, canvas); const drawn = []; // the elbow corners follow the theme's rounding (a square theme draws square elbows) const sample = dfDollar(canvas).find('.diagram-node:not([data-shape])').get(0) ?? canvas; corner = Math.min(14, Math.max(0, parseFloat(getComputedStyle(sample).borderTopLeftRadius) || 0) * 1.2); // text a wire or a label must not cover: node boxes, group labels, cells, tags, axes ... const texts = textRects(canvas, nodes, rectOf); const queue = []; // labels wait until every wire exists - then each finds a spot clear of nodes, text, other labels and other wires const label = (text, at, edge, cls = 'diagram-wire-label', along = null, g = null) => { if (text) queue.push({ text, at, edge, cls, along, g }); }; const wireGroup = (edge, extra: Record<string, unknown> & { beatOf?: HTMLElement } = {}) => { const g = svg('g', { class: 'diagram-wire', 'data-line': edge?.dataset.line, 'data-tone': edge?.dataset.tone, 'data-step-state': edge?.dataset.stepState, 'data-active-state': edge?.dataset.activeState, 'data-edge-ref': edge ? edgeRef(edge) : null, 'data-change': panel === 'changes' ? edge?.dataset.change : null, ...Object.fromEntries(Object.entries(extra).filter(([k]) => k !== 'beatOf')), }); const i = (extra.beatOf ?? edge)?.style?.getPropertyValue('--step-i'); if (i) g.style.setProperty('--step-i', i); if (g.dataset.stepState === 'current' && entered.has(`g|${g.dataset.edgeRef ?? ''}|`)) g.setAttribute('data-step-entered', ''); wires.append(g); return g; }; const interactive = root.hasAttribute('data-interactive') && !panel; const line = (g, d, solid) => { g.append(svg('path', { class: 'diagram-wire-line', d, pathLength: solid ? 1 : null })); // a wide invisible stroke: what a click on an interactive wire hits if (interactive && g.dataset.edgeRef) g.append(svg('path', { class: 'diagram-wire-hit', d })); }; if (type === 'sequence') drawSequence(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn); else drawEdges(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn, texts); if (type === 'fishbone') drawBones(nodes, wireGroup, line); // the queued labels point at the groups just built - after the reconcile they must point at the // groups that stayed, or a label measures a detached wire (its own wire no longer excluded, it // landed elsewhere on every second draw, the clearance grew and shrank, the resize observer // drew again: a 3 Hz flicker on the deck's proof-loop slide) const kept = reconcileWires(wires); for (const item of queue) item.g = kept.get(item.g) ?? item.g; // a dot's name goes to the side its wires leave free; then the labels avoid it there sideDotLabels(nodes, wires, rectOf); labels._entered = entered; placeLabels(queue, labels, wires, nodes, textRects(canvas, nodes, rectOf)); if (panel === 'changes') badges(canvas, labels, rectOf); flowTokens(labels, wires); if (clear(root, canvas, rectOf)) return; // grew its padding and drew again - that draw announced itself // Fires after a canvas's wires are drawn (init, resize, state change) - how many edges, and which delta panel ('before' / 'changes' / 'after', null outside a delta). root.dispatchEvent(new CustomEvent<DiagramDrawnDetail>('diagram-drawn', { detail: { edges: drawn.length, panel } }));}/** * A wire that did not change keeps its element. Every draw builds the wires anew, but the one * already on the layer that is identical to a new one (same edge, geometry, line, states) stays * and the new copy goes - its enter animation keeps running. A step's redraw (the next node * arriving) used to replace the wire still drawing in, which snapped it to complete; a resize or * fonts loading restarted it. Stale wires that differ (new geometry, another state) are removed. * VERIFIED: (diagram.e2e "a redraw within a step keeps the current wire element") the element * and its running animations are the same objects after a redraw. */function reconcileWires(wires): Map<Element, Element> { // identity = the group's attributes (minus the two runtime marks) + its markup, read through df$ const sig = (g) => [...g.attributes].filter((a) => a.name !== 'data-stale' && a.name !== 'data-step-entered').map((a) => `${a.name}=${a.value}`).join(' ') + '>' + dfDollar(g).html(); const stale = new Map(); const kept = new Map<Element, Element>(); // the dropped new group → the old one that stays for (const g of dfDollar(wires).children('[data-stale]').toArray()) stale.set(`${g.dataset.edgeRef ?? ''}|${sig(g)}`, g); for (const g of dfDollar(wires).children(':not([data-stale])').toArray()) { const key = `${g.dataset.edgeRef ?? ''}|${sig(g)}`; const old = stale.get(key); if (!old) continue; stale.delete(key); old.removeAttribute('data-stale'); kept.set(g, old); g.remove(); } for (const g of stale.values()) g.remove(); return kept;}/** * The least distance (px, unscaled) anything drawn keeps from a framed * canvas's border. Zones reach into the canvas padding (their negative * margins wrap their nodes) and wire labels are placed around the boxes at * draw time, so no padding in the stylesheet can promise it for every * diagram. VERIFIED: (diagram.e2e "clearance") every framed fixture diagram * keeps it after a draw. */const CLEARANCE = 20;const SIDES = ['top', 'right', 'bottom', 'left'];/** * After a draw: measure everything on the canvas - nodes, zones and their * labels, wire labels, wire paths - and grow the padding of each side that * comes closer than CLEARANCE, then draw once more on the new layout. A * frameless canvas (no border) has no edge to keep away from. True when it * redrew. Up to three passes: a canvas that fits its figure (zoom) re-scales * when its padding grows, so one pass can land a pixel short. Flow tokens * ride on their wires (animated, measured through the wire) - not counted. */function clear(root, canvas, rectOf) { if ((canvas._clearPass ?? 0) >= 3) return false; const cs = getComputedStyle(canvas); if (!parseFloat(cs.borderTopWidth)) return false; const w = canvas.clientWidth; const h = canvas.clientHeight; const gap = { top: Infinity, right: Infinity, bottom: Infinity, left: Infinity }; for (const el of dfDollar(canvas).find('*').toArray()) { if (el.matches('.diagram-wires, .diagram-wire-labels, .diagram-wire-hit, .diagram-token, g') || !el.getClientRects().length) continue; const r = rectOf(el); if (!r.w && !r.h) continue; gap.top = Math.min(gap.top, r.y); gap.left = Math.min(gap.left, r.x); gap.bottom = Math.min(gap.bottom, h - (r.y + r.h)); gap.right = Math.min(gap.right, w - (r.x + r.w)); } const grow = SIDES.filter((side) => gap[side] < CLEARANCE - 0.5); if (!grow.length) return false; // absolutely placed content ignores padding (its box is the padding box): grow the inset it is placed in const absolute = dfDollar(canvas).children().toArray().some((c) => getComputedStyle(c).position === 'absolute' && !c.matches('.diagram-wires, .diagram-wire-labels')); for (const side of grow) { const more = Math.ceil(CLEARANCE - gap[side]); if (absolute) canvas.style.setProperty(`--_inset-${side}`, `${Math.ceil(parseFloat(canvas.style.getPropertyValue(`--_inset-${side}`)) || 0) + more}px`); else canvas.style.setProperty(`padding-${side}`, `${Math.ceil(parseFloat(cs.getPropertyValue(`padding-${side}`))) + more}px`); } canvas._clearPass = (canvas._clearPass ?? 0) + 1; try { draw(root, canvas); } finally { canvas._clearPass -= 1; } return true;}/** the boxes of the text in a canvas that wires and labels keep clear of (canvas-local) */function textRects(canvas, nodes, rectOf) { const out = []; for (const n of nodes.values()) { if (n.el.classList.contains('diagram-group') || n.el.dataset.shape === 'ghost') continue; if (ROUND.has(n.el.dataset.shape) || n.el.dataset.shape === 'event') { // a dot's label sits outside its box for (const t of dfDollar(n.el).find('.diagram-node-name, .diagram-node-eyebrow, .diagram-node-meta, .diagram-node-body').toArray()) if (t.getClientRects().length) out.push(rectOf(t)); } } const sel = '.diagram-group-label, .diagram-cell, .diagram-tag, .diagram-tick, .diagram-rule > span, .diagram-axis > *, .diagram-phases > li, .diagram-band-labels > *, .diagram-note'; for (const t of dfDollar(canvas).find(sel).toArray()) if (!t.closest(RUNTIME) && t.getClientRects().length) out.push(rectOf(t)); return out;}/** * A dot (Wardley component, journey feeling) carries its name outside its * box - above by default. When a wire runs through it there, the name moves * below, left or right (data-label-side): the side that covers the fewest * wire points and nothing else. data-label-side in the markup is kept. */function sideDotLabels(nodes, wires, rectOf) { const dots = [...nodes.values()].filter((n) => n.el.dataset.shape === 'dot' && dfDollar(n.el).children('.diagram-node-name').get(0)); if (!dots.length) return; const points = [...wirePoints(wires).values()].flat(); const others = [...nodes.values()].filter((n) => n.el.dataset.shape !== 'ghost' && !n.el.classList.contains('diagram-group')); for (const dot of dots) { if (dot.el.hasAttribute('data-label-side') && !dot.el._autoSide) continue; const text = dfDollar(dot.el).children('.diagram-node-name, .diagram-node-meta').toArray(); let best = null; for (const side of ['above', 'below', 'right', 'left']) { dot.el.dataset.labelSide = side; let score = side === 'above' ? 0 : 1; for (const t of text) { const r = rectOf(t); for (const q of points) if (q.x > r.x - 2 && q.x < r.x + r.w + 2 && q.y > r.y - 2 && q.y < r.y + r.h + 2) score += 10; for (const o of others) { if (o === dot) continue; const ov = Math.max(0, Math.min(r.x + r.w, o.rect.x + o.rect.w) - Math.max(r.x, o.rect.x)) * Math.max(0, Math.min(r.y + r.h, o.rect.y + o.rect.h) - Math.max(r.y, o.rect.y)); if (ov > 0) score += 20; } } if (!best || score < best.score) best = { side, score }; if (score === 0) break; } dot.el._autoSide = true; dot.el.dataset.labelSide = best.side; }}/** points along every drawn wire, by wire group - what a label must not sit on */function wirePoints(wires) { const out = new Map(); for (const g of dfDollar(wires).children('.diagram-wire').toArray()) { const path = dfDollar(g).children<SVGPathElement>('.diagram-wire-line').get(0); if (!path?.getTotalLength) continue; const len = path.getTotalLength(); const pts = []; for (let t = 0; t <= len; t += 5) pts.push(path.getPointAtLength(t)); out.set(g, pts); } return out;}/** * Place the queued labels: each tries spots along its own segment (and just * beside it) and takes the one that covers the least - of nodes, text, * labels already placed and the OTHER wires (a label never sits on an arrow * that is not its own). */function placeLabels(queue, layerEl, wires, nodes, texts) { if (!queue.length) return; const placed = [ ...[...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost').map((n) => n.rect), ...texts, ]; const points = wirePoints(wires); for (const item of queue) { const { text, at, edge, cls, along, g } = item; const span = document.createElement('span'); span.className = cls; dfDollar(span).text(text); for (const name of ['tone', 'stepState', 'change', 'activeState']) if (edge?.dataset[name]) span.dataset[name] = edge.dataset[name]; if (edge) span.dataset.edgeRef = edgeRef(edge); if (span.dataset.stepState === 'current' && layerEl._entered?.has(`span|${span.dataset.edgeRef ?? ''}|${text}`)) span.dataset.stepEntered = ''; const i = edge?.style.getPropertyValue('--step-i'); if (i) span.style.setProperty('--step-i', i); dfDollar(layerEl).append(span); const w = span.offsetWidth + 4; const h = span.offsetHeight + 2; const centered = cls === 'diagram-wire-label' || cls.includes('diagram-wire-end'); const box = (q) => (centered ? { x: q.x - w / 2, y: q.y - h / 2, w, h } : { x: q.x, y: q.y - h / 2, w, h }); const cover = (b) => { let sum = 0; for (const o of placed) sum += Math.max(0, Math.min(b.x + b.w, o.x + o.w) - Math.max(b.x, o.x)) * Math.max(0, Math.min(b.y + b.h, o.y + o.h) - Math.max(b.y, o.y)); for (const [wg, pts] of points) { if (wg === g) continue; for (const q of pts) if (q.x > b.x - 1 && q.x < b.x + b.w + 1 && q.y > b.y - 1 && q.y < b.y + b.h + 1) sum += 40; } return sum; }; let pos = at; const dir = along ?? { x: 1, y: 0, len: 0 }; const step = dir.x ? w * 0.6 + 6 : h + 4; const across = dir.x ? { x: 0, y: h / 2 + 5 } : { x: w / 2 + 6, y: 0 }; // slide only within the segment the label belongs to const room = Math.max(0, (dir.len ?? Infinity) / 2 - (dir.x ? w / 2 : h / 2)); let least = Infinity; for (const [k, j] of [[0, 0], [1, 0], [-1, 0], [2, 0], [-2, 0], [0, -1], [0, 1], [1, -1], [-1, -1], [1, 1], [-1, 1], [3, 0], [-3, 0]]) { if (Math.abs(k * step) > room) continue; if (!along && (k || j)) continue; const q = { x: at.x + dir.x * k * step + across.x * j, y: at.y + dir.y * k * step + across.y * j }; const c = cover(box(q)) + (k || j ? 1 : 0); if (c < least) { least = c; pos = q; } if (c <= 1) break; } placed.push(box(pos)); span.style.left = `${r1(pos.x)}px`; span.style.top = `${r1(pos.y)}px`; }}/** every edge of a canvas: anchors, distributed ports, route, ends, label */function drawEdges(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn, texts = []) { const plans = []; // nodes a route should not cross (groups are zones - wires pass over them) const fig = canvas.closest('.diagram'); const busRoute = fig?.dataset.route === 'bus' || fig?.dataset.type === 'organigram'; const obstacles = [...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost'); for (const edge of edgesIn(list)) { const ends = edgeIn(edge, panel); if (!ends) continue; const from = parseEnd(ends.from); const to = parseEnd(ends.to); const a = nodes.get(from.id); const b = nodes.get(to.id); if (!a || !b) continue; const curve = edge.dataset.curve || 'elbow'; const plan: { edge: HTMLElement; from: typeof from; to: typeof to; a: typeof a; b: typeof b; curve: string; sa?: string; sb?: string; outer?: boolean } = { edge, from, to, a, b, curve }; if (a === b) plan.curve = 'self'; else if (curve === 'around') { plan.sa = from.side || 'right'; plan.sb = to.side || plan.sa; } else if (curve === 'elbow' && busRoute && !from.side && !to.side && Math.abs(center(a.rect).y - center(b.rect).y) > a.rect.h / 2) { // a bus (organigram): parents drop from the bottom, children are entered from the top [plan.sa, plan.sb] = center(b.rect).y > center(a.rect).y ? ['bottom', 'top'] : ['top', 'bottom']; } else if (curve === 'elbow') { [plan.sa, plan.sb, plan.outer] = bestSides(a, b, from.side, to.side, obstacles, texts); } plans.push(plan); } // distribute ports: edges sharing a node side fan out, ordered by where they go // (bus routing - an organigram - shares them: one trunk per parent) const bus = canvas.closest('.diagram')?.dataset.route === 'bus' || canvas.closest('.diagram')?.dataset.type === 'organigram'; const ports = new Map(); for (const plan of plans) { if (!plan.sa || bus) continue; for (const [end, node, side, other] of [['a', plan.a, plan.sa, plan.b], ['b', plan.b, plan.sb, plan.a]]) { if ((end === 'a' ? plan.from : plan.to).field) continue; const key = `${node.el.dataset.node}|${side}`; if (!ports.has(key)) ports.set(key, []); ports.get(key).push({ plan, end, node, side, toward: center(other.rect) }); } } for (const group of ports.values()) { const along = horizontal(group[0].side) ? 'y' : 'x'; group.sort((p, q) => p.toward[along] - q.toward[along]); const r = group[0].node.rect; const length = horizontal(group[0].side) ? r.h : r.w; const step = Math.min(18, (length * 0.8) / Math.max(1, group.length)); group.forEach((p, i) => { const offset = (i - (group.length - 1) / 2) * step; const t = 0.5 + offset / length; p.plan[p.end === 'a' ? 'pa' : 'pb'] = sidePoint(r, p.side, t); }); } const fieldPoint = (node, field, toward) => { const row = dfDollar(node.el).find(`[data-field="${field}"]`).get(0); if (!row) return null; const rr = rectOf(row); const left = toward.x < node.rect.x + node.rect.w / 2; return { point: { x: left ? node.rect.x : node.rect.x + node.rect.w, y: rr.y + rr.h / 2 }, side: left ? 'left' : 'right' }; }; const solid = [...nodes.values()].filter((n) => !n.el.classList.contains('diagram-group') && n.el.dataset.shape !== 'ghost'); const outside = Math.max(0, ...solid.map((n) => n.rect.x + n.rect.w)) + 24; const outsideLeft = Math.min(...solid.map((n) => n.rect.x)) - 24; // pass 1: the orthogonal routes for (const plan of plans) { if (!plan.sa) continue; const { a, b } = plan; let pa = plan.pa ?? sidePoint(a.rect, plan.sa); let pb = plan.pb ?? sidePoint(b.rect, plan.sb); let sa = plan.sa; let sb = plan.sb; if (plan.from.field) { const f = fieldPoint(a, plan.from.field, center(b.rect)); if (f) ({ point: pa, side: sa } = f); } if (plan.to.field) { const f = fieldPoint(b, plan.to.field, pa); if (f) ({ point: pb, side: sb } = f); } if (plan.from.field && plan.to.field && sa !== sb && (pb.x - pa.x) * NORMAL[sa][0] <= 16) { sb = sa; // two tables side by side: both leave on the near side pb = { x: sa === 'left' ? b.rect.x : b.rect.x + b.rect.w, y: pb.y }; } // a near-straight route with one fanned end: move the lone end, not the line const facing = sa !== sb && horizontal(sa) === horizontal(sb); const axis = horizontal(sa) ? 'y' : 'x'; if (facing && !plan.from.field && !plan.to.field && Math.abs(pa[axis] - pb[axis]) < 14) { const lone = (node, side) => (ports.get(`${node.el.dataset.node}|${side}`)?.length ?? 1) === 1; const within = (r, v) => (axis === 'y' ? v > r.y + 4 && v < r.y + r.h - 4 : v > r.x + 4 && v < r.x + r.w - 4); if (lone(a, sa) && within(a.rect, pb[axis])) pa = { ...pa, [axis]: pb[axis] }; else if (lone(b, sb) && within(b.rect, pa[axis])) pb = { ...pb, [axis]: pa[axis] }; } plan.raw = elbow(pa, sa, pb, sb, 16, plan.curve === 'around' ? outside : (sa === plan.sa && sb === plan.sb ? plan.outer ?? null : null)); // an 'around' route without fixed sides takes the outer side that crosses fewer nodes if (plan.curve === 'around' && !plan.from.side && !plan.to.side) { const hits = (pts) => solid.filter((o) => o !== a && o !== b && crosses(pts, o.rect)).length; const left = elbow(sidePoint(a.rect, 'left'), 'left', sidePoint(b.rect, 'left'), 'left', 16, outsideLeft); if (hits(left) < hits(plan.raw)) plan.raw = left; } } if (!bus) spreadChannels(plans.filter((plan) => plan.raw)); // pass 2: draw for (const plan of plans) { const { edge, a, b } = plan; let pts; let dEnd; let dStart; let at; let path; const head = edge.dataset.head || 'arrow'; const tail = edge.dataset.tail || 'none'; if (plan.curve === 'self') { const r = a.rect; const p1 = { x: r.x + r.w * 0.68, y: r.y }; const p2 = { x: r.x + r.w * 0.32, y: r.y }; path = `M${r1(p1.x)} ${r1(p1.y)} C${r1(p1.x + 6)} ${r1(r.y - 30)} ${r1(p2.x - 6)} ${r1(r.y - 30)} ${r1(p2.x)} ${r1(p2.y)}`; at = { x: p1.x + 8, y: r.y - 18 }; plan.selfLabel = true; pts = [p1, p2]; dEnd = { x: 0.25, y: 1 }; dStart = { x: 0.25, y: 1 }; } else if (plan.curve === 'straight' || plan.curve === 'curve') { const ca = center(a.rect); const cb = center(b.rect); if (plan.curve === 'straight') { const pa = boundary(a.rect, cb, a.round); const pb = boundary(b.rect, ca, b.round); pts = inset(inset([pa, pb], HEADS[tail]?.inset ?? 0, true), HEADS[head]?.inset ?? 0, false); path = roundedPath(pts); at = { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 }; dEnd = unit(pa, pb); dStart = unit(pb, pa); pts = [pa, pb]; } else { const len = Math.hypot(cb.x - ca.x, cb.y - ca.y) || 1; let n = { x: -(cb.y - ca.y) / len, y: (cb.x - ca.x) / len }; const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 }; const c0 = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }; if (n.x * (mid.x - c0.x) + n.y * (mid.y - c0.y) < 0) n = { x: -n.x, y: -n.y }; const bend = parseFloat(edge.dataset.bend ?? '0.22'); const ctrl = { x: mid.x + n.x * len * bend, y: mid.y + n.y * len * bend }; const pa = boundary(a.rect, ctrl, a.round); const pb = boundary(b.rect, ctrl, b.round); dEnd = unit(ctrl, pb); dStart = unit(ctrl, pa); const ib = HEADS[head]?.inset ?? 0; const pbi = { x: pb.x - dEnd.x * ib, y: pb.y - dEnd.y * ib }; path = `M${r1(pa.x)} ${r1(pa.y)} Q${r1(ctrl.x)} ${r1(ctrl.y)} ${r1(pbi.x)} ${r1(pbi.y)}`; at = { x: 0.25 * pa.x + 0.5 * ctrl.x + 0.25 * pb.x, y: 0.25 * pa.y + 0.5 * ctrl.y + 0.25 * pb.y }; pts = [pa, pb]; } } else { const raw = plan.raw; ({ at, along: plan.along } = labelSpot(raw)); dEnd = unit(raw[raw.length - 2], raw[raw.length - 1]); dStart = unit(raw[1], raw[0]); pts = inset(inset(raw, HEADS[tail]?.inset ?? 0, true), HEADS[head]?.inset ?? 0, false); path = roundedPath(pts); pts = raw; } const g = wireGroup(edge); line(g, path, !edge.dataset.line); const tip = plan.curve === 'self' ? pts[1] : pts[pts.length - 1]; if (head !== 'none') drawHead(g, head, tip, dEnd); if (tail !== 'none') drawHead(g, tail, pts[0], dStart); g.dataset.path = path; copyFlow(edge, g, '2400'); label(panel === 'before' && edge.dataset.before != null ? edge.dataset.before : edge.dataset.label ?? edge.textContent.trim(), at, edge, plan.selfLabel ? 'diagram-wire-label diagram-wire-self' : undefined, plan.along ?? null, g); const endLabel = (text, p, dir) => { if (!text) return; const perp = { x: -dir.y, y: dir.x }; label(text, { x: p.x - dir.x * 16 + perp.x * 9, y: p.y - dir.y * 16 + perp.y * 9 }, edge, 'diagram-wire-label diagram-wire-end', { x: Math.abs(perp.x) > 0.5 ? 1 : 0, y: Math.abs(perp.y) > 0.5 ? 1 : 0, len: 72 }, g); }; endLabel(edge.dataset.fromLabel, pts[0], dStart); endLabel(edge.dataset.toLabel, tip, dEnd); drawn.push(edge); }}/** * Parallel middle segments that share a channel (the same gap between two * columns or rows) fan out 10px apart, so no two connectors overlap. */function spreadChannels(plans) { const channels = new Map(); for (const plan of plans) { const r = plan.raw; if (r.length !== 4) continue; const vertical = Math.abs(r[1].x - r[2].x) < 0.5; const key = vertical ? `v${Math.round(r[1].x / 8)}` : `h${Math.round(r[1].y / 8)}`; if (!channels.has(key)) channels.set(key, []); channels.get(key).push({ plan, vertical }); } for (const group of channels.values()) { if (group.length < 2) continue; const along = group[0].vertical ? 'y' : 'x'; group.sort((p, q) => Math.min(p.plan.raw[1][along], p.plan.raw[2][along]) - Math.min(q.plan.raw[1][along], q.plan.raw[2][along])); group.forEach(({ plan, vertical }, i) => { const offset = (i - (group.length - 1) / 2) * 10; const axis = vertical ? 'x' : 'y'; plan.raw = plan.raw.map((pt, j) => (j === 1 || j === 2 ? { ...pt, [axis]: pt[axis] + offset } : pt)); }); }}/** a sequence: lifelines under the participants, one message per list row */function drawSequence(canvas, nodes, list, panel, rectOf, wireGroup, line, label, drawn) { const bottom = canvas.clientHeight - CLEARANCE; for (const node of nodes.values()) { if (node.el.dataset.shape === 'ghost') continue; const c = center(node.rect); const g = wireGroup(null, { 'data-lifeline': '', 'data-step-state': node.el.dataset.stepState, 'data-active-state': node.el.dataset.activeState, beatOf: node.el }); line(g, `M${r1(c.x)} ${r1(node.rect.y + node.rect.h)} V${r1(bottom)}`, false); } for (const edge of edgesIn(list)) { const ends = edgeIn(edge, panel); if (!ends) continue; const a = nodes.get(parseEnd(ends.from).id); const b = nodes.get(parseEnd(ends.to).id); if (!a || !b) continue; const row = rectOf(edge); const y = row.y + row.h * 0.62; const x1 = center(a.rect).x; const x2 = center(b.rect).x; const g = wireGroup(edge); const head = edge.dataset.head || (edge.dataset.line === 'dashed' ? 'open' : 'arrow'); if (a === b) { line(g, `M${r1(x1)} ${r1(y - 8)} H${r1(x1 + 30)} V${r1(y + 8)} H${r1(x1 + 4)}`, !edge.dataset.line); drawHead(g, head, { x: x1 + 1, y: y + 8 }, { x: -1, y: 0 }); label(edge.textContent.trim(), { x: x1 + 36, y }, edge, 'diagram-wire-label diagram-wire-self', null, g); } else { const dir = x2 > x1 ? 1 : -1; const ib = HEADS[head]?.inset ?? 0; line(g, `M${r1(x1)} ${r1(y)} H${r1(x2 - dir * ib)}`, !edge.dataset.line); drawHead(g, head, { x: x2, y }, { x: dir, y: 0 }); // a message label may slide along its message, clear of the lifelines it passes label(edge.textContent.trim(), { x: (x1 + x2) / 2, y: y - 11 }, edge, 'diagram-wire-label', { x: 1, y: 0, len: Math.abs(x2 - x1) - 16 }, g); g.dataset.path = `M${r1(x1)} ${r1(y)} H${r1(x2)}`; copyFlow(edge, g, '1600'); } drawn.push(edge); }}/** a fishbone: the spine into the effect, one 60° bone per [data-bone] category */function drawBones(nodes, wireGroup, line) { const effect = [...nodes.values()].find((n) => n.el.hasAttribute('data-effect')); if (!effect) return; const bones = [...nodes.values()].filter((n) => n.el.hasAttribute('data-bone')); const y = effect.rect.y + effect.rect.h / 2; const x0 = Math.min(effect.rect.x, ...bones.map((n) => n.rect.x)) - 8; const spine = wireGroup(null, { 'data-spine': '', 'data-tone': effect.el.dataset.tone }); line(spine, `M${r1(x0)} ${r1(y)} H${r1(effect.rect.x - 3)}`, true); drawHead(spine, 'arrow', { x: effect.rect.x, y }, { x: 1, y: 0 }); for (const bone of bones) { const above = bone.rect.y + bone.rect.h / 2 < y; const from = { x: bone.rect.x + bone.rect.w / 2, y: above ? bone.rect.y + bone.rect.h : bone.rect.y }; const dy = Math.abs(y - from.y); const to = { x: from.x + dy * 0.577, y }; const g = wireGroup(null, { 'data-bone': '', 'data-tone': bone.el.dataset.tone, 'data-step-state': bone.el.dataset.stepState, 'data-active-state': bone.el.dataset.activeState, beatOf: bone.el }); line(g, `M${r1(from.x)} ${r1(from.y)} L${r1(to.x)} ${r1(to.y)}`, true); }}const BADGE = { added: '+', removed: '−', changed: 'Δ', moved: '→', rewired: '⇄' };/** the change badges of the changes panel: one chip on each changed node or group */function badges(canvas, labels, rectOf) { for (const el of dfDollar(canvas).find('[data-change]').toArray()) { if (el.closest('.diagram-edges, .diagram-wires, .diagram-wire-labels') || !el.getClientRects().length || !BADGE[el.dataset.change]) continue; const r = rectOf(el); const chip = document.createElement('span'); chip.className = 'diagram-badge'; chip.dataset.change = el.dataset.change; dfDollar(chip).text(BADGE[el.dataset.change]); chip.style.left = `${r1(r.x + r.w)}px`; chip.style.top = `${r1(r.y)}px`; dfDollar(labels).append(chip); }}/** an edge's flow settings onto its wire: duration (data-flow), tokens on it (data-flow-tokens), start offset (data-flow-delay) */function copyFlow(edge, g, fallback) { if (edge.dataset.flow == null) return; g.dataset.flow = edge.dataset.flow || fallback; if (edge.dataset.flowTokens) g.dataset.flowTokens = edge.dataset.flowTokens; if (edge.dataset.flowDelay) g.dataset.flowDelay = edge.dataset.flowDelay;}/** * The flow tokens: data-flow-tokens dots (default 1) per data-flow wire, * evenly spaced, travelling its path - a whole pipeline can flow at once, * data-flow-delay offsets a wire so tokens hand over from one to the next. * Hidden on future steps and dimmed wires; never under reduced motion. */function flowTokens(labels, wires) { if (reducedMotion()) return; for (const g of dfDollar(wires).children('[data-flow]').toArray()) { if (g.dataset.stepState === 'future' || g.dataset.activeState === 'dimmed') continue; const duration = Math.max(800, parseInt(g.dataset.flow, 10) || 2400); const count = Math.max(1, Math.min(8, parseInt(g.dataset.flowTokens, 10) || 1)); const delay = parseInt(g.dataset.flowDelay, 10) || 0; for (let i = 0; i < count; i++) { const token = document.createElement('span'); token.className = 'diagram-token'; if (g.dataset.tone) token.dataset.tone = g.dataset.tone; token.style.offsetPath = `path('${g.dataset.path}')`; dfDollar(labels).append(token); const once = g.dataset.stepState === 'current'; token.animate?.([{ offsetDistance: '0%', opacity: 0 }, { opacity: 1, offset: 0.08 }, { opacity: 1, offset: 0.92 }, { offsetDistance: '100%', opacity: 0 }], { duration, delay: once ? delay + (i * duration) / count : delay - (i * duration) / count, iterations: once ? 1 : Infinity, easing: 'linear', fill: 'backwards', }); } }}// -- delta -----------------------------------------------------------------------------------const PANEL_TITLES = ['Before', 'Changes', 'After'];const CHANGE_WORD = { added: 'Added', removed: 'Removed', changed: 'Changed', moved: 'Moved', rewired: 'Rewired' };/** one panel's copy of the canvas: what that snapshot shows */function panelCanvas(source, panel) { const copy = source.cloneNode(true); for (const el of [copy, ...dfDollar(copy).find('[id]').toArray()]) el.removeAttribute('id'); dfDollar(copy).find('.diagram-wires, .diagram-wire-labels').remove(); for (const el of dfDollar(copy).find('[data-change]').toArray()) { const change = el.dataset.change; if ((panel === 'before' && change === 'added') || (panel === 'after' && change === 'removed')) el.setAttribute('data-delta-hidden', ''); } if (panel === 'before') { for (const el of dfDollar(copy).find('[data-before]').toArray()) dfDollar(el).text(el.dataset.before); for (const el of [copy, ...dfDollar(copy).find('[data-before-style]').toArray()]) { if (el.dataset.beforeStyle != null) el.setAttribute('style', el.dataset.beforeStyle); } // a status, tone, badge or shape that changed: data-before-status="skip" ... for (const key of ['status', 'tone', 'badge', 'shape']) { for (const el of dfDollar(copy).find(`[data-before-${key}]`).toArray()) { const old = el.getAttribute(`data-before-${key}`); dfDollar(el).attr(`data-${key}`, old === '' ? null : old); } } } return copy;}/** the change ledger: one entry per changed element, edge or cell */function ledger(root, canvas) { const names = new Map(authored(canvas, '[data-node]').map((n) => [n.dataset.node, nameOf(n)])); const endName = (v) => names.get(parseEnd(v).id) || parseEnd(v).id; const list = edgeListOf(root, canvas); const items = [...authored(canvas, '[data-change]'), ...(list && !canvas.contains(list) ? edgesIn(list).filter((e) => e.dataset.change) : [])]; const entries = []; for (const el of items) { const change = el.dataset.change; if (!CHANGE_WORD[change]) continue; const isEdge = el.classList.contains('diagram-edge'); let subject = nameOf(el); if (isEdge) subject = `${endName(el.dataset.from)} → ${endName(el.dataset.to)}${el.textContent.trim() ? ` (${el.textContent.trim()})` : ''}`; let note = el.dataset.changeNote || ''; if (!note && change === 'changed') { note = [el, ...dfDollar(el).find('[data-before]').toArray()].filter((n) => n.dataset.before != null).map((n) => `${n.dataset.before} → ${n.textContent.trim()}`).join(' · '); } if (!note && change === 'rewired' && isEdge) note = `was ${endName(el.dataset.beforeFrom || el.dataset.from)} → ${endName(el.dataset.beforeTo || el.dataset.to)}`; entries.push({ change, subject, note }); } return entries;}/** * The delta view (data-delta): three panels from the one annotated canvas - * Before (added things hidden, data-before values), Changes (everything, * changes marked, the ledger) and After (removed things hidden). Rebuilt on * every state change, so a played diagram plays in all three. */function rebuildDelta(root) { const source = dfDollar(root).children('.diagram-canvas').get(0); let host = dfDollar(root).children('.diagram-delta').get(0); if (!root.hasAttribute('data-delta') || !source) { if (host) dfDollar(host).remove(); return; } if (!host) { host = document.createElement('div'); host.className = 'diagram-delta'; source.after(host); } dfDollar(host).empty(); const titles = (root.dataset.deltaLabels || '').split('|').map((s) => s.trim()); const entries = ledger(root, source); ['before', 'changes', 'after'].forEach((panel, i) => { const section = document.createElement('section'); section.className = 'diagram-panel'; section.dataset.panel = panel; const title = document.createElement('h4'); title.className = 'diagram-panel-title'; dfDollar(title).text(titles[i] || PANEL_TITLES[i]); if (panel === 'changes') { const count = document.createElement('span'); count.className = 'diagram-panel-count'; dfDollar(count).text(String(entries.length)); dfDollar(title).append(count); } const body = document.createElement('div'); body.className = 'diagram-panel-body'; dfDollar(body).append(panelCanvas(source, panel)); dfDollar(section).append(title).append(body); if (panel === 'changes' && entries.length) { const ol = document.createElement('ol'); ol.className = 'diagram-ledger'; for (const entry of entries) { const li = document.createElement('li'); li.className = 'diagram-ledger-item'; li.dataset.change = entry.change; const kind = document.createElement('span'); kind.className = 'diagram-ledger-kind'; dfDollar(kind).text(`${BADGE[entry.change]} ${CHANGE_WORD[entry.change]}`); const subject = document.createElement('strong'); dfDollar(subject).text(entry.subject); dfDollar(li).append(kind).append(subject); if (entry.note) { const note = document.createElement('span'); note.className = 'diagram-ledger-note'; dfDollar(note).text(entry.note); dfDollar(li).append(note); } dfDollar(ol).append(li); } dfDollar(section).append(ol); } dfDollar(host).append(section); }); root._observer?.observe(host);}// -- controls --------------------------------------------------------------------------------const ICONS = { prev: '<path d="m15 18-6-6 6-6"/>', next: '<path d="m9 18 6-6-6-6"/>', play: '<path d="M6 4l14 8-14 8z"/>', pause: '<path d="M7 4v16M17 4v16"/>', replay: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>', all: '<path d="M4 6h16M4 12h16M4 18h16"/>',};function button(action, text, label?) { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn'; b.dataset.variant = 'outline'; b.dataset.size = 'sm'; b.dataset.action = action; if (label) b.setAttribute('aria-label', label); const icon = svg('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true', fill: 'none', stroke: 'currentColor', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }); for (const d of ICONS[action].match(/d="[^"]+"/g)) icon.append(svg('path', { d: d.slice(3, -1) })); b.append(icon); if (text) { const span = document.createElement('span'); dfDollar(span).text(text); b.append(span); } return b;}/** the step toolbar (data-steps): previous / play-pause / next / replay / show all + a status line */function makeControls(root) { const bar = document.createElement('div'); bar.className = 'diagram-controls'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Diagram steps'); bar.append(button('prev', '', 'Previous step'), button('play', 'Play'), button('next', '', 'Next step'), button('replay', 'Replay'), button('all', 'Show all')); const status = document.createElement('output'); status.className = 'diagram-status'; status.setAttribute('aria-live', 'polite'); bar.append(status); dfDollar(bar).on('click', (e) => { const action = (e.target as HTMLElement).closest?.<HTMLElement>('[data-action]')?.dataset.action; if (action) control(root, action); }); dfDollar(bar).on('keydown', (e) => { const map = { ArrowLeft: 'prev', ArrowRight: 'next', Home: 'first', End: 'all', r: 'replay', R: 'replay', ' ': 'toggle' }; const action = map[e.key]; if (!action || (e.key === ' ' && (e.target as HTMLElement).closest?.('button'))) return; e.preventDefault(); control(root, action); }); const canvas = dfDollar(root).children('.diagram-canvas').get(0); const after = dfDollar(root).children('.diagram-delta').get(0) ?? canvas; if (after) after.after(bar); else dfDollar(root).append(bar); return bar;}function updateControls(root) { const bar = root._controls; if (!bar) return; const { max } = stepPlan(root); // the state being shown (the store records it only after the DOM work) const state = root._shown ?? root.store?.value ?? { name: 'default', config: {} }; const playing = state.name === 'playing'; const step = !stepping(state) ? max : stateStep(state, max); const play = dfDollar(bar).find('[data-action="play"]').get(0); if (play) { const swap = dfDollar(play).find('svg').get(0); if (swap) { dfDollar(swap).empty(); for (const d of ICONS[playing ? 'pause' : 'play'].match(/d="[^"]+"/g)) swap.append(svg('path', { d: d.slice(3, -1) })); } dfDollar(play).find('span').text(playing ? 'Pause' : 'Play'); play.setAttribute('aria-pressed', String(playing)); } dfDollar(bar).find('[data-action="prev"]').prop('disabled', stepping(state) && step <= 1); dfDollar(bar).find('[data-action="next"]').prop('disabled', !stepping(state) || step >= max); const status = dfDollar(bar).find('.diagram-status'); if (!stepping(state)) status.text(`Complete · ${max} ${max === 1 ? 'step' : 'steps'}`); else { const name = stepLabel(root, step); status.text(`Step ${step} of ${max}${name ? ` · ${name}` : ''}`); }}/** a toolbar action, through the State API */function control(root, action) { const { max } = stepPlan(root); const state = root._shown ?? root.store?.value ?? { name: 'default', config: {} }; const step = !stepping(state) ? max : stateStep(state, max); const api = root.api; if (action === 'toggle') action = state.name === 'playing' ? 'pause' : 'play'; if (action === 'play' && state.name === 'playing') action = 'pause'; if (action === 'pause') api.setState('paused', { step }); else if (action === 'play') api.setState('playing', { step: state.name === 'paused' && step < max ? step + 1 : 1 }); else if (action === 'replay') api.setState('playing', { step: 1 }); else if (action === 'first') api.setState('paused', { step: 1 }); else if (action === 'all') api.setState('default'); else if (action === 'prev') api.setState('paused', { step: !stepping(state) ? max - 1 : Math.max(1, step - 1) }); else if (action === 'next') api.setState('paused', { step: Math.min(max, step + 1) });}// -- state -----------------------------------------------------------------------------------/** a time custom property in ms - '900ms', '0.9s' (a minifier's spelling) or a bare number */function msOf(root, name, fallback) { const raw = getComputedStyle(root).getPropertyValue(name).trim(); const n = parseFloat(raw); if (!Number.isFinite(n)) return fallback; return raw.endsWith('ms') ? n : raw.endsWith('s') ? n * 1000 : n;}/** how long a step stays: its elements appear one a beat, then it rests for --diagram-hold */function holdOf(root, state) { const { stepOf, max } = stepPlan(root); const step = stateStep(state, max); let boxes = 0; stepOf.forEach((s, el) => { if (s === step && !el.classList.contains('diagram-edge')) boxes++; }); const element = msOf(root, '--diagram-element-ms', 900); const appear = msOf(root, '--diagram-step-ms', 800); return Math.max(0, boxes - 1) * element + appear + msOf(root, '--diagram-hold', 1200);}/** redraw everything a state shows: the delta panels, every canvas's wires, the toolbar */function sync(root) { rebuildDelta(root); for (const canvas of canvasesOf(root)) draw(root, canvas); updateControls(root);}function canvasesOf(root) { const delta = dfDollar(root).children('.diagram-delta').get(0); if (delta) return dfDollar(delta).find('.diagram-canvas').toArray(); return dfDollar(root).children('.diagram-canvas').toArray();}/** UI side of setState (AGENTS.md "State API"): the state's markup, then the playback clock */function triggerStateChange(root, state) { applyMarkup(root, state); clearTimeout(root._timer); const { max } = stepPlan(root); const step = stateStep(state, max); if (state.name === 'playing') { root._timer = setTimeout(() => { if (!root.isConnected || root.store?.value.name !== 'playing') return; if (step >= max) root.api.setState('paused', { step: max }); else root.api.setState('playing', { step: step + 1 }); }, holdOf(root, state)); } const wasActive = root._shown?.name === 'active' ? root._shown.config?.ref ?? null : null; root._shown = state; sync(root); const ref = state.name === 'active' ? state.config?.ref ?? null : null; const about = describe(root, ref); updateOutputs(root, about); queueMicrotask(() => { if (state.name === 'playing' || state.name === 'paused') { // Fires on every step change - the step, the number of steps and the step's label. root.dispatchEvent(new CustomEvent<DiagramStepDetail>('diagram-step', { bubbles: true, detail: { step, max, label: stepLabel(root, step), state: state.name as DiagramStepDetail['state'] } })); } if (String(ref ?? '') !== String(wasActive ?? '')) { // Fires when the activation moves (a click, the keyboard, an outside control, setState) - the reference, 'node' or 'edge', the element, its label and detail; null detail fields when it is cleared. root.dispatchEvent(new CustomEvent<DiagramActivation>('diagram-activate', { bubbles: true, detail: about ?? { ref: null, kind: null, element: null, label: '', detail: '' } })); } });}/** outputs bound to a figure (<output data-diagram-for="id">) say what is active */function updateOutputs(root, about) { if (!root.id) return; for (const out of dfDollar(`[data-diagram-for="${root.id}"]`).toArray()) { if (out.tagName !== 'OUTPUT') continue; if (!out.dataset.diagramEmpty) out.dataset.diagramEmpty = out.textContent.trim(); dfDollar(out).text(about ? `${about.label}${about.detail ? ` - ${about.detail}` : ''}` : out.dataset.diagramEmpty); }}/** Registry-level API; pass the figure explicitly. Unknown names throw. */export const diagramApi = componentState({ component: 'diagram', states: diagramStates, apply: (root, state) => triggerStateChange(root, state), markup: (el, state) => applyMarkup(el, state),});df$.diagramApi = diagramApi;df$.diagramStates = diagramStates;// -- the parametric path: a spec → the same markup ------------------------------------------const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);const attr = (name, value) => (value == null || value === false ? '' : value === true ? ` ${name}` : ` ${name}="${esc(value)}"`);const place = (o) => { const css = [['--col', o.col], ['--row', o.row], ['--span', o.span], ['--rspan', o.rspan], ['--x', o.x], ['--y', o.y], ['--i', o.i]] .filter(([, v]) => v != null) .map(([k, v]) => `${k}:${v}`); if (o.style) css.push(o.style); return css.join(';');};function nodeMarkup(n, children = '') { const before = n.before ?? {}; const fields = n.fields?.length ? `<ul class="diagram-node-fields">${n.fields.map((f) => { const field = typeof f === 'string' ? { name: f } : f; return `<li${attr('data-field', field.name)}${attr('data-key', field.key)}${attr('data-change', field.change)}><span>${esc(field.name)}</span>${field.type ? `<span>${esc(field.type)}</span>` : ''}</li>`; }).join('')}</ul>` : ''; const ops = n.ops?.length ? `<ul class="diagram-node-ops">${n.ops.map((o) => `<li>${esc(o)}</li>`).join('')}</ul>` : ''; const text = (cls, key) => (n[key] != null ? `<span class="${cls}"${attr('data-before', before[key])}>${esc(n[key])}</span>` : ''); const beforeStyle = ['col', 'row', 'span', 'rspan', 'x', 'y'].some((k) => before[k] != null) ? place({ ...n, ...before }) : null; return `<div class="diagram-node"${attr('data-node', n.id)}${attr('data-shape', n.shape)}${attr('data-tone', n.tone)}${attr('data-status', n.status)}${attr('data-badge', n.badge)}${attr('data-step', n.step)}${attr('data-change', n.change)}${attr('data-change-note', n.note)}${attr('data-before-style', beforeStyle)}${attr('style', place(n) || null)}>${text('diagram-node-eyebrow', 'eyebrow')}${text('diagram-node-name', 'name')}${text('diagram-node-meta', 'meta')}${fields}${ops}${children}</div>`;}/** * The markup of a spec: { type, title, eyebrow, caption, cols, phases, * groups: [{ id, label, col, row, span, rspan, shape, tone, parent }], * nodes: [{ id, name, eyebrow, meta, col, row, span, shape, tone, badge, * fields, ops, step, parent, change, before }], edges: [{ from, to, label, * line, tone, head, tail, curve, step, flow, change, before }] }. */function markupOf(spec) { const groups = spec.groups ?? []; const nodes = spec.nodes ?? []; const childrenOf = (id) => [ ...groups.filter((g) => g.parent === id).map((g) => groupMarkup(g)), ...nodes.filter((n) => n.parent === id).map((n) => nodeMarkup(n)), ].join(''); function groupMarkup(g) { return `<div class="diagram-group"${attr('data-node', g.id)}${attr('data-shape', g.shape)}${attr('data-tone', g.tone)}${attr('data-step', g.step)}${attr('data-change', g.change)}${attr('style', place(g) || null)}><span class="diagram-group-label">${esc(g.label)}</span>${childrenOf(g.id)}</div>`; } const caption = spec.title || spec.caption ? `<figcaption class="diagram-caption">${spec.eyebrow ? `<span class="diagram-eyebrow">${esc(spec.eyebrow)}</span>` : ''}${spec.title ? `<span class="diagram-title">${esc(spec.title)}</span>` : ''}${spec.caption ? `<span class="diagram-dek">${esc(spec.caption)}</span>` : ''}</figcaption>` : ''; const phases = spec.phases?.length ? `<ol class="diagram-phases" style="--row:1;--col:1;--span:${spec.cols ?? spec.phases.length}">${spec.phases.map((p) => { const phase = typeof p === 'string' ? { name: p } : p; return `<li${attr('style', phase.span ? `--span:${phase.span}` : null)}>${esc(phase.name)}</li>`; }).join('')}</ol>` : ''; const top = [...groups.filter((g) => !g.parent).map(groupMarkup), ...nodes.filter((n) => !n.parent).map((n) => nodeMarkup(n))].join(''); const edges = (spec.edges ?? []).map((e) => { const before = e.before ?? {}; return `<li class="diagram-edge"${attr('data-from', e.from)}${attr('data-to', e.to)}${attr('data-line', e.line)}${attr('data-tone', e.tone)}${attr('data-head', e.head)}${attr('data-tail', e.tail)}${attr('data-curve', e.curve)}${attr('data-step', e.step)}${attr('data-flow', e.flow === true ? '' : e.flow)}${attr('data-from-label', e.fromLabel)}${attr('data-to-label', e.toLabel)}${attr('data-change', e.change)}${attr('data-before', before.label)}${attr('data-before-from', before.from)}${attr('data-before-to', before.to)}>${esc(e.label)}</li>`; }).join(''); return `${caption}<div class="diagram-canvas"${attr('style', [spec.cols ? `--cols:${spec.cols}` : '', spec.style ?? ''].filter(Boolean).join(';') || null)}>${phases}${top}</div>${edges ? `<ol class="diagram-edges">${edges}</ol>` : ''}`;}/** * Two specs → one annotated spec: nodes, groups and edges only in `after` are * added, only in `before` removed; a node whose text or kind differs is * changed (its old text kept as before), one whose place differs moved; an * edge with the same id but other ends rewired. */function diffSpecs(before, after) { const key = (e) => e.id ?? `${e.from}->${e.to}`; const merge = (olds = [], news = [], keyOf, compare) => { const oldMap = new Map(olds.map((o) => [keyOf(o), o])); const newKeys = new Set(news.map(keyOf)); const out = news.map((n) => { const o = oldMap.get(keyOf(n)); if (!o) return { ...n, change: 'added' }; return compare(o, n); }); // removed items keep their place in the old order olds.forEach((o, i) => { if (!newKeys.has(keyOf(o))) out.splice(Math.min(i, out.length), 0, { ...o, change: 'removed' }); }); return out; }; const TEXT = ['name', 'eyebrow', 'meta']; const PLACE = ['col', 'row', 'span', 'rspan', 'x', 'y']; const compareNode = (o, n) => { const text = TEXT.filter((k) => (o[k] ?? null) !== (n[k] ?? null)); const kind = ['shape', 'tone', 'badge'].some((k) => (o[k] ?? null) !== (n[k] ?? null)) || JSON.stringify(o.fields ?? []) !== JSON.stringify(n.fields ?? []); const moved = PLACE.filter((k) => (o[k] ?? null) !== (n[k] ?? null)); if (!text.length && !kind && !moved.length) return n; const prev = Object.fromEntries([...text, ...moved].map((k) => [k, o[k]])); return { ...n, change: text.length || kind ? 'changed' : 'moved', before: prev }; }; const compareEdge = (o, n) => { if (o.from !== n.from || o.to !== n.to) return { ...n, change: 'rewired', before: { from: o.from, to: o.to } }; if ((o.label ?? '') !== (n.label ?? '') || (o.line ?? '') !== (n.line ?? '')) return { ...n, change: 'changed', before: { label: o.label ?? '' } }; return n; }; return { ...before, ...after, groups: merge(before.groups, after.groups, (g) => g.id, (o, n) => ((o.label !== n.label || PLACE.some((k) => o[k] !== n[k])) ? { ...n, change: 'changed' } : n)), nodes: merge(before.nodes, after.nodes, (n) => n.id, compareNode), edges: merge(before.edges, after.edges, key, compareEdge), };}const resolve = (target) => (typeof target === 'string' ? dfDollar(target).get(0) : target);/** { before, after } - two specs for a delta, not one diagram */const isDelta = (spec: DiagramSpec | DiagramDelta): spec is DiagramDelta => !!(spec as DiagramDelta)?.before && !!(spec as DiagramDelta)?.after;/** a spec's figure switches: steps, autoplay, interactive (the same data-* an author writes) */function specFlags(root, spec) { for (const flag of ['steps', 'autoplay', 'interactive']) if (spec[flag]) root.setAttribute(`data-${flag}`, typeof spec[flag] === 'number' ? String(spec[flag]) : '');}// -- properties: a node or an edge as plain JSON (an inspector, the Studio) ---------------------const TONES = ['none', 'accent', 'link', 'muted', 'external', 'warn', 'ok', 'danger'];const SHAPES = ['box', 'pill', 'diamond', 'store', 'circle', 'dot', 'bar', 'note', 'activity', 'class', 'start', 'end'];const NODE_TEXT = [['eyebrow', 'diagram-node-eyebrow'], ['name', 'diagram-node-name'], ['meta', 'diagram-node-meta']];/** a cell's own text (a matrix chip keeps its span) */const cellText = (c) => (dfDollar(c).children('span').get(0) ?? c).textContent.trim();/** a column's header text, without its small print */const headText = (c) => [...c.childNodes].filter((n) => n.nodeType === 3 || n.nodeName !== 'SMALL').map((n) => n.textContent).join('').trim();function propertiesOf(root, ref) { const target = refTarget(root, ref); if (!target) return null; const { el, kind } = target; if (el.tagName === 'TR') { const cells = dfDollar(el).children('th, td').toArray(); const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray(); const out = { row: headText(cells[0]) }; cells.slice(1).forEach((c, i) => { out[heads[i + 1] ? headText(heads[i + 1]) : `column ${i + 2}`] = cellText(c); }); return out; } if (kind === 'node') { const out: Record<string, string> = { id: el.dataset.node }; for (const [key, cls] of NODE_TEXT) out[key] = dfDollar(el).find(`.${cls}`).get(0)?.textContent.trim() ?? ''; out.tone = el.dataset.tone || 'none'; out.shape = el.dataset.shape || 'box'; return out; } return { from: el.dataset.from, to: el.dataset.to, label: el.textContent.trim(), line: el.dataset.line || 'solid', tone: el.dataset.tone || 'none', head: el.dataset.head || 'arrow', tail: el.dataset.tail || 'none', curve: el.dataset.curve || 'elbow', };}function propertySchemaOf(root, ref) { const target = refTarget(root, ref); if (!target) return {}; if (target.el.tagName === 'TR') return { row: { readOnly: true } }; if (target.kind === 'node') return { id: { readOnly: true }, tone: { options: TONES }, shape: { options: SHAPES } }; const ends = Object.keys(HEADS); return { from: { readOnly: true }, to: { readOnly: true }, line: { options: ['solid', 'dashed', 'dotted', 'thick'] }, tone: { options: ['none', 'accent', 'link', 'ink', 'ok', 'danger'] }, head: { options: ends }, tail: { options: ends }, curve: { options: ['elbow', 'straight', 'curve', 'around'] }, };}/** write properties back - only what differs - and redraw */function setPropertiesOf(root, ref, props) { const target = refTarget(root, ref); if (!target || !props) return false; const { el, kind } = target; const attr = (name, value, none) => dfDollar(el).attr(`data-${name}`, value == null || value === '' || value === none ? null : String(value)); if (el.tagName === 'TR') { const cells = dfDollar(el).children('th, td').toArray(); const heads = dfDollar(el.closest('table')).find('thead tr').first().children('th, td').toArray(); cells.slice(1).forEach((c, i) => { const key = heads[i + 1] ? headText(heads[i + 1]) : `column ${i + 2}`; if (key in props && cellText(c) !== String(props[key])) dfDollar(dfDollar(c).children('span').get(0) ?? c).text(String(props[key])); }); } else if (kind === 'node') { // text parts: the node's own spans (a timeline event keeps them in its body) const host = dfDollar(el).children('.diagram-node-body').get(0) ?? el; for (const [key, cls] of NODE_TEXT) { if (!(key in props)) continue; const value = String(props[key] ?? ''); let span = dfDollar(el).find(`.${cls}`).get(0); if (span && !value) dfDollar(span).remove(); else if (span && span.textContent !== value) dfDollar(span).text(value); else if (!span && value) { span = document.createElement('span'); span.className = cls; dfDollar(span).text(value); // eyebrow first, meta after the name const name = dfDollar(host).children('.diagram-node-name').get(0); if (key === 'eyebrow') host.prepend(span); else if (key === 'meta' && name) name.after(span); else host.append(span); } } if ('tone' in props) attr('tone', props.tone, 'none'); if ('shape' in props) attr('shape', props.shape, 'box'); } else { if ('label' in props && el.textContent !== String(props.label ?? '')) dfDollar(el).text(String(props.label ?? '')); if ('line' in props) attr('line', props.line, 'solid'); if ('tone' in props) attr('tone', props.tone, 'none'); if ('head' in props) attr('head', props.head, 'arrow'); if ('tail' in props) attr('tail', props.tail, 'none'); if ('curve' in props) attr('curve', props.curve, 'elbow'); } for (const canvas of canvasesOf(root)) draw(root, canvas); return true;}df$.diagram = { /** * Build a diagram from a spec (see markup) into a .diagram figure, replacing * what it holds - or from { before, after } (two specs → one delta diagram, * see diff). * @param target - the .diagram figure or its selector * @param spec - the diagram, or { before, after } for a delta * @returns the figure, null when the target matches none */ build(target: string | HTMLElement, spec: DiagramSpec | DiagramDelta): HTMLElement | null { const root = resolve(target); if (!root) return null; const delta = isDelta(spec); const merged = isDelta(spec) ? diffSpecs(spec.before, spec.after) : spec; if (merged.type) dfDollar(root).attr('data-type', merged.type); specFlags(root, merged); if (delta) dfDollar(root).attr('data-delta', root.getAttribute('data-delta') ?? ''); root._controls?.remove(); root._controls = null; dfDollar(root).children('.diagram-delta').remove(); dfDollar(root).html(markupOf(merged)); setup(root); if (root.api) triggerStateChange(root, root.store?.value ?? { name: 'default', config: {} }); return root; }, /** * The HTML of a spec - the same markup an author writes (server-side rendering, copy-paste). * @param spec - the diagram, or { before, after } for a delta * @returns the figure's inner markup (caption, canvas, edge list) */ markup: (spec: DiagramSpec | DiagramDelta): string => markupOf(isDelta(spec) ? diffSpecs(spec.before, spec.after) : spec), /** * Two specs → one spec annotated with added / removed / changed / moved / rewired (the delta source). * @param before - the diagram before * @param after - the diagram after * @returns one spec: the after state, every element marked with its change */ diff: (before: DiagramSpec, after: DiagramSpec): DiagramSpec => diffSpecs(before, after), /** * Measure the nodes again and redraw every wire (after you moved or resized nodes yourself). * @param target - the .diagram figure or its selector */ redraw(target: string | HTMLElement): void { const root = resolve(target); if (root) for (const canvas of canvasesOf(root)) draw(root, canvas); }, /** * Play the steps from the first (or from `step`). * @param target - the .diagram figure or its selector * @param step - the step to start at, 1-based */ play: (target: string | HTMLElement, step: number = 1): void => { resolve(target)?.api.setState('playing', { step }); }, /** * Pause on the current step. * @param target - the .diagram figure or its selector */ pause: (target: string | HTMLElement): void => { control(resolve(target), 'pause'); }, /** * One step forward (pauses). * @param target - the .diagram figure or its selector */ next: (target: string | HTMLElement): void => { control(resolve(target), 'next'); }, /** * One step back (pauses). * @param target - the .diagram figure or its selector */ prev: (target: string | HTMLElement): void => { control(resolve(target), 'prev'); }, /** * The complete figure again (state 'default'). * @param target - the .diagram figure or its selector */ reset: (target: string | HTMLElement): void => { resolve(target)?.api.setState('default'); }, /** * Activate a node (its data-node) or an edge (its data-edge, else "from->to"); null clears. * @param target - the .diagram figure or its selector * @param ref - null or '' clears; else a node (its data-node) or an edge (its data-edge, else "from->to") */ activate(target: string | HTMLElement, ref: string | null): void { const root = resolve(target); if (!root) return; if (ref == null || ref === '') root.api.setState('default'); else root.api.setState('active', { ref: String(ref) }); }, /** * Activate the next node in the activation order (step order, then markup order; wraps around). * @param target - the .diagram figure or its selector */ activateNext: (target: string | HTMLElement): void => { stepActivation(resolve(target), 1); }, /** * Activate the previous node in the activation order (wraps around). * @param target - the .diagram figure or its selector */ activatePrev: (target: string | HTMLElement): void => { stepActivation(resolve(target), -1); }, /** * What is active: { ref, kind ('node' / 'edge'), element, label, detail } - or null. * @param target - the .diagram figure or its selector * @returns the active node or edge, null when nothing is active */ active(target: string | HTMLElement): DiagramActivation | null { const root = resolve(target); const state = root?._shown ?? root?.store?.value; return state?.name === 'active' ? describe(root, state.config?.ref) : null; }, /** * The activation order - the node references next / previous walk through. * @param target - the .diagram figure or its selector * @returns the nodes' data-node values, in that order */ order: (target: string | HTMLElement): string[] => activationOrder(resolve(target)), /** * A node's or an edge's properties as plain JSON - a node: { id, eyebrow, * name, meta, tone, shape }; an edge: { from, to, label, line, tone, head, * tail, curve }; a matrix row: { row, <column>: text ... }. null when the * reference names nothing. Feed it to a property grid to inspect it. * @param target - the .diagram figure or its selector * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to") * @returns its properties as strings, null when the reference names nothing */ properties: (target: string | HTMLElement, ref: string): Record<string, string> | null => propertiesOf(resolve(target), ref), /** * The property grid sourceConfig for properties(): the options for tone, shape, line, ends, curve; ids and ends read-only. * @param target - the .diagram figure or its selector * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to") * @returns per property: readOnly, or the options to choose from ({} when the reference names nothing) */ propertySchema: (target: string | HTMLElement, ref: string): Record<string, { readOnly?: boolean; options?: string[] }> => propertySchemaOf(resolve(target), ref), /** * Write properties back onto the node / edge / row (only what differs) and redraw - a property grid's change, applied. * @param target - the .diagram figure or its selector * @param ref - a node (its data-node) or an edge (its data-edge, else "from->to") * @param props - the properties to write (properties() names them) * @returns false when the reference names nothing */ setProperties: (target: string | HTMLElement, ref: string, props: Record<string, string>): boolean => setPropertiesOf(resolve(target), ref, props), /** * The step plan: { max, current } - current is max in the complete figure. * @param target - the .diagram figure or its selector * @returns the number of steps and the step shown */ steps(target: string | HTMLElement): { max: number; current: number } { const root = resolve(target); const { max } = stepPlan(root); const state = root.store?.value ?? { name: 'default' }; return { max, current: !stepping(state) ? max : stateStep(state, max) }; },};// -- init ------------------------------------------------------------------------------------/** the per-figure wiring that a rebuild (build()) needs again: spec, controls, observers */function setup(root) { if (root.hasAttribute('data-steps') && !root._controls) root._controls = makeControls(root); if (!root._observer) { root._observer = new ResizeObserver(() => { if (root._queued) return; root._queued = true; queueMicrotask(() => { root._queued = false; for (const canvas of canvasesOf(root)) draw(root, canvas); }); }); root._observer.observe(root); } for (const canvas of dfDollar(root).find('.diagram-canvas').toArray()) root._observer.observe(canvas); if (root.hasAttribute('data-interactive')) makeInteractive(root);}/** * data-interactive: a click (or Enter / Space on a focused node) activates a * node or an edge - its wire or its label; a click on the active one, on the * empty canvas or Escape clears; the arrow keys move the activation along the * activation order. Nodes become focusable buttons. */function makeInteractive(root) { for (const n of activatable(root)) { if (n.getAttribute('tabindex') == null) dfDollar(n).attr('tabindex', '0'); if (!n.getAttribute('role') && !tablePart(n)) dfDollar(n).attr('role', 'button'); if (n.getAttribute(pressedAttr(n)) == null) dfDollar(n).attr(pressedAttr(n), 'false'); } if (root._interactive) return; root._interactive = true; const current = () => { const state = root._shown ?? root.store?.value ?? { name: 'default' }; return state.name === 'active' ? String(state.config?.ref ?? '') : null; }; const toggle = (ref) => (current() === ref ? root.api.setState('default') : root.api.setState('active', { ref })); dfDollar(root).on('click', (e) => { const t = e.target as HTMLElement | null; if (!t?.closest || t.closest('.diagram-controls, .diagram-delta, .diagram-edges, .diagram-caption, .diagram-legend')) return; const wire = t.closest<SVGElement>('[data-edge-ref]'); if (wire) return toggle(wire.dataset.edgeRef); const node = t.closest<HTMLElement>('[data-node]'); if (node && activatable(root).includes(node)) return toggle(node.dataset.node); if (t.closest('.diagram-canvas') && current() != null) root.api.setState('default'); }); dfDollar(root).on('keydown', (e) => { const node = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-node]'); if (!node || !activatable(root).includes(node)) return; const focusActive = () => activatable(root).find((n) => n.dataset.node === current())?.focus(); if (e.key === 'Enter' || e.key === ' ') toggle(node.dataset.node); else if (e.key === 'Escape') root.api.setState('default'); else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { stepActivation(root, 1); focusActive(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { stepActivation(root, -1); focusActive(); } else return; e.preventDefault(); });}// outside controls, declared in markup: <button data-diagram-for="id" data-diagram-action="next">// actions - next / prev / clear (the activation), play / pause / step-next / step-prev / reset (the steps)let controlsBound = false;function bindOutsideControls() { if (controlsBound) return; controlsBound = true; dfDollar(document).on('click', (e) => { const trigger = (e.target as HTMLElement | null)?.closest?.<HTMLElement>('[data-diagram-for][data-diagram-action]'); if (!trigger) return; const root = dfDollar(`#${CSS.escape(trigger.dataset.diagramFor)}`).get(0); if (!root?.api) return; const action = trigger.dataset.diagramAction; if (action === 'next') stepActivation(root, 1); else if (action === 'prev') stepActivation(root, -1); else if (action === 'clear' || action === 'reset') root.api.setState('default'); else if (action === 'activate') root.api.setState('active', { ref: trigger.dataset.diagramRef }); else if (action === 'play') root.api.setState('playing', { step: 1 }); else if (action === 'pause') control(root, 'pause'); else if (action === 'step-next') control(root, 'next'); else if (action === 'step-prev') control(root, 'prev'); });}function init() { bindOutsideControls(); dfDollar('.diagram:not([data-init])').toArray().forEach((root) => { root.dataset.init = ''; // a parametric diagram: <script type="application/json" class="diagram-spec"> const spec = dfDollar(root).children('script.diagram-spec').get(0); if (spec && !dfDollar(root).children('.diagram-canvas').get(0)) { try { const parsed = JSON.parse(spec.textContent); const merged = parsed.before && parsed.after ? diffSpecs(parsed.before, parsed.after) : parsed; if (parsed.before && parsed.after && !root.hasAttribute('data-delta')) root.setAttribute('data-delta', ''); if (merged.type && !root.dataset.type) root.dataset.type = merged.type; specFlags(root, merged); const holder = document.createElement('div'); dfDollar(holder).html(markupOf(merged)); while (holder.firstChild) dfDollar(root).append(holder.firstChild); } catch (error) { console.error('[diagram] invalid spec', error); } } setup(root); // el.store + el.api: `$('#arch').api.setState('paused', { step: 2 })` bindComponent(root, diagramApi, { name: 'default', config: {} }); triggerStateChange(root, { name: 'default', config: {} }); document.fonts?.ready.then(() => root.isConnected && diagramRedraw(root)); if (root.hasAttribute('data-autoplay') && root.hasAttribute('data-steps')) { // data-autoplay="1000": wait that long after it comes into view. On a // presentation slide it replays on every arrival (hidden until then); // anywhere else it plays once. const delay = Math.max(0, Number(root.getAttribute('data-autoplay')) || 0); const onSlide = !!root.closest('[data-slide]'); let seen = false; const io = new IntersectionObserver((entries) => { const shown = entries.some((e) => e.isIntersecting); if (!shown) { if (seen && onSlide) clearTimeout(root._autoplay); return; } if (seen && !onSlide) return; const first = !seen; seen = true; if (!onSlide) io.disconnect(); if (reducedMotion() || (first && root.store?.value.name !== 'default')) return; clearTimeout(root._autoplay); if (delay || !first) root.api.setState('paused', { step: 0 }); root._autoplay = setTimeout(() => root.isConnected && root.api.setState('playing', { step: 1 }), delay); }, { threshold: 0.35 }); io.observe(root); } });}const diagramRedraw = (root) => { for (const canvas of canvasesOf(root)) draw(root, canvas);};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub