init: technical-visualization-haness 하네스 설계
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user