skilldrop-cli 0.16.22 → 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 (30) hide show
  1. package/package.json +1 -1
  2. package/packs/solution-architect/skills/interactive-diagram/SKILL.md +37 -9
  3. package/packs/solution-architect/skills/interactive-diagram/evals/evals.json +90 -0
  4. package/packs/solution-architect/skills/interactive-diagram/evals/files/10/build-4127-timing.json +179 -0
  5. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AbstractSensor.java +36 -0
  6. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AlertService.java +15 -0
  7. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Greenhouse.java +36 -0
  8. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Sensor.java +10 -0
  9. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/SoilMoistureSensor.java +25 -0
  10. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/TemperatureSensor.java +19 -0
  11. package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Zone.java +26 -0
  12. package/packs/solution-architect/skills/interactive-diagram/evals/files/6/notes.txt +7 -0
  13. package/packs/solution-architect/skills/interactive-diagram/evals/files/6/trace-quote-slow.jsonl +13 -0
  14. package/packs/solution-architect/skills/interactive-diagram/evals/files/7/oncall-notes.md +16 -0
  15. package/packs/solution-architect/skills/interactive-diagram/evals/files/7/system-log.txt +14 -0
  16. package/packs/solution-architect/skills/interactive-diagram/evals/files/8/run-4471.json +151 -0
  17. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/chat-export.txt +16 -0
  18. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/lesson-sync.log +24 -0
  19. package/packs/solution-architect/skills/interactive-diagram/evals/files/9/ticket-8812.csv +6 -0
  20. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.html +25 -8
  21. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.html +203 -0
  22. package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.json +110 -0
  23. package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.html +203 -0
  24. package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.json +21 -0
  25. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/incident/oncall-notes.md +14 -0
  26. package/packs/solution-architect/skills/interactive-diagram/examples/inputs/trace/checkout-trace.jsonl +9 -0
  27. package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.html +25 -8
  28. package/packs/solution-architect/skills/interactive-diagram/manifest.json +2 -2
  29. package/packs/solution-architect/skills/interactive-diagram/reference.md +86 -6
  30. package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +1025 -102
