skilldrop-cli 0.16.21 → 0.16.23

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 (55) 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 +160 -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 +164 -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/10/build-4127-timing.json +179 -0
  14. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AbstractSensor.java +36 -0
  15. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AlertService.java +15 -0
  16. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Greenhouse.java +36 -0
  17. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Sensor.java +10 -0
  18. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/SoilMoistureSensor.java +25 -0
  19. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/TemperatureSensor.java +19 -0
  20. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Zone.java +26 -0
  21. package/packs/solution-architect/skills/interactive-diagram/evals/files/3/schema.sql +35 -0
  22. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/README.md +26 -0
  23. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/config/default.json +29 -0
  24. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/clients/transcription.js +25 -0
  25. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/db.js +16 -0
  26. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/handlers/storageWebhook.js +43 -0
  27. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/jobs/transcribeEpisode.js +28 -0
  28. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/mailer.js +18 -0
  29. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/server.js +32 -0
  30. package/packs/solution-architect/skills/interactive-diagram/evals/files/4/src/worker.js +32 -0
  31. package/packs/solution-architect/skills/interactive-diagram/evals/files/6/notes.txt +7 -0
  32. package/packs/solution-architect/skills/interactive-diagram/evals/files/6/trace-quote-slow.jsonl +13 -0
  33. package/packs/solution-architect/skills/interactive-diagram/evals/files/7/oncall-notes.md +16 -0
  34. package/packs/solution-architect/skills/interactive-diagram/evals/files/7/system-log.txt +14 -0
  35. package/packs/solution-architect/skills/interactive-diagram/evals/files/8/run-4471.json +151 -0
  36. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/chat-export.txt +16 -0
  37. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/lesson-sync.log +24 -0
  38. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/ticket-8812.csv +6 -0
  39. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.html +203 -0
  40. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.json +48 -0
  41. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.md +31 -0
  42. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.html +203 -0
  43. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.json +110 -0
  44. package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.html +203 -0
  45. package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.json +21 -0
  46. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/api/checkout.py +41 -0
  47. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/docker-compose.yml +20 -0
  48. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/checkout/worker/fulfil.py +23 -0
  49. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/incident/oncall-notes.md +14 -0
  50. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/trace/checkout-trace.jsonl +9 -0
  51. package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.html +203 -0
  52. package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.json +28 -0
  53. package/packs/solution-architect/skills/interactive-diagram/manifest.json +39 -0
  54. package/packs/solution-architect/skills/interactive-diagram/reference.md +231 -0
  55. package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +2605 -0
