skilldrop-cli 0.16.21 → 0.16.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/skilldrop.js +5 -3
  2. package/catalogue.json +1 -0
  3. package/model-routing.json +4 -0
  4. package/package.json +1 -1
  5. package/packs/solution-architect/skills/interactive-diagram/SKILL.md +132 -0
  6. package/packs/solution-architect/skills/interactive-diagram/evals/eval_queries.json +12 -0
  7. package/packs/solution-architect/skills/interactive-diagram/evals/evals.json +74 -0
  8. package/packs/solution-architect/skills/interactive-diagram/evals/files/1/README.md +11 -0
  9. package/packs/solution-architect/skills/interactive-diagram/evals/files/1/api/app.py +43 -0
  10. package/packs/solution-architect/skills/interactive-diagram/evals/files/1/docker-compose.yml +20 -0
  11. package/packs/solution-architect/skills/interactive-diagram/evals/files/1/web/checkout.js +9 -0
  12. package/packs/solution-architect/skills/interactive-diagram/evals/files/1/worker/worker.py +23 -0
  13. package/packs/solution-architect/skills/interactive-diagram/evals/files/3/schema.sql +35 -0
  14. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/README.md +26 -0
  15. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/config/default.json +29 -0
  16. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/clients/transcription.js +25 -0
  17. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/db.js +16 -0
  18. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/handlers/storageWebhook.js +43 -0
  19. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/jobs/transcribeEpisode.js +28 -0
  20. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/mailer.js +18 -0
  21. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/server.js +32 -0
  22. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/worker.js +32 -0
  23. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.html +186 -0
  24. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.json +48 -0
  25. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.md +31 -0
  26. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/api/checkout.py +41 -0
  27. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/docker-compose.yml +20 -0
  28. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/worker/fulfil.py +23 -0
  29. package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.html +186 -0
  30. package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.json +28 -0
  31. package/packs/solution-architect/skills/interactive-diagram/manifest.json +39 -0
  32. package/packs/solution-architect/skills/interactive-diagram/reference.md +151 -0
  33. package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +1682 -0