@@ -19,14 +19,16 @@ Standard library only, Python 3.9+. Exit 0 when the HTML was written (warnings a
19
19
  """
20
20
  import argparse
21
21
  import base64
22
+ import datetime
22
23
  import hashlib
23
24
  import html
24
25
  import json
26
+ import math
25
27
  import os
26
28
  import re
27
29
  import sys
28
30
 
29
- TYPES = ("architecture", "workflow", "dataflow", "lifecycle", "erd", "sequence")
31
+ TYPES = ("architecture", "workflow", "dataflow", "lifecycle", "erd", "sequence", "tree", "timeline", "waterfall", "class")
30
32
 
31
33
  # kind -> (shape, colour family). Unknown kinds fall back to a plain box, with a warning.
32
34
  KINDS = {
@@ -43,9 +45,28 @@ KINDS = {
43
45
  "state": ("round", "blue"), "final": ("final", "slate"), "initial": ("initial", "slate"),
44
46
  "table": ("table", "green"), "entity": ("table", "green"), "view": ("table", "teal"),
45
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"),
46
54
  }
47
- DEFAULT_KIND = {"architecture": "service", "workflow": "step", "dataflow": "service",
48
- "lifecycle": "state", "erd": "table", "sequence": "service"}
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"}
49
70
  EDGE_STYLES = ("solid", "dashed", "dotted")
50
71
  STATUSES = ("added", "removed", "changed")
51
72
  SECRET_PATH = re.compile(r"(^|/)(\.env(\..*)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials(\..*)?|"
@@ -143,20 +164,31 @@ def normalise(spec, issues):
143
164
  issues.error("spec/title", "title", "the diagram has no title", "Add a title naming what the map shows.")
144
165
  spec.setdefault("question", "")
145
166
  spec.setdefault("summary", "")
146
- spec["direction"] = "TB" if str(spec.get("direction", "LR")).upper() == "TB" and t not in ("erd", "sequence") else "LR"
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"
147
169
  if spec.get("source_url") and not re.match(r"^https?://[^\s\"'<>]+$", str(spec["source_url"])):
148
170
  issues.warn("spec/source-url", "source_url", "source_url is not an http(s) address; citations will not be links",
149
171
  "Use the repository's https address, e.g. https://github.com/org/repo/blob/<commit>/")
150
172
  spec["groups"] = [dict(g) for g in as_list(spec.get("groups"))]
151
173
  spec["notes"] = as_list(spec.get("notes"))
152
- key = "participants" if t == "sequence" else "nodes"
174
+ key = {"sequence": "participants", "timeline": "events", "waterfall": "spans", "class": "classes"}.get(t, "nodes")
153
175
  nodes = [dict(n) for n in as_list(spec.get(key) or spec.get("nodes"))]
154
176
  if not nodes:
155
177
  issues.error("spec/empty", key, "there is nothing to draw", "List the %s of the diagram." % key)
156
178
  spec["nodes"] = nodes
157
- spec["edges"] = [dict(e) for e in as_list(spec.get("messages" if t == "sequence" else "edges"))]
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"] = []
158
184
  spec["frames"] = [dict(f) for f in as_list(spec.get("frames"))]
159
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)
160
192
 
161
193
  ids = set()
162
194
  for g in spec["groups"]:
@@ -201,13 +233,14 @@ def normalise(spec, issues):
201
233
  if n.get("group") and n["group"] not in gids:
202
234
  issues.error("node/group", nid, "group %r does not exist" % n["group"], "Add the group or drop the field.")
203
235
  n["group"] = None
204
- n["fields"] = [f if isinstance(f, dict) else {"name": str(f)} for f in as_list(n.get("fields"))]
236
+ n["fields"] = [f if isinstance(f, dict) else {"name": str(f)} for f in as_list(n.get("fields") or n.get("members"))]
205
237
  if len(n["label"]) > 48:
206
238
  issues.warn("node/label-long", nid, "label is %d characters; it will be cut on the map" % len(n["label"]),
207
239
  "Keep labels to a few words and put the rest in note or detail.")
208
240
  _evidence(n, nid, issues)
209
241
 
210
242
  nid_set = {n["id"] for n in nodes}
243
+ _check_kind(spec, t, nodes, nid_set, issues)
211
244
  for i, e in enumerate(spec["edges"]):
212
245
  where = e.get("id") or "%s %d" % ("message" if t == "sequence" else "edge", i + 1)
213
246
  if not e.get("id"):
@@ -222,6 +255,17 @@ def normalise(spec, issues):
222
255
  issues.error("edge/endpoint", where, "%s %r is not a %s" % (end, e.get(end), key[:-1]),
223
256
  "Use the id of an existing %s." % key[:-1])
224
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"
225
269
  e["style"] = e.get("style") if e.get("style") in EDGE_STYLES else ("dashed" if e.get("kind") in ("async", "return") else "solid")
226
270
  _evidence(e, where, issues)
227
271
  for i, s in enumerate(spec["story"]):
@@ -241,7 +285,7 @@ def normalise(spec, issues):
241
285
  if t != "sequence" and len(nodes) > 36:
242
286
  issues.warn("size/large", "nodes", "%d nodes is more than one map reads well" % len(nodes),
243
287
  "Split it by question, or group the detail into fewer boxes.")
244
- if t not in ("sequence", "erd"):
288
+ if t not in ("sequence", "erd", "tree", "timeline", "waterfall", "class"):
245
289
  linked = {e.get("from") for e in spec["edges"]} | {e.get("to") for e in spec["edges"]}
246
290
  for n in nodes:
247
291
  if n["id"] not in linked and len(nodes) > 1:
@@ -263,12 +307,78 @@ def _evidence(item, where, issues):
263
307
  'Cite where it comes from ("src/x.py:10", "user: ...") or set "assumed": true so it is drawn as an assumption.')
264
308
 
265
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
+
266
376
  def verify_evidence(spec, repo_root, issues):
267
377
  """Check every file citation against the repository: the file exists, the lines exist,
268
378
  and a quoted fragment appears on those lines. Attach the cited lines as a snippet, except
269
379
  from files that hold secrets, and with secret-looking lines masked."""
270
380
  cache, total = {}, 0
271
- items = [("node", n) for n in spec["nodes"]] + [("edge", e) for e in spec["edges"]]
381
+ items = [("node", n) for n in spec["nodes"]] + [("edge", e) for e in spec["edges"]] + [("answer", a) for a in spec["answers"]]
272
382
  root = os.path.realpath(repo_root) if repo_root else None
273
383
  for kind, item in items:
274
384
  for e in item["evidence"]:
@@ -404,6 +514,12 @@ def wrap(s, max_w, size=13.0, max_lines=2):
404
514
  return lines or [""]
405
515
 
406
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
+
407
523
  def size_node(n):
408
524
  shape = KINDS.get(n["kind"], ("rect", "slate"))[0]
409
525
  n["shape"] = shape
@@ -415,6 +531,15 @@ def size_node(n):
415
531
  n["lines"] = [n["label"]]
416
532
  n["h"] = 32 + 20 * len(rows) + (18 if len(n["fields"]) > 24 else 0) + (4 if rows else 6)
417
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
418
543
  if shape in ("initial", "final"):
419
544
  n["lines"] = wrap(n["label"], 120, 11) if n.get("label_shown", n["label"] != n["id"]) else []
420
545
  n["w"], n["h"] = 34, 34
@@ -595,61 +720,94 @@ def layout_graph(spec):
595
720
  return ax, by_id, layer, edges, back, bands, spans, cells, n_layers
596
721
 
597
722
 
598
- def place(spec, geo, gap_m, gap_c):
599
- """Coordinates from the ordering. gap_m[i] is the space before layer i (gap_m[n_layers]
600
- is after the last); route lanes run there. gap_c[i] is the corridor before band i
601
- (gap_c[n_bands] is after the last); long routes run there. Neither holds a node."""
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."""
602
735
  ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
603
736
  lr = ax.lr
604
737
  pad, head = 18, 26
605
- layer_m = [0] * n_layers
738
+ cols, rows = grid(n_layers, per_row)
739
+ nb = len(bands)
740
+ col_m = [0] * cols
606
741
  for n in spec["nodes"]:
607
- layer_m[layer[n["id"]]] = max(layer_m[layer[n["id"]]], ax.m_size(n))
742
+ k = layer[n["id"]] % cols
743
+ col_m[k] = max(col_m[k], ax.m_size(n))
608
744
  max_depth = max([d for (_, _, d) in spans.values()] or [0])
609
745
  m = 20 + (pad + (0 if lr else head)) * max_depth
610
- layer_start, gap_mid = [], []
611
- for l in range(n_layers):
612
- gap_mid.append(m + gap_m[l] / 2.0)
613
- m += gap_m[l]
614
- layer_start.append(m)
615
- m += layer_m[l]
616
- gap_mid.append(m + gap_m[n_layers] / 2.0)
617
- m += gap_m[n_layers] + (pad + (0 if lr else head)) * max_depth + 20
618
- opening = lambda bi: sorted(d for (s, e, d) in spans.values() if s == bi)
619
- closing = lambda bi: [d for (s, e, d) in spans.values() if e == bi]
620
- band_start, band_size, corridors, c = [], [], [], 20.0
621
- for bi in range(len(bands)):
622
- corridors.append(c + gap_c[bi] / 2.0)
623
- c += gap_c[bi] + len(opening(bi)) * (head + 6 if lr else pad)
624
- band_start.append(c)
625
- size = 0
626
- for l in range(n_layers):
627
- stack = cells.get((bi, l), [])
628
- size = max(size, sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0))
629
- band_size.append(size)
630
- c += size + pad * len(closing(bi))
631
- corridors.append(c + gap_c[len(bands)] / 2.0)
632
- c += gap_c[len(bands)] + 20
633
- for bi in range(len(bands)):
634
- for l in range(n_layers):
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]:
635
783
  stack = cells.get((bi, l), [])
