# System Diagrams — graph as data, layout computed, SVG emitted

For any diagram above ~5 boxes: architecture, request paths, dependencies, state machines, deployment topology, data flow. Below 5 boxes, hand-place and move on.

**The rule this file exists to enforce: never type `x` and `y` by hand for a real system.** Declare the graph, run the layout pass while authoring, paste the resulting coordinates into static SVG. The shipped page contains no graph library, calls no CDN, and lays out identically on every open.

---

## 1. The authoring shape

```js
const G = {
  nodes: [
    { id: 'gateway', label: 'API gateway', sub: 'envoy',  team: 'platform' },
    { id: 'orders',  label: 'orders-api',  sub: 'go',     team: 'checkout' },
    { id: 'cdn',     label: 'CDN',         sub: 'cached', team: 'platform', resilient: true },
    { id: 'user',    label: 'customer',    sub: 'web',    team: 'none',     actor: true }
  ],
  edges: [
    { from: 'gateway', to: 'orders', kind: 'sync',  proto: 'http',   rps: 1180, p99: '42ms' },
    { from: 'orders',  to: 'kafka',  kind: 'async', proto: 'events', rps: 1180, p99: '-'   }
  ]
};
```

Required per node: `id`, `label`, `sub` (its one-line job). Optional and load-bearing: `team` (ownership lens), `actor` (a person or external client, never marked as failing), `resilient` (can answer without its dependency, so failure propagation stops here).

Required per edge: `from`, `to`, `kind` (`sync` | `async`), `proto`. Add `rps`, `p99`, `bytes` when a lens needs them.

---

## 2. Layered layout (the default)

Sugiyama, the algorithm behind `dagre` and ELK's layered mode, reduced to what an explainer needs. Rank by longest path, order within rank, assign coordinates, route edges as cubic curves.

```js
function layered(g, W, H, { padX = 78, padY = 46 } = {}) {
  const rank = {};
  g.nodes.forEach((n) => { rank[n.id] = 0; });
  // longest-path ranking: relax every edge |nodes| times. Deterministic, adequate to ~40 nodes.
  for (let pass = 0; pass < g.nodes.length; pass++) {
    g.edges.forEach((e) => {
      if (rank[e.to] < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1;
    });
  }
  const cols = {};
  g.nodes.forEach((n) => { (cols[rank[n.id]] = cols[rank[n.id]] || []).push(n.id); });
  const maxRank = Math.max(...Object.keys(cols).map(Number));
  const colW = (W - padX * 2) / Math.max(1, maxRank);
  const usable = H - padY * 2;
  const pos = {};
  Object.entries(cols).forEach(([r, ids]) => {
    ids.forEach((id, i) => {
      pos[id] = {
        x: padX + Number(r) * colW,
        y: padY + (ids.length === 1 ? usable / 2 : (usable / (ids.length - 1)) * i)
      };
    });
  });
  return { pos, rank, cols, maxRank };
}

// edge between two node centres, entering/leaving the box edge horizontally
const edgePath = (a, b, NW) => {
  const x1 = a.x + NW / 2, x2 = b.x - NW / 2, mid = (x1 + x2) / 2;
  return `M${x1} ${a.y} C${mid} ${a.y} ${mid} ${b.y} ${x2} ${b.y}`;
};
```

Cycles: the relaxation above terminates but ranks a cycle arbitrarily. Break cycles first by dropping the back-edge from the ranking pass and drawing it afterwards with a distinct curve, labelled as the feedback path. That label is usually the most interesting thing in the diagram.

Crossing reduction: order within a rank by the mean rank-position of each node's predecessors (one pass of the barycentre heuristic). Skip it under ~10 nodes; declaration order is usually already sensible.

| Layout | Use for | Cost |
|---|---|---|
| Layered, left to right | request paths, pipelines, dependency order, C4 container views | the function above |
| Radial (rank as concentric ring) | "what surrounds this one thing", blast radius | ~25 lines |
| Grid | nothing. Positions that carry no meaning make the reader work for free | never |
| Force-directed (`d3-force`) | nothing here. Non-deterministic, and reads as a hairball | never |
| Orthogonal routing | dense infra diagrams where crossings must stay legible | real work; vendor ELK at authoring time if the topic demands it |

---

## 3. Archetypes

| Archetype | Concept shape | Layout | Interaction | Required labels |
|---|---|---|---|---|
| **Altitude ladder** (C4: context → container → component) | HIERARCHY | layered per level | level buttons | actor names, system boundary, container tech |
| **Request path** | FLOW | layered | travelling marker + hop stepper | protocol per hop, latency or deadline per hop |
| **Blast radius** | SYSTEM | radial from the failing node | pick the failing node | hard-fail vs degraded, and why propagation stops |
| **State machine** | MECHANISM | layered on transitions | fire a transition | event on every edge, terminal states marked |
| **Data flow with volume** | FLOW | layered | volume lens | units on every edge, totals that add up |
| **Deployment topology** | HIERARCHY | nested boxes, not a graph | region/zone toggle | zone, replica count, what is shared |

