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
+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"