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''
if shape in {"database", "datastore", "storage", "cylinder"}:
ry = min(13.0, height / 6)
body_y = y + ry
body_h = height - 2 * ry
return (
f''
f''
f''
)
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''
if shape in {"controller", "server", "rack"}:
parts = [f'']
for fraction in (0.34, 0.67):
yy = y + height * fraction
parts.append(f'')
for fraction in (0.17, 0.5, 0.83):
yy = y + height * fraction
parts.append(f'')
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''
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''
f''
f''
f''
f''
f''
f''
)
radius = 22 if shape in {"queue", "event", "topic", "port", "capsule"} else 7
return f''
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'«{_esc(role)}»'
)
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'{_esc(line)}'
)
if node.details and not is_actor:
divider_y = label_start + len(label_lines) * line_height + 3.0
parts.append(
f''
)
detail_y = divider_y + 17.0
for index, detail in enumerate(node.details):
parts.append(
f'{_esc(detail)}'
)
if node.assumption:
parts.append(
f'ASSUMPTION'
)
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''
)
if edge.label:
label_width = max(44.0, min(320.0, len(edge.label) * 6.7 + 18.0))
parts.extend(
[
f'',
f'{_esc(edge.label)}',
]
)
if edge.kind in {"failure", "error"} or edge.emphasis == "warning":
mid_x, mid_y = _polyline_midpoint(points)
size = 13.0
parts.extend(
[
f'',
f'',
]
)
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 [
'',
(
f'")
return "\n".join(parts) + "\n"