설계 개편
This commit is contained in:
+313
-77
@@ -5,7 +5,7 @@ import json
|
||||
import re
|
||||
|
||||
from ..layout import DiagramLayout, NodeBox
|
||||
from ..spec import Node, VizSpec
|
||||
from ..spec import Edge, Node, VizSpec
|
||||
|
||||
|
||||
SAFE_ID_RE = re.compile(r"[^A-Za-z0-9_.-]+")
|
||||
@@ -19,42 +19,210 @@ 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(
|
||||
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"{item.start_line}-{item.end_line}" for item in node.evidence
|
||||
f"{getattr(item, 'start_line')}-{getattr(item, 'end_line')}"
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
common = f'class="node-shape kind-{_safe_id(node.kind)}{assumption_class}" data-evidence="{data}"'
|
||||
if node.kind in {"decision", "gateway"}:
|
||||
|
||||
|
||||
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 node.kind in {"database", "datastore", "storage"}:
|
||||
ry = min(12.0, height / 6)
|
||||
|
||||
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="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'<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}" />'
|
||||
)
|
||||
radius = 26 if node.kind in {"queue", "event", "topic"} else 10
|
||||
|
||||
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 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}
|
||||
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},
|
||||
"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,
|
||||
}
|
||||
|
||||
parts = [
|
||||
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}" '
|
||||
@@ -65,92 +233,160 @@ def render_svg(spec: VizSpec, layout: DiagramLayout) -> str:
|
||||
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">
|
||||
<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>
|
||||
<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; }
|
||||
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
|
||||
.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-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: #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; }
|
||||
.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: #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 { 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 { fill: none; stroke: #2d4357; stroke-width: 2; }
|
||||
.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-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; }
|
||||
.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}" />',
|
||||
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.
|
||||
|
||||
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.2 + 22.0)
|
||||
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="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'<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():
|
||||
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>',
|
||||
]
|
||||
)
|
||||
|
||||
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))
|
||||
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.extend(_render_node_text(node, box))
|
||||
parts.append("</g>")
|
||||
return parts
|
||||
|
||||
parts.append(
|
||||
f'<text class="footer" x="50" y="{layout.height-22:.1f}">Generated from grounded VizSpec · editable sources are versioned separately</text>'
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user