spinagram
Write diagrams

Layout engine

Declare content and intent; spinagram fits text, lays out zone headers, places labels, routes edges and fixes what it can.

Hand-placed slides used to need many render → inspect → edit rounds. The engine now owns text fitting, zone headers, label placement and routing, and spinagram check --fix repairs the rest. The full example is examples/layout-engine/git_two_sites.py.

Text fits its box

Long titles, hostnames and CIDR lists fit without hand-tuned widths. Fitting runs in order and stops at the first step that fits:

  1. wrap at spaces;
  2. break long tokens after -, ., /, : or · (no hyphen added; 10.0.8.0/21 never splits);
  3. shrink the font in 1 px steps to 85 % (min_scale);
  4. grow the box (only with fit: grow or shrink-then-grow).
d.box("lb-pool-https-8081", text="8 members · nginx :8081", w=230, h=90)        # default: fit="shrink"
d.box("subnet", text="10.0.0.0/22 · 10.0.8.0/21", fit="shrink-then-grow", min_scale=0.8, break_on="-./")

Set defaults in the theme: defaults: {node: {fit: shrink, min_scale: 0.85, break_on: "-./:·"}}. fit: none keeps the 0.3 behaviour. When every strategy fails, check says which one: overflow-x — … after shrink to 21px.

Characters a font lacks fall back per character to the body font, then to the bundled Noto Sans Symbols 2 and Noto Sans Math (OFL). check lists each one: glyph-missing — '×' (U+00D7) in heading font, fell back to Body.

Boxes, rows and grids

  • Leave out w/h and a box takes the size of its icon, title and body.
  • with d.row(x=, y=, gap=, same_size=True, max_width=): places the boxes created inside, at one size; d.column(...) stacks them. same_icon_size=True gives them one icon size. YAML: rows: / columns:.
  • g = d.grid(cols=[80, 400, 1480], rows=[190, 760], gutter=40) names grid lines; cell=g[1, 0] places a node or zone there (and sizes it to the cell when it has no size). YAML: grid: and cell: [1, 0].
  • d.align_x(a, b), d.align_y(a, b), d.center_on(a, b) align centres after sizing.
  • d["api"] returns the node (or zone) with that id.
  • d.distribute(3, w=300, gap=40, within=(80, 1840)) returns the x of 3 boxes 300 px wide, centred as a block; without w, xs, w = d.distribute(3, gap=40, within=(80, 1840)) fills the span with equal widths.
  • same_text_scale=True on a row or column gives its members one title and one body size (the smallest any member needs), so a long title does not make one card's text smaller than its neighbours'.
  • Diagram(..., origin="safe") (YAML origin: safe) measures hand-placed coordinates from the safe area (the canvas margin), so x=0, y=0 is its top-left corner instead of the canvas corner.
  • d.edge(a, b, straight=True) anchors both ends on the boxes' shared span, so the edge is vertical or horizontal. a.at_left(0.35) returns the anchor a.left@0.35.

Zones

with d.zone("AZ site-a", dash="dotted", stroke="primary", fit="children",
            icon=["selfhosted:vmware", "logos:redhat"], header={"strip": "green"}, meaning="availability zone"):
    d.box("node-a", ...)
  • Header: icons sit 14 px inside the outline at one size (28 px), the label is centred on them, and a list of icons becomes chips. header: {padding, icon_size, strip, upper, variant, inset} sets it per zone, per zone style or in defaults.zone.header. AWS group styles keep the flush corner icon with inset: false.
  • Outline: dash: solid | dashed | dotted | [4, 2], stroke, stroke_width, fill (none too), on the zone or its style. SVG and PowerPoint draw the same pattern.
  • CSS: every zone has an id (z<index> by default) and its style name as class: zone#net, zone.az.
  • Size: fit: children wraps the children (padding on three sides, the header on top), innermost first; equalize: siblings gives sibling zones one height (opt-in: blank space is never the default).
  • zone.content_top is the first free y below the header.
  • Header side: header="right" mirrors the header (icons end at the right outline, the label ends before them); header="none" draws no header, so content starts padding (24 px) below the top. The long form is header: {align: left | right | none, ...}.
  • Bands: band=(y0, y1) (YAML band: [y0, y1]) spans a zone from y0 to y1; without w it takes the safe width (canvas minus both margins). band and fit="children" exclude each other.

Rows inside zones

Inside with d.zone(...), a row or column without x/y is placed in the zone's content area: below the header, padding inside the outline, at most the content width wide.

with d.zone("Observe", band=(560, 790)):
    d.row(["Metrics", "Logs", "Traces"], same_size=True)          # centred in the content area
with d.zone("Clusters", band=(820, 1000)):
    d.row(["eu-1", "eu-2"], cols=4, align="start")                # each card 1/4 of the content width
OptionMeaning
aligncenter (default), start or end in the content area; start also puts the row at the top
colsEach member takes 1/cols of the content width; more members than cols is an error
same_text_scaleMembers share the smallest title and body size the fit needs (default: same_size)

d.row(items) with no w/h returns the nodes and sizes them to their content; with x, y, w and h it is still the fixed row of earlier versions. Outside a zone, x and y are required; align and cols need a zone. In YAML, rows: [{zone: <zone id>, align, cols, same_text_scale, nodes: [...]}].

Zone anchors

z.top, z.bottom, z.left, z.right and z.at_top(0.8) (also at_bottom, at_left, at_right, or z.at("left", 0.3)) return the endpoint zone:<id>.<side>[@frac], so an edge ends on that point of the outline: d.edge(deploy, observe.at_top(0.8)). In YAML, write the string: deploy -> zone:observe.top@0.8.

Snakes