636
784
  total = sum(ax.c_size(by_id[u]) for u in stack) + 26 * max(len(stack) - 1, 0)
637
- cc = band_start[bi] + (band_size[bi] - total) / 2.0
785
+ cc = start + (band_size[(r, bi)] - total) / 2.0
786
+ k = l % cols
638
787
  for u in stack:
639
788
  n = by_id[u]
640
- n["m"] = layer_start[l] + (layer_m[l] - ax.m_size(n)) / 2.0
789
+ n["m"] = col_start[k] + (col_m[k] - ax.m_size(n)) / 2.0
641
790
  n["c"] = cc
642
791
  cc += ax.c_size(n) + 26
643
792
  boxes = []
644
- for gid, (s, e, d) in spans.items():
645
- mem = [n for n in spec["nodes"] if band_of_group(n, gid, spec)]
646
- outer = max_depth - d + 1 # how many group borders sit inside this one, plus one
647
- m0 = min(n["m"] for n in mem) - pad * outer - (0 if lr else head * outer)
648
- m1 = max(n["m"] + ax.m_size(n) for n in mem) + pad * outer
649
- c0 = band_start[s] - sum(1 for x in opening(s) if x >= d) * (head + 6 if lr else pad)
650
- c1 = band_start[e] + band_size[e] + pad * sum(1 for x in closing(e) if x >= d)
651
- boxes.append({"id": gid, "m0": m0, "m1": m1, "c0": c0, "c1": c1, "depth": d})
652
- return {"groups": boxes, "corridors": corridors, "gaps": gap_mid, "extent": (m, c)}
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}
653
811
 
