164 lines
6.5 KiB
Python
164 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""C4 model diagrams: levels JSON -> one multi-page .drawio with drill-down.
|
|
|
|
Generates a C4 architecture diagram set (System Context -> Containers ->
|
|
Components, as many levels as you define) in a single `.drawio` file: one
|
|
page per level, official draw.io C4 shapes and colors, Graphviz placement
|
|
per page (via autolayout), and **drill-down links** — an element with a
|
|
`"children"` key becomes clickable and jumps to that level's page in
|
|
draw.io / the diagrams.net viewer.
|
|
|
|
python3 c4.py c4.json -o architecture.drawio
|
|
|
|
Input JSON:
|
|
{
|
|
"title": "Internet Banking",
|
|
"levels": [
|
|
{
|
|
"name": "System Context",
|
|
"elements": [
|
|
{"id": "customer", "type": "person", "label": "Personal Customer",
|
|
"desc": "A customer of the bank"},
|
|
{"id": "ibs", "type": "system", "label": "Internet Banking System",
|
|
"desc": "Lets customers manage accounts", "children": "Containers"},
|
|
{"id": "email", "type": "external", "label": "E-mail System",
|
|
"desc": "Microsoft Exchange"}
|
|
],
|
|
"relations": [
|
|
{"from": "customer", "to": "ibs", "label": "Uses"},
|
|
{"from": "ibs", "to": "email", "label": "Sends e-mail via"}
|
|
]
|
|
},
|
|
{
|
|
"name": "Containers",
|
|
"elements": [
|
|
{"id": "spa", "type": "container", "label": "Single-Page App",
|
|
"tech": "React", "desc": "Banking UI in the browser"},
|
|
{"id": "api", "type": "container", "label": "API Application",
|
|
"tech": "Java/Spring", "children": "Components"},
|
|
{"id": "db", "type": "database", "label": "Database",
|
|
"tech": "PostgreSQL"}
|
|
],
|
|
"relations": [
|
|
{"from": "spa", "to": "api", "label": "JSON/HTTPS"},
|
|
{"from": "api", "to": "db", "label": "JDBC"}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Element types: person, system, external (greyed external system), container,
|
|
component, database. `tech` renders as the [Type: Tech] line, `desc` as the
|
|
description line — the standard C4 label. Element ids must be unique across
|
|
ALL levels (pages share one link namespace). Requires Graphviz `dot`.
|
|
|
|
Usage: python3 c4.py <c4.json> [-o out.drawio] [--direction TB|LR]
|
|
"""
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# Official draw.io C4 template styles (colors from c4model.com).
|
|
_BASE = "html=1;whiteSpace=wrap;fontSize=12;fontColor=#ffffff;align=center;"
|
|
STYLES = {
|
|
"person": ("shape=mxgraph.c4.person2;" + _BASE +
|
|
"fillColor=#083F75;strokeColor=#06315C;", 200, 180),
|
|
"system": ("rounded=1;arcSize=10;" + _BASE +
|
|
"fillColor=#1061B0;strokeColor=#0D5091;", 240, 120),
|
|
"external": ("rounded=1;arcSize=10;" + _BASE +
|
|
"fillColor=#8C8496;strokeColor=#736782;", 240, 120),
|
|
"container": ("rounded=1;arcSize=10;" + _BASE +
|
|
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
|
|
"component": ("rounded=1;arcSize=10;" + _BASE +
|
|
"fillColor=#63BEF2;strokeColor=#2086C9;", 240, 120),
|
|
"database": ("shape=cylinder3;size=15;boundedLbl=1;" + _BASE +
|
|
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
|
|
}
|
|
TYPE_WORD = {"person": "Person", "system": "Software System",
|
|
"external": "Software System", "container": "Container",
|
|
"component": "Component", "database": "Container"}
|
|
EDGE = ("endArrow=blockThin;endFill=1;endSize=10;html=1;fontSize=11;"
|
|
"fontColor=#404040;strokeColor=#828282;labelBackgroundColor=#ffffff;"
|
|
"rounded=0;")
|
|
|
|
|
|
def load_autolayout():
|
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autolayout.py")
|
|
spec = importlib.util.spec_from_file_location("autolayout", path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def slug(name):
|
|
return re.sub(r"[^a-z0-9]+", "-", str(name).lower()).strip("-") or "page"
|
|
|
|
|
|
def c4_label(el):
|
|
"""Standard C4 element label: Name / [Type: Tech] / description."""
|
|
kind = TYPE_WORD.get(el.get("type", "system"), "Software System")
|
|
bracket = f"[{kind}: {el['tech']}]" if el.get("tech") else f"[{kind}]"
|
|
lines = [el.get("label", el["id"]), bracket]
|
|
if el.get("desc"):
|
|
lines.append(el["desc"])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="C4 levels JSON -> multi-page draw.io.")
|
|
ap.add_argument("input", help="C4 JSON file")
|
|
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
|
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
|
args = ap.parse_args()
|
|
with open(args.input, encoding="utf-8") as f:
|
|
spec = json.load(f)
|
|
levels = spec.get("levels") or []
|
|
if not levels:
|
|
sys.exit("error: no levels in input")
|
|
al = load_autolayout()
|
|
|
|
page_ids = {lv["name"]: slug(lv["name"]) for lv in levels}
|
|
seen = set()
|
|
pages = []
|
|
for lv in levels:
|
|
nodes = []
|
|
for el in lv.get("elements", []):
|
|
if el["id"] in seen:
|
|
sys.exit(f"error: duplicate element id {el['id']!r} "
|
|
"(ids must be unique across all levels)")
|
|
seen.add(el["id"])
|
|
style, w, h = STYLES.get(el.get("type", "system"), STYLES["system"])
|
|
node = {"id": el["id"], "label": c4_label(el), "style": style,
|
|
"width": w, "height": h}
|
|
child = el.get("children")
|
|
if child:
|
|
if child not in page_ids:
|
|
sys.exit(f"error: element {el['id']!r} drills down to "
|
|
f"unknown level {child!r}")
|
|
node["link"] = f"data:page/id,{page_ids[child]}"
|
|
nodes.append(node)
|
|
edges = [{"source": r["from"], "target": r["to"],
|
|
"label": r.get("label", ""), "style": EDGE}
|
|
for r in lv.get("relations", [])]
|
|
graph = {"direction": args.direction, "nodes": nodes, "edges": edges,
|
|
"ranksep": 0.9, "nodesep": 0.5}
|
|
height, pos, edge_pts = al.layout(al.build_dot(graph))
|
|
pages.append(al.wrap_page(al.page_cells(graph, height, pos, edge_pts, color=False),
|
|
page_id=page_ids[lv["name"]], name=lv["name"]))
|
|
|
|
xml = "<mxfile>\n" + "".join(pages) + "</mxfile>\n"
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(xml)
|
|
print(f"wrote {args.output} ({len(pages)} pages, {len(seen)} elements)",
|
|
file=sys.stderr)
|
|
else:
|
|
sys.stdout.write(xml)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|