19 KiB
Auto-layout (Graphviz)
Read this when a diagram is large or layout-heavy — dependency/call graphs, code/module structure, or roughly more than ~15 nodes — where hand-placing x/y coordinates is slow, error-prone, and overlap-prone.
Instead of computing coordinates by hand in the Generate step, describe the graph as JSON and let scripts/autolayout.py place the nodes and route the edges with Graphviz, then continue the normal workflow (Export draft → Self-check → …) on the produced .drawio.
For small or carefully-styled diagrams, keep hand-placing — auto-layout trades fine control for scale.
Dependency
Requires Graphviz dot on PATH:
# macOS
brew install graphviz
# Debian/Ubuntu
sudo apt install graphviz
The script exits with a clear message if dot is missing — fall back to hand-placed coordinates in that case.
Usage
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o diagram.drawio
It prints wrote diagram.drawio (N nodes, M edges) to stderr and writes a normal .drawio file. From there, continue at the Export draft step of the main workflow (preview PNG with --width 2000, self-check, review loop, final export with -e + repair_png.py).
Input format
{
"direction": "TB",
"nodes": [
{"id": "client", "label": "Web Client", "style": "rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;"},
{"id": "gw", "label": "API Gateway", "group": "edge", "groupLabel": "Edge tier"},
{"id": "db", "label": "User DB", "style": "shape=cylinder3;whiteSpace=wrap;html=1;", "width": 120, "height": 80, "group": "data"}
],
"edges": [
{"source": "client", "target": "gw", "label": "HTTPS"},
{"source": "gw", "target": "db"}
]
}
Fields
| Field | Required | Default | Notes |
|---|---|---|---|
direction |
no | TB |
TB (top→bottom) or LR (left→right) — the layout rank direction |
nodes[].id |
yes | — | Unique; must not be 0 or 1 (reserved for draw.io root cells) |
nodes[].label |
no | the id |
Display text; auto XML-escaped |
nodes[].style |
no | group colour, else blue | Any draw.io style string — reuse the role/shape styles from diagram-types.md and the active preset. A styleless node is tinted by its group (see Containers / grouping); an explicit style always wins |
nodes[].width / height |
no | 120 / 60 |
Pixels; dot lays out at this real size |
nodes[].group |
no | none | Group key, or a /-delimited path ("core/db") for nested containers — nodes sharing a path are boxed together (see Containers / grouping) |
nodes[].groupLabel |
no | last path segment | Title shown on the node's deepest container (first node with the path wins) |
edges[].source / target |
yes | — | Must match node ids |
edges[].label |
no | empty | Edge text |
How it places things
- Node positions come from
dot(hierarchical layered layout), converted to draw.io pixels and snapped to the grid (multiples of 10). - Edges use
splines=ortho: dot's orthogonal route is replayed as draw.io waypoints, so edges go around nodes instead of through them. - Apply the active style preset by setting each node's
styleto the preset's role/shape values before calling the script — the script does not know about presets.
Containers / grouping
Give nodes a group key and the script wraps each group in a labeled container (a dashed box with the group title at top) and tells dot to keep that group's nodes together via a Graphviz cluster. Grouped nodes become children of their container (parent="<container>", relative coordinates); ungrouped nodes stay at the top level. This turns a flat hairball into a "boxes of related modules" architecture view.
Nesting. A group value with / separators builds nested containers: "core/db" puts the node inside a db box that itself sits inside a core box. Every path prefix becomes a container, so an arbitrarily deep package tree maps to nested boxes. A node can also sit directly in a parent box (group: "core") alongside a sibling sub-box (group: "core/db").
- Colour by group. Each top-level group is assigned a colour from the skill's own palette (
styles/built-in/default.json, cycled in role order: blue → green → orange → purple → yellow → red → grey). A node with nostyleof its own is tinted with its group's colour, and the container's border + title match — so related modules read as a coloured cluster instead of monochrome boxes. A node that carries its ownstyle(e.g. from an applied preset) is left untouched. Pass--monoto turn colouring off (dashed grey boxes, default-blue nodes — the previous look). Ungrouped graphs are unaffected. - Each container box is the bounding box of its members and child boxes plus a uniform padding. The dot cluster margin is set to that same padding, so each box equals dot's cluster box — which dot keeps non-overlapping at any nesting depth.
- The title sits in the top padding (
verticalAlign=top); the box title is the path's last segment, or a member'sgroupLabel. - Containers are visual only (no edges of their own). Edges still connect node→node and route across containers normally.
- If a container's top padding would cross the page origin, the whole diagram is shifted so nothing lands at a negative coordinate.
Validate before previewing
scripts/validate.py is a deterministic structural linter — run it on the produced .drawio before the (slower, vision-based) self-check:
python3 <this-skill-dir>/scripts/validate.py diagram.drawio
It catches dangling edge endpoints, duplicate/reserved ids, broken parent references (errors), plus off-grid/negative geometry and overlapping sibling nodes (warnings) — without launching draw.io. Exit status is non-zero on any error (or any warning with --strict), so it can gate the workflow. Auto-layout output should always pass clean; a failure means a malformed input graph (e.g. an edge referencing a missing node id).
Importers — visualize code & infrastructure
Bundled importers turn a codebase or an IaC configuration into a graph JSON ready for autolayout, so "visualize this project" is a two-step pipeline:
| Source | Script | Node = | Edge = |
|---|---|---|---|
| Python | scripts/pyimports.py <dir> |
module / package (ast) |
intra-project import / from |
| JS / TS | scripts/jsimports.py <dir> |
source file (.ts/.tsx/.js/.jsx/.mjs/.cjs) |
resolved relative import/export from/require()/import() |
| Go | scripts/goimports.py <dir> |
package (directory, via go.mod) |
intra-module package import |
| Rust | scripts/rustimports.py <dir> |
module (.rs file / mod) |
intra-crate use crate:: / super:: / self:: |
| Python (classes) | scripts/pyclasses.py <dir> |
class (ast) |
subclass → base (inheritance) |
| Terraform | scripts/tfimports.py <dir> |
resource / module block, rendered as its official AWS/Azure/GCP icon |
cross-resource reference (aws_iam_role.x.arn, ${...}, depends_on) |
| Kubernetes | scripts/k8simports.py <dir> |
manifest object (kind/name), rendered as its official K8s kind icon | Ingress→Service, Service→workload (selector), workload→ConfigMap/Secret/PVC, HPA→target |
| docker-compose | scripts/composeimports.py <file-or-dir> |
service (name + image box) / named volume (cylinder) | depends_on / links / volumes_from / named-volume mounts |
| Terraform state (live) | terraform show -json | scripts/tfstate.py - |
deployed resource instance, rendered as its official cloud icon | recorded dependency (depends_on in state) |
| Docker (live) | docker inspect $(docker ps -q) | scripts/dockerimports.py - |
running container (name + image) / user network (ellipse) / named volume (cylinder) | container→network, container→volume, links / compose depends_on |
| SQL DDL | scripts/sqlerd.py <file-or-dir> |
table (column list with PK/FK markers) | foreign key (crow's-foot, labeled with the FK column) |
python3 <this-skill-dir>/scripts/pyimports.py myproject -o graph.json
python3 <this-skill-dir>/scripts/autolayout.py graph.json -o diagram.drawio
Each code importer keeps only intra-project edges (third-party/stdlib imports are ignored), shortens node labels (drops the shared package/module/directory prefix; ids stay fully qualified), and shares the same flags: --direction TB|LR (default TB), --group, --no-reduce. The IaC importers share --direction and --group and add --no-icons.
- Python (
pyimports.py): if the directory is itself a package (__init__.pypresent), module names are package-qualified so the project's own absolute imports resolve; nested subpackages (pkg.sub.mod) are handled. - JS/TS (
jsimports.py): resolution is path-based (tries the source extensions and directoryindexfiles);node_modulesand bare specifiers are skipped. Scanning is regex-based, not a full parser. - Go (
goimports.py): reads themodulepath fromgo.mod; each directory of.gofiles is one package;*_test.goandvendor/are skipped. - Rust (
rustimports.py): each.rsfile is a module (mod.rs/main.rs/lib.rsname the enclosing module); edges come fromusepaths rooted atcrate::/super::/self::(brace groups expanded).std/external crates andtarget/are skipped. Regex-based — inlinemod { … }blocks aren't split out, and 2015-edition bare intra-crate paths aren't resolved. - Python classes (
pyclasses.py): a finer granularity — one node per class, edges from each subclass to the project base classes it extends, so the result is an auto-generated class hierarchy. Bases are matched by name (preferring the same module); external bases (object, third-party) are ignored. With--group, classes are boxed by their module, so a deep package tree nests naturally. Inheritance only — function-level call graphs are out of scope (static call resolution in Python is unreliable). - Terraform (
tfimports.py): parses.tffiles directly (regex + brace matching, no HCL library). Each resource type is resolved to its official icon through the bundled shape index — AWSaws4set, Azureazure2set, GCP icon set — with a curated query table for the ~45 most common types and strict tag-AND matching so a partial match never lands on the wrong vendor's icon; unresolvable types fall back to a plain box labeledname+ type (--no-iconsforces boxes for all).--groupboxes resources by service (aws_s3_* → s3). Data sources, variables, locals and providers are ignored; heredocs with unbalanced braces are the known parse limit. - Kubernetes (
k8simports.py): accepts one or more manifest files or a directory. JSON (includingkind: List, i.e.kubectl get ... -o jsonoutput) parses with the stdlib alone;.yaml/.ymlneeds PyYAML. Kind icons come from the officialmxgraph.kubernetesset (25 kinds mapped). Edges land only on objects present in the manifest set, matched within the same namespace.--groupboxes objects by namespace. No--no-reduceflag — reference edges are sparse and never reduced. - docker-compose (
composeimports.py): needs PyYAML. Services become rounded boxes labeledname+ image (orbuild:context); named volumes declared in the top-levelvolumes:section become cylinders.--groupboxes services by their first network. - Terraform state — live (
tfstate.py): reads the JSON thatterraform show -jsonprints (live state, or a saved plan) from a file or-(stdin). Provider-agnostic;count/for_eachinstances are expanded (labeledname[0],name[1], …) and module nesting is preserved. Reuses tfimports' icon resolver, so the same official AWS/Azure/GCP icons and--no-iconsfallback apply. Edges come from the dependencies Terraform recorded in state (depends_on); data sources are skipped.--groupboxes resources by module; shares--direction/--no-reduce. This is the actually-deployed counterpart to tfimports' declared-config view. - Docker — live (
dockerimports.py): reads the JSON arraydocker inspectprints (file or-). Containers become rounded boxes (name + image) matching the compose look; the user networks they attach to become green ellipses and the named volumes they mount become cylinders (Docker's built-inbridge/host/none/ingressnetworks and bind mounts are ignored as noise). Edges: container→network, container→volume, plus container→container fromlinksand the composedepends_onlabel.--groupboxes containers by compose project (falling back to first network). The actually-running counterpart to composeimports' declared view. - SQL DDL (
sqlerd.py): regex + paren matching, no SQL library. Handles inline and table-levelPRIMARY KEY/FOREIGN KEY ... REFERENCES, quoted identifiers,schema.tableprefixes (--groupboxes by schema). Column lines carryPK/FKmarkers and types (--no-typesto hide). Unknown dialect clauses are skipped, never mis-parsed into edges.
Diffing two diagrams (drawiodiff.py)
drawiodiff.py old.drawio new.drawio -o diff.json compares two .drawio files and emits a colour-coded graph JSON for autolayout — one diagram showing what changed: nodes/edges added (green), removed (red, dashed), changed (orange, a matched node whose label moved) or unchanged (grey).
python3 <this-skill-dir>/scripts/drawiodiff.py old.drawio new.drawio -o diff.json
python3 <this-skill-dir>/scripts/autolayout.py diff.json -o diff.drawio
Nodes match by cell id by default — ideal for anything the importers or live-infra snapshots produce (their ids are stable semantic keys), so snapshot → change → snapshot → diff shows drift directly (e.g. two tfstate.py or k8simports.py snapshots). Pass --by-label to match on the visible label instead, for hand-drawn diagrams whose ids are random. Only leaf vertices and their edges are compared (containers/group cells and edge labels are skipped); the diff is a flat colour-coded view, so original icons are replaced by status colours (labels are kept). Multi-page files are flattened; compressed pages are skipped with a warning (this skill always writes uncompressed XML).
Architecture time-lapse over git history (timelapse.py)
timelapse.py <dir> --importer pyimports shows how a codebase's structure grew: it walks the git history of <dir>, re-runs the importer at each sampled commit (pulling the tree with git archive — the working copy is never touched), lays each out and exports a PNG frame, then assembles one self-contained HTML player (frames embedded as base64, play / step / scrub controls, no external files or CDNs).
python3 <this-skill-dir>/scripts/timelapse.py src --importer pyimports --max-frames 12
# -> architecture-evolution.html (open in any browser)
--importer is any bundled graph extractor (pyimports/jsimports/goimports/rustimports/pyclasses/tfimports/k8simports/composeimports/sqlerd), run with the same positional <dir> it expects, so point <dir> at the module / project / infra root — extra flags pass through via --importer-args "--group". Commits touching the dir are sampled evenly down to --max-frames (always keeping the first and last); a commit where the importer finds nothing (the path did not exist yet) is skipped. It renders one draw.io frame per commit, so it needs git + Graphviz + the draw.io CLI and takes a few seconds per frame. The story is strongest on a package with real import edges (they accumulate over time); a flat directory still shows the node count grow.
The tf/k8s importers emit ranksep/nodesep in the graph JSON automatically (icon labels render below the shape, so rows need extra separation).
--tune (autolayout flag): lays the graph out in both directions (TB and LR), scores each (through-vertex routes ×20 + edge crossings ×10 + total edge length as tiebreak), and keeps the better one — report on stderr. validate.py --score prints the same style of readability score for a finished .drawio, for comparing variants.
Density reduction is on by default — this is the key to a readable result. Real import graphs are dense (asyncio: 33 modules / ~149 edges); without reduction they render as a hairball. Every importer applies transitive reduction (Graphviz tred — drops edges already implied by a longer path), which on asyncio cuts ~149 edges to ~46 and turns the hairball into a clean, traceable diagram. Pass --no-reduce to keep every edge.
--group assigns each node a container by its sub-package / directory path, so autolayout boxes related modules together — nested when the path has depth (see Containers / grouping). The fastest way to turn a large code graph into a tiered architecture view.
For any other language, produce the same graph JSON from any analyzer (e.g. dependency-cruiser for richer JS/TS resolution, go-callvis for Go call graphs) and feed it to autolayout the same way.
Edge routing after auto-layout
Dot already routes edges orthogonally as part of its layout pass (splines=ortho), so the result usually needs no further routing.
There is no CLI flag that reroutes edges without moving nodes. --layout only accepts ELK node layout presets (verticalFlow, horizontalFlow, verticalTree, horizontalTree, radialTree, organic) or a JSON layout array — every one of them re-places vertices. Passing an unrecognised value (e.g. libavoid) makes draw.io open a modal Unknown layout: error dialog, which hangs a headless run until it is killed.
When the output still has edges cutting across shapes, fix it at authoring time instead:
python3 edgeports.py diagram.drawio # spread stacked edges over each perimeter
edgeports.py handles the common case — several edges leaving the same side of a node all landing on the same point. It is a port assigner, not a router: for an edge crossing an unrelated shape mid-run, add <Array as="points"> waypoints or increase node spacing (see xml-authoring.md "Edge style rules"). draw.io's obstacle-avoiding connector router is editor-side only: open the .drawio in draw.io desktop and re-route there.
Limitations
- Placement is topological, not semantic — dot minimises edge crossings, which may put a node in a different column than you'd choose by hand. Re-export with the other
direction, or hand-tune the produced XML afterwards (it's a normal.drawio). - Import edges are static —
pyimports/jsimports/goimportsread static import statements (not dynamicimportlib, runtimerequire, or reflection);pyclassesresolves inheritance only, not method-level calls. - Parallel edges between the same
(source, target)pair share one route. - Containers don't add edges —
group/nesting only boxes nodes for layout; edges remain node→node. For hand-built swimlane/architecture containers with their own connections, seereferences/xml-authoring.md"Containers and groups".