654
812
 
655
813
  def band_of_group(n, gid, spec):
@@ -666,6 +824,7 @@ def route(spec, geo, pl):
666
824
  """Orthogonal routes that never cross a node: lanes run in the gaps between layers and in
667
825
  the corridors between bands, which hold no nodes by construction."""
668
826
  ax, by_id, layer, edges, back, bands, spans, cells, n_layers = geo
827
+ cols = pl["cols"]
669
828
  lanes_v = {} # gap key -> list of (lo, hi, edge id)
670
829
  lanes_h = {}
671
830
  plans = []
@@ -683,14 +842,15 @@ def route(spec, geo, pl):
683
842
  if s["id"] == t["id"]:
684
843
  plans.append({"e": e, "kind": "self"})
685
844
  continue
686
- if lt == ls + 1:
687
- plans.append({"e": e, "kind": "adjacent", "gap": ("v", lt), "out": "end", "in": "start"})
688
- elif lt > ls + 1:
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:
689
849
  plans.append({"e": e, "kind": "multi", "out": "end", "in": "start"})
690
850
  elif lt == ls:
691
- plans.append({"e": e, "kind": "same", "gap": ("v", ls + 1), "out": "end", "in": "end"})
692
- else:
693
- plans.append({"e": e, "kind": "corridor", "g1": ("v", ls + 1), "g2": ("v", lt), "out": "end", "in": "start"})
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"})
694
854
  # ports: spread the edges that share a node side, ordered by the far end
695
855
  for p in plans:
696
856
  if p["kind"] == "self":
@@ -743,11 +903,11 @@ def route(spec, geo, pl):
743
903
  continue
744
904
  ls, lt = layer[p["e"]["from"]], layer[p["e"]["to"]]
745
905
  if clear(p["sp"][1], ls + 1, lt):
746
- p.update(kind="straight", gap=("v", lt))
906
+ p.update(kind="straight", gap=("v", lt % cols))
747
907
  elif clear(p["tp"][1], ls + 1, lt):
748
- p.update(kind="straight", gap=("v", ls + 1))
908
+ p.update(kind="straight", gap=("v", ls % cols + 1))
749
909
  else:
750
- p.update(kind="corridor", g1=("v", ls + 1), g2=("v", lt))
910
+ p.update(kind="corridor", g1=("v", ls % cols + 1), g2=("v", lt % cols))
751
911
  for p in plans:
752
912
  if p["kind"] == "self":
753
913
  continue
@@ -846,17 +1006,23 @@ def build_paths(spec, geo, pl, plans, lanes_v, lanes_h, need_v, need_h):
846
1006
  e["pts"] = [ax.xy(m, c) for m, c in clean]
847
1007
 
848
1008
 
849
- def layout_graph_full(spec):
850
- geo = layout_graph(spec)
851
- ax, n_layers, n_bands = geo[0], geo[8], len(geo[5])
852
- gap_m = [24] + [72] * (n_layers - 1) + [24]
853
- gap_c = [30] * (n_bands + 1)
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))]
854
1019
  for _ in range(4):
855
- pl = place(spec, geo, gap_m, gap_c)
1020
+ pl = place(spec, geo, gap_m, gap_c, per_row)
856
1021
  plans, lv, lh, need_v, need_h = route(spec, geo, pl)
857
- base = lambda l: 24 if l in (0, n_layers) else 72
858
- new_m = [max(base(l), 30 + 9 * need_v.get(("v", l), 0)) for l in range(n_layers + 1)]
859
- new_c = [max(30, 20 + 9 * need_h.get(i, 0)) for i in range(n_bands + 1)]
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))
860
1026
  if new_m == gap_m and new_c == gap_c:
861
1027
  break
862
1028
  gap_m, gap_c = new_m, new_c
@@ -882,7 +1048,28 @@ def layout_graph_full(spec):
882
1048
  g["y"] += dy
883
1049
  W = max(b[2] for b in boxes) + dx + 40
884
1050
  H = max(b[3] for b in boxes) + dy + 40
885
- return {"w": W, "h": H, "groups": pl["groups"]}
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
886
1073
 
887
1074
 
888
1075
  # --------------------------------------------------------------------------- sequence layout
@@ -960,6 +1147,514 @@ def layout_sequence(spec):
960
1147
  return {"w": W, "h": end_y + 40, "groups": boxes, "lifeline": (top + head_h - 30, end_y)}
