393 lines
17 KiB
Python
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"
|