1774 lines
68 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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/")
|