961
1148
 
962
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
+
963
1658
  # --------------------------------------------------------------------------- SVG
964
1659
 
965
1660
  def esc(s):
@@ -1033,7 +1728,19 @@ def node_svg(n):
1033
1728
  out.append("<title>%s</title>" % (label + (" — " + esc(n.get("note")) if n.get("note") else "")))
1034
1729
  out.append(node_shape(n))
1035
1730
  x, y, w, h = n["x"], n["y"], n["w"], n["h"]
1036
- if n["shape"] == "table":
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":
1037
1744
  out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="28" rx="6"/>' % (x, y, w))
1038
1745
  out.append('<rect class="table-head" x="%.1f" y="%.1f" width="%.1f" height="8"/>' % (x, y + 20, w))
1039
1746
  out.append('<text class="label" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + w / 2, y + 19, label))
@@ -1132,10 +1839,22 @@ def edge_svg(e, seq=False):
1132
1839
  cls.append("s-dashed")
1133
1840
  title = esc(e["label"] or "%s → %s" % (e["from"], e["to"]))
1134
1841
  out = ['<g class="%s" id="e-%s" data-id="%s" data-from="%s" data-to="%s">' % (
1135
- " ".join(cls), esc(e["id"]), esc(e["id"]), esc(e["from"]), esc(e["to"]))]
1842
+ " ".join(cls + (["tree"] if e.get("tree") else [])), esc(e["id"]), esc(e["id"]), esc(e["from"]), esc(e["to"]))]
1136
1843
  out.append("<title>%s</title>" % title)
1137
1844
  d = rounded_path(e["pts"]) if not seq else "M" + " L".join("%.1f,%.1f" % p for p in e["pts"])
1138
- markers = ' marker-end="url(#arrow)"' + (' marker-start="url(#arrow-start)"' if e.get("both") else "")
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 "")
1139
1858
  out.append('<path class="hit" d="%s"/>' % d)
1140
1859
  out.append('<path class="line" d="%s"%s/>' % (d, markers))
1141
1860
  if seq and e.get("lines"):
@@ -1162,10 +1881,18 @@ def render_svg(spec, geo):
1162
1881
  seq = spec["type"] == "sequence"
1163
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">' % (
1164
1883
  W, H, W, H, esc(spec["title"]))]
1165
- out.append('<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
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">'
1166
1885
  '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker>'
1167
- '<marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
1168
- '<path d="M0,0 L10,5 L0,10 z" class="arrowhead"/></marker></defs>')
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>')
1169
1896
  out.append('<rect class="canvas" x="0" y="0" width="%d" height="%d"/>' % (W, H))
1170
1897
  labels = {g["id"]: g for g in spec["groups"]}
1171
1898
  for g in sorted(geo["groups"], key=lambda g: g["depth"]):
@@ -1173,7 +1900,8 @@ def render_svg(spec, geo):
1173
1900
  cls = "group d%d" % min(g["depth"], 3) + (" st-" + meta["status"] if meta.get("status") else "")
1174
1901
  out.append('<g class="%s" data-group="%s"><rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="10"/>'
1175
1902
  '<text class="glabel" x="%.1f" y="%.1f">%s</text></g>' % (
1176
- cls, esc(g["id"]), g["x"], g["y"], g["w"], g["h"], g["x"] + 12, g["y"] + 18, esc(meta.get("label", g["id"]))))
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 ""))))
1177
1905
  if seq:
1178
1906
  y0, y1 = geo["lifeline"]
1179
1907
  for f in spec["frames"]:
@@ -1189,11 +1917,16 @@ def render_svg(spec, geo):
1189
1917
  for i, m in enumerate(spec["edges"]):
1190
1918
  if m.get("pts"):
1191
1919
  out.append('<text class="seqno" x="%.1f" y="%.1f">%d</text>' % (14, m["pts"][0][1] + 4, i + 1))
1192
- for e in spec["edges"]:
1193
- if e.get("pts"):
1194
- out.append(edge_svg(e, seq))
1195
- for n in spec["nodes"]:
1196
- out.append(node_svg(n))
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))
1197
1930
  out.append("</svg>")
1198
1931
  return "".join(out)
1199
1932
 
@@ -1212,6 +1945,57 @@ def to_mermaid(spec):
1212
1945
  t = spec["type"]
1213
1946
  nodes = [n for n in spec["nodes"] if n.get("status") != "removed"]
1214
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"
1215
1999
  if t == "sequence":
