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'' ), f'{_esc(spec.title)}', f'{_esc(spec.long_description)}', f'{_esc(json.dumps(metadata, ensure_ascii=False, separators=(",", ":")))}', """ """, f'', ] 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'', f'', f'{_esc(group.label)}', ] ) 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'') parts.append(_node_shape(node, box)) parts.extend(_render_node_text(node, box)) parts.append("") 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'' ) 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''] 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'', f'', f'{_esc(node.label)}', ] ) 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'{_esc(detail)}' ) 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("") return "\n".join(parts) + "\n"