@@ -0,0 +1,1682 @@
1
+ #!/usr/bin/env python3
2
+ """Render a diagram spec (JSON) into one self-contained, explorable HTML file.
3
+
4
+ The spec says what is on the map and where each part came from; this script does the rest:
5
+ it checks the spec, verifies every cited file and line against the repository, lays the
6
+ diagram out (nobody writes coordinates), and writes an HTML file with no external requests:
7
+ pan and zoom, click for detail and evidence, upstream/downstream reach, path between two
8
+ parts, search, a step-through tour, light and dark themes, and SVG / PNG / Mermaid export.
9
+ The same content is in the page as plain text, so it reads without JavaScript.
10
+
11
+ python3 build_diagram.py spec.json -o map.html
12
+ python3 build_diagram.py spec.json -o map.html --repo-root . # verify file:line evidence
13
+ python3 build_diagram.py spec.json --check # validate only
14
+ python3 build_diagram.py head.json -o diff.html --compare base.json # what changed
15
+ python3 build_diagram.py spec.json -o map.html --mermaid map.mmd # also write Mermaid
16
+
17
+ Standard library only, Python 3.9+. Exit 0 when the HTML was written (warnings allowed),
18
+ 1 when the spec has errors (nothing written), 2 for usage errors.
19
+ """
20
+ import argparse
21
+ import base64
22
+ import hashlib
23
+ import html
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+
29
+ TYPES = ("architecture", "workflow", "dataflow", "lifecycle", "erd", "sequence")
30
+
31
+ # kind -> (shape, colour family). Unknown kinds fall back to a plain box, with a warning.
32
+ KINDS = {
33
+ "service": ("rect", "blue"), "app": ("rect", "blue"), "function": ("rect", "blue"),
34
+ "ui": ("rect", "violet"), "client": ("rect", "violet"),
35
+ "datastore": ("cylinder", "green"), "database": ("cylinder", "green"),
36
+ "cache": ("cylinder", "teal"), "storage": ("cylinder", "green"),
37
+ "queue": ("pipe", "amber"), "topic": ("pipe", "amber"), "stream": ("pipe", "amber"),
38
+ "external": ("rect", "slate"), "person": ("person", "rose"), "actor": ("person", "rose"),
39
+ "job": ("rect", "indigo"), "worker": ("rect", "indigo"),
40
+ "step": ("rect", "blue"), "task": ("rect", "blue"), "manual": ("rect", "rose"),
41
+ "decision": ("diamond", "amber"), "document": ("document", "teal"),
42
+ "start": ("terminal", "green"), "end": ("terminal", "slate"),
43
+ "state": ("round", "blue"), "final": ("final", "slate"), "initial": ("initial", "slate"),
44
+ "table": ("table", "green"), "entity": ("table", "green"), "view": ("table", "teal"),
45
+ "security": ("rect", "rose"), "network": ("rect", "slate"), "file": ("document", "teal"),
46
+ }
47
+ DEFAULT_KIND = {"architecture": "service", "workflow": "step", "dataflow": "service",
48
+ "lifecycle": "state", "erd": "table", "sequence": "service"}
49
+ EDGE_STYLES = ("solid", "dashed", "dotted")
50
+ STATUSES = ("added", "removed", "changed")
51
+ SECRET_PATH = re.compile(r"(^|/)(\.env(\..*)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials(\..*)?|"
52
+ r".*secrets?\.(json|ya?ml|toml)|\.npmrc|\.pypirc|\.netrc)$", re.I)
53
+ SECRET_LINE = re.compile(r"(-----BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|"
54
+ r"sk-[A-Za-z0-9_-]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|"
55
+ r"(?i:(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['\"]?[^\s'\"]{6,}))")
56
+ MAX_SNIPPET_LINES = 14
57
+ MAX_SNIPPET_TOTAL = 80000
58
+
59
+
60
+ # --------------------------------------------------------------------------- spec checks
61
+
62
+ class Issues(object):
63
+ def __init__(self):
64
+ self.items = []
65
+
66
+ def add(self, level, code, where, message, fix=""):
67
+ self.items.append({"level": level, "code": code, "where": where, "message": message, "fix": fix})
68
+
69
+ def error(self, *a):
70
+ self.add("error", *a)
71
+
72
+ def warn(self, *a):
73
+ self.add("warning", *a)
74
+
75
+ @property
76
+ def errors(self):
77
+ return [i for i in self.items if i["level"] == "error"]
78
+
79
+ @property
80
+ def warnings(self):
81
+ return [i for i in self.items if i["level"] == "warning"]
82
+
83
+
84
+ EVIDENCE_RE = re.compile(r"^(?P<file>[^:#\s][^:#]*?)(?::(?P<start>\d+)(?:-(?P<end>\d+))?)?(?:#(?P<quote>.+))?$")
85
+
86
+
87
+ def parse_evidence(ev):
88
+ """Evidence is a file reference ("src/api.py:40-52#redis.get(") or something the person
89
+ said ("user: we approve leave in the HR portal"). Objects with the same fields work too."""
90
+ if isinstance(ev, dict):
91
+ if "said" in ev:
92
+ return {"said": str(ev["said"])}
93
+ out = {"file": str(ev.get("file", ""))}
94
+ lines = str(ev.get("lines", "") or "")
95
+ m = re.match(r"^(\d+)(?:-(\d+))?$", lines)
96
+ if m:
97
+ out["start"], out["end"] = int(m.group(1)), int(m.group(2) or m.group(1))
98
+ if ev.get("quote"):
99
+ out["quote"] = str(ev["quote"])
100
+ return out
101
+ s = str(ev).strip()
102
+ low = s.lower()
103
+ for prefix in ("user:", "said:", "brief:", "doc:"):
104
+ if low.startswith(prefix):
105
+ return {"said": s[len(prefix):].strip().strip('"“”'), "source": prefix[:-1]}
106
+ m = EVIDENCE_RE.match(s)
107
+ if not m or not m.group("file"):
108
+ return {"bad": s}
109
+ out = {"file": m.group("file").strip()}
110
+ if m.group("start"):
111
+ out["start"] = int(m.group("start"))
112
+ out["end"] = int(m.group("end") or m.group("start"))
113
+ if m.group("quote"):
114
+ out["quote"] = m.group("quote").strip()
115
+ return out
116
+
117
+
118
+ def ev_text(e):
119
+ if "said" in e:
120
+ return "%s: “%s”" % (e.get("source", "user"), e["said"])
121
+ if "file" in e:
122
+ s = e["file"]
123
+ if "start" in e:
124
+ s += ":%d" % e["start"] + ("-%d" % e["end"] if e["end"] != e["start"] else "")
125
+ return s
126
+ return e.get("bad", "")
127
+
128
+
129
+ def as_list(x):
130
+ if x is None:
131
+ return []
132
+ return x if isinstance(x, list) else [x]
133
+
134
+
135
+ def normalise(spec, issues):
136
+ """Fill defaults and parse shorthand, collecting problems. Returns the normalised spec."""
137
+ t = spec.get("type")
138
+ if t not in TYPES:
139
+ issues.error("spec/type", "type", "type is %r" % t, "Use one of: " + ", ".join(TYPES))
140
+ t = "architecture"
141
+ spec["type"] = t
142
+ if not str(spec.get("title", "")).strip():
143
+ issues.error("spec/title", "title", "the diagram has no title", "Add a title naming what the map shows.")
144
+ spec.setdefault("question", "")
145
+ spec.setdefault("summary", "")
146
+ spec["direction"] = "TB" if str(spec.get("direction", "LR")).upper() == "TB" and t not in ("erd", "sequence") else "LR"
147
+ if spec.get("source_url") and not re.match(r"^https?://[^\s\"'<>]+$", str(spec["source_url"])):
148
+ issues.warn("spec/source-url", "source_url", "source_url is not an http(s) address; citations will not be links",
149
+ "Use the repository's https address, e.g. https://github.com/org/repo/blob/<commit>/")
150
+ spec["groups"] = [dict(g) for g in as_list(spec.get("groups"))]
151
+ spec["notes"] = as_list(spec.get("notes"))
152
+ key = "participants" if t == "sequence" else "nodes"
153
+ nodes = [dict(n) for n in as_list(spec.get(key) or spec.get("nodes"))]
154
+ if not nodes:
155
+ issues.error("spec/empty", key, "there is nothing to draw", "List the %s of the diagram." % key)
156
+ spec["nodes"] = nodes
157
+ spec["edges"] = [dict(e) for e in as_list(spec.get("messages" if t == "sequence" else "edges"))]
158
+ spec["frames"] = [dict(f) for f in as_list(spec.get("frames"))]
159
+ spec["story"] = [dict(s) for s in as_list(spec.get("story"))]
160
+
161
+ ids = set()
162
+ for g in spec["groups"]:
163
+ gid = str(g.get("id", "")).strip()
164
+ if not gid:
165
+ issues.error("group/id", "groups", "a group has no id", "Give every group an id.")
166
+ continue
167
+ if gid in ids:
168
+ issues.error("id/duplicate", gid, "the id %r is used twice" % gid, "Ids must be unique across groups, nodes and edges.")
169
+ ids.add(gid)
170
+ g["id"] = gid
171
+ g["label"] = str(g.get("label") or gid)
172
+ gids = {g["id"] for g in spec["groups"]}
173
+ for g in spec["groups"]:
174
+ if g.get("parent") and g["parent"] not in gids:
175
+ issues.error("group/parent", g["id"], "parent group %r does not exist" % g["parent"], "Name an existing group or drop parent.")
176
+ g["parent"] = None
177
+ for g in spec["groups"]: # a parent chain must end
178
+ seen, cur = set(), g
179
+ while cur and cur.get("parent"):
180
+ if cur["id"] in seen:
181
+ issues.error("group/cycle", g["id"], "group parents form a loop", "Remove one parent link.")
182
+ g["parent"] = None
183
+ break
184
+ seen.add(cur["id"])
185
+ cur = next((x for x in spec["groups"] if x["id"] == cur["parent"]), None)
186
+
187
+ for n in nodes:
188
+ nid = str(n.get("id", "")).strip()
189
+ if not nid:
190
+ issues.error("node/id", key, "a %s has no id" % key[:-1], "Give every %s an id." % key[:-1])
191
+ nid = "_node%d" % len(ids)
192
+ if nid in ids:
193
+ issues.error("id/duplicate", nid, "the id %r is used twice" % nid, "Ids must be unique across groups, nodes and edges.")
194
+ ids.add(nid)
195
+ n["id"] = nid
196
+ n["label"] = str(n.get("label") or nid)
197
+ n["kind"] = str(n.get("kind") or DEFAULT_KIND[t]).lower()
198
+ if n["kind"] not in KINDS:
199
+ issues.warn("node/kind", nid, "kind %r is not one the renderer knows; drawn as a box" % n["kind"],
200
+ "Known kinds: " + ", ".join(sorted(KINDS)))
201
+ if n.get("group") and n["group"] not in gids:
202
+ issues.error("node/group", nid, "group %r does not exist" % n["group"], "Add the group or drop the field.")
203
+ n["group"] = None
204
+ n["fields"] = [f if isinstance(f, dict) else {"name": str(f)} for f in as_list(n.get("fields"))]
205
+ if len(n["label"]) > 48:
206
+ issues.warn("node/label-long", nid, "label is %d characters; it will be cut on the map" % len(n["label"]),
207
+ "Keep labels to a few words and put the rest in note or detail.")
208
+ _evidence(n, nid, issues)
209
+
210
+ nid_set = {n["id"] for n in nodes}
211
+ for i, e in enumerate(spec["edges"]):
212
+ where = e.get("id") or "%s %d" % ("message" if t == "sequence" else "edge", i + 1)
213
+ if not e.get("id"):
214
+ e["id"] = "m%d" % (i + 1) if t == "sequence" else "e%d" % (i + 1)
215
+ while e["id"] in ids:
216
+ e["id"] += "_"
217
+ elif e["id"] in ids:
218
+ issues.error("id/duplicate", e["id"], "the id %r is used twice" % e["id"], "Ids must be unique across groups, nodes and edges.")
219
+ ids.add(e["id"])
220
+ for end in ("from", "to"):
221
+ if e.get(end) not in nid_set:
222
+ issues.error("edge/endpoint", where, "%s %r is not a %s" % (end, e.get(end), key[:-1]),
223
+ "Use the id of an existing %s." % key[:-1])
224
+ e["label"] = str(e.get("label") or "")
225
+ e["style"] = e.get("style") if e.get("style") in EDGE_STYLES else ("dashed" if e.get("kind") in ("async", "return") else "solid")
226
+ _evidence(e, where, issues)
227
+ for i, s in enumerate(spec["story"]):
228
+ for ref in as_list(s.get("nodes")) + as_list(s.get("edges")):
229
+ if ref not in ids:
230
+ issues.error("story/ref", "story step %d" % (i + 1), "%r is not a node or edge id" % ref, "Point the step at ids on the map.")
231
+ for i, f in enumerate(spec["frames"]):
232
+ if t != "sequence":
233
+ issues.warn("frame/type", "frames", "frames are drawn only on sequence diagrams", "Use groups instead.")
234
+ break
235
+ if f.get("from") not in [e["id"] for e in spec["edges"]] or f.get("to") not in [e["id"] for e in spec["edges"]]:
236
+ issues.error("frame/range", "frame %d" % (i + 1), "a frame must name its first and last message ids in from and to",
237
+ "Set from and to to message ids.")
238
+ if len(nodes) > 400 or len(spec["edges"]) > 2000:
239
+ issues.error("size/limit", key, "%d nodes and %d edges is past what one page can lay out" % (len(nodes), len(spec["edges"])),
240
+ "Split the map by question.")
241
+ if t != "sequence" and len(nodes) > 36:
242
+ issues.warn("size/large", "nodes", "%d nodes is more than one map reads well" % len(nodes),
243
+ "Split it by question, or group the detail into fewer boxes.")
244
+ if t not in ("sequence", "erd"):
245
+ linked = {e.get("from") for e in spec["edges"]} | {e.get("to") for e in spec["edges"]}
246
+ for n in nodes:
247
+ if n["id"] not in linked and len(nodes) > 1:
248
+ issues.warn("node/isolated", n["id"], "%r has no connections" % n["label"],
249
+ "Connect it, or leave it out if it doesn't answer the question.")
250
+ return spec
251
+
252
+
253
+ def _evidence(item, where, issues):
254
+ evs = [parse_evidence(x) for x in as_list(item.get("evidence"))]
255
+ for e in evs:
256
+ if "bad" in e:
257
+ issues.error("evidence/format", where, "cannot read evidence %r" % e["bad"],
258
+ 'Write "path/to/file.py:12-20" (optionally "#text on those lines") or "user: their words".')
259
+ item["evidence"] = [e for e in evs if "bad" not in e]
260
+ item["assumed"] = bool(item.get("assumed"))
261
+ if not item["evidence"] and not item["assumed"]:
262
+ issues.error("evidence/missing", where, "no evidence and not marked assumed",
263
+ 'Cite where it comes from ("src/x.py:10", "user: ...") or set "assumed": true so it is drawn as an assumption.')
264
+
265
+
266
+ def verify_evidence(spec, repo_root, issues):
267
+ """Check every file citation against the repository: the file exists, the lines exist,
268
+ and a quoted fragment appears on those lines. Attach the cited lines as a snippet, except
269
+ from files that hold secrets, and with secret-looking lines masked."""
270
+ cache, total = {}, 0
271
+ items = [("node", n) for n in spec["nodes"]] + [("edge", e) for e in spec["edges"]]
272
+ root = os.path.realpath(repo_root) if repo_root else None
273
+ for kind, item in items:
274
+ for e in item["evidence"]:
275
+ if "file" not in e:
276
+ continue
277
+ where = item["id"]
278
+ if not root:
279
+ e["verified"] = None
280
+ continue
281
+ path = os.path.realpath(os.path.join(root, e["file"]))
282
+ if not (path == root or path.startswith(root + os.sep)):
283
+ issues.error("evidence/outside", where, "%s is outside the repository" % e["file"], "Cite files inside --repo-root.")
284
+ e["verified"] = False
285
+ continue
286
+ if not os.path.isfile(path):
287
+ issues.error("evidence/file", where, "%s does not exist" % e["file"], "Fix the path, relative to the repository root.")
288
+ e["verified"] = False
289
+ continue
290
+ if path not in cache:
291
+ try:
292
+ with open(path, encoding="utf-8", errors="replace") as fh:
293
+ cache[path] = fh.read().splitlines()
294
+ except OSError as err:
295
+ issues.error("evidence/read", where, "cannot read %s: %s" % (e["file"], err), "")
296
+ e["verified"] = False
297
+ continue
298
+ lines = cache[path]
299
+ start, end = e.get("start"), e.get("end")
300
+ if start is not None:
301
+ if start < 1 or end < start or end > len(lines):
302
+ issues.error("evidence/lines", where, "%s has %d lines; cited %s" % (e["file"], len(lines), ev_text(e).split(":")[-1]),
303
+ "Cite lines that exist.")
304
+ e["verified"] = False
305
+ continue
306
+ span = lines[start - 1:end] if start else lines
307
+ if e.get("quote"):
308
+ flat = lambda s: re.sub(r"\s+", " ", s).strip().lower()
309
+ if flat(e["quote"]) not in flat(" ".join(span)):
310
+ issues.error("evidence/quote", where, "%r is not on %s" % (e["quote"], ev_text(e)),
311
+ "Quote text that is on the cited lines, or fix the line numbers.")
312
+ e["verified"] = False
313
+ continue
314
+ e["verified"] = True
315
+ if start and not SECRET_PATH.search(e["file"]) and total < MAX_SNIPPET_TOTAL:
316
+ shown = span[:MAX_SNIPPET_LINES]
317
+ masked = ["[line hidden: looks like a secret]" if SECRET_LINE.search(l) else l for l in shown]
318
+ e["snippet"] = "\n".join(masked) + ("\n…" if len(span) > len(shown) else "")
319
+ total += len(e["snippet"])
320
+
321
+
322
+ def merge_compare(head, base, issues):
323
+ """Mark what changed between a base spec and this one: added, removed (drawn from the
324
+ base), changed (same id, different label, kind, group, note or ends)."""
325
+ t = head["type"]
326
+ if base["type"] != t:
327
+ issues.error("compare/type", "compare", "the base is a %s diagram and this is a %s" % (base["type"], t), "Compare like with like.")
328
+ return
329
+ bn = {n["id"]: n for n in base["nodes"]}
330
+ hn = {n["id"] for n in head["nodes"]}
331
+ for n in head["nodes"]:
332
+ if n.get("status"):
333
+ continue
334
+ if n["id"] not in bn:
335
+ n["status"] = "added"
336
+ elif any(str(n.get(k) or "") != str(bn[n["id"]].get(k) or "") for k in ("label", "kind", "group", "note")):
337
+ n["status"] = "changed"
338
+ gids = {g["id"] for g in head["groups"]}
339
+ for g in base["groups"]:
340
+ if g["id"] not in gids:
341
+ head["groups"].append(dict(g, status="removed"))
342
+ gids.add(g["id"])
343
+ for nid, n in bn.items():
344
+ if nid not in hn:
345
+ head["nodes"].append(dict(n, status="removed"))
346
+ ekey = lambda e: (e.get("from"), e.get("to"), e.get("label", ""))
347
+ be = {ekey(e): e for e in base["edges"]}
348
+ he = {ekey(e) for e in head["edges"]}
349
+ for e in head["edges"]:
350
+ if not e.get("status") and ekey(e) not in be:
351
+ e["status"] = "added"
352
+ used = {e["id"] for e in head["edges"]}
353
+ for k, e in be.items():
354
+ if k not in he:
355
+ e = dict(e, status="removed")
356
+ while e["id"] in used:
357
+ e["id"] += "_base"
358
+ used.add(e["id"])
359
+ head["edges"].append(e)
360
+ head["compared"] = True
361
+
362
+
363
+ # --------------------------------------------------------------------------- text metrics
364
+
365
+ def text_w(s, size=13.0):
366
+ """Width estimate for a sans-serif label; wide letters count more. Deliberately generous."""
367
+ w = 0.0
368
+ for ch in s:
369
+ if ch in "il.,:;|!'`":
370
+ w += 0.30
371
+ elif ch in "mwMW@":
372
+ w += 0.86
373
+ elif ch.isupper() or ch.isdigit():
374
+ w += 0.64
375
+ elif ord(ch) > 0x2E80:
376
+ w += 1.0
377
+ else:
378
+ w += 0.55
379
+ return w * size
380
+
381
+
382
+ def wrap(s, max_w, size=13.0, max_lines=2):
383
+ words, lines, cur = s.split(), [], ""
384
+ for wd in words:
385
+ trial = (cur + " " + wd).strip()
386
+ if text_w(trial, size) <= max_w or not cur:
387
+ cur = trial
388
+ else:
389
+ lines.append(cur)
390
+ cur = wd
391
+ if cur:
392
+ lines.append(cur)
393
+ if len(lines) > max_lines:
394
+ lines = lines[:max_lines]
395
+ last = lines[-1]
396
+ while last and text_w(last + "…", size) > max_w:
397
+ last = last[:-1]
398
+ lines[-1] = last.rstrip() + "…"
399
+ for i, l in enumerate(lines): # a single long word
400
+ if text_w(l, size) > max_w * 1.02:
401
+ while l and text_w(l + "…", size) > max_w:
402
+ l = l[:-1]
403
+ lines[i] = l + "…"
404
+ return lines or [""]
405
+
406
+
407
+ def size_node(n):
408
+ shape = KINDS.get(n["kind"], ("rect", "slate"))[0]
409
+ n["shape"] = shape
410
+ if shape == "table":
411
+ rows = n["fields"][:24]
412
+ widest = max([text_w(n["label"], 13) + 24] + [text_w(f.get("name", ""), 12) + text_w(str(f.get("type", "")), 11)
413
+ + (52 if f.get("key") else 0) + 40 for f in rows])
414
+ n["w"] = int(min(max(widest, 170), 320))
415
+ n["lines"] = [n["label"]]
416
+ n["h"] = 32 + 20 * len(rows) + (18 if len(n["fields"]) > 24 else 0) + (4 if rows else 6)
417
+ return
418
+ if shape in ("initial", "final"):
419
+ n["lines"] = wrap(n["label"], 120, 11) if n.get("label_shown", n["label"] != n["id"]) else []
420
+ n["w"], n["h"] = 34, 34
421
+ return
422
+ max_w = 190 if shape != "diamond" else 150
423
+ n["lines"] = wrap(n["label"], max_w, 13, 2)
424
+ n["note_lines"] = wrap(str(n.get("note") or ""), max_w, 11, 1) if n.get("note") else []
425
+ tw = max([text_w(l, 13) for l in n["lines"]] + [text_w(l, 11) for l in n["note_lines"]])
426
+ h = 16 + 17 * len(n["lines"]) + 15 * len(n["note_lines"]) + 6
427
+ if shape == "diamond":
428
+ n["w"], n["h"] = int(max(tw * 1.55 + 40, 120)), int(max(h * 1.5, 76))
429
+ elif shape == "person":
430
+ n["w"], n["h"] = int(max(tw + 30, 110)), int(h + 26)
431
+ elif shape == "cylinder":
432
+ n["w"], n["h"] = int(max(tw + 34, 120)), int(h + 14)
433
+ elif shape == "terminal":
434
+ n["w"], n["h"] = int(max(tw + 44, 110)), int(max(h, 44))
435
+ else:
436
+ n["w"], n["h"] = int(max(tw + 34, 120)), int(max(h, 50))
437
+
438
+
439
+ # --------------------------------------------------------------------------- graph layout
440
+
441
+ class Axis(object):
442
+ """Layout runs in (main, cross) coordinates: main is the direction of flow (left to right
443
+ by default), cross is across it. LR maps main to x; TB maps main to y."""
444
+
445
+ def __init__(self, direction):
446
+ self.lr = direction == "LR"
447
+
448
+ def m_size(self, n):
449
+ return n["w"] if self.lr else n["h"]
450
+
451
+ def c_size(self, n):
452
+ return n["h"] if self.lr else n["w"]
453
+
454
+ def xy(self, m, c):
455
+ return (m, c) if self.lr else (c, m)
456
+
457
+
458
+ def layered(spec):
459
+ nodes = spec["nodes"]
460
+ by_id = {n["id"]: n for n in nodes}
461
+ edges = [e for e in spec["edges"] if e.get("from") in by_id and e.get("to") in by_id]
462
+ order = {n["id"]: i for i, n in enumerate(nodes)}
463
+ succ = {n["id"]: [] for n in nodes}
464
+ for e in edges:
465
+ if e["from"] != e["to"]:
466
+ succ[e["from"]].append(e["to"])
467
+ # break cycles: edges that close a loop in author order count as back edges
468
+ back, state = set(), {}
469
+
470
+ def dfs(u):
471
+ state[u] = 1
472
+ for v in sorted(succ[u], key=order.get):
473
+ if state.get(v) == 1:
474
+ back.add((u, v))
475
+ elif not state.get(v):
476
+ dfs(v)
477
+ state[u] = 2
478
+ sys.setrecursionlimit(10000)
479
+ for n in nodes:
480
+ if not state.get(n["id"]):
481
+ dfs(n["id"])
482
+ dag = {u: [v for v in vs if (u, v) not in back] for u, vs in succ.items()}
483
+ pred = {n["id"]: [] for n in nodes}
484
+ for u, vs in dag.items():
485
+ for v in vs:
486
+ pred[v].append(u)
487
+ layer = {}
488
+
489
+ def longest(u):
490
+ if u in layer:
491
+ return layer[u]
492
+ layer[u] = 0
493
+ layer[u] = max([longest(p) + 1 for p in pred[u]] or [0])
494
+ return layer[u]
495
+ for n in nodes:
496
+ longest(n["id"])
497
+ for _ in range(2): # pull sources next to what they feed
498
+ for n in nodes:
499
+ u = n["id"]
500
+ if not pred[u] and dag[u]:
501
+ layer[u] = max(layer[u], min(layer[v] for v in dag[u]) - 1)
502
+ return layer, edges, back
503
+
504
+
505
+ def group_tree(spec):
506
+ groups = {g["id"]: g for g in spec["groups"]}
507
+ kids = {g["id"]: [] for g in spec["groups"]}
508
+ top = []
509
+ for g in spec["groups"]:
510
+ (kids[g["parent"]] if g.get("parent") else top).append(g["id"])
511
+ return groups, kids, top
512
+
513
+
514
+ def bands_for(spec):
515
+ """Groups become bands across the flow, so their boxes never overlap. Order follows the
516
+ author's: the first node mentioned decides where its group sits."""
517
+ groups, kids, top = group_tree(spec)
518
+ first = {}
519
+ for i, n in enumerate(spec["nodes"]):
520
+ g = n.get("group")
521
+ while g:
522
+ first.setdefault(g, i)
523
+ g = groups[g].get("parent")
524
+ members = {}
525
+ for n in spec["nodes"]:
526
+ members.setdefault(n.get("group"), []).append(n["id"])
527
+ bands, spans = [], {}
528
+
529
+ def walk(gid, depth):
530
+ start = len(bands)
531
+ if members.get(gid):
532
+ bands.append({"group": gid, "depth": depth})
533
+ for k in sorted(kids[gid], key=lambda x: first.get(x, 1e9)):
534
+ walk(k, depth + 1)
535
+ if len(bands) > start:
536
+ spans[gid] = (start, len(bands) - 1, depth)
537
+ units = [("g", g) for g in top] + ([("n", None)] if members.get(None) else [])
538
+ pos = lambda u: first.get(u[1], 1e9) if u[0] == "g" else min(i for i, n in enumerate(spec["nodes"]) if not n.get("group"))
539
+ for kind, gid in sorted(units, key=pos):
540
+ if kind == "n":
541
+ bands.append({"group": None, "depth": 0})
542
+ else:
543
+ walk(gid, 1)
544
+ return bands, spans
545
+
546
+
547
+ def layout_graph(spec):
548
+ ax = Axis(spec["direction"])
549
+ nodes = spec["nodes"]
550
+ for n in nodes:
551
+ size_node(n)
552
+ by_id = {n["id"]: n for n in nodes}
553
+ layer, edges, back = layered(spec)
554
+ bands, spans = bands_for(spec)
555
+ band_of = {}
556
+ for bi, b in enumerate(bands):
557
+ for n in nodes:
558
+ if n.get("group") == b["group"]:
559
+ band_of[n["id"]] = bi
560
+ n_layers = max(layer.values()) + 1 if layer else 1
561
+ cells = {}
562
+ for n in nodes:
563
+ cells.setdefault((band_of[n["id"]], layer[n["id"]]), []).append(n["id"])
564
+ order = {n["id"]: i for i, n in enumerate(nodes)}
565
+ for k in cells:
566
+ cells[k].sort(key=order.get)
567
+ nbrs = {n["id"]: [] for n in nodes}
568
+ for e in edges:
569
+ if e["from"] != e["to"]:
570
+ nbrs[e["from"]].append(e["to"])
571
+ nbrs[e["to"]].append(e["from"])
572
+
573
+ def rank():
574
+ r, i = {}, 0
575
+ for bi in range(len(bands)):
576
+ depth = max([len(cells.get((bi, l), [])) for l in range(n_layers)] or [1])
577
+ for l in range(n_layers):
578
+ for j, u in enumerate(cells.get((bi, l), [])):
579
+ r[u] = i + j + (depth - len(cells.get((bi, l), []))) / 2.0
580
+ i += depth + 1
581
+ return r
582
+ for sweep in range(6): # barycentre ordering inside each band and layer
583
+ r = rank()
584
+ layers = range(n_layers) if sweep % 2 == 0 else range(n_layers - 1, -1, -1)
585
+ for l in layers:
586
+ for bi in range(len(bands)):
587
+ c = cells.get((bi, l))
588
+ if not c or len(c) < 2:
589
+ continue
590
+ key = {}
591
+ for u in c:
592
+ ns = [r[v] for v in nbrs[u] if layer[v] != l]
593
+ key[u] = sum(ns) / len(ns) if ns else r[u]
594
+ c.sort(key=lambda u: (key[u], order[u]))
595
+ return ax, by_id, layer, edges, back, bands, spans, cells, n_layers
596
+
597
+
598
+ def place(spec, geo, gap_m, gap_c):
599
+ """Coordinates from the ordering. gap_m[i] is the space before layer i (gap_m[n_layers]
600
+ is after the last); route lanes run there. gap_c[i] is the corridor before band i
601
+ (gap_c[n_bands] is after the last); long routes run there. Neither holds a node."""
602
+ ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
603
+ lr = ax.lr
604
+ pad, head = 18, 26
605
+ layer_m = [0] * n_layers
606
+ for n in spec["nodes"]:
607
+ layer_m[layer[n["id"]]] = max(layer_m[layer[n["id"]]], ax.m_size(n))
608
+ max_depth = max([d for (_, _, d) in spans.values()] or [0])
609
+ m = 20 + (pad + (0 if lr else head)) * max_depth
610
+ layer_start, gap_mid = [], []
611
+ for l in range(n_layers):
612
+ gap_mid.append(m + gap_m[l] / 2.0)
613
+ m += gap_m[l]
614
+ layer_start.append(m)
615
+ m += layer_m[l]
616
+ gap_mid.append(m + gap_m[n_layers] / 2.0)
617
+ m += gap_m[n_layers] + (pad + (0 if lr else head)) * max_depth + 20
618
+ opening = lambda bi: sorted(d for (s, e, d) in spans.values() if s == bi)
619
+ closing = lambda bi: [d for (s, e, d) in spans.values() if e == bi]
620
+ band_start, band_size, corridors, c = [], [], [], 20.0
621
+ for bi in range(len(bands)):
622
+ corridors.append(c + gap_c[bi] / 2.0)
623
+ c += gap_c[bi] + len(opening(bi)) * (head + 6 if lr else pad)
624
+ band_start.append(c)
625
+ size = 0
626
+ for l in range(n_layers):
627
+ stack = cells.get((bi, l), [])
628
+ size = max(size, sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0))
629
+ band_size.append(size)
630
+ c += size + pad * len(closing(bi))
631
+ corridors.append(c + gap_c[len(bands)] / 2.0)
632
+ c += gap_c[len(bands)] + 20
633
+ for bi in range(len(bands)):
634
+ for l in range(n_layers):
635
+ stack = cells.get((bi, l), [])
636
+ total = sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0)
637
+ cc = band_start[bi] + (band_size[bi] - total) / 2.0
638
+ for u in stack:
639
+ n = by_id[u]
640
+ n["m"] = layer_start[l] + (layer_m[l] - ax.m_size(n)) / 2.0
641
+ n["c"] = cc
642
+ cc += ax.c_size(n) + 26
643
+ boxes = []
644
+ for gid, (s, e, d) in spans.items():
645
+ mem = [n for n in spec["nodes"] if band_of_group(n, gid, spec)]
646
+ outer = max_depth - d + 1 # how many group borders sit inside this one, plus one
647
+ m0 = min(n["m"] for n in mem) - pad * outer - (0 if lr else head * outer)
648
+ m1 = max(n["m"] + ax.m_size(n) for n in mem) + pad * outer
649
+ c0 = band_start[s] - sum(1 for x in opening(s) if x >= d) * (head + 6 if lr else pad)
650
+ c1 = band_start[e] + band_size[e] + pad * sum(1 for x in closing(e) if x >= d)
651
+ boxes.append({"id": gid, "m0": m0, "m1": m1, "c0": c0, "c1": c1, "depth": d})
652
+ return {"groups": boxes, "corridors": corridors, "gaps": gap_mid, "extent": (m, c)}
653
+
654
+
655
+ def band_of_group(n, gid, spec):
656
+ groups = {g["id"]: g for g in spec["groups"]}
657
+ g = n.get("group")
658
+ while g:
659
+ if g == gid:
660
+ return True
661
+ g = groups[g].get("parent") if g in groups else None
662
+ return False
663
+
664
+
665
+ def route(spec, geo, pl):
666
+ """Orthogonal routes that never cross a node: lanes run in the gaps between layers and in
667
+ the corridors between bands, which hold no nodes by construction."""
668
+ ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
669
+ lanes_v = {} # gap key -> list of (lo, hi, edge id)
670
+ lanes_h = {}
671
+ plans = []
672
+ side_use = {}
673
+
674
+ def box(n):
675
+ return n["m"], n["c"], n["m"] + ax.m_size(n), n["c"] + ax.c_size(n)
676
+
677
+ for e in edges:
678
+ s, t = by_id[e["from"]], by_id[e["to"]]
679
+ ls, lt = layer[s["id"]], layer[t["id"]]
680
+ sm0, sc0, sm1, sc1 = box(s)
681
+ tm0, tc0, tm1, tc1 = box(t)
682
+ scen, tcen = (sc0 + sc1) / 2.0, (tc0 + tc1) / 2.0
683
+ if s["id"] == t["id"]:
684
+ plans.append({"e": e, "kind": "self"})
685
+ continue
686
+ if lt == ls + 1:
687
+ plans.append({"e": e, "kind": "adjacent", "gap": ("v", lt), "out": "end", "in": "start"})
688
+ elif lt > ls + 1:
689
+ plans.append({"e": e, "kind": "multi", "out": "end", "in": "start"})
690
+ elif lt == ls:
691
+ plans.append({"e": e, "kind": "same", "gap": ("v", ls + 1), "out": "end", "in": "end"})
692
+ else:
693
+ plans.append({"e": e, "kind": "corridor", "g1": ("v", ls + 1), "g2": ("v", lt), "out": "end", "in": "start"})
694
+ # ports: spread the edges that share a node side, ordered by the far end
695
+ for p in plans:
696
+ if p["kind"] == "self":
697
+ continue
698
+ e = p["e"]
699
+ side_use.setdefault((e["from"], p["out"]), []).append((p, "s"))
700
+ side_use.setdefault((e["to"], p["in"]), []).append((p, "t"))
701
+ for (nid, side), lst in side_use.items():
702
+ n = by_id[nid]
703
+ m0, c0, m1, c1 = box(n)
704
+
705
+ def far(item):
706
+ p, which = item
707
+ other = by_id[p["e"]["to"] if which == "s" else p["e"]["from"]]
708
+ return other["c"] + ax.c_size(other) / 2.0
709
+ lst.sort(key=far)
710
+ k = len(lst)
711
+ for i, (p, which) in enumerate(lst):
712
+ field = p["e"].get("from_field" if which == "s" else "to_field")
713
+ fidx = next((j for j, f in enumerate(n["fields"][:24]) if f.get("name") == field), None) if field else None
714
+ if fidx is not None and n["shape"] == "table":
715
+ cpos = c0 + 32 + 20 * fidx + 10
716
+ else:
717
+ f = 0.36 if n["shape"] in ("diamond", "initial", "final") else 0.22
718
+ lo, hi = (c0 + (c1 - c0) * f, c1 - (c1 - c0) * f) if k > 1 else ((c0 + c1) / 2.0,) * 2
719
+ cpos = lo + (hi - lo) * (i / (k - 1.0) if k > 1 else 0.5)
720
+ mpos = m1 if side == "end" else m0
721
+ half_c, mid_c = (c1 - c0) / 2.0, (c0 + c1) / 2.0
722
+ off = min(abs(cpos - mid_c), half_c)
723
+ if n["shape"] == "diamond":
724
+ inset = off / half_c * ((m1 - m0) / 2.0) if half_c else 0
725
+ elif n["shape"] == "terminal" and ax.lr:
726
+ inset = half_c - (half_c ** 2 - off ** 2) ** 0.5
727
+ elif n["shape"] in ("initial", "final"):
728
+ inset = half_c - (max(half_c ** 2 - off ** 2, 0)) ** 0.5
729
+ else:
730
+ inset = 0
731
+ mpos += -inset if side == "end" else inset
732
+ p["sp" if which == "s" else "tp"] = (mpos, cpos)
733
+ def clear(cv, l0, l1):
734
+ for l in range(l0, l1):
735
+ for bi in range(len(bands)):
736
+ for u in cells.get((bi, l), []):
737
+ _, c0, _, c1 = box(by_id[u])
738
+ if c0 - 8 <= cv <= c1 + 8:
739
+ return False
740
+ return True
741
+ for p in plans: # a long forward edge goes straight when nothing sits at its port's height
742
+ if p["kind"] != "multi":
743
+ continue
744
+ ls, lt = layer[p["e"]["from"]], layer[p["e"]["to"]]
745
+ if clear(p["sp"][1], ls + 1, lt):
746
+ p.update(kind="straight", gap=("v", lt))
747
+ elif clear(p["tp"][1], ls + 1, lt):
748
+ p.update(kind="straight", gap=("v", ls + 1))
749
+ else:
750
+ p.update(kind="corridor", g1=("v", ls + 1), g2=("v", lt))
751
+ for p in plans:
752
+ if p["kind"] == "self":
753
+ continue
754
+ sp, tp = p["sp"], p["tp"]
755
+ if p["kind"] in ("adjacent", "same"):
756
+ lanes_v.setdefault(p["gap"], []).append([min(sp[1], tp[1]), max(sp[1], tp[1]), p])
757
+ elif p["kind"] == "straight":
758
+ lanes_v.setdefault(p["gap"], []).append([min(sp[1], tp[1]), max(sp[1], tp[1]), p])
759
+ else:
760
+ cv = (sp[1] + tp[1]) / 2.0
761
+ corr = min(range(len(pl["corridors"])), key=lambda i: (abs(pl["corridors"][i] - sp[1]) + abs(pl["corridors"][i] - tp[1]),
762
+ abs(pl["corridors"][i] - cv)))
763
+ p["corr"] = corr
764
+ cy = pl["corridors"][corr]
765
+ lanes_v.setdefault(p["g1"], []).append([min(sp[1], cy), max(sp[1], cy), p, "g1"])
766
+ lanes_v.setdefault(p["g2"], []).append([min(tp[1], cy), max(tp[1], cy), p, "g2"])
767
+ g1m = p["g1"][1]
768
+ g2m = p["g2"][1]
769
+ lanes_h.setdefault(corr, []).append([min(g1m, g2m), max(g1m, g2m), p])
770
+ need_v, need_h = {}, {}
771
+ for key, segs in lanes_v.items():
772
+ segs.sort(key=lambda s: (s[0], s[1]))
773
+ ends = []
774
+ for sg in segs:
775
+ for i, end in enumerate(ends):
776
+ if end < sg[0] - 10:
777
+ ends[i] = sg[1]
778
+ sg.append(i)
779
+ break
780
+ else:
781
+ ends.append(sg[1])
782
+ sg.append(len(ends) - 1)
783
+ need_v[key] = len(ends)
784
+ for key, segs in lanes_h.items():
785
+ segs.sort(key=lambda s: (s[0], s[1]))
786
+ ends = []
787
+ for sg in segs:
788
+ for i, end in enumerate(ends):
789
+ if end < sg[0] - 0.05:
790
+ ends[i] = sg[1]
791
+ sg.append(i)
792
+ break
793
+ else:
794
+ ends.append(sg[1])
795
+ sg.append(len(ends) - 1)
796
+ need_h[key] = len(ends)
797
+ return plans, lanes_v, lanes_h, need_v, need_h
798
+
799
+
800
+ def lane_pos(center, idx, count, step=9):
801
+ return center + (idx - (count - 1) / 2.0) * step
802
+
803
+
804
+ def build_paths(spec, geo, pl, plans, lanes_v, lanes_h, need_v, need_h):
805
+ ax = geo[0]
806
+ by_id = geo[1]
807
+ vlane, hlane = {}, {}
808
+ for key, segs in lanes_v.items():
809
+ for sg in segs:
810
+ tag = sg[3] if len(sg) == 5 else "v"
811
+ vlane[(id(sg[2]), tag)] = (key, sg[-1])
812
+ for key, segs in lanes_h.items():
813
+ for sg in segs:
814
+ hlane[id(sg[2])] = (key, sg[-1])
815
+
816
+ def vx(p, tag):
817
+ key, idx = vlane[(id(p), tag)]
818
+ return lane_pos(pl["gaps"][key[1]], idx, need_v[key])
819
+ for p in plans:
820
+ e = p["e"]
821
+ if p["kind"] == "self":
822
+ n = by_id[e["from"]]
823
+ m1, c0 = n["m"] + ax.m_size(n), n["c"]
824
+ mid = n["m"] + ax.m_size(n) * 0.7
825
+ pts = [(m1, c0 + ax.c_size(n) * 0.3), (m1 + 22, c0 + ax.c_size(n) * 0.3), (m1 + 22, c0 - 20), (mid, c0 - 20), (mid, c0)]
826
+ else:
827
+ sp, tp = p["sp"], p["tp"]
828
+ if p["kind"] == "adjacent":
829
+ x = vx(p, "v")
830
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
831
+ elif p["kind"] == "same":
832
+ x = vx(p, "v")
833
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
834
+ elif p["kind"] == "straight":
835
+ x = vx(p, "v")
836
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
837
+ else:
838
+ key, idx = hlane[id(p)]
839
+ cy = lane_pos(pl["corridors"][key], idx, need_h[key])
840
+ x1, x2 = vx(p, "g1"), vx(p, "g2")
841
+ pts = [sp, (x1, sp[1]), (x1, cy), (x2, cy), (x2, tp[1]), tp]
842
+ clean = [pts[0]]
843
+ for q in pts[1:]:
844
+ if abs(q[0] - clean[-1][0]) > 0.5 or abs(q[1] - clean[-1][1]) > 0.5:
845
+ clean.append(q)
846
+ e["pts"] = [ax.xy(m, c) for m, c in clean]
847
+
848
+
849
+ def layout_graph_full(spec):
850
+ geo = layout_graph(spec)
851
+ ax, n_layers, n_bands = geo[0], geo[8], len(geo[5])
852
+ gap_m = [24] + [72] * (n_layers - 1) + [24]
853
+ gap_c = [30] * (n_bands + 1)
854
+ for _ in range(4):
855
+ pl = place(spec, geo, gap_m, gap_c)
856
+ plans, lv, lh, need_v, need_h = route(spec, geo, pl)
857
+ base = lambda l: 24 if l in (0, n_layers) else 72
858
+ new_m = [max(base(l), 30 + 9 * need_v.get(("v", l), 0)) for l in range(n_layers + 1)]
859
+ new_c = [max(30, 20 + 9 * need_h.get(i, 0)) for i in range(n_bands + 1)]
860
+ if new_m == gap_m and new_c == gap_c:
861
+ break
862
+ gap_m, gap_c = new_m, new_c
863
+ build_paths(spec, geo, pl, plans, lv, lh, need_v, need_h)
864
+ for n in spec["nodes"]:
865
+ n["x"], n["y"] = ax.xy(n["m"], n["c"])
866
+ for g in pl["groups"]:
867
+ x0, y0 = ax.xy(g["m0"], g["c0"])
868
+ x1, y1 = ax.xy(g["m1"], g["c1"])
869
+ g.update({"x": min(x0, x1), "y": min(y0, y1), "w": abs(x1 - x0), "h": abs(y1 - y0)})
870
+ boxes = [(n["x"], n["y"], n["x"] + n["w"], n["y"] + n["h"] + (30 if n["shape"] in ("initial", "final") else 0)) for n in spec["nodes"]]
871
+ boxes += [(p[0], p[1], p[0], p[1]) for e in spec["edges"] for p in e.get("pts", [])]
872
+ boxes += [(g["x"], g["y"], g["x"] + g["w"], g["y"] + g["h"]) for g in pl["groups"]]
873
+ minx, miny = min(b[0] for b in boxes), min(b[1] for b in boxes)
874
+ dx, dy = 30 - minx, 30 - miny
875
+ for n in spec["nodes"]:
876
+ n["x"] += dx
877
+ n["y"] += dy
878
+ for e in spec["edges"]:
879
+ e["pts"] = [(x + dx, y + dy) for x, y in e.get("pts", [])]
880
+ for g in pl["groups"]:
881
+ g["x"] += dx
882
+ g["y"] += dy
883
+ W = max(b[2] for b in boxes) + dx + 40
884
+ H = max(b[3] for b in boxes) + dy + 40
885
+ return {"w": W, "h": H, "groups": pl["groups"]}
886
+
887
+
888
+ # --------------------------------------------------------------------------- sequence layout
889
+
890
+ def layout_sequence(spec):
891
+ parts = spec["nodes"]
892
+ idx = {p["id"]: i for i, p in enumerate(parts)}
893
+ for p in parts:
894
+ p["lines"] = wrap(p["label"], 150, 13, 2)
895
+ p["note_lines"] = wrap(str(p.get("note") or ""), 150, 11, 1) if p.get("note") else []
896
+ p["w"] = int(max(max(text_w(l, 13) for l in p["lines"] + [""]) + 30, 110))
897
+ p["shape"] = "person" if p["kind"] in ("person", "actor") else "rect"
898
+ p["h"] = 22 + 17 * len(p["lines"]) + 14 * len(p["note_lines"]) + (24 if p["shape"] == "person" else 0)
899
+ msgs = [m for m in spec["edges"] if m.get("from") in idx and m.get("to") in idx]
900
+ need = [max(p["w"], 120) for p in parts]
901
+ spacing = [0.0] * max(len(parts) - 1, 0)
902
+ for m in msgs:
903
+ a, b = sorted((idx[m["from"]], idx[m["to"]]))
904
+ lw = text_w(m["label"], 12) + 40
905
+ if a == b:
906
+ if a < len(spacing):
907
+ spacing[a] = max(spacing[a], lw + 40)
908
+ continue
909
+ per = lw / float(b - a)
910
+ for k in range(a, b):
911
+ spacing[k] = max(spacing[k], per)
912
+ xs, x = [], 40.0
913
+ for i, p in enumerate(parts):
914
+ xs.append(x + p["w"] / 2.0)
915
+ if i < len(spacing):
916
+ x += max(p["w"] / 2.0 + parts[i + 1]["w"] / 2.0 + 40, spacing[i], 170)
917
+ head_h = max(p["h"] for p in parts) + 30
918
+ group_pad = 30 if spec["groups"] else 0
919
+ top = 30 + group_pad
920
+ for i, p in enumerate(parts):
921
+ p["cx"] = xs[i]
922
+ p["x"], p["y"] = xs[i] - p["w"] / 2.0, top
923
+ y = top + head_h + 20
924
+ frame_open = {}
925
+ for f in spec["frames"]:
926
+ frame_open.setdefault(f.get("from"), []).append(f)
927
+ for m in msgs:
928
+ for f in frame_open.get(m["id"], []):
929
+ y += 26
930
+ f["y0"] = y - 22
931
+ m["lines"] = wrap(m["label"], max(abs(xs[idx[m["to"]]] - xs[idx[m["from"]]]) - 20, 150), 12, 2)
932
+ y += 14 * len(m["lines"])
933
+ x1, x2 = xs[idx[m["from"]]], xs[idx[m["to"]]]
934
+ if x1 == x2:
935
+ m["pts"] = [(x1, y), (x1 + 40, y), (x1 + 40, y + 18), (x1, y + 18)]
936
+ m["label_at"] = (x1 + 46, y - 4 - 14 * (len(m["lines"]) - 1), "start")
937
+ y += 18
938
+ else:
939
+ m["pts"] = [(x1, y), (x2, y)]
940
+ m["label_at"] = ((x1 + x2) / 2.0, y - 6 - 14 * (len(m["lines"]) - 1), "middle")
941
+ m["row_y"] = y
942
+ y += 26
943
+ for f in spec["frames"]:
944
+ if f.get("to") == m["id"]:
945
+ f["y1"] = y - 6
946
+ y += 10
947
+ for f in spec["frames"]:
948
+ inv = [m for m in msgs if f.get("y0", 0) <= m.get("row_y", -1) <= f.get("y1", 0)]
949
+ cols = [xs[idx[m["from"]]] for m in inv] + [xs[idx[m["to"]]] for m in inv] or xs[:1]
950
+ f["x0"], f["x1"] = min(cols) - 60, max(cols) + 60
951
+ end_y = y + 10
952
+ boxes = []
953
+ for g in spec["groups"]:
954
+ mem = [p for p in parts if p.get("group") == g["id"]]
955
+ if mem:
956
+ boxes.append({"id": g["id"], "x": min(p["x"] for p in mem) - 14, "y": top - 26,
957
+ "w": max(p["x"] + p["w"] for p in mem) - min(p["x"] for p in mem) + 28, "h": end_y - top + 34, "depth": 1})
958
+ W = max(p["x"] + p["w"] for p in parts) + 60
959
+ W = max([W] + [f["x1"] + 20 for f in spec["frames"]] + [m["pts"][1][0] + 60 + text_w(m["label"], 12) for m in msgs if m["from"] == m["to"]])
960
+ return {"w": W, "h": end_y + 40, "groups": boxes, "lifeline": (top + head_h - 30, end_y)}
961
+
962
+
963
+ # --------------------------------------------------------------------------- SVG
964
+
965
+ def esc(s):
966
+ return html.escape(str(s), quote=True)
967
+
968
+
969
+ def rounded_path(pts, r=7):
970
+ if len(pts) < 2:
971
+ return ""
972
+ d = ["M%.1f,%.1f" % pts[0]]
973
+ for i in range(1, len(pts) - 1):
974
+ (x0, y0), (x1, y1), (x2, y2) = pts[i - 1], pts[i], pts[i + 1]
975
+ l1 = max(abs(x1 - x0), abs(y1 - y0))
976
+ l2 = max(abs(x2 - x1), abs(y2 - y1))
977
+ rr = min(r, l1 / 2.0, l2 / 2.0)
978
+ ax_ = x1 - rr * ((x1 > x0) - (x1 < x0)) if abs(x1 - x0) > 0.1 else x1
979
+ ay_ = y1 - rr * ((y1 > y0) - (y1 < y0)) if abs(y1 - y0) > 0.1 else y1
980
+ bx_ = x1 + rr * ((x2 > x1) - (x2 < x1)) if abs(x2 - x1) > 0.1 else x1
981
+ by_ = y1 + rr * ((y2 > y1) - (y2 < y1)) if abs(y2 - y1) > 0.1 else y1
982
+ d.append("L%.1f,%.1f Q%.1f,%.1f %.1f,%.1f" % (ax_, ay_, x1, y1, bx_, by_))
983
+ d.append("L%.1f,%.1f" % pts[-1])
984
+ return " ".join(d)
985
+
986
+
987
+ def node_shape(n):
988
+ x, y, w, h = n["x"], n["y"], n["w"], n["h"]
989
+ s = n["shape"]
990
+ if s == "cylinder":
991
+ ry = 7
992
+ return ('<path class="shape" d="M%.1f,%.1f a%.1f,%d 0 0,1 %.1f,0 v%.1f a%.1f,%d 0 0,1 %.1f,0 z"/>'
993
+ '<path class="shape-line" d="M%.1f,%.1f a%.1f,%d 0 0,0 %.1f,0"/>') % (
994
+ x, y + ry, w / 2, ry, w, h - 2 * ry, w / 2, ry, -w, x, y + ry, w / 2, ry, w)
995
+ if s == "diamond":
996
+ return '<path class="shape" d="M%.1f,%.1f L%.1f,%.1f L%.1f,%.1f L%.1f,%.1f Z"/>' % (
997
+ x + w / 2, y, x + w, y + h / 2, x + w / 2, y + h, x, y + h / 2)
998
+ if s == "terminal":
999
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%.1f"/>' % (x, y, w, h, h / 2)
1000
+ if s == "round":
1001
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="14"/>' % (x, y, w, h)
1002
+ if s == "pipe":
1003
+ return ('<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1004
+ '<path class="shape-line" d="M%.1f,%.1f v%.1f M%.1f,%.1f v%.1f"/>') % (
1005
+ x, y, w, h, x + 10, y + 3, h - 6, x + w - 10, y + 3, h - 6)
1006
+ if s == "document":
1007
+ return '<path class="shape" d="M%.1f,%.1f h%.1f v%.1f q%.1f,10 %.1f,0 t%.1f,0 Z"/>' % (
1008
+ x, y, w, h - 8, -w / 4, -w / 2, -w / 2)
1009
+ if s == "person":
1010
+ cx = x + w / 2
1011
+ return ('<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1012
+ '<circle class="glyph" cx="%.1f" cy="%.1f" r="5"/><path class="glyph" d="M%.1f,%.1f a8,7 0 0,1 16,0 z"/>') % (
1013
+ x, y, w, h, cx, y + 11, cx - 8, y + 26)
1014
+ if s == "initial":
1015
+ return '<circle class="shape solid" cx="%.1f" cy="%.1f" r="%.1f"/>' % (x + w / 2, y + h / 2, w / 2 - 3)
1016
+ if s == "final":
1017
+ return ('<circle class="shape" cx="%.1f" cy="%.1f" r="%.1f"/><circle class="shape solid" cx="%.1f" cy="%.1f" r="%.1f"/>') % (
1018
+ x + w / 2, y + h / 2, w / 2 - 1, x + w / 2, y + h / 2, w / 2 - 7)
1019
+ rx = 6 if n["kind"] != "external" else 4
1020
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%d"/>' % (x, y, w, h, rx)
1021
+
1022
+
1023
+ def node_svg(n):
1024
+ fam = KINDS.get(n["kind"], ("rect", "slate"))[1]
1025
+ cls = ["node", "c-" + fam, "k-" + re.sub(r"[^a-z0-9-]", "", n["kind"])]
1026
+ if n["assumed"]:
1027
+ cls.append("assumed")
1028
+ if n.get("status"):
1029
+ cls.append("st-" + n["status"])
1030
+ label = esc(n["label"])
1031
+ out = ['<g class="%s" id="n-%s" data-id="%s" tabindex="0" role="button" aria-label="%s">' % (
1032
+ " ".join(cls), esc(n["id"]), esc(n["id"]), label + (" (assumption)" if n["assumed"] else ""))]
1033
+ out.append("<title>%s</title>" % (label + (" — " + esc(n.get("note")) if n.get("note") else "")))
1034
+ out.append(node_shape(n))
1035
+ x, y, w, h = n["x"], n["y"], n["w"], n["h"]
1036
+ if n["shape"] == "table":
1037
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="28" rx="6"/>' % (x, y, w))
1038
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="8"/>' % (x, y + 20, w))
1039
+ out.append('<text class="label" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + 19, label))
1040
+ keyw = max([text_w("/".join(k.upper() for k in (f["key"] if isinstance(f.get("key"), list) else [f["key"]])), 9) + 16
1041
+ for f in n["fields"][:24] if f.get("key")] or [0])
1042
+ for i, f in enumerate(n["fields"][:24]):
1043
+ fy = y + 32 + 20 * i + 14
1044
+ key = f.get("key")
1045
+ keys = key if isinstance(key, list) else ([key] if key else [])
1046
+ if keys:
1047
+ out.append('<text class="fkey" x="%.1f" y="%.1f">%s</text>' % (x + 8, fy, esc("/".join(k.upper() for k in keys))))
1048
+ out.append('<text class="fname" x="%.1f" y="%.1f">%s</text>' % (x + (keyw + 4 if keyw else 10), fy, esc(f.get("name", ""))))
1049
+ if f.get("type"):
1050
+ out.append('<text class="ftype" x="%.1f" y="%.1f" text-anchor="end">%s</text>' % (x + w - 8, fy, esc(f["type"])))
1051
+ if len(n["fields"]) > 24:
1052
+ out.append('<text class="ftype" x="%.1f" y="%.1f">+%d more (open for all)</text>' % (x + 10, y + 32 + 20 * 24 + 12, len(n["fields"]) - 24))
1053
+ elif n["shape"] in ("initial", "final"):
1054
+ for i, l in enumerate(n["lines"]):
1055
+ out.append('<text class="note" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + h + 14 + 13 * i, esc(l)))
1056
+ else:
1057
+ lines = n["lines"]
1058
+ notes = n.get("note_lines", [])
1059
+ top_pad = 26 if n["shape"] == "person" else (8 if n["shape"] == "cylinder" else 0)
1060
+ block = 17 * len(lines) + 15 * len(notes)
1061
+ ty = y + top_pad + (h - top_pad - block) / 2.0 + 13
1062
+ for l in lines:
1063
+ out.append('<text class="label" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, ty, esc(l)))
1064
+ ty += 17
1065
+ for l in notes:
1066
+ out.append('<text class="note" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, ty - 1, esc(l)))
1067
+ ty += 15
1068
+ if n["evidence"] and any(e.get("verified") for e in n["evidence"]):
1069
+ out.append('<text class="badge" x="%.1f" y="%.1f" text-anchor="end">✓ src</text>' % (x + w - 5, y - 4))
1070
+ elif n["assumed"]:
1071
+ out.append('<text class="badge warn" x="%.1f" y="%.1f" text-anchor="end">assumed</text>' % (x + w - 5, y - 4))
1072
+ out.append("</g>")
1073
+ return "".join(out)
1074
+
1075
+
1076
+ def place_labels(spec, occupied):
1077
+ """Put each edge label on a segment of its own route where it overlaps nothing placed."""
1078
+ def hit(r, rs):
1079
+ for o in rs:
1080
+ if r[0] < o[2] and r[2] > o[0] and r[1] < o[3] and r[3] > o[1]:
1081
+ return True
1082
+ return False
1083
+ segs_all = []
1084
+ for e in spec["edges"]:
1085
+ pts = e.get("pts", [])
1086
+ for i in range(len(pts) - 1):
1087
+ (x0, y0), (x1, y1) = pts[i], pts[i + 1]
1088
+ segs_all.append((e["id"], (min(x0, x1) - 2, min(y0, y1) - 2, max(x0, x1) + 2, max(y0, y1) + 2)))
1089
+ for e in spec["edges"]:
1090
+ if not e.get("label") or len(e.get("pts", [])) < 2:
1091
+ continue
1092
+ others = [r for (eid, r) in segs_all if eid != e["id"]]
1093
+ lines = wrap(e["label"], 150, 11, 2)
1094
+ w = max(text_w(l, 11) for l in lines) + 10
1095
+ h = 14 * len(lines) + 4
1096
+ pts = e["pts"]
1097
+ segs = sorted(range(len(pts) - 1), key=lambda i: -(abs(pts[i + 1][0] - pts[i][0]) + abs(pts[i + 1][1] - pts[i][1])))
1098
+ cands = []
1099
+ for i in segs:
1100
+ (x0, y0), (x1, y1) = pts[i], pts[i + 1]
1101
+ for f in (0.5, 0.3, 0.7, 0.15, 0.85):
1102
+ cands.append((x0 + (x1 - x0) * f, y0 + (y1 - y0) * f))
1103
+ best = None
1104
+ for cx, cy in cands:
1105
+ r = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1106
+ if not hit(r, occupied) and not hit(r, others):
1107
+ best = r
1108
+ break
1109
+ if best is None:
1110
+ for cx, cy in cands:
1111
+ r = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1112
+ if not hit(r, occupied):
1113
+ best = r
1114
+ break
1115
+ if best is None:
1116
+ cx, cy = cands[0]
1117
+ best = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1118
+ occupied.append(best)
1119
+ e["label_box"] = best
1120
+ e["label_lines"] = lines
1121
+
1122
+
1123
+ def edge_svg(e, seq=False):
1124
+ cls = ["edge", "s-" + e["style"]]
1125
+ if e["assumed"]:
1126
+ cls.append("assumed")
1127
+ if e.get("status"):
1128
+ cls.append("st-" + e["status"])
1129
+ if e.get("emphasis"):
1130
+ cls.append("emph")
1131
+ if seq and e.get("kind") == "return":
1132
+ cls.append("s-dashed")
1133
+ title = esc(e["label"] or "%s → %s" % (e["from"], e["to"]))
1134
+ out = ['<g class="%s" id="e-%s" data-id="%s" data-from="%s" data-to="%s">' % (
1135
+ " ".join(cls), esc(e["id"]), esc(e["id"]), esc(e["from"]), esc(e["to"]))]
1136
+ out.append("<title>%s</title>" % title)
1137
+ d = rounded_path(e["pts"]) if not seq else "M" + " L".join("%.1f,%.1f" % p for p in e["pts"])
1138
+ markers = ' marker-end="url(#arrow)"' + (' marker-start="url(#arrow-start)"' if e.get("both") else "")
1139
+ out.append('<path class="hit" d="%s"/>' % d)
1140
+ out.append('<path class="line" d="%s"%s/>' % (d, markers))
1141
+ if seq and e.get("lines"):
1142
+ x, y, anchor = e["label_at"]
1143
+ for i, l in enumerate(e["lines"]):
1144
+ out.append('<text class="elabel" x="%.1f" y="%.1f" text-anchor="%s">%s</text>' % (x, y + 14 * i, anchor, esc(l)))
1145
+ elif e.get("label_box"):
1146
+ x0, y0, x1, y1 = e["label_box"]
1147
+ out.append('<rect class="lbg" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="3"/>' % (x0, y0, x1 - x0, y1 - y0))
1148
+ for i, l in enumerate(e["label_lines"]):
1149
+ out.append('<text class="elabel" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % ((x0 + x1) / 2, y0 + 13 + 14 * i, esc(l)))
1150
+ for key, at in (("from_card", 0), ("to_card", -1)):
1151
+ if e.get(key) and len(e["pts"]) >= 2:
1152
+ p, q = e["pts"][at], e["pts"][1 if at == 0 else -2]
1153
+ dx = 1 if q[0] > p[0] else -1
1154
+ out.append('<text class="card" x="%.1f" y="%.1f" text-anchor="%s">%s</text>' % (
1155
+ p[0] + (6 if at == 0 else 14) * dx, p[1] - 6, "start" if dx > 0 else "end", esc(e[key])))
1156
+ out.append("</g>")
1157
+ return "".join(out)
1158
+
1159
+
1160
+ def render_svg(spec, geo):
1161
+ W, H = geo["w"], geo["h"]
1162
+ seq = spec["type"] == "sequence"
1163
+ out = ['<svg id="map" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" role="group" aria-label="%s">' % (
1164
+ W, H, W, H, esc(spec["title"]))]
1165
+ out.append('<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
1166
+ '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker>'
1167
+ '<marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
1168
+ '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker></defs>')
1169
+ out.append('<rect class="canvas" x="0" y="0" width="%d" height="%d"/>' % (W, H))
1170
+ labels = {g["id"]: g for g in spec["groups"]}
1171
+ for g in sorted(geo["groups"], key=lambda g: g["depth"]):
1172
+ meta = labels.get(g["id"], {})
1173
+ cls = "group d%d" % min(g["depth"], 3) + (" st-" + meta["status"] if meta.get("status") else "")
1174
+ out.append('<g class="%s" data-group="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1175
+ '<text class="glabel" x="%.1f" y="%.1f">%s</text></g>' % (
1176
+ cls, esc(g["id"]), g["x"], g["y"], g["w"], g["h"], g["x"] + 12, g["y"] + 18, esc(meta.get("label", g["id"]))))
1177
+ if seq:
1178
+ y0, y1 = geo["lifeline"]
1179
+ for f in spec["frames"]:
1180
+ if "y0" in f and "y1" in f:
1181
+ lab = "%s %s" % (f.get("kind", "group"), f.get("label", ""))
1182
+ out.append('<g class="frame"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="4"/>'
1183
+ '<rect class="ftab" x="%.1f" y="%.1f" width="%.1f" height="18"/><text x="%.1f" y="%.1f">%s</text></g>' % (
1184
+ f["x0"], f["y0"], f["x1"] - f["x0"], f["y1"] - f["y0"], f["x0"], f["y0"], text_w(lab, 11) + 14,
1185
+ f["x0"] + 7, f["y0"] + 13, esc(lab.strip())))
1186
+ for p in spec["nodes"]:
1187
+ out.append('<line class="lifeline" data-for="%s" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>' % (
1188
+ esc(p["id"]), p["cx"], p["y"] + p["h"], p["cx"], y1))
1189
+ for i, m in enumerate(spec["edges"]):
1190
+ if m.get("pts"):
1191
+ out.append('<text class="seqno" x="%.1f" y="%.1f">%d</text>' % (14, m["pts"][0][1] + 4, i + 1))
1192
+ for e in spec["edges"]:
1193
+ if e.get("pts"):
1194
+ out.append(edge_svg(e, seq))
1195
+ for n in spec["nodes"]:
1196
+ out.append(node_svg(n))
1197
+ out.append("</svg>")
1198
+ return "".join(out)
1199
+
1200
+
1201
+ # --------------------------------------------------------------------------- Mermaid
1202
+
1203
+ def mm_text(s):
1204
+ return str(s).replace('"', "#quot;").replace("\n", " ")
1205
+
1206
+
1207
+ def mm_id(s):
1208
+ return re.sub(r"[^A-Za-z0-9_]", "_", s) or "n"
1209
+
1210
+
1211
+ def to_mermaid(spec):
1212
+ t = spec["type"]
1213
+ nodes = [n for n in spec["nodes"] if n.get("status") != "removed"]
1214
+ edges = [e for e in spec["edges"] if e.get("status") != "removed"]
1215
+ if t == "sequence":
1216
+ out = ["sequenceDiagram"]
1217
+ for p in nodes:
1218
+ out.append(' %s %s as %s' % ("actor" if p["kind"] in ("person", "actor") else "participant", mm_id(p["id"]), mm_text(p["label"]).replace(";", ",")))
1219
+ opens = {}
1220
+ closes = {}
1221
+ for f in spec["frames"]:
1222
+ opens.setdefault(f.get("from"), []).append(f)
1223
+ closes.setdefault(f.get("to"), []).append(f)
1224
+ for m in edges:
1225
+ for f in opens.get(m["id"], []):
1226
+ out.append(" %s %s" % (f.get("kind", "rect") if f.get("kind") in ("loop", "alt", "opt", "par", "critical", "break") else "opt",
1227
+ mm_text(f.get("label", ""))))
1228
+ arrow = "-->>" if m.get("kind") == "return" else ("-)" if m.get("kind") == "async" else "->>")
1229
+ out.append(" %s%s%s: %s" % (mm_id(m["from"]), arrow, mm_id(m["to"]), mm_text(m["label"]).replace(";", ",") or " "))
1230
+ for f in closes.get(m["id"], []):
1231
+ out.append(" end")
1232
+ return "\n".join(out) + "\n"
1233
+ if t == "lifecycle":
1234
+ out = ["stateDiagram-v2"]
1235
+ if spec["direction"] == "LR":
1236
+ out.append(" direction LR")
1237
+ for n in nodes:
1238
+ if n["shape"] not in ("initial", "final"):
1239
+ out.append(' state "%s" as %s' % (mm_text(n["label"]), mm_id(n["id"])))
1240
+ kinds = {n["id"]: n["shape"] for n in nodes}
1241
+ for e in edges:
1242
+ a = "[*]" if kinds.get(e["from"]) == "initial" else mm_id(e["from"])
1243
+ b = "[*]" if kinds.get(e["to"]) == "final" else mm_id(e["to"])
1244
+ out.append(" %s --> %s%s" % (a, b, (": " + mm_text(e["label"])) if e["label"] else ""))
1245
+ return "\n".join(out) + "\n"
1246
+ if t == "erd":
1247
+ out = ["erDiagram"]
1248
+ left = {"0..1": "|o", "1": "||", "1..1": "||", "1..*": "}|", "*": "}o", "0..*": "}o", "n": "}o", "many": "}o", "m": "}o"}
1249
+ right = {"0..1": "o|", "1": "||", "1..1": "||", "1..*": "|{", "*": "o{", "0..*": "o{", "n": "o{", "many": "o{", "m": "o{"}
1250
+ name = {n["id"]: mm_id(n["label"]) for n in nodes}
1251
+ for e in edges:
1252
+ l = left.get(str(e.get("from_card", "*")).lower(), "}o")
1253
+ r = right.get(str(e.get("to_card", "1")).lower(), "||")
1254
+ out.append(' %s %s--%s %s : "%s"' % (name[e["from"]], l, r, name[e["to"]], mm_text(e["label"] or "has")))
1255
+ for n in nodes:
1256
+ out.append(" %s {" % name[n["id"]])
1257
+ for f in n["fields"]:
1258
+ keys = f.get("key")
1259
+ keys = keys if isinstance(keys, list) else ([keys] if keys else [])
1260
+ ks = ",".join(k.upper() for k in keys if k.lower() in ("pk", "fk", "uk"))
1261
+ out.append(" %s %s%s" % (mm_id(str(f.get("type") or "string")), mm_id(f.get("name", "field")), (" " + ks) if ks else ""))
1262
+ out.append(" }")
1263
+ return "\n".join(out) + "\n"
1264
+ out = ["flowchart " + spec["direction"]]
1265
+ shapes = {"cylinder": ('[("', '")]'), "diamond": ('{"', '"}'), "terminal": ('(["', '"])'), "round": ('("', '")'),
1266
+ "pipe": ('[["', '"]]'), "document": ('[/"', '"/]'), "person": ('(["', '"])'), "initial": ('(("', '"))'),
1267
+ "final": ('((("', '")))'), "table": ('["', '"]')}
1268
+
1269
+ def decl(n, indent):
1270
+ a, b = shapes.get(n["shape"], ('["', '"]'))
1271
+ return "%s%s%s%s%s" % (indent, mm_id(n["id"]), a, mm_text(n["label"]), b)
1272
+ groups, kids, top = group_tree(spec)
1273
+ members = {}
1274
+ for n in nodes:
1275
+ members.setdefault(n.get("group"), []).append(n)
1276
+
1277
+ def emit(gid, depth):
1278
+ ind = " " * depth
1279
+ out.append('%ssubgraph %s["%s"]' % (ind, mm_id(gid), mm_text(groups[gid]["label"])))
1280
+ for n in members.get(gid, []):
1281
+ out.append(decl(n, ind + " "))
1282
+ for k in kids[gid]:
1283
+ emit(k, depth + 1)
1284
+ out.append(ind + "end")
1285
+ for n in members.get(None, []):
1286
+ out.append(decl(n, " "))
1287
+ for g in top:
1288
+ emit(g, 1)
1289
+ for e in edges:
1290
+ arrow = "-.->" if e["style"] != "solid" or e["assumed"] else ("==>" if e.get("emphasis") else "-->")
1291
+ if e.get("both"):
1292
+ arrow = "<" + arrow
1293
+ lab = '|"%s"|' % mm_text(e["label"]) if e["label"] else ""
1294
+ out.append(" %s %s%s %s" % (mm_id(e["from"]), arrow, lab, mm_id(e["to"])))
1295
+ assumed = [mm_id(n["id"]) for n in nodes if n["assumed"]]
1296
+ if assumed:
1297
+ out.append(" classDef assumed stroke-dasharray: 5 4")
1298
+ out.append(" class %s assumed" % ",".join(assumed))
1299
+ return "\n".join(out) + "\n"
1300
+
1301
+
1302
+ # --------------------------------------------------------------------------- text outline
1303
+
1304
+ def outline_html(spec):
1305
+ """The map as text: what a screen reader, a search engine, a judge or a reader without
1306
+ JavaScript gets, and what can be pasted into a doc."""
1307
+ t = spec["type"]
1308
+ by_id = {n["id"]: n for n in spec["nodes"]}
1309
+ lab = lambda i: by_id[i]["label"] if i in by_id else i
1310
+ out = []
1311
+ groups = {g["id"]: g for g in spec["groups"]}
1312
+
1313
+ def status(x):
1314
+ s = x.get("status")
1315
+ return ' <span class="tag st-%s">%s</span>' % (s, s) if s else ""
1316
+
1317
+ def assumed(x):
1318
+ return ' <span class="tag warn">assumption</span>' if x.get("assumed") else ""
1319
+
1320
+ def evs(x):
1321
+ if not x["evidence"]:
1322
+ return ""
1323
+ parts = []
1324
+ for e in x["evidence"]:
1325
+ mark = " ✓" if e.get("verified") else ""
1326
+ parts.append("<code>%s</code>%s" % (esc(ev_text(e)), mark))
1327
+ return ' <span class="ev">(%s)</span>' % ", ".join(parts)
1328
+ noun = "Participants" if t == "sequence" else ("Tables" if t == "erd" else ("States" if t == "lifecycle" else "Parts"))
1329
+ out.append("<h2>%s</h2><ul>" % noun)
1330
+ for n in spec["nodes"]:
1331
+ g = groups.get(n.get("group"))
1332
+ extra = " — %s" % esc(n["note"]) if n.get("note") else ""
1333
+ ing = " <span class=\"muted\">in %s</span>" % esc(g["label"]) if g else ""
1334
+ fields = ""
1335
+ if n["fields"]:
1336
+ fields = "<ul>" + "".join("<li><code>%s</code> %s%s</li>" % (
1337
+ esc(f.get("name", "")), esc(f.get("type", "")),
1338
+ (" <b>%s</b>" % esc("/".join(f["key"]) if isinstance(f.get("key"), list) else f["key"]).upper()) if f.get("key") else "")
1339
+ for f in n["fields"]) + "</ul>"
1340
+ out.append("<li><b>%s</b>%s%s%s%s%s%s</li>" % (esc(n["label"]), extra, ing, assumed(n), status(n), evs(n), fields))
1341
+ out.append("</ul>")
1342
+ noun = "Messages, in order" if t == "sequence" else ("Relationships" if t == "erd" else "Connections")
1343
+ out.append("<h2>%s</h2><ol>" % noun if t == "sequence" else "<h2>%s</h2><ul>" % noun)
1344
+ for e in spec["edges"]:
1345
+ card = ""
1346
+ if e.get("from_card") or e.get("to_card"):
1347
+ card = " <span class=\"muted\">(%s to %s)</span>" % (esc(e.get("from_card", "?")), esc(e.get("to_card", "?")))
1348
+ out.append("<li>%s → %s%s%s%s%s%s</li>" % (esc(lab(e["from"])), esc(lab(e["to"])),
1349
+ (": " + esc(e["label"])) if e["label"] else "", card, assumed(e), status(e), evs(e)))
1350
+ out.append("</ol>" if t == "sequence" else "</ul>")
1351
+ if spec["story"]:
1352
+ out.append("<h2>Walkthrough</h2><ol>" + "".join("<li>%s</li>" % esc(s.get("text", "")) for s in spec["story"]) + "</ol>")
1353
+ return "".join(out)
1354
+
1355
+
1356
+ # --------------------------------------------------------------------------- HTML
1357
+
1358
+ CSS = r"""
1359
+ :root{--bg:#f7f8fa;--panel:#ffffff;--ink:#16181d;--muted:#5b6170;--line:#d5d9e0;--edge:#6b7385;--accent:#2563eb;
1360
+ --warn:#b45309;--ok:#15803d;--bad:#b91c1c;--hl:#f59e0b;--canvas:#fbfcfd;--group:#eef1f6;--group2:#e6eaf2;--shadow:0 1px 2px rgba(0,0,0,.06)}
1361
+ :root[data-theme=dark]{--bg:#0f1115;--panel:#171a21;--ink:#e7e9ee;--muted:#9aa1b2;--line:#2b303b;--edge:#8b93a7;--accent:#60a5fa;
1362
+ --warn:#f59e0b;--ok:#4ade80;--bad:#f87171;--hl:#fbbf24;--canvas:#12151b;--group:#1a1e27;--group2:#20252f;--shadow:none}
1363
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
1364
+ body{display:flex;flex-direction:column}body>*{flex:none}section.text{order:10}
1365
+ header.top{padding:18px 24px 10px;max-width:1400px}header.top h1{margin:0 0 4px;font-size:21px}
1366
+ .q{color:var(--muted);margin:0 0 6px}.sum{margin:0;max-width:900px}
1367
+ .bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:8px 24px;position:sticky;top:0;background:var(--bg);z-index:5;border-bottom:1px solid var(--line)}
1368
+ .bar button,.bar input{font:inherit;font-size:13px;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:4px 9px;cursor:pointer}
1369
+ .bar input{cursor:text;width:180px}.bar button:hover{border-color:var(--accent)}.bar .sep{width:1px;height:20px;background:var(--line);margin:0 4px}
1370
+ .bar .hint{color:var(--muted);font-size:12px;margin-left:auto}
1371
+ .wrap{display:flex;gap:0;height:calc(100vh - 150px);min-height:420px;padding:10px 24px 0}
1372
+ #stage{flex:1;position:relative;overflow:hidden;border:1px solid var(--line);border-radius:10px;background:var(--canvas);touch-action:none;cursor:grab}
1373
+ #stage.drag{cursor:grabbing}#stage svg{position:absolute;left:0;top:0;width:100%;height:100%}
1374
+ aside#panel{width:340px;margin-left:12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:auto;padding:14px 16px;display:none}
1375
+ aside#panel.open{display:block}aside h3{margin:0 0 2px;font-size:16px}aside .kind{color:var(--muted);font-size:12px;margin-bottom:8px}
1376
+ aside .close{float:right;border:0;background:none;color:var(--muted);font-size:18px;cursor:pointer}
1377
+ aside pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:260px}
1378
+ aside ul{padding-left:18px;margin:4px 0}aside a.jump{color:var(--accent);cursor:pointer;text-decoration:none}
1379
+ aside .acts button{font:inherit;font-size:12px;margin:2px 4px 2px 0;border:1px solid var(--line);background:var(--bg);color:var(--ink);border-radius:5px;padding:3px 7px;cursor:pointer}
1380
+ .caption{display:none;margin:8px 24px 0;padding:8px 12px;border-left:3px solid var(--hl);background:var(--panel);border-radius:4px}
1381
+ .caption.on{display:block}
1382
+ section.text{max-width:1100px;padding:10px 24px 40px}section.text h2{font-size:15px;margin:18px 0 4px}
1383
+ .tag{font-size:11px;padding:0 5px;border-radius:4px;border:1px solid var(--line);margin-left:4px}.tag.warn{color:var(--warn);border-color:var(--warn)}
1384
+ .tag.st-added{color:var(--ok);border-color:var(--ok)}.tag.st-removed{color:var(--bad);border-color:var(--bad)}.tag.st-changed{color:var(--accent);border-color:var(--accent)}
1385
+ .muted{color:var(--muted)}.ev{color:var(--muted);font-size:12px}code{font-size:12px}
1386
+ .legend{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--muted);padding:6px 24px 0}
1387
+ .legend i{display:inline-block;width:22px;height:0;border-top:2px solid var(--edge);vertical-align:middle;margin-right:5px}
1388
+ .legend i.d{border-top-style:dashed}.legend i.a{border-top:2px dashed var(--warn)}
1389
+ .issues{margin:8px 24px 0;padding:8px 12px;border:1px solid var(--warn);border-radius:6px;color:var(--warn);font-size:13px}
1390
+ /* diagram */
1391
+ .canvas{fill:var(--canvas)}
1392
+ .group rect{fill:var(--group);stroke:var(--line);stroke-width:1}.group.d2 rect{fill:var(--group2)}.group.d3 rect{fill:var(--group)}
1393
+ .glabel{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.02em}
1394
+ .group.st-removed rect{stroke:var(--bad);stroke-dasharray:6 4;fill:none}
1395
+ .node{cursor:pointer;outline:none}.node .shape{stroke-width:1.5;fill:var(--panel)}.node .shape.solid{fill:var(--ink);stroke:var(--ink)}
1396
+ .node .shape-line{fill:none;stroke-width:1.2}.node .glyph{fill:var(--muted)}
1397
+ .node .label{font-size:13px;font-weight:600;fill:var(--ink)}.node .note{font-size:11px;fill:var(--muted)}
1398
+ .node .badge{font-size:10px;fill:var(--ok)}.node .badge.warn{fill:var(--warn)}
1399
+ .table-head{fill:var(--group2)}.fname{font-size:12px;fill:var(--ink)}.ftype{font-size:11px;fill:var(--muted)}.fkey{font-size:9px;font-weight:700;fill:var(--warn)}
1400
+ .c-blue .shape,.c-blue .shape-line{stroke:#3b82f6}.c-violet .shape{stroke:#8b5cf6}.c-green .shape,.c-green .shape-line{stroke:#16a34a}
1401
+ .c-teal .shape,.c-teal .shape-line{stroke:#0d9488}.c-amber .shape,.c-amber .shape-line{stroke:#d97706}.c-slate .shape{stroke:#64748b}
1402
+ .c-rose .shape{stroke:#e11d48}.c-indigo .shape{stroke:#4f46e5}.k-external .shape{stroke-dasharray:5 3}
1403
+ .node.assumed .shape{stroke:var(--warn)!important;stroke-dasharray:6 4}
1404
+ .node.st-added .shape{stroke:var(--ok)!important;stroke-width:2.5}.node.st-changed .shape{stroke:var(--accent)!important;stroke-width:2.5}
1405
+ .node.st-removed{opacity:.55}.node.st-removed .shape{stroke:var(--bad)!important;stroke-dasharray:6 4}.node.st-removed .label{text-decoration:line-through}
1406
+ .edge .line{fill:none;stroke:var(--edge);stroke-width:1.5}.edge .hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}
1407
+ .edge.s-dashed .line{stroke-dasharray:7 5}.edge.s-dotted .line{stroke-dasharray:2 4}.edge.emph .line{stroke-width:2.6}
1408
+ .edge.assumed .line{stroke:var(--warn);stroke-dasharray:6 4}.edge.st-added .line{stroke:var(--ok);stroke-width:2.2}
1409
+ .edge.st-removed .line{stroke:var(--bad);stroke-dasharray:6 4;opacity:.7}
1410
+ .arrowhead{fill:var(--edge)}.lbg{fill:var(--canvas);opacity:.92}.elabel{font-size:11px;fill:var(--muted)}.card{font-size:11px;font-weight:700;fill:var(--muted)}
1411
+ .lifeline{stroke:var(--line);stroke-width:1.2;stroke-dasharray:4 4}.seqno{font-size:10px;fill:var(--muted)}
1412
+ .frame rect{fill:none;stroke:var(--muted);stroke-width:1}.frame .ftab{fill:var(--group2);stroke:var(--muted)}.frame text{font-size:11px;fill:var(--ink);font-weight:600}
1413
+ svg.focus .node,svg.focus .edge,svg.focus .group{opacity:.18;transition:opacity .15s}
1414
+ svg.focus .on,svg.focus .group.on{opacity:1}svg .node.on .shape{stroke-width:2.6}svg .edge.on .line{stroke:var(--hl);stroke-width:2.6}
1415
+ svg .node.sel .shape{stroke:var(--hl)!important;stroke-width:3}svg .node:focus-visible .shape{stroke:var(--accent)!important;stroke-width:3}
1416
+ @media (prefers-reduced-motion:no-preference){svg .edge.on.flow .line{stroke-dasharray:8 6;animation:flow 1s linear infinite}}
1417
+ @keyframes flow{to{stroke-dashoffset:-28}}
1418
+ @media print{.bar,aside,.caption{display:none!important}.wrap{height:auto}#stage{border:0}#stage svg{position:static;height:auto}}
1419
+ """
1420
+
1421
+ JS = r"""
1422
+ (function(){
1423
+ var DATA=JSON.parse(document.getElementById('diagram-data').textContent);
1424
+ var svg=document.getElementById('map'),stage=document.getElementById('stage'),panel=document.getElementById('panel');
1425
+ var root=document.documentElement;
1426
+ var pref=localStorage.getItem('idg-theme')||root.getAttribute('data-theme-default');
1427
+ if(pref==='auto'||!pref){pref=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}
1428
+ root.setAttribute('data-theme',pref);
1429
+ var W=+svg.getAttribute('width'),H=+svg.getAttribute('height');svg.removeAttribute('width');svg.removeAttribute('height');
1430
+ var vb={x:0,y:0,w:W,h:H};
1431
+ function apply(){svg.setAttribute('viewBox',vb.x+' '+vb.y+' '+vb.w+' '+vb.h)}
1432
+ function fit(){var r=stage.getBoundingClientRect();var s=Math.max(W/r.width,H/r.height,0.6);vb.w=r.width*s;vb.h=r.height*s;vb.x=(W-vb.w)/2;vb.y=(H-vb.h)/2;apply()}
1433
+ function zoom(f,cx,cy){var r=stage.getBoundingClientRect();if(cx==null){cx=r.width/2;cy=r.height/2}
1434
+ var px=vb.x+cx/r.width*vb.w,py=vb.y+cy/r.height*vb.h;var nw=Math.min(Math.max(vb.w*f,120),W*6);var k=nw/vb.w;
1435
+ vb.x=px-(px-vb.x)*k;vb.y=py-(py-vb.y)*k;vb.w=nw;vb.h=vb.h*k;apply()}
1436
+ stage.addEventListener('wheel',function(e){e.preventDefault();var r=stage.getBoundingClientRect();zoom(e.deltaY>0?1.12:1/1.12,e.clientX-r.left,e.clientY-r.top)},{passive:false});
1437
+ var drag=null;
1438
+ stage.addEventListener('pointerdown',function(e){if(e.target.closest('.node')||e.target.closest('.edge'))return;drag={x:e.clientX,y:e.clientY,vx:vb.x,vy:vb.y};stage.classList.add('drag');stage.setPointerCapture(e.pointerId)});
1439
+ stage.addEventListener('pointermove',function(e){if(!drag)return;var r=stage.getBoundingClientRect();vb.x=drag.vx-(e.clientX-drag.x)*vb.w/r.width;vb.y=drag.vy-(e.clientY-drag.y)*vb.h/r.height;apply()});
1440
+ stage.addEventListener('pointerup',function(){drag=null;stage.classList.remove('drag')});
1441
+ // Lookups are Maps, never plain objects: ids come from the spec and the URL, and a key such as
1442
+ // __proto__ must stay a key.
1443
+ var nodes=new Map(),edges=new Map(),outE=new Map(),inE=new Map();
1444
+ DATA.nodes.forEach(function(n){nodes.set(n.id,n);outE.set(n.id,[]);inE.set(n.id,[])});
1445
+ DATA.edges.forEach(function(e){edges.set(e.id,e);if(outE.has(e.from))outE.get(e.from).push(e.id);if(inE.has(e.to))inE.get(e.to).push(e.id)});
1446
+ function el(kind,id){return document.getElementById(kind+'-'+id)}
1447
+ function clear(){svg.classList.remove('focus');svg.querySelectorAll('.on,.sel,.flow').forEach(function(x){x.classList.remove('on','sel','flow')})}
1448
+ function light(ns,es,flow){clear();svg.classList.add('focus');ns.forEach(function(i){var x=el('n',i);x&&x.classList.add('on')});
1449
+ es.forEach(function(i){var x=el('e',i);if(x){x.classList.add('on');flow&&x.classList.add('flow')}})}
1450
+ function reach(id,dir){if(!nodes.has(id))return {n:[],e:[]};var seen=new Set([id]),es=[],q=[id];while(q.length){var u=q.shift();(dir>0?outE.get(u):inE.get(u)).forEach(function(eid){var e=edges.get(eid);es.push(eid);var v=dir>0?e.to:e.from;if(!seen.has(v)){seen.add(v);q.push(v)}})}
1451
+ return {n:Array.from(seen),e:es}}
1452
+ function path(a,b,directed){if(!nodes.has(a)||!nodes.has(b))return null;var prev=new Map([[a,null]]),q=[a];while(q.length){var u=q.shift();if(u===b)break;
1453
+ var nb=outE.get(u).map(function(i){return [edges.get(i).to,i]});if(!directed)nb=nb.concat(inE.get(u).map(function(i){return [edges.get(i).from,i]}));
1454
+ nb.forEach(function(p){if(!prev.has(p[0])){prev.set(p[0],[u,p[1]]);q.push(p[0])}})}
1455
+ if(!prev.has(b))return null;var ns=[b],es=[],c=b;while(prev.get(c)){es.push(prev.get(c)[1]);c=prev.get(c)[0];ns.push(c)}return {n:ns.reverse(),e:es.reverse()}}
1456
+ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
1457
+ function evHtml(list){if(!list||!list.length)return '<p class="muted">No evidence cited.</p>';
1458
+ return '<ul>'+list.map(function(e){var t=e.said!=null?(esc(e.source||'user')+': “'+esc(e.said)+'”'):esc(e.file+(e.start?':'+e.start+(e.end!==e.start?'-'+e.end:''):''));
1459
+ if(e.file&&DATA.source_url){t='<a href="'+esc(DATA.source_url.replace(/\/?$/,'/')+e.file+(e.start?'#L'+e.start+(e.end!==e.start?'-L'+e.end:''):''))+'" target="_blank" rel="noopener">'+t+'</a>'}
1460
+ var v=e.verified===true?' <span class="tag" style="color:var(--ok)">checked</span>':(e.verified===null&&e.file?' <span class="tag">not checked</span>':'');
1461
+ return '<li>'+t+v+(e.snippet?'<pre>'+esc(e.snippet)+'</pre>':'')+'</li>'}).join('')+'</ul>'}
1462
+ var selected=null;
1463
+ function show(kind,id){var x=kind==='n'?nodes.get(id):edges.get(id);if(!x)return;var h='<button class="close" aria-label="Close">×</button>';
1464
+ if(kind==='n'){h+='<h3>'+esc(x.label)+'</h3><div class="kind">'+esc(x.kind)+(x.group_label?' · '+esc(x.group_label):'')+(x.assumed?' · <span style="color:var(--warn)">assumption</span>':'')+(x.status?' · '+esc(x.status):'')+'</div>';
1465
+ if(x.note)h+='<p>'+esc(x.note)+'</p>';if(x.detail)h+='<p>'+esc(x.detail)+'</p>';
1466
+ if(x.fields&&x.fields.length)h+='<ul>'+x.fields.map(function(f){return '<li><code>'+esc(f.name)+'</code> '+esc(f.type||'')+(f.key?' <b>'+esc([].concat(f.key).join('/').toUpperCase())+'</b>':'')+(f.references?' → '+esc(f.references):'')+(f.note?' — '+esc(f.note):'')+'</li>'}).join('')+'</ul>';
1467
+ h+='<div class="acts"><button data-act="up">What it depends on</button><button data-act="down">What depends on it</button><button data-act="near">Neighbours</button></div>';
1468
+ h+='<p class="muted" style="font-size:12px">Shift-click another part to see the path between them.</p>';
1469
+ var o=outE.get(id).map(function(i){var e=edges.get(i);return '<li><a class="jump" data-n="'+esc(e.to)+'">'+esc(nodes.get(e.to).label)+'</a>'+(e.label?' — '+esc(e.label):'')+'</li>'}).join('');
1470
+ var n=inE.get(id).map(function(i){var e=edges.get(i);return '<li><a class="jump" data-n="'+esc(e.from)+'">'+esc(nodes.get(e.from).label)+'</a>'+(e.label?' — '+esc(e.label):'')+'</li>'}).join('');
1471
+ if(o)h+='<b>Goes to</b><ul>'+o+'</ul>';if(n)h+='<b>Comes from</b><ul>'+n+'</ul>';
1472
+ h+='<b>Evidence</b>'+evHtml(x.evidence);
1473
+ var nb=new Set([id]);outE.get(id).concat(inE.get(id)).forEach(function(i){nb.add(edges.get(i).from);nb.add(edges.get(i).to)});light(Array.from(nb),outE.get(id).concat(inE.get(id)));el('n',id).classList.add('sel');
1474
+ }else{h+='<h3>'+esc(nodes.get(x.from).label)+' → '+esc(nodes.get(x.to).label)+'</h3><div class="kind">'+(x.label?esc(x.label):'connection')+(x.assumed?' · <span style="color:var(--warn)">assumption</span>':'')+(x.status?' · '+esc(x.status):'')+'</div>';
1475
+ if(x.detail)h+='<p>'+esc(x.detail)+'</p>';h+='<b>Evidence</b>'+evHtml(x.evidence);light([x.from,x.to],[id],true)}
1476
+ panel.innerHTML=h;panel.classList.add('open');selected=kind==='n'?id:null;
1477
+ panel.querySelector('.close').onclick=function(){panel.classList.remove('open');clear();selected=null};
1478
+ panel.querySelectorAll('.jump').forEach(function(a){a.onclick=function(){show('n',a.getAttribute('data-n'))}});
1479
+ panel.querySelectorAll('[data-act]').forEach(function(b){b.onclick=function(){var a=b.getAttribute('data-act');
1480
+ if(a==='near'){show('n',id);return}var r=reach(id,a==='down'?1:-1);light(r.n,r.e,true);el('n',id).classList.add('sel')}})}
1481
+ svg.addEventListener('click',function(e){var g=e.target.closest('.node');if(g){var id=g.getAttribute('data-id');
1482
+ if(e.shiftKey&&selected&&selected!==id){var p=path(selected,id,true)||path(selected,id,false);if(p){light(p.n,p.e,true)}return}show('n',id);return}
1483
+ var ed=e.target.closest('.edge');if(ed){show('e',ed.getAttribute('data-id'));return}});
1484
+ svg.addEventListener('keydown',function(e){var g=e.target.closest&&e.target.closest('.node');if(g&&(e.key==='Enter'||e.key===' ')){e.preventDefault();show('n',g.getAttribute('data-id'))}});
1485
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){panel.classList.remove('open');clear();selected=null}
1486
+ if(e.target.tagName==='INPUT')return;if(e.key==='ArrowRight'&&tour)step(1);if(e.key==='ArrowLeft'&&tour)step(-1)});
1487
+ var q=document.getElementById('find');q.addEventListener('input',function(){var v=q.value.trim().toLowerCase();if(!v){clear();return}
1488
+ var ns=DATA.nodes.filter(function(n){return (n.label+' '+(n.note||'')+' '+(n.fields||[]).map(function(f){return f.name}).join(' ')).toLowerCase().indexOf(v)>=0}).map(function(n){return n.id});light(ns,[])});
1489
+ document.getElementById('zin').onclick=function(){zoom(1/1.25)};document.getElementById('zout').onclick=function(){zoom(1.25)};document.getElementById('zfit').onclick=fit;
1490
+ document.getElementById('theme').onclick=function(){var t=root.getAttribute('data-theme')==='dark'?'light':'dark';root.setAttribute('data-theme',t);try{localStorage.setItem('idg-theme',t)}catch(e){}};
1491
+ function download(name,type,data){var a=document.createElement('a');a.href=URL.createObjectURL(new Blob([data],{type:type}));a.download=name;document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(a.href);a.remove()},500)}
1492
+ function standalone(){var c=svg.cloneNode(true);c.setAttribute('viewBox','0 0 '+W+' '+H);c.setAttribute('width',W);c.setAttribute('height',H);c.classList.remove('focus');
1493
+ c.querySelectorAll('.on,.sel,.flow').forEach(function(x){x.classList.remove('on','sel','flow')});
1494
+ var cs=getComputedStyle(root),vars=['--bg','--panel','--ink','--muted','--line','--edge','--accent','--warn','--ok','--bad','--hl','--canvas','--group','--group2'].map(function(v){return v+':'+cs.getPropertyValue(v)}).join(';');
1495
+ var st=document.createElementNS('http://www.w3.org/2000/svg','style');st.textContent='svg{'+vars+';font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}'+document.getElementById('diagram-css').textContent.split('/* diagram */')[1];
1496
+ c.insertBefore(st,c.firstChild);return new XMLSerializer().serializeToString(c)}
1497
+ var base=(DATA.slug||'diagram');window.interactiveDiagram={svg:standalone,mermaid:function(){return DATA.mermaid}};
1498
+ document.getElementById('xsvg').onclick=function(){download(base+'.svg','image/svg+xml',standalone())};
1499
+ document.getElementById('xpng').onclick=function(){var img=new Image();img.onload=function(){var k=2,cv=document.createElement('canvas');cv.width=W*k;cv.height=H*k;var ctx=cv.getContext('2d');ctx.scale(k,k);ctx.drawImage(img,0,0);cv.toBlob(function(b){download(base+'.png','image/png',b)})};
1500
+ img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(standalone())};
1501
+ document.getElementById('xmmd').onclick=function(){var t=DATA.mermaid;if(navigator.clipboard)navigator.clipboard.writeText(t).catch(function(){});download(base+'.mmd','text/plain',t)};
1502
+ var tour=null,cap=document.getElementById('caption');
1503
+ var steps=DATA.story&&DATA.story.length?DATA.story:(DATA.type==='sequence'?DATA.edges.map(function(m,i){return {text:(i+1)+'. '+nodes.get(m.from).label+' → '+nodes.get(m.to).label+(m.label?': '+m.label:''),edges:[m.id],nodes:[m.from,m.to]}}):[]);
1504
+ var tb=document.getElementById('tour');if(!steps.length){tb.style.display='none'}
1505
+ function step(d){tour.i=Math.max(0,Math.min(steps.length-1,tour.i+d));var s=steps[tour.i];
1506
+ light((s.nodes||[]).concat((s.edges||[]).map(function(i){return edges.get(i)?edges.get(i).from:null}).filter(Boolean)).concat((s.edges||[]).map(function(i){return edges.get(i)?edges.get(i).to:null}).filter(Boolean)),s.edges||[],true);
1507
+ cap.innerHTML='<b>Step '+(tour.i+1)+' of '+steps.length+'</b> — '+esc(s.text||'')+' <span class="muted">(← → to move, Esc to stop)</span>';cap.classList.add('on')}
1508
+ tb.onclick=function(){if(tour){tour=null;cap.classList.remove('on');clear();tb.textContent='Walk through';return}tour={i:-1};tb.textContent='Stop';step(1)};
1509
+ document.getElementById('prev').onclick=function(){if(tour)step(-1)};document.getElementById('next').onclick=function(){if(!tour){tb.onclick()}else step(1)};
1510
+ if(!steps.length){document.getElementById('prev').style.display='none';document.getElementById('next').style.display='none'}
1511
+ function size(){var wr=document.querySelector('.wrap');var avail=Math.max(window.innerHeight-150,360);var r=stage.getBoundingClientRect();
1512
+ var s=Math.max(W/Math.max(r.width,1),0.6);wr.style.height=Math.min(avail,Math.max(H/s+24,300))+'px'}
1513
+ size();fit();window.addEventListener('resize',function(){size();fit()});
1514
+ function hash(){var h=decodeURIComponent(location.hash.slice(1));if(!h)return;var kv=new Map();h.split('&').forEach(function(p){var i=p.indexOf('=');if(i>0)kv.set(p.slice(0,i),p.slice(i+1))});
1515
+ if(kv.get('theme'))root.setAttribute('data-theme',kv.get('theme'));
1516
+ if(kv.get('n')&&nodes.get(kv.get('n')))show('n',kv.get('n'));if(kv.get('e')&&edges.get(kv.get('e')))show('e',kv.get('e'));
1517
+ if(kv.get('path')){var ab=kv.get('path').split(',');var p=path(ab[0],ab[1],true)||path(ab[0],ab[1],false);if(p)light(p.n,p.e,true)}
1518
+ if(kv.get('reach')){var ar=kv.get('reach').split(',');var r=reach(ar[0],ar[1]==='up'?-1:1);light(r.n,r.e,true)}
1519
+ if(kv.get('step')&&steps.length){tour={i:-1};tb.textContent='Stop';step(+kv.get('step'))}}
1520
+ hash();window.addEventListener('hashchange',hash);
1521
+ })();
1522
+ """
1523
+
1524
+
1525
+ def build_html(spec, svg, mermaid, issues, theme):
1526
+ t = spec["type"]
1527
+ by_group = {g["id"]: g["label"] for g in spec["groups"]}
1528
+ data = {"type": t, "slug": re.sub(r"[^a-z0-9]+", "-", spec["title"].lower()).strip("-")[:60] or "diagram",
1529
+ "source_url": spec.get("source_url") if re.match(r"^https?://[^\s\"'<>]+$", str(spec.get("source_url") or "")) else None,
1530
+ "story": spec["story"], "nodes": [], "edges": [], "mermaid": mermaid}
1531
+ for n in spec["nodes"]:
1532
+ d = {k: n.get(k) for k in ("id", "label", "kind", "note", "detail", "fields", "evidence", "assumed", "status")}
1533
+ d["group_label"] = by_group.get(n.get("group"))
1534
+ data["nodes"].append(d)
1535
+ for e in spec["edges"]:
1536
+ data["edges"].append({k: e.get(k) for k in ("id", "from", "to", "label", "detail", "evidence", "assumed", "status")})
1537
+ payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
1538
+ assumed_n = sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"])
1539
+ file_ev = [e for x in spec["nodes"] + spec["edges"] for e in x["evidence"] if "file" in e]
1540
+ checked = sum(1 for e in file_ev if e.get("verified"))
1541
+ facts = []
1542
+ if file_ev:
1543
+ facts.append("%d of %d file citations checked against the repository" % (checked, len(file_ev)) if checked
1544
+ else "%d file citations, not checked against a repository" % len(file_ev))
1545
+ if assumed_n:
1546
+ facts.append("%d item%s drawn dashed as assumptions" % (assumed_n, "" if assumed_n == 1 else "s"))
1547
+ if spec.get("compared"):
1548
+ cnt = {}
1549
+ for x in spec["nodes"] + spec["edges"]:
1550
+ if x.get("status"):
1551
+ cnt[x["status"]] = cnt.get(x["status"], 0) + 1
1552
+ facts.append(", ".join("%d %s" % (v, k) for k, v in sorted(cnt.items())) or "no changes")
1553
+ legend = ['<span><i></i>connection</span>', '<span><i class="d"></i>async / optional</span>']
1554
+ if assumed_n:
1555
+ legend.append('<span><i class="a"></i>assumption (not shown by the evidence)</span>')
1556
+ if spec.get("compared"):
1557
+ legend.append('<span style="color:var(--ok)">■ added</span><span style="color:var(--bad)">■ removed</span>'
1558
+ '<span style="color:var(--accent)">■ changed</span>')
1559
+ notes = "".join("<li>%s</li>" % esc(x) for x in spec["notes"])
1560
+ warn_html = ""
1561
+ if issues.warnings:
1562
+ warn_html = '<div class="issues"><b>Check before sharing:</b> ' + "; ".join(esc(w["message"] + " (" + w["where"] + ")") for w in issues.warnings[:6]) + "</div>"
1563
+ return """<!doctype html>
1564
+ <html lang="en" data-theme-default="%(theme)s"><head><meta charset="utf-8">
1565
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src '%(js_hash)s'; style-src 'unsafe-inline'; img-src data: blob:; base-uri 'none'; form-action 'none'">
1566
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1567
+ <!-- One self-contained file: no external scripts, styles, fonts or requests. The policy above makes the browser refuse any. -->
1568
+ <title>%(title)s</title><meta name="generator" content="interactive-diagram (skilldrop)"></head>
1569
+ <body>
1570
+ <header class="top"><h1>%(title)s</h1>%(question)s%(summary)s<p class="muted" style="font-size:12px;margin:6px 0 0">%(facts)s</p></header>
1571
+ <section class="text" id="outline" aria-label="The diagram as text">
1572
+ <details%(open)s><summary>The diagram as text</summary>%(outline)s%(notes)s</details></section>
1573
+ <nav class="bar" aria-label="Diagram controls">
1574
+ <input id="find" type="search" placeholder="Find a part…" aria-label="Find a part">
1575
+ <button id="zin" title="Zoom in">+</button><button id="zout" title="Zoom out">−</button><button id="zfit" title="Fit to screen">Fit</button>
1576
+ <span class="sep"></span><button id="tour">Walk through</button><button id="prev" title="Previous step">←</button><button id="next" title="Next step">→</button>
1577
+ <span class="sep"></span><button id="xsvg">SVG</button><button id="xpng">PNG</button><button id="xmmd" title="Copy and download Mermaid">Mermaid</button>
1578
+ <button id="theme" title="Light or dark">◐</button>
1579
+ <span class="hint">Click a part for detail and evidence · shift-click a second part for the path · drag to pan · scroll to zoom</span></nav>
1580
+ <div class="legend">%(legend)s</div>%(warn)s
1581
+ <div class="caption" id="caption" aria-live="polite"></div>
1582
+ <div class="wrap"><div id="stage">%(svg)s</div><aside id="panel" aria-live="polite"></aside></div>
1583
+ <style id="diagram-css">%(css)s</style>
1584
+ <script type="application/json" id="diagram-data">%(data)s</script>
1585
+ <script>%(js)s</script>
1586
+ </body></html>
1587
+ """ % {"theme": esc(theme), "title": esc(spec["title"]),
1588
+ "question": '<p class="q">%s</p>' % esc(spec["question"]) if spec["question"] else "",
1589
+ "summary": '<p class="sum">%s</p>' % esc(spec["summary"]) if spec["summary"] else "",
1590
+ "facts": esc(" · ".join(facts)), "open": "" if len(spec["nodes"]) > 12 else " open",
1591
+ "outline": outline_html(spec), "notes": "<h2>Notes</h2><ul>%s</ul>" % notes if notes else "",
1592
+ "legend": "".join(legend), "warn": warn_html, "svg": svg, "css": CSS,
1593
+ "data": payload, "js": JS,
1594
+ "js_hash": "sha256-" + base64.b64encode(hashlib.sha256(JS.encode("utf-8")).digest()).decode()}
1595
+
1596
+
1597
+ # --------------------------------------------------------------------------- main
1598
+
1599
+ def load(path):
1600
+ with open(path, encoding="utf-8") as fh:
1601
+ return json.load(fh)
1602
+
1603
+
1604
+ def main(argv=None):
1605
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1606
+ ap.add_argument("spec", help="diagram spec (JSON)")
1607
+ ap.add_argument("-o", "--out", help="HTML file to write")
1608
+ ap.add_argument("--repo-root", help="check every file:line citation against this directory and show the cited lines")
1609
+ ap.add_argument("--compare", metavar="BASE", help="a base spec: mark what this one added, removed or changed")
1610
+ ap.add_argument("--mermaid", metavar="FILE", help="also write the diagram as Mermaid")
1611
+ ap.add_argument("--theme", choices=("auto", "light", "dark"), default="auto")
1612
+ ap.add_argument("--check", action="store_true", help="validate (and verify evidence); write nothing")
1613
+ ap.add_argument("--json", action="store_true", help="print the receipt as JSON")
1614
+ a = ap.parse_args(argv)
1615
+ if not a.check and not a.out:
1616
+ ap.error("give -o OUT.html, or --check to validate only")
1617
+ issues = Issues()
1618
+ try:
1619
+ spec = normalise(load(a.spec), issues)
1620
+ except (OSError, ValueError) as err:
1621
+ print("cannot read %s: %s" % (a.spec, err), file=sys.stderr)
1622
+ return 2
1623
+ if a.compare:
1624
+ try:
1625
+ base_issues = Issues()
1626
+ base = normalise(load(a.compare), base_issues)
1627
+ merge_compare(spec, base, issues)
1628
+ except (OSError, ValueError) as err:
1629
+ print("cannot read %s: %s" % (a.compare, err), file=sys.stderr)
1630
+ return 2
1631
+ verify_evidence(spec, a.repo_root, issues)
1632
+ file_ev = [e for x in spec["nodes"] + spec["edges"] for e in x["evidence"] if "file" in e]
1633
+ if file_ev and not a.repo_root:
1634
+ issues.warn("evidence/unchecked", "evidence", "%d file citations were not checked" % len(file_ev),
1635
+ "Pass --repo-root so each file and line is verified.")
1636
+ receipt = {"ok": not issues.errors, "type": spec["type"], "nodes": len(spec["nodes"]), "edges": len(spec["edges"]),
1637
+ "assumed": sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"]),
1638
+ "file_citations": len(file_ev), "verified": sum(1 for e in file_ev if e.get("verified")),
1639
+ "errors": issues.errors, "warnings": issues.warnings}
1640
+ if not issues.errors and not a.check:
1641
+ if spec["type"] == "sequence":
1642
+ geo = layout_sequence(spec)
1643
+ else:
1644
+ geo = layout_graph_full(spec)
1645
+ occupied = [(n["x"], n["y"], n["x"] + n["w"], n["y"] + n["h"]) for n in spec["nodes"]]
1646
+ labels = {g["id"]: g["label"] for g in spec["groups"]}
1647
+ occupied += [(g["x"] + 6, g["y"] + 4, g["x"] + 18 + text_w(labels.get(g["id"], ""), 12), g["y"] + 24) for g in geo["groups"]]
1648
+ place_labels(spec, occupied)
1649
+ svg = render_svg(spec, geo)
1650
+ mermaid = to_mermaid(spec)
1651
+ out = build_html(spec, svg, mermaid, issues, a.theme)
1652
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
1653
+ with open(a.out, "w", encoding="utf-8") as fh:
1654
+ fh.write(out)
1655
+ receipt["output"] = os.path.abspath(a.out)
1656
+ receipt["bytes"] = len(out.encode("utf-8"))
1657
+ if a.mermaid:
1658
+ with open(a.mermaid, "w", encoding="utf-8") as fh:
1659
+ fh.write(mermaid)
1660
+ receipt["mermaid"] = os.path.abspath(a.mermaid)
1661
+ if a.json:
1662
+ print(json.dumps(receipt, indent=1))
1663
+ else:
1664
+ for i in issues.items:
1665
+ print("%s %s [%s] %s%s" % ("ERROR" if i["level"] == "error" else "warning", i["where"], i["code"], i["message"],
1666
+ (" — " + i["fix"]) if i["fix"] else ""))
1667
+ if issues.errors:
1668
+ print("%d error(s); nothing written. Fix the spec and run again." % len(issues.errors))
1669
+ elif a.check:
1670
+ print("OK: %d %s, %d %s, %d assumed, %d of %d file citations verified" % (
1671
+ receipt["nodes"], "participants" if spec["type"] == "sequence" else "nodes", receipt["edges"],
1672
+ "messages" if spec["type"] == "sequence" else "edges", receipt["assumed"], receipt["verified"], receipt["file_citations"]))
1673
+ else:
1674
+ print("wrote %s (%d KB): %d %s, %d %s, %d assumed, %d of %d file citations verified" % (
1675
+ a.out, receipt["bytes"] // 1024, receipt["nodes"], "participants" if spec["type"] == "sequence" else "nodes",
1676
+ receipt["edges"], "messages" if spec["type"] == "sequence" else "edges", receipt["assumed"],
1677
+ receipt["verified"], receipt["file_citations"]))
1678
+ return 1 if issues.errors else 0
1679
+
1680
+
1681
+ if __name__ == "__main__":
1682
+ sys.exit(main())