1216
2000
  out = ["sequenceDiagram"]
1217
2001
  for p in nodes:
@@ -1301,6 +2085,39 @@ def to_mermaid(spec):
1301
2085
 
1302
2086
  # --------------------------------------------------------------------------- text outline
1303
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
+
1304
2121
  def outline_html(spec):
1305
2122
  """The map as text: what a screen reader, a search engine, a judge or a reader without
1306
2123
  JavaScript gets, and what can be pasted into a doc."""
@@ -1325,7 +2142,13 @@ def outline_html(spec):
1325
2142
  mark = " ✓" if e.get("verified") else ""
1326
2143
  parts.append("<code>%s</code>%s" % (esc(ev_text(e)), mark))
1327
2144
  return ' <span class="ev">(%s)</span>' % ", ".join(parts)
1328
- noun = "Participants" if t == "sequence" else ("Tables" if t == "erd" else ("States" if t == "lifecycle" else "Parts"))
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")
1329
2152
  out.append("<h2>%s</h2><ul>" % noun)
1330
2153
  for n in spec["nodes"]:
1331
2154
  g = groups.get(n.get("group"))
@@ -1386,6 +2209,11 @@ section.text{max-width:1100px;padding:10px 24px 40px}section.text h2{font-size:1
1386
2209
  .legend{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--muted);padding:6px 24px 0}
1387
2210
  .legend i{display:inline-block;width:22px;height:0;border-top:2px solid var(--edge);vertical-align:middle;margin-right:5px}
1388
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)}
1389
2217
  .issues{margin:8px 24px 0;padding:8px 12px;border:1px solid var(--warn);border-radius:6px;color:var(--warn);font-size:13px}
1390
2218
  /* diagram */
1391
2219
  .canvas{fill:var(--canvas)}
@@ -1415,12 +2243,23 @@ svg.focus .on,svg.focus .group.on{opacity:1}svg .node.on .shape{stroke-width:2.6
1415
2243
  svg .node.sel .shape{stroke:var(--hl)!important;stroke-width:3}svg .node:focus-visible .shape{stroke:var(--accent)!important;stroke-width:3}
1416
2244
  @media (prefers-reduced-motion:no-preference){svg .edge.on.flow .line{stroke-dasharray:8 6;animation:flow 1s linear infinite}}
1417
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}
1418
2257
  @media print{.bar,aside,.caption{display:none!important}.wrap{height:auto}#stage{border:0}#stage svg{position:static;height:auto}}
1419
2258
  """
1420
2259
 
1421
2260
  JS = r"""
1422
2261
  (function(){
1423
- var DATA=JSON.parse(document.getElementById('diagram-data').textContent);
2262
+ var DATA=JSON.parse(document.getElementById('diagram-data').textContent);var WD=DATA.words;
1424
2263
  var svg=document.getElementById('map'),stage=document.getElementById('stage'),panel=document.getElementById('panel');
1425
2264
  var root=document.documentElement;
1426
2265
  var pref=localStorage.getItem('idg-theme')||root.getAttribute('data-theme-default');
@@ -1464,11 +2303,12 @@ function show(kind,id){var x=kind==='n'?nodes.get(id):edges.get(id);if(!x)return
1464
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>';
1465
2304
  if(x.note)h+='<p>'+esc(x.note)+'</p>';if(x.detail)h+='<p>'+esc(x.detail)+'</p>';
1466
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>';
1467
- h+='<div class="acts"><button data-act="up">What it depends on</button><button data-act="down">What depends on it</button><button data-act="near">Neighbours</button></div>';
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>';
1468
2308
  h+='<p class="muted" style="font-size:12px">Shift-click another part to see the path between them.</p>';
1469
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('');
1470
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('');
1471
- if(o)h+='<b>Goes to</b><ul>'+o+'</ul>';if(n)h+='<b>Comes from</b><ul>'+n+'</ul>';
2311
+ if(o)h+='<b>'+esc(WD.out)+'</b><ul>'+o+'</ul>';if(n)h+='<b>'+esc(WD.in)+'</b><ul>'+n+'</ul>';
1472
2312
  h+='<b>Evidence</b>'+evHtml(x.evidence);
1473
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');
1474
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>';
@@ -1477,7 +2317,7 @@ function show(kind,id){var x=kind==='n'?nodes.get(id):edges.get(id);if(!x)return
1477
2317
  panel.querySelector('.close').onclick=function(){panel.classList.remove('open');clear();selected=null};
1478
2318
  panel.querySelectorAll('.jump').forEach(function(a){a.onclick=function(){show('n',a.getAttribute('data-n'))}});
1479
2319
  panel.querySelectorAll('[data-act]').forEach(function(b){b.onclick=function(){var a=b.getAttribute('data-act');
1480
- if(a==='near'){show('n',id);return}var r=reach(id,a==='down'?1:-1);light(r.n,r.e,true);el('n',id).classList.add('sel')}})}
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')}})}
1481
2321
  svg.addEventListener('click',function(e){var g=e.target.closest('.node');if(g){var id=g.getAttribute('data-id');
1482
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}
1483
2323
  var ed=e.target.closest('.edge');if(ed){show('e',ed.getAttribute('data-id'));return}});
@@ -1500,7 +2340,7 @@ document.getElementById('xpng').onclick=function(){var img=new Image();img.onloa
1500
2340
  img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(standalone())};
1501
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)};
1502
2342
  var tour=null,cap=document.getElementById('caption');
1503
- var steps=DATA.story&&DATA.story.length?DATA.story:(DATA.type==='sequence'?DATA.edges.map(function(m,i){return {text:(i+1)+'. '+nodes.get(m.from).label+' → '+nodes.get(m.to).label+(m.label?': '+m.label:''),edges:[m.id],nodes:[m.from,m.to]}}):[]);
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]}}):[]);
1504
2344
  var tb=document.getElementById('tour');if(!steps.length){tb.style.display='none'}
