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.
- package/package.json +1 -1
- package/packs/solution-architect/skills/interactive-diagram/SKILL.md +37 -9
- package/packs/solution-architect/skills/interactive-diagram/evals/evals.json +90 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/10/build-4127-timing.json +179 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AbstractSensor.java +36 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/AlertService.java +15 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Greenhouse.java +36 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Sensor.java +10 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/SoilMoistureSensor.java +25 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/TemperatureSensor.java +19 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/11/src/org/example/greenhouse/Zone.java +26 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/6/notes.txt +7 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/6/trace-quote-slow.jsonl +13 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/7/oncall-notes.md +16 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/7/system-log.txt +14 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/8/run-4471.json +151 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/chat-export.txt +16 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/lesson-sync.log +24 -0
- package/packs/solution-architect/skills/interactive-diagram/evals/files/9/ticket-8812.csv +6 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-map.html +25 -8
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/checkout-trace.json +110 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.html +203 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/incident-timeline.json +21 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/incident/oncall-notes.md +14 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/inputs/trace/checkout-trace.jsonl +9 -0
- package/packs/solution-architect/skills/interactive-diagram/examples/leave-approval.html +25 -8
- package/packs/solution-architect/skills/interactive-diagram/manifest.json +2 -2
- package/packs/solution-architect/skills/interactive-diagram/reference.md +86 -6
- 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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
599
|
-
"""
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
for
|
|
612
|
-
gap_mid.append(m + gap_m[
|
|
613
|
-
m += gap_m[
|
|
614
|
-
|
|
615
|
-
m +=
|
|
616
|
-
gap_mid.append(m + gap_m[
|
|
617
|
-
m += gap_m[
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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 =
|
|
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"] =
|
|
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, (
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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"] == "
|
|
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
|
-
|
|
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="
|
|
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="
|
|
1168
|
-
'<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>')
|
|
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,
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
|
|
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+='<
|
|
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>
|
|
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==='
|
|
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 =
|
|
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
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
|