223 lines
9.7 KiB
Python
223 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic sequence-diagram layout: message list JSON -> .drawio XML.
|
|
|
|
Sequence diagrams are the easiest type to get wrong by hand-placing
|
|
coordinates (lifelines, activation bars and message arrows all share exact
|
|
x/y math) and the least suited to Graphviz — but their geometry is pure
|
|
arithmetic: participants split the x axis, messages advance the y axis.
|
|
This script computes all of it, using the same official styles as
|
|
references/diagram-types.md (umlLifeline shapes, block/open arrows).
|
|
|
|
python3 seqlayout.py seq.json -o diagram.drawio
|
|
|
|
Input JSON:
|
|
{
|
|
"title": "Login flow", # optional page name
|
|
"participants": [
|
|
{"id": "u", "label": "User", "actor": true},
|
|
{"id": "s", "label": "Server"} # order = left-to-right order
|
|
],
|
|
"messages": [
|
|
{"from": "u", "to": "s", "label": "POST /login"}, # sync (solid, filled arrow)
|
|
{"from": "s", "to": "s", "label": "validate()"}, # self message
|
|
{"from": "s", "to": "u", "label": "200 OK", "return": true}, # return (grey dashed)
|
|
{"from": "u", "to": "s", "label": "notify", "async": true}, # async (dashed, open arrow)
|
|
{"note": "token cached", "over": "s"} # note beside a lifeline
|
|
]
|
|
}
|
|
|
|
Activation bars are automatic: a sync/async message opens a bar on the
|
|
target, a return message closes the sender's bar, and bars still open at the
|
|
end run to the bottom. Override per message with "activate": false (don't
|
|
open on target) or "deactivate": true (close the sender's bar after this
|
|
message). Arrows attach to the bar edge when a bar is active, else to the
|
|
lifeline. Fragments (alt/loop/opt frames) are out of scope — add them in
|
|
draw.io afterwards.
|
|
|
|
Usage: python3 seqlayout.py <seq.json> [-o diagram.drawio]
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from xml.sax.saxutils import escape
|
|
|
|
LIFELINE_W, HEADER_H = 100, 40
|
|
BAR_W = 10
|
|
TOP, ROW, SELF_ROW, NOTE_ROW, BOTTOM_PAD = 40, 50, 70, 60, 40
|
|
MIN_SPACING = 200
|
|
|
|
LIFELINE = ("shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;"
|
|
"container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;"
|
|
f"portConstraint=eastwest;size={HEADER_H};")
|
|
# Actor lifelines render a stick figure in the header — anchor the name at
|
|
# the header bottom (white-backed) so figure and label don't overlap.
|
|
ACTOR = LIFELINE + ("participant=umlActor;verticalAlign=bottom;"
|
|
"spacingBottom=-14;labelBackgroundColor=#ffffff;")
|
|
BAR = "html=1;points=[];perimeter=orthogonalPerimeter;outlineConnect=0;fillColor=#ffffff;"
|
|
NOTE = ("shape=note;whiteSpace=wrap;html=1;size=14;fillColor=#fff2cc;"
|
|
"strokeColor=#d6b656;")
|
|
SYNC = "html=1;verticalAlign=bottom;endArrow=block;curved=0;rounded=0;"
|
|
ASYNC = "html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
|
|
RETURN = ("html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
|
|
"strokeColor=#999999;fontColor=#999999;")
|
|
|
|
|
|
def attr(value):
|
|
return escape(str(value), {'"': """, "\n": "
"})
|
|
|
|
|
|
def frac(y, top, height):
|
|
return round(max(0.0, min(1.0, (y - top) / height)), 4)
|
|
|
|
|
|
def layout(spec):
|
|
parts = spec["participants"]
|
|
if not parts:
|
|
sys.exit("error: no participants")
|
|
order = {p["id"]: i for i, p in enumerate(parts)}
|
|
if len(order) != len(parts):
|
|
sys.exit("error: duplicate participant ids")
|
|
|
|
# x axis: uniform spacing, widened if any label needs it (~7px/char).
|
|
spacing = max([MIN_SPACING] + [7 * len(str(p.get("label", p["id"]))) + 80 for p in parts])
|
|
spacing = -(-spacing // 10) * 10 # snap up to the grid
|
|
cx = {p["id"]: TOP + i * spacing + LIFELINE_W // 2 for i, p in enumerate(parts)}
|
|
|
|
# y axis: walk the messages once, assigning each row a y position and
|
|
# tracking one open activation bar per participant ({pid: start_y}).
|
|
y = TOP + HEADER_H + 50
|
|
rows, open_bar, bars = [], {}, [] # bars: (pid, y0, y1)
|
|
|
|
def close(pid, at):
|
|
if pid in open_bar:
|
|
bars.append((pid, open_bar.pop(pid), at))
|
|
|
|
for i, m in enumerate(spec.get("messages", [])):
|
|
if "note" in m:
|
|
rows.append(("note", m, y))
|
|
y += NOTE_ROW
|
|
continue
|
|
src, dst = m["from"], m["to"]
|
|
if src not in order or dst not in order:
|
|
sys.exit(f"error: message {i} references unknown participant")
|
|
is_return = m.get("return", False)
|
|
if src == dst:
|
|
rows.append(("self", m, y))
|
|
y += SELF_ROW
|
|
continue
|
|
rows.append(("msg", m, y))
|
|
if is_return:
|
|
close(src, y) # returning ends the caller's work
|
|
elif m.get("activate", True) and dst not in open_bar:
|
|
open_bar[dst] = y # call starts work on the target
|
|
if m.get("deactivate"):
|
|
close(src, y)
|
|
y += ROW
|
|
height = y + BOTTOM_PAD - TOP
|
|
for pid in list(open_bar):
|
|
close(pid, TOP + height - BOTTOM_PAD)
|
|
|
|
cells = []
|
|
for p in parts:
|
|
style = ACTOR if p.get("actor") else LIFELINE
|
|
cells.append(
|
|
f' <mxCell id="{attr(p["id"])}" value="{attr(p.get("label", p["id"]))}" '
|
|
f'style="{style}" vertex="1" parent="1">\n'
|
|
f' <mxGeometry x="{cx[p["id"]] - LIFELINE_W // 2}" y="{TOP}" '
|
|
f'width="{LIFELINE_W}" height="{height}" as="geometry"/>\n'
|
|
" </mxCell>")
|
|
|
|
# Activation bars: children of their lifeline (coordinates relative to it).
|
|
bar_of = {} # pid -> list of (y0, y1, cell_id)
|
|
for n, (pid, y0, y1) in enumerate(bars):
|
|
bid = f"act{n}"
|
|
bar_of.setdefault(pid, []).append((y0, y1, bid))
|
|
cells.append(
|
|
f' <mxCell id="{bid}" value="" style="{BAR}" vertex="1" '
|
|
f'parent="{attr(pid)}">\n'
|
|
f' <mxGeometry x="{LIFELINE_W // 2 - BAR_W // 2}" y="{y0 - TOP}" '
|
|
f'width="{BAR_W}" height="{y1 - y0}" as="geometry"/>\n'
|
|
" </mxCell>")
|
|
|
|
def anchor(pid, my, side):
|
|
"""(cell_id, exitX-style fragment values) for a message endpoint: the
|
|
activation bar's edge when one is active at this y, else the lifeline."""
|
|
for y0, y1, bid in bar_of.get(pid, []):
|
|
if y0 <= my <= y1:
|
|
return bid, (1 if side == "right" else 0), frac(my, y0, y1 - y0)
|
|
return pid, 0.5, frac(my, TOP, height)
|
|
|
|
for i, (kind, m, my) in enumerate(rows):
|
|
if kind == "note":
|
|
pid = m.get("over")
|
|
if pid not in cx:
|
|
sys.exit(f"error: note {i} is over unknown participant {pid!r}")
|
|
w = max(120, 7 * len(str(m["note"])) + 30)
|
|
cells.append(
|
|
f' <mxCell id="note{i}" value="{attr(m["note"])}" style="{NOTE}" '
|
|
f'vertex="1" parent="1">\n'
|
|
f' <mxGeometry x="{cx[pid] + 20}" y="{my - 20}" '
|
|
f'width="{min(w, spacing - 60)}" height="40" as="geometry"/>\n'
|
|
" </mxCell>")
|
|
continue
|
|
src, dst = m["from"], m["to"]
|
|
style = RETURN if m.get("return") else ASYNC if m.get("async") else SYNC
|
|
if kind == "self":
|
|
sid, sx, sy = anchor(src, my, "right")
|
|
_, tx, ty = anchor(src, my + 30, "right")
|
|
loop_x = cx[src] + 60
|
|
cells.append(
|
|
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
|
|
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
|
|
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(sid)}">\n'
|
|
f' <mxGeometry relative="1" as="geometry">\n'
|
|
f' <Array as="points">'
|
|
f'<mxPoint x="{loop_x}" y="{my}"/><mxPoint x="{loop_x}" y="{my + 30}"/>'
|
|
f'</Array>\n'
|
|
" </mxGeometry>\n"
|
|
" </mxCell>")
|
|
continue
|
|
rightward = order[src] < order[dst]
|
|
sid, sx, sy = anchor(src, my, "right" if rightward else "left")
|
|
tid, tx, ty = anchor(dst, my, "left" if rightward else "right")
|
|
cells.append(
|
|
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
|
|
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
|
|
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(tid)}">\n'
|
|
f' <mxGeometry relative="1" as="geometry"/>\n'
|
|
" </mxCell>")
|
|
|
|
name = attr(spec.get("title", "Sequence"))
|
|
return (
|
|
f'<mxfile>\n <diagram id="seqlayout" name="{name}">\n'
|
|
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
|
|
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
|
|
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
|
|
" <root>\n"
|
|
' <mxCell id="0"/>\n'
|
|
' <mxCell id="1" parent="0"/>\n'
|
|
+ "\n".join(cells)
|
|
+ "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
|
|
)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Sequence-diagram JSON -> draw.io XML.")
|
|
ap.add_argument("input", help="sequence JSON file")
|
|
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
|
args = ap.parse_args()
|
|
with open(args.input, encoding="utf-8") as f:
|
|
spec = json.load(f)
|
|
xml = layout(spec)
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(xml)
|
|
print(f"wrote {args.output} ({len(spec['participants'])} participants, "
|
|
f"{len(spec.get('messages', []))} messages)", file=sys.stderr)
|
|
else:
|
|
sys.stdout.write(xml)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|