d.snake(steps, cols, y=, h=) lays a long process out in rows of cols cards and chains them. Turns between rows are routed around the cards and the zone headers.

d.snake(["Commit", "Build", "Unit tests", "Image scan", "Stage deploy", "Smoke tests", "Approval", "Production"],
        cols=4, y=170, h=100, zones=["Build", "Release"])
OptionMeaning
stepsTitles, or dicts of node fields ({"title": "Build", "icon": "logos:docker"})
cols, y, hCards per row, top of the first row, card height
x, wLeft edge and width (default: the canvas margin and the safe width)
gap, row_gapSpace between cards (40) and between rows (60)
zonesOne zone per row: a label or a dict of zone fields; the cards sit in its content area
modeboustrophedon (default: odd rows run right to left) or restart (every row left to right)
chainFalse draws no edges between the steps

Other keyword arguments (color, dashed, …) apply to every chain edge. It returns the step nodes. YAML: snakes: [{steps: [...], cols: 4, y: 170, h: 100, zones: [Build, Release]}].

Icons

Icons keep their aspect ratio (fitted by height, up to four heights wide), so wordmarks stay legible. variant="wordmark" picks the theme alias <icon>-wordmark. badges=[...] adds up to three corner badges at 40 % of the icon size; the text column ends before them. A theme declares fallback chains, icons: {fallback: {load-balancer: [logos:f5, network:loadbalancer]}}, used as icon="concept:load-balancer". spinagram icons report specs/ lists every icon with its source (official, theme, fallback:n).

A centred icon (align="center") sits above the text: the text starts icon_text_gap (8 px) below the icon, and the icon shrinks, down to icon_min (24 px), before the text does. Set both in defaults: {node: {icon_text_gap: 8, icon_min: 24}}. align="auto" centres the icon when the box is taller than wide and the text fits under the icon at its normal size, and puts it on the left otherwise.

With icons: {prefer_vendor_for_services: true} in the theme, a box with a generic icon (a theme icon, an icon file or a concept: chain) whose title names a cloud service gets the vendor icon instead (Cloud Run service → gcp:cloud-run). Runs of one to three title words, at least four characters long, are matched against the vendor packs already in the cache (the longest wins); nothing is downloaded.

Edges

  • Labels take the first clear spot among 15 candidates (midpoint, along the edge, then beside it), clear of boxes, zone headers, other labels, arrowheads and the margin. label_at still wins.
  • Zones as targets: d.edge(pool, net_zone) or "pool -> zone:net" ends on the facing side, at the point nearest the source. fan="bus" adds a bar inside the zone. PowerPoint binds the connector to the zone.
  • Routing: route="ortho" (or defaults.edge.route: ortho) routes around boxes and avoids crossings. Orthogonal routes also go around zone headers, except the header of a zone the edge starts or ends on.
  • Elbows: route="elbow" draws two bends with the middle segment at mid: a y when the edge leaves a top or bottom side, an x when it leaves a left or right side. Without mid, the segment is halfway. d.edge(a.bottom, b.top, route="elbow", mid=520); YAML {from: a.bottom, to: b.top, route: elbow, mid: 520}.
  • Trunks: d.bundle([e1, e2], trunk="bottom") or fan_in=True joins edges into one target on a shared trunk with a single arrowhead.

Legend

meaning= on edges and zones, then d.add_legend(items="auto") lists one row per style: a line in its colour and dash, or a mini outline. place=(x, y, w, h) draws the card in a cell, scaled to fit.

check and --fix

spinagram check also reports icon-touches-border, header-overlap, icon-overflows-box, glyph-missing, zone-underused, zone-clips-child, outside-safe-area, low-contrast, edge-crossings, edge-through-box, label-collision, not-straight, icon-size-mismatch, version-mismatch, edge-crosses-zone-header, icon-overlaps-text, text-scale-mismatch, icon-generic-for-service and glyph-mapped. Each issue has a severity (P0 to P2) and, when safe, a fix (move-label, resize, nudge, shrink-font, anchor, reroute):

spinagram check --json specs/             # issues with their fixes
spinagram check --fix specs/runners.yaml  # writes runners.fixed.yaml; Python specs get suggested edits
spinagram render --relayout specs/        # applies the safe fixes in memory
KindSeverityReportsFix
edge-crosses-zone-headerP0crosses the header of zone 'z1'; the header of a zone the edge ends on does not countanchor: the first pair of side fractions (0.5, 0.65, 0.35, 0.8, 0.2, 0.85, 0.15) whose straight edge clears every header and box; else reroute to ortho
icon-overlaps-textP0icon overlaps the first text line by 12pxresize: a taller box, when it stays in the safe area
text-scale-mismatchP2title sizes differ: [19, 24] px; use same_text_scale in a row or column without same_text_scale (more than 10 %)
icon-generic-for-serviceP2'Cloud Run service' matches gcp:cloud-run; icon 'concept:serverless' is generic; without prefer_vendor_for_services
glyph-mappedP2'→' (U+2192) replaced by '->' (theme glyph_map), including fact values

anchor and reroute fixes apply to straight edges between two boxes. zone-underused counts nested zones as content (overlaps once), so a Region filled by its VPC is not reported for holding no boxes of its own.

low-contrast flags near-invisible text and single-colour icons by default; raise it to WCAG AA with defaults: {check: {contrast: {title: 3, body: 4.5, icon: 3}}}.

Outputs

Diagram(..., output="runners-network") (YAML output:) writes runners-network.svg; render --name-from id|file names the others. requires=">=0.4,<0.5" warns when the installed version does not match. Every SVG carries a sha256 content hash in <metadata id="spinagram-hash"> for CI drift checks.

On this page