Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

1774 lines
68 KiB
Python

#!/usr/bin/env python3
"""
Hand-Drawn Diagram Generator
零成本 SVG 图表生成器 - 不调用任何 AI API
Usage:
from generator import DiagramGenerator
gen = DiagramGenerator()
gen.create_architecture(
title="My System",
layers=[
{"name": "Frontend", "items": ["React", "Vue"], "color": "frontend"},
{"name": "Backend", "items": ["API", "Worker"], "color": "backend"},
],
output="/tmp/my_diagram.html"
)
"""
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import json
# ============ 风格配置系统(从 fireworks-tech-graph 学习) ============
STYLE_PROFILES = {
"sketch": {
"name": "Hand-Drawn Sketch",
"background": "#020617",
"surface": "#0f172a",
"grid_stroke": "#1e293b",
"colors": {
"frontend": ("rgba(8, 51, 68, 0.4)", "#22d3ee"),
"backend": ("rgba(6, 78, 59, 0.4)", "#34d399"),
"database": ("rgba(76, 29, 149, 0.4)", "#a78bfa"),
"cloud": ("rgba(120, 53, 15, 0.3)", "#fbbf24"),
"security": ("rgba(136, 19, 55, 0.4)", "#fb7185"),
"message": ("rgba(251, 146, 60, 0.3)", "#fb923c"),
"generic": ("rgba(30, 41, 59, 0.5)", "#94a3b8"),
},
"text": {
"primary": "#e2e8f0",
"secondary": "#94a3b8",
"muted": "#64748b",
"on_surface": "#cbd5e1",
},
"container": {
"fill": "rgba(15, 23, 42, 0.3)",
"stroke": "#64748b",
"label_bg": "#0f172a",
},
"legend": {
"bg": "rgba(15, 23, 42, 0.8)",
"stroke": "#1e293b",
},
"arrow": {
"default": "#64748b",
"cyan": "#22d3ee",
"green": "#34d399",
"rose": "#fb7185",
"amber": "#fbbf24",
"purple": "#a78bfa",
},
"font_family": "'Noto Sans Mono CJK SC', 'JetBrains Mono', 'WenQuanYi Zen Hei Mono', monospace",
"html": {
"title_color": "white",
"subtitle_color": "#94a3b8",
"bg": "#020617",
"surface": "rgba(15, 23, 42, 0.5)",
"border": "#1e293b",
"card_bg": "rgba(15, 23, 42, 0.5)",
"card_border": "#1e293b",
"card_title": "#e2e8f0",
"card_text": "#94a3b8",
"footer": "#64748b",
"pulse_dot": "#22d3ee",
},
},
"flat": {
"name": "Flat Professional",
"background": "#ffffff",
"surface": "#f8fafc",
"grid_stroke": "#e2e8f0",
"colors": {
"frontend": ("#f0f9ff", "#0ea5e9"),
"backend": ("#f0fdf4", "#22c55e"),
"database": ("#faf5ff", "#a855f7"),
"cloud": ("#fffbeb", "#f59e0b"),
"security": ("#fff1f2", "#f43f5e"),
"message": ("#fff7ed", "#f97316"),
"generic": ("#f8fafc", "#64748b"),
},
"text": {
"primary": "#1e293b",
"secondary": "#475569",
"muted": "#94a3b8",
"on_surface": "#334155",
},
"container": {
"fill": "rgba(248, 250, 252, 0.8)",
"stroke": "#cbd5e1",
"label_bg": "#ffffff",
},
"legend": {
"bg": "#f8fafc",
"stroke": "#e2e8f0",
},
"arrow": {
"default": "#94a3b8",
"cyan": "#0ea5e9",
"green": "#22c55e",
"rose": "#f43f5e",
"amber": "#f59e0b",
"purple": "#a855f7",
},
"font_family": "'Noto Sans CJK SC', '-apple-system', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif",
"html": {
"title_color": "#0f172a",
"subtitle_color": "#64748b",
"bg": "#f8fafc",
"surface": "#ffffff",
"border": "#e2e8f0",
"card_bg": "#ffffff",
"card_border": "#e2e8f0",
"card_title": "#1e293b",
"card_text": "#475569",
"footer": "#94a3b8",
"pulse_dot": "#0ea5e9",
},
},
}
def _get_style(name: str = "sketch") -> dict:
"""获取风格配置,支持默认回退"""
return STYLE_PROFILES.get(name, STYLE_PROFILES["sketch"])
# 向后兼容:默认使用 sketch 风格的配色
COLORS = STYLE_PROFILES["sketch"]["colors"]
@dataclass
class Component:
"""架构组件"""
name: str
type: str = "generic" # frontend/backend/database/cloud/security/generic
x: int = 0
y: int = 0
width: int = 120
height: int = 50
sublabel: str = "" # 副标题/端口号
description: str = "" # 描述文字(多行)
shape: str = "" # 语义形状: box(默认)/double/cylinder/hexagon/diamond/cloud
@dataclass
class Container:
"""分组容器(虚线框)- 如 "FastAPI Backend" 这样的组"""
name: str
x: int = 0
y: int = 0
width: int = 300
height: int = 150
color: str = "#64748b" # 边框颜色
@dataclass
class Connection:
"""组件连接"""
from_comp: str
to_comp: str
style: str = "solid" # solid/dashed/dotted
color: str = "#64748b"
label: str = ""
type: str = "data" # data/auth/config - 决定箭头样式
class SVGBuilder:
"""SVG 构建器"""
def __init__(self, width: int = 1000, height: int = 600, style: str = "sketch"):
self.width = width
self.height = height
self.elements = []
self.defs = []
self.style = style if isinstance(style, dict) else _get_style(style)
self._add_default_defs()
def _add_default_defs(self):
"""添加默认定义(箭头、网格、滤镜等)"""
s = self.style
arrow = s["arrow"]
self.defs = [
# 网格图案
f'<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">',
f' <path d="M 40 0 L 0 0 0 40" fill="none" stroke="{s["grid_stroke"]}" stroke-width="0.5"/>',
f'</pattern>',
# 阴影滤镜
'<filter id="dropShadow" x="-50%" y="-50%" width="200%" height="200%">',
' <feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.3"/>',
'</filter>',
# 标准箭头(灰色 - 数据流)
f'<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["default"]}"/>',
f'</marker>',
# 青色箭头(前端)
f'<marker id="arrowhead-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["cyan"]}"/>',
f'</marker>',
# 绿色箭头(后端)
f'<marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["green"]}"/>',
f'</marker>',
# 玫瑰色箭头(认证/安全)
f'<marker id="arrowhead-rose" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["rose"]}"/>',
f'</marker>',
# 琥珀色箭头(配置)
f'<marker id="arrowhead-amber" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["amber"]}"/>',
f'</marker>',
# 紫色箭头(数据库)
f'<marker id="arrowhead-purple" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">',
f' <polygon points="0 0, 10 3.5, 0 7" fill="{arrow["purple"]}"/>',
f'</marker>',
]
def add_rect(self, x: int, y: int, width: int, height: int,
fill: str, stroke: str, stroke_width: float = 1.5,
rx: int = 6, opacity: float = 1.0):
"""添加矩形"""
self.elements.append(
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" '
f'rx="{rx}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" '
f'opacity="{opacity}"/>'
)
def add_text(self, x: int, y: int, text: str,
fill: str = "white", font_size: int = 11,
font_weight: str = "normal", text_anchor: str = "middle"):
"""添加文本"""
# 转义特殊字符
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
self.elements.append(
f'<text x="{x}" y="{y}" fill="{fill}" font-size="{font_size}" '
f'font-weight="{font_weight}" text-anchor="{text_anchor}">{text}</text>'
)
def add_line(self, x1: int, y1: int, x2: int, y2: int,
stroke: str = "#64748b", stroke_width: float = 1.5,
dashed: bool = False, marker_end: str = "url(#arrowhead)"):
"""添加连线"""
dash_attr = ' stroke-dasharray="4,2"' if dashed else ''
self.elements.append(
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
f'stroke="{stroke}" stroke-width="{stroke_width}"{dash_attr} '
f'marker-end="{marker_end}"/>'
)
def add_component_box(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic",
sublabel: str = "", description: str = ""):
"""添加组件框(带背景+样式+多行描述)
Args:
height: 组件高度。如果为0或None,自动根据文字内容计算
"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
center_x = x + width // 2
# 计算高度
if height and height > 0:
# 使用用户指定的高度
base_height = height
else:
# 自动根据文字内容计算
base_height = 50
if description:
# 估算描述行数
desc_lines = description.count('\\n') + 1 if '\\n' in description else 1
base_height = max(50, 35 + desc_lines * 14)
if sublabel:
base_height += 14
# 不透明背景(遮挡箭头)
self.add_rect(x, y, width, base_height, self.style["surface"], stroke, rx=6, opacity=1.0)
# 样式层
self.add_rect(x, y, width, base_height, fill, stroke, rx=6, opacity=1.0)
# 标签
self.add_text(center_x, y + 18, label,
fill=self.style["text"]["primary"],
font_weight="600", font_size=11)
# 子标签(端口号等)
if sublabel:
self.add_text(center_x, y + 32, sublabel, fill=self.style["text"]["secondary"], font_size=8)
start_y = y + 46
else:
start_y = y + 32
# 描述文字(多行支持)
if description:
desc_y = start_y
for line in description.split('\\n'):
self.add_text(center_x, desc_y, line.strip(), fill=self.style["text"]["on_surface"], font_size=8)
desc_y += 12
def add_container_box(self, x: int, y: int, width: int, height: int,
label: str, color: str = None):
"""添加分组容器(虚线框)- 如 FastAPI Backend"""
if color is None:
color = self.style["container"]["stroke"]
c = self.style["container"]
# 虚线边框
self.elements.append(
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" '
f'rx="10" fill="{c["fill"]}" stroke="{color}" '
f'stroke-width="1.5" stroke-dasharray="5,3"/>'
)
# 标签(左上角)
self.elements.append(
f'<rect x="{x+15}" y="{y-8}" width="{len(label)*8+10}" height="16" '
f'fill="{c["label_bg"]}"/>'
)
self.add_text(x + 20 + len(label)*4, y + 3, label,
fill=color, font_size=9, font_weight="500")
def add_connection(self, x1: int, y1: int, x2: int, y2: int,
style: str = "solid", color: str = None,
label: str = "", conn_type: str = "data"):
if color is None:
color = self.style["arrow"]["default"]
"""添加带样式的连接线
Args:
style: solid/dashed/dotted
conn_type: data/auth/config - 决定箭头颜色
"""
# 选择箭头
arrow_map = {
"data": "url(#arrowhead)",
"auth": "url(#arrowhead-rose)",
"config": "url(#arrowhead-amber)",
"frontend": "url(#arrowhead-cyan)",
"backend": "url(#arrowhead-green)",
"database": "url(#arrowhead-purple)",
}
marker = arrow_map.get(conn_type, "url(#arrowhead)")
# 处理样式
dash_attr = ""
if style == "dashed":
dash_attr = ' stroke-dasharray="5,3"'
elif style == "dotted":
dash_attr = ' stroke-dasharray="2,2"'
self.elements.append(
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
f'stroke="{color}" stroke-width="1.5"{dash_attr} '
f'marker-end="{marker}"/>'
)
# 添加标签
if label:
mid_x = (x1 + x2) // 2
mid_y = (y1 + y2) // 2
# 标签背景
label_width = len(label) * 7
self.elements.append(
f'<rect x="{mid_x-label_width//2}" y="{mid_y-8}" width="{label_width}" '
f'height="14" fill="{self.style["surface"]}" rx="2"/>'
)
self.add_text(mid_x, mid_y + 3, label, fill=color, font_size=8)
def add_diamond(self, x: int, y: int, size: int, label: str):
"""添加菱形(判断节点)"""
cx, cy = x + size // 2, y + size // 2
half = size // 2
points = f"{cx},{y} {x+size},{cy} {cx},{y+size} {x},{cy}"
colors = self.style["colors"]
fill, stroke = colors.get("message", colors["generic"])
self.elements.append(
f'<polygon points="{points}" fill="{fill}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self.add_text(cx, cy + 4, label,
fill=self.style["text"]["primary"], font_size=10)
def add_rounded_rect(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic"):
"""添加圆角矩形(开始/结束节点)"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
self.add_rect(x, y, width, height, fill, stroke, rx=height//2)
self.add_text(x + width//2, y + height//2 + 4, label,
fill=self.style["text"]["primary"], font_weight="600")
# ============ 语义形状(从 fireworks-tech-graph 学习) ============
def add_double_border_box(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic",
sublabel: str = ""):
"""双边框圆角矩形 — LLM / 模型 / AI 节点"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
center_x = x + width // 2
# 外框
self.elements.append(
f'<rect x="{x-2}" y="{y-2}" width="{width+4}" height="{height+4}" '
f'rx="8" fill="none" stroke="{stroke}" stroke-width="1.5" opacity="0.6"/>'
)
# 内框(主框)
self.add_rect(x, y, width, height, fill, stroke, rx=6)
# 标签
self.add_text(center_x, y + height//2 + 4, label,
fill=self.style["text"]["primary"],
font_weight="600", font_size=11)
if sublabel:
self.add_text(center_x, y + height - 10, sublabel,
fill=self.style["text"]["secondary"], font_size=8)
def add_cylinder(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic",
sublabel: str = ""):
"""圆柱体 — 数据库 / 长期记忆 / 存储节点"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
center_x = x + width // 2
ry = 10 # 椭圆弧度
# 主体
self.elements.append(
f'<path d="M {x} {y+ry} L {x} {y+height-ry} '
f'A {width//2} {ry} 0 0 0 {x+width} {y+height-ry} '
f'L {x+width} {y+ry} '
f'A {width//2} {ry} 0 0 0 {x} {y+ry} Z" '
f'fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>'
)
# 顶部椭圆
self.elements.append(
f'<ellipse cx="{center_x}" cy="{y+ry}" rx="{width//2}" ry="{ry}" '
f'fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>'
)
# 标签
self.add_text(center_x, y + height//2 + 4, label,
fill=self.style["text"]["primary"],
font_weight="600", font_size=11)
if sublabel:
self.add_text(center_x, y + height - 8, sublabel,
fill=self.style["text"]["secondary"], font_size=8)
def add_hexagon(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic",
sublabel: str = ""):
"""六边形 — Agent / 编排器 / API 网关"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
center_x = x + width // 2
cy = y + height // 2
hw = width // 2
hh = height // 2
inset = min(hw * 0.25, 15)
points = (f"{x+inset},{y} {x+width-inset},{y} "
f"{x+width},{cy} {x+width-inset},{y+height} "
f"{x+inset},{y+height} {x},{cy}")
self.elements.append(
f'<polygon points="{points}" fill="{fill}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self.add_text(center_x, cy + 4, label,
fill=self.style["text"]["primary"],
font_weight="600", font_size=11)
if sublabel:
self.add_text(center_x, y + height - 10, sublabel,
fill=self.style["text"]["secondary"], font_size=8)
def add_cloud_shape(self, x: int, y: int, width: int, height: int,
label: str, color_type: str = "generic"):
"""云朵形状 — 云服务 / 外部 SaaS"""
colors = self.style["colors"]
fill, stroke = colors.get(color_type, colors["cloud"])
center_x = x + width // 2
# 用几个圆弧拼成云朵
r = height * 0.35
self.elements.append(
f'<path d="M {x+width*0.2} {y+height*0.6} '
f'A {r} {r} 0 0 1 {x+width*0.35} {y+height*0.3} '
f'A {r*1.2} {r*1.2} 0 0 1 {x+width*0.65} {y+height*0.25} '
f'A {r} {r} 0 0 1 {x+width*0.8} {y+height*0.55} '
f'A {r*0.8} {r*0.8} 0 0 1 {x+width*0.75} {y+height*0.75} '
f'L {x+width*0.25} {y+height*0.75} '
f'A {r*0.8} {r*0.8} 0 0 1 {x+width*0.2} {y+height*0.6} Z" '
f'fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>'
)
self.add_text(center_x, y + height//2 + 4, label,
fill=self.style["text"]["primary"],
font_weight="600", font_size=11)
def build(self) -> str:
"""构建完整 SVG"""
defs_str = "\n ".join(["<defs>"] + self.defs + ["</defs>"])
elements_str = "\n ".join(self.elements)
font = self.style.get("font_family", "sans-serif")
return f'''<svg viewBox="0 0 {self.width} {self.height}" xmlns="http://www.w3.org/2000/svg" font-family="{font}">
{defs_str}
<rect width="{self.width}" height="{self.height}" fill="url(#grid)"/>
{elements_str}
</svg>'''
class DiagramGenerator:
"""图表生成器主类"""
def __init__(self, style: str = "sketch"):
self.template_dir = Path(__file__).parent / "assets"
self.style = _get_style(style)
def _wrap_html(self, title: str, svg_content: str,
cards: List[Dict] = None) -> str:
"""包装为完整 HTML"""
s = self.style
html = s["html"]
cards_html = ""
if cards:
cards_content = ""
colors = s["colors"]
color_map = {name: stroke for name, (fill, stroke) in colors.items()}
for card in cards:
dot_color = card.get("color", "generic")
dot_color_hex = color_map.get(dot_color, color_map["generic"])
items_html = "\n".join([f"<li>{item}</li>" for item in card.get("items", [])])
cards_content += f'''
<div class="card">
<div class="card-header">
<div class="card-dot" style="background: {dot_color_hex};"></div>
<h3>{card.get("title", "")}</h3>
</div>
<ul>{items_html}</ul>
</div>'''
cards_html = f'<div class="cards">{cards_content}</div>'
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
style_name = "Hand-Drawn SVG" if s["name"] == "Hand-Drawn Sketch" else "Flat Professional SVG"
# 使用系统字体,避免截图时等待外部字体加载
font_link = ""
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
{font_link}
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: {s["font_family"]};
background: {html["bg"]};
min-height: 100vh;
padding: 2rem;
color: {html["title_color"]};
}}
.container {{ max-width: 1200px; margin: 0 auto; }}
.header {{ margin-bottom: 2rem; }}
.header-row {{ display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }}
.pulse-dot {{
width: 12px; height: 12px; background: {html["pulse_dot"]}; border-radius: 50%;
animation: pulse 2s infinite;
}}
@keyframes pulse {{ 0%, 100% {{ opacity: 1; }} 50% {{ opacity: 0.5; }} }}
h1 {{ font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; }}
.subtitle {{ color: {html["subtitle_color"]}; font-size: 0.875rem; margin-left: 1.75rem; }}
.diagram-container {{
background: {html["surface"]}; border-radius: 1rem;
border: 1px solid {html["border"]}; padding: 1.5rem; overflow-x: auto;
}}
svg {{ width: 100%; min-width: 900px; display: block; }}
.cards {{
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem; margin-top: 2rem;
}}
.card {{
background: {html["card_bg"]}; border-radius: 0.75rem;
border: 1px solid {html["card_border"]}; padding: 1.25rem;
}}
.card-header {{ display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }}
.card-dot {{ width: 8px; height: 8px; border-radius: 50%; }}
.card h3 {{ font-size: 0.875rem; font-weight: 600; color: {html["card_title"]}; }}
.card ul {{ list-style: none; font-size: 0.75rem; color: {html["card_text"]}; line-height: 1.8; }}
.card li::before {{ content: "• "; color: {s["text"]["muted"]}; }}
.footer {{
margin-top: 2rem; padding-top: 1rem; border-top: 1px solid {html["border"]};
font-size: 0.75rem; color: {html["footer"]}; text-align: center;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-row">
<div class="pulse-dot"></div>
<h1>{title}</h1>
</div>
<div class="subtitle">系统架构图 · {style_name}</div>
</div>
<div class="diagram-container">
{svg_content}
</div>
{cards_html}
<div class="footer">
生成时间: {timestamp} | Hand-Drawn Diagram · Zero Token Cost
</div>
</div>
</body>
</html>'''
def _auto_layout(self, components: List[Component],
connections: List[Connection]) -> Dict[str, Tuple[int, int]]:
"""自动计算组件位置,避免重叠
采用分层布局算法:
1. 根据连接关系确定层级(拓扑排序)
2. 同一层级的组件水平排列
3. 不同层级垂直排列
"""
if not components:
return {}
# 构建邻接表和入度表
graph = {c.name: [] for c in components}
in_degree = {c.name: 0 for c in components}
for conn in connections:
if conn.from_comp in graph and conn.to_comp in graph:
graph[conn.from_comp].append(conn.to_comp)
in_degree[conn.to_comp] += 1
# 拓扑排序确定层级
levels = {}
queue = [name for name, deg in in_degree.items() if deg == 0]
current_level = 0
while queue:
next_queue = []
for name in queue:
levels[name] = current_level
for neighbor in graph[name]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
next_queue.append(neighbor)
queue = next_queue
current_level += 1
# 未处理的节点(孤立节点)放在最后一层
max_level = max(levels.values()) if levels else 0
for name in in_degree:
if name not in levels:
levels[name] = max_level
# 按层级分组
level_groups = {}
for name, level in levels.items():
if level not in level_groups:
level_groups[level] = []
level_groups[level].append(name)
# 计算位置
positions = {}
level_height = 110 # 每层高度
start_y = 60
for level, names in sorted(level_groups.items()):
# 同层组件水平居中排列
comp_objs = [c for c in components if c.name in names]
total_width = sum(c.width for c in comp_objs) + (len(comp_objs) - 1) * 40
start_x = max(60, (1000 - total_width) // 2)
x = start_x
y = start_y + level * level_height
for comp in comp_objs:
positions[comp.name] = (x, y)
x += comp.width + 40
return positions
def create_architecture(self, title: str,
components: List[Component],
connections: List[Connection],
output: str,
containers: List[Container] = None,
cards: List[Dict] = None,
auto_layout: bool = True,
format: str = "html") -> str:
"""创建架构图(支持容器/分组框)
Args:
containers: 分组容器列表(虚线框),如 FastAPI Backend
auto_layout: 是否自动计算位置(默认 True),设为 False 使用组件的 x,y
format: "html" 或 "svg"
"""
containers = containers or []
# 自动布局或保留原位置
if auto_layout and components:
positions = self._auto_layout(components, connections)
for comp in components:
if comp.name in positions:
comp.x, comp.y = positions[comp.name]
# 计算 SVG 尺寸
max_x = max([c.x + c.width for c in components]) if components else 800
max_y = max([c.y + c.height for c in components]) if components else 400
# 考虑容器边界
for cont in containers:
max_x = max(max_x, cont.x + cont.width)
max_y = max(max_y, cont.y + cont.height)
width = max(1100, max_x + 100)
height = max(700, max_y + 150)
svg = SVGBuilder(width, height, style=self.style)
# 先添加容器(在最底层)
for cont in containers:
svg.add_container_box(cont.x, cont.y, cont.width, cont.height,
cont.name, cont.color)
# 添加组件(根据语义 shape 自动选择绘制方法)
comp_map = {c.name: c for c in components}
for comp in components:
shape = comp.shape or ""
# 若未显式指定 shape,根据 type 做默认推断
if not shape:
shape_map = {
"llm": "double", "model": "double", "ai": "double",
"db": "cylinder", "database": "cylinder", "storage": "cylinder",
"agent": "hexagon", "orchestrator": "hexagon",
"cloud": "cloud", "saas": "cloud",
"decision": "diamond", "choice": "diamond",
}
shape = shape_map.get(comp.type, "box")
if shape == "double":
svg.add_double_border_box(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type, comp.sublabel)
elif shape == "cylinder":
svg.add_cylinder(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type, comp.sublabel)
elif shape == "hexagon":
svg.add_hexagon(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type, comp.sublabel)
elif shape == "cloud":
svg.add_cloud_shape(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type)
elif shape == "diamond":
svg.add_diamond(
comp.x, comp.y, min(comp.width, comp.height),
comp.name)
else:
svg.add_component_box(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type, comp.sublabel, comp.description
)
# 添加连线(智能路径,避开组件)
for conn in connections:
if conn.from_comp in comp_map and conn.to_comp in comp_map:
from_c = comp_map[conn.from_comp]
to_c = comp_map[conn.to_comp]
# 智能计算连接点
x1, y1, x2, y2 = self._calculate_connection_points(from_c, to_c)
# 使用新的连接方法
svg.add_connection(x1, y1, x2, y2,
conn.style, conn.color, conn.label, conn.type)
# 添加图例(增强版)
self._add_enhanced_legend(svg, width, height)
svg_content = svg.build()
return self._save(title, svg_content, output, format, cards)
def _save(self, title: str, svg_content: str, output: str,
format: str = "html", cards: list = None) -> str:
"""统一保存输出,支持 html / svg / png"""
if format == "svg":
Path(output).write_text(svg_content, encoding='utf-8')
elif format == "png":
# 先写临时 HTML(需要完整页面才能正确渲染)
html = self._wrap_html(title, svg_content, cards)
tmp_html = output.rsplit(".", 1)[0] + "_tmp.html"
Path(tmp_html).write_text(html, encoding='utf-8')
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_viewport_size({"width": 1200, "height": 3200})
page.goto(f"file://{tmp_html}", wait_until="domcontentloaded")
page.wait_for_timeout(500)
el = page.query_selector("svg")
if el:
el.screenshot(path=output)
else:
page.screenshot(path=output, full_page=True)
browser.close()
except Exception:
# Playwright 不可用时回退到 chromium-browser 命令行
import subprocess
cmd = [
"chromium-browser", "--headless", "--disable-gpu",
"--no-sandbox", "--disable-dev-shm-usage",
f"--screenshot={output}",
f"--window-size=1280,3200", f"file://{tmp_html}"
]
subprocess.run(cmd, capture_output=True, timeout=30)
Path(tmp_html).unlink(missing_ok=True)
else:
html = self._wrap_html(title, svg_content, cards)
Path(output).write_text(html, encoding='utf-8')
return output
def _calculate_connection_points(self, from_c: Component, to_c: Component) -> Tuple[int, int, int, int]:
"""智能计算组件间连接点,避开组件内部"""
# 确定相对位置
dx = to_c.x - from_c.x
dy = to_c.y - from_c.y
# 默认从底部中心到顶部中心(上层到下层的标准流向)
if dy > 0: # to_c 在 from_c 下方
x1 = from_c.x + from_c.width // 2
y1 = from_c.y + from_c.height
x2 = to_c.x + to_c.width // 2
y2 = to_c.y
elif dy < 0: # to_c 在 from_c 上方
x1 = from_c.x + from_c.width // 2
y1 = from_c.y
x2 = to_c.x + to_c.width // 2
y2 = to_c.y + to_c.height
else: # 同一水平线
if dx > 0: # to_c 在右边
x1 = from_c.x + from_c.width
y1 = from_c.y + from_c.height // 2
x2 = to_c.x
y2 = to_c.y + to_c.height // 2
else: # to_c 在左边
x1 = from_c.x
y1 = from_c.y + from_c.height // 2
x2 = to_c.x + to_c.width
y2 = to_c.y + to_c.height // 2
return x1, y1, x2, y2
def _add_enhanced_legend(self, svg: SVGBuilder, width: int, height: int):
"""添加增强版图例(包含连接线说明)"""
s = svg.style
y_pos = height - 70
x_start = 30
# 图例背景
svg.add_rect(x_start, y_pos, 850, 60, s["legend"]["bg"], s["legend"]["stroke"], rx=6)
svg.add_text(x_start + 15, y_pos + 20, "组件类型:", fill=s["text"]["muted"], font_size=10, text_anchor="start")
# 组件图例
legend_items = [
("frontend", "前端"), ("backend", "后端"), ("database", "数据库"),
("cloud", "外部API"), ("security", "认证"), ("generic", "通用")
]
x = x_start + 90
colors = s["colors"]
for color_type, label in legend_items:
fill, stroke = colors[color_type]
svg.add_rect(x, y_pos + 8, 16, 16, fill, stroke, rx=3)
svg.add_text(x + 22, y_pos + 20, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start")
x += 75
# 连接线说明
svg.add_text(x_start + 15, y_pos + 45, "连接类型:", fill=s["text"]["muted"], font_size=10, text_anchor="start")
arrow = s["arrow"]
conn_items = [
("data", "数据流", arrow["default"]),
("auth", "认证流", arrow["rose"]),
("config", "配置", arrow["amber"]),
]
x = x_start + 90
for conn_type, label, color in conn_items:
arrow_map = {
"data": "url(#arrowhead)",
"auth": "url(#arrowhead-rose)",
"config": "url(#arrowhead-amber)",
}
marker = arrow_map.get(conn_type, "url(#arrowhead)")
svg.elements.append(
f'<line x1="{x}" y1="{y_pos+40}" x2="{x+20}" y2="{y_pos+40}" '
f'stroke="{color}" stroke-width="1.5" marker-end="{marker}"/>'
)
svg.add_text(x + 28, y_pos + 44, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start")
x += 75
def _add_legend(self, svg: SVGBuilder, width: int, height: int):
"""添加图例"""
s = svg.style
y_pos = height - 60
x_start = 30
# 图例背景
svg.add_rect(x_start, y_pos, 600, 45, s["legend"]["bg"], s["legend"]["stroke"], rx=6)
svg.add_text(x_start + 15, y_pos + 28, "图例:", fill=s["text"]["muted"], font_size=10, text_anchor="start")
# 图例项
legend_items = [
("frontend", "前端"), ("backend", "后端"), ("database", "数据库"),
("cloud", "外部API"), ("security", "认证"), ("generic", "通用")
]
x = x_start + 60
colors = s["colors"]
for color_type, label in legend_items:
fill, stroke = colors[color_type]
svg.add_rect(x, y_pos + 12, 16, 16, fill, stroke, rx=3)
svg.add_text(x + 22, y_pos + 25, label, fill=s["text"]["secondary"], font_size=9, text_anchor="start")
x += 80
def create_flowchart(self, title: str, steps: List[Dict],
output: str, format: str = "html") -> str:
"""创建流程图
steps: [{"type": "start|process|decision|end", "label": "...", "next": "..."}]
"""
svg = SVGBuilder(800, 100 + len(steps) * 80, style=self.style)
y = 50
x = 400
for step in steps:
step_type = step.get("type", "process")
label = step.get("label", "")
if step_type == "start":
svg.add_rounded_rect(x - 60, y, 120, 40, label, "backend")
elif step_type == "end":
svg.add_rounded_rect(x - 60, y, 120, 40, label, "security")
elif step_type == "decision":
svg.add_diamond(x - 40, y, 80, label)
else: # process
svg.add_component_box(x - 80, y, 160, 50, label, "generic")
y += 80
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def create_pyramid(self, title: str, levels: List[Dict],
output: str, width: int = 700, format: str = "html") -> str:
"""创建金字塔/漏斗图
Args:
levels: 从顶层到底层的列表
[{"label": "重要", "value": "10%", "color": "security"}, ...]
width: 金字塔底边宽度
"""
level_height = 60
padding = 80
height = padding * 2 + len(levels) * level_height
svg = SVGBuilder(width, height, style=self.style)
# 计算每一层的宽度(从顶到底递增)
center_x = width // 2
max_half_width = (width - 100) // 2 # 留边距
y = padding
for i, level in enumerate(levels):
# 线性插值计算这层的一半宽度
ratio = (i + 1) / len(levels)
half_w = int(30 + (max_half_width - 30) * ratio) # 顶层30px,底层max
label = level.get("label", "")
value = level.get("value", "")
color_type = level.get("color", "backend")
colors = svg.style["colors"]
fill, stroke = colors.get(color_type, colors["backend"])
# 梯形坐标
if i == 0:
# 顶层是矩形(或很小的梯形)
x1, x2 = center_x - half_w, center_x + half_w
else:
# 使用上一层的底部宽度作为这层的顶部
prev_ratio = i / len(levels)
prev_half_w = int(30 + (max_half_width - 30) * prev_ratio)
x1 = center_x - half_w
x2 = center_x + half_w
x3 = center_x - prev_half_w
x4 = center_x + prev_half_w
# 绘制梯形
if i == 0:
svg.add_rect(center_x - half_w, y, half_w * 2, level_height - 5,
fill, stroke, rx=4)
else:
# 梯形多边形
prev_ratio = i / len(levels)
prev_half_w = int(30 + (max_half_width - 30) * prev_ratio)
x_top_left = center_x - prev_half_w
x_top_right = center_x + prev_half_w
x_bottom_left = center_x - half_w
x_bottom_right = center_x + half_w
y_top = y
y_bottom = y + level_height - 5
points = f"{x_top_left},{y_top} {x_top_right},{y_top} {x_bottom_right},{y_bottom} {x_bottom_left},{y_bottom}"
svg.elements.append(
f'<polygon points="{points}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>'
)
# 标签文字
text_y = y + level_height // 2 + 4
svg.add_text(center_x, text_y, label, fill=svg.style["text"]["primary"],
font_size=13, font_weight="600", text_anchor="middle")
# 数值/百分比(右侧)
if value:
svg.add_text(center_x + half_w + 15, text_y, value, fill=svg.style["text"]["secondary"],
font_size=11, text_anchor="start")
y += level_height
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def create_quadrant(self, title: str,
x_axis: Tuple[str, str],
y_axis: Tuple[str, str],
items: List[Dict],
output: str,
width: int = 800,
height: int = 700,
format: str = "html") -> str:
"""创建象限图 (2x2 矩阵)
Args:
x_axis: (左标签, 右标签) - 如 ("低成本", "高成本")
y_axis: (下标签, 上标签) - 如 ("低影响", "高影响")
items: [{"name": "...", "x": 0-1, "y": 0-1, "color": "...", "size": 1-3}, ...]
x,y 是 0-1 的相对位置(左下角为0,0,右上角为1,1)
"""
svg = SVGBuilder(width, height, style=self.style)
margin = 80
chart_w = width - margin * 2
chart_h = height - margin * 2 - 60 # 留空间给标题
center_x = width // 2
center_y = margin + chart_h // 2 + 30 # 下移一点给标题留空间
# 背景四象限
quad_fill = svg.style["container"]["fill"]
quad_stroke = svg.style["legend"]["stroke"]
# 左上象限
svg.add_rect(margin, center_y - chart_h//2, chart_w//2, chart_h//2,
quad_fill, quad_stroke, rx=0)
# 右上象限
svg.add_rect(center_x, center_y - chart_h//2, chart_w//2, chart_h//2,
"rgba(30, 41, 59, 0.5)", quad_stroke, rx=0)
# 左下象限
svg.add_rect(margin, center_y, chart_w//2, chart_h//2,
"rgba(30, 41, 59, 0.5)", quad_stroke, rx=0)
# 右下象限
svg.add_rect(center_x, center_y, chart_w//2, chart_h//2,
quad_fill, quad_stroke, rx=0)
axis_color = svg.style["text"]["muted"]
# 中心轴线
svg.elements.append(
f'<line x1="{margin}" y1="{center_y}" x2="{width-margin}" y2="{center_y}" '
f'stroke="{axis_color}" stroke-width="1.5" stroke-dasharray="5,5"/>'
)
svg.elements.append(
f'<line x1="{center_x}" y1="{margin+30}" x2="{center_x}" y2="{height-margin}" '
f'stroke="{axis_color}" stroke-width="1.5" stroke-dasharray="5,5"/>'
)
# 轴标签
left_label, right_label = x_axis
bottom_label, top_label = y_axis
axis_label_color = svg.style["text"]["secondary"]
# X轴标签
svg.add_text(margin + 10, height - margin + 20, left_label,
fill=axis_label_color, font_size=11, text_anchor="start")
svg.add_text(width - margin - 10, height - margin + 20, right_label,
fill=axis_label_color, font_size=11, text_anchor="end")
# Y轴标签(旋转90度)
svg.elements.append(
f'<text x="{margin - 15}" y="{center_y + 40}" fill="{axis_label_color}" '
f'font-size="11" text-anchor="end" transform="rotate(-90 {margin-15} {center_y+40})">{bottom_label}</text>'
)
svg.elements.append(
f'<text x="{margin - 15}" y="{margin + 50}" fill="{axis_label_color}" '
f'font-size="11" text-anchor="start" transform="rotate(-90 {margin-15} {margin+50})">{top_label}</text>'
)
# 绘制项目点
for item in items:
name = item.get("name", "")
x_ratio = item.get("x", 0.5)
y_ratio = item.get("y", 0.5)
color_type = item.get("color", "frontend")
size = item.get("size", 2) # 1-3
colors = svg.style["colors"]
fill, stroke = colors.get(color_type, colors["frontend"])
# 计算实际坐标(y需要翻转,因为SVG y向下)
x_pos = margin + x_ratio * chart_w
y_pos = (center_y + chart_h//2) - y_ratio * chart_h
radius = 8 + size * 4 # 16-32px
# 绘制圆点
svg.elements.append(
f'<circle cx="{x_pos}" cy="{y_pos}" r="{radius}" '
f'fill="{fill}" stroke="{stroke}" stroke-width="2"/>'
)
# 标签(点上或点旁)
svg.add_text(x_pos, y_pos - radius - 8, name,
fill="#e2e8f0", font_size=11,
font_weight="500", text_anchor="middle")
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def create_swimlane(self, title: str,
lanes: List[Dict],
steps: List[Dict],
output: str,
width: int = 1000,
format: str = "html") -> str:
"""创建泳道图(跨职能流程图)
Args:
lanes: [{"name": "前端", "color": "frontend"}, ...]
steps: [{"lane": 0, "step": 0, "label": "...", "type": "process|decision"}, ...]
lane: 泳道索引(从0开始)
step: 步骤顺序(从左到右,从0开始)
"""
lane_height = 100
step_width = 180
margin = 60
max_step = max([s.get("step", 0) for s in steps]) if steps else 0
height = margin * 2 + len(lanes) * lane_height
width = max(width, margin * 2 + (max_step + 2) * step_width)
svg = SVGBuilder(width, height, style=self.style)
# 绘制泳道
for i, lane in enumerate(lanes):
y = margin + i * lane_height
name = lane.get("name", f"Lane {i}")
color_type = lane.get("color", "generic")
colors = svg.style["colors"]
fill, stroke = colors.get(color_type, colors["generic"])
# 泳道背景
svg.add_rect(margin, y, width - margin * 2, lane_height - 2,
svg.style["container"]["fill"], stroke, rx=0)
# 泳道标签(左侧)
svg.add_rect(margin, y + 10, 100, lane_height - 22,
fill, stroke, rx=4)
svg.add_text(margin + 50, y + lane_height//2 + 4, name,
fill=svg.style["text"]["primary"], font_size=12,
font_weight="600", text_anchor="middle")
# 绘制步骤
for step in steps:
lane_idx = step.get("lane", 0)
step_idx = step.get("step", 0)
label = step.get("label", "")
step_type = step.get("type", "process")
y = margin + lane_idx * lane_height + lane_height // 2
x = margin + 130 + step_idx * step_width
if step_type == "decision":
# 菱形
svg.add_diamond(x - 30, y - 25, 60, label)
else:
# 矩形
svg.add_component_box(x - 60, y - 25, 120, 50, label, "generic")
# 绘制连接线(同泳道或跨泳道)
for step_idx in range(max_step + 1):
current_steps = [s for s in steps if s.get("step", 0) == step_idx]
next_steps = [s for s in steps if s.get("step", 0) == step_idx + 1]
for curr in current_steps:
for nxt in next_steps:
y1 = margin + curr["lane"] * lane_height + lane_height // 2
x1 = margin + 130 + step_idx * step_width + 60
y2 = margin + nxt["lane"] * lane_height + lane_height // 2
x2 = margin + 130 + (step_idx + 1) * step_width - 60
style = "solid" if curr["lane"] == nxt["lane"] else "dashed"
svg.add_connection(x1, y1, x2, y2, style, svg.style["arrow"]["default"])
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def create_timeline(self, title: str,
events: List[Dict],
output: str,
width: int = 900,
orientation: str = "horizontal",
format: str = "html") -> str:
"""创建时间线图
Args:
events: [{"date": "2024-Q1", "label": "...", "color": "...", "side": "top|bottom"}, ...]
orientation: "horizontal" 或 "vertical"
"""
if orientation == "horizontal":
return self._create_timeline_horizontal(title, events, output, width, format)
else:
return self._create_timeline_vertical(title, events, output, width, format)
def _create_timeline_horizontal(self, title: str, events: List[Dict],
output: str, width: int, format: str = "html") -> str:
"""水平时间线"""
margin = 80
height = 400
timeline_y = height // 2
svg = SVGBuilder(width, height, style=self.style)
# 主线
main_color = svg.style["arrow"]["cyan"]
svg.elements.append(
f'<line x1="{margin}" y1="{timeline_y}" x2="{width - margin}" y2="{timeline_y}" '
f'stroke="{main_color}" stroke-width="3"/>'
)
# 事件节点
spacing = (width - margin * 2) // (len(events) + 1)
for i, event in enumerate(events):
x = margin + (i + 1) * spacing
date = event.get("date", "")
label = event.get("label", "")
color_type = event.get("color", "backend")
side = event.get("side", "top" if i % 2 == 0 else "bottom")
colors = svg.style["colors"]
fill, stroke = colors.get(color_type, colors["backend"])
# 节点圆点
svg.elements.append(
f'<circle cx="{x}" cy="{timeline_y}" r="8" '
f'fill="{stroke}" stroke="{fill}" stroke-width="2"/>'
)
# 连接线
line_length = 40
if side == "top":
y_end = timeline_y - line_length
svg.elements.append(
f'<line x1="{x}" y1="{timeline_y - 8}" x2="{x}" y2="{y_end}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self._add_timeline_card(svg, x, y_end - 50, date, label, fill, stroke, "top")
else:
y_end = timeline_y + line_length
svg.elements.append(
f'<line x1="{x}" y1="{timeline_y + 8}" x2="{x}" y2="{y_end}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self._add_timeline_card(svg, x, y_end + 10, date, label, fill, stroke, "bottom")
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def _create_timeline_vertical(self, title: str, events: List[Dict],
output: str, width: int, format: str = "html") -> str:
"""垂直时间线"""
margin = 80
event_height = 100
height = margin * 2 + len(events) * event_height
timeline_x = width // 2
svg = SVGBuilder(width, height, style=self.style)
# 主线
main_color = svg.style["arrow"]["cyan"]
svg.elements.append(
f'<line x1="{timeline_x}" y1="{margin}" x2="{timeline_x}" y2="{height - margin}" '
f'stroke="{main_color}" stroke-width="3"/>'
)
for i, event in enumerate(events):
y = margin + 30 + i * event_height
date = event.get("date", "")
label = event.get("label", "")
color_type = event.get("color", "backend")
colors = svg.style["colors"]
fill, stroke = colors.get(color_type, colors["backend"])
# 节点
svg.elements.append(
f'<circle cx="{timeline_x}" cy="{y}" r="8" '
f'fill="{stroke}" stroke="{fill}" stroke-width="2"/>'
)
# 交替左右显示
if i % 2 == 0:
x_card = timeline_x - 200
svg.elements.append(
f'<line x1="{timeline_x - 8}" y1="{y}" x2="{x_card + 150}" y2="{y}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self._add_timeline_card(svg, x_card + 75, y - 25, date, label, fill, stroke, "right")
else:
x_card = timeline_x + 50
svg.elements.append(
f'<line x1="{timeline_x + 8}" y1="{y}" x2="{x_card}" y2="{y}" '
f'stroke="{stroke}" stroke-width="1.5"/>'
)
self._add_timeline_card(svg, x_card + 75, y - 25, date, label, fill, stroke, "left")
svg_content = svg.build()
html = self._wrap_html(title, svg_content)
return self._save(title, svg_content, output, format)
def _add_timeline_card(self, svg: SVGBuilder, x: int, y: int,
date: str, label: str, fill: str, stroke: str, align: str):
"""添加时间线事件卡片"""
card_w, card_h = 150, 50
# 背景
svg.add_rect(x - card_w//2, y, card_w, card_h,
"rgba(15, 23, 42, 0.9)", stroke, rx=6)
# 日期
svg.add_text(x, y + 18, date, fill=stroke,
font_size=10, font_weight="600", text_anchor="middle")
# 标签
svg.add_text(x, y + 38, label, fill="#e2e8f0",
font_size=11, text_anchor="middle")
# ============ Markdown / Mermaid 支持 ============
def to_mermaid(title: str, layers: List[Dict]) -> str:
"""生成 Mermaid 流程图语法
可直接粘贴到 Markdown 中:
```markdown
```mermaid
{生成的代码}
```
```
"""
lines = ["graph TD"]
# 定义节点样式类
class_defs = [
" classDef frontend fill:#083344,stroke:#22d3ee,stroke-width:2px,color:#fff",
" classDef backend fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#fff",
" classDef database fill:#4c1d95,stroke:#a78bfa,stroke-width:2px,color:#fff",
" classDef cloud fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#fff",
" classDef security fill:#881337,stroke:#fb7185,stroke-width:2px,color:#fff",
" classDef generic fill:#1e293b,stroke:#94a3b8,stroke-width:2px,color:#fff",
]
node_id = 0
prev_nodes = []
for layer in layers:
layer_name = layer.get("name", "")
items = layer.get("items", [])
color = layer.get("color", "generic")
current_nodes = []
for item in items:
node_id += 1
safe_id = f"N{node_id}"
# 转义特殊字符
safe_label = item.replace("(", "[").replace(")", "]").replace(" ", "_")
lines.append(f' {safe_id}["{item}"]')
lines.append(f' class {safe_id} {color}')
current_nodes.append(safe_id)
# 连接上一层的节点
if prev_nodes and current_nodes:
for prev in prev_nodes:
for curr in current_nodes:
lines.append(f' {prev} --> {curr}')
prev_nodes = current_nodes
# 添加样式定义
lines.extend(class_defs)
return "\n".join(lines)
def to_svg_only(title: str, layers: List[Dict]) -> str:
"""生成纯 SVG 代码(可嵌入 Markdown HTML 块)
使用方式:
```markdown
<div align="center">
{生成的 SVG}
</div>
```
"""
gen = DiagramGenerator()
components = []
connections = []
y_pos = 50
node_id = 0
for layer in layers:
color = layer.get("color", "generic")
items = layer.get("items", [])
total_width = len(items) * 140 - 20
start_x = max(50, (800 - total_width) // 2)
for j, item in enumerate(items):
node_id += 1
comp = Component(
name=item,
type=color,
x=start_x + j * 140,
y=y_pos,
width=120,
height=45
)
components.append(comp)
y_pos += 100
# 计算 SVG 尺寸
max_x = max([c.x + c.width for c in components]) if components else 600
max_y = max([c.y + c.height for c in components]) if components else 300
svg = SVGBuilder(max_x + 100, max_y + 50)
# 添加组件
for comp in components:
svg.add_component_box(
comp.x, comp.y, comp.width, comp.height,
comp.name, comp.type
)
# 添加层间连线
layer_items = {}
for comp in components:
# 按 y 坐标分组
y_key = comp.y
if y_key not in layer_items:
layer_items[y_key] = []
layer_items[y_key].append(comp)
sorted_layers = sorted(layer_items.keys())
for i in range(len(sorted_layers) - 1):
current_layer = layer_items[sorted_layers[i]]
next_layer = layer_items[sorted_layers[i + 1]]
for curr in current_layer:
for next_c in next_layer:
x1 = curr.x + curr.width // 2
y1 = curr.y + curr.height
x2 = next_c.x + next_c.width // 2
y2 = next_c.y
svg.add_line(x1, y1, x2, y2)
return svg.build()
class MarkdownDiagramHelper:
"""Markdown 图表辅助类 - 一键生成多种格式"""
def __init__(self, title: str, layers: List[Dict]):
self.title = title
self.layers = layers
def mermaid_code_block(self) -> str:
"""生成带 markdown 代码块的 Mermaid"""
mermaid = to_mermaid(self.title, self.layers)
return f"```mermaid\n{mermaid}\n```"
def html_embedded(self) -> str:
"""生成可嵌入 Markdown 的 HTML"""
svg = to_svg_only(self.title, self.layers)
return f'''<details>
<summary>📊 {self.title} 架构图</summary>
<div align="center">
{svg}
</div>
</details>'''
def markdown_table(self) -> str:
"""生成架构表格(纯文本,最通用)"""
lines = [f"### {self.title} 架构", ""]
lines.append("| 层级 | 组件 | 类型 |")
lines.append("|------|------|------|")
type_names = {
"frontend": "前端", "backend": "后端", "database": "数据库",
"cloud": "云服务", "security": "安全", "generic": "通用"
}
for layer in self.layers:
layer_name = layer.get("name", "")
items = layer.get("items", [])
color = layer.get("color", "generic")
type_name = type_names.get(color, "通用")
for item in items:
lines.append(f"| {layer_name} | {item} | {type_name} |")
return "\n".join(lines)
def full_document(self) -> str:
"""生成完整的设计文档片段"""
parts = [
f"## {self.title}",
"",
self.markdown_table(),
"",
"### 架构图",
"",
self.mermaid_code_block(),
"",
"### 详细视图",
"",
self.html_embedded(),
]
return "\n".join(parts)
# ============ 便捷函数 ============
def quick_architecture(title: str, layers: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str:
"""快速创建分层架构图
layers: [{"name": "Layer", "items": ["Item1", "Item2"], "color": "frontend"}]
"""
gen = DiagramGenerator(style=style)
components = []
connections = []
cards = []
y_pos = 80
colors = ["frontend", "backend", "database", "cloud"]
for i, layer in enumerate(layers):
layer_color = layer.get("color", colors[i % len(colors)])
items = layer.get("items", [])
# 计算布局
total_width = len(items) * 160 - 40
start_x = (1000 - total_width) // 2
layer_comps = []
display_items = []
for j, item in enumerate(items):
shape = ""
if isinstance(item, dict):
name = item.get("label", "")
color = item.get("color", layer_color)
shape = item.get("shape", "")
display_items.append(name)
else:
name = item
color = layer_color
display_items.append(name)
comp = Component(
name=name,
type=color,
x=start_x + j * 160,
y=y_pos,
width=120,
height=50,
shape=shape
)
components.append(comp)
layer_comps.append(comp)
cards.append({
"title": layer.get("name", f"Layer {i+1}"),
"color": layer_color,
"items": display_items
})
y_pos += 120
# 添加层间连接
for i in range(len(components) - 1):
if i < len(components) - 1:
connections.append(Connection(
components[i].name, components[i+1].name
))
fmt = format
return gen.create_architecture(title, components, connections, output,
cards=cards, auto_layout=False, format=fmt)
def quick_pyramid(title: str, levels: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str:
"""快速创建金字塔图
levels: [{"label": "...", "value": "...", "color": "frontend|backend|..."}]
"""
gen = DiagramGenerator(style=style)
return gen.create_pyramid(title, levels, output, format=format)
def quick_quadrant(title: str,
x_axis: Tuple[str, str],
y_axis: Tuple[str, str],
items: List[Dict],
output: str, style: str = "sketch", format: str = "html") -> str:
"""快速创建象限图
x_axis: (左, 右) 如 ("低成本", "高成本")
y_axis: (下, 上) 如 ("低影响", "高影响")
items: [{"name": "...", "x": 0-1, "y": 0-1, "color": "...", "size": 1-3}]
"""
gen = DiagramGenerator(style=style)
return gen.create_quadrant(title, x_axis, y_axis, items, output, format=format)
def quick_swimlane(title: str, lanes: List[Dict], steps: List[Dict], output: str, style: str = "sketch", format: str = "html") -> str:
"""快速创建泳道图
lanes: [{"name": "前端", "color": "frontend"}]
steps: [{"lane": 0, "step": 0, "label": "...", "type": "process|decision"}]
"""
gen = DiagramGenerator(style=style)
return gen.create_swimlane(title, lanes, steps, output, format=format)
def quick_timeline(title: str, events: List[Dict], output: str,
orientation: str = "horizontal", style: str = "sketch", format: str = "html") -> str:
"""快速创建时间线
events: [{"date": "...", "label": "...", "color": "...", "side": "top|bottom"}]
orientation: "horizontal" 或 "vertical"
"""
gen = DiagramGenerator(style=style)
return gen.create_timeline(title, events, output, orientation=orientation, format=format)
if __name__ == "__main__":
import os
os.makedirs("/tmp/diagram_tests", exist_ok=True)
# 测试金字塔
quick_pyramid(
"需求优先级金字塔",
[
{"label": "必须有 (Must have)", "value": "20%", "color": "security"},
{"label": "应该有 (Should have)", "value": "30%", "color": "frontend"},
{"label": "可以有 (Could have)", "value": "35%", "color": "backend"},
{"label": "暂不需要 (Won't have)", "value": "15%", "color": "generic"},
],
"/tmp/diagram_tests/pyramid.html"
)
print("Generated: /tmp/diagram_tests/pyramid.html")
# 测试象限
quick_quadrant(
"项目优先级矩阵",
("低影响", "高影响"),
("低成本", "高成本"),
[
{"name": "快速获胜", "x": 0.2, "y": 0.8, "color": "backend", "size": 3},
{"name": "重大项目", "x": 0.8, "y": 0.9, "color": "security", "size": 3},
{"name": "填补空白", "x": 0.3, "y": 0.2, "color": "generic", "size": 2},
{"name": "避坑", "x": 0.9, "y": 0.1, "color": "cloud", "size": 1},
],
"/tmp/diagram_tests/quadrant.html"
)
print("Generated: /tmp/diagram_tests/quadrant.html")
# 测试泳道
quick_swimlane(
"用户注册流程",
[
{"name": "前端", "color": "frontend"},
{"name": "后端", "color": "backend"},
{"name": "外部", "color": "cloud"},
],
[
{"lane": 0, "step": 0, "label": "填写表单", "type": "process"},
{"lane": 0, "step": 1, "label": "提交", "type": "process"},
{"lane": 1, "step": 1, "label": "验证", "type": "decision"},
{"lane": 1, "step": 2, "label": "创建用户", "type": "process"},
{"lane": 2, "step": 2, "label": "发送邮件", "type": "process"},
{"lane": 0, "step": 3, "label": "成功页", "type": "process"},
],
"/tmp/diagram_tests/swimlane.html"
)
print("Generated: /tmp/diagram_tests/swimlane.html")
# 测试水平时间线
quick_timeline(
"产品路线图",
[
{"date": "Q1 2024", "label": "MVP 发布", "color": "backend", "side": "top"},
{"date": "Q2 2024", "label": "用户增长", "color": "frontend", "side": "bottom"},
{"date": "Q3 2024", "label": "企业版", "color": "security", "side": "top"},
{"date": "Q4 2024", "label": "国际化", "color": "cloud", "side": "bottom"},
],
"/tmp/diagram_tests/timeline_h.html"
)
print("Generated: /tmp/diagram_tests/timeline_h.html")
# 测试垂直时间线
quick_timeline(
"开发里程碑",
[
{"date": "3月", "label": "架构设计", "color": "backend"},
{"date": "4月", "label": "核心开发", "color": "frontend"},
{"date": "5月", "label": "测试上线", "color": "security"},
],
"/tmp/diagram_tests/timeline_v.html",
orientation="vertical"
)
print("Generated: /tmp/diagram_tests/timeline_v.html")
print("\n所有测试图表已生成到 /tmp/diagram_tests/")