1505
2345
  function step(d){tour.i=Math.max(0,Math.min(steps.length-1,tour.i+d));var s=steps[tour.i];
1506
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);
@@ -1522,20 +2362,59 @@ hash();window.addEventListener('hashchange',hash);
1522
2362
  """
1523
2363
 
1524
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
+
1525
2403
  def build_html(spec, svg, mermaid, issues, theme):
1526
2404
  t = spec["type"]
1527
2405
  by_group = {g["id"]: g["label"] for g in spec["groups"]}
1528
2406
  data = {"type": t, "slug": re.sub(r"[^a-z0-9]+", "-", spec["title"].lower()).strip("-")[:60] or "diagram",
1529
2407
  "source_url": spec.get("source_url") if re.match(r"^https?://[^\s\"'<>]+$", str(spec.get("source_url") or "")) else None,
1530
- "story": spec["story"], "nodes": [], "edges": [], "mermaid": mermaid}
2408
+ "story": spec["story"], "nodes": [], "edges": [], "mermaid": mermaid,
2409
+ "words": WORDS.get(t, WORDS["default"]), "auto_steps": auto_steps(spec)}
1531
2410
  for n in spec["nodes"]:
1532
- d = {k: n.get(k) for k in ("id", "label", "kind", "note", "detail", "fields", "evidence", "assumed", "status")}
2411
+ d = {k: n.get(k) for k in ("id", "label", "kind", "note", "detail", "fields", "evidence", "assumed", "status", "facts")}
1533
2412
  d["group_label"] = by_group.get(n.get("group"))
1534
2413
  data["nodes"].append(d)
1535
2414
  for e in spec["edges"]:
1536
2415
  data["edges"].append({k: e.get(k) for k in ("id", "from", "to", "label", "detail", "evidence", "assumed", "status")})
1537
2416
  payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
1538
- assumed_n = sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"])
2417
+ assumed_n = sum(1 for x in spec["nodes"] + spec["edges"] if x["assumed"] and not x.get("tree"))
1539
2418
  file_ev = [e for x in spec["nodes"] + spec["edges"] for e in x["evidence"] if "file" in e]
1540
2419
  checked = sum(1 for e in file_ev if e.get("verified"))
1541
2420
  facts = []
@@ -1550,16 +2429,34 @@ def build_html(spec, svg, mermaid, issues, theme):
1550
2429
  if x.get("status"):
1551
2430
  cnt[x["status"]] = cnt.get(x["status"], 0) + 1
1552
2431
  facts.append(", ".join("%d %s" % (v, k) for k, v in sorted(cnt.items())) or "no changes")
1553
- legend = ['<span><i></i>connection</span>', '<span><i class="d"></i>async / optional</span>']
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>'])
1554
2442
  if assumed_n:
1555
2443
  legend.append('<span><i class="a"></i>assumption (not shown by the evidence)</span>')
1556
2444
  if spec.get("compared"):
1557
2445
  legend.append('<span style="color:var(--ok)">■ added</span><span style="color:var(--bad)">■ removed</span>'
1558
2446
  '<span style="color:var(--accent)">■ changed</span>')
1559
2447
  notes = "".join("<li>%s</li>" % esc(x) for x in spec["notes"])
1560
- warn_html = ""
1561
- if issues.warnings:
1562
- warn_html = '<div class="issues"><b>Check before sharing:</b> ' + "; ".join(esc(w["message"] + " (" + w["where"] + ")") for w in issues.warnings[:6]) + "</div>"
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>")
1563
2460
  return """<!doctype html>