**Altitude rule:** the box you descend into keeps its screen position, and its siblings stay visible at reduced contrast. A descent that clears the canvas costs the reader the orientation they just built.

---

## 4. Lenses — one graph, several questions

A lens is a styling function over the same positioned graph. 3 to 5 per diagram, maximum.

| Lens | Question | Encoding |
|---|---|---|
| `structure` | what talks to what, over what | edge label = protocol, dashed = async |
| `traffic` | where is the volume | edge width and label = rps |
| `ownership` | who gets paged | node fill by team, legend mandatory |
| `failure` | what breaks if X breaks | hard-fail vs degraded vs untouched, 3 fills |
| `latency` | where does the budget go | edge label = p99, width by share of budget |
| `cost` | what is expensive | node area by monthly spend |

**Two hard rules.** A lens switch never moves a node: spatial memory is the entire asset a good layout buys. And every non-obvious encoding gets a legend in the same figure, not in prose below it.

Failure propagation, when you offer that lens:

```js
const HARD = new Set([failingId]);
let changed = true;
while (changed) {
  changed = false;
  G.edges.forEach((e) => {
    const n = byId[e.from];
    if (e.kind !== 'sync' || !HARD.has(e.to) || HARD.has(e.from)) return;
    if (n.actor || n.resilient) return;      // stops at anyone who can answer without it
    HARD.add(e.from); changed = true;
  });
}
```

Async callers degrade rather than fail: they keep accepting work that will be processed late. Say which is which in the caption, and make the two states visually distinct rather than two shades of the same colour.

---

## 5. Motion grammar (node to node)

A moving mark is a claim. These are the only permitted claims:

| Visual | Must mean | Must never mean |
|---|---|---|
| travelling marker | one unit of work: a request, a message, a packet | "data flows here", vaguely, forever |
| marker colour | state of that unit: in flight, waiting, retried, failed | decoration, or ownership |
| marker count | concurrency, at honest ratios | "lots of traffic" as a vibe |
| speed | latency, on a stated scale | whatever looked good |
| dashed edge | async, nobody is waiting | a stylistic alternative to solid |
| edge width | volume or share, with a legend | emphasis |
| pulse, glow, halo | nothing. Not in this skill | attention, urgency, "alive" |

Position the marker with `getPointAtLength` on the same `<path>` that draws the edge, so motion cannot drift from the picture:

```js
const paths = HOPS.map((h) => addPath(edgePath(pos[h.from], pos[h.to], NW)));  // stroke: none
const lens  = paths.map((p) => p.getTotalLength());
const total = lens.reduce((a, b) => a + b, 0);
function place(t) {                       // t in 0..1 across the whole route
  let d = t * total, i = 0;
  while (i < lens.length - 1 && d > lens[i]) { d -= lens[i]; i++; }
  const pt = paths[i].getPointAtLength(Math.min(d, lens[i]));
  marker.setAttribute('cx', pt.x); marker.setAttribute('cy', pt.y);
  marker.setAttribute('fill', STATE_FILL[HOPS[i].state]);
  return i;                               // caption the hop, not the pixel
}
```

Under `prefers-reduced-motion` the animation collapses to hop-by-hop stepping. Ship the prev/next hop buttons regardless: they are the scrub control and the reduced-motion path at once.

---

## 6. Budgets and labels

- **9 nodes per view, 12 hard maximum.** Past that, split by altitude. Never by shrinking the type.
- **Every node:** label plus one-line role. A box called "service" teaches nothing.
- **Every edge:** protocol or payload, and sync versus async visible without reading a legend.
- **Labels inside boxes.** Edge labels offset from the midpoint. Check for collisions at 380px, not just at desktop.
- **Numbers in a diagram are claims.** Verify every asserted figure out of band before shipping; arithmetic errors inside a figure are the defect class no visual rule catches.

## 7. Accessibility

- `<title>` and `<desc>` on every SVG, referenced by `aria-labelledby`. The `desc` states what the diagram shows, not that it is a diagram.
- Above 6 nodes, add a text alternative: a node and edge table in a `<details>` drawer. This is also the fastest way for a sighted reader to check a number.
- Interactive nodes are focusable (`tabindex="0"`), operable by Enter and Space, in reading order, with a visible focus ring.
- Never encode meaning by colour alone: pair fill with a shape, a border weight, or a label.

## 8. Determinism and self-containment

- No `Math.random` in layout, ever. Same input, same coordinates.
- Layout runs while authoring. Paste the numbers. The page ships plain SVG.
- Mermaid is a legitimate fast rung for a throwaway structural sketch, and it is fine to reach for when the diagram is not the lesson. It cannot be the default here: it fixes styling to its own theme, cannot carry lenses, and cannot animate node to node.
