init: technical-visualization-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:02:50 +09:00
parent 09d7c594da
commit f43e909162
117 changed files with 10150 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from typing import Callable
from ..layout import DiagramLayout
from ..spec import VizSpec
from .d2 import render_d2
from .drawio import render_drawio
from .excalidraw import render_excalidraw
from .graphviz import render_dot
from .mermaid import render_mermaid
from .svg import render_svg
from .text import render_accessibility_markdown
Renderer = Callable[[VizSpec, DiagramLayout], str]
RENDERERS: dict[str, tuple[str, Renderer]] = {
"svg": ("svg", render_svg),
"mermaid": ("mmd", render_mermaid),
"d2": ("d2", render_d2),
"dot": ("dot", render_dot),
"drawio": ("drawio", render_drawio),
"excalidraw": ("excalidraw", render_excalidraw),
"a11y": ("alt.md", render_accessibility_markdown),
}
def render_formats(
spec: VizSpec,
layout: DiagramLayout,
output_dir: str | Path,
formats: list[str],
) -> list[Path]:
target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for name in formats:
if name not in RENDERERS:
raise ValueError(f"Unknown renderer '{name}'. Available: {', '.join(sorted(RENDERERS))}")
extension, renderer = RENDERERS[name]
target = target_dir / f"{spec.id}.{extension}"
target.write_text(renderer(spec, layout), encoding="utf-8")
written.append(target)
return written
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from ..layout import DiagramLayout
from ..spec import Node, VizSpec
def _quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
def _shape(node: Node) -> str:
return {
"decision": "diamond",
"gateway": "diamond",
"database": "sql_table",
"datastore": "cylinder",
"storage": "cylinder",
"queue": "queue",
"event": "oval",
"topic": "queue",
"actor": "person",
"user": "person",
}.get(node.kind, "rectangle")
def render_d2(spec: VizSpec, _layout: DiagramLayout) -> str:
aliases = {node.id: f"n{index}" for index, node in enumerate(spec.nodes)}
lines = [f"# {spec.title}", f"# Question: {spec.question}", f"direction: {'right' if spec.direction in {'LR', 'RL'} else 'down'}"]
group_aliases = {group.id: f"g{index}" for index, group in enumerate(spec.groups)}
for group in spec.groups:
lines.append(f"{group_aliases[group.id]}: {_quote(group.label)} {{")
for node in [item for item in spec.nodes if item.group == group.id]:
alias = aliases[node.id]
lines.append(f" {alias}: {_quote(node.label)} {{")
lines.append(f" shape: {_shape(node)}")
if node.assumption:
lines.append(" style.stroke-dash: 4")
lines.append(" }")
lines.append("}")
for node in [item for item in spec.nodes if item.group not in group_aliases]:
alias = aliases[node.id]
lines.append(f"{alias}: {_quote(node.label)} {{")
lines.append(f" shape: {_shape(node)}")
if node.assumption:
lines.append(" style.stroke-dash: 4")
lines.append("}")
def ref(node_id: str) -> str:
node = next(item for item in spec.nodes if item.id == node_id)
return f"{group_aliases[node.group]}.{aliases[node.id]}" if node.group in group_aliases else aliases[node.id]
for edge in spec.edges:
connector = "->"
label = f": {_quote(edge.label)}" if edge.label else ""
suffix = " { style.stroke-dash: 4 }" if edge.assumption or edge.kind in {"async", "event", "publish"} else ""
lines.append(f"{ref(edge.source)} {connector} {ref(edge.target)}{label}{suffix}")
return "\n".join(lines) + "\n"
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import html
from ..layout import DiagramLayout
from ..spec import Node, VizSpec
def _esc(value: str) -> str:
return html.escape(value, quote=True)
def _node_style(node: Node) -> str:
base = [
"whiteSpace=wrap",
"html=1",
"rounded=1",
"strokeWidth=2",
"fontSize=14",
"fontStyle=1",
"fillColor=#ffffff",
"strokeColor=#2d4357",
"verticalAlign=middle",
]
if node.kind in {"database", "datastore", "storage"}:
base.extend(["shape=cylinder3", "boundedLbl=1", "backgroundOutline=1", "fillColor=#eef6fb"])
elif node.kind in {"decision", "gateway"}:
base.extend(["rhombus", "perimeter=rhombusPerimeter", "fillColor=#fff7e8"])
elif node.kind in {"queue", "event", "topic"}:
base.extend(["rounded=1", "arcSize=50", "fillColor=#f6f1fb"])
elif node.kind in {"external", "actor", "user"}:
base.extend(["dashed=1", "fillColor=#f5f7fa"])
if node.assumption:
base.append("dashed=1")
return ";".join(base) + ";"
def render_drawio(spec: VizSpec, layout: DiagramLayout) -> str:
node_by_id = {item.id: item for item in spec.nodes}
edge_by_id = {item.id: item for item in spec.edges}
group_by_id = {item.id: item for item in spec.groups}
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">',
f' <diagram id="{_esc(spec.id)}" name="{_esc(spec.title)}">',
f' <mxGraphModel dx="{layout.width:.0f}" dy="{layout.height:.0f}" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="{max(827, int(layout.width))}" pageHeight="{max(1169, int(layout.height))}" math="0" shadow="0">',
" <root>",
' <mxCell id="0"/>',
' <mxCell id="1" parent="0"/>',
]
for group_id, box in layout.groups.items():
group = group_by_id[group_id]
style = "swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;"
lines.extend(
[
f' <mxCell id="g_{_esc(group_id)}" value="{_esc(group.label)}" style="{style}" vertex="1" parent="1">',
f' <mxGeometry x="{box.x:.1f}" y="{box.y:.1f}" width="{box.width:.1f}" height="{box.height:.1f}" as="geometry"/>',
" </mxCell>",
]
)
for node_id, box in layout.nodes.items():
node = node_by_id[node_id]
evidence = ", ".join(f"L{item.start_line}-L{item.end_line}" for item in node.evidence)
tooltip = f"{node.description or node.kind} | Evidence: {evidence or 'assumption'}"
lines.extend(
[
f' <mxCell id="n_{_esc(node_id)}" value="{_esc(node.label)}" tooltip="{_esc(tooltip)}" style="{_node_style(node)}" vertex="1" parent="1">',
f' <mxGeometry x="{box.x:.1f}" y="{box.y:.1f}" width="{box.width:.1f}" height="{box.height:.1f}" as="geometry"/>',
" </mxCell>",
]
)
for edge_id, path in layout.edges.items():
edge = edge_by_id[edge_id]
dashed = "dashed=1;" if edge.assumption or edge.kind in {"async", "event", "publish"} else ""
style = f"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;{dashed}"
lines.extend(
[
f' <mxCell id="e_{_esc(edge_id)}" value="{_esc(edge.label)}" style="{style}" edge="1" parent="1" source="n_{_esc(edge.source)}" target="n_{_esc(edge.target)}">',
' <mxGeometry relative="1" as="geometry">',
f' <mxPoint x="{path.label_x:.1f}" y="{path.label_y:.1f}" as="offset"/>',
" </mxGeometry>",
" </mxCell>",
]
)
lines.extend(
[
" </root>",
" </mxGraphModel>",
" </diagram>",
"</mxfile>",
]
)
return "\n".join(lines) + "\n"
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import hashlib
import json
from typing import Any
from ..layout import DiagramLayout
from ..spec import VizSpec
def _seed(value: str) -> int:
return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:8], 16) % 2_000_000_000
def _base(element_id: str, element_type: str, x: float, y: float, width: float, height: float) -> dict[str, Any]:
return {
"id": element_id,
"type": element_type,
"x": x,
"y": y,
"width": width,
"height": height,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": None,
"index": None,
"roundness": {"type": 3},
"seed": _seed(element_id),
"version": 1,
"versionNonce": _seed(element_id + ":nonce"),
"isDeleted": False,
"boundElements": [],
"updated": 0,
"link": None,
"locked": False,
}
def _text(element_id: str, text: str, x: float, y: float, width: float, height: float, font_size: int = 16) -> dict[str, Any]:
item = _base(element_id, "text", x, y, width, height)
item.update(
{
"strokeWidth": 1,
"roughness": 0,
"fontSize": font_size,
"fontFamily": 5,
"text": text,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": None,
"originalText": text,
"autoResize": True,
"lineHeight": 1.25,
}
)
return item
def render_excalidraw(spec: VizSpec, layout: DiagramLayout) -> str:
elements: list[dict[str, Any]] = []
group_by_id = {item.id: item for item in spec.groups}
node_by_id = {item.id: item for item in spec.nodes}
edge_by_id = {item.id: item for item in spec.edges}
elements.append(_text("title", spec.title, 50, 24, max(400, len(spec.title) * 14), 36, 24))
elements.append(_text("question", spec.question, 50, 62, max(500, len(spec.question) * 8), 26, 14))
for group_id, box in layout.groups.items():
group = group_by_id[group_id]
rect = _base(f"group-{group_id}", "rectangle", box.x, box.y, box.width, box.height)
rect.update({"strokeStyle": "dashed", "strokeWidth": 1, "backgroundColor": "#f8f9fa", "roughness": 0})
elements.append(rect)
elements.append(_text(f"group-label-{group_id}", group.label, box.x + 16, box.y + 6, max(100, len(group.label) * 9), 24, 14))
for edge_id, path in layout.edges.items():
edge = edge_by_id[edge_id]
min_x = min(x for x, _ in path.points)
min_y = min(y for _, y in path.points)
points = [[x - min_x, y - min_y] for x, y in path.points]
arrow = _base(
f"edge-{edge_id}",
"arrow",
min_x,
min_y,
max(x for x, _ in path.points) - min_x,
max(y for _, y in path.points) - min_y,
)
arrow.update(
{
"points": points,
"lastCommittedPoint": None,
"startBinding": {"elementId": f"node-{edge.source}", "focus": 0, "gap": 4},
"endBinding": {"elementId": f"node-{edge.target}", "focus": 0, "gap": 4},
"startArrowhead": None,
"endArrowhead": "arrow",
"elbowed": True,
"strokeStyle": "dashed" if edge.assumption or edge.kind in {"async", "event", "publish"} else "solid",
"roundness": None,
}
)
elements.append(arrow)
if edge.label:
elements.append(
_text(
f"edge-label-{edge_id}",
edge.label,
path.label_x - max(45, len(edge.label) * 4),
path.label_y - 12,
max(90, len(edge.label) * 8),
24,
13,
)
)
for node_id, box in layout.nodes.items():
node = node_by_id[node_id]
rect = _base(f"node-{node_id}", "rectangle", box.x, box.y, box.width, box.height)
rect["backgroundColor"] = {
"database": "#e7f5ff",
"datastore": "#e7f5ff",
"storage": "#e7f5ff",
"decision": "#fff4e6",
"gateway": "#fff4e6",
"queue": "#f3f0ff",
"event": "#f3f0ff",
"topic": "#f3f0ff",
}.get(node.kind, "#ffffff")
if node.kind in {"external", "actor", "user"} or node.assumption:
rect["strokeStyle"] = "dashed"
elements.append(rect)
label_text = node.label + ("\n[ASSUMPTION]" if node.assumption else "")
elements.append(
_text(
f"node-label-{node_id}",
label_text,
box.x + 10,
box.y + 10,
box.width - 20,
box.height - 20,
15,
)
)
payload = {
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": elements,
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5,
},
"files": {},
}
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from ..layout import DiagramLayout
from ..spec import Node, VizSpec
def _q(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
def _shape(node: Node) -> str:
return {
"decision": "diamond",
"gateway": "diamond",
"database": "cylinder",
"datastore": "cylinder",
"storage": "cylinder",
"queue": "oval",
"event": "oval",
"topic": "oval",
"actor": "box",
"user": "box",
}.get(node.kind, "box")
def render_dot(spec: VizSpec, _layout: DiagramLayout) -> str:
aliases = {node.id: f"n{index}" for index, node in enumerate(spec.nodes)}
rankdir = spec.direction if spec.direction in {"LR", "RL", "TB", "BT"} else "LR"
lines = [
"digraph techviz {",
f" graph [rankdir={rankdir}, splines=ortho, nodesep=0.55, ranksep=0.85, label={_q(spec.title)}, labelloc=t, fontsize=20];",
" node [fontname=Helvetica, fontsize=11, margin=\"0.18,0.12\", style=\"rounded,filled\", fillcolor=white, color=\"#2d4357\", penwidth=1.5];",
" edge [fontname=Helvetica, fontsize=10, color=\"#364b5f\", penwidth=1.4, arrowsize=0.75];",
]
grouped_ids: set[str] = set()
for group_index, group in enumerate(spec.groups):
members = [node for node in spec.nodes if node.group == group.id]
if not members:
continue
lines.append(f" subgraph cluster_{group_index} {{")
lines.append(f" label={_q(group.label)};")
lines.append(" style=\"rounded,dashed\";")
lines.append(" color=\"#66788a\";")
for node in members:
grouped_ids.add(node.id)
style = "rounded,dashed,filled" if node.assumption or node.kind in {"external", "actor", "user"} else "rounded,filled"
lines.append(
f" {aliases[node.id]} [label={_q(node.label)}, shape={_shape(node)}, style={_q(style)}];"
)
lines.append(" }")
for node in spec.nodes:
if node.id in grouped_ids:
continue
style = "rounded,dashed,filled" if node.assumption or node.kind in {"external", "actor", "user"} else "rounded,filled"
lines.append(f" {aliases[node.id]} [label={_q(node.label)}, shape={_shape(node)}, style={_q(style)}];")
for edge in spec.edges:
style = "dashed" if edge.assumption or edge.kind in {"async", "event", "publish"} else "solid"
lines.append(
f" {aliases[edge.source]} -> {aliases[edge.target]} [label={_q(edge.label)}, style={style}];"
)
lines.append("}")
return "\n".join(lines) + "\n"
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import re
from ..layout import DiagramLayout
from ..spec import Node, VizSpec
def _label(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', "&quot;").replace("\n", "<br/>")
def _node_syntax(alias: str, node: Node) -> str:
label = _label(node.label)
if node.kind in {"decision", "gateway"}:
return f'{alias}{{"{label}"}}'
if node.kind in {"database", "datastore", "storage"}:
return f'{alias}[("{label}")]'
if node.kind in {"queue", "event", "topic"}:
return f'{alias}(["{label}"])'
if node.kind in {"external", "actor", "user"}:
return f'{alias}(["{label}"])'
return f'{alias}["{label}"]'
def _arrow(kind: str, assumption: bool) -> str:
if assumption or kind in {"async", "event", "publish"}:
return "-.->"
return "-->"
def render_mermaid(spec: VizSpec, _layout: DiagramLayout) -> str:
aliases = {node.id: f"n{index}" for index, node in enumerate(spec.nodes)}
lines = [f"%% {spec.title}", f"%% question: {spec.question}"]
if spec.type == "sequence":
lines.append("sequenceDiagram")
for node in spec.nodes:
lines.append(f' participant {aliases[node.id]} as {_label(node.label)}')
for edge in sorted(spec.edges, key=lambda item: (item.order if item.order is not None else 10_000, item.id)):
arrow = "-->>" if edge.kind in {"async", "event", "publish"} or edge.assumption else "->>"
lines.append(
f" {aliases[edge.source]}{arrow}{aliases[edge.target]}: {_label(edge.label or edge.kind)}"
)
return "\n".join(lines) + "\n"
lines.append(f"flowchart {spec.direction}")
group_members = {group.id: [] for group in spec.groups}
ungrouped: list[Node] = []
for node in spec.nodes:
if node.group in group_members:
group_members[node.group].append(node)
else:
ungrouped.append(node)
for group in spec.groups:
members = group_members[group.id]
if not members:
continue
lines.append(f' subgraph g_{re.sub(r"[^A-Za-z0-9_]", "_", group.id)}["{_label(group.label)}"]')
for node in members:
lines.append(f" {_node_syntax(aliases[node.id], node)}")
lines.append(" end")
for node in ungrouped:
lines.append(f" {_node_syntax(aliases[node.id], node)}")
for edge in spec.edges:
label = f'|"{_label(edge.label)}"|' if edge.label else ""
lines.append(
f" {aliases[edge.source]} {_arrow(edge.kind, edge.assumption)}{label} {aliases[edge.target]}"
)
assumption_aliases = [aliases[node.id] for node in spec.nodes if node.assumption]
external_aliases = [aliases[node.id] for node in spec.nodes if node.kind in {"external", "actor", "user"}]
if assumption_aliases:
lines.append(" classDef assumption stroke-dasharray: 4 4,stroke-width:2px")
lines.append(f" class {','.join(assumption_aliases)} assumption")
if external_aliases:
lines.append(" classDef external stroke-dasharray: 6 4")
lines.append(f" class {','.join(external_aliases)} external")
return "\n".join(lines) + "\n"
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import html
import json
import re
from ..layout import DiagramLayout, NodeBox
from ..spec import Node, VizSpec
SAFE_ID_RE = re.compile(r"[^A-Za-z0-9_.-]+")
def _safe_id(value: str) -> str:
return SAFE_ID_RE.sub("-", value)
def _esc(value: str) -> str:
return html.escape(value, quote=True)
def _node_shape(node: Node, box: NodeBox) -> str:
x, y, width, height = box.x, box.y, box.width, box.height
assumption_class = " assumption" if node.assumption else ""
data = _esc(
",".join(
f"{item.start_line}-{item.end_line}" for item in node.evidence
)
)
common = f'class="node-shape kind-{_safe_id(node.kind)}{assumption_class}" data-evidence="{data}"'
if node.kind in {"decision", "gateway"}:
points = f"{box.cx},{y} {x + width},{box.cy} {box.cx},{y + height} {x},{box.cy}"
return f'<polygon {common} points="{points}" />'
if node.kind in {"database", "datastore", "storage"}:
ry = min(12.0, height / 6)
body_y = y + ry
body_h = height - 2 * ry
return (
f'<rect {common} x="{x:.1f}" y="{body_y:.1f}" width="{width:.1f}" height="{body_h:.1f}" />'
f'<ellipse class="node-shape kind-{_safe_id(node.kind)}{assumption_class}" cx="{box.cx:.1f}" cy="{body_y:.1f}" rx="{width/2:.1f}" ry="{ry:.1f}" />'
f'<path class="storage-bottom" d="M {x:.1f} {y+height-ry:.1f} A {width/2:.1f} {ry:.1f} 0 0 0 {x+width:.1f} {y+height-ry:.1f}" />'
)
radius = 26 if node.kind in {"queue", "event", "topic"} else 10
return f'<rect {common} x="{x:.1f}" y="{y:.1f}" width="{width:.1f}" height="{height:.1f}" rx="{radius}" />'
def render_svg(spec: VizSpec, layout: DiagramLayout) -> str:
node_by_id = {item.id: item for item in spec.nodes}
edge_by_id = {item.id: item for item in spec.edges}
group_by_id = {item.id: item for item in spec.groups}
metadata = {
"techviz": {"spec_version": spec.version, "id": spec.id},
"source_context": spec.source_context,
"evidence_policy": "Each factual element cites source lines or is marked assumption.",
}
parts = [
'<?xml version="1.0" encoding="UTF-8"?>',
(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{layout.width:.0f}" height="{layout.height:.0f}" '
f'viewBox="0 0 {layout.width:.0f} {layout.height:.0f}" role="img" '
f'aria-labelledby="diagram-title diagram-description">'
),
f'<title id="diagram-title">{_esc(spec.title)}</title>',
f'<desc id="diagram-description">{_esc(spec.long_description)}</desc>',
f'<metadata>{_esc(json.dumps(metadata, ensure_ascii=False, separators=(",", ":")))}</metadata>',
"""<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.16" />
</filter>
<style>
:root { color-scheme: light; }
text { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #17202a; }
.canvas { fill: #ffffff; }
.diagram-title { font-size: 24px; font-weight: 700; }
.diagram-question { font-size: 14px; fill: #4d5966; }
.group-box { fill: #f7f9fb; stroke: #66788a; stroke-width: 1.5; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #334455; }
.edge { fill: none; stroke: #364b5f; stroke-width: 2; stroke-linejoin: round; marker-end: url(#arrow); }
.edge.async, .edge.event, .edge.publish { stroke-dasharray: 7 5; }
.edge.assumption { stroke-dasharray: 3 5; }
.edge-label-bg { fill: #ffffff; stroke: #d5dce3; stroke-width: 1; rx: 5; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #2d4357; stroke-width: 2; }
.kind-external, .kind-actor, .kind-user { fill: #f5f7fa; stroke-dasharray: 6 4; }
.kind-database, .kind-datastore, .kind-storage { fill: #eef6fb; }
.kind-queue, .kind-event, .kind-topic { fill: #f6f1fb; }
.kind-decision, .kind-gateway { fill: #fff7e8; }
.kind-security, .kind-auth { fill: #fdf0f0; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom { fill: none; stroke: #2d4357; stroke-width: 2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-kind { font-size: 10px; letter-spacing: 0.07em; text-transform: uppercase; text-anchor: middle; fill: #5d6975; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #7a4300; }
.footer { font-size: 10px; fill: #697783; }
</style>
</defs>""",
f'<rect class="canvas" width="{layout.width:.0f}" height="{layout.height:.0f}" />',
f'<text class="diagram-title" x="50" y="42">{_esc(spec.title)}</text>',
f'<text class="diagram-question" x="50" y="67">{_esc(spec.question)}</text>',
]
# Group boundaries are deliberately behind edges and nodes.
for group_id, box in layout.groups.items():
group = group_by_id[group_id]
label_width = max(90.0, len(group.label) * 7.2 + 22.0)
parts.extend(
[
f'<rect class="group-box" x="{box.x:.1f}" y="{box.y:.1f}" width="{box.width:.1f}" height="{box.height:.1f}" rx="12" />',
f'<rect class="group-label-bg" x="{box.x+14:.1f}" y="{box.y-10:.1f}" width="{label_width:.1f}" height="22" rx="5" />',
f'<text class="group-label" x="{box.x+24:.1f}" y="{box.y+5:.1f}">{_esc(group.label)}</text>',
]
)
for edge_id, path in layout.edges.items():
edge = edge_by_id[edge_id]
points = " ".join(f"{x:.1f},{y:.1f}" for x, y in path.points)
assumption_class = " assumption" if edge.assumption else ""
parts.append(
f'<polyline class="edge {_safe_id(edge.kind)}{assumption_class}" points="{points}" '
f'data-evidence="{_esc(",".join(f"{e.start_line}-{e.end_line}" for e in edge.evidence))}" />'
)
if edge.label:
label_width = max(44.0, min(300.0, len(edge.label) * 6.8 + 18.0))
parts.extend(
[
f'<rect class="edge-label-bg" x="{path.label_x-label_width/2:.1f}" y="{path.label_y-14:.1f}" width="{label_width:.1f}" height="22" />',
f'<text class="edge-label" x="{path.label_x:.1f}" y="{path.label_y+1:.1f}">{_esc(edge.label)}</text>',
]
)
for node_id, box in layout.nodes.items():
node = node_by_id[node_id]
parts.append(f'<g id="node-{_safe_id(node_id)}">')
parts.append(_node_shape(node, box))
kind_y = box.y + 17
parts.append(f'<text class="node-kind" x="{box.cx:.1f}" y="{kind_y:.1f}">{_esc(node.kind)}</text>')
line_height = 19.0
start_y = box.cy - ((len(box.lines) - 1) * line_height) / 2 + 7
for index, line in enumerate(box.lines):
parts.append(
f'<text class="node-label" x="{box.cx:.1f}" y="{start_y + index * line_height:.1f}">{_esc(line)}</text>'
)
if node.assumption:
parts.append(f'<text class="assumption-badge" x="{box.x+8:.1f}" y="{box.bottom-7:.1f}">ASSUMPTION</text>')
parts.append("</g>")
parts.append(
f'<text class="footer" x="50" y="{layout.height-22:.1f}">Generated from grounded VizSpec · editable sources are versioned separately</text>'
)
parts.append("</svg>")
return "\n".join(parts) + "\n"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from ..layout import DiagramLayout
from ..spec import VizSpec
def render_accessibility_markdown(spec: VizSpec, _layout: DiagramLayout) -> str:
lines = [
f"# {spec.title}",
"",
"## Alternative text",
"",
spec.alt,
"",
"## Long description",
"",
spec.long_description,
"",
"## Elements and evidence",
"",
]
for group in spec.groups:
evidence = ", ".join(f"L{item.start_line}L{item.end_line}" for item in group.evidence) or "explicit assumption"
lines.append(f"- **Boundary: {group.label}** ({group.kind}): {group.description or 'No additional description.'} Evidence: {evidence}.")
for node in spec.nodes:
evidence = ", ".join(f"L{item.start_line}L{item.end_line}" for item in node.evidence) or "explicit assumption"
lines.append(f"- **{node.label}** ({node.kind}): {node.description or 'No additional description.'} Evidence: {evidence}.")
if spec.edges:
lines.extend(["", "## Relationships", ""])
for edge in sorted(spec.edges, key=lambda item: (item.order if item.order is not None else 10_000, item.id)):
source = next(node.label for node in spec.nodes if node.id == edge.source)
target = next(node.label for node in spec.nodes if node.id == edge.target)
evidence = ", ".join(f"L{item.start_line}L{item.end_line}" for item in edge.evidence) or "explicit assumption"
lines.append(f"- **{source}{target}:** {edge.label or edge.kind}. Evidence: {evidence}.")
return "\n".join(lines) + "\n"