1564
2461
  <html lang="en" data-theme-default="%(theme)s"><head><meta charset="utf-8">
1565
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'">
@@ -1567,7 +2464,7 @@ def build_html(spec, svg, mermaid, issues, theme):
1567
2464
  <!-- One self-contained file: no external scripts, styles, fonts or requests. The policy above makes the browser refuse any. -->
1568
2465
  <title>%(title)s</title><meta name="generator" content="interactive-diagram (skilldrop)"></head>
1569
2466
  <body>
1570
- <header class="top"><h1>%(title)s</h1>%(question)s%(summary)s<p class="muted" style="font-size:12px;margin:6px 0 0">%(facts)s</p></header>
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>
1571
2468
  <section class="text" id="outline" aria-label="The diagram as text">
1572
2469
  <details%(open)s><summary>The diagram as text</summary>%(outline)s%(notes)s</details></section>
1573
2470
  <nav class="bar" aria-label="Diagram controls">
@@ -1589,7 +2486,7 @@ def build_html(spec, svg, mermaid, issues, theme):
1589
2486
  "summary": '<p class="sum">%s</p>' % esc(spec["summary"]) if spec["summary"] else "",
1590
2487
  "facts": esc(" · ".join(facts)), "open": "" if len(spec["nodes"]) > 12 else " open",
1591
2488
  "outline": outline_html(spec), "notes": "<h2>Notes</h2><ul>%s</ul>" % notes if notes else "",
1592
- "legend": "".join(legend), "warn": warn_html, "svg": svg, "css": CSS,
2489
+ "legend": "".join(legend), "warn": warn_html, "up_front": up_front, "svg": svg, "css": CSS,
1593
2490
  "data": payload, "js": JS,
1594
2491
  "js_hash": "sha256-" + base64.b64encode(hashlib.sha256(JS.encode("utf-8")).digest()).decode()}
1595
2492
 
@@ -1638,14 +2535,31 @@ def main(argv=None):
1638
2535
  "file_citations": len(file_ev), "verified": sum(1 for e in file_ev if e.get("verified")),
1639
2536
  "errors": issues.errors, "warnings": issues.warnings}
1640
2537
  if not issues.errors and not a.check:
1641
- if spec["type"] == "sequence":
2538
+ t = spec["type"]
2539
+ if t == "sequence":
1642
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)
1643
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"]
1644
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", [])))
1645
2555
  occupied = [(n["x"], n["y"], n["x"] + n["w"], n["y"] + n["h"]) for n in spec["nodes"]]
1646
2556
  labels = {g["id"]: g["label"] for g in spec["groups"]}
1647
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"]]
1648
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)
1649
2563
  svg = render_svg(spec, geo)
1650
2564
  mermaid = to_mermaid(spec)
1651
2565
  out = build_html(spec, svg, mermaid, issues, a.theme)
@@ -1654,6 +2568,10 @@ def main(argv=None):
1654
2568
  fh.write(out)
1655
2569
  receipt["output"] = os.path.abspath(a.out)
1656
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", [])
1657
2575
  if a.mermaid:
1658
2576
  with open(a.mermaid, "w", encoding="utf-8") as fh:
1659
2577
  fh.write(mermaid)
@@ -1671,10 +2589,15 @@ def main(argv=None):
1671
2589
  receipt["nodes"], "participants" if spec["type"] == "sequence" else "nodes", receipt["edges"],
1672
2590
  "messages" if spec["type"] == "sequence" else "edges", receipt["assumed"], receipt["verified"], receipt["file_citations"]))
1673
2591
  else:
1674
- print("wrote %s (%d KB): %d %s, %d %s, %d assumed, %d of %d file citations verified" % (
1675
- a.out, receipt["bytes"] // 1024, receipt["nodes"], "participants" if spec["type"] == "sequence" else "nodes",
1676
- receipt["edges"], "messages" if spec["type"] == "sequence" else "edges", receipt["assumed"],
1677
- receipt["verified"], receipt["file_citations"]))
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"]))
1678
2601
  return 1 if issues.errors else 0
1679
2602
 
1680
2603