@@ -0,0 +1,2605 @@
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 datetime
23
+ import hashlib
24
+ import html
25
+ import json
26
+ import math
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ TYPES = ("architecture", "workflow", "dataflow", "lifecycle", "erd", "sequence", "tree", "timeline", "waterfall", "class")
32
+
33
+ # kind -> (shape, colour family). Unknown kinds fall back to a plain box, with a warning.
34
+ KINDS = {
35
+ "service": ("rect", "blue"), "app": ("rect", "blue"), "function": ("rect", "blue"),
36
+ "ui": ("rect", "violet"), "client": ("rect", "violet"),
37
+ "datastore": ("cylinder", "green"), "database": ("cylinder", "green"),
38
+ "cache": ("cylinder", "teal"), "storage": ("cylinder", "green"),
39
+ "queue": ("pipe", "amber"), "topic": ("pipe", "amber"), "stream": ("pipe", "amber"),
40
+ "external": ("rect", "slate"), "person": ("person", "rose"), "actor": ("person", "rose"),
41
+ "job": ("rect", "indigo"), "worker": ("rect", "indigo"),
42
+ "step": ("rect", "blue"), "task": ("rect", "blue"), "manual": ("rect", "rose"),
43
+ "decision": ("diamond", "amber"), "document": ("document", "teal"),
44
+ "start": ("terminal", "green"), "end": ("terminal", "slate"),
45
+ "state": ("round", "blue"), "final": ("final", "slate"), "initial": ("initial", "slate"),
46
+ "table": ("table", "green"), "entity": ("table", "green"), "view": ("table", "teal"),
47
+ "security": ("rect", "rose"), "network": ("rect", "slate"), "file": ("document", "teal"),
48
+ "folder": ("rect", "slate"), "module": ("rect", "blue"), "package": ("rect", "indigo"),
49
+ "capability": ("round", "teal"), "feature": ("round", "blue"), "team": ("rect", "rose"), "role": ("rect", "violet"),
50
+ "event": ("dot", "blue"), "milestone": ("dot", "violet"), "alert": ("dot", "rose"), "change": ("dot", "amber"),
51
+ "action": ("dot", "green"), "span": ("bar", "blue"),
52
+ "class": ("classbox", "blue"), "interface": ("classbox", "violet"), "abstract": ("classbox", "indigo"),
53
+ "enum": ("classbox", "amber"), "record": ("classbox", "teal"),
54
+ }
55
+ RELATIONS = ("inherits", "implements", "composition", "aggregation", "association", "dependency")
56
+ WORDS = { # how the detail panel names a part's connections, per diagram type
57
+ "default": {"out": "Goes to", "in": "Comes from", "reach_out": "Everything it reaches", "reach_in": "Everything that reaches it"},
58
+ "workflow": {"out": "Goes to", "in": "Comes from", "reach_out": "What can follow", "reach_in": "What leads here"},
59
+ "lifecycle": {"out": "Can move to", "in": "Can come from", "reach_out": "What can follow", "reach_in": "What leads here"},
60
+ "erd": {"out": "References", "in": "Referenced by", "reach_out": "Tables it references", "reach_in": "Tables that reference it"},
61
+ "sequence": {"out": "Sends to", "in": "Receives from", "reach_out": "Who it reaches", "reach_in": "Who reaches it"},
62
+ "tree": {"out": "Contains", "in": "Inside", "reach_out": "Everything inside it", "reach_in": "What it sits in"},
63
+ "timeline": {"out": "Led to", "in": "Followed from", "reach_out": "What followed", "reach_in": "What led to it"},
64
+ "waterfall": {"out": "Child spans", "in": "Parent span", "reach_out": "Everything it spawned", "reach_in": "What it ran under"},
65
+ "class": {"out": "Points to", "in": "Pointed to by", "reach_out": "What it uses or extends", "reach_in": "What uses or extends it"},
66
+ }
67
+ DEFAULT_KIND = {"architecture": "service", "workflow": "step", "dataflow": "service", "lifecycle": "state",
68
+ "erd": "table", "sequence": "service", "tree": "module", "timeline": "event", "waterfall": "span",
69
+ "class": "class"}
70
+ EDGE_STYLES = ("solid", "dashed", "dotted")
71
+ STATUSES = ("added", "removed", "changed")
72
+ SECRET_PATH = re.compile(r"(^|/)(\.env(\..*)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials(\..*)?|"
73
+ r".*secrets?\.(json|ya?ml|toml)|\.npmrc|\.pypirc|\.netrc)$", re.I)
74
+ SECRET_LINE = re.compile(r"(-----BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|"
75
+ r"sk-[A-Za-z0-9_-]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|"
76
+ r"(?i:(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['\"]?[^\s'\"]{6,}))")
77
+ MAX_SNIPPET_LINES = 14
78
+ MAX_SNIPPET_TOTAL = 80000
79
+
80
+
81
+ # --------------------------------------------------------------------------- spec checks
82
+
83
+ class Issues(object):
84
+ def __init__(self):
85
+ self.items = []
86
+
87
+ def add(self, level, code, where, message, fix=""):
88
+ self.items.append({"level": level, "code": code, "where": where, "message": message, "fix": fix})
89
+
90
+ def error(self, *a):
91
+ self.add("error", *a)
92
+
93
+ def warn(self, *a):
94
+ self.add("warning", *a)
95
+
96
+ @property
97
+ def errors(self):
98
+ return [i for i in self.items if i["level"] == "error"]
99
+
100
+ @property
101
+ def warnings(self):
102
+ return [i for i in self.items if i["level"] == "warning"]
103
+
104
+
105
+ EVIDENCE_RE = re.compile(r"^(?P<file>[^:#\s][^:#]*?)(?::(?P<start>\d+)(?:-(?P<end>\d+))?)?(?:#(?P<quote>.+))?$")
106
+
107
+
108
+ def parse_evidence(ev):
109
+ """Evidence is a file reference ("src/api.py:40-52#redis.get(") or something the person
110
+ said ("user: we approve leave in the HR portal"). Objects with the same fields work too."""
111
+ if isinstance(ev, dict):
112
+ if "said" in ev:
113
+ return {"said": str(ev["said"])}
114
+ out = {"file": str(ev.get("file", ""))}
115
+ lines = str(ev.get("lines", "") or "")
116
+ m = re.match(r"^(\d+)(?:-(\d+))?$", lines)
117
+ if m:
118
+ out["start"], out["end"] = int(m.group(1)), int(m.group(2) or m.group(1))
119
+ if ev.get("quote"):
120
+ out["quote"] = str(ev["quote"])
121
+ return out
122
+ s = str(ev).strip()
123
+ low = s.lower()
124
+ for prefix in ("user:", "said:", "brief:", "doc:"):
125
+ if low.startswith(prefix):
126
+ return {"said": s[len(prefix):].strip().strip('"“”'), "source": prefix[:-1]}
127
+ m = EVIDENCE_RE.match(s)
128
+ if not m or not m.group("file"):
129
+ return {"bad": s}
130
+ out = {"file": m.group("file").strip()}
131
+ if m.group("start"):
132
+ out["start"] = int(m.group("start"))
133
+ out["end"] = int(m.group("end") or m.group("start"))
134
+ if m.group("quote"):
135
+ out["quote"] = m.group("quote").strip()
136
+ return out
137
+
138
+
139
+ def ev_text(e):
140
+ if "said" in e:
141
+ return "%s: “%s”" % (e.get("source", "user"), e["said"])
142
+ if "file" in e:
143
+ s = e["file"]
144
+ if "start" in e:
145
+ s += ":%d" % e["start"] + ("-%d" % e["end"] if e["end"] != e["start"] else "")
146
+ return s
147
+ return e.get("bad", "")
148
+
149
+
150
+ def as_list(x):
151
+ if x is None:
152
+ return []
153
+ return x if isinstance(x, list) else [x]
154
+
155
+
156
+ def normalise(spec, issues):
157
+ """Fill defaults and parse shorthand, collecting problems. Returns the normalised spec."""
158
+ t = spec.get("type")
159
+ if t not in TYPES:
160
+ issues.error("spec/type", "type", "type is %r" % t, "Use one of: " + ", ".join(TYPES))
161
+ t = "architecture"
162
+ spec["type"] = t
163
+ if not str(spec.get("title", "")).strip():
164
+ issues.error("spec/title", "title", "the diagram has no title", "Add a title naming what the map shows.")
165
+ spec.setdefault("question", "")
166
+ spec.setdefault("summary", "")
167
+ default_dir = "TB" if t == "class" else "LR"
168
+ spec["direction"] = "TB" if str(spec.get("direction", default_dir)).upper() == "TB" and t not in ("erd", "sequence", "tree", "timeline", "waterfall") else "LR"
169
+ if spec.get("source_url") and not re.match(r"^https?://[^\s\"'<>]+$", str(spec["source_url"])):
170
+ issues.warn("spec/source-url", "source_url", "source_url is not an http(s) address; citations will not be links",
171
+ "Use the repository's https address, e.g. https://github.com/org/repo/blob/<commit>/")
172
+ spec["groups"] = [dict(g) for g in as_list(spec.get("groups"))]
173
+ spec["notes"] = as_list(spec.get("notes"))
174
+ key = {"sequence": "participants", "timeline": "events", "waterfall": "spans", "class": "classes"}.get(t, "nodes")
175
+ nodes = [dict(n) for n in as_list(spec.get(key) or spec.get("nodes"))]
176
+ if not nodes:
177
+ issues.error("spec/empty", key, "there is nothing to draw", "List the %s of the diagram." % key)
178
+ spec["nodes"] = nodes
179
+ spec["edges"] = [dict(e) for e in as_list(spec.get("messages" if t == "sequence" else "edges") or spec.get("relations"))]
180
+ if t in ("tree", "waterfall") and spec["edges"]:
181
+ issues.warn("edge/ignored", "edges", "a %s draws its connections from each item's parent; edges are ignored" % t,
182
+ "Set parent on each item instead.")
183
+ spec["edges"] = []
184
+ spec["frames"] = [dict(f) for f in as_list(spec.get("frames"))]
185
+ spec["story"] = [dict(s) for s in as_list(spec.get("story"))]
186
+ spec["answers"] = [dict(a) for a in as_list(spec.get("answers"))]
187
+ for i, a in enumerate(spec["answers"]):
188
+ a["id"] = "answer %d" % (i + 1)
189
+ if not str(a.get("question", "")).strip() or not str(a.get("answer", "")).strip():
190
+ issues.error("answer/shape", a["id"], "an answer needs question and answer", "")
191
+ _evidence(a, a["id"], issues)
192
+
193
+ ids = set()
194
+ for g in spec["groups"]:
195
+ gid = str(g.get("id", "")).strip()
196
+ if not gid:
197
+ issues.error("group/id", "groups", "a group has no id", "Give every group an id.")
198
+ continue
199
+ if gid in ids:
200
+ issues.error("id/duplicate", gid, "the id %r is used twice" % gid, "Ids must be unique across groups, nodes and edges.")
201
+ ids.add(gid)
202
+ g["id"] = gid
203
+ g["label"] = str(g.get("label") or gid)
204
+ gids = {g["id"] for g in spec["groups"]}
205
+ for g in spec["groups"]:
206
+ if g.get("parent") and g["parent"] not in gids:
207
+ issues.error("group/parent", g["id"], "parent group %r does not exist" % g["parent"], "Name an existing group or drop parent.")
208
+ g["parent"] = None
209
+ for g in spec["groups"]: # a parent chain must end
210
+ seen, cur = set(), g
211
+ while cur and cur.get("parent"):
212
+ if cur["id"] in seen:
213
+ issues.error("group/cycle", g["id"], "group parents form a loop", "Remove one parent link.")
214
+ g["parent"] = None
215
+ break
216
+ seen.add(cur["id"])
217
+ cur = next((x for x in spec["groups"] if x["id"] == cur["parent"]), None)
218
+
219
+ for n in nodes:
220
+ nid = str(n.get("id", "")).strip()
221
+ if not nid:
222
+ issues.error("node/id", key, "a %s has no id" % key[:-1], "Give every %s an id." % key[:-1])
223
+ nid = "_node%d" % len(ids)
224
+ if nid in ids:
225
+ issues.error("id/duplicate", nid, "the id %r is used twice" % nid, "Ids must be unique across groups, nodes and edges.")
226
+ ids.add(nid)
227
+ n["id"] = nid
228
+ n["label"] = str(n.get("label") or nid)
229
+ n["kind"] = str(n.get("kind") or DEFAULT_KIND[t]).lower()
230
+ if n["kind"] not in KINDS:
231
+ issues.warn("node/kind", nid, "kind %r is not one the renderer knows; drawn as a box" % n["kind"],
232
+ "Known kinds: " + ", ".join(sorted(KINDS)))
233
+ if n.get("group") and n["group"] not in gids:
234
+ issues.error("node/group", nid, "group %r does not exist" % n["group"], "Add the group or drop the field.")
235
+ n["group"] = None
236
+ n["fields"] = [f if isinstance(f, dict) else {"name": str(f)} for f in as_list(n.get("fields") or n.get("members"))]
237
+ if len(n["label"]) > 48:
238
+ issues.warn("node/label-long", nid, "label is %d characters; it will be cut on the map" % len(n["label"]),
239
+ "Keep labels to a few words and put the rest in note or detail.")
240
+ _evidence(n, nid, issues)
241
+
242
+ nid_set = {n["id"] for n in nodes}
243
+ _check_kind(spec, t, nodes, nid_set, issues)
244
+ for i, e in enumerate(spec["edges"]):
245
+ where = e.get("id") or "%s %d" % ("message" if t == "sequence" else "edge", i + 1)
246
+ if not e.get("id"):
247
+ e["id"] = "m%d" % (i + 1) if t == "sequence" else "e%d" % (i + 1)
248
+ while e["id"] in ids:
249
+ e["id"] += "_"
250
+ elif e["id"] in ids:
251
+ issues.error("id/duplicate", e["id"], "the id %r is used twice" % e["id"], "Ids must be unique across groups, nodes and edges.")
252
+ ids.add(e["id"])
253
+ for end in ("from", "to"):
254
+ if e.get(end) not in nid_set:
255
+ issues.error("edge/endpoint", where, "%s %r is not a %s" % (end, e.get(end), key[:-1]),
256
+ "Use the id of an existing %s." % key[:-1])
257
+ e["label"] = str(e.get("label") or "")
258
+ if t == "class":
259
+ rel = str(e.get("kind") or e.get("rel") or "association").lower()
260
+ rel = {"extends": "inherits", "inheritance": "inherits", "realizes": "implements", "realises": "implements",
261
+ "uses": "dependency", "depends": "dependency", "has": "association", "references": "association",
262
+ "owns": "composition", "contains": "composition"}.get(rel, rel)
263
+ if rel not in RELATIONS:
264
+ issues.error("edge/relation", where, "relation %r is not one of %s" % (rel, ", ".join(RELATIONS)), "")
265
+ rel = "association"
266
+ e["rel"] = rel
267
+ if rel in ("implements", "dependency") and not e.get("style"):
268
+ e["style"] = "dashed"
269
+ e["style"] = e.get("style") if e.get("style") in EDGE_STYLES else ("dashed" if e.get("kind") in ("async", "return") else "solid")
270
+ _evidence(e, where, issues)
271
+ for i, s in enumerate(spec["story"]):
272
+ for ref in as_list(s.get("nodes")) + as_list(s.get("edges")):
273
+ if ref not in ids:
274
+ 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.")
275
+ for i, f in enumerate(spec["frames"]):
276
+ if t != "sequence":
277
+ issues.warn("frame/type", "frames", "frames are drawn only on sequence diagrams", "Use groups instead.")
278
+ break
279
+ 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"]]:
280
+ issues.error("frame/range", "frame %d" % (i + 1), "a frame must name its first and last message ids in from and to",
281
+ "Set from and to to message ids.")
282
+ if len(nodes) > 400 or len(spec["edges"]) > 2000:
283
+ issues.error("size/limit", key, "%d nodes and %d edges is past what one page can lay out" % (len(nodes), len(spec["edges"])),
284
+ "Split the map by question.")
285
+ if t != "sequence" and len(nodes) > 36:
286
+ issues.warn("size/large", "nodes", "%d nodes is more than one map reads well" % len(nodes),
287
+ "Split it by question, or group the detail into fewer boxes.")
288
+ if t not in ("sequence", "erd", "tree", "timeline", "waterfall", "class"):
289
+ linked = {e.get("from") for e in spec["edges"]} | {e.get("to") for e in spec["edges"]}
290
+ for n in nodes:
291
+ if n["id"] not in linked and len(nodes) > 1:
292
+ issues.warn("node/isolated", n["id"], "%r has no connections" % n["label"],
293
+ "Connect it, or leave it out if it doesn't answer the question.")
294
+ return spec
295
+
296
+
297
+ def _evidence(item, where, issues):
298
+ evs = [parse_evidence(x) for x in as_list(item.get("evidence"))]
299
+ for e in evs:
300
+ if "bad" in e:
301
+ issues.error("evidence/format", where, "cannot read evidence %r" % e["bad"],
302
+ 'Write "path/to/file.py:12-20" (optionally "#text on those lines") or "user: their words".')
303
+ item["evidence"] = [e for e in evs if "bad" not in e]
304
+ item["assumed"] = bool(item.get("assumed"))
305
+ if not item["evidence"] and not item["assumed"]:
306
+ issues.error("evidence/missing", where, "no evidence and not marked assumed",
307
+ 'Cite where it comes from ("src/x.py:10", "user: ...") or set "assumed": true so it is drawn as an assumption.')
308
+
309
+
310
+ def _check_kind(spec, t, nodes, ids, issues):
311
+ """What the tree, timeline and waterfall types need from their items."""
312
+ by_id = {n["id"]: n for n in nodes}
313
+ if t in ("tree", "waterfall"):
314
+ for n in nodes:
315
+ if n.get("parent") and n["parent"] not in ids:
316
+ issues.error("parent/missing", n["id"], "parent %r does not exist" % n["parent"],
317
+ "Name an existing item, or drop parent for a top-level one.")
318
+ n["parent"] = None
319
+ for n in nodes:
320
+ seen, cur = set(), n
321
+ while cur and cur.get("parent"):
322
+ if cur["id"] in seen:
323
+ issues.error("parent/cycle", n["id"], "parents form a loop", "Each item has one parent, and the chain must end.")
324
+ n["parent"] = None
325
+ break
326
+ seen.add(cur["id"])
327
+ cur = by_id.get(cur["parent"])
328
+ if t == "timeline":
329
+ base = None
330
+ for n in nodes:
331
+ w = parse_when(n.get("at"))
332
+ if w is None:
333
+ issues.error("time/missing", n["id"], "at %r is not a time" % n.get("at"),
334
+ 'Use a date or date-time ("2026-10-03T14:05"), a time of day ("14:05") or a number.')
335
+ w = (0.0, base or "num")
336
+ if base is None:
337
+ base = w[1]
338
+ elif w[1].split(":")[0] != base.split(":")[0]:
339
+ issues.error("time/mixed", n["id"], "this time is written differently from the first one",
340
+ "Write every time the same way: all dates, all times of day, or all numbers.")
341
+ n["_t"] = (w[0], base)
342
+ n["_zone"] = w[1][4:] if w[1].startswith("abs:") else ("+0000" if w[1] == "abs" and re.search(r"(Z|[+-]00:?00)$", str(n.get("at"))) else None)
343
+ n["when"] = str(n.get("at"))
344
+ if n.get("end") is not None:
345
+ we = parse_when(n["end"])
346
+ if we is None or we[0] < w[0]:
347
+ issues.error("time/end", n["id"], "end %r is not a time after at" % n["end"], "")
348
+ else:
349
+ n["_end"] = (we[0], base)
350
+ n["when"] += " to " + str(n["end"])
351
+ if t == "waterfall":
352
+ for n in nodes:
353
+ st, en, du = parse_when(n.get("start")), parse_when(n.get("end")), n.get("duration")
354
+ if st is None:
355
+ issues.error("span/start", n["id"], "start %r is not a number or time" % n.get("start"), "")
356
+ n["_start"] = n["_end"] = 0.0
357
+ continue
358
+ k = 1000.0 if st[1].startswith("abs") or st[1] == "clock" else 1.0 # dates and times of day become ms
359
+ n["_start"] = st[0] * k
360
+ if en is not None:
361
+ n["_end"] = en[0] * k
362
+ elif isinstance(du, (int, float)) and not isinstance(du, bool):
363
+ n["_end"] = n["_start"] + float(du)
364
+ else:
365
+ issues.error("span/end", n["id"], "a span needs end or duration", "")
366
+ n["_end"] = n["_start"]
367
+ if n["_end"] < n["_start"]:
368
+ issues.error("span/order", n["id"], "the span ends before it starts", "")
369
+ for n in nodes:
370
+ p = by_id.get(n.get("parent"))
371
+ if p and "_start" in p and "_start" in n and (n["_start"] < p["_start"] - 1e-9 or n["_end"] > p["_end"] + 1e-9):
372
+ issues.warn("span/outside", n["id"], "%r runs outside its parent %r" % (n["label"], p["label"]),
373
+ "Check the times: clocks on two machines can disagree, or the child is not really under that parent.")
374
+
375
+
376
+ def verify_evidence(spec, repo_root, issues):
377
+ """Check every file citation against the repository: the file exists, the lines exist,
378
+ and a quoted fragment appears on those lines. Attach the cited lines as a snippet, except
379
+ from files that hold secrets, and with secret-looking lines masked."""
380
+ cache, total = {}, 0
381
+ items = [("node", n) for n in spec["nodes"]] + [("edge", e) for e in spec["edges"]] + [("answer", a) for a in spec["answers"]]
382
+ root = os.path.realpath(repo_root) if repo_root else None
383
+ for kind, item in items:
384
+ for e in item["evidence"]:
385
+ if "file" not in e:
386
+ continue
387
+ where = item["id"]
388
+ if not root:
389
+ e["verified"] = None
390
+ continue
391
+ path = os.path.realpath(os.path.join(root, e["file"]))
392
+ if not (path == root or path.startswith(root + os.sep)):
393
+ issues.error("evidence/outside", where, "%s is outside the repository" % e["file"], "Cite files inside --repo-root.")
394
+ e["verified"] = False
395
+ continue
396
+ if not os.path.isfile(path):
397
+ issues.error("evidence/file", where, "%s does not exist" % e["file"], "Fix the path, relative to the repository root.")
398
+ e["verified"] = False
399
+ continue
400
+ if path not in cache:
401
+ try:
402
+ with open(path, encoding="utf-8", errors="replace") as fh:
403
+ cache[path] = fh.read().splitlines()
404
+ except OSError as err:
405
+ issues.error("evidence/read", where, "cannot read %s: %s" % (e["file"], err), "")
406
+ e["verified"] = False
407
+ continue
408
+ lines = cache[path]
409
+ start, end = e.get("start"), e.get("end")
410
+ if start is not None:
411
+ if start < 1 or end < start or end > len(lines):
412
+ issues.error("evidence/lines", where, "%s has %d lines; cited %s" % (e["file"], len(lines), ev_text(e).split(":")[-1]),
413
+ "Cite lines that exist.")
414
+ e["verified"] = False
415
+ continue
416
+ span = lines[start - 1:end] if start else lines
417
+ if e.get("quote"):
418
+ flat = lambda s: re.sub(r"\s+", " ", s).strip().lower()
419
+ if flat(e["quote"]) not in flat(" ".join(span)):
420
+ issues.error("evidence/quote", where, "%r is not on %s" % (e["quote"], ev_text(e)),
421
+ "Quote text that is on the cited lines, or fix the line numbers.")
422
+ e["verified"] = False
423
+ continue
424
+ e["verified"] = True
425
+ if start and not SECRET_PATH.search(e["file"]) and total < MAX_SNIPPET_TOTAL:
426
+ shown = span[:MAX_SNIPPET_LINES]
427
+ masked = ["[line hidden: looks like a secret]" if SECRET_LINE.search(l) else l for l in shown]
428
+ e["snippet"] = "\n".join(masked) + ("\n…" if len(span) > len(shown) else "")
429
+ total += len(e["snippet"])
430
+
431
+
432
+ def merge_compare(head, base, issues):
433
+ """Mark what changed between a base spec and this one: added, removed (drawn from the
434
+ base), changed (same id, different label, kind, group, note or ends)."""
435
+ t = head["type"]
436
+ if base["type"] != t:
437
+ issues.error("compare/type", "compare", "the base is a %s diagram and this is a %s" % (base["type"], t), "Compare like with like.")
438
+ return
439
+ bn = {n["id"]: n for n in base["nodes"]}
440
+ hn = {n["id"] for n in head["nodes"]}
441
+ for n in head["nodes"]:
442
+ if n.get("status"):
443
+ continue
444
+ if n["id"] not in bn:
445
+ n["status"] = "added"
446
+ elif any(str(n.get(k) or "") != str(bn[n["id"]].get(k) or "") for k in ("label", "kind", "group", "note")):
447
+ n["status"] = "changed"
448
+ gids = {g["id"] for g in head["groups"]}
449
+ for g in base["groups"]:
450
+ if g["id"] not in gids:
451
+ head["groups"].append(dict(g, status="removed"))
452
+ gids.add(g["id"])
453
+ for nid, n in bn.items():
454
+ if nid not in hn:
455
+ head["nodes"].append(dict(n, status="removed"))
456
+ ekey = lambda e: (e.get("from"), e.get("to"), e.get("label", ""))
457
+ be = {ekey(e): e for e in base["edges"]}
458
+ he = {ekey(e) for e in head["edges"]}
459
+ for e in head["edges"]:
460
+ if not e.get("status") and ekey(e) not in be:
461
+ e["status"] = "added"
462
+ used = {e["id"] for e in head["edges"]}
463
+ for k, e in be.items():
464
+ if k not in he:
465
+ e = dict(e, status="removed")
466
+ while e["id"] in used:
467
+ e["id"] += "_base"
468
+ used.add(e["id"])
469
+ head["edges"].append(e)
470
+ head["compared"] = True
471
+
472
+
473
+ # --------------------------------------------------------------------------- text metrics
474
+
475
+ def text_w(s, size=13.0):
476
+ """Width estimate for a sans-serif label; wide letters count more. Deliberately generous."""
477
+ w = 0.0
478
+ for ch in s:
479
+ if ch in "il.,:;|!'`":
480
+ w += 0.30
481
+ elif ch in "mwMW@":
482
+ w += 0.86
483
+ elif ch.isupper() or ch.isdigit():
484
+ w += 0.64
485
+ elif ord(ch) > 0x2E80:
486
+ w += 1.0
487
+ else:
488
+ w += 0.55
489
+ return w * size
490
+
491
+
492
+ def wrap(s, max_w, size=13.0, max_lines=2):
493
+ words, lines, cur = s.split(), [], ""
494
+ for wd in words:
495
+ trial = (cur + " " + wd).strip()
496
+ if text_w(trial, size) <= max_w or not cur:
497
+ cur = trial
498
+ else:
499
+ lines.append(cur)
500
+ cur = wd
501
+ if cur:
502
+ lines.append(cur)
503
+ if len(lines) > max_lines:
504
+ lines = lines[:max_lines]
505
+ last = lines[-1]
506
+ while last and text_w(last + "…", size) > max_w:
507
+ last = last[:-1]
508
+ lines[-1] = last.rstrip() + "…"
509
+ for i, l in enumerate(lines): # a single long word
510
+ if text_w(l, size) > max_w * 1.02:
511
+ while l and text_w(l + "…", size) > max_w:
512
+ l = l[:-1]
513
+ lines[i] = l + "…"
514
+ return lines or [""]
515
+
516
+
517
+ def member_text(f):
518
+ vis = {"public": "+", "private": "-", "protected": "#", "package": "~"}.get(str(f.get("visibility", "")), str(f.get("visibility", "")))
519
+ typ = str(f.get("type") or "")
520
+ return ("%s %s" % (vis, f.get("name", ""))).strip() + (": " + typ if typ else "")
521
+
522
+
523
+ def size_node(n):
524
+ shape = KINDS.get(n["kind"], ("rect", "slate"))[0]
525
+ n["shape"] = shape
526
+ if shape == "table":
527
+ rows = n["fields"][:24]
528
+ widest = max([text_w(n["label"], 13) + 24] + [text_w(f.get("name", ""), 12) + text_w(str(f.get("type", "")), 11)
529
+ + (52 if f.get("key") else 0) + 40 for f in rows])
530
+ n["w"] = int(min(max(widest, 170), 320))
531
+ n["lines"] = [n["label"]]
532
+ n["h"] = 32 + 20 * len(rows) + (18 if len(n["fields"]) > 24 else 0) + (4 if rows else 6)
533
+ return
534
+ if shape == "classbox":
535
+ stereo = "" if n["kind"] == "class" else "\u00ab%s\u00bb" % n["kind"]
536
+ n["stereo"] = stereo
537
+ n["member_lines"] = [member_text(f) for f in n["fields"][:30]]
538
+ widest = max([text_w(n["label"], 13) + 30, text_w(stereo, 11) + 30] + [text_w(l, 12) + 24 for l in n["member_lines"]])
539
+ n["w"] = int(min(max(widest, 150), 380))
540
+ n["lines"] = [n["label"]]
541
+ n["h"] = 30 + (14 if stereo else 0) + 18 * len(n["member_lines"]) + (8 if n["member_lines"] else 0) + (16 if len(n["fields"]) > 30 else 0)
542
+ return
543
+ if shape in ("initial", "final"):
544
+ n["lines"] = wrap(n["label"], 120, 11) if n.get("label_shown", n["label"] != n["id"]) else []
545
+ n["w"], n["h"] = 34, 34
546
+ return
547
+ max_w = 190 if shape != "diamond" else 150
548
+ n["lines"] = wrap(n["label"], max_w, 13, 2)
549
+ n["note_lines"] = wrap(str(n.get("note") or ""), max_w, 11, 1) if n.get("note") else []
550
+ tw = max([text_w(l, 13) for l in n["lines"]] + [text_w(l, 11) for l in n["note_lines"]])
551
+ h = 16 + 17 * len(n["lines"]) + 15 * len(n["note_lines"]) + 6
552
+ if shape == "diamond":
553
+ n["w"], n["h"] = int(max(tw * 1.55 + 40, 120)), int(max(h * 1.5, 76))
554
+ elif shape == "person":
555
+ n["w"], n["h"] = int(max(tw + 30, 110)), int(h + 26)
556
+ elif shape == "cylinder":
557
+ n["w"], n["h"] = int(max(tw + 34, 120)), int(h + 14)
558
+ elif shape == "terminal":
559
+ n["w"], n["h"] = int(max(tw + 44, 110)), int(max(h, 44))
560
+ else:
561
+ n["w"], n["h"] = int(max(tw + 34, 120)), int(max(h, 50))
562
+
563
+
564
+ # --------------------------------------------------------------------------- graph layout
565
+
566
+ class Axis(object):
567
+ """Layout runs in (main, cross) coordinates: main is the direction of flow (left to right
568
+ by default), cross is across it. LR maps main to x; TB maps main to y."""
569
+
570
+ def __init__(self, direction):
571
+ self.lr = direction == "LR"
572
+
573
+ def m_size(self, n):
574
+ return n["w"] if self.lr else n["h"]
575
+
576
+ def c_size(self, n):
577
+ return n["h"] if self.lr else n["w"]
578
+
579
+ def xy(self, m, c):
580
+ return (m, c) if self.lr else (c, m)
581
+
582
+
583
+ def layered(spec):
584
+ nodes = spec["nodes"]
585
+ by_id = {n["id"]: n for n in nodes}
586
+ edges = [e for e in spec["edges"] if e.get("from") in by_id and e.get("to") in by_id]
587
+ order = {n["id"]: i for i, n in enumerate(nodes)}
588
+ succ = {n["id"]: [] for n in nodes}
589
+ for e in edges:
590
+ if e["from"] != e["to"]:
591
+ succ[e["from"]].append(e["to"])
592
+ # break cycles: edges that close a loop in author order count as back edges
593
+ back, state = set(), {}
594
+
595
+ def dfs(u):
596
+ state[u] = 1
597
+ for v in sorted(succ[u], key=order.get):
598
+ if state.get(v) == 1:
599
+ back.add((u, v))
600
+ elif not state.get(v):
601
+ dfs(v)
602
+ state[u] = 2
603
+ sys.setrecursionlimit(10000)
604
+ for n in nodes:
605
+ if not state.get(n["id"]):
606
+ dfs(n["id"])
607
+ dag = {u: [v for v in vs if (u, v) not in back] for u, vs in succ.items()}
608
+ pred = {n["id"]: [] for n in nodes}
609
+ for u, vs in dag.items():
610
+ for v in vs:
611
+ pred[v].append(u)
612
+ layer = {}
613
+
614
+ def longest(u):
615
+ if u in layer:
616
+ return layer[u]
617
+ layer[u] = 0
618
+ layer[u] = max([longest(p) + 1 for p in pred[u]] or [0])
619
+ return layer[u]
620
+ for n in nodes:
621
+ longest(n["id"])
622
+ for _ in range(2): # pull sources next to what they feed
623
+ for n in nodes:
624
+ u = n["id"]
625
+ if not pred[u] and dag[u]:
626
+ layer[u] = max(layer[u], min(layer[v] for v in dag[u]) - 1)
627
+ return layer, edges, back
628
+
629
+
630
+ def group_tree(spec):
631
+ groups = {g["id"]: g for g in spec["groups"]}
632
+ kids = {g["id"]: [] for g in spec["groups"]}
633
+ top = []
634
+ for g in spec["groups"]:
635
+ (kids[g["parent"]] if g.get("parent") else top).append(g["id"])
636
+ return groups, kids, top
637
+
638
+
639
+ def bands_for(spec):
640
+ """Groups become bands across the flow, so their boxes never overlap. Order follows the
641
+ author's: the first node mentioned decides where its group sits."""
642
+ groups, kids, top = group_tree(spec)
643
+ first = {}
644
+ for i, n in enumerate(spec["nodes"]):
645
+ g = n.get("group")
646
+ while g:
647
+ first.setdefault(g, i)
648
+ g = groups[g].get("parent")
649
+ members = {}
650
+ for n in spec["nodes"]:
651
+ members.setdefault(n.get("group"), []).append(n["id"])
652
+ bands, spans = [], {}
653
+
654
+ def walk(gid, depth):
655
+ start = len(bands)
656
+ if members.get(gid):
657
+ bands.append({"group": gid, "depth": depth})
658
+ for k in sorted(kids[gid], key=lambda x: first.get(x, 1e9)):
659
+ walk(k, depth + 1)
660
+ if len(bands) > start:
661
+ spans[gid] = (start, len(bands) - 1, depth)
662
+ units = [("g", g) for g in top] + ([("n", None)] if members.get(None) else [])
663
+ 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"))
664
+ for kind, gid in sorted(units, key=pos):
665
+ if kind == "n":
666
+ bands.append({"group": None, "depth": 0})
667
+ else:
668
+ walk(gid, 1)
669
+ return bands, spans
670
+
671
+
672
+ def layout_graph(spec):
673
+ ax = Axis(spec["direction"])
674
+ nodes = spec["nodes"]
675
+ for n in nodes:
676
+ size_node(n)
677
+ by_id = {n["id"]: n for n in nodes}
678
+ layer, edges, back = layered(spec)
679
+ bands, spans = bands_for(spec)
680
+ band_of = {}
681
+ for bi, b in enumerate(bands):
682
+ for n in nodes:
683
+ if n.get("group") == b["group"]:
684
+ band_of[n["id"]] = bi
685
+ n_layers = max(layer.values()) + 1 if layer else 1
686
+ cells = {}
687
+ for n in nodes:
688
+ cells.setdefault((band_of[n["id"]], layer[n["id"]]), []).append(n["id"])
689
+ order = {n["id"]: i for i, n in enumerate(nodes)}
690
+ for k in cells:
691
+ cells[k].sort(key=order.get)
692
+ nbrs = {n["id"]: [] for n in nodes}
693
+ for e in edges:
694
+ if e["from"] != e["to"]:
695
+ nbrs[e["from"]].append(e["to"])
696
+ nbrs[e["to"]].append(e["from"])
697
+
698
+ def rank():
699
+ r, i = {}, 0
700
+ for bi in range(len(bands)):
701
+ depth = max([len(cells.get((bi, l), [])) for l in range(n_layers)] or [1])
702
+ for l in range(n_layers):
703
+ for j, u in enumerate(cells.get((bi, l), [])):
704
+ r[u] = i + j + (depth - len(cells.get((bi, l), []))) / 2.0
705
+ i += depth + 1
706
+ return r
707
+ for sweep in range(6): # barycentre ordering inside each band and layer
708
+ r = rank()
709
+ layers = range(n_layers) if sweep % 2 == 0 else range(n_layers - 1, -1, -1)
710
+ for l in layers:
711
+ for bi in range(len(bands)):
712
+ c = cells.get((bi, l))
713
+ if not c or len(c) < 2:
714
+ continue
715
+ key = {}
716
+ for u in c:
717
+ ns = [r[v] for v in nbrs[u] if layer[v] != l]
718
+ key[u] = sum(ns) / len(ns) if ns else r[u]
719
+ c.sort(key=lambda u: (key[u], order[u]))
720
+ return ax, by_id, layer, edges, back, bands, spans, cells, n_layers
721
+
722
+
723
+ def grid(n_layers, per_row):
724
+ """Layer l sits in row l // cols, column l % cols. One row unless the flow wraps."""
725
+ cols = max(1, min(per_row, n_layers))
726
+ return cols, -(-n_layers // cols)
727
+
728
+
729
+ def place(spec, geo, gap_m, gap_c, per_row):
730
+ """Coordinates from the ordering. A long flow wraps onto rows, like text: layer l is in
731
+ row l // cols, column l % cols. gap_m[k] is the space before column k (gap_m[cols] is after
732
+ the last); route lanes run there, through every row. gap_c[r * (bands + 1) + i] is the
733
+ corridor before band i of row r (i == bands is after the row's last band); long routes
734
+ and the routes from one row to the next run there. Neither ever holds a node."""
735
+ ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
736
+ lr = ax.lr
737
+ pad, head = 18, 26
738
+ cols, rows = grid(n_layers, per_row)
739
+ nb = len(bands)
740
+ col_m = [0] * cols
741
+ for n in spec["nodes"]:
742
+ k = layer[n["id"]] % cols
743
+ col_m[k] = max(col_m[k], ax.m_size(n))
744
+ max_depth = max([d for (_, _, d) in spans.values()] or [0])
745
+ m = 20 + (pad + (0 if lr else head)) * max_depth
746
+ col_start, gap_mid = [], []
747
+ for k in range(cols):
748
+ gap_mid.append(m + gap_m[k] / 2.0)
749
+ m += gap_m[k]
750
+ col_start.append(m)
751
+ m += col_m[k]
752
+ gap_mid.append(m + gap_m[cols] / 2.0)
753
+ m += gap_m[cols] + (pad + (0 if lr else head)) * max_depth + 20
754
+ row_layers = [range(r * cols, min((r + 1) * cols, n_layers)) for r in range(rows)]
755
+ present = [[any(cells.get((bi, l)) for l in row_layers[r]) for bi in range(nb)] for r in range(rows)]
756
+
757
+ def span_in_row(r, s_, e_):
758
+ bis = [bi for bi in range(s_, e_ + 1) if present[r][bi]]
759
+ return (bis[0], bis[-1]) if bis else None
760
+ opening = lambda r, bi: sorted(d for (s_, e_, d) in spans.values() if (span_in_row(r, s_, e_) or (None,))[0] == bi)
761
+ closing = lambda r, bi: [d for (s_, e_, d) in spans.values() if (span_in_row(r, s_, e_) or (None, None))[1] == bi]
762
+ band_start, band_size, corridors, c = {}, {}, [], 20.0
763
+ for r in range(rows):
764
+ for bi in range(nb):
765
+ if not present[r][bi]:
766
+ continue
767
+ idx = r * (nb + 1) + bi
768
+ corridors.append((c + gap_c[idx] / 2.0, idx))
769
+ c += gap_c[idx] + len(opening(r, bi)) * (head + 6 if lr else pad)
770
+ band_start[(r, bi)] = c
771
+ size = 0
772
+ for l in row_layers[r]:
773
+ stack = cells.get((bi, l), [])
774
+ size = max(size, sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0))
775
+ band_size[(r, bi)] = size
776
+ c += size + pad * len(closing(r, bi))
777
+ idx = r * (nb + 1) + nb
778
+ corridors.append((c + gap_c[idx] / 2.0, idx))
779
+ c += gap_c[idx]
780
+ c += 20
781
+ for (r, bi), start in band_start.items():
782
+ for l in row_layers[r]:
783
+ stack = cells.get((bi, l), [])
784
+ total = sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0)
785
+ cc = start + (band_size[(r, bi)] - total) / 2.0
786
+ k = l % cols
787
+ for u in stack:
788
+ n = by_id[u]
789
+ n["m"] = col_start[k] + (col_m[k] - ax.m_size(n)) / 2.0
790
+ n["c"] = cc
791
+ cc += ax.c_size(n) + 26
792
+ boxes = []
793
+ for gid, (s_, e_, d) in spans.items():
794
+ first_row = None
795
+ for r in range(rows):
796
+ sp_ = span_in_row(r, s_, e_)
797
+ if not sp_:
798
+ continue
799
+ mem = [n for n in spec["nodes"] if layer[n["id"]] in row_layers[r] and band_of_group(n, gid, spec)]
800
+ if not mem:
801
+ continue
802
+ first_row = r if first_row is None else first_row
803
+ outer = max_depth - d + 1 # how many group borders sit inside this one, plus one
804
+ m0 = min(n["m"] for n in mem) - pad * outer - (0 if lr else head * outer)
805
+ m1 = max(n["m"] + ax.m_size(n) for n in mem) + pad * outer
806
+ c0 = band_start[(r, sp_[0])] - sum(1 for x in opening(r, sp_[0]) if x >= d) * (head + 6 if lr else pad)
807
+ c1 = band_start[(r, sp_[1])] + band_size[(r, sp_[1])] + pad * sum(1 for x in closing(r, sp_[1]) if x >= d)
808
+ boxes.append({"id": gid, "m0": m0, "m1": m1, "c0": c0, "c1": c1, "depth": d, "cont": r != first_row})
809
+ return {"groups": boxes, "corridors": [cv for cv, _ in corridors], "corridor_idx": [i for _, i in corridors],
810
+ "gaps": gap_mid, "extent": (m, c), "cols": cols, "rows": rows}
811
+
812
+
813
+ def band_of_group(n, gid, spec):
814
+ groups = {g["id"]: g for g in spec["groups"]}
815
+ g = n.get("group")
816
+ while g:
817
+ if g == gid:
818
+ return True
819
+ g = groups[g].get("parent") if g in groups else None
820
+ return False
821
+
822
+
823
+ def route(spec, geo, pl):
824
+ """Orthogonal routes that never cross a node: lanes run in the gaps between layers and in
825
+ the corridors between bands, which hold no nodes by construction."""
826
+ ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
827
+ cols = pl["cols"]
828
+ lanes_v = {} # gap key -> list of (lo, hi, edge id)
829
+ lanes_h = {}
830
+ plans = []
831
+ side_use = {}
832
+
833
+ def box(n):
834
+ return n["m"], n["c"], n["m"] + ax.m_size(n), n["c"] + ax.c_size(n)
835
+
836
+ for e in edges:
837
+ s, t = by_id[e["from"]], by_id[e["to"]]
838
+ ls, lt = layer[s["id"]], layer[t["id"]]
839
+ sm0, sc0, sm1, sc1 = box(s)
840
+ tm0, tc0, tm1, tc1 = box(t)
841
+ scen, tcen = (sc0 + sc1) / 2.0, (tc0 + tc1) / 2.0
842
+ if s["id"] == t["id"]:
843
+ plans.append({"e": e, "kind": "self"})
844
+ continue
845
+ same_row = ls // cols == lt // cols
846
+ if lt == ls + 1 and same_row:
847
+ plans.append({"e": e, "kind": "adjacent", "gap": ("v", lt % cols), "out": "end", "in": "start"})
848
+ elif lt > ls + 1 and same_row:
849
+ plans.append({"e": e, "kind": "multi", "out": "end", "in": "start"})
850
+ elif lt == ls:
851
+ plans.append({"e": e, "kind": "same", "gap": ("v", ls % cols + 1), "out": "end", "in": "end"})
852
+ else: # backwards, or on to a later row
853
+ plans.append({"e": e, "kind": "corridor", "g1": ("v", ls % cols + 1), "g2": ("v", lt % cols), "out": "end", "in": "start"})
854
+ # ports: spread the edges that share a node side, ordered by the far end
855
+ for p in plans:
856
+ if p["kind"] == "self":
857
+ continue
858
+ e = p["e"]
859
+ side_use.setdefault((e["from"], p["out"]), []).append((p, "s"))
860
+ side_use.setdefault((e["to"], p["in"]), []).append((p, "t"))
861
+ for (nid, side), lst in side_use.items():
862
+ n = by_id[nid]
863
+ m0, c0, m1, c1 = box(n)
864
+
865
+ def far(item):
866
+ p, which = item
867
+ other = by_id[p["e"]["to"] if which == "s" else p["e"]["from"]]
868
+ return other["c"] + ax.c_size(other) / 2.0
869
+ lst.sort(key=far)
870
+ k = len(lst)
871
+ for i, (p, which) in enumerate(lst):
872
+ field = p["e"].get("from_field" if which == "s" else "to_field")
873
+ fidx = next((j for j, f in enumerate(n["fields"][:24]) if f.get("name") == field), None) if field else None
874
+ if fidx is not None and n["shape"] == "table":
875
+ cpos = c0 + 32 + 20 * fidx + 10
876
+ else:
877
+ f = 0.36 if n["shape"] in ("diamond", "initial", "final") else 0.22
878
+ lo, hi = (c0 + (c1 - c0) * f, c1 - (c1 - c0) * f) if k > 1 else ((c0 + c1) / 2.0,) * 2
879
+ cpos = lo + (hi - lo) * (i / (k - 1.0) if k > 1 else 0.5)
880
+ mpos = m1 if side == "end" else m0
881
+ half_c, mid_c = (c1 - c0) / 2.0, (c0 + c1) / 2.0
882
+ off = min(abs(cpos - mid_c), half_c)
883
+ if n["shape"] == "diamond":
884
+ inset = off / half_c * ((m1 - m0) / 2.0) if half_c else 0
885
+ elif n["shape"] == "terminal" and ax.lr:
886
+ inset = half_c - (half_c ** 2 - off ** 2) ** 0.5
887
+ elif n["shape"] in ("initial", "final"):
888
+ inset = half_c - (max(half_c ** 2 - off ** 2, 0)) ** 0.5
889
+ else:
890
+ inset = 0
891
+ mpos += -inset if side == "end" else inset
892
+ p["sp" if which == "s" else "tp"] = (mpos, cpos)
893
+ def clear(cv, l0, l1):
894
+ for l in range(l0, l1):
895
+ for bi in range(len(bands)):
896
+ for u in cells.get((bi, l), []):
897
+ _, c0, _, c1 = box(by_id[u])
898
+ if c0 - 8 <= cv <= c1 + 8:
899
+ return False
900
+ return True
901
+ for p in plans: # a long forward edge goes straight when nothing sits at its port's height
902
+ if p["kind"] != "multi":
903
+ continue
904
+ ls, lt = layer[p["e"]["from"]], layer[p["e"]["to"]]
905
+ if clear(p["sp"][1], ls + 1, lt):
906
+ p.update(kind="straight", gap=("v", lt % cols))
907
+ elif clear(p["tp"][1], ls + 1, lt):
908
+ p.update(kind="straight", gap=("v", ls % cols + 1))
909
+ else:
910
+ p.update(kind="corridor", g1=("v", ls % cols + 1), g2=("v", lt % cols))
911
+ for p in plans:
912
+ if p["kind"] == "self":
913
+ continue
914
+ sp, tp = p["sp"], p["tp"]
915
+ if p["kind"] in ("adjacent", "same"):
916
+ lanes_v.setdefault(p["gap"], []).append([min(sp[1], tp[1]), max(sp[1], tp[1]), p])
917
+ elif p["kind"] == "straight":
918
+ lanes_v.setdefault(p["gap"], []).append([min(sp[1], tp[1]), max(sp[1], tp[1]), p])
919
+ else:
920
+ cv = (sp[1] + tp[1]) / 2.0
921
+ corr = min(range(len(pl["corridors"])), key=lambda i: (abs(pl["corridors"][i] - sp[1]) + abs(pl["corridors"][i] - tp[1]),
922
+ abs(pl["corridors"][i] - cv)))
923
+ p["corr"] = corr
924
+ cy = pl["corridors"][corr]
925
+ lanes_v.setdefault(p["g1"], []).append([min(sp[1], cy), max(sp[1], cy), p, "g1"])
926
+ lanes_v.setdefault(p["g2"], []).append([min(tp[1], cy), max(tp[1], cy), p, "g2"])
927
+ g1m = p["g1"][1]
928
+ g2m = p["g2"][1]
929
+ lanes_h.setdefault(corr, []).append([min(g1m, g2m), max(g1m, g2m), p])
930
+ need_v, need_h = {}, {}
931
+ for key, segs in lanes_v.items():
932
+ segs.sort(key=lambda s: (s[0], s[1]))
933
+ ends = []
934
+ for sg in segs:
935
+ for i, end in enumerate(ends):
936
+ if end < sg[0] - 10:
937
+ ends[i] = sg[1]
938
+ sg.append(i)
939
+ break
940
+ else:
941
+ ends.append(sg[1])
942
+ sg.append(len(ends) - 1)
943
+ need_v[key] = len(ends)
944
+ for key, segs in lanes_h.items():
945
+ segs.sort(key=lambda s: (s[0], s[1]))
946
+ ends = []
947
+ for sg in segs:
948
+ for i, end in enumerate(ends):
949
+ if end < sg[0] - 0.05:
950
+ ends[i] = sg[1]
951
+ sg.append(i)
952
+ break
953
+ else:
954
+ ends.append(sg[1])
955
+ sg.append(len(ends) - 1)
956
+ need_h[key] = len(ends)
957
+ return plans, lanes_v, lanes_h, need_v, need_h
958
+
959
+
960
+ def lane_pos(center, idx, count, step=9):
961
+ return center + (idx - (count - 1) / 2.0) * step
962
+
963
+
964
+ def build_paths(spec, geo, pl, plans, lanes_v, lanes_h, need_v, need_h):
965
+ ax = geo[0]
966
+ by_id = geo[1]
967
+ vlane, hlane = {}, {}
968
+ for key, segs in lanes_v.items():
969
+ for sg in segs:
970
+ tag = sg[3] if len(sg) == 5 else "v"
971
+ vlane[(id(sg[2]), tag)] = (key, sg[-1])
972
+ for key, segs in lanes_h.items():
973
+ for sg in segs:
974
+ hlane[id(sg[2])] = (key, sg[-1])
975
+
976
+ def vx(p, tag):
977
+ key, idx = vlane[(id(p), tag)]
978
+ return lane_pos(pl["gaps"][key[1]], idx, need_v[key])
979
+ for p in plans:
980
+ e = p["e"]
981
+ if p["kind"] == "self":
982
+ n = by_id[e["from"]]
983
+ m1, c0 = n["m"] + ax.m_size(n), n["c"]
984
+ mid = n["m"] + ax.m_size(n) * 0.7
985
+ 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)]
986
+ else:
987
+ sp, tp = p["sp"], p["tp"]
988
+ if p["kind"] == "adjacent":
989
+ x = vx(p, "v")
990
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
991
+ elif p["kind"] == "same":
992
+ x = vx(p, "v")
993
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
994
+ elif p["kind"] == "straight":
995
+ x = vx(p, "v")
996
+ pts = [sp, (x, sp[1]), (x, tp[1]), tp]
997
+ else:
998
+ key, idx = hlane[id(p)]
999
+ cy = lane_pos(pl["corridors"][key], idx, need_h[key])
1000
+ x1, x2 = vx(p, "g1"), vx(p, "g2")
1001
+ pts = [sp, (x1, sp[1]), (x1, cy), (x2, cy), (x2, tp[1]), tp]
1002
+ clean = [pts[0]]
1003
+ for q in pts[1:]:
1004
+ if abs(q[0] - clean[-1][0]) > 0.5 or abs(q[1] - clean[-1][1]) > 0.5:
1005
+ clean.append(q)
1006
+ e["pts"] = [ax.xy(m, c) for m, c in clean]
1007
+
1008
+
1009
+ VIEW_W, VIEW_H = 1360.0, 720.0 # the canvas on a laptop screen; wrapping aims to fill it
1010
+
1011
+
1012
+ def run_graph(spec, geo, per_row):
1013
+ ax, n_layers, nb = geo[0], geo[8], len(geo[5])
1014
+ cols, rows = grid(n_layers, per_row)
1015
+ edge_gap = lambda k: 24 if k in (0, cols) else 72
1016
+ row_gap = lambda i: 44 if i % (nb + 1) == 0 and i > 0 else 30
1017
+ gap_m = [edge_gap(k) for k in range(cols + 1)]
1018
+ gap_c = [row_gap(i) for i in range(rows * (nb + 1))]
1019
+ for _ in range(4):
1020
+ pl = place(spec, geo, gap_m, gap_c, per_row)
1021
+ plans, lv, lh, need_v, need_h = route(spec, geo, pl)
1022
+ new_m = [max(edge_gap(k), 30 + 9 * need_v.get(("v", k), 0)) for k in range(cols + 1)]
1023
+ new_c = list(gap_c)
1024
+ for j, idx in enumerate(pl["corridor_idx"]):
1025
+ new_c[idx] = max(row_gap(idx), 20 + 9 * need_h.get(j, 0))
1026
+ if new_m == gap_m and new_c == gap_c:
1027
+ break
1028
+ gap_m, gap_c = new_m, new_c
1029
+ build_paths(spec, geo, pl, plans, lv, lh, need_v, need_h)
1030
+ for n in spec["nodes"]:
1031
+ n["x"], n["y"] = ax.xy(n["m"], n["c"])
1032
+ for g in pl["groups"]:
1033
+ x0, y0 = ax.xy(g["m0"], g["c0"])
1034
+ x1, y1 = ax.xy(g["m1"], g["c1"])
1035
+ g.update({"x": min(x0, x1), "y": min(y0, y1), "w": abs(x1 - x0), "h": abs(y1 - y0)})
1036
+ 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"]]
1037
+ boxes += [(p[0], p[1], p[0], p[1]) for e in spec["edges"] for p in e.get("pts", [])]
1038
+ boxes += [(g["x"], g["y"], g["x"] + g["w"], g["y"] + g["h"]) for g in pl["groups"]]
1039
+ minx, miny = min(b[0] for b in boxes), min(b[1] for b in boxes)
1040
+ dx, dy = 30 - minx, 30 - miny
1041
+ for n in spec["nodes"]:
1042
+ n["x"] += dx
1043
+ n["y"] += dy
1044
+ for e in spec["edges"]:
1045
+ e["pts"] = [(x + dx, y + dy) for x, y in e.get("pts", [])]
1046
+ for g in pl["groups"]:
1047
+ g["x"] += dx
1048
+ g["y"] += dy
1049
+ W = max(b[2] for b in boxes) + dx + 40
1050
+ H = max(b[3] for b in boxes) + dy + 40
1051
+ return {"w": W, "h": H, "groups": pl["groups"], "rows": rows}
1052
+
1053
+
1054
+ def layout_graph_full(spec):
1055
+ """Lay the graph out; a long left-to-right flow wraps onto as many rows as keep its text
1056
+ largest on a laptop screen. "wrap": false keeps one row; a number sets layers per row."""
1057
+ geo = layout_graph(spec)
1058
+ n_layers = geo[8]
1059
+ wrap = spec.get("wrap")
1060
+ if wrap is False or spec["direction"] != "LR" or n_layers < 6:
1061
+ options = [n_layers]
1062
+ elif isinstance(wrap, int) and not isinstance(wrap, bool) and wrap > 0:
1063
+ options = [max(2, wrap)]
1064
+ else:
1065
+ options = sorted({max(3, -(-n_layers // r)) for r in range(1, 5)}, reverse=True)
1066
+ best = None
1067
+ for per_row in options:
1068
+ out = run_graph(spec, geo, per_row)
1069
+ score = min(VIEW_W / out["w"], VIEW_H / out["h"], 1.0) * (0.95 ** (out["rows"] - 1))
1070
+ if best is None or score > best[0] + 1e-9:
1071
+ best = (score, per_row)
1072
+ return run_graph(spec, geo, best[1]) if len(options) > 1 else out
1073
+
1074
+
1075
+ # --------------------------------------------------------------------------- sequence layout
1076
+
1077
+ def layout_sequence(spec):
1078
+ parts = spec["nodes"]
1079
+ idx = {p["id"]: i for i, p in enumerate(parts)}
1080
+ for p in parts:
1081
+ p["lines"] = wrap(p["label"], 150, 13, 2)
1082
+ p["note_lines"] = wrap(str(p.get("note") or ""), 150, 11, 1) if p.get("note") else []
1083
+ p["w"] = int(max(max(text_w(l, 13) for l in p["lines"] + [""]) + 30, 110))
1084
+ p["shape"] = "person" if p["kind"] in ("person", "actor") else "rect"
1085
+ p["h"] = 22 + 17 * len(p["lines"]) + 14 * len(p["note_lines"]) + (24 if p["shape"] == "person" else 0)
1086
+ msgs = [m for m in spec["edges"] if m.get("from") in idx and m.get("to") in idx]
1087
+ need = [max(p["w"], 120) for p in parts]
1088
+ spacing = [0.0] * max(len(parts) - 1, 0)
1089
+ for m in msgs:
1090
+ a, b = sorted((idx[m["from"]], idx[m["to"]]))
1091
+ lw = text_w(m["label"], 12) + 40
1092
+ if a == b:
1093
+ if a < len(spacing):
1094
+ spacing[a] = max(spacing[a], lw + 40)
1095
+ continue
1096
+ per = lw / float(b - a)
1097
+ for k in range(a, b):
1098
+ spacing[k] = max(spacing[k], per)
1099
+ xs, x = [], 40.0
1100
+ for i, p in enumerate(parts):
1101
+ xs.append(x + p["w"] / 2.0)
1102
+ if i < len(spacing):
1103
+ x += max(p["w"] / 2.0 + parts[i + 1]["w"] / 2.0 + 40, spacing[i], 170)
1104
+ head_h = max(p["h"] for p in parts) + 30
1105
+ group_pad = 30 if spec["groups"] else 0
1106
+ top = 30 + group_pad
1107
+ for i, p in enumerate(parts):
1108
+ p["cx"] = xs[i]
1109
+ p["x"], p["y"] = xs[i] - p["w"] / 2.0, top
1110
+ y = top + head_h + 20
1111
+ frame_open = {}
1112
+ for f in spec["frames"]:
1113
+ frame_open.setdefault(f.get("from"), []).append(f)
1114
+ for m in msgs:
1115
+ for f in frame_open.get(m["id"], []):
1116
+ y += 26
1117
+ f["y0"] = y - 22
1118
+ m["lines"] = wrap(m["label"], max(abs(xs[idx[m["to"]]] - xs[idx[m["from"]]]) - 20, 150), 12, 2)
1119
+ y += 14 * len(m["lines"])
1120
+ x1, x2 = xs[idx[m["from"]]], xs[idx[m["to"]]]
1121
+ if x1 == x2:
1122
+ m["pts"] = [(x1, y), (x1 + 40, y), (x1 + 40, y + 18), (x1, y + 18)]
1123
+ m["label_at"] = (x1 + 46, y - 4 - 14 * (len(m["lines"]) - 1), "start")
1124
+ y += 18
1125
+ else:
1126
+ m["pts"] = [(x1, y), (x2, y)]
1127
+ m["label_at"] = ((x1 + x2) / 2.0, y - 6 - 14 * (len(m["lines"]) - 1), "middle")
1128
+ m["row_y"] = y
1129
+ y += 26
1130
+ for f in spec["frames"]:
1131
+ if f.get("to") == m["id"]:
1132
+ f["y1"] = y - 6
1133
+ y += 10
1134
+ for f in spec["frames"]:
1135
+ inv = [m for m in msgs if f.get("y0", 0) <= m.get("row_y", -1) <= f.get("y1", 0)]
1136
+ cols = [xs[idx[m["from"]]] for m in inv] + [xs[idx[m["to"]]] for m in inv] or xs[:1]
1137
+ f["x0"], f["x1"] = min(cols) - 60, max(cols) + 60
1138
+ end_y = y + 10
1139
+ boxes = []
1140
+ for g in spec["groups"]:
1141
+ mem = [p for p in parts if p.get("group") == g["id"]]
1142
+ if mem:
1143
+ boxes.append({"id": g["id"], "x": min(p["x"] for p in mem) - 14, "y": top - 26,
1144
+ "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})
1145
+ W = max(p["x"] + p["w"] for p in parts) + 60
1146
+ 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"]])
1147
+ return {"w": W, "h": end_y + 40, "groups": boxes, "lifeline": (top + head_h - 30, end_y)}
1148
+
1149
+
1150
+ # --------------------------------------------------------------------------- time
1151
+
1152
+ def parse_when(v):
1153
+ """A point in time as (seconds, kind). kind is "abs" for a date or date-time, "clock" for
1154
+ a time of day ("14:05"), "num" for a plain number. Returns None when it can't be read."""
1155
+ if isinstance(v, bool):
1156
+ return None
1157
+ if isinstance(v, (int, float)):
1158
+ return float(v), "num"
1159
+ s = str(v).strip()
1160
+ m = re.match(r"^(\d{1,2}):(\d{2})(?::(\d{2})(\.\d+)?)?$", s)
1161
+ if m:
1162
+ return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3) or 0) + float(m.group(4) or 0), "clock"
1163
+ iso = s.replace("Z", "+00:00")
1164
+ if " " in iso and "T" not in iso:
1165
+ iso = iso.replace(" ", "T", 1)
1166
+ try:
1167
+ dt = datetime.datetime.fromisoformat(iso)
1168
+ except ValueError:
1169
+ try:
1170
+ return float(s), "num"
1171
+ except ValueError:
1172
+ return None
1173
+ if dt.tzinfo is None:
1174
+ dt = dt.replace(tzinfo=datetime.timezone.utc) # wall-clock time, shown as written
1175
+ return dt.timestamp(), "abs"
1176
+ return dt.timestamp(), "abs:" + dt.strftime("%z")
1177
+
1178
+
1179
+ def fmt_when(t, kind, interval=60):
1180
+ if kind == "num":
1181
+ return ("%g" % round(t, 3))
1182
+ if kind == "clock":
1183
+ t = int(round(t))
1184
+ return "%02d:%02d" % ((t // 3600) % 24, (t // 60) % 60) + (":%02d" % (t % 60) if interval < 60 else "")
1185
+ tz = datetime.timezone.utc
1186
+ if kind.startswith("abs:"):
1187
+ off = kind[4:]
1188
+ sign = -1 if off.startswith("-") else 1
1189
+ tz = datetime.timezone(sign * datetime.timedelta(hours=int(off[1:3]), minutes=int(off[3:5])))
1190
+ d = datetime.datetime.fromtimestamp(t, tz)
1191
+ if interval >= 86400:
1192
+ return d.strftime("%d %b %Y") if interval >= 30 * 86400 else d.strftime("%d %b")
1193
+ return d.strftime("%H:%M" + (":%S" if interval < 60 else ""))
1194
+
1195
+
1196
+ def fmt_span(seconds, kind):
1197
+ if kind == "num":
1198
+ return "%g" % round(seconds, 3)
1199
+ s = int(round(abs(seconds)))
1200
+ d, h, m = s // 86400, (s % 86400) // 3600, (s % 3600) // 60
1201
+ parts = (["%dd" % d] if d else []) + (["%dh" % h] if h else []) + (["%dm" % m] if m and not d else [])
1202
+ return " ".join(parts) or "%ds" % s
1203
+
1204
+
1205
+ def nice_step(span, target=8):
1206
+ """A round step (1, 2 or 5 times a power of ten) giving about target ticks."""
1207
+ if span <= 0:
1208
+ return 1.0
1209
+ raw = span / float(target)
1210
+ p = 10 ** math.floor(math.log10(raw))
1211
+ for k in (1, 2, 5, 10):
1212
+ if k * p >= raw:
1213
+ return k * p
1214
+ return 10 * p
1215
+
1216
+
1217
+ TIME_STEPS = [1, 5, 10, 15, 30, 60, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800,
1218
+ 604800, 1209600, 2592000, 7776000, 31536000]
1219
+
1220
+
1221
+ # --------------------------------------------------------------------------- tree
1222
+
1223
+ def layout_tree(spec):
1224
+ """Single-parent containment, depth left to right: leaves stack in order, each parent
1225
+ sits beside the middle of its children."""
1226
+ nodes = spec["nodes"]
1227
+ for n in nodes:
1228
+ size_node(n)
1229
+ by_id = {n["id"]: n for n in nodes}
1230
+ kids = {n["id"]: [] for n in nodes}
1231
+ roots = []
1232
+ for n in nodes:
1233
+ (kids[n["parent"]] if n.get("parent") in by_id else roots).append(n["id"])
1234
+ depth = {}
1235
+
1236
+ def set_depth(u, d):
1237
+ depth[u] = d
1238
+ for v in kids[u]:
1239
+ set_depth(v, d + 1)
1240
+ for r in roots:
1241
+ set_depth(r, 0)
1242
+ col_w = {}
1243
+ for n in nodes:
1244
+ col_w[depth[n["id"]]] = max(col_w.get(depth[n["id"]], 0), n["w"])
1245
+ col_x, x = {}, 30.0
1246
+ for d in sorted(col_w):
1247
+ col_x[d] = x
1248
+ x += col_w[d] + 64
1249
+ cursor = [30.0]
1250
+
1251
+ def place_(u):
1252
+ n = by_id[u]
1253
+ if not kids[u]:
1254
+ n["y"] = cursor[0]
1255
+ cursor[0] += n["h"] + 14
1256
+ else:
1257
+ for v in kids[u]:
1258
+ place_(v)
1259
+ first, last = by_id[kids[u][0]], by_id[kids[u][-1]]
1260
+ mid = (first["y"] + first["h"] / 2.0 + last["y"] + last["h"] / 2.0) / 2.0
1261
+ n["y"] = mid - n["h"] / 2.0
1262
+ low = cursor[0]
1263
+ if n["y"] + n["h"] + 14 > low: # a tall parent with few children
1264
+ cursor[0] = n["y"] + n["h"] + 14
1265
+ n["x"] = col_x[depth[u]] + (col_w[depth[u]] - n["w"]) / 2.0 if not kids[u] else col_x[depth[u]]
1266
+ for r in roots:
1267
+ place_(r)
1268
+ cursor[0] += 16
1269
+ spec["edges"] = []
1270
+ for n in nodes:
1271
+ if n.get("parent") in by_id:
1272
+ p = by_id[n["parent"]]
1273
+ mx = col_x[depth[p["id"]]] + col_w[depth[p["id"]]] + 32
1274
+ py, cy = p["y"] + p["h"] / 2.0, n["y"] + n["h"] / 2.0
1275
+ spec["edges"].append({"id": "t-" + n["id"], "from": p["id"], "to": n["id"], "label": "", "style": "solid",
1276
+ "assumed": n["assumed"], "evidence": [], "tree": True,
1277
+ "pts": [(p["x"] + p["w"], py), (mx, py), (mx, cy), (n["x"], cy)]})
1278
+ W = max(n["x"] + n["w"] for n in nodes) + 40
1279
+ H = max(n["y"] + n["h"] for n in nodes) + 40
1280
+ return {"w": W, "h": H, "groups": []}
1281
+
1282
+
1283
+ # --------------------------------------------------------------------------- timeline
1284
+
1285
+ def timeline_findings(spec, evs, kind):
1286
+ """The span, the longest quiet stretch, approximate times and time zones, computed."""
1287
+ out = []
1288
+ order = sorted(evs, key=lambda e: e["_t"][0])
1289
+ if len(order) >= 2:
1290
+ first = order[0]["when"]
1291
+ if kind.startswith("abs") and ":" in first and not re.match(r"^~?\d{2} [A-Z][a-z]{2} ", first):
1292
+ first = fmt_when(order[0]["_t"][0], kind, 86400) + " " + first # one day: give its date once
1293
+ out.append("From %s to %s: %s." % (first, order[-1]["when"].split(" to ")[0], fmt_span(order[-1]["_t"][0] - order[0]["_t"][0], kind)))
1294
+ gaps = [(b["_t"][0] - (a["_end"][0] if a.get("_end") else a["_t"][0]), a, b) for a, b in zip(order, order[1:])]
1295
+ g, a, b = max(gaps, key=lambda x: x[0])
1296
+ if g > 0:
1297
+ out.append("Longest quiet stretch: %s, between %s (%s) and %s (%s)." % (
1298
+ fmt_span(g, kind), a["label"], a["when"], b["label"], b["when"]))
1299
+ approx = [e for e in order if e.get("approx")]
1300
+ if approx:
1301
+ out.append("Approximate times (the source is unsure): " + "; ".join("%s at %s" % (e["label"], e["when"]) for e in approx) + ".")
1302
+ zname = lambda z: "UTC" if z in ("+0000", "") else "UTC%s:%s" % (z[:3], z[3:])
1303
+ zones = sorted({e["_zone"] for e in evs if e.get("_zone")})
1304
+ if len(zones) > 1:
1305
+ out.append("The sources give times in %s; all are shown in %s." % (" and ".join(zname(z) for z in zones), zname(kind[4:])))
1306
+ return out
1307
+
1308
+
1309
+ def layout_timeline(spec):
1310
+ """Events on a time axis, one lane per source. A long quiet stretch is shortened and
1311
+ marked, so a burst of events stays readable; everything else is to scale."""
1312
+ evs = spec["nodes"]
1313
+ kind = evs[0]["_t"][1]
1314
+ times = sorted({e["_t"][0] for e in evs} | {e["_end"][0] for e in evs if e.get("_end")})
1315
+ t0, t1 = times[0], times[-1]
1316
+ total = (t1 - t0) or 1.0
1317
+ long_gaps = []
1318
+ if len(times) >= 4:
1319
+ for a, b in zip(times, times[1:]):
1320
+ if b - a > 0.3 * total:
1321
+ long_gaps.append((a, b))
1322
+ width, brk = 1080.0, 64.0
1323
+ kept = total - sum(b - a for a, b in long_gaps)
1324
+ per = (width - brk * len(long_gaps)) / (kept or 1.0)
1325
+ x0 = 190.0
1326
+
1327
+ def X(t):
1328
+ x, cur = x0, t0
1329
+ for a, b in long_gaps:
1330
+ if t <= a:
1331
+ break
1332
+ x += (min(t, a) - cur) * per
1333
+ if t >= b:
1334
+ x += brk
1335
+ cur = b
1336
+ else:
1337
+ return x + brk * (t - a) / (b - a)
1338
+ return x + (t - cur) * per
1339
+ groups = {g["id"]: g["label"] for g in spec["groups"]}
1340
+ lanes = []
1341
+ for e in evs:
1342
+ ln = e.get("lane") or e.get("group") or ""
1343
+ if ln not in lanes:
1344
+ lanes.append(ln)
1345
+ lane_label = lambda ln: groups.get(ln, ln) or "Events"
1346
+ y = 70.0
1347
+ lane_geo = {}
1348
+ for ln in lanes:
1349
+ rows = []
1350
+ mine = sorted([e for e in evs if (e.get("lane") or e.get("group") or "") == ln], key=lambda e: e["_t"][0])
1351
+ for e in mine:
1352
+ e["lines"] = wrap(e["label"], 200, 12, 2)
1353
+ w = max(text_w(l, 12) for l in e["lines"]) + 12
1354
+ x = X(e["_t"][0])
1355
+ box = (x - 4, x - 4 + w)
1356
+ for r, used in enumerate(rows):
1357
+ if all(box[0] > b + 8 or box[1] < a - 8 for a, b in used):
1358
+ used.append(box)
1359
+ e["_row"] = r
1360
+ break
1361
+ else:
1362
+ rows.append([box])
1363
+ e["_row"] = len(rows) - 1
1364
+ row_h = 34
1365
+ lane_h = max(len(rows), 1) * row_h + 34
1366
+ line_y = y + lane_h - 18
1367
+ lane_geo[ln] = (y, lane_h, line_y)
1368
+ for e in mine:
1369
+ x = X(e["_t"][0])
1370
+ e["x"], e["y"], e["w"], e["h"] = x - 7, line_y - 7, 14, 14
1371
+ e["label_y"] = line_y - 26 - e["_row"] * row_h - 14 * (len(e["lines"]) - 1)
1372
+ if e.get("_end"):
1373
+ e["x2"] = X(e["_end"][0])
1374
+ y += lane_h + 10
1375
+ # ticks inside the stretches drawn to scale
1376
+ span_kept = kept or total
1377
+ step = next((s for s in TIME_STEPS if span_kept / s <= 9), TIME_STEPS[-1]) if kind != "num" else nice_step(span_kept)
1378
+ ticks, t = [], math.ceil(t0 / step) * step
1379
+ while t <= t1 + 1e-9:
1380
+ if not any(a < t < b for a, b in long_gaps):
1381
+ ticks.append(t)
1382
+ t += step
1383
+ shown = str(spec.get("timezone") or "").strip().upper()
1384
+ if kind.startswith("abs") and shown:
1385
+ m = re.match(r"^(?:UTC)?([+-])(\d{1,2}):?(\d{2})?$", shown)
1386
+ if shown in ("UTC", "Z", "GMT"):
1387
+ kind = "abs:+0000"
1388
+ elif m:
1389
+ kind = "abs:%s%02d%s" % (m.group(1), int(m.group(2)), m.group(3) or "00")
1390
+ one_day = kind.startswith("abs") and fmt_when(t0, kind, 86400) == fmt_when(t1, kind, 86400)
1391
+ for e in evs:
1392
+ raw = str(e.get("at"))
1393
+ if kind.startswith("abs"):
1394
+ date_only = bool(re.match(r"^\d{4}-\d{2}-\d{2}$", raw))
1395
+ e["when"] = fmt_when(e["_t"][0], kind, 86400 * 30 if date_only else 60)
1396
+ if not date_only and not one_day:
1397
+ e["when"] = fmt_when(e["_t"][0], kind, 86400) + " " + e["when"]
1398
+ if e.get("approx"):
1399
+ e["when"] = "~" + e["when"]
1400
+ if e.get("_end"):
1401
+ e["when"] += " to " + (fmt_when(e["_end"][0], kind, 60) if one_day or fmt_when(e["_end"][0], kind, 86400) == fmt_when(e["_t"][0], kind, 86400)
1402
+ else fmt_when(e["_end"][0], kind, 86400) + " " + fmt_when(e["_end"][0], kind, 60))
1403
+ tick_labels, last_day = [], None
1404
+ for t in ticks:
1405
+ lab = fmt_when(t, kind, step)
1406
+ if kind.startswith("abs") and step < 86400 and not one_day:
1407
+ day = fmt_when(t, kind, 86400)
1408
+ if day != last_day:
1409
+ lab = day + " " + lab
1410
+ last_day = day
1411
+ tick_labels.append((X(t), lab))
1412
+ spec["_findings"] = timeline_findings(spec, evs, kind)
1413
+ spec["_timeline"] = {"lanes": [(lane_label(ln),) + lane_geo[ln] for ln in lanes], "ticks": tick_labels,
1414
+ "breaks": [(X(a) + 6, X(b) - 6, fmt_span(b - a, kind)) for a, b in long_gaps], "x0": x0, "x1": X(t1), "kind": kind,
1415
+ "start": "", "end": "",
1416
+ "span": fmt_span(total, kind)}
1417
+ for e in spec["edges"]:
1418
+ a, b = next((n for n in evs if n["id"] == e["from"]), None), next((n for n in evs if n["id"] == e["to"]), None)
1419
+ if a and b:
1420
+ e["pts"] = [(a["x"] + 7, a["y"] + 7), (b["x"] + 7, b["y"] + 7)]
1421
+ first, last = min(evs, key=lambda e: e["_t"][0]), max(evs, key=lambda e: e["_end"][0] if e.get("_end") else e["_t"][0])
1422
+ spec["_timeline"]["start"] = first["when"].split(" to ")[0]
1423
+ spec["_timeline"]["end"] = last["when"].split(" to ")[-1] if not last.get("_end") or " " in last["when"].split(" to ")[-1] else last["when"]
1424
+ right = max([X(t1) + 40] + [e["x"] + max(text_w(l, 12) for l in e["lines"]) + 24 for e in evs])
1425
+ spec["_timeline"]["right"] = right
1426
+ return {"w": right + 40, "h": y + 30, "groups": []}
1427
+
1428
+
1429
+ def timeline_svg(spec):
1430
+ tl = spec["_timeline"]
1431
+ out = []
1432
+ for label, top, h, line_y in tl["lanes"]:
1433
+ out.append('<g class="lane"><rect x="20" y="%.1f" width="%.1f" height="%.1f" rx="8"/><text class="glabel" x="32" y="%.1f">%s</text>'
1434
+ '<line class="lane-line" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/></g>' % (
1435
+ top, tl["right"] - 20, h, line_y + 4, esc(label), tl["x0"] - 10, line_y, tl["x1"] + 30, line_y))
1436
+ out.append('<g class="axis">')
1437
+ for x, lab in tl["ticks"]:
1438
+ out.append('<line x1="%.1f" y1="44" x2="%.1f" y2="52"/><text x="%.1f" y="38" text-anchor="middle">%s</text>' % (x, x, x, esc(lab)))
1439
+ for a, b, lab in tl["breaks"]:
1440
+ mid = (a + b) / 2.0
1441
+ out.append('<path class="brk" d="M%.1f,40 l6,16 M%.1f,40 l6,16"/><text class="brk-label" x="%.1f" y="66" text-anchor="middle">%s later</text>' % (
1442
+ mid - 7, mid + 1, mid, esc(lab)))
1443
+ out.append('<line x1="%.1f" y1="48" x2="%.1f" y2="48"/></g>' % (tl["x0"] - 10, tl["x1"] + 30))
1444
+ for e in spec["edges"]:
1445
+ if e.get("pts"):
1446
+ out.append(edge_svg(e))
1447
+ for n in spec["nodes"]:
1448
+ fam = KINDS.get(n["kind"], ("dot", "blue"))[1]
1449
+ cls = ["node", "event", "c-" + fam, "k-" + n["kind"]] + (["assumed"] if n["assumed"] else []) + (["approx"] if n.get("approx") else []) + (["st-" + n["status"]] if n.get("status") else [])
1450
+ cx, cy = n["x"] + 7, n["y"] + 7
1451
+ g = ['<g class="%s" id="n-%s" data-id="%s" tabindex="0" role="button" aria-label="%s">' % (
1452
+ " ".join(cls), esc(n["id"]), esc(n["id"]), esc(n["when"] + " " + n["label"]))]
1453
+ g.append("<title>%s — %s</title>" % (esc(n["when"]), esc(n["label"])))
1454
+ if n.get("x2"):
1455
+ g.append('<rect class="shape bar" x="%.1f" y="%.1f" width="%.1f" height="10" rx="5"/>' % (cx, cy - 5, max(n["x2"] - cx, 4)))
1456
+ g.append('<line class="leader" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>' % (cx, cy - 7, cx, n["label_y"] + 4 + 14 * (len(n["lines"]) - 1)))
1457
+ if n["kind"] == "milestone":
1458
+ g.append('<path class="shape" d="M%.1f,%.1f l8,8 l-8,8 l-8,-8 z"/>' % (cx, cy - 8))
1459
+ else:
1460
+ g.append('<circle class="shape" cx="%.1f" cy="%.1f" r="6.5"/>' % (cx, cy))
1461
+ for i, l in enumerate(n["lines"]):
1462
+ g.append('<text class="label tl" x="%.1f" y="%.1f">%s</text>' % (cx - 3, n["label_y"] + 14 * i, esc(l)))
1463
+ g.append('<text class="note tl" x="%.1f" y="%.1f">%s</text>' % (cx - 3, n["label_y"] - 13, esc(n["when"])))
1464
+ g.append("</g>")
1465
+ out.append("".join(g))
1466
+ return "".join(out)
1467
+
1468
+
1469
+ # --------------------------------------------------------------------------- waterfall
1470
+
1471
+ SERVICE_COLOURS = ["#3b82f6", "#16a34a", "#d97706", "#8b5cf6", "#0d9488", "#e11d48", "#4f46e5", "#64748b"]
1472
+
1473
+
1474
+ def layout_waterfall(spec):
1475
+ """Spans as bars on one clock, in call order, children under their parent. The critical
1476
+ path (the chain of spans that decided when the whole thing ended) is marked."""
1477
+ spans = spec["nodes"]
1478
+ by_id = {s["id"]: s for s in spans}
1479
+ kids = {s["id"]: [] for s in spans}
1480
+ roots = []
1481
+ for s in spans:
1482
+ (kids[s["parent"]] if s.get("parent") in by_id else roots).append(s["id"])
1483
+ key = lambda i: (by_id[i]["_start"], by_id[i]["_end"])
1484
+ order, depth = [], {}
1485
+
1486
+ def walk(u, d):
1487
+ order.append(u)
1488
+ depth[u] = d
1489
+ for v in sorted(kids[u], key=key):
1490
+ walk(v, d + 1)
1491
+ for r in sorted(roots, key=key):
1492
+ walk(r, 0)
1493
+ t0 = min(s["_start"] for s in spans)
1494
+ t1 = max(s["_end"] for s in spans)
1495
+ total = (t1 - t0) or 1.0 # the axis
1496
+ whole = (by_id[roots[0]]["_end"] - by_id[roots[0]]["_start"]) if len(roots) == 1 else total
1497
+ whole = whole or 1.0
1498
+ crit, crit_order = set(), []
1499
+
1500
+ def critical(u):
1501
+ """u is on the path; so is the child that finished last, the child that finished last
1502
+ before that one started, and so on back to the start of u."""
1503
+ crit.add(u)
1504
+ crit_order.append(u)
1505
+ limit, chain = by_id[u]["_end"], []
1506
+ inside = [c for c in kids[u] if by_id[c]["_end"] <= by_id[u]["_end"] + 1e-9] # a span that outlives its parent was not waited for
1507
+ for c in sorted(inside, key=lambda i: by_id[i]["_end"], reverse=True):
1508
+ if by_id[c]["_end"] <= limit + 1e-9:
1509
+ chain.append(c)
1510
+ limit = by_id[c]["_start"]
1511
+ for c in sorted(chain, key=lambda i: by_id[i]["_start"]):
1512
+ critical(c)
1513
+ if roots:
1514
+ critical(max(roots, key=lambda i: by_id[i]["_end"]))
1515
+ unit = spec.get("unit") or "ms"
1516
+ label_w = min(max([text_w(by_id[i]["label"], 12) + 18 * depth[i] + 20 for i in order] + [180]), 360)
1517
+ bx0, bw = label_w + 30, 880.0
1518
+ X = lambda t: bx0 + (t - t0) / total * bw
1519
+ services = []
1520
+ for i, sid in enumerate(order):
1521
+ s = by_id[sid]
1522
+ svc = s.get("service") or ""
1523
+ if svc not in services:
1524
+ services.append(svc)
1525
+ s["row"], s["depth"], s["crit"] = i, depth[sid], sid in crit
1526
+ s["x"], s["y"], s["w"], s["h"] = X(s["_start"]), 60 + i * 28, max(X(s["_end"]) - X(s["_start"]), 2), 18
1527
+ s["colour"] = SERVICE_COLOURS[services.index(svc) % len(SERVICE_COLOURS)]
1528
+ dur = s["_end"] - s["_start"]
1529
+ covered, cur_end = 0.0, s["_start"]
1530
+ for k in sorted(kids[sid], key=key):
1531
+ a, b = max(by_id[k]["_start"], cur_end), min(by_id[k]["_end"], s["_end"])
1532
+ if b > a:
1533
+ covered += b - a
1534
+ cur_end = b
1535
+ parent = by_id.get(s.get("parent"))
1536
+ s["_detached"] = bool(parent and s["_end"] > parent["_end"] + 1e-9)
1537
+ s["facts"] = [["starts at", fmt_dur(s["_start"] - t0, unit)], ["takes", fmt_dur(dur, unit)],
1538
+ ["share of the whole", "%.0f%%" % (100.0 * dur / whole)], ["time of its own (not in a child span)", fmt_dur(dur - covered, unit)]]
1539
+ s["_self"] = dur - covered
1540
+ if s["crit"]:
1541
+ s["facts"].append(["on the critical path", "yes"])
1542
+ if s["_detached"]:
1543
+ s["facts"].append(["after its parent ends", "runs on for %s; nothing waits for it" % fmt_dur(s["_end"] - parent["_end"], unit)])
1544
+ k = UNIT_MS.get(str(unit).lower())
1545
+ if k:
1546
+ step = next((st for st in TIME_STEPS_MS if total * k / st <= 8), TIME_STEPS_MS[-1]) / k
1547
+ else:
1548
+ step = nice_step(total)
1549
+ ticks, t = [], 0.0
1550
+ while t <= total + 1e-9:
1551
+ ticks.append((X(t0 + t), fmt_dur(t, unit) if k else fmt_num(t) + " " + unit))
1552
+ t += step
1553
+ spec["edges"] = [{"id": "w-" + s["id"], "from": s["parent"], "to": s["id"], "label": "", "style": "solid", "assumed": False,
1554
+ "evidence": []} for s in spans if s.get("parent") in by_id]
1555
+ spec["_findings"] = waterfall_findings(spans, by_id, kids, roots, whole, unit)
1556
+ waited = [i for i in order if i in crit and (not any(c in crit for c in kids[i]) or by_id[i]["_self"] >= 0.05 * whole)]
1557
+ spec["_waterfall"] = {"ticks": ticks, "bx0": bx0, "bw": bw, "label_w": label_w, "services": services, "total": fmt_dur(whole, unit),
1558
+ "crit": waited}
1559
+ return {"w": bx0 + bw + 160, "h": 60 + len(order) * 28 + 40, "groups": []}
1560
+
1561
+
1562
+ def waterfall_findings(spans, by_id, kids, roots, whole, unit):
1563
+ """What the trace says about where the time went, computed, so nobody has to add it up:
1564
+ the spans with the most time of their own, the ones that mostly wait on their children,
1565
+ siblings that overlap (their durations must not be summed), and spans that outlive their
1566
+ parent (not part of the wait)."""
1567
+ u = lambda v: fmt_dur(v, unit)
1568
+ pct = lambda v: "%.0f%%" % (100.0 * v / whole)
1569
+ out = []
1570
+ if len(roots) == 1:
1571
+ r = by_id[roots[0]]
1572
+ out.append("The whole of %s took %s." % (r["label"], u(r["_end"] - r["_start"])))
1573
+ # what the whole waited on: own time of spans on the critical path (a span running beside a
1574
+ # slower sibling, or detached from its parent, cost no wall-clock time)
1575
+ own = sorted([s for s in spans if s.get("crit") and not s.get("_detached")], key=lambda s: -s["_self"])
1576
+ top = [s for s in own if s["_self"] > 0.05 * whole][:3]
1577
+ if top:
1578
+ out.append("What the whole waited on most: " + "; ".join("%s, %s of its own work (%s)" % (s["label"], u(s["_self"]), pct(s["_self"])) for s in top) + ".")
1579
+ for s in spans:
1580
+ dur = s["_end"] - s["_start"]
1581
+ if kids[s["id"]] and dur > 0.2 * whole and s["_self"] < 0.1 * dur:
1582
+ out.append("%s takes %s but only %s of it is its own; the rest is waiting on what it calls." % (s["label"], u(dur), u(s["_self"])))
1583
+ for pid, ks in kids.items():
1584
+ ks = sorted(ks, key=lambda i: by_id[i]["_start"])
1585
+ for i, a in enumerate(ks):
1586
+ for b in ks[i + 1:]:
1587
+ A, B = by_id[a], by_id[b]
1588
+ ov = min(A["_end"], B["_end"]) - max(A["_start"], B["_start"])
1589
+ if ov > 0.1 * min(A["_end"] - A["_start"], B["_end"] - B["_start"]) and ov > 0.02 * whole:
1590
+ both = (A["_end"] - A["_start"]) + (B["_end"] - B["_start"])
1591
+ wall = max(A["_end"], B["_end"]) - min(A["_start"], B["_start"])
1592
+ out.append("%s and %s run at the same time for %s: together they take %s, not the %s their durations add up to." % (
1593
+ A["label"], B["label"], u(ov), u(wall), u(both)))
1594
+ for s in spans:
1595
+ p = by_id.get(s.get("parent"))
1596
+ if p and s["_end"] > p["_end"] + 1e-9:
1597
+ out.append("%s ends %s after %s does, so nothing waited for it: it runs on after its parent (fire-and-forget), or the two clocks disagree." % (
1598
+ s["label"], u(s["_end"] - p["_end"]), p["label"]))
1599
+ return out
1600
+
1601
+
1602
+ UNIT_MS = {"us": 0.001, "\u00b5s": 0.001, "ms": 1.0, "s": 1000.0, "sec": 1000.0, "min": 60000.0, "m": 60000.0, "h": 3600000.0}
1603
+ TIME_STEPS_MS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000,
1604
+ 900000, 1800000, 3600000, 7200000, 10800000, 21600000, 43200000, 86400000]
1605
+
1606
+
1607
+ def fmt_dur(v, unit):
1608
+ """A duration as a person says it. Units the script doesn't know stay as written."""
1609
+ k = UNIT_MS.get(str(unit).lower())
1610
+ if k is None:
1611
+ return "%s %s" % (fmt_num(v), unit)
1612
+ ms = abs(v) * k
1613
+ sign = "-" if v < 0 else ""
1614
+ if ms < 1000:
1615
+ return sign + "%s ms" % fmt_num(ms)
1616
+ if ms < 60000:
1617
+ return sign + ("%.1f s" % (ms / 1000.0) if ms < 10000 else "%d s" % round(ms / 1000.0))
1618
+ if ms < 3600000:
1619
+ sec = int(round(ms / 1000.0))
1620
+ return sign + "%dm %02ds" % (sec // 60, sec % 60)
1621
+ mins = int(round(ms / 60000.0))
1622
+ return sign + "%dh %02dm" % (mins // 60, mins % 60)
1623
+
1624
+
1625
+ def fmt_num(v):
1626
+ return ("%.1f" % v).rstrip("0").rstrip(".") if abs(v) < 100 else "%d" % round(v)
1627
+
1628
+
1629
+ def waterfall_svg(spec):
1630
+ wf = spec["_waterfall"]
1631
+ out = ['<g class="axis">']
1632
+ for x, lab in wf["ticks"]:
1633
+ out.append('<line class="grid" x1="%.1f" y1="44" x2="%.1f" y2="%.1f"/><text x="%.1f" y="38" text-anchor="middle">%s</text>' % (
1634
+ x, x, 60 + 28 * len(spec["nodes"]) + 4, x, esc(lab)))
1635
+ out.append("</g>")
1636
+ for s in sorted(spec["nodes"], key=lambda s: s["row"]):
1637
+ cls = ["node", "span"] + (["crit"] if s["crit"] else []) + (["err"] if str(s.get("status", "")).lower() in ("error", "failed", "err") else []) + \
1638
+ (["assumed"] if s["assumed"] else [])
1639
+ dur = s["facts"][1][1]
1640
+ g = ['<g class="%s" id="n-%s" data-id="%s" tabindex="0" role="button" aria-label="%s">' % (
1641
+ " ".join(cls), esc(s["id"]), esc(s["id"]), esc("%s, %s" % (s["label"], dur)))]
1642
+ g.append("<title>%s — %s</title>" % (esc(s["label"]), esc(dur)))
1643
+ g.append('<rect class="rowbg" x="20" y="%.1f" width="%.1f" height="26"/>' % (s["y"] - 4, wf["bx0"] + wf["bw"] + 120))
1644
+ label = s["label"]
1645
+ while label and text_w(label, 12) + 18 * s["depth"] > wf["label_w"]:
1646
+ label = label[:-2]
1647
+ if label != s["label"]:
1648
+ label = label.rstrip() + "…"
1649
+ g.append('<text class="label wf" x="%.1f" y="%.1f">%s</text>' % (30 + 18 * s["depth"], s["y"] + 13, esc(label)))
1650
+ g.append('<rect class="shape bar" style="fill:%s" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="3"/>' % (
1651
+ s["colour"], s["x"], s["y"], s["w"], s["h"]))
1652
+ g.append('<text class="note wf" x="%.1f" y="%.1f">%s</text>' % (s["x"] + s["w"] + 6, s["y"] + 13, esc(dur)))
1653
+ g.append("</g>")
1654
+ out.append("".join(g))
1655
+ return "".join(out)
1656
+
1657
+
1658
+ # --------------------------------------------------------------------------- SVG
1659
+
1660
+ def esc(s):
1661
+ return html.escape(str(s), quote=True)
1662
+
1663
+
1664
+ def rounded_path(pts, r=7):
1665
+ if len(pts) < 2:
1666
+ return ""
1667
+ d = ["M%.1f,%.1f" % pts[0]]
1668
+ for i in range(1, len(pts) - 1):
1669
+ (x0, y0), (x1, y1), (x2, y2) = pts[i - 1], pts[i], pts[i + 1]
1670
+ l1 = max(abs(x1 - x0), abs(y1 - y0))
1671
+ l2 = max(abs(x2 - x1), abs(y2 - y1))
1672
+ rr = min(r, l1 / 2.0, l2 / 2.0)
1673
+ ax_ = x1 - rr * ((x1 > x0) - (x1 < x0)) if abs(x1 - x0) > 0.1 else x1
1674
+ ay_ = y1 - rr * ((y1 > y0) - (y1 < y0)) if abs(y1 - y0) > 0.1 else y1
1675
+ bx_ = x1 + rr * ((x2 > x1) - (x2 < x1)) if abs(x2 - x1) > 0.1 else x1
1676
+ by_ = y1 + rr * ((y2 > y1) - (y2 < y1)) if abs(y2 - y1) > 0.1 else y1
1677
+ d.append("L%.1f,%.1f Q%.1f,%.1f %.1f,%.1f" % (ax_, ay_, x1, y1, bx_, by_))
1678
+ d.append("L%.1f,%.1f" % pts[-1])
1679
+ return " ".join(d)
1680
+
1681
+
1682
+ def node_shape(n):
1683
+ x, y, w, h = n["x"], n["y"], n["w"], n["h"]
1684
+ s = n["shape"]
1685
+ if s == "cylinder":
1686
+ ry = 7
1687
+ 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"/>'
1688
+ '<path class="shape-line" d="M%.1f,%.1f a%.1f,%d 0 0,0 %.1f,0"/>') % (
1689
+ x, y + ry, w / 2, ry, w, h - 2 * ry, w / 2, ry, -w, x, y + ry, w / 2, ry, w)
1690
+ if s == "diamond":
1691
+ return '<path class="shape" d="M%.1f,%.1f L%.1f,%.1f L%.1f,%.1f L%.1f,%.1f Z"/>' % (
1692
+ x + w / 2, y, x + w, y + h / 2, x + w / 2, y + h, x, y + h / 2)
1693
+ if s == "terminal":
1694
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%.1f"/>' % (x, y, w, h, h / 2)
1695
+ if s == "round":
1696
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="14"/>' % (x, y, w, h)
1697
+ if s == "pipe":
1698
+ return ('<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1699
+ '<path class="shape-line" d="M%.1f,%.1f v%.1f M%.1f,%.1f v%.1f"/>') % (
1700
+ x, y, w, h, x + 10, y + 3, h - 6, x + w - 10, y + 3, h - 6)
1701
+ if s == "document":
1702
+ return '<path class="shape" d="M%.1f,%.1f h%.1f v%.1f q%.1f,10 %.1f,0 t%.1f,0 Z"/>' % (
1703
+ x, y, w, h - 8, -w / 4, -w / 2, -w / 2)
1704
+ if s == "person":
1705
+ cx = x + w / 2
1706
+ return ('<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1707
+ '<circle class="glyph" cx="%.1f" cy="%.1f" r="5"/><path class="glyph" d="M%.1f,%.1f a8,7 0 0,1 16,0 z"/>') % (
1708
+ x, y, w, h, cx, y + 11, cx - 8, y + 26)
1709
+ if s == "initial":
1710
+ return '<circle class="shape solid" cx="%.1f" cy="%.1f" r="%.1f"/>' % (x + w / 2, y + h / 2, w / 2 - 3)
1711
+ if s == "final":
1712
+ return ('<circle class="shape" cx="%.1f" cy="%.1f" r="%.1f"/><circle class="shape solid" cx="%.1f" cy="%.1f" r="%.1f"/>') % (
1713
+ x + w / 2, y + h / 2, w / 2 - 1, x + w / 2, y + h / 2, w / 2 - 7)
1714
+ rx = 6 if n["kind"] != "external" else 4
1715
+ return '<rect class="shape" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%d"/>' % (x, y, w, h, rx)
1716
+
1717
+
1718
+ def node_svg(n):
1719
+ fam = KINDS.get(n["kind"], ("rect", "slate"))[1]
1720
+ cls = ["node", "c-" + fam, "k-" + re.sub(r"[^a-z0-9-]", "", n["kind"])]
1721
+ if n["assumed"]:
1722
+ cls.append("assumed")
1723
+ if n.get("status"):
1724
+ cls.append("st-" + n["status"])
1725
+ label = esc(n["label"])
1726
+ out = ['<g class="%s" id="n-%s" data-id="%s" tabindex="0" role="button" aria-label="%s">' % (
1727
+ " ".join(cls), esc(n["id"]), esc(n["id"]), label + (" (assumption)" if n["assumed"] else ""))]
1728
+ out.append("<title>%s</title>" % (label + (" — " + esc(n.get("note")) if n.get("note") else "")))
1729
+ out.append(node_shape(n))
1730
+ x, y, w, h = n["x"], n["y"], n["w"], n["h"]
1731
+ if n["shape"] == "classbox":
1732
+ hh = 30 + (14 if n["stereo"] else 0)
1733
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="%d" rx="6"/>' % (x, y, w, hh))
1734
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="8"/>' % (x, y + hh - 8, w))
1735
+ if n["stereo"]:
1736
+ out.append('<text class="note" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + 14, esc(n["stereo"])))
1737
+ out.append('<text class="label%s" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (
1738
+ " italic" if n["kind"] in ("abstract", "interface") else "", x + w / 2, y + hh - 9, label))
1739
+ for i, l in enumerate(n["member_lines"]):
1740
+ out.append('<text class="fname" x="%.1f" y="%.1f">%s</text>' % (x + 10, y + hh + 16 + 18 * i, esc(l)))
1741
+ if len(n["fields"]) > 30:
1742
+ out.append('<text class="ftype" x="%.1f" y="%.1f">+%d more (open for all)</text>' % (x + 10, y + hh + 16 + 18 * 30, len(n["fields"]) - 30))
1743
+ elif n["shape"] == "table":
1744
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="28" rx="6"/>' % (x, y, w))
1745
+ out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="8"/>' % (x, y + 20, w))
1746
+ out.append('<text class="label" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + 19, label))
1747
+ keyw = max([text_w("/".join(k.upper() for k in (f["key"] if isinstance(f.get("key"), list) else [f["key"]])), 9) + 16
1748
+ for f in n["fields"][:24] if f.get("key")] or [0])
1749
+ for i, f in enumerate(n["fields"][:24]):
1750
+ fy = y + 32 + 20 * i + 14
1751
+ key = f.get("key")
1752
+ keys = key if isinstance(key, list) else ([key] if key else [])
1753
+ if keys:
1754
+ out.append('<text class="fkey" x="%.1f" y="%.1f">%s</text>' % (x + 8, fy, esc("/".join(k.upper() for k in keys))))
1755
+ out.append('<text class="fname" x="%.1f" y="%.1f">%s</text>' % (x + (keyw + 4 if keyw else 10), fy, esc(f.get("name", ""))))
1756
+ if f.get("type"):
1757
+ out.append('<text class="ftype" x="%.1f" y="%.1f" text-anchor="end">%s</text>' % (x + w - 8, fy, esc(f["type"])))
1758
+ if len(n["fields"]) > 24:
1759
+ 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))
1760
+ elif n["shape"] in ("initial", "final"):
1761
+ for i, l in enumerate(n["lines"]):
1762
+ out.append('<text class="note" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + h + 14 + 13 * i, esc(l)))
1763
+ else:
1764
+ lines = n["lines"]
1765
+ notes = n.get("note_lines", [])
1766
+ top_pad = 26 if n["shape"] == "person" else (8 if n["shape"] == "cylinder" else 0)
1767
+ block = 17 * len(lines) + 15 * len(notes)
1768
+ ty = y + top_pad + (h - top_pad - block) / 2.0 + 13
1769
+ for l in lines:
1770
+ out.append('<text class="label" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, ty, esc(l)))
1771
+ ty += 17
1772
+ for l in notes:
1773
+ out.append('<text class="note" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, ty - 1, esc(l)))
1774
+ ty += 15
1775
+ if n["evidence"] and any(e.get("verified") for e in n["evidence"]):
1776
+ out.append('<text class="badge" x="%.1f" y="%.1f" text-anchor="end">✓ src</text>' % (x + w - 5, y - 4))
1777
+ elif n["assumed"]:
1778
+ out.append('<text class="badge warn" x="%.1f" y="%.1f" text-anchor="end">assumed</text>' % (x + w - 5, y - 4))
1779
+ out.append("</g>")
1780
+ return "".join(out)
1781
+
1782
+
1783
+ def place_labels(spec, occupied):
1784
+ """Put each edge label on a segment of its own route where it overlaps nothing placed."""
1785
+ def hit(r, rs):
1786
+ for o in rs:
1787
+ if r[0] < o[2] and r[2] > o[0] and r[1] < o[3] and r[3] > o[1]:
1788
+ return True
1789
+ return False
1790
+ segs_all = []
1791
+ for e in spec["edges"]:
1792
+ pts = e.get("pts", [])
1793
+ for i in range(len(pts) - 1):
1794
+ (x0, y0), (x1, y1) = pts[i], pts[i + 1]
1795
+ segs_all.append((e["id"], (min(x0, x1) - 2, min(y0, y1) - 2, max(x0, x1) + 2, max(y0, y1) + 2)))
1796
+ for e in spec["edges"]:
1797
+ if not e.get("label") or len(e.get("pts", [])) < 2:
1798
+ continue
1799
+ others = [r for (eid, r) in segs_all if eid != e["id"]]
1800
+ lines = wrap(e["label"], 150, 11, 2)
1801
+ w = max(text_w(l, 11) for l in lines) + 10
1802
+ h = 14 * len(lines) + 4
1803
+ pts = e["pts"]
1804
+ 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])))
1805
+ cands = []
1806
+ for i in segs:
1807
+ (x0, y0), (x1, y1) = pts[i], pts[i + 1]
1808
+ for f in (0.5, 0.3, 0.7, 0.15, 0.85):
1809
+ cands.append((x0 + (x1 - x0) * f, y0 + (y1 - y0) * f))
1810
+ best = None
1811
+ for cx, cy in cands:
1812
+ r = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1813
+ if not hit(r, occupied) and not hit(r, others):
1814
+ best = r
1815
+ break
1816
+ if best is None:
1817
+ for cx, cy in cands:
1818
+ r = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1819
+ if not hit(r, occupied):
1820
+ best = r
1821
+ break
1822
+ if best is None:
1823
+ cx, cy = cands[0]
1824
+ best = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
1825
+ occupied.append(best)
1826
+ e["label_box"] = best
1827
+ e["label_lines"] = lines
1828
+
1829
+
1830
+ def edge_svg(e, seq=False):
1831
+ cls = ["edge", "s-" + e["style"]]
1832
+ if e["assumed"]:
1833
+ cls.append("assumed")
1834
+ if e.get("status"):
1835
+ cls.append("st-" + e["status"])
1836
+ if e.get("emphasis"):
1837
+ cls.append("emph")
1838
+ if seq and e.get("kind") == "return":
1839
+ cls.append("s-dashed")
1840
+ title = esc(e["label"] or "%s → %s" % (e["from"], e["to"]))
1841
+ out = ['<g class="%s" id="e-%s" data-id="%s" data-from="%s" data-to="%s">' % (
1842
+ " ".join(cls + (["tree"] if e.get("tree") else [])), esc(e["id"]), esc(e["id"]), esc(e["from"]), esc(e["to"]))]
1843
+ out.append("<title>%s</title>" % title)
1844
+ d = rounded_path(e["pts"]) if not seq else "M" + " L".join("%.1f,%.1f" % p for p in e["pts"])
1845
+ rel = e.get("rel")
1846
+ if e.get("tree"):
1847
+ markers = ""
1848
+ elif rel in ("inherits", "implements"):
1849
+ markers = ' marker-end="url(#tri)"'
1850
+ elif rel == "composition":
1851
+ markers = ' marker-start="url(#dia-solid)"'
1852
+ elif rel == "aggregation":
1853
+ markers = ' marker-start="url(#dia-open)"'
1854
+ elif rel in ("association", "dependency"):
1855
+ markers = ' marker-end="url(#open)"'
1856
+ else:
1857
+ markers = ' marker-end="url(#arrow)"' + (' marker-start="url(#arrow-start)"' if e.get("both") else "")
1858
+ out.append('<path class="hit" d="%s"/>' % d)
1859
+ out.append('<path class="line" d="%s"%s/>' % (d, markers))
1860
+ if seq and e.get("lines"):
1861
+ x, y, anchor = e["label_at"]
1862
+ for i, l in enumerate(e["lines"]):
1863
+ out.append('<text class="elabel" x="%.1f" y="%.1f" text-anchor="%s">%s</text>' % (x, y + 14 * i, anchor, esc(l)))
1864
+ elif e.get("label_box"):
1865
+ x0, y0, x1, y1 = e["label_box"]
1866
+ out.append('<rect class="lbg" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="3"/>' % (x0, y0, x1 - x0, y1 - y0))
1867
+ for i, l in enumerate(e["label_lines"]):
1868
+ out.append('<text class="elabel" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % ((x0 + x1) / 2, y0 + 13 + 14 * i, esc(l)))
1869
+ for key, at in (("from_card", 0), ("to_card", -1)):
1870
+ if e.get(key) and len(e["pts"]) >= 2:
1871
+ p, q = e["pts"][at], e["pts"][1 if at == 0 else -2]
1872
+ dx = 1 if q[0] > p[0] else -1
1873
+ out.append('<text class="card" x="%.1f" y="%.1f" text-anchor="%s">%s</text>' % (
1874
+ p[0] + (6 if at == 0 else 14) * dx, p[1] - 6, "start" if dx > 0 else "end", esc(e[key])))
1875
+ out.append("</g>")
1876
+ return "".join(out)
1877
+
1878
+
1879
+ def render_svg(spec, geo):
1880
+ W, H = geo["w"], geo["h"]
1881
+ seq = spec["type"] == "sequence"
1882
+ 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">' % (
1883
+ W, H, W, H, esc(spec["title"]))]
1884
+ out.append('<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="userSpaceOnUse" markerWidth="10" markerHeight="10" orient="auto-start-reverse">'
1885
+ '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker>'
1886
+ '<marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5" markerUnits="userSpaceOnUse" markerWidth="10" markerHeight="10" orient="auto-start-reverse">'
1887
+ '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker>'
1888
+ '<marker id="tri" viewBox="0 0 12 12" refX="11" refY="6" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto-start-reverse">'
1889
+ '<path d="M1,1 L11,6 L1,11 z" class="mk-open"/></marker>'
1890
+ '<marker id="open" viewBox="0 0 12 12" refX="11" refY="6" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto-start-reverse">'
1891
+ '<path d="M1,1 L11,6 L1,11" class="mk-line"/></marker>'
1892
+ '<marker id="dia-solid" viewBox="0 0 16 10" refX="15" refY="5" markerUnits="userSpaceOnUse" markerWidth="18" markerHeight="11" orient="auto-start-reverse">'
1893
+ '<path d="M1,5 L8,1 L15,5 L8,9 z" class="arrowhead"/></marker>'
1894
+ '<marker id="dia-open" viewBox="0 0 16 10" refX="15" refY="5" markerUnits="userSpaceOnUse" markerWidth="18" markerHeight="11" orient="auto-start-reverse">'
1895
+ '<path d="M1,5 L8,1 L15,5 L8,9 z" class="mk-open"/></marker></defs>')
1896
+ out.append('<rect class="canvas" x="0" y="0" width="%d" height="%d"/>' % (W, H))
1897
+ labels = {g["id"]: g for g in spec["groups"]}
1898
+ for g in sorted(geo["groups"], key=lambda g: g["depth"]):
1899
+ meta = labels.get(g["id"], {})
1900
+ cls = "group d%d" % min(g["depth"], 3) + (" st-" + meta["status"] if meta.get("status") else "")
1901
+ out.append('<g class="%s" data-group="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1902
+ '<text class="glabel" x="%.1f" y="%.1f">%s</text></g>' % (
1903
+ cls, esc(g["id"]), g["x"], g["y"], g["w"], g["h"], g["x"] + 12, g["y"] + 18,
1904
+ esc(meta.get("label", g["id"]) + (" (continued)" if g.get("cont") else ""))))
1905
+ if seq:
1906
+ y0, y1 = geo["lifeline"]
1907
+ for f in spec["frames"]:
1908
+ if "y0" in f and "y1" in f:
1909
+ lab = "%s %s" % (f.get("kind", "group"), f.get("label", ""))
1910
+ out.append('<g class="frame"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="4"/>'
1911
+ '<rect class="ftab" x="%.1f" y="%.1f" width="%.1f" height="18"/><text x="%.1f" y="%.1f">%s</text></g>' % (
1912
+ f["x0"], f["y0"], f["x1"] - f["x0"], f["y1"] - f["y0"], f["x0"], f["y0"], text_w(lab, 11) + 14,
1913
+ f["x0"] + 7, f["y0"] + 13, esc(lab.strip())))
1914
+ for p in spec["nodes"]:
1915
+ out.append('<line class="lifeline" data-for="%s" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>' % (
1916
+ esc(p["id"]), p["cx"], p["y"] + p["h"], p["cx"], y1))
1917
+ for i, m in enumerate(spec["edges"]):
1918
+ if m.get("pts"):
1919
+ out.append('<text class="seqno" x="%.1f" y="%.1f">%d</text>' % (14, m["pts"][0][1] + 4, i + 1))
1920
+ if spec["type"] == "timeline":
1921
+ out.append(timeline_svg(spec))
1922
+ elif spec["type"] == "waterfall":
1923
+ out.append(waterfall_svg(spec))
1924
+ else:
1925
+ for e in spec["edges"]:
1926
+ if e.get("pts"):
1927
+ out.append(edge_svg(e, seq))
1928
+ for n in spec["nodes"]:
1929
+ out.append(node_svg(n))
1930
+ out.append("</svg>")
1931
+ return "".join(out)
1932
+
1933
+
1934
+ # --------------------------------------------------------------------------- Mermaid
1935
+
1936
+ def mm_text(s):
1937
+ return str(s).replace('"', "#quot;").replace("\n", " ")
1938
+
1939
+
1940
+ def mm_id(s):
1941
+ return re.sub(r"[^A-Za-z0-9_]", "_", s) or "n"
1942
+
1943
+
1944
+ def to_mermaid(spec):
1945
+ t = spec["type"]
1946
+ nodes = [n for n in spec["nodes"] if n.get("status") != "removed"]
1947
+ edges = [e for e in spec["edges"] if e.get("status") != "removed"]
1948
+ if t == "tree":
1949
+ out = ["flowchart LR"] + [' %s["%s"]' % (mm_id(n["id"]), mm_text(n["label"])) for n in nodes]
1950
+ out += [" %s --- %s" % (mm_id(n["parent"]), mm_id(n["id"])) for n in nodes if n.get("parent")]
1951
+ return "\n".join(out) + "\n"
1952
+ if t == "timeline":
1953
+ groups = {g["id"]: g["label"] for g in spec["groups"]}
1954
+ out = ["timeline", " title %s" % mm_text(spec["title"]).replace(":", " -")]
1955
+ lanes = []
1956
+ for n in nodes:
1957
+ ln = n.get("lane") or n.get("group") or ""
1958
+ if ln not in lanes:
1959
+ lanes.append(ln)
1960
+ for ln in lanes:
1961
+ if ln:
1962
+ out.append(" section %s" % mm_text(groups.get(ln, ln)).replace(":", " -"))
1963
+ for n in sorted([n for n in nodes if (n.get("lane") or n.get("group") or "") == ln], key=lambda n: n["_t"][0]):
1964
+ out.append(" %s : %s" % (mm_text(n["when"]).replace(":", "."), mm_text(n["label"]).replace(":", " -")))
1965
+ return "\n".join(out) + "\n"
1966
+ if t == "waterfall":
1967
+ t0 = min(n["_start"] for n in nodes)
1968
+ out = ["gantt", " title %s" % mm_text(spec["title"]).replace(":", " -"), " dateFormat x", " axisFormat %S.%L"]
1969
+ svcs = []
1970
+ for n in sorted(nodes, key=lambda n: n.get("row", 0)):
1971
+ svc = n.get("service") or "spans"
1972
+ if svc not in svcs:
1973
+ svcs.append(svc)
1974
+ for svc in svcs:
1975
+ out.append(" section %s" % mm_text(svc).replace(":", " -"))
1976
+ for n in sorted([n for n in nodes if (n.get("service") or "spans") == svc], key=lambda n: n.get("row", 0)):
1977
+ tag = "crit, " if n.get("crit") else ""
1978
+ out.append(" %s :%s%s, %d, %d" % (mm_text(n["label"]).replace(":", " -"), tag, mm_id(n["id"]),
1979
+ round(n["_start"] - t0), max(round(n["_end"] - t0), round(n["_start"] - t0) + 1)))
1980
+ return "\n".join(out) + "\n"
1981
+ if t == "class":
1982
+ out = ["classDiagram"]
1983
+ if spec["direction"] == "LR":
1984
+ out.append(" direction LR")
1985
+ for n in nodes:
1986
+ out.append(" class %s {" % mm_id(n["label"]))
1987
+ if n["kind"] != "class":
1988
+ out.append(" <<%s>>" % n["kind"])
1989
+ for f in n["fields"]:
1990
+ out.append(" %s" % mm_text(member_text(f)).replace("{", "(").replace("}", ")"))
1991
+ out.append(" }")
1992
+ name = {n["id"]: mm_id(n["label"]) for n in nodes}
1993
+ arrow = {"inherits": "--|>", "implements": "..|>", "composition": "*--", "aggregation": "o--",
1994
+ "association": "-->", "dependency": "..>"}
1995
+ for e in edges:
1996
+ lab = (" : " + mm_text(e["label"])) if e["label"] else ""
1997
+ out.append(" %s %s %s%s" % (name[e["from"]], arrow[e["rel"]], name[e["to"]], lab))
1998
+ return "\n".join(out) + "\n"
1999
+ if t == "sequence":
2000
+ out = ["sequenceDiagram"]
2001
+ for p in nodes:
2002
+ out.append(' %s %s as %s' % ("actor" if p["kind"] in ("person", "actor") else "participant", mm_id(p["id"]), mm_text(p["label"]).replace(";", ",")))
2003
+ opens = {}
2004
+ closes = {}
2005
+ for f in spec["frames"]:
2006
+ opens.setdefault(f.get("from"), []).append(f)
2007
+ closes.setdefault(f.get("to"), []).append(f)
2008
+ for m in edges:
2009
+ for f in opens.get(m["id"], []):
2010
+ out.append(" %s %s" % (f.get("kind", "rect") if f.get("kind") in ("loop", "alt", "opt", "par", "critical", "break") else "opt",
2011
+ mm_text(f.get("label", ""))))
2012
+ arrow = "-->>" if m.get("kind") == "return" else ("-)" if m.get("kind") == "async" else "->>")
2013
+ out.append(" %s%s%s: %s" % (mm_id(m["from"]), arrow, mm_id(m["to"]), mm_text(m["label"]).replace(";", ",") or " "))
2014
+ for f in closes.get(m["id"], []):
2015
+ out.append(" end")
2016
+ return "\n".join(out) + "\n"
2017
+ if t == "lifecycle":
2018
+ out = ["stateDiagram-v2"]
2019
+ if spec["direction"] == "LR":
2020
+ out.append(" direction LR")
2021
+ for n in nodes:
2022
+ if n["shape"] not in ("initial", "final"):
2023
+ out.append(' state "%s" as %s' % (mm_text(n["label"]), mm_id(n["id"])))
2024
+ kinds = {n["id"]: n["shape"] for n in nodes}
2025
+ for e in edges:
2026
+ a = "[*]" if kinds.get(e["from"]) == "initial" else mm_id(e["from"])
2027
+ b = "[*]" if kinds.get(e["to"]) == "final" else mm_id(e["to"])
2028
+ out.append(" %s --> %s%s" % (a, b, (": " + mm_text(e["label"])) if e["label"] else ""))
2029
+ return "\n".join(out) + "\n"
2030
+ if t == "erd":
2031
+ out = ["erDiagram"]
2032
+ left = {"0..1": "|o", "1": "||", "1..1": "||", "1..*": "}|", "*": "}o", "0..*": "}o", "n": "}o", "many": "}o", "m": "}o"}
2033
+ right = {"0..1": "o|", "1": "||", "1..1": "||", "1..*": "|{", "*": "o{", "0..*": "o{", "n": "o{", "many": "o{", "m": "o{"}
2034
+ name = {n["id"]: mm_id(n["label"]) for n in nodes}
2035
+ for e in edges:
2036
+ l = left.get(str(e.get("from_card", "*")).lower(), "}o")
2037
+ r = right.get(str(e.get("to_card", "1")).lower(), "||")
2038
+ out.append(' %s %s--%s %s : "%s"' % (name[e["from"]], l, r, name[e["to"]], mm_text(e["label"] or "has")))
2039
+ for n in nodes:
2040
+ out.append(" %s {" % name[n["id"]])
2041
+ for f in n["fields"]:
2042
+ keys = f.get("key")
2043
+ keys = keys if isinstance(keys, list) else ([keys] if keys else [])
2044
+ ks = ",".join(k.upper() for k in keys if k.lower() in ("pk", "fk", "uk"))
2045
+ out.append(" %s %s%s" % (mm_id(str(f.get("type") or "string")), mm_id(f.get("name", "field")), (" " + ks) if ks else ""))
2046
+ out.append(" }")
2047
+ return "\n".join(out) + "\n"
2048
+ out = ["flowchart " + spec["direction"]]
2049
+ shapes = {"cylinder": ('[("', '")]'), "diamond": ('{"', '"}'), "terminal": ('(["', '"])'), "round": ('("', '")'),
2050
+ "pipe": ('[["', '"]]'), "document": ('[/"', '"/]'), "person": ('(["', '"])'), "initial": ('(("', '"))'),
2051
+ "final": ('((("', '")))'), "table": ('["', '"]')}
2052
+
2053
+ def decl(n, indent):
2054
+ a, b = shapes.get(n["shape"], ('["', '"]'))
2055
+ return "%s%s%s%s%s" % (indent, mm_id(n["id"]), a, mm_text(n["label"]), b)
2056
+ groups, kids, top = group_tree(spec)
2057
+ members = {}
2058
+ for n in nodes:
2059
+ members.setdefault(n.get("group"), []).append(n)
2060
+
2061
+ def emit(gid, depth):
2062
+ ind = " " * depth
2063
+ out.append('%ssubgraph %s["%s"]' % (ind, mm_id(gid), mm_text(groups[gid]["label"])))
2064
+ for n in members.get(gid, []):
2065
+ out.append(decl(n, ind + " "))
2066
+ for k in kids[gid]:
2067
+ emit(k, depth + 1)
2068
+ out.append(ind + "end")
2069
+ for n in members.get(None, []):
2070
+ out.append(decl(n, " "))
2071
+ for g in top:
2072
+ emit(g, 1)
2073
+ for e in edges:
2074
+ arrow = "-.->" if e["style"] != "solid" or e["assumed"] else ("==>" if e.get("emphasis") else "-->")
2075
+ if e.get("both"):
2076
+ arrow = "<" + arrow
2077
+ lab = '|"%s"|' % mm_text(e["label"]) if e["label"] else ""
2078
+ out.append(" %s %s%s %s" % (mm_id(e["from"]), arrow, lab, mm_id(e["to"])))
2079
+ assumed = [mm_id(n["id"]) for n in nodes if n["assumed"]]
2080
+ if assumed:
2081
+ out.append(" classDef assumed stroke-dasharray: 5 4")
2082
+ out.append(" class %s assumed" % ",".join(assumed))
2083
+ return "\n".join(out) + "\n"
2084
+
2085
+
2086
+ # --------------------------------------------------------------------------- text outline
2087
+
2088
+ def outline_special(spec, evs, assumed):
2089
+ t = spec["type"]
2090
+ if t == "tree":
2091
+ kids = {}
2092
+ for n in spec["nodes"]:
2093
+ kids.setdefault(n.get("parent"), []).append(n)
2094
+
2095
+ def ul(parent):
2096
+ items = kids.get(parent, [])
2097
+ if not items:
2098
+ return ""
2099
+ return "<ul>" + "".join("<li><b>%s</b>%s%s%s%s</li>" % (
2100
+ esc(n["label"]), (" \u2014 " + esc(n["note"])) if n.get("note") else "", assumed(n), evs(n), ul(n["id"])) for n in items) + "</ul>"
2101
+ return "<h2>What contains what</h2>" + ul(None)
2102
+ if t == "timeline":
2103
+ groups = {g["id"]: g["label"] for g in spec["groups"]}
2104
+ rows = sorted(spec["nodes"], key=lambda n: n["_t"][0])
2105
+ tl = spec.get("_timeline") or {}
2106
+ head = "<p>%s to %s (%s).</p>" % (esc(tl.get("start", "")), esc(tl.get("end", "")), esc(tl.get("span", ""))) if tl else ""
2107
+ return "<h2>Events, in time order</h2>" + head + "<ol>" + "".join("<li><b>%s</b> %s%s%s%s</li>" % (
2108
+ esc(n["when"]), esc(n["label"]), (" <span class=\"muted\">(%s)</span>" % esc(groups.get(n.get("lane") or n.get("group"), n.get("lane") or n.get("group"))))
2109
+ if (n.get("lane") or n.get("group")) else "", assumed(n), evs(n)) for n in rows) + "</ol>"
2110
+ if t == "waterfall":
2111
+ wf = spec.get("_waterfall") or {}
2112
+ rows = sorted(spec["nodes"], key=lambda n: n.get("row", 0))
2113
+ return ("<h2>Spans, in call order</h2><p>Total %s. What it waited on, in order: %s.</p><ul>" % (
2114
+ esc(wf.get("total", "")), esc(" \u2192 ".join(next(n["label"] for n in rows if n["id"] == i) for i in wf.get("crit", [])))) +
2115
+ "".join("<li>%s<b>%s</b>: starts at %s, takes %s (%s)%s%s</li>" % (
2116
+ "\u2003" * n.get("depth", 0), esc(n["label"]), esc(n["facts"][0][1]), esc(n["facts"][1][1]), esc(n["facts"][2][1]),
2117
+ assumed(n), evs(n)) for n in rows) + "</ul>")
2118
+ return None
2119
+
2120
+
2121
+ def outline_html(spec):
2122
+ """The map as text: what a screen reader, a search engine, a judge or a reader without
2123
+ JavaScript gets, and what can be pasted into a doc."""
2124
+ t = spec["type"]
2125
+ by_id = {n["id"]: n for n in spec["nodes"]}
2126
+ lab = lambda i: by_id[i]["label"] if i in by_id else i
2127
+ out = []
2128
+ groups = {g["id"]: g for g in spec["groups"]}
2129
+
2130
+ def status(x):
2131
+ s = x.get("status")
2132
+ return ' <span class="tag st-%s">%s</span>' % (s, s) if s else ""
2133
+
2134
+ def assumed(x):
2135
+ return ' <span class="tag warn">assumption</span>' if x.get("assumed") else ""
2136
+
2137
+ def evs(x):
2138
+ if not x["evidence"]:
2139
+ return ""
2140
+ parts = []
2141
+ for e in x["evidence"]:
2142
+ mark = " ✓" if e.get("verified") else ""
2143
+ parts.append("<code>%s</code>%s" % (esc(ev_text(e)), mark))
2144
+ return ' <span class="ev">(%s)</span>' % ", ".join(parts)
2145
+ found = "" # the findings sit at the top of the page
2146
+ special = outline_special(spec, evs, assumed)
2147
+ if special is not None:
2148
+ return found + special
2149
+ if special is not None:
2150
+ return special
2151
+ noun = {"sequence": "Participants", "erd": "Tables", "lifecycle": "States", "class": "Types"}.get(t, "Parts")
2152
+ out.append("<h2>%s</h2><ul>" % noun)
2153
+ for n in spec["nodes"]:
2154
+ g = groups.get(n.get("group"))
2155
+ extra = " — %s" % esc(n["note"]) if n.get("note") else ""
2156
+ ing = " <span class=\"muted\">in %s</span>" % esc(g["label"]) if g else ""
2157
+ fields = ""
2158
+ if n["fields"]:
2159
+ fields = "<ul>" + "".join("<li><code>%s</code> %s%s</li>" % (
2160
+ esc(f.get("name", "")), esc(f.get("type", "")),
2161
+ (" <b>%s</b>" % esc("/".join(f["key"]) if isinstance(f.get("key"), list) else f["key"]).upper()) if f.get("key") else "")
2162
+ for f in n["fields"]) + "</ul>"
2163
+ 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))
2164
+ out.append("</ul>")
2165
+ noun = "Messages, in order" if t == "sequence" else ("Relationships" if t == "erd" else "Connections")
2166
+ out.append("<h2>%s</h2><ol>" % noun if t == "sequence" else "<h2>%s</h2><ul>" % noun)
2167
+ for e in spec["edges"]:
2168
+ card = ""
2169
+ if e.get("from_card") or e.get("to_card"):
2170
+ card = " <span class=\"muted\">(%s to %s)</span>" % (esc(e.get("from_card", "?")), esc(e.get("to_card", "?")))
2171
+ out.append("<li>%s → %s%s%s%s%s%s</li>" % (esc(lab(e["from"])), esc(lab(e["to"])),
2172
+ (": " + esc(e["label"])) if e["label"] else "", card, assumed(e), status(e), evs(e)))
2173
+ out.append("</ol>" if t == "sequence" else "</ul>")
2174
+ if spec["story"]:
2175
+ out.append("<h2>Walkthrough</h2><ol>" + "".join("<li>%s</li>" % esc(s.get("text", "")) for s in spec["story"]) + "</ol>")
2176
+ return "".join(out)
2177
+
2178
+
2179
+ # --------------------------------------------------------------------------- HTML
2180
+
2181
+ CSS = r"""
2182
+ :root{--bg:#f7f8fa;--panel:#ffffff;--ink:#16181d;--muted:#5b6170;--line:#d5d9e0;--edge:#6b7385;--accent:#2563eb;
2183
+ --warn:#b45309;--ok:#15803d;--bad:#b91c1c;--hl:#f59e0b;--canvas:#fbfcfd;--group:#eef1f6;--group2:#e6eaf2;--shadow:0 1px 2px rgba(0,0,0,.06)}
2184
+ :root[data-theme=dark]{--bg:#0f1115;--panel:#171a21;--ink:#e7e9ee;--muted:#9aa1b2;--line:#2b303b;--edge:#8b93a7;--accent:#60a5fa;
2185
+ --warn:#f59e0b;--ok:#4ade80;--bad:#f87171;--hl:#fbbf24;--canvas:#12151b;--group:#1a1e27;--group2:#20252f;--shadow:none}
2186
+ *{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}
2187
+ body{display:flex;flex-direction:column}body>*{flex:none}section.text{order:10}
2188
+ header.top{padding:18px 24px 10px;max-width:1400px}header.top h1{margin:0 0 4px;font-size:21px}
2189
+ .q{color:var(--muted);margin:0 0 6px}.sum{margin:0;max-width:900px}
2190
+ .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)}
2191
+ .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}
2192
+ .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}
2193
+ .bar .hint{color:var(--muted);font-size:12px;margin-left:auto}
2194
+ .wrap{display:flex;gap:0;height:calc(100vh - 150px);min-height:420px;padding:10px 24px 0}
2195
+ #stage{flex:1;position:relative;overflow:hidden;border:1px solid var(--line);border-radius:10px;background:var(--canvas);touch-action:none;cursor:grab}
2196
+ #stage.drag{cursor:grabbing}#stage svg{position:absolute;left:0;top:0;width:100%;height:100%}
2197
+ 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}
2198
+ 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}
2199
+ aside .close{float:right;border:0;background:none;color:var(--muted);font-size:18px;cursor:pointer}
2200
+ aside pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:260px}
2201
+ aside ul{padding-left:18px;margin:4px 0}aside a.jump{color:var(--accent);cursor:pointer;text-decoration:none}
2202
+ 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}
2203
+ .caption{display:none;margin:8px 24px 0;padding:8px 12px;border-left:3px solid var(--hl);background:var(--panel);border-radius:4px}
2204
+ .caption.on{display:block}
2205
+ section.text{max-width:1100px;padding:10px 24px 40px}section.text h2{font-size:15px;margin:18px 0 4px}
2206
+ .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)}
2207
+ .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)}
2208
+ .muted{color:var(--muted)}.ev{color:var(--muted);font-size:12px}code{font-size:12px}
2209
+ .legend{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--muted);padding:6px 24px 0}
2210
+ .legend i{display:inline-block;width:22px;height:0;border-top:2px solid var(--edge);vertical-align:middle;margin-right:5px}
2211
+ .legend i.d{border-top-style:dashed}.legend i.a{border-top:2px dashed var(--warn)}
2212
+ .findings{margin:10px 0 0;padding:8px 14px;border-left:3px solid var(--accent);background:var(--panel);border-radius:4px;max-width:1000px}
2213
+ .findings ul{margin:4px 0 0;padding-left:18px}.findings li{margin:2px 0}
2214
+ table.answers{border-collapse:collapse;margin:10px 0 0;max-width:1100px;font-size:13px}
2215
+ table.answers th,table.answers td{border:1px solid var(--line);padding:5px 9px;text-align:left;vertical-align:top}
2216
+ table.answers th{background:var(--group2);font-weight:600}table.answers td.ev{font-size:12px;color:var(--muted)}
2217
+ .issues{margin:8px 24px 0;padding:8px 12px;border:1px solid var(--warn);border-radius:6px;color:var(--warn);font-size:13px}
2218
+ /* diagram */
2219
+ .canvas{fill:var(--canvas)}
2220
+ .group rect{fill:var(--group);stroke:var(--line);stroke-width:1}.group.d2 rect{fill:var(--group2)}.group.d3 rect{fill:var(--group)}
2221
+ .glabel{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.02em}
2222
+ .group.st-removed rect{stroke:var(--bad);stroke-dasharray:6 4;fill:none}
2223
+ .node{cursor:pointer;outline:none}.node .shape{stroke-width:1.5;fill:var(--panel)}.node .shape.solid{fill:var(--ink);stroke:var(--ink)}
2224
+ .node .shape-line{fill:none;stroke-width:1.2}.node .glyph{fill:var(--muted)}
2225
+ .node .label{font-size:13px;font-weight:600;fill:var(--ink)}.node .note{font-size:11px;fill:var(--muted)}
2226
+ .node .badge{font-size:10px;fill:var(--ok)}.node .badge.warn{fill:var(--warn)}
2227
+ .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)}
2228
+ .c-blue .shape,.c-blue .shape-line{stroke:#3b82f6}.c-violet .shape{stroke:#8b5cf6}.c-green .shape,.c-green .shape-line{stroke:#16a34a}
2229
+ .c-teal .shape,.c-teal .shape-line{stroke:#0d9488}.c-amber .shape,.c-amber .shape-line{stroke:#d97706}.c-slate .shape{stroke:#64748b}
2230
+ .c-rose .shape{stroke:#e11d48}.c-indigo .shape{stroke:#4f46e5}.k-external .shape{stroke-dasharray:5 3}
2231
+ .node.assumed .shape{stroke:var(--warn)!important;stroke-dasharray:6 4}
2232
+ .node.st-added .shape{stroke:var(--ok)!important;stroke-width:2.5}.node.st-changed .shape{stroke:var(--accent)!important;stroke-width:2.5}
2233
+ .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}
2234
+ .edge .line{fill:none;stroke:var(--edge);stroke-width:1.5}.edge .hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}
2235
+ .edge.s-dashed .line{stroke-dasharray:7 5}.edge.s-dotted .line{stroke-dasharray:2 4}.edge.emph .line{stroke-width:2.6}
2236
+ .edge.assumed .line{stroke:var(--warn);stroke-dasharray:6 4}.edge.st-added .line{stroke:var(--ok);stroke-width:2.2}
2237
+ .edge.st-removed .line{stroke:var(--bad);stroke-dasharray:6 4;opacity:.7}
2238
+ .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)}
2239
+ .lifeline{stroke:var(--line);stroke-width:1.2;stroke-dasharray:4 4}.seqno{font-size:10px;fill:var(--muted)}
2240
+ .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}
2241
+ svg.focus .node,svg.focus .edge,svg.focus .group{opacity:.18;transition:opacity .15s}
2242
+ 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}
2243
+ svg .node.sel .shape{stroke:var(--hl)!important;stroke-width:3}svg .node:focus-visible .shape{stroke:var(--accent)!important;stroke-width:3}
2244
+ @media (prefers-reduced-motion:no-preference){svg .edge.on.flow .line{stroke-dasharray:8 6;animation:flow 1s linear infinite}}
2245
+ @keyframes flow{to{stroke-dashoffset:-28}}
2246
+ .lane rect{fill:var(--group);stroke:var(--line)}.lane-line{stroke:var(--line);stroke-width:2}
2247
+ .axis line{stroke:var(--muted);stroke-width:1}.axis text{font-size:11px;fill:var(--muted)}.axis .grid{stroke:var(--line);stroke-dasharray:2 4}
2248
+ .brk{stroke:var(--muted);stroke-width:1.5;fill:none}.brk-label{font-size:10px;fill:var(--muted)}
2249
+ .node.event .shape{stroke-width:2;fill:var(--panel)}.node.event .bar{fill:var(--group2);stroke-width:1.5}
2250
+ .node.event .leader{stroke:var(--line);stroke-width:1}.node.event.approx .shape{stroke-dasharray:3 2}.node .label.tl{font-size:12px}.node .note.tl{font-size:10px}
2251
+ .node.span .rowbg{fill:transparent}.node.span:hover .rowbg,.node.span.on .rowbg{fill:var(--group)}
2252
+ .node.span .bar{stroke:none;opacity:.88}.node.span.crit .bar{stroke:var(--hl);stroke-width:2.5;opacity:1}
2253
+ .node.span.err .bar{stroke:var(--bad);stroke-width:2.5}.node .label.wf{font-size:12px;font-weight:500}.node .note.wf{font-size:11px}
2254
+ .node .label.italic{font-style:italic}.mk-open{fill:var(--canvas);stroke:var(--edge);stroke-width:1.2}.mk-line{fill:none;stroke:var(--edge);stroke-width:1.4}
2255
+ .edge.tree .line{stroke:var(--line);stroke-width:1.5}
2256
+ aside table.facts{border-collapse:collapse;font-size:13px;margin:6px 0}aside table.facts th{text-align:left;color:var(--muted);font-weight:400;padding:2px 10px 2px 0}
2257
+ @media print{.bar,aside,.caption{display:none!important}.wrap{height:auto}#stage{border:0}#stage svg{position:static;height:auto}}
2258
+ """
2259
+
2260
+ JS = r"""
2261
+ (function(){
2262
+ var DATA=JSON.parse(document.getElementById('diagram-data').textContent);var WD=DATA.words;
2263
+ var svg=document.getElementById('map'),stage=document.getElementById('stage'),panel=document.getElementById('panel');
2264
+ var root=document.documentElement;
2265
+ var pref=localStorage.getItem('idg-theme')||root.getAttribute('data-theme-default');
2266
+ if(pref==='auto'||!pref){pref=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}
2267
+ root.setAttribute('data-theme',pref);
2268
+ var W=+svg.getAttribute('width'),H=+svg.getAttribute('height');svg.removeAttribute('width');svg.removeAttribute('height');
2269
+ var vb={x:0,y:0,w:W,h:H};
2270
+ function apply(){svg.setAttribute('viewBox',vb.x+' '+vb.y+' '+vb.w+' '+vb.h)}
2271
+ 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()}
2272
+ function zoom(f,cx,cy){var r=stage.getBoundingClientRect();if(cx==null){cx=r.width/2;cy=r.height/2}
2273
+ 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;
2274
+ vb.x=px-(px-vb.x)*k;vb.y=py-(py-vb.y)*k;vb.w=nw;vb.h=vb.h*k;apply()}
2275
+ 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});
2276
+ var drag=null;
2277
+ 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)});
2278
+ 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()});
2279
+ stage.addEventListener('pointerup',function(){drag=null;stage.classList.remove('drag')});
2280
+ // Lookups are Maps, never plain objects: ids come from the spec and the URL, and a key such as
2281
+ // __proto__ must stay a key.
2282
+ var nodes=new Map(),edges=new Map(),outE=new Map(),inE=new Map();
2283
+ DATA.nodes.forEach(function(n){nodes.set(n.id,n);outE.set(n.id,[]);inE.set(n.id,[])});
2284
+ 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)});
2285
+ function el(kind,id){return document.getElementById(kind+'-'+id)}
2286
+ function clear(){svg.classList.remove('focus');svg.querySelectorAll('.on,.sel,.flow').forEach(function(x){x.classList.remove('on','sel','flow')})}
2287
+ function light(ns,es,flow){clear();svg.classList.add('focus');ns.forEach(function(i){var x=el('n',i);x&&x.classList.add('on')});
2288
+ es.forEach(function(i){var x=el('e',i);if(x){x.classList.add('on');flow&&x.classList.add('flow')}})}
2289
+ 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)}})}
2290
+ return {n:Array.from(seen),e:es}}
2291
+ 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;
2292
+ 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]}));
2293
+ nb.forEach(function(p){if(!prev.has(p[0])){prev.set(p[0],[u,p[1]]);q.push(p[0])}})}
2294
+ 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()}}
2295
+ function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
2296
+ function evHtml(list){if(!list||!list.length)return '<p class="muted">No evidence cited.</p>';
2297
+ 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:''):''));
2298
+ 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>'}
2299
+ 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>':'');
2300
+ return '<li>'+t+v+(e.snippet?'<pre>'+esc(e.snippet)+'</pre>':'')+'</li>'}).join('')+'</ul>'}
2301
+ var selected=null;
2302
+ 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>';
2303
+ 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>';
2304
+ if(x.note)h+='<p>'+esc(x.note)+'</p>';if(x.detail)h+='<p>'+esc(x.detail)+'</p>';
2305
+ 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>';
2306
+ if(x.facts&&x.facts.length)h+='<table class="facts">'+x.facts.map(function(f){return '<tr><th>'+esc(f[0])+'</th><td>'+esc(f[1])+'</td></tr>'}).join('')+'</table>';
2307
+ h+='<div class="acts"><button data-act="out">'+esc(WD.reach_out)+'</button><button data-act="in">'+esc(WD.reach_in)+'</button><button data-act="near">Neighbours</button></div>';
2308
+ h+='<p class="muted" style="font-size:12px">Shift-click another part to see the path between them.</p>';
2309
+ 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('');
2310
+ 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('');
2311
+ if(o)h+='<b>'+esc(WD.out)+'</b><ul>'+o+'</ul>';if(n)h+='<b>'+esc(WD.in)+'</b><ul>'+n+'</ul>';
2312
+ h+='<b>Evidence</b>'+evHtml(x.evidence);
2313
+ 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');
2314
+ }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>';
2315
+ if(x.detail)h+='<p>'+esc(x.detail)+'</p>';h+='<b>Evidence</b>'+evHtml(x.evidence);light([x.from,x.to],[id],true)}
2316
+ panel.innerHTML=h;panel.classList.add('open');selected=kind==='n'?id:null;
2317
+ panel.querySelector('.close').onclick=function(){panel.classList.remove('open');clear();selected=null};
2318
+ panel.querySelectorAll('.jump').forEach(function(a){a.onclick=function(){show('n',a.getAttribute('data-n'))}});
2319
+ panel.querySelectorAll('[data-act]').forEach(function(b){b.onclick=function(){var a=b.getAttribute('data-act');
2320
+ if(a==='near'){show('n',id);return}var r=reach(id,a==='out'?1:-1);light(r.n,r.e,true);el('n',id).classList.add('sel')}})}
2321
+ svg.addEventListener('click',function(e){var g=e.target.closest('.node');if(g){var id=g.getAttribute('data-id');
2322
+ 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}
2323
+ var ed=e.target.closest('.edge');if(ed){show('e',ed.getAttribute('data-id'));return}});
2324
+ 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'))}});
2325
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){panel.classList.remove('open');clear();selected=null}
2326
+ if(e.target.tagName==='INPUT')return;if(e.key==='ArrowRight'&&tour)step(1);if(e.key==='ArrowLeft'&&tour)step(-1)});
2327
+ var q=document.getElementById('find');q.addEventListener('input',function(){var v=q.value.trim().toLowerCase();if(!v){clear();return}
2328
+ 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,[])});
2329
+ document.getElementById('zin').onclick=function(){zoom(1/1.25)};document.getElementById('zout').onclick=function(){zoom(1.25)};document.getElementById('zfit').onclick=fit;
2330
+ 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){}};
2331
+ 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)}
2332
+ 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');
2333
+ c.querySelectorAll('.on,.sel,.flow').forEach(function(x){x.classList.remove('on','sel','flow')});
2334
+ 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(';');
2335
+ 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];
2336
+ c.insertBefore(st,c.firstChild);return new XMLSerializer().serializeToString(c)}
2337
+ var base=(DATA.slug||'diagram');window.interactiveDiagram={svg:standalone,mermaid:function(){return DATA.mermaid}};
2338
+ document.getElementById('xsvg').onclick=function(){download(base+'.svg','image/svg+xml',standalone())};
2339
+ 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)})};
2340
+ img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(standalone())};
2341
+ document.getElementById('xmmd').onclick=function(){var t=DATA.mermaid;if(navigator.clipboard)navigator.clipboard.writeText(t).catch(function(){});download(base+'.mmd','text/plain',t)};
2342
+ var tour=null,cap=document.getElementById('caption');
2343
+ var steps=DATA.story&&DATA.story.length?DATA.story:(DATA.auto_steps&&DATA.auto_steps.length)?DATA.auto_steps:(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]}}):[]);
2344
+ var tb=document.getElementById('tour');if(!steps.length){tb.style.display='none'}
2345
+ function step(d){tour.i=Math.max(0,Math.min(steps.length-1,tour.i+d));var s=steps[tour.i];
2346
+ 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);
2347
+ 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')}
2348
+ 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)};
2349
+ document.getElementById('prev').onclick=function(){if(tour)step(-1)};document.getElementById('next').onclick=function(){if(!tour){tb.onclick()}else step(1)};
2350
+ if(!steps.length){document.getElementById('prev').style.display='none';document.getElementById('next').style.display='none'}
2351
+ function size(){var wr=document.querySelector('.wrap');var avail=Math.max(window.innerHeight-150,360);var r=stage.getBoundingClientRect();
2352
+ 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'}
2353
+ size();fit();window.addEventListener('resize',function(){size();fit()});
2354
+ 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))});
2355
+ if(kv.get('theme'))root.setAttribute('data-theme',kv.get('theme'));
2356
+ 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'));
2357
+ 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)}
2358
+ 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)}
2359
+ if(kv.get('step')&&steps.length){tour={i:-1};tb.textContent='Stop';step(+kv.get('step'))}}
2360
+ hash();window.addEventListener('hashchange',hash);
2361
+ })();
2362
+ """
2363
+
2364
+
2365
+ def auto_steps(spec):
2366
+ """A walkthrough when the spec has none: a timeline in time order, a waterfall along its
2367
+ critical path."""
2368
+ t = spec["type"]
2369
+ if spec["story"]:
2370
+ return []
2371
+ if t == "timeline":
2372
+ return [{"text": "%s \u2014 %s" % (n["when"], n["label"]), "nodes": [n["id"]]}
2373
+ for n in sorted(spec["nodes"], key=lambda n: n["_t"][0])]
2374
+ if t == "waterfall":
2375
+ by_id = {n["id"]: n for n in spec["nodes"]}
2376
+ return [{"text": "%s: %s, %s of the whole, %s of its own" % (by_id[i]["label"], by_id[i]["facts"][1][1], by_id[i]["facts"][2][1],
2377
+ by_id[i]["facts"][3][1]), "nodes": [i]}
2378
+ for i in spec["_waterfall"]["crit"]]
2379
+ return []
2380
+
2381
+
2382
+ def type_facts(spec):
2383
+ """Facts a part's detail panel lists, beyond its note and evidence."""
2384
+ t = spec["type"]
2385
+ if t == "timeline":
2386
+ groups = {g["id"]: g["label"] for g in spec["groups"]}
2387
+ for n in spec["nodes"]:
2388
+ ln = n.get("lane") or n.get("group")
2389
+ n["facts"] = [["when", n["when"]]] + ([["source", groups.get(ln, ln)]] if ln else [])
2390
+ if t == "tree":
2391
+ kids = {}
2392
+ for n in spec["nodes"]:
2393
+ kids.setdefault(n.get("parent"), []).append(n["id"])
2394
+
2395
+ def count(u):
2396
+ return sum(1 + count(v) for v in kids.get(u, []))
2397
+ for n in spec["nodes"]:
2398
+ c = count(n["id"])
2399
+ if c:
2400
+ n["facts"] = [["inside it", "%d item%s" % (c, "" if c == 1 else "s")]]
2401
+
2402
+
2403
+ def build_html(spec, svg, mermaid, issues, theme):
2404
+ t = spec["type"]
2405
+ by_group = {g["id"]: g["label"] for g in spec["groups"]}
2406
+ data = {"type": t, "slug": re.sub(r"[^a-z0-9]+", "-", spec["title"].lower()).strip("-")[:60] or "diagram",
2407
+ "source_url": spec.get("source_url") if re.match(r"^https?://[^\s\"'<>]+$", str(spec.get("source_url") or "")) else None,
2408
+ "story": spec["story"], "nodes": [], "edges": [], "mermaid": mermaid,
2409
+ "words": WORDS.get(t, WORDS["default"]), "auto_steps": auto_steps(spec)}
2410
+ for n in spec["nodes"]:
2411
+ d = {k: n.get(k) for k in ("id", "label", "kind", "note", "detail", "fields", "evidence", "assumed", "status", "facts")}
2412
+ d["group_label"] = by_group.get(n.get("group"))
2413
+ data["nodes"].append(d)
2414
+ for e in spec["edges"]:
2415
+ data["edges"].append({k: e.get(k) for k in ("id", "from", "to", "label", "detail", "evidence", "assumed", "status")})
2416
+ payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
2417
+ assumed_n = sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"] and not x.get("tree"))
2418
+ file_ev = [e for x in spec["nodes"] + spec["edges"] for e in x["evidence"] if "file" in e]
2419
+ checked = sum(1 for e in file_ev if e.get("verified"))
2420
+ facts = []
2421
+ if file_ev:
2422
+ facts.append("%d of %d file citations checked against the repository" % (checked, len(file_ev)) if checked
2423
+ else "%d file citations, not checked against a repository" % len(file_ev))
2424
+ if assumed_n:
2425
+ facts.append("%d item%s drawn dashed as assumptions" % (assumed_n, "" if assumed_n == 1 else "s"))
2426
+ if spec.get("compared"):
2427
+ cnt = {}
2428
+ for x in spec["nodes"] + spec["edges"]:
2429
+ if x.get("status"):
2430
+ cnt[x["status"]] = cnt.get(x["status"], 0) + 1
2431
+ facts.append(", ".join("%d %s" % (v, k) for k, v in sorted(cnt.items())) or "no changes")
2432
+ legend = {
2433
+ "tree": ['<span><i></i>contains</span>'],
2434
+ "timeline": ['<span>\u25cf event</span>', '<span>\u25c6 milestone</span>', '<span>\u25ac lasted from \u2026 to</span>',
2435
+ '<span>// a quiet stretch, shortened</span>'],
2436
+ "waterfall": ['<span>\u25ac a span, to scale</span>', '<span style="color:var(--hl)">\u25ad what the whole waited on (the critical path)</span>',
2437
+ '<span style="color:var(--bad)">\u25ad failed</span>'] + (
2438
+ ['<span>total %s</span>' % esc(spec["_waterfall"]["total"])] if spec.get("_waterfall") else []),
2439
+ "class": ['<span>\u25b7 inherits (solid) or implements (dashed)</span>', '<span>\u25c6 composition</span>',
2440
+ '<span>\u25c7 aggregation</span>', '<span>\u2192 association</span>', '<span>\u21e2 dependency</span>'],
2441
+ }.get(spec["type"], ['<span><i></i>connection</span>', '<span><i class="d"></i>async / optional</span>'])
2442
+ if assumed_n:
2443
+ legend.append('<span><i class="a"></i>assumption (not shown by the evidence)</span>')
2444
+ if spec.get("compared"):
2445
+ legend.append('<span style="color:var(--ok)">■ added</span><span style="color:var(--bad)">■ removed</span>'
2446
+ '<span style="color:var(--accent)">■ changed</span>')
2447
+ notes = "".join("<li>%s</li>" % esc(x) for x in spec["notes"])
2448
+ warn_html = "" # warnings are for whoever builds the page; they are printed, not published
2449
+ up_front = ""
2450
+ if spec.get("_findings"):
2451
+ up_front += '<div class="findings"><b>What it shows</b><ul>' + "".join("<li>%s</li>" % esc(f) for f in spec["_findings"]) + "</ul></div>"
2452
+ if spec["answers"]:
2453
+ def ev_short(a):
2454
+ if a.get("assumed") and not a["evidence"]:
2455
+ return '<span class="tag warn">assumption</span>'
2456
+ return ", ".join("<code>%s</code>%s" % (esc(ev_text(e)), " \u2713" if e.get("verified") else "") for e in a["evidence"])
2457
+ up_front += ('<table class="answers"><thead><tr><th>Question</th><th>What the material says</th><th>From</th></tr></thead><tbody>' +
2458
+ "".join("<tr><td>%s</td><td>%s</td><td class=\"ev\">%s</td></tr>" % (esc(a["question"]), esc(a["answer"]), ev_short(a))
2459
+ for a in spec["answers"]) + "</tbody></table>")
2460
+ return """<!doctype html>
2461
+ <html lang="en" data-theme-default="%(theme)s"><head><meta charset="utf-8">
2462
+ <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'">
2463
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2464
+ <!-- One self-contained file: no external scripts, styles, fonts or requests. The policy above makes the browser refuse any. -->
2465
+ <title>%(title)s</title><meta name="generator" content="interactive-diagram (skilldrop)"></head>
2466
+ <body>
2467
+ <header class="top"><h1>%(title)s</h1>%(question)s%(summary)s%(up_front)s<p class="muted" style="font-size:12px;margin:6px 0 0">%(facts)s</p></header>
2468
+ <section class="text" id="outline" aria-label="The diagram as text">
2469
+ <details%(open)s><summary>The diagram as text</summary>%(outline)s%(notes)s</details></section>
2470
+ <nav class="bar" aria-label="Diagram controls">
2471
+ <input id="find" type="search" placeholder="Find a part…" aria-label="Find a part">
2472
+ <button id="zin" title="Zoom in">+</button><button id="zout" title="Zoom out">−</button><button id="zfit" title="Fit to screen">Fit</button>
2473
+ <span class="sep"></span><button id="tour">Walk through</button><button id="prev" title="Previous step">←</button><button id="next" title="Next step">→</button>
2474
+ <span class="sep"></span><button id="xsvg">SVG</button><button id="xpng">PNG</button><button id="xmmd" title="Copy and download Mermaid">Mermaid</button>
2475
+ <button id="theme" title="Light or dark">◐</button>
2476
+ <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>
2477
+ <div class="legend">%(legend)s</div>%(warn)s
2478
+ <div class="caption" id="caption" aria-live="polite"></div>
2479
+ <div class="wrap"><div id="stage">%(svg)s</div><aside id="panel" aria-live="polite"></aside></div>
2480
+ <style id="diagram-css">%(css)s</style>
2481
+ <script type="application/json" id="diagram-data">%(data)s</script>
2482
+ <script>%(js)s</script>
2483
+ </body></html>
2484
+ """ % {"theme": esc(theme), "title": esc(spec["title"]),
2485
+ "question": '<p class="q">%s</p>' % esc(spec["question"]) if spec["question"] else "",
2486
+ "summary": '<p class="sum">%s</p>' % esc(spec["summary"]) if spec["summary"] else "",
2487
+ "facts": esc(" · ".join(facts)), "open": "" if len(spec["nodes"]) > 12 else " open",
2488
+ "outline": outline_html(spec), "notes": "<h2>Notes</h2><ul>%s</ul>" % notes if notes else "",
2489
+ "legend": "".join(legend), "warn": warn_html, "up_front": up_front, "svg": svg, "css": CSS,
2490
+ "data": payload, "js": JS,
2491
+ "js_hash": "sha256-" + base64.b64encode(hashlib.sha256(JS.encode("utf-8")).digest()).decode()}
2492
+
2493
+
2494
+ # --------------------------------------------------------------------------- main
2495
+
2496
+ def load(path):
2497
+ with open(path, encoding="utf-8") as fh:
2498
+ return json.load(fh)
2499
+
2500
+
2501
+ def main(argv=None):
2502
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
2503
+ ap.add_argument("spec", help="diagram spec (JSON)")
2504
+ ap.add_argument("-o", "--out", help="HTML file to write")
2505
+ ap.add_argument("--repo-root", help="check every file:line citation against this directory and show the cited lines")
2506
+ ap.add_argument("--compare", metavar="BASE", help="a base spec: mark what this one added, removed or changed")
2507
+ ap.add_argument("--mermaid", metavar="FILE", help="also write the diagram as Mermaid")
2508
+ ap.add_argument("--theme", choices=("auto", "light", "dark"), default="auto")
2509
+ ap.add_argument("--check", action="store_true", help="validate (and verify evidence); write nothing")
2510
+ ap.add_argument("--json", action="store_true", help="print the receipt as JSON")
2511
+ a = ap.parse_args(argv)
2512
+ if not a.check and not a.out:
2513
+ ap.error("give -o OUT.html, or --check to validate only")
2514
+ issues = Issues()
2515
+ try:
2516
+ spec = normalise(load(a.spec), issues)
2517
+ except (OSError, ValueError) as err:
2518
+ print("cannot read %s: %s" % (a.spec, err), file=sys.stderr)
2519
+ return 2
2520
+ if a.compare:
2521
+ try:
2522
+ base_issues = Issues()
2523
+ base = normalise(load(a.compare), base_issues)
2524
+ merge_compare(spec, base, issues)
2525
+ except (OSError, ValueError) as err:
2526
+ print("cannot read %s: %s" % (a.compare, err), file=sys.stderr)
2527
+ return 2
2528
+ verify_evidence(spec, a.repo_root, issues)
2529
+ file_ev = [e for x in spec["nodes"] + spec["edges"] for e in x["evidence"] if "file" in e]
2530
+ if file_ev and not a.repo_root:
2531
+ issues.warn("evidence/unchecked", "evidence", "%d file citations were not checked" % len(file_ev),
2532
+ "Pass --repo-root so each file and line is verified.")
2533
+ receipt = {"ok": not issues.errors, "type": spec["type"], "nodes": len(spec["nodes"]), "edges": len(spec["edges"]),
2534
+ "assumed": sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"]),
2535
+ "file_citations": len(file_ev), "verified": sum(1 for e in file_ev if e.get("verified")),
2536
+ "errors": issues.errors, "warnings": issues.warnings}
2537
+ if not issues.errors and not a.check:
2538
+ t = spec["type"]
2539
+ if t == "sequence":
2540
+ geo = layout_sequence(spec)
2541
+ elif t == "tree":
2542
+ geo = layout_tree(spec)
2543
+ elif t == "timeline":
2544
+ geo = layout_timeline(spec)
2545
+ elif t == "waterfall":
2546
+ geo = layout_waterfall(spec)
2547
+ else:
2548
+ flipped = [e for e in spec["edges"] if e.get("rel") in ("inherits", "implements")]
2549
+ for e in flipped: # the parent goes first, so it sits above its children
2550
+ e["from"], e["to"] = e["to"], e["from"]
2551
+ geo = layout_graph_full(spec)
2552
+ for e in flipped:
2553
+ e["from"], e["to"] = e["to"], e["from"]
2554
+ e["pts"] = list(reversed(e.get("pts", [])))
2555
+ occupied = [(n["x"], n["y"], n["x"] + n["w"], n["y"] + n["h"]) for n in spec["nodes"]]
2556
+ labels = {g["id"]: g["label"] for g in spec["groups"]}
2557
+ 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"]]
2558
+ place_labels(spec, occupied)
2559
+ if t == "tree":
2560
+ for e in spec["edges"]:
2561
+ e.pop("label_box", None)
2562
+ type_facts(spec)
2563
+ svg = render_svg(spec, geo)
2564
+ mermaid = to_mermaid(spec)
2565
+ out = build_html(spec, svg, mermaid, issues, a.theme)
2566
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
2567
+ with open(a.out, "w", encoding="utf-8") as fh:
2568
+ fh.write(out)
2569
+ receipt["output"] = os.path.abspath(a.out)
2570
+ receipt["bytes"] = len(out.encode("utf-8"))
2571
+ receipt["edges"] = len([e for e in spec["edges"] if not e.get("tree")]) or len(spec["edges"])
2572
+ shown_path = os.path.relpath(os.path.abspath(a.out)) if not os.path.relpath(os.path.abspath(a.out)).startswith("..") else os.path.abspath(a.out)
2573
+ receipt["reply_first_line"] = "[%s](%s)" % (spec["title"].replace("]", ")"), shown_path)
2574
+ receipt["findings"] = spec.get("_findings", [])
2575
+ if a.mermaid:
2576
+ with open(a.mermaid, "w", encoding="utf-8") as fh:
2577
+ fh.write(mermaid)
2578
+ receipt["mermaid"] = os.path.abspath(a.mermaid)
2579
+ if a.json:
2580
+ print(json.dumps(receipt, indent=1))
2581
+ else:
2582
+ for i in issues.items:
2583
+ print("%s %s [%s] %s%s" % ("ERROR" if i["level"] == "error" else "warning", i["where"], i["code"], i["message"],
2584
+ (" — " + i["fix"]) if i["fix"] else ""))
2585
+ if issues.errors:
2586
+ print("%d error(s); nothing written. Fix the spec and run again." % len(issues.errors))
2587
+ elif a.check:
2588
+ print("OK: %d %s, %d %s, %d assumed, %d of %d file citations verified" % (
2589
+ receipt["nodes"], "participants" if spec["type"] == "sequence" else "nodes", receipt["edges"],
2590
+ "messages" if spec["type"] == "sequence" else "edges", receipt["assumed"], receipt["verified"], receipt["file_citations"]))
2591
+ else:
2592
+ item, link = {"sequence": ("participants", "messages"), "tree": ("items", "links"), "timeline": ("events", "links"),
2593
+ "waterfall": ("spans", "parent links"), "class": ("types", "relations")}.get(spec["type"], ("nodes", "edges"))
2594
+ print("Your reply starts with this line, before anything else:\n" + receipt["reply_first_line"] + "\n")
2595
+ print("For the end of the reply: %d of %d file citations checked; %d drawn as assumptions. (%s, %d KB: %d %s, %d %s.)" % (
2596
+ receipt["verified"], receipt["file_citations"], receipt["assumed"], a.out, receipt["bytes"] // 1024,
2597
+ receipt["nodes"], item, receipt["edges"], link))
2598
+ if receipt["findings"]:
2599
+ print("Computed from the spec, and shown at the top of the page (use these numbers as they are; don't re-add durations):\n"
2600
+ + "\n".join("- " + f for f in receipt["findings"]))
2601
+ return 1 if issues.errors else 0
2602
+
2603
+
2604
+ if __name__ == "__main__":
2605
+ sys.exit(main())