文件预览

render_architecture.py

查看 Visual Architecture 技能包中的文件内容。

文件内容

scripts/render_architecture.py

#!/usr/bin/env python3
import argparse
import json
import math
from pathlib import Path
from xml.sax.saxutils import escape

GRID_X = 120
GRID_Y = 80
PADDING = 40
FONT_FAMILY = "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"

NODE_STYLES = {
    "service": {"fill": "#ffffff", "stroke": "#1f2937", "text": "#111827"},
    "llm": {"fill": "#ffffff", "stroke": "#0f172a", "text": "#0f172a"},
    "agent": {"fill": "#f8fafc", "stroke": "#0f172a", "text": "#0f172a"},
    "memory": {"fill": "#f8fafc", "stroke": "#14532d", "text": "#14532d"},
}

EDGE_STYLES = {
    "primary-data": {"stroke": "#2563eb", "dash": None, "label_fill": "#dbeafe", "label_text": "#1d4ed8"},
    "memory-write": {"stroke": "#16a34a", "dash": "10 8", "label_fill": "#dcfce7", "label_text": "#166534"},
    "control": {"stroke": "#475569", "dash": "6 6", "label_fill": "#e2e8f0", "label_text": "#334155"},
}


def snap(value, grid):
    return round(value / grid) * grid


def load(path):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def text_width(text, font_size):
    return max(36, int(len(text) * font_size * 0.58))


def node_box(node):
    title = node["label"]
    subtitle = node.get("subtitle", "")
    width = max(180, text_width(title, 18) + 48, text_width(subtitle, 13) + 48 if subtitle else 0)
    height = 76 if subtitle else 56
    return width, height


def position_nodes(nodes):
    placed = {}
    for node in nodes:
        x = snap(node.get("x", 0), GRID_X)
        y = snap(node.get("y", 0), GRID_Y)
        width, height = node_box(node)
        placed[node["id"]] = {**node, "x": x, "y": y, "width": width, "height": height}
    return placed


def anchor(node, side):
    x = node["x"]
    y = node["y"]
    w = node["width"]
    h = node["height"]
    if side == "left":
        return x - w / 2, y
    if side == "right":
        return x + w / 2, y
    if side == "top":
        return x, y - h / 2
    return x, y + h / 2


def choose_sides(source, target):
    dx = target["x"] - source["x"]
    dy = target["y"] - source["y"]
    if abs(dx) >= abs(dy):
        return ("right", "left") if dx >= 0 else ("left", "right")
    return ("bottom", "top") if dy >= 0 else ("top", "bottom")


def clean_points(points):
    cleaned = [points[0]]
    for point in points[1:]:
        if point != cleaned[-1]:
            cleaned.append(point)

    final = [cleaned[0]]
    for point in cleaned[1:]:
        if len(final) >= 2:
            ax, ay = final[-2]
            bx, by = final[-1]
            cx, cy = point
            if (ax == bx == cx) or (ay == by == cy):
                final[-1] = point
                continue
        final.append(point)
    return final


def orthogonal_points(source, target, edge=None):
    edge = edge or {}
    source_side = edge.get("source_side")
    target_side = edge.get("target_side")
    if not source_side or not target_side:
        auto_source, auto_target = choose_sides(source, target)
        source_side = source_side or auto_source
        target_side = target_side or auto_target

    sx, sy = anchor(source, source_side)
    tx, ty = anchor(target, target_side)
    points = [(sx, sy)]

    via = edge.get("via", [])
    if via:
        for point in via:
            points.append((snap(point["x"], GRID_X), snap(point["y"], GRID_Y)))
    elif source_side in ("left", "right"):
        dogleg = snap((sx + tx) / 2, GRID_X)
        points.extend([(dogleg, sy), (dogleg, ty)])
    else:
        dogleg = snap((sy + ty) / 2, GRID_Y)
        points.extend([(sx, dogleg), (tx, dogleg)])

    points.append((tx, ty))
    return clean_points(points)


def path_d(points):
    return " ".join([f"M {points[0][0]:.1f} {points[0][1]:.1f}"] + [f"L {x:.1f} {y:.1f}" for x, y in points[1:]])


