feat: sync 12 new skills into catalog (total 31 skills), update manifest and README
This commit is contained in:
222
skills/drawio-skill/scripts/seqlayout.py
Normal file
222
skills/drawio-skill/scripts/seqlayout.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user