system-diff 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,550 @@
1
+ """Pictures drawn from data, in one look: white boxes, a column or a stage per role, curves by kind of connection.
2
+
3
+ system-diff draw SPEC.json... # every spec with a "picture" key -> SPEC.svg beside it
4
+
5
+ Three kinds:
6
+
7
+ map boxes in columns and curves between them. The system map and the system diff are maps; a spec can draw
8
+ one by hand ("where state lives").
9
+ stages a process left to right, with its loops back, its exits, and where a person comes in.
10
+ sequence who talks to whom, numbered, in order.
11
+
12
+ Layouts are deterministic and keyed by id, so a part that did not change never moves between versions.
13
+ Standard library only.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import html
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Dict, Iterable, List, Optional, Tuple
21
+
22
+ INK, INK2, MUTED, LINE = "#161c24", "#3b4655", "#667385", "#e1e5eb"
23
+ SURFACE, SURFACE2 = "#ffffff", "#f8f9fb"
24
+ OK, WARN, WARN_BG, ATTN, ATTN_BG, WORK, WORK_BG = "#157347", "#9a5600", "#fff3dd", "#c4321c", "#fdecea", "#1f5fbf", "#e8f0fc"
25
+ SANS = "'Segoe UI', FreeSans, 'Helvetica Neue', Arial, sans-serif"
26
+ MONO = "Consolas, Inconsolata, 'DejaVu Sans Mono', monospace"
27
+
28
+ CSS = f"""
29
+ text {{ font-family: {SANS}; }}
30
+ .bg {{ fill: {SURFACE2}; stroke: {LINE}; }}
31
+ .col {{ font-size: 11px; font-weight: 600; letter-spacing: .05em; fill: {MUTED}; }}
32
+ .bn .box {{ fill: {SURFACE}; stroke: {LINE}; stroke-width: 1.2; }}
33
+ .bn.t-store .box {{ fill: {WORK_BG}; }}
34
+ .bn.legacy .box {{ fill: {SURFACE2}; stroke: #c3cad4; stroke-dasharray: 5 4; }}
35
+ .bn.legacy .bn-t {{ fill: {INK2}; }}
36
+ .bn .band {{ fill: {WORK}; }}
37
+ .bn.undeclared .band {{ fill: {WARN}; }}
38
+ .bn.s-added .box {{ stroke: {OK}; stroke-width: 1.8; }} .bn.s-added .band {{ fill: {OK}; }}
39
+ .bn.s-changed .box {{ stroke: {WARN}; stroke-width: 1.8; }} .bn.s-changed .band {{ fill: {WARN}; }}
40
+ .bn.s-removed .box {{ stroke: {ATTN}; stroke-dasharray: 5 4; fill: {ATTN_BG}; }} .bn.s-removed .band {{ fill: {ATTN}; }}
41
+ .bn-t {{ font-size: 13px; font-weight: 600; fill: {INK}; }}
42
+ .bn-s {{ font-size: 11.5px; fill: {MUTED}; }}
43
+ .bn-p {{ font-size: 11.5px; fill: {INK2}; }} .bn-p.code {{ font-family: {MONO}; }} .bn-p.more {{ fill: {MUTED}; }}
44
+ .bn-p.s-added {{ fill: {OK}; font-weight: 600; }} .bn-p.s-changed {{ fill: {WARN}; font-weight: 600; }}
45
+ .bn-p.s-removed {{ fill: {ATTN}; text-decoration: line-through; }}
46
+ .be {{ fill: none; stroke: {MUTED}; stroke-width: 1.4; opacity: .75; }}
47
+ .be.k-data {{ stroke: {WORK}; }} .be.k-network {{ stroke: {WARN}; stroke-dasharray: 6 4; }}
48
+ .be.s-added {{ stroke: {OK}; stroke-width: 2.2; opacity: 1; }}
49
+ .be.s-removed {{ stroke: {ATTN}; stroke-dasharray: 5 4; opacity: 1; }}
50
+ .ar {{ fill: {MUTED}; }} .ar.k-data {{ fill: {WORK}; }} .ar.k-network {{ fill: {WARN}; }}
51
+ .ar.s-added {{ fill: {OK}; }} .ar.s-removed {{ fill: {ATTN}; }} .ar.k-person {{ fill: {WORK}; }}
52
+ .halo {{ paint-order: stroke; stroke: {SURFACE2}; stroke-width: 4px; stroke-linejoin: round; }}
53
+ .lg {{ font-size: 11.5px; fill: {MUTED}; }}
54
+ .lg.s-added {{ fill: {OK}; font-weight: 600; }} .lg.s-changed {{ fill: {WARN}; font-weight: 600; }}
55
+ .lg.s-removed {{ fill: {ATTN}; font-weight: 600; }}
56
+ .foot {{ font-size: 11.5px; fill: {INK2}; }}
57
+ .st .box {{ fill: {SURFACE}; stroke: {LINE}; stroke-width: 1.4; }}
58
+ .st.hi .box {{ fill: {WORK_BG}; stroke: {WORK}; }}
59
+ .st-w {{ font-size: 13px; font-weight: 600; fill: {INK}; }}
60
+ .fl {{ fill: none; stroke: {MUTED}; stroke-width: 1.4; }} .fl.loop {{ stroke-dasharray: 4 4; }}
61
+ .fl.gate {{ stroke: {WORK}; stroke-dasharray: 2 4; opacity: .7; }}
62
+ .fl-note {{ font-size: 11.5px; fill: {MUTED}; }}
63
+ .gate .box {{ fill: {WORK_BG}; stroke: {WORK}; }} .gate text {{ font-size: 12.5px; font-weight: 600; fill: {WORK}; }}
64
+ .exit {{ font-size: 12.5px; font-weight: 600; fill: {INK}; }}
65
+ .lane .box {{ fill: {SURFACE}; stroke: {LINE}; stroke-width: 1.2; }}
66
+ .lane.person .box {{ fill: {WORK_BG}; stroke: {WORK}; }}
67
+ .life {{ stroke: #c9d0da; stroke-width: 1.2; stroke-dasharray: 3 4; }}
68
+ .msg {{ fill: none; stroke: {MUTED}; stroke-width: 1.4; }}
69
+ .msg.frame {{ stroke: {WORK}; stroke-dasharray: 5 4; }} .msg.person {{ stroke: {WORK}; stroke-width: 2; }}
70
+ .msg-t {{ font-size: 11.5px; fill: {INK2}; }} .msg-t.frame {{ fill: {WORK}; }} .msg-t.person {{ fill: {WORK}; font-weight: 600; }}
71
+ .num circle {{ fill: {MUTED}; }} .num.person circle {{ fill: {WORK}; }}
72
+ .num text {{ fill: #ffffff; font-size: 10px; font-weight: 700; }}
73
+ .note .box {{ fill: {WARN_BG}; stroke: #e9c98f; }} .note text {{ font-size: 11.5px; fill: {WARN}; }}
74
+ """
75
+
76
+ OP = {"added": "+", "changed": "~", "removed": "−", "same": ""}
77
+
78
+
79
+ def esc(text) -> str:
80
+ return html.escape(str(text), quote=True)
81
+
82
+
83
+ def cut(text, n: int) -> str:
84
+ text = str(text)
85
+ return text if len(text) <= n else text[: n - 1] + "…"
86
+
87
+
88
+ def cut_code(text, n: int) -> str:
89
+ """Like cut, but `METHOD /a/long/path` keeps its method and the end of its path, where routes differ."""
90
+ text = str(text)
91
+ head, _, path = text.partition(" ")
92
+ if len(text) <= n or not path.startswith("/") or " " in path:
93
+ return cut(text, n)
94
+ for i, ch in enumerate(path):
95
+ if ch == "/" and i and len(head) + 2 + len(path) - i <= n:
96
+ return f"{head} …{path[i:]}"
97
+ return cut(text, n)
98
+
99
+
100
+ def text_width(text: str, size: float = 11.5) -> float:
101
+ """Roughly how wide a line of sans text is; only for spacing, never for clipping."""
102
+ return len(text) * size * 0.54
103
+
104
+
105
+ def wrap(text: str, n: int, max_lines: int = 3) -> List[str]:
106
+ """Greedy word wrap to `n` characters; a line breaks before a ` · ` rather than after it."""
107
+ lines: List[str] = []
108
+ line = ""
109
+ for word in str(text).split(" "):
110
+ cand = f"{line} {word}".strip()
111
+ if len(cand) > n and line:
112
+ lines.append(line.rstrip(" ·"))
113
+ line = word if word != "·" else ""
114
+ else:
115
+ line = cand
116
+ if line:
117
+ lines.append(line)
118
+ if len(lines) > max_lines:
119
+ lines = lines[: max_lines - 1] + [cut(" ".join(lines[max_lines - 1:]), n)]
120
+ return lines
121
+
122
+
123
+ def _svg(width: float, height: float, body: Iterable[str], label: str, defs: Iterable[str] = ()) -> str:
124
+ w, h = int(round(width)), int(round(height))
125
+ return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" '
126
+ f'aria-label="{esc(label)}">\n<style>{CSS}</style>\n<defs>{"".join(defs)}</defs>\n'
127
+ f'<rect class="bg" x="0.5" y="0.5" width="{w - 1}" height="{h - 1}" rx="10"/>\n'
128
+ + "\n".join(body) + "\n</svg>\n")
129
+
130
+
131
+ def _marker(mid: str, cls: str) -> str:
132
+ return (f'<marker id="{mid}" class="ar {cls}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
133
+ f'markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z"/></marker>')
134
+
135
+
136
+ def _f(x: float) -> str:
137
+ return f"{x:.1f}".rstrip("0").rstrip(".")
138
+
139
+
140
+ # ── map: boxes in columns ─────────────────────────────────────────────────────
141
+ #
142
+ # graph = {"columns": [[key, heading], ...],
143
+ # "nodes": [{"id", "col", "title", "sub", "type": component|store|host, "style": new|legacy|shared|undeclared,
144
+ # "status": same|added|changed|removed, "lines": [{"text", "code", "status"}], "more": n}],
145
+ # "edges": [{"from", "to", "kind": call|data|network, "status"}],
146
+ # "legend": [...] | None, "footer": [...]}
147
+ # Nodes are drawn in the order given within their column.
148
+
149
+ W, GX, PAD, TOP = 200, 64, 14, 30
150
+ MAX_LINES = {"component": 4, "store": 3, "host": 0}
151
+ LEGEND_H = 30
152
+
153
+
154
+ def _title_lines(title: str) -> List[str]:
155
+ return wrap(title, 24, max_lines=2)
156
+
157
+
158
+ def node_height(n: dict) -> int:
159
+ extra = 16 * (len(_title_lines(n["title"])) - 1)
160
+ if n.get("type") == "host":
161
+ return 40 + extra
162
+ k = min(len(n.get("lines") or []), MAX_LINES.get(n.get("type", "component"), 4))
163
+ more = len(n.get("lines") or []) > k or n.get("more")
164
+ return 46 + k * 15 + (15 if more else 0) + extra
165
+
166
+
167
+ def map_layout(columns: List[List[str]], nodes: List[dict]) -> Tuple[Dict[str, tuple], List[tuple], int, int]:
168
+ """id -> (x, y, height, column index); the headings drawn; width; height. Empty columns take no room."""
169
+ pos: Dict[str, tuple] = {}
170
+ heads: List[tuple] = []
171
+ x, bottom = PAD, 0
172
+ for key, heading in columns:
173
+ col = [n for n in nodes if n["col"] == key]
174
+ if not col:
175
+ continue
176
+ y = TOP
177
+ for n in col:
178
+ h = node_height(n)
179
+ pos[n["id"]] = (x, y, h, len(heads))
180
+ y += h + 14
181
+ heads.append((heading, x))
182
+ bottom = max(bottom, y)
183
+ x += W + GX
184
+ return pos, heads, max(x - GX + PAD, 320), bottom + PAD
185
+
186
+
187
+ def _s(x1: float, y1: float, x2: float, y2: float) -> str:
188
+ m = (x2 - x1) / 2
189
+ return "C{},{} {},{} {},{}".format(*map(_f, (x1 + m, y1, x2 - m, y2, x2, y2)))
190
+
191
+
192
+ def _free_y(boxes: List[tuple], want: float) -> float:
193
+ """Where a connection can cross a column at about `want`: there if no box is in the way, else the nearest gap
194
+ between two boxes, or below the last one."""
195
+ if not any(y - 5 < want < y + h + 5 for y, h in boxes):
196
+ return want
197
+ gaps = [(y1 + h1 + y2) / 2 for (y1, h1), (y2, _h2) in zip(boxes, boxes[1:])]
198
+ gaps.append(boxes[-1][0] + boxes[-1][1] + 10)
199
+ return min(gaps, key=lambda g: (abs(g - want), g))
200
+
201
+
202
+ def _curve(a: tuple, b: tuple, columns: Dict[int, tuple]) -> str:
203
+ """A connection from box a to box b. One that skips a column crosses it through a gap
204
+ between its boxes, so it never seems to leave from a box it only passed behind."""
205
+ ax, ay0, ah, ac = a
206
+ bx, by0, bh, bc = b
207
+ ay, by = ay0 + min(ah / 2, 22), by0 + min(bh / 2, 22)
208
+ if bc == ac: # same column: an arc on the right
209
+ x = ax + W
210
+ bend = 26 + min(40, abs(by - ay) / 6)
211
+ return "M{},{} C{},{} {},{} {},{}".format(*map(_f, (x, ay, x + bend, ay, x + bend, by, x + 6, by)))
212
+ step = 1 if bc > ac else -1
213
+ x, y = (ax + W, ay) if step > 0 else (ax, ay)
214
+ d = f"M{_f(x)},{_f(y)}"
215
+ for k in range(ac + step, bc, step):
216
+ cx, boxes = columns[k]
217
+ gy = _free_y(boxes, ay + (by - ay) * (k - ac) / (bc - ac))
218
+ near, far = (cx, cx + W) if step > 0 else (cx + W, cx)
219
+ d += f" {_s(x, y, near, gy)} L{_f(far)},{_f(gy)}"
220
+ x, y = far, gy
221
+ end = bx - 6 if step > 0 else bx + W + 6
222
+ return d + f" {_s(x, y, end, by)}"
223
+
224
+
225
+ def _box(n: dict, p: tuple, diff: bool) -> str:
226
+ x, y, h, _ = p
227
+ kind = n.get("type", "component")
228
+ status = n.get("status", "same") if diff else "same"
229
+ cls = ["bn", "t-" + kind, "s-" + status] + ([n["style"]] if n.get("style") else [])
230
+ out = [f'<g class="{" ".join(cls)}" transform="translate({_f(x)},{_f(y)})">',
231
+ f'<rect class="box" width="{W}" height="{h}" rx="{20 if kind == "host" else 8}"/>']
232
+ if kind != "host" and ((diff and status != "same") or (not diff and n.get("style") in ("new", "undeclared"))):
233
+ out.append(f'<rect class="band" width="5" height="{h}" rx="2"/>')
234
+ titles = _title_lines(n["title"])
235
+ for i, t in enumerate(titles):
236
+ mark = f"{OP[status]} " if i == 0 and OP.get(status) else ""
237
+ out.append(f'<text x="12" y="{19 + 16 * i}" class="bn-t">{esc(mark + t)}</text>')
238
+ dy = 16 * (len(titles) - 1)
239
+ if n.get("sub"):
240
+ out.append(f'<text x="12" y="{35 + dy}" class="bn-s">{esc(cut(n["sub"], 32))}</text>')
241
+ lines = n.get("lines") or []
242
+ shown = lines[: MAX_LINES.get(kind, 4)]
243
+ for i, line in enumerate(shown):
244
+ ls = line.get("status", "same") if diff else "same"
245
+ mark = OP.get(ls) or "·"
246
+ code = " code" if line.get("code") else ""
247
+ text = cut_code(line["text"], 29) if code else cut(line["text"], 31)
248
+ out.append(f'<text x="14" y="{53 + dy + i * 15}" class="bn-p s-{ls}{code}">{esc(mark)} {esc(text)}</text>')
249
+ more = len(lines) - len(shown) + int(n.get("more") or 0)
250
+ if more:
251
+ out.append(f'<text x="14" y="{53 + dy + len(shown) * 15}" class="bn-p more">+{more} more</text>')
252
+ out.append(f"<title>{esc(n['title'])}</title></g>")
253
+ return "".join(out)
254
+
255
+
256
+ MAP_LEGEND = [("band", "new", "new"), ("dashed", "", "legacy, still running"),
257
+ ("band", "undeclared", "not named in the config"),
258
+ ("line", "call", "calls"), ("line", "data", "reads and writes data"), ("line", "network", "over the network")]
259
+ DIFF_LEGEND = [("op", "added", "added"), ("op", "changed", "changed"), ("op", "removed", "removed"),
260
+ ("line", "call", "calls"), ("line", "data", "reads and writes data"), ("line", "network", "over the network")]
261
+
262
+
263
+ def _legend(items: List[tuple], x: float, y: float) -> List[str]:
264
+ out = []
265
+ for kind, key, text in items:
266
+ if kind == "band":
267
+ fill = WARN if key == "undeclared" else WORK
268
+ out.append(f'<rect x="{_f(x)}" y="{_f(y - 10)}" width="14" height="12" rx="2" fill="{SURFACE}" stroke="{LINE}"/>'
269
+ f'<rect x="{_f(x)}" y="{_f(y - 10)}" width="4" height="12" rx="1" fill="{fill}"/>')
270
+ x += 20
271
+ elif kind == "dashed":
272
+ out.append(f'<rect x="{_f(x)}" y="{_f(y - 10)}" width="14" height="12" rx="2" fill="{SURFACE2}" '
273
+ f'stroke="#c3cad4" stroke-dasharray="3 2"/>')
274
+ x += 20
275
+ elif kind == "line":
276
+ out.append(f'<path class="be k-{key}" d="M{_f(x)},{_f(y - 4)} L{_f(x + 20)},{_f(y - 4)}" style="opacity:1"/>')
277
+ x += 26
278
+ elif kind == "op":
279
+ text = f"{OP[key]} {text}"
280
+ cls = f"lg s-{key}" if kind == "op" else "lg"
281
+ out.append(f'<text x="{_f(x)}" y="{_f(y)}" class="{cls}">{esc(text)}</text>')
282
+ x += text_width(text) + 18
283
+ return out
284
+
285
+
286
+ def map_svg(graph: dict, diff: bool = False, label: str = "System map") -> str:
287
+ nodes = graph["nodes"]
288
+ pos, heads, width, height = map_layout(graph["columns"], nodes)
289
+ ids = set(pos)
290
+ edges = [e for e in graph.get("edges", []) if e["from"] in ids and e["to"] in ids and e["from"] != e["to"]]
291
+ legend = graph.get("legend")
292
+ if legend is None:
293
+ legend = DIFF_LEGEND if diff else [i for i in MAP_LEGEND if i[1] != "undeclared"
294
+ or any(n.get("style") == "undeclared" for n in nodes)]
295
+ footer = graph.get("footer") or []
296
+ columns: Dict[int, tuple] = {} # column index -> (x, [(y, height) of each box, top to bottom])
297
+ for x, y, h, c in sorted(pos.values(), key=lambda v: (v[3], v[1])):
298
+ columns.setdefault(c, (x, []))[1].append((y, h))
299
+ body = [f'<text class="col" x="{_f(x)}" y="18">{esc(h.upper())}</text>' for h, x in heads]
300
+ markers = {}
301
+ for e in edges:
302
+ status = e.get("status", "same") if diff else "same"
303
+ kind = e.get("kind", "call")
304
+ mid = f"ar-{kind}-{status}"
305
+ markers.setdefault(mid, _marker(mid, f"k-{kind} s-{status}"))
306
+ body.append(f'<path class="be k-{kind} s-{status}" d="{_curve(pos[e["from"]], pos[e["to"]], columns)}" '
307
+ f'marker-end="url(#{mid})"><title>{esc(e["from"])} → {esc(e["to"])}</title></path>')
308
+ body += [_box(n, pos[n["id"]], diff) for n in nodes if n["id"] in pos]
309
+ y = height + 6
310
+ if legend:
311
+ body += _legend(legend, PAD + 2, y + 10)
312
+ y += LEGEND_H - 6
313
+ for line in footer:
314
+ body.append(f'<text class="foot" x="{PAD + 2}" y="{_f(y + 12)}">{esc(line)}</text>')
315
+ y += 18
316
+ width = max(width, PAD * 2 + max([0.0] + [sum(text_width(t[2]) + 44 for t in legend)]),
317
+ PAD * 2 + max([0.0] + [text_width(t) for t in footer]))
318
+ return _svg(width, y + (8 if legend or footer else 0), body, label, markers.values())
319
+
320
+
321
+ # ── stages: a process, left to right ──────────────────────────────────────────
322
+ #
323
+ # spec = {"stages": [{"title", "lines": [text | {"code": text}], "hi": bool}], "width": 170,
324
+ # "loops": [{"from": i, "to": j, "label"}], "exits": [{"label", "note"}],
325
+ # "gate": {"text", "from": [i, ...]}}
326
+
327
+ def _line_items(lines: Iterable, n: int) -> List[Tuple[str, bool]]:
328
+ out: List[Tuple[str, bool]] = []
329
+ for line in lines:
330
+ code = isinstance(line, dict)
331
+ text = line["code"] if code else str(line)
332
+ out += [(t, code) for t in wrap(text, n - (2 if code else 0), max_lines=2)]
333
+ return out
334
+
335
+
336
+ def stages_svg(spec: dict, label: str = "Stages") -> str:
337
+ stages = spec["stages"]
338
+ sw, sg, x0 = int(spec.get("width", 170)), 36, 14
339
+ per_line = int((sw - 22) / 5.7)
340
+ items = [_line_items(s.get("lines", []), per_line) for s in stages]
341
+ titles = [wrap(s["title"], int((sw - 22) / 6.8), max_lines=2) for s in stages]
342
+ sh = max(64, max(30 + 16 * (len(t) - 1) + 16 * len(i) + 12 for t, i in zip(titles, items)))
343
+ loops = sorted(spec.get("loops", []), key=lambda lp: abs(lp["from"] - lp["to"]))
344
+ heights = [26 + 30 * i for i in range(len(loops))]
345
+ y0 = 24 + (int(0.75 * heights[-1]) + 18 if loops else 0)
346
+ sx = lambda i: x0 + i * (sw + sg) # noqa: E731
347
+ exits = spec.get("exits", [])
348
+ exit_w = max([0.0] + [max(text_width(e["label"], 12.5), text_width(e.get("note", ""))) for e in exits])
349
+ exit_w = exit_w + 52 if exits else 0
350
+ sh = max(sh, 30 + 34 * len(exits))
351
+ gate = spec.get("gate")
352
+ width = x0 * 2 + len(stages) * sw + (len(stages) - 1) * sg + exit_w
353
+ height = y0 + sh + (84 if gate else 18)
354
+ body: List[str] = []
355
+ for (lp, ht) in zip(loops, heights):
356
+ a, b = sx(lp["from"]) + sw / 2, sx(lp["to"]) + sw / 2
357
+ body.append(f'<path class="fl loop" d="M{_f(a)},{y0} C{_f(a)},{_f(y0 - ht)} {_f(b)},{_f(y0 - ht)} {_f(b)},{y0}" '
358
+ f'marker-end="url(#ar-stage)"/>')
359
+ body.append(f'<text class="fl-note halo" x="{_f((a + b) / 2)}" y="{_f(y0 - ht * 0.75 - 5)}" '
360
+ f'text-anchor="middle">{esc(lp["label"])}</text>')
361
+ for i, (s, t, its) in enumerate(zip(stages, titles, items)):
362
+ x = sx(i)
363
+ out = [f'<g class="st{" hi" if s.get("hi") else ""}" transform="translate({_f(x)},{y0})">',
364
+ f'<rect class="box" width="{sw}" height="{sh}" rx="10"/>']
365
+ out += [f'<text x="12" y="{22 + 16 * k}" class="st-w">{esc(line)}</text>' for k, line in enumerate(t)]
366
+ base = 42 + 16 * (len(t) - 1)
367
+ for k, (text, code) in enumerate(its):
368
+ cls = "bn-p code" if code else "fl-note"
369
+ out.append(f'<text x="12" y="{base + 16 * k}" class="{cls}">{esc(text)}</text>')
370
+ out.append("</g>")
371
+ body.append("".join(out))
372
+ if i < len(stages) - 1:
373
+ body.append(f'<path class="fl" d="M{_f(x + sw)},{_f(y0 + sh / 2)} L{_f(x + sw + sg - 4)},{_f(y0 + sh / 2)}" '
374
+ f'marker-end="url(#ar-stage)"/>')
375
+ ex = sx(len(stages) - 1) + sw
376
+ for k, e in enumerate(exits):
377
+ y = y0 + 22 + 34 * k
378
+ body.append(f'<path class="fl" d="M{_f(ex)},{_f(y)} L{_f(ex + 26)},{_f(y)}" marker-end="url(#ar-stage)"/>')
379
+ body.append(f'<text class="exit" x="{_f(ex + 32)}" y="{_f(y + 4)}">{esc(e["label"])}</text>')
380
+ if e.get("note"):
381
+ body.append(f'<text class="fl-note" x="{_f(ex + 32)}" y="{_f(y + 18)}">{esc(e["note"])}</text>')
382
+ if gate:
383
+ gy = y0 + sh + 46
384
+ for i in gate.get("from", range(len(stages))):
385
+ cx = sx(i) + sw / 2
386
+ body.append(f'<path class="fl gate" d="M{_f(cx)},{y0 + sh} L{_f(cx)},{gy}" marker-end="url(#ar-gate)"/>')
387
+ gw = ex - sx(0)
388
+ body.append(f'<g class="gate" transform="translate({_f(sx(0))},{gy})"><rect class="box" width="{_f(gw)}" '
389
+ f'height="34" rx="17"/><text x="18" y="22">{esc(gate["text"])}</text></g>')
390
+ defs = [_marker("ar-stage", ""), _marker("ar-gate", "k-person")]
391
+ return _svg(width, height, body, label, defs)
392
+
393
+
394
+ # ── sequence: who talks to whom, in order ─────────────────────────────────────
395
+ #
396
+ # spec = {"lanes": [{"id", "title", "sub", "person": bool}], "lane": 156,
397
+ # "steps": [{"from", "to", "text", "kind": call|frame} | {"note", "over": [lane, lane]}]}
398
+ # A step from a person's lane is drawn as the person's (blue); a frame is what changes the right pane.
399
+
400
+ def sequence_svg(spec: dict, label: str = "Sequence") -> str:
401
+ lanes = spec["lanes"]
402
+ step_w = int(spec.get("lane", 156))
403
+ x0, top = 16, 14
404
+ cx = {ln["id"]: x0 + step_w / 2 + i * step_w for i, ln in enumerate(lanes)}
405
+ person = {ln["id"] for ln in lanes if ln.get("person")}
406
+ width = x0 * 2 + step_w * len(lanes)
407
+ body: List[str] = []
408
+ for ln in lanes:
409
+ x = cx[ln["id"]] - (step_w - 16) / 2
410
+ cls = "lane person" if ln["id"] in person else "lane"
411
+ out = [f'<g class="{cls}" transform="translate({_f(x)},{top})"><rect class="box" width="{step_w - 16}" '
412
+ f'height="44" rx="{20 if ln["id"] in person else 8}"/>',
413
+ f'<text x="{_f((step_w - 16) / 2)}" y="{19 if ln.get("sub") else 27}" text-anchor="middle" '
414
+ f'class="bn-t">{esc(cut(ln["title"], 20))}</text>']
415
+ if ln.get("sub"):
416
+ out.append(f'<text x="{_f((step_w - 16) / 2)}" y="35" text-anchor="middle" class="bn-s">'
417
+ f'{esc(cut(ln["sub"], 24))}</text>')
418
+ out.append("</g>")
419
+ body.append("".join(out))
420
+ y, n, rows = top + 44 + 34, 0, []
421
+ for s in spec["steps"]:
422
+ if "note" in s:
423
+ xs = [cx[k] for k in s.get("over", [lanes[0]["id"], lanes[-1]["id"]])]
424
+ x1, x2 = min(xs) - (step_w - 16) / 2, max(xs) + (step_w - 16) / 2
425
+ rows.append(f'<g class="note"><rect class="box" x="{_f(x1)}" y="{_f(y - 14)}" width="{_f(x2 - x1)}" height="24" '
426
+ f'rx="6"/><text x="{_f((x1 + x2) / 2)}" y="{_f(y + 2)}" text-anchor="middle">{esc(s["note"])}</text></g>')
427
+ y += 36
428
+ continue
429
+ n += 1
430
+ a, b = cx[s["from"]], cx[s["to"]]
431
+ kind = "person" if s["from"] in person else s.get("kind", "call")
432
+ span = abs(b - a) if a != b else step_w * 1.6
433
+ texts = wrap(s["text"], int(max(span - 24, 320) / 5.9), max_lines=2)
434
+ y += 14 * (len(texts) - 1)
435
+ if a == b:
436
+ rows.append(f'<path class="msg {kind}" d="M{_f(a)},{_f(y)} L{_f(a + 30)},{_f(y)} L{_f(a + 30)},{_f(y + 14)} '
437
+ f'L{_f(a + 7)},{_f(y + 14)}" marker-end="url(#ar-msg-{kind})"/>')
438
+ ty = y + 4 - 14 * (len(texts) - 1)
439
+ rows += [f'<text class="msg-t halo {kind}" x="{_f(a + 38)}" y="{_f(ty + 14 * k)}">{esc(t)}</text>'
440
+ for k, t in enumerate(texts)]
441
+ y += 14
442
+ else:
443
+ direction = 1 if b > a else -1
444
+ rows.append(f'<path class="msg {kind}" d="M{_f(a + 10 * direction)},{_f(y)} L{_f(b - 7 * direction)},{_f(y)}" '
445
+ f'marker-end="url(#ar-msg-{kind})"/>')
446
+ ty = y - 7 - 14 * (len(texts) - 1)
447
+ if max(text_width(t) for t in texts) < abs(b - a) - 30:
448
+ x, anchor = (a + b) / 2 + 5 * direction, "middle"
449
+ else: # wider than its arrow: from the sender's end, clear of the step's number
450
+ x, anchor = a + 14 * direction, "start" if direction > 0 else "end"
451
+ rows += [f'<text class="msg-t halo {kind}" x="{_f(x)}" y="{_f(ty + 14 * k)}" text-anchor="{anchor}">{esc(t)}</text>'
452
+ for k, t in enumerate(texts)]
453
+ rows.append(f'<g class="num{" person" if kind == "person" else ""}"><circle cx="{_f(a)}" cy="{_f(y)}" r="8"/>'
454
+ f'<text x="{_f(a)}" y="{_f(y + 3.5)}" text-anchor="middle">{n}</text></g>')
455
+ y += 32
456
+ bottom = y - 6
457
+ for ln in lanes:
458
+ body.insert(0, f'<path class="life" d="M{_f(cx[ln["id"]])},{top + 44} L{_f(cx[ln["id"]])},{_f(bottom)}"/>')
459
+ body += rows
460
+ defs = [_marker(f"ar-msg-{k}", cls) for k, cls in (("call", ""), ("frame", "k-data"), ("person", "k-person"))]
461
+ return _svg(width, bottom + 14, body, label, defs)
462
+
463
+
464
+ # ── specs ─────────────────────────────────────────────────────────────────────
465
+
466
+ def _map_from_spec(spec: dict) -> dict:
467
+ nodes = []
468
+ for n in spec["nodes"]:
469
+ lines = [{"text": ln["code"], "code": True} if isinstance(ln, dict) else {"text": str(ln)}
470
+ for ln in n.get("lines", [])]
471
+ nodes.append({**n, "type": n.get("type", "component"), "lines": lines})
472
+ return {"columns": spec["columns"], "nodes": nodes, "edges": spec.get("edges", []),
473
+ "legend": spec.get("legend", []), "footer": spec.get("footer", [])}
474
+
475
+
476
+ def check(spec: dict) -> List[str]:
477
+ """What is wrong with a spec, in words; empty when it can be drawn as meant. The renderer itself tolerates a
478
+ dangling reference (it leaves the line out), which a person reading the picture would not notice."""
479
+ kind = spec.get("picture")
480
+ out: List[str] = []
481
+ if kind == "map":
482
+ cols = {c[0] for c in spec.get("columns") or [] if c}
483
+ ids = [n.get("id") for n in spec.get("nodes") or []]
484
+ out += [f"node {n.get('id')!r} is in column {n.get('col')!r}, which `columns` does not have"
485
+ for n in spec.get("nodes") or [] if n.get("col") not in cols]
486
+ out += [f"node id {i!r} is used twice" for i in sorted({i for i in ids if ids.count(i) > 1})]
487
+ out += [f"edge {e.get('from')!r} → {e.get('to')!r} names a node that does not exist"
488
+ for e in spec.get("edges") or [] if e.get("from") not in ids or e.get("to") not in ids]
489
+ if not spec.get("nodes"):
490
+ out.append("a map needs nodes")
491
+ elif kind == "stages":
492
+ n = len(spec.get("stages") or [])
493
+ bad = lambda i: not isinstance(i, int) or not 0 <= i < n # noqa: E731
494
+ out += [f"loop {lp.get('from')!r} → {lp.get('to')!r}: stages are numbered 0 to {n - 1}"
495
+ for lp in spec.get("loops") or [] if bad(lp.get("from")) or bad(lp.get("to"))]
496
+ out += [f"gate from stage {i!r}: stages are numbered 0 to {n - 1}"
497
+ for i in (spec.get("gate") or {}).get("from") or [] if bad(i)]
498
+ if not n:
499
+ out.append("stages needs at least one stage")
500
+ elif kind == "sequence":
501
+ lanes = {ln.get("id") for ln in spec.get("lanes") or []}
502
+ for i, st in enumerate(spec.get("steps") or [], 1):
503
+ if "note" in st:
504
+ out += [f"step {i}: the note spans lane {x!r}, which does not exist" for x in st.get("over") or []
505
+ if x not in lanes]
506
+ elif st.get("from") not in lanes or st.get("to") not in lanes:
507
+ out.append(f"step {i}: {st.get('from')!r} → {st.get('to')!r} names a lane that does not exist")
508
+ elif not st.get("text"):
509
+ out.append(f"step {i} has no text")
510
+ if not lanes:
511
+ out.append("a sequence needs lanes")
512
+ else:
513
+ out.append(f"picture must be map, stages or sequence, not {kind!r}")
514
+ return out
515
+
516
+
517
+ def draw(spec: dict) -> str:
518
+ kind, label = spec["picture"], spec.get("label") or spec["picture"]
519
+ if kind == "map":
520
+ return map_svg(_map_from_spec(spec), label=label)
521
+ if kind == "stages":
522
+ return stages_svg(spec, label=label)
523
+ if kind == "sequence":
524
+ return sequence_svg(spec, label=label)
525
+ raise ValueError(f"unknown picture kind: {kind!r}")
526
+
527
+
528
+ def write_if_changed(path: Path, text: str) -> bool:
529
+ """LF on every OS (a Windows text-mode write would turn each newline into CRLF)."""
530
+ if path.exists() and path.read_text(encoding="utf-8") == text:
531
+ return False
532
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
533
+ f.write(text)
534
+ return True
535
+
536
+
537
+ def draw_files(paths: List[Path]) -> List[Path]:
538
+ """Each spec (a JSON file with a "picture" key) drawn as an SVG beside it; other JSON files are skipped."""
539
+ drawn = []
540
+ for path in paths:
541
+ spec = json.loads(path.read_text(encoding="utf-8"))
542
+ if not isinstance(spec, dict) or "picture" not in spec:
543
+ continue # the config and other data beside the pictures
544
+ problems = check(spec)
545
+ if problems:
546
+ raise ValueError(f"{path}: " + "; ".join(problems))
547
+ out = path.with_suffix(".svg")
548
+ if write_if_changed(out, draw(spec)):
549
+ drawn.append(out)
550
+ return drawn