def segment_midpoint(points, index=None):
    segments = list(zip(points, points[1:]))
    if not segments:
        return points[0]
    if index is not None:
        index = max(0, min(index, len(segments) - 1))
        a, b = segments[index]
        return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)

    max_len = -1
    best = (points[0][0], points[0][1])
    for a, b in segments:
        length = abs(b[0] - a[0]) + abs(b[1] - a[1])
        if length > max_len:
            max_len = length
            best = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
    return best


def arrow_head(a, b, size=9):
    angle = math.atan2(b[1] - a[1], b[0] - a[0])
    left = (b[0] - size * math.cos(angle) + size * 0.6 * math.sin(angle),
            b[1] - size * math.sin(angle) - size * 0.6 * math.cos(angle))
    right = (b[0] - size * math.cos(angle) - size * 0.6 * math.sin(angle),
             b[1] - size * math.sin(angle) + size * 0.6 * math.cos(angle))
    return left, b, right


def render_node(node):
    style = NODE_STYLES.get(node.get("kind", "service"), NODE_STYLES["service"])
    x = node["x"]
    y = node["y"]
    w = node["width"]
    h = node["height"]
    left = x - w / 2
    top = y - h / 2
    shape_parts = []
    label_parts = []
    kind = node.get("kind", "service")

    if kind == "llm":
        shape_parts.append(f'<rect x="{left:.1f}" y="{top:.1f}" width="{w:.1f}" height="{h:.1f}" rx="16" fill="{style["fill"]}" stroke="{style["stroke"]}" stroke-width="2"/>')
        shape_parts.append(f'<rect x="{left+6:.1f}" y="{top+6:.1f}" width="{w-12:.1f}" height="{h-12:.1f}" rx="12" fill="none" stroke="{style["stroke"]}" stroke-width="1.5"/>')
    elif kind == "agent":
        cut = min(22, w * 0.14)
        points = [
            (left + cut, top), (left + w - cut, top), (left + w, y),
            (left + w - cut, top + h), (left + cut, top + h), (left, y)
        ]
        points_str = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
        shape_parts.append(f'<polygon points="{points_str}" fill="{style["fill"]}" stroke="{style["stroke"]}" stroke-width="2"/>')
    elif kind == "memory":
        rx = w / 2
        ry = 12
        cx = x
        top_y = top + ry
        bottom_y = top + h - ry
        shape_parts.append(f'<path d="M {left:.1f} {top_y:.1f} A {rx:.1f} {ry:.1f} 0 0 1 {left+w:.1f} {top_y:.1f} L {left+w:.1f} {bottom_y:.1f} A {rx:.1f} {ry:.1f} 0 0 1 {left:.1f} {bottom_y:.1f} Z" fill="{style["fill"]}" stroke="{style["stroke"]}" stroke-width="2"/>')
        shape_parts.append(f'<ellipse cx="{cx:.1f}" cy="{top_y:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" fill="{style["fill"]}" stroke="{style["stroke"]}" stroke-width="2"/>')
        shape_parts.append(f'<path d="M {left:.1f} {bottom_y:.1f} A {rx:.1f} {ry:.1f} 0 0 0 {left+w:.1f} {bottom_y:.1f}" fill="none" stroke="{style["stroke"]}" stroke-width="2"/>')
    else:
        shape_parts.append(f'<rect x="{left:.1f}" y="{top:.1f}" width="{w:.1f}" height="{h:.1f}" rx="12" fill="{style["fill"]}" stroke="{style["stroke"]}" stroke-width="2"/>')

    label_parts.append(f'<text x="{x:.1f}" y="{y - (4 if node.get("subtitle") else -6):.1f}" text-anchor="middle" font-family="{FONT_FAMILY}" font-size="18" font-weight="600" fill="{style["text"]}">{escape(node["label"])}</text>')
    if node.get("subtitle"):
        label_parts.append(f'<text x="{x:.1f}" y="{y + 18:.1f}" text-anchor="middle" font-family="{FONT_FAMILY}" font-size="13" font-weight="500" fill="#475569">{escape(node["subtitle"])}</text>')
    return "\n".join(shape_parts), "\n".join(label_parts)


def render_edge(edge, nodes):
    source = nodes[edge["from"]]
    target = nodes[edge["to"]]
    points = orthogonal_points(source, target, edge)
    style = EDGE_STYLES.get(edge.get("kind", "control"), EDGE_STYLES["control"])
    edge_parts = []
    label_parts = []
    dash = f' stroke-dasharray="{style["dash"]}"' if style["dash"] else ""
    edge_parts.append(f'<path d="{path_d(points)}" fill="none" stroke="{style["stroke"]}" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"{dash}/>')
    left, tip, right = arrow_head(points[-2], points[-1])
    edge_parts.append(f'<polygon points="{left[0]:.1f},{left[1]:.1f} {tip[0]:.1f},{tip[1]:.1f} {right[0]:.1f},{right[1]:.1f}" fill="{style["stroke"]}"/>')
    if edge.get("label"):
        lx, ly = segment_midpoint(points, edge.get("label_segment"))
        dx, dy = edge.get("label_offset", [0, 0])
        lx += dx
        ly += dy
        label = edge["label"]
        width = text_width(label, 12) + 18
        height = 24
        label_parts.append(f'<rect x="{lx - width/2:.1f}" y="{ly - height/2:.1f}" width="{width:.1f}" height="{height:.1f}" rx="6" fill="{style["label_fill"]}" stroke="#ffffff" stroke-width="2"/>')
        label_parts.append(f'<text x="{lx:.1f}" y="{ly + 4:.1f}" text-anchor="middle" font-family="{FONT_FAMILY}" font-size="12" font-weight="600" fill="{style["label_text"]}">{escape(label)}</text>')
    return "\n".join(edge_parts), "\n".join(label_parts)


def render(data):
    nodes = position_nodes(data["nodes"])
    xs = [n["x"] for n in nodes.values()]
    ys = [n["y"] for n in nodes.values()]
    widths = [n["width"] for n in nodes.values()]
    heights = [n["height"] for n in nodes.values()]
    min_x = min(x - w / 2 for x, w in zip(xs, widths)) - PADDING
    max_x = max(x + w / 2 for x, w in zip(xs, widths)) + PADDING
    min_y = min(y - h / 2 for y, h in zip(ys, heights)) - PADDING
    max_y = max(y + h / 2 for y, h in zip(ys, heights)) + PADDING
    width = max_x - min_x
    height = max_y - min_y

    edge_shapes = []
    edge_labels = []
    for edge in data.get("edges", []):
        shape_svg, label_svg = render_edge(edge, nodes)
        edge_shapes.append(shape_svg)
        if label_svg:
            edge_labels.append(label_svg)

    node_shapes = []
    node_labels = []
    for node in nodes.values():
        shape_svg, label_svg = render_node(node)
        node_shapes.append(shape_svg)
        if label_svg:
            node_labels.append(label_svg)

    title = escape(data.get("title", "Architecture"))
    edge_shapes_svg = indent("\n".join(edge_shapes), 4)
    node_shapes_svg = indent("\n".join(node_shapes), 4)
    labels_svg = indent("\n".join(edge_labels + node_labels), 4)

    show_grid = data.get("show_grid", False)
    grid_svg = ""
    if show_grid:
        grid_svg = f'\n  <rect x="{min_x:.1f}" y="{min_y:.1f}" width="{width:.1f}" height="{height:.1f}" fill="url(#grid)" opacity="0.85"/>'

    return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width:.1f}" height="{height:.1f}" viewBox="{min_x:.1f} {min_y:.1f} {width:.1f} {height:.1f}" role="img" aria-label="{title}">
  <defs>
    <pattern id="grid" width="{GRID_X}" height="{GRID_Y}" patternUnits="userSpaceOnUse">
      <path d="M {GRID_X} 0 L 0 0 0 {GRID_Y}" fill="none" stroke="#e5e7eb" stroke-width="1"/>
    </pattern>
  </defs>
  <rect x="{min_x:.1f}" y="{min_y:.1f}" width="{width:.1f}" height="{height:.1f}" fill="#f8fafc"/>{grid_svg}
  <g id="arrows">
{edge_shapes_svg}
  </g>
  <g id="nodes">
{node_shapes_svg}
  </g>
  <g id="labels">
{labels_svg}
  </g>
</svg>
'''


def indent(text, spaces):
    prefix = " " * spaces
    return "\n".join(prefix + line if line else line for line in text.splitlines())


def main():
    parser = argparse.ArgumentParser(description="Render architecture JSON to SVG")
    parser.add_argument("input", help="Path to architecture JSON")
    parser.add_argument("output", help="Path to output SVG")
    args = parser.parse_args()

    data = load(args.input)
    svg = render(data)
    output_path = Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(svg, encoding="utf-8")


if __name__ == "__main__":
    main()