Files
technical-visualization-haness/src/techviz/renderers/svg.py
T
2026-07-24 16:31:12 +09:00

393 lines
17 KiB
Python

from __future__ import annotations
import html
import json
import re
from ..layout import DiagramLayout, NodeBox
from ..spec import Edge, 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 _shape_name(node: Node) -> str:
if node.shape:
return node.shape.casefold()
if node.role == "core":
return "hexagon"
if node.role in {"resource-spec", "custom-resource", "desired-state"}:
return "document"
if node.role in {"controller", "orchestrator"}:
return "controller"
if node.role == "port":
return "port"
return node.kind.casefold()
def _evidence_data(items: list[object]) -> str:
return _esc(
",".join(
f"{getattr(item, 'start_line')}-{getattr(item, 'end_line')}"
for item in items
)
)
def _node_classes(node: Node) -> str:
classes = ["node-shape", f"kind-{_safe_id(node.kind)}", f"emphasis-{_safe_id(node.emphasis)}"]
if node.role:
classes.append(f"role-{_safe_id(node.role)}")
if node.assumption:
classes.append("assumption")
return " ".join(classes)
def _node_shape(node: Node, box: NodeBox) -> str:
x, y, width, height = box.x, box.y, box.width, box.height
classes = _node_classes(node)
data = _evidence_data(node.evidence)
common = f'class="{classes}" data-evidence="{data}"'
shape = _shape_name(node)
if shape in {"decision", "gateway", "diamond"}:
points = f"{box.cx},{y} {x + width},{box.cy} {box.cx},{y + height} {x},{box.cy}"
return f'<polygon {common} points="{points}" />'
if shape in {"database", "datastore", "storage", "cylinder"}:
ry = min(13.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="{classes}" 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}" />'
)
if shape in {"document", "resource", "resource-spec", "custom-resource"}:
fold = min(34.0, width * 0.16, height * 0.28)
path = (
f"M {x:.1f} {y:.1f} H {x + width - fold:.1f} "
f"L {x + width:.1f} {y + fold:.1f} V {y + height:.1f} H {x:.1f} Z "
f"M {x + width - fold:.1f} {y:.1f} V {y + fold:.1f} H {x + width:.1f}"
)
return f'<path {common} d="{path}" />'
if shape in {"controller", "server", "rack"}:
parts = [f'<rect {common} x="{x:.1f}" y="{y:.1f}" width="{width:.1f}" height="{height:.1f}" rx="5" />']
for fraction in (0.34, 0.67):
yy = y + height * fraction
parts.append(f'<line class="controller-divider" x1="{x:.1f}" y1="{yy:.1f}" x2="{x+width:.1f}" y2="{yy:.1f}" />')
for fraction in (0.17, 0.5, 0.83):
yy = y + height * fraction
parts.append(f'<rect class="controller-led" x="{x+13:.1f}" y="{yy-4:.1f}" width="8" height="8" rx="1" />')
return "".join(parts)
if shape in {"hexagon", "core"}:
cut = min(34.0, width * 0.16)
points = (
f"{x+cut:.1f},{y:.1f} {x+width-cut:.1f},{y:.1f} {x+width:.1f},{box.cy:.1f} "
f"{x+width-cut:.1f},{y+height:.1f} {x+cut:.1f},{y+height:.1f} {x:.1f},{box.cy:.1f}"
)
return f'<polygon {common} points="{points}" />'
if shape in {"actor", "user", "person"}:
head_r = 11.0
head_cx = box.cx
head_cy = y + 20.0
body_top = head_cy + head_r + 5.0
body_bottom = min(y + height - 29.0, body_top + 26.0)
arm_y = body_top + 10.0
return (
f'<g class="actor-symbol emphasis-{_safe_id(node.emphasis)}" data-evidence="{data}">'
f'<circle cx="{head_cx:.1f}" cy="{head_cy:.1f}" r="{head_r:.1f}" />'
f'<line x1="{head_cx:.1f}" y1="{body_top:.1f}" x2="{head_cx:.1f}" y2="{body_bottom:.1f}" />'
f'<line x1="{head_cx-18:.1f}" y1="{arm_y:.1f}" x2="{head_cx+18:.1f}" y2="{arm_y:.1f}" />'
f'<line x1="{head_cx:.1f}" y1="{body_bottom:.1f}" x2="{head_cx-15:.1f}" y2="{body_bottom+17:.1f}" />'
f'<line x1="{head_cx:.1f}" y1="{body_bottom:.1f}" x2="{head_cx+15:.1f}" y2="{body_bottom+17:.1f}" />'
f'</g>'
)
radius = 22 if shape in {"queue", "event", "topic", "port", "capsule"} else 7
return f'<rect {common} x="{x:.1f}" y="{y:.1f}" width="{width:.1f}" height="{height:.1f}" rx="{radius}" />'
def _visible_role(node: Node) -> str | None:
if not node.role:
return None
technical = {
"port", "controller", "orchestrator", "worker", "subprocess", "resource-spec",
"custom-resource", "runtime-resource", "desired-state", "actual-state", "core",
"inbound-adapter", "outbound-adapter", "shard", "parser", "router",
}
return node.role if node.role in technical else None
def _render_node_text(node: Node, box: NodeBox) -> list[str]:
parts: list[str] = []
shape = _shape_name(node)
role = _visible_role(node)
is_actor = shape in {"actor", "user", "person"}
top = box.y + (62.0 if is_actor else 19.0)
if role and not is_actor:
parts.append(
f'<text class="node-role" x="{box.cx:.1f}" y="{top:.1f}">«{_esc(role)}»</text>'
)
top += 19.0
label_lines = box.lines
line_height = 18.0
if is_actor:
label_start = box.bottom - 7.0 - (len(label_lines) - 1) * line_height
else:
content_height = len(label_lines) * line_height + (len(node.details) * 16.0 if node.details else 0.0)
label_start = max(top + 8.0, box.cy - content_height / 2 + 7.0)
for index, line in enumerate(label_lines):
parts.append(
f'<text class="node-label" x="{box.cx:.1f}" y="{label_start + index * line_height:.1f}">{_esc(line)}</text>'
)
if node.details and not is_actor:
divider_y = label_start + len(label_lines) * line_height + 3.0
parts.append(
f'<line class="node-detail-divider" x1="{box.x+14:.1f}" y1="{divider_y:.1f}" x2="{box.right-14:.1f}" y2="{divider_y:.1f}" />'
)
detail_y = divider_y + 17.0
for index, detail in enumerate(node.details):
parts.append(
f'<text class="node-detail" x="{box.x+16:.1f}" y="{detail_y + index * 16.0:.1f}">{_esc(detail)}</text>'
)
if node.assumption:
parts.append(
f'<text class="assumption-badge" x="{box.x+8:.1f}" y="{box.bottom-7:.1f}">ASSUMPTION</text>'
)
return parts
def _edge_classes(edge: Edge) -> str:
classes = ["edge", f"kind-{_safe_id(edge.kind)}", f"style-{_safe_id(edge.style)}", f"emphasis-{_safe_id(edge.emphasis)}"]
if edge.assumption:
classes.append("assumption")
if edge.kind in {"async", "event", "publish", "notification", "response"}:
classes.append("semantic-dashed")
return " ".join(classes)
def _polyline_midpoint(points: list[tuple[float, float]]) -> tuple[float, float]:
if not points:
return 0.0, 0.0
return points[len(points) // 2]
def _render_edge(edge: Edge, points: list[tuple[float, float]], label_x: float, label_y: float) -> list[str]:
parts: list[str] = []
point_text = " ".join(f"{x:.1f},{y:.1f}" for x, y in points)
parts.append(
f'<polyline class="{_edge_classes(edge)}" points="{point_text}" data-evidence="{_evidence_data(edge.evidence)}" />'
)
if edge.label:
label_width = max(44.0, min(320.0, len(edge.label) * 6.7 + 18.0))
parts.extend(
[
f'<rect class="edge-label-bg" x="{label_x-label_width/2:.1f}" y="{label_y-14:.1f}" width="{label_width:.1f}" height="22" rx="3" />',
f'<text class="edge-label" x="{label_x:.1f}" y="{label_y+1:.1f}">{_esc(edge.label)}</text>',
]
)
if edge.kind in {"failure", "error"} or edge.emphasis == "warning":
mid_x, mid_y = _polyline_midpoint(points)
size = 13.0
parts.extend(
[
f'<line class="failure-mark" x1="{mid_x-size:.1f}" y1="{mid_y-size:.1f}" x2="{mid_x+size:.1f}" y2="{mid_y+size:.1f}" />',
f'<line class="failure-mark" x1="{mid_x+size:.1f}" y1="{mid_y-size:.1f}" x2="{mid_x-size:.1f}" y2="{mid_y+size:.1f}" />',
]
)
return parts
def _base_svg(spec: VizSpec, layout: DiagramLayout) -> list[str]:
metadata = {
"techviz": {"spec_version": spec.version, "id": spec.id, "profile": spec.profile},
"source_context": spec.source_context,
"evidence_policy": "Each factual element cites source lines or is marked assumption.",
"diagram_only": True,
}
return [
'<?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="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>""",
f'<rect class="canvas" width="{layout.width:.0f}" height="{layout.height:.0f}" />',
]
def _render_groups(spec: VizSpec, layout: DiagramLayout) -> list[str]:
group_by_id = {item.id: item for item in spec.groups}
parts: list[str] = []
for group_id, box in layout.groups.items():
group = group_by_id[group_id]
label_width = max(90.0, len(group.label) * 7.0 + 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="8" />',
f'<rect class="group-label-bg" x="{box.x+14:.1f}" y="{box.y-10:.1f}" width="{label_width:.1f}" height="22" />',
f'<text class="group-label" x="{box.x+24:.1f}" y="{box.y+5:.1f}">{_esc(group.label)}</text>',
]
)
return parts
def _render_graph(spec: VizSpec, layout: DiagramLayout) -> list[str]:
node_by_id = {item.id: item for item in spec.nodes}
edge_by_id = {item.id: item for item in spec.edges}
parts: list[str] = []
parts.extend(_render_groups(spec, layout))
for edge_id, path in layout.edges.items():
parts.extend(_render_edge(edge_by_id[edge_id], path.points, path.label_x, path.label_y))
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))
parts.extend(_render_node_text(node, box))
parts.append("</g>")
return parts
def _render_sequence(spec: VizSpec, layout: DiagramLayout) -> list[str]:
node_by_id = {item.id: item for item in spec.nodes}
edge_by_id = {item.id: item for item in spec.edges}
parts: list[str] = []
lifeline_bottom = layout.height - 30.0
for node_id, box in layout.nodes.items():
node = node_by_id[node_id]
parts.append(_node_shape(node, box))
parts.extend(_render_node_text(node, box))
parts.append(
f'<line class="lifeline" x1="{box.cx:.1f}" y1="{box.bottom:.1f}" x2="{box.cx:.1f}" y2="{lifeline_bottom:.1f}" />'
)
for edge_id, path in layout.edges.items():
edge = edge_by_id[edge_id]
label = f"{edge.order}. {edge.label}" if edge.order is not None else edge.label
cloned = Edge(
id=edge.id,
source=edge.source,
target=edge.target,
label=label,
kind=edge.kind,
evidence=edge.evidence,
assumption=edge.assumption,
order=edge.order,
style=edge.style,
emphasis=edge.emphasis,
)
parts.extend(_render_edge(cloned, path.points, path.label_x, path.label_y))
return parts
def _render_timeline(spec: VizSpec, layout: DiagramLayout) -> list[str]:
nodes = sorted(spec.nodes, key=lambda node: (node.position if node.position is not None else 10_000, node.id))
if not nodes:
return []
first = layout.nodes[nodes[0].id]
last = layout.nodes[nodes[-1].id]
axis_y = 145.0
start_x = max(25.0, first.cx - 35.0)
end_x = min(layout.width - 25.0, last.cx + 55.0)
parts = [f'<line class="timeline-axis" x1="{start_x:.1f}" y1="{axis_y:.1f}" x2="{end_x:.1f}" y2="{axis_y:.1f}" />']
for index, node in enumerate(nodes):
box = layout.nodes[node.id]
marker_class = "timeline-marker"
if node.emphasis == "primary":
marker_class += " primary"
elif node.emphasis == "warning":
marker_class += " warning"
above = index % 2 == 0
stem_end = axis_y - 48.0 if above else axis_y + 48.0
label_y = stem_end - 12.0 if above else stem_end + 24.0
parts.extend(
[
f'<line class="timeline-stem" x1="{box.cx:.1f}" y1="{axis_y:.1f}" x2="{box.cx:.1f}" y2="{stem_end:.1f}" />',
f'<circle class="{marker_class}" cx="{box.cx:.1f}" cy="{axis_y:.1f}" r="7" />',
f'<text class="timeline-label" x="{box.cx:.1f}" y="{label_y:.1f}">{_esc(node.label)}</text>',
]
)
for detail_index, detail in enumerate(node.details[:3]):
dy = label_y + (17.0 * (detail_index + 1) if above else 17.0 * (detail_index + 1))
parts.append(
f'<text class="timeline-detail" x="{box.cx:.1f}" y="{dy:.1f}">{_esc(detail)}</text>'
)
return parts
def render_svg(spec: VizSpec, layout: DiagramLayout) -> str:
parts = _base_svg(spec, layout)
if spec.profile == "sequence":
parts.extend(_render_sequence(spec, layout))
elif spec.profile == "timeline":
parts.extend(_render_timeline(spec, layout))
else:
parts.extend(_render_graph(spec, layout))
parts.append("</svg>")
return "\n".join(parts) + "\n"