part of BrainFables
When an SVG flowchart silently eats its own edge labels
A hand-rolled :::diagram renderer clipped long edge labels because its viewBox was sized from nodes only — the fix is a viewBox that bounds the labels too, plus wrapping.
A user pointed at a rendered flowchart and said: the Mermaid-style diagrams look like they're cutting off some of the content. The screenshot showed it plainly — one edge label read @supports not (scrollbar-color: a, sheared off mid-word at the right edge of the picture. The arrows were fine. The boxes were fine. Only the little text labels riding on the connectors were being guillotined.
This fable is about why that happens, and why the fix is not "make the SVG wider."
The shape of the renderer
BrainFables renders :::diagram blocks with a hand-rolled SVG layout engine — no Mermaid at runtime. It ranks nodes by longest path, stacks them, routes cubic-bezier edges between them, and drops each edge's label at the curve's midpoint.
That last dashed edge is the whole bug. The layout function measured every node, summed their widths and heights into a total, and handed those back as the canvas size. Edge labels were positioned after layout, at render time, and never fed back into the size calculation.
Why "off the canvas" means "gone"
Here is the trap that makes this a clipping bug and not just an overflow bug. An SVG's outer <svg> element clips its content to the viewBox by default — anything drawn outside those bounds is not shown, it is cut. So a label whose text extended past the node-derived width didn't spill into the margin; it hit the edge of the coordinate box and vanished mid-character.
<svg viewBox={`0 0 ${laid.width} ${laid.height}`} style={{ maxWidth: laid.width * 1.15, minWidth: Math.min(laid.width, 480) }}>laid.width and laid.height came purely from the node-packing math. A label like @supports not (scrollbar-color: auto) is wider than the node it hangs off of, so its right end sat at an x-coordinate past laid.width — outside the box, clipped.
The fix: let the labels vote on the bounds
Two moves. First, wrap long edge labels so a 36-character @supports clause becomes two or three stacked lines instead of one very wide one — the same word-wrap the node labels already used, just parameterized with a tighter width.
function wrapLabel(label: string): Array<string> {function wrapLabel(label: string, wrapAt: number = WRAP_AT): Array<string> {Second — and this is the real fix — stop deriving the viewBox from nodes alone. Precompute every edge's laid-out geometry (anchor points, bezier control points, midpoint, wrapped label lines), then sweep a bounding box over both the node rectangles and the label boxes. A viewBox origin doesn't have to be 0 0, so there's no coordinate shifting — you just report the true min/max the drawing actually occupies.
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinityconst grow = (x, y) => { /* expand min/max */ }for (const n of laid.nodes.values()) { grow(n.cx - n.w / 2, n.cy - n.h / 2); grow(n.cx + n.w / 2, n.cy + n.h / 2)}for (const e of laidEdges) { if (e.labelLines.length === 0) continue const lw = Math.max(...e.labelLines.map((l) => l.length)) * EDGE_CHAR_W const lh = e.labelLines.length * EDGE_LINE_H grow(e.mx - lw / 2, e.my - lh / 2); grow(e.mx + lw / 2, e.my + lh / 2)}viewBox={`0 0 ${laid.width} ${laid.height}`}viewBox={`${vbX} ${vbY} ${vbW} ${vbH}`}The arrow paths didn't change a single coordinate — only the window we view them through grew to admit the text that was always being drawn, just off-screen.
Why precompute the edges instead of measuring labels in layout()?◆◆◆◆◆go deeper +
Label geometry depends on the bezier midpoint, which depends on the node anchor points and the curve's control reach — all of which layout() produces. Rather than thread label measurement back into the packing pass (which would couple text metrics to the ranking algorithm), the renderer computes a laidEdges array once, derives the bounding box from it plus the nodes, and reuses the same array to draw the paths. One source of truth for edge geometry, consumed twice: by the bounds sweep and by the JSX.
Check yourself
Why did widening the SVG's CSS maxWidth not stop the labels from being cut off?
The companion request in the same session — a "copy markdown" button on every fable — was the easy half: the raw source was already loaded server-side, just not returned on the page's data object, so surfacing one field and adding a clipboard button finished it. The diagram was the one with a lesson in it: when a vector drawing loses content at its edges, suspect the coordinate window before you suspect the zoom.