Skip to content

How to make a diagram

Boxes, groups and labelled arrows. This is the recipe behind examples/diagrams/, which ports four real Mermaid diagrams.

Is svg-plus the right tool for this one?

Only if you know the arrangement. In an architecture diagram the layout usually carries the argument — these three sit in a tier, that one spans the others — and an engine will move them. If the shape comes from data and varies in size, use Graphviz or D2 and embed the SVG.

1. Decide the tiers first

A diagram is a Stack of tiers; a tier is a Row of boxes. Say that first and the rest follows.

from svg_plus import Doc, Row

doc = Doc(900, pad=16).add(
    Row(edge_node, cdn_node, gap=22.0),          # a tier
    Row(gateway_node, workers_node, gap=22.0),   # the next one
)

This is where svg-plus earns its place. Mermaid has nowhere to put the ordering, so people force it with invisible edges — three of your five diagrams do. Row(a, b, c) states it once and nothing competes with it.

2. Build a node helper

Every box in a diagram looks the same, so write it once. What it is in bold, what it is made of underneath:

from svg_plus import Frame, Stack, Text

def node(label, detail="", *, key=None):
    lines = [Text(label, size=11.0, weight=700)]
    lines += [Text(line, size=9.0, fill="muted") for line in detail.split("\n") if line]
    return Frame(Stack(*lines, gap=3.0), pad=9.0, key=key)

Splitting on \n matters: a label is not prose, so a newline in it is meant. A plain Text would fold it into a paragraph.

3. Group with tinted enclosures

A Frame around a Stack is Mermaid's subgraph:

from svg_plus import tint

def group(title, *children, color="#1565c0", key=None):
    return Frame(
        Stack(
            Text(title.upper(), size=8.5, weight=700, fill=color, tracking=0.9, wrap=False),
            *children,
            gap=9.0,
        ),
        fill=tint(color, 0.965),
        stroke=color,
        radius=8.0,
        key=key,
    )

Groups nest, and a group can be connected to like any other block — give it a key.

4. Name the boxes, then join them

Names resolve after the layout has run, so an arrow follows its boxes when they move:

doc.connect("gateway", "db", label="reads", heads="<->")

heads takes ->, <-, <-> or --.

Connect at the right level

If an edge is really about two groups, connect the groups. Joining a box in one to a box in another sends a line back across everything between:

doc.connect("build", "run", label="BuildArtifact consumed")   # group to group

Routing

You get this without asking:

Boxes Route
Line up on an axis A straight arrow down the middle of what they share
Line up on neither Square corners, rounded, turning in the gutter
Nearly touching Clearance is given up so the arrow still spans the gap

For an edge running back against the flow, name the side it should travel:

doc.connect("prometheus", "predictor", label="historical data", via="left")

Without via, a route uses its two endpoints only — so a back-edge across three tiers will cross them. Leave the figure enough pad for the lane to run in.

5. Know where a label fits

A label sits beside its line, never across it. The constraint is the gutter:

  • Vertical runs — labels sit to the side, where there is width to spare. These are your good ones.
  • Horizontal runs between side-by-side boxes — the gutter is usually narrower than the word, and the label will overlap. Either widen the gap, or fold the information into the box.

In a tier diagram, that means: label the tier-to-tier edges, not the within-tier ones.

6. Add a stamp if you need one

An overlay sits on top of the flow, against a block that has already landed:

doc.overlay(badge("rate limited"), near="gateway", at="top-left")

Nine anchor points, from top-left to bottom-right. Put it on a corner no arrow arrives at.

7. Something the blocks cannot express

A decision diamond, a lifeline, a swimlane — reserve the height and draw it:

from svg_plus import Draw, Rect

def diamond(label, *, key=None):
    def paint(canvas, box):
        points = [(box.cx, box.y), (box.right, box.cy), (box.cx, box.bottom), (box.x, box.cy)]
        moves = " ".join(f"{'M' if i == 0 else 'L'} {x:.1f} {y:.1f}" for i, (x, y) in enumerate(points))
        canvas.path(f"{moves} Z", fill="#fff8e1", stroke="#f9a825")
        canvas.text(box.cx, box.cy + 3.5, label, canvas.theme.style(10.0, weight=700), "middle")
    return Draw(60.0, paint, key=key)

examples/diagrams/smo_request.py takes this further: a whole sequence diagram — participants across the top, lifelines, nested activation bars — is one Block in about seventy lines, because that layout is computable. svg-plus has no sequence diagram and did not need one.

Checklist

  • Tiers are rows; the flow is the stack. No invisible edges.
  • One node() helper, one group() helper, used everywhere.
  • Edges join things at the same level of abstraction.
  • Labels are on the tier-to-tier edges, where there is room.
  • Back-edges name a via side.
  • doc.describe(...) gives the figure an accessible name.