stdraw 0.7.13__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.
stdraw/regularity.py ADDED
@@ -0,0 +1,176 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Placement regularity (WS2 item 6): structure detection + computable mathematical definitions.
3
+
4
+ "Regularity" in this module is given entirely as **computable mathematical
5
+ definitions** (docs/crossing-exact.md §6,
6
+ same source as this file's docstring); no eyeball judgment:
7
+
8
+ 1. Structure detection detect_structure:
9
+ cycle — connected and every vertex degree = 2 (single-cycle graph)
10
+ chain — connected, degrees <= 2 with exactly two degree-1 vertices (chain graph)
11
+ general — everything else
12
+
13
+ 2. Cycle regular-polygon deviation D_poly (range [0,1), 0 = a perfect regular k-gon):
14
+ Let the k drawn center points be p_1..p_k, centroid c = mean(p_i), radii
15
+ r_i = |p_i - c|, adjacent polar-angle gaps
16
+ delta_i = (theta_{i+1} - theta_i) mod 2pi (theta in cycle order).
17
+ radial_cv = std(r) / mean(r)
18
+ angular_cv = std(delta) / mean(delta)
19
+ D_poly = 1 - exp(-(radial_cv + angular_cv))
20
+ The exp form guarantees monotonicity, boundedness and a differentiable
21
+ interpretation: both CVs are 0 <=> D_poly = 0.
22
+
23
+ 3. Chain straightness S_straight (range [0,1], 1 = fully collinear/monotone polyline):
24
+ Chain vertices in chain order p_1..p_k, interior turn angle alpha_i = the
25
+ angle between the two adjacent segments
26
+ (cos-normalized, in [-1,1], collinear same-direction = 1).
27
+ S_straight = mean_i (alpha_i + 1) / 2
28
+ Perfectly straight = 1; a random zigzag ~= 0.5; many reversals ~= 0.
29
+
30
+ 4. Symmetry-axis alignment A_sym (range [0,1], 1 = a vertical axis exists such
31
+ that all nodes pair up as mirror images):
32
+ Candidate axes = the x coordinates of all nodes (plus all pairwise midpoints,
33
+ on a 24px-tolerance slot grid).
34
+ For each candidate axis c, the mirror-partner hit rate:
35
+ hit(c) = |{i : exists j != i, y_j = y_i, |x_j - (2c - x_i)| <= 24}| / k
36
+ A_sym = max_c hit(c). The vertical-axis convention matches the row
37
+ structure (y rows).
38
+
39
+ 5. Full report regularity_report: the dominant item is picked by structure
40
+ detection, every metric is computed,
41
+ and all enter the certificate (structure only decides which one is the
42
+ "dominant metric"; nothing else is hidden).
43
+ """
44
+ import math
45
+ from collections import defaultdict
46
+
47
+
48
+ def detect_structure(graph):
49
+ """Structure detection (see module docstring §1). Returns {kind, cycle_nodes, chain_nodes}."""
50
+ nodes = graph.node_ids()
51
+ adj = graph.adj_undirected()
52
+ comps = _components(nodes, adj)
53
+ cycle_nodes, chain_nodes = [], []
54
+ for comp in comps:
55
+ degs = [len(adj[v]) for v in comp]
56
+ if degs and all(d == 2 for d in degs):
57
+ cycle_nodes.append(sorted(comp))
58
+ elif all(d <= 2 for d in degs) and degs.count(1) == 2:
59
+ chain_nodes.append(sorted(comp))
60
+ kind = "general"
61
+ if len(comps) == 1:
62
+ if cycle_nodes:
63
+ kind = "cycle"
64
+ elif chain_nodes:
65
+ kind = "chain"
66
+ return {"kind": kind, "cycle_nodes": cycle_nodes, "chain_nodes": chain_nodes}
67
+
68
+
69
+ def _components(nodes, adj):
70
+ seen, comps = set(), []
71
+ for v in nodes:
72
+ if v in seen:
73
+ continue
74
+ stack, comp = [v], []
75
+ seen.add(v)
76
+ while stack:
77
+ x = stack.pop()
78
+ comp.append(x)
79
+ for w in adj[x]:
80
+ if w not in seen:
81
+ seen.add(w)
82
+ stack.append(w)
83
+ comps.append(comp)
84
+ return comps
85
+
86
+
87
+ def cycle_polygon_deviation(positions, cycle):
88
+ """Cycle regular-polygon deviation D_poly (see §2). positions: {node: (x, y)}."""
89
+ k = len(cycle)
90
+ if k < 3:
91
+ return 0.0
92
+ pts = [positions[v] for v in cycle]
93
+ cx = sum(p[0] for p in pts) / k
94
+ cy = sum(p[1] for p in pts) / k
95
+ rs = [math.hypot(p[0] - cx, p[1] - cy) for p in pts]
96
+ mr = sum(rs) / k
97
+ radial_cv = (math.sqrt(sum((r - mr) ** 2 for r in rs) / k) / mr) if mr else 0.0
98
+ angles = [math.atan2(p[1] - cy, p[0] - cx) for p in pts]
99
+ deltas = []
100
+ for i in range(k):
101
+ d = (angles[(i + 1) % k] - angles[i]) % (2 * math.pi)
102
+ deltas.append(d)
103
+ md = sum(deltas) / k
104
+ angular_cv = (math.sqrt(sum((d - md) ** 2 for d in deltas) / k) / md) if md else 0.0
105
+ return 1.0 - math.exp(-(radial_cv + angular_cv))
106
+
107
+
108
+ def chain_straightness(positions, chain):
109
+ """Chain straightness S_straight in [0,1] (see §3). chain: node list in chain order."""
110
+ if len(chain) < 3:
111
+ return 1.0
112
+ seq = chain if len(chain) >= 2 else []
113
+ s = 0.0
114
+ cnt = 0
115
+ for i in range(1, len(seq) - 1):
116
+ p0, p1, p2 = (positions[seq[i - 1]], positions[seq[i]],
117
+ positions[seq[i + 1]])
118
+ v1 = (p1[0] - p0[0], p1[1] - p0[1])
119
+ v2 = (p2[0] - p1[0], p2[1] - p1[1])
120
+ n1 = math.hypot(*v1)
121
+ n2 = math.hypot(*v2)
122
+ if n1 < 1e-9 or n2 < 1e-9:
123
+ continue
124
+ cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
125
+ s += (cosang + 1.0) / 2.0
126
+ cnt += 1
127
+ return s / cnt if cnt else 1.0
128
+
129
+
130
+ def symmetry_axis_alignment(positions, tol=24.0):
131
+ """Symmetry-axis alignment A_sym in [0,1] (see §4)."""
132
+ items = list(positions.items())
133
+ if len(items) < 2:
134
+ return 1.0
135
+ xs = [p[0] for _, p in items]
136
+ cands = set()
137
+ for x in xs:
138
+ cands.add(round(x / tol) * tol)
139
+ for i in range(len(xs)):
140
+ for j in range(i + 1, len(xs)):
141
+ cands.add(round((xs[i] + xs[j]) / 2 / tol) * tol)
142
+ best = 0.0
143
+ for c in cands:
144
+ hits = 0
145
+ for v, (x, y) in items:
146
+ mirror = 2 * c - x
147
+ if any(abs(yy - y) <= 1e-6 and abs(xx - mirror) <= tol
148
+ for vv, (xx, yy) in items if vv != v):
149
+ hits += 1
150
+ best = max(best, hits / len(items))
151
+ return best
152
+
153
+
154
+ def regularity_report(graph, positions):
155
+ """Full regularity report (the dominant metric is decided by structure detection; the rest are still computed and reported)."""
156
+ st = detect_structure(graph)
157
+ out = {
158
+ "structure": st["kind"],
159
+ "cycle_polygon_deviation": None,
160
+ "chain_straightness": None,
161
+ "symmetry_axis_alignment": round(
162
+ symmetry_axis_alignment(positions), 3),
163
+ }
164
+ if st["cycle_nodes"]:
165
+ vals = [cycle_polygon_deviation(positions, cyc)
166
+ for cyc in st["cycle_nodes"]]
167
+ out["cycle_polygon_deviation"] = round(max(vals), 3)
168
+ if st["chain_nodes"]:
169
+ vals = [chain_straightness(positions, ch) for ch in st["chain_nodes"]]
170
+ out["chain_straightness"] = round(min(vals), 3)
171
+ out["dominant"] = {
172
+ "cycle": "cycle_polygon_deviation",
173
+ "chain": "chain_straightness",
174
+ "general": "symmetry_axis_alignment",
175
+ }[st["kind"] if st["kind"] in ("cycle", "chain") else "general"]
176
+ return out
stdraw/render.py ADDED
@@ -0,0 +1,361 @@
1
+ # -*- coding: utf-8 -*-
2
+ """2D rendering layer: planar layout JSON -> standalone HTML (inline SVG, zero
3
+ dependencies, zero network)."""
4
+ import html
5
+ import json
6
+
7
+ from ._interactive_js import _INTERACTIVE_JS
8
+
9
+
10
+ def render_html(v4: dict, graph_name: str = "", cert: dict = None,
11
+ loops_report: dict = None, labels: dict = None,
12
+ values: dict = None) -> str:
13
+ if v4.get("edges") and "route" in v4["edges"][0]:
14
+ return render_auto_html(v4, graph_name, cert, loops_report, labels, values)
15
+ W, H = v4["viewBox"][2], v4["viewBox"][3]
16
+ nodes, edges, bars = v4["nodes"], v4["edges"], v4.get("bars", {})
17
+ meta = v4["meta"]
18
+ card_h = meta.get("cardH", 42)
19
+ labels = labels or {}
20
+ values = values or {}
21
+
22
+ parts = []
23
+ parts.append(f'<svg viewBox="0 0 {W} {H}" font-family="Microsoft YaHei,sans-serif">')
24
+ # Leader bars (horizontal lines at card row centers, spanning only their
25
+ # own associated line columns)
26
+ for v, (b0, b1) in bars.items():
27
+ parts.append(f'<line x1="{b0}" y1="{nodes[v]["y"]}" x2="{b1}" y2="{nodes[v]["y"]}" '
28
+ f'stroke="#94a3b8" stroke-width="2"/>')
29
+ # Vertical lines + arrows
30
+ for e in edges:
31
+ x, y1, y2 = e["x"], e["y1"], e["y2"]
32
+ parts.append(f'<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" '
33
+ f'stroke="#64748b" stroke-width="1.3"/>')
34
+ ax, ay, d = e["arrow"]
35
+ parts.append(f'<path d="M {ax - 4} {ay - 6 * d} L {ax} {ay} L {ax + 4} {ay - 6 * d} '
36
+ f'Z" fill="#64748b"/>')
37
+ # Cards
38
+ for v, p in nodes.items():
39
+ x, y, w = p["x"], p["y"], p["w"]
40
+ label = html.escape(labels.get(v, v))
41
+ value = html.escape(values.get(v, ""))
42
+ parts.append(f'<g><rect x="{x - w / 2:.0f}" y="{y - card_h / 2:.0f}" width="{w}" '
43
+ f'height="{card_h}" rx="7" fill="#fffbeb" stroke="#f59e0b" '
44
+ f'stroke-width="1.5"/>'
45
+ f'<text x="{x:.0f}" y="{y - card_h / 2 + 17:.0f}" text-anchor="middle" '
46
+ f'font-size="{meta.get("fs", [12.5, 14])[0]}" fill="#78350f">{label}</text>')
47
+ if value:
48
+ parts.append(f'<text x="{x:.0f}" y="{y + card_h / 2 - 7:.0f}" text-anchor="middle" '
49
+ f'font-size="{meta.get("fs", [12.5, 14])[1]}" font-weight="bold" '
50
+ f'fill="#1f2937">{value}</text>')
51
+ parts.append('</g>')
52
+ parts.append('</svg>')
53
+
54
+ cert_html = ""
55
+ if cert:
56
+ m = cert["metrics"]
57
+ def _cls(v):
58
+ return "ok" if v else "bad"
59
+ def _mark(v):
60
+ return "✓" if v else "✗"
61
+ rows = "".join(
62
+ f'<tr><td>{k}</td><td class="{_cls(v)}">{_mark(v)}</td></tr>'
63
+ for k, v in cert["gates"].items())
64
+ cert_html = f'''<div class="cert"><h3>Quality certificate (rechecked on delivered coordinates)</h3>
65
+ <table>
66
+ <tr><td>Crossings</td><td>{m["crossings"]}</td></tr>
67
+ <tr><td>Line piercing unrelated cards (0px)</td><td>{m["occlusions_true_0px"]}</td></tr>
68
+ <tr><td>Line piercing unrelated cards (4px inflated)</td><td>{m["occlusions_inflated4px"]}</td></tr>
69
+ <tr><td>Average edge length</td><td>{m["avg_edge_length"]}</td></tr>
70
+ <tr><td>Row alignment rate</td><td>{m["row_align_rate"]}</td></tr>
71
+ <tr><td>Minimum card gap</td><td>{m["min_card_gap"]}</td></tr>
72
+ <tr><td>Spacing coefficient of variation (CV)</td><td>{m["spacing_cv"]}</td></tr>
73
+ </table>
74
+ <h4>Gates {cert["gates_passed"]}</h4><table>{rows}</table>
75
+ <p class="grade">Grade: <b>{cert["grade"]}</b> — {html.escape(cert["grade_note"])}</p>
76
+ <p class="note">{html.escape(" / ".join(cert.get("honest_notes", [])))}</p>
77
+ </div>'''
78
+ loops_html = ""
79
+ if loops_report:
80
+ def _status(it):
81
+ st = "closed ✓" if it["closed"] else "not closed ✗"
82
+ par = "balancing B" if it["neg_edges"] % 2 else "reinforcing R"
83
+ bad = "" if it["kind_consistent"] else " (inconsistent with declaration!)"
84
+ return f'{st}, negative edges {it["neg_edges"]} -> {par}{bad}'
85
+ items = "".join(
86
+ '<li>[{} {}] {} — {}</li>'.format(
87
+ it["declared_kind"] or "?", it["id"], html.escape(it["label"]),
88
+ _status(it))
89
+ for it in loops_report["loops"])
90
+ probs = loops_report["problems"]
91
+ prob_text = html.escape("; ".join(probs)) if probs else "no problems"
92
+ loops_html = ('<div class="cert"><h3>Loop semantics audit</h3><ul>' + items +
93
+ '</ul><p>' + prob_text + '</p></div>')
94
+
95
+ return f'''<!doctype html><html lang="en"><meta charset="utf-8">
96
+ <title>stdraw 2D · {html.escape(graph_name)}</title>
97
+ <style>
98
+ body{{margin:16px;background:#f8fafc;font-family:Microsoft YaHei,sans-serif}}
99
+ h1{{font-size:18px}} svg{{background:#fff;border:1px solid #e2e8f0;border-radius:8px;max-width:1280px}}
100
+ .cert{{margin-top:12px;padding:10px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;max-width:1280px}}
101
+ .cert table{{border-collapse:collapse}} .cert td{{border:1px solid #e2e8f0;padding:2px 10px}}
102
+ .ok{{color:#16a34a}} .bad{{color:#dc2626}} .note{{color:#64748b;font-size:12px}}
103
+ .grade{{color:#1d4ed8}}
104
+ </style>
105
+ <h1>stdraw 2D layout · {html.escape(graph_name)} (visibility representation: all-vertical lines, 0 crossings guaranteed by theorem; unrelated lines piercing cards occlusion-style is allowed by design)</h1>
106
+ {''.join(parts)}
107
+ {cert_html}
108
+ {loops_html}
109
+ <p class="note">stdraw M0 · honest grading: this certificate holds only for this graph and this layout; no claim of 0 crossings for arbitrary graphs.</p>
110
+ '''
111
+
112
+
113
+ # ═══ auto-v2 rendering: straight/arc lines first-class + anchored directional arrows ═══
114
+ # Edge coloring caliber (jacky 2026-11-09 ruling, two-color polarity rework):
115
+ # lines carry exactly TWO colors keyed to each edge's own sign — '+' = amber,
116
+ # '−' = sky blue — and every line is solid. The dashed=negative encoding, the
117
+ # gray shared-edge color and the loop-id badges are retired; loop membership is
118
+ # no longer a visual encoding (a shared R/B edge takes its own sign color).
119
+ # The legend shrinks to two swatches + "+"/"−". Polarity data in layout.json /
120
+ # certificate.json unchanged.
121
+ EDGE_COLOR_POS = "#d97706"
122
+ EDGE_COLOR_NEG = "#0284c7"
123
+ NEUTRAL_COLOR = "#64748b" # defensive fallback only (DSL edges always carry a sign)
124
+
125
+
126
+ def _edge_svg(e, labels, values):
127
+ """One edge -> SVG (line/poly polyline, arc real Bezier, vg exact L+A
128
+ path); arrow follows the last segment direction."""
129
+ r = e["route"]
130
+ pts = r["points"]
131
+ pol = e.get("polarity", "+")
132
+ color = (EDGE_COLOR_NEG if pol == "-" else
133
+ EDGE_COLOR_POS if pol == "+" else NEUTRAL_COLOR)
134
+ if r.get("path_d"):
135
+ d = r["path_d"]
136
+ elif r["kind"] == "arc" and "ctrl" in r:
137
+ c = r["ctrl"]
138
+ d = (f'M {pts[0][0]} {pts[0][1]} Q {c[0]} {c[1]} '
139
+ f'{pts[-1][0]} {pts[-1][1]}')
140
+ else:
141
+ d = 'M ' + ' L '.join(f'{p[0]} {p[1]}' for p in pts)
142
+ out = [f'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.3"/>']
143
+ # Arrow: last-segment direction
144
+ import math as _m
145
+ (x1, y1), (x2, y2) = pts[-2], pts[-1]
146
+ ang = _m.degrees(_m.atan2(y2 - y1, x2 - x1))
147
+ ax, ay = e["arrow"]
148
+ out.append(f'<path d="M 0 0 L -7 -3 L -7 3 Z" fill="{color}" '
149
+ f'transform="translate({ax} {ay}) rotate({ang:.1f})"/>')
150
+ return ''.join(out)
151
+
152
+
153
+ # R/B text badges were removed together with the coloring rework: edge
154
+ # coloring now carries each edge's own sign ('+' amber / '−' sky, all solid);
155
+ # polarity data stays in layout.json / certificate.json. The drag layer was
156
+ # synced the same way: the badge replay (refreshMarkers/polGroups) in
157
+ # _interactive_js was removed too, and drag re-routing keeps the static
158
+ # stroke attributes.
159
+
160
+
161
+ def render_auto_html(v4: dict, graph_name: str = "", cert: dict = None,
162
+ loops_report: dict = None, labels: dict = None,
163
+ values: dict = None) -> str:
164
+ W, H = v4["viewBox"][2], v4["viewBox"][3]
165
+ nodes, edges = v4["nodes"], v4["edges"]
166
+ meta = v4["meta"]
167
+ card_h = meta.get("cardH", 44)
168
+ labels = labels or {}
169
+ values = values or {}
170
+
171
+ parts = []
172
+ parts.append(f'<svg id="stdraw-svg" viewBox="0 0 {W} {H}" '
173
+ f'font-family="Microsoft YaHei,sans-serif">')
174
+ # R/B text badges removed (each edge's sign is now expressed by two-color
175
+ # edge coloring: '+' amber / '−' sky blue, all solid).
176
+ # Polarity data stays in layout.json / certificate.json.
177
+ for i, e in enumerate(edges):
178
+ e["_idx"] = i
179
+ parts.append(f'<g data-edge="{i}">' + _edge_svg(e, labels, values) + '</g>')
180
+ for v, p in nodes.items():
181
+ x, y, w = p["x"], p["y"], p["w"]
182
+ label = html.escape(labels.get(v, v))
183
+ value = html.escape(values.get(v, ""))
184
+ parts.append(f'<g data-node="{html.escape(v, quote=True)}">'
185
+ f'<rect x="{x - w / 2:.0f}" y="{y - card_h / 2:.0f}" width="{w}" '
186
+ f'height="{card_h}" rx="7" fill="#fffbeb" stroke="#f59e0b" '
187
+ f'stroke-width="1.5"/>'
188
+ f'<text x="{x:.0f}" y="{y + 4:.0f}" text-anchor="middle" '
189
+ f'dominant-baseline="middle" '
190
+ f'font-size="{meta.get("fs", [12.5, 14])[0]}" fill="#78350f">{label}</text>')
191
+ if value:
192
+ parts.append(f'<text x="{x:.0f}" y="{y + card_h / 2 - 7:.0f}" text-anchor="middle" '
193
+ f'font-size="{meta.get("fs", [12.5, 14])[1]}" font-weight="bold" '
194
+ f'fill="#1f2937">{value}</text>')
195
+ parts.append('</g>')
196
+ parts.append('</svg>')
197
+ # ── Interactive layer: toolbar + embedded data + script ──
198
+ # Zero dependencies, zero network; without dragging the initial geometry is
199
+ # byte-identical to the static rendering (the script only attaches
200
+ # listeners; the v0.7.9 font-environment sync is a strict no-op whenever
201
+ # the rendered text fits the planned cards, which the engine's
202
+ # conservative text-metrics padding guarantees in matching environments).
203
+ payload = {
204
+ "name": graph_name,
205
+ "viewBox": [0, 0, W, H],
206
+ "cardH": card_h,
207
+ "nodes": [{"id": v, "x": p["x"], "y": p["y"], "w": p.get("w", 140)}
208
+ for v, p in nodes.items()],
209
+ "edges": [{"a": e["a"], "b": e["b"], "pol": e.get("polarity", "+"),
210
+ "arc": bool(e["route"].get("arc")),
211
+ "ctrl": e["route"].get("ctrl"),
212
+ # vg polyline corner points (handle positions and the bend
213
+ # semantics source for drag re-routing)
214
+ "via": e["route"].get("via"),
215
+ "pts": e["route"]["points"]}
216
+ for e in edges],
217
+ }
218
+ parts.append(
219
+ '<div class="stdraw-toolbar">'
220
+ '<span class="stdraw-hint">Drag cards to rearrange (mouse/touch) · double-click a line '
221
+ 'to cycle its style (auto/straight/arc/free) · in free style click the line to add fold '
222
+ 'points (max 8) · drag a segment of a hand-drawn line to shift it · hover a box to show '
223
+ 'its 16 standard anchors (toggle) · drag an edge endpoint to snap it to the nearest '
224
+ 'anchor · click an anchor to connect explicitly · metrics are live '
225
+ 'recomputed on preview geometry; the certificate is '
226
+ 'authoritative via CLI --from-layout</span>'
227
+ '<span id="stdraw-metrics">…</span>'
228
+ '<button id="stdraw-anchors-toggle" type="button">Anchors: off</button>'
229
+ '<button id="stdraw-export" type="button">Export layout</button>'
230
+ '<button id="stdraw-reset" type="button">Reset</button>'
231
+ '</div>')
232
+ parts.append('<script id="stdraw-data" type="application/json">' +
233
+ json.dumps(payload, ensure_ascii=False,
234
+ separators=(",", ":")).replace("</", "<\\/") +
235
+ '</script>')
236
+ parts.append('<scr' + 'ipt>' + _INTERACTIVE_JS + '</scr' + 'ipt>')
237
+
238
+ cert_html = ""
239
+ if cert:
240
+ m = cert["metrics"]
241
+ def _cls(v):
242
+ return "ok" if v else "bad"
243
+ def _mark(v):
244
+ return "✓" if v else "✗"
245
+ rows = "".join(
246
+ f'<tr><td>{k}</td><td class="{_cls(v)}">{_mark(v)}</td></tr>'
247
+ for k, v in cert["gates"].items())
248
+ placement = cert.get("placement")
249
+ metric_rows = (
250
+ f'<tr><td>Crossings (soft target · report item)</td><td>{m["crossings"]}</td></tr>'
251
+ f'<tr><td>Visual crossings (hard gate · incl. fan pairs)</td><td>{m.get("visual_crossings")}</td></tr>'
252
+ f'<tr><td>Box press (1px margin)</td><td>{m["box_press_1px"]}</td></tr>'
253
+ f'<tr><td>Min edge-box clearance</td><td>{m["edge_box_min_clear"]}</td></tr>'
254
+ f'<tr><td>Anchor violations</td><td>{m["anchor_violations"]}</td></tr>'
255
+ f'<tr><td>Average edge length</td><td>{m["avg_edge_length"]}</td></tr>'
256
+ f'<tr><td>Minimum card gap</td><td>{m["min_card_gap"]}</td></tr>'
257
+ f'<tr><td>Line-kind distribution</td><td>{m.get("line_kind_counts")}</td></tr>')
258
+ if placement == "ring-cycle":
259
+ metric_rows += (
260
+ f'<tr><td>On-ring equal-spacing CV</td><td>{m.get("ring_spacing_cv")}</td></tr>'
261
+ f'<tr><td>Regular-polygon template deviation</td><td>{m.get("ring_template_deviation")}</td></tr>')
262
+ placement_html = (
263
+ '<h4>Placement mode: ring (main ring in regular-polygon slots + '
264
+ 'in-ring chord routing; row alignment / column snap inapplicable)</h4>')
265
+ elif placement == "column-chain":
266
+ metric_rows += (
267
+ f'<tr><td>Main-chain column-axis deviation</td><td>{m.get("column_axis_deviation")}</td></tr>'
268
+ f'<tr><td>Loop cohesion</td><td>{m.get("loop_cohesion_spread")}</td></tr>')
269
+ placement_html = (
270
+ '<h4>Placement mode: column (main chain in one vertical column + '
271
+ 'branches on the nearest side + closing edge as a large left arc; '
272
+ 'row alignment / column snap inapplicable)</h4>')
273
+ elif placement == "comb-bilateral":
274
+ metric_rows += (
275
+ f'<tr><td>Column-axis deviation</td><td>{m.get("column_axis_deviation")}</td></tr>'
276
+ f'<tr><td>Hub-pair symmetry error</td><td>{m.get("hub_symmetry_err")}</td></tr>')
277
+ placement_html = (
278
+ '<h4>Placement mode: comb-bilateral (sources in one vertical '
279
+ 'column + two mirrored hub wings at the column middle row; '
280
+ 'all hub edges straight lines)</h4>')
281
+ elif placement == "symmetric":
282
+ metric_rows += (
283
+ f'<tr><td>Mirror-axis error</td><td>{m.get("symmetry_mirror_err")}</td></tr>')
284
+ placement_html = (
285
+ '<h4>Placement mode: symmetric (shared edge on the vertical axis + '
286
+ 'left/right vertices mirrored; axis edges straight + side edges all arcs)</h4>')
287
+ else:
288
+ metric_rows += (
289
+ f'<tr><td>Row alignment rate (report item)</td><td>{m["row_align_rate"]}</td></tr>'
290
+ f'<tr><td>Spacing coefficient of variation (CV)</td><td>{m["spacing_cv"]}</td></tr>'
291
+ f'<tr><td>24px column snap rate</td><td>{m["column_snap24_rate"]}</td></tr>')
292
+ placement_html = ""
293
+ rep = cert.get("reports") or {}
294
+ rep_html = ""
295
+ if rep:
296
+ rep_html = (f'<h4>Report items (non-vetoing)</h4><table>'
297
+ f'<tr><td>0 crossings</td><td class="{_cls(rep.get("crossings_zero"))}">'
298
+ f'{_mark(rep.get("crossings_zero"))}</td></tr>'
299
+ f'<tr><td>Row alignment &ge;0.60</td>'
300
+ f'<td class="{_cls(rep.get("row_align_ge_060"))}">'
301
+ f'{_mark(rep.get("row_align_ge_060"))}</td></tr></table>')
302
+ legend = ('<p class="note">Edge sign: '
303
+ '<svg width="28" height="10" style="vertical-align:middle" aria-hidden="true">'
304
+ '<line x1="2" y1="5" x2="26" y2="5" stroke="#d97706" stroke-width="4"/></svg> <b>+</b>'
305
+ '&nbsp;&nbsp;'
306
+ '<svg width="28" height="10" style="vertical-align:middle" aria-hidden="true">'
307
+ '<line x1="2" y1="5" x2="26" y2="5" stroke="#0284c7" stroke-width="4"/></svg> <b>−</b></p>')
308
+ cert_html = f'''<div class="cert"><h3>Quality certificate (rechecked on delivered coordinates · auto-v2 hard gates)</h3>
309
+ <table>
310
+ {metric_rows}
311
+ </table>
312
+ {placement_html}
313
+ <h4>Gates {cert["gates_passed"]}</h4><table>{rows}</table>
314
+ {rep_html}
315
+ <p class="grade">Grade: <b>{cert["grade"]}</b> — {html.escape(cert["grade_note"])}</p>
316
+ {legend}
317
+ <p class="note">{html.escape(" / ".join(cert.get("honest_notes", [])))}</p>
318
+ </div>'''
319
+ loops_html = ""
320
+ if loops_report:
321
+ def _status(it):
322
+ st = "closed ✓" if it["closed"] else "not closed ✗"
323
+ par = "balancing B" if it["neg_edges"] % 2 else "reinforcing R"
324
+ bad = "" if it["kind_consistent"] else " (inconsistent with declaration!)"
325
+ return f'{st}, negative edges {it["neg_edges"]} -> {par}{bad}'
326
+ items = "".join(
327
+ '<li>[{} {}] {} — {}</li>'.format(
328
+ it["declared_kind"] or "?", it["id"], html.escape(it["label"]),
329
+ _status(it))
330
+ for it in loops_report["loops"])
331
+ probs = loops_report["problems"]
332
+ prob_text = html.escape("; ".join(probs)) if probs else "no problems"
333
+ loops_html = ('<div class="cert"><h3>Loop semantics audit</h3><ul>' + items +
334
+ '</ul><p>' + prob_text + '</p></div>')
335
+
336
+ engine = meta.get("position_engine", "construct")
337
+ ls = meta.get("line_style", "straight")
338
+ return f'''<!doctype html><html lang="en"><meta charset="utf-8">
339
+ <title>stdraw 2D · {html.escape(graph_name)}</title>
340
+ <style>
341
+ body{{margin:16px;background:#f8fafc;font-family:Microsoft YaHei,sans-serif}}
342
+ h1{{font-size:18px}} svg{{background:#fff;border:1px solid #e2e8f0;border-radius:8px;max-width:1280px}}
343
+ .cert{{margin-top:12px;padding:10px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;max-width:1280px}}
344
+ .cert table{{border-collapse:collapse}} .cert td{{border:1px solid #e2e8f0;padding:2px 10px}}
345
+ .ok{{color:#16a34a}} .bad{{color:#dc2626}} .note{{color:#64748b;font-size:12px}}
346
+ .grade{{color:#1d4ed8}}
347
+ .stdraw-toolbar{{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:10px;padding:8px 12px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;font-size:13px;max-width:1280px}}
348
+ .stdraw-toolbar button{{padding:4px 14px;border:1px solid #cbd5e1;border-radius:6px;background:#f8fafc;cursor:pointer;font-size:13px}}
349
+ .stdraw-toolbar button:hover{{background:#e2e8f0}}
350
+ .stdraw-hint{{color:#64748b;font-size:12px}}
351
+ #stdraw-metrics{{font-weight:600;color:#1f2937}}
352
+ #stdraw-svg [data-node]{{cursor:grab}}
353
+ #stdraw-svg [data-node] rect{{touch-action:none}}
354
+ #stdraw-svg [data-node].dragging{{cursor:grabbing}}
355
+ </style>
356
+ <h1>stdraw 2D layout · {html.escape(graph_name)} (auto-v2: straight/arc lines first-class, zero box-press hard gate, edge-box clearance &ge;16px; position engine {engine}, line style {ls})</h1>
357
+ {''.join(parts)}
358
+ {cert_html}
359
+ {loops_html}
360
+ <p class="note">stdraw auto-v2 · honest grading: the crossing count is an objective-function minimization term, not a theorem guarantee; this certificate holds only for this graph and this layout.</p>
361
+ '''