Files
STM32F4-Base/tools/arch_diagram/gen_arch_endtoend_svg.py
T
wandering1 045ca7f688 docs(配图): 端到端链路鸟瞰进生成器体系,补大图碰撞审计判据
把 archify 出的交互架构图落回仓内可复现形态,避免 docs/ 里出现「没有生成器的
SVG」:

- 新生成器 tools/arch_diagram/gen_arch_endtoend_svg.py → docs/images/架构总览_
  端到端链路.svg(42 框 / 37 边 / 5 条纵带,线型分数据·命令·屏障心跳三类);
  数据全部内嵌在 .py 里(generated-fresh 只把单个 gen_*.py 拷进临时目录重跑,
  外部 JSON 输入会 rc≠0 假红),SVG 内不含时间戳/commit/绝对路径
- docs/架构设计/项目架构总览.md 新增 §1.3 挂图并写清与 §1 分层图的分工
  (那张答「谁在哪一层」,这张答「一条数据走到底穿过谁」;纵带只是列分组,
  依赖方向仍以 §1.1 表与 layer-deps 门禁为准),版本 v1.2 → v1.3
- 两处生成器清单登记:tools/arch_diagram/README.md 脚本一览、
  docs/架构设计/图形绘制流程.md 的选型表与产物对照表
- draw-diagram skill 新增 §三b(大图碰撞审计判据 + 两条几何纪律)+
  reference/svg-collision-audit.py(拷进 ci-artifacts 用,刻意不进 tools/)

本窗实测(都已写进 skill):42 框 37 边的图缩略目检看着干净,解析式审计一眼
报出 23 条——边标签文字盒压到节点框会被后画的节点白底吃掉半个字(「串行执行」
渲染成「行执行」);根因是同行两框走了「同轴加一段走廊」的通用规则,产出退化
四点折线,把标签落点推到第一半中点。修法:同轴且已对齐直接返回两点线,标签
落点改按总弧长取 50%。

零余量第二遍(用生成器自己的 Box/route/label_at 真值扫,框不膨胀不内缩)又抓到
两处近失:「TRGO 触发」「帧 B 下发」与相邻框只剩 0.7px / 1.5px,缩成「TRGO」
「帧 B」后最紧余量升到 3.5px;同一遍还纠正了审计器自己的错——`hi > y0` 误写成
`hi > x0` 凭空报 3 条穿框,差点重排三条边的走线,所以判据里加了「先验审计器
本身再信 0 条」。

自查:连跑两次产物 sha1 不变;scripts/check_generated_fresh.py rc=0(25 生成器
/ 21 一致 / 4 在只减不增基线内);碰撞审计 rc=0(0 条);生成器无 OVERFLOW;
doc-links --sections-strict 逐篇 rc=0;mermaid-syntax / nested-comment /
skill-size / bom-drift 无新发;改动文件字节体检干净;/keil-build 0 错 0 警。
2026-10-30 15:59:24 +08:00

430 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 docs/images/架构总览_端到端链路.svg —— 端到端链路鸟瞰(纯标准库)。
这张图回答什么问题:一个电气量从芯片引脚走到 104 报文 / 显存 / 记录文件,
中间要穿过哪些框;以及「谁在什么时候被谁唤醒、谁独占写、谁只是同一份代码的
另一颗脑袋」——即**数据流与控制握手**,不是层间依赖清单。
与同目录另两张总览图的分工:
gen_arch_svg.py → 架构分层图:六层 + 每层有哪些模块(回答「谁属于哪一层」)
gen_barrier_svg.py → 就绪屏障图:Event Flags 模型细节
本脚本 → 端到端链路:片外硬件 → 驱动 → 算法 → 任务 → 落点 → 对外/同构端
线型三类:实线 = 数据与读写;短虚线 = 命令 / 请求 / 配置;紫点线 = 屏障位、心跳与同源镜像。
为什么不用 Mermaid:本图 42 框 37 边,含跨行长边(遥测快照 → 主机侧第三端独占一条行通道)、
同列相邻框之间的并行双线(定值下行 / YK 上行各占一个出框位置)、绕底槽的握手边
(就绪屏障 → 控制台要跨过整行),还要把五条分层纵带画成等宽对齐的框。
Mermaid 自动布局既不可控(多节点跨层依赖会画成"意大利面"),也表达不了
「一条边独占一条通道」这类判据。
用法(仓库根目录,任意 python3):
python tools/arch_diagram/gen_arch_endtoend_svg.py
输出:
docs/images/架构总览_端到端链路.svg
改内容:编辑 NODES / EDGES / BANDS / 文案常量后重跑。
调坐标:先改顶部几何常量(CW / CH / PX / PY 与三条独占通道 Y、一条走廊 X),
单边微调走 EDGES 尾部旋钮 f / g(出框、入框位置 0~1)与 my / gx(独占通道坐标)。
产物内容零不确定:**不得**写入时间戳 / 提交号 / 绝对路径(generated-fresh 逐字节比对)。
"""
import html
import os
import sys
import xml.etree.ElementTree as ET
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "docs", "images", "架构总览_端到端链路.svg")
# ----------------------------- 画布与几何 -----------------------------
PAD_L = 34
PAD_R = 30
TITLE_H = 74
LEGEND_H = 50
FOOT_GAP = 14
CW = 148
CH = 58
COLS = 6
ROWS = 9
PX = 196
PY = 92
BAND_PAD = 13
BAND_TITLE_H = 30
BAND_BOTTOM_PAD = 24 # 带内底部专用走廊(第 8 行下方绕行的握手边走这里)
GRID_W = (COLS - 1) * PX + CW
W = PAD_L + GRID_W + PAD_R
X0 = PAD_L + BAND_PAD
Y0 = TITLE_H + 30 + BAND_PAD + BAND_TITLE_H
H = Y0 + (ROWS - 1) * PY + CH + BAND_PAD + BAND_BOTTOM_PAD + FOOT_GAP + LEGEND_H
# 独占通道 / 走廊(长边走这里;数值落在通道内,改动后回看有无重叠)
CHAN_B = Y0 + 2 * PY + CH + 11 # 第 2、3 行之间(遥测快照 → 主机侧第三端)
CHAN_B2 = Y0 + 2 * PY + CH + 24 # 同一条走廊的下半股(遥测快照 → iec104Task,两条平行不共线)
CHAN_78 = Y0 + 7 * PY + CH + 12 # 第 7、8 行之间(RS-485 → 屏板链路)
CHAN_SLOTS = Y0 + 8 * PY + CH + 13 # 第 8 行下方底槽(就绪屏障 → 控制台,带内独占)
GUT_C4C5 = X0 + 4 * PX + CW + (PX - CW) * 0.62 # 第 4、5 列之间垂直走廊(配置引擎 → 落点)
FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
TITLE_FONT = 20
SUB_FONT = 12.5
BAND_FONT = 13.5
BAND_SUB_FONT = 10.5
NODE_FONT = 11.5
NODE_SUB_FONT = 9.5
EDGE_FONT = 9.5
FOOT_FONT = 9.5
# 调色板(沿用 docs/架构设计/图形绘制流程.md;紫色整族让给「屏障 / 事件」,纵带改用玫红)
C_HW = ("#475569", "#f1f5f9")
C_DRV = ("#d97706", "#fffbeb")
C_MID = ("#16a34a", "#f0fdf4")
C_TASK = ("#0d9488", "#f0fdfa")
C_SVC = ("#e11d48", "#fff1f2")
C_EXT = ("#2563eb", "#eff6ff")
E_DATA = "#334155"
E_CTRL = "#64748b"
E_EVT = "#7c3aed"
GRID_LINE = "#cbd5e1"
TITLE = "STM32F407ZGTx 工业 DTU 平台 · 端到端链路总览"
SUBTITLE = ("一个电气量从引脚到 104 报文 / 显存 / 记录文件穿过的框,与「谁在什么时候被谁唤醒」;"
"分层归属与层内模块清单见 项目架构总览.svg,本图只画链路与握手")
FOOTNOTE = ("口径:AGENTS.md「数据主线」+ docs/架构设计/项目架构总览.md(行号级证据在那份文档与代码里)。"
"RS-485 采集未接线:rs485_task 有实现但不建任务,接线后数据进语义取数层;"
"其余行为条件(未标定闭锁 / 就绪位 NoClear / CH395F 单连接 / V0.1 双转换器无共同采样触发)见正文,"
"它们只改变判据与端口、不改变本图的框与连线。")
# ----------------------------- 纵带(一列 = 一条带) -----------------------------
BANDS = [
(0, "片外硬件", "板级器件 · 不走软件分层", C_HW),
(1, "驱动层 Drivers/BSP", "手写芯片驱动与总线", C_DRV),
(2, "中间件与算法", "取数 → 相量 → 快照", C_MID),
(3, "任务编排与握手", "App/task · 就绪屏障", C_TASK),
(4, "平台服务与数据落点", "记录 / 配置 / 网络门面", C_SVC),
]
# ----------------------------- 节点:(id, 列, 行, 标题, 副题) -----------------------------
NODES = [
("tpafe", 0, 0, "TPAFE5160 前端", "并行总线 · 8 路"),
("adc1", 0, 1, "片内 ADC1 + DMA", "TIM3 TRGO 乒乓"),
("eth", 0, 4, "CH395F 以太网", "SPI 串行化"),
("nandchip", 0, 5, "GD5F SPI NAND", "2 KB 页 · ECC"),
("rtc", 0, 6, "SD2506 RTC", "I2C1 硬件亚秒"),
("panel", 0, 7, "屏板 160×160", "USART1 700000"),
("rs485hw", 0, 8, "RS-485 总线", "UART4 · 未接线(暂缓)"),
("drv_tpafe", 1, 0, "TPAFE 驱动", "原子快照 snapshot"),
("drv_adc", 1, 1, "ADC · TIM3 · DMA", "手写上电自校准"),
("drv_ch395", 1, 4, "CH395F 驱动", "中断集中读清"),
("drv_nand", 1, 5, "NAND 驱动 + FTL", "ECC · BBT"),
("drv_rtc", 1, 6, "RTC 驱动 + 墙钟", "sys_clock epoch"),
("sb_link", 1, 7, "屏板链路驱动", "借道 RS485"),
("console", 1, 8, "控制台 UART5", "4 KB 环形"),
("adc_input", 2, 0, "语义取数层", "有效性 · 标定"),
("phasor", 2, 1, "全周傅氏相量", "ring → phasor"),
("meas", 2, 2, "遥测快照双区", "测频 200 ms"),
("netmw", 2, 4, "NET 中间件", "socket · 挂起"),
("fs", 2, 5, "FTL + FatFs", "dhara · 8.3 短名"),
("timeproto", 2, 6, "对时协议", "推送写 RTC"),
("lvgl", 2, 7, "LVGL + ui_port", "160×160 1bpp"),
("selfheal", 2, 8, "IWDG + Fault", "心跳掩码"),
("adc_task", 3, 0, "adcTask", "1 kHz 消费者"),
("protect_task", 3, 1, "protectTask", "20 ms 判据"),
("protset", 3, 2, "定值层", "prot_set 校验"),
("iec104_task", 3, 3, "iec104Task", "100 ms · :2404"),
("net_task", 3, 4, "netTask", "MsgQueue 深 8"),
("default_task", 3, 5, "defaultTask", "记录唯一写者"),
("time_sync_task", 3, 6, "timeSyncTask", "等 NET_READY"),
("ui_task", 3, 7, "uiTask", "唯一写侧"),
("barrier", 3, 8, "就绪屏障", "位组 · NoClear"),
("soe", 4, 1, "SOE 事件", "soe.log 定长"),
("setfile", 4, 2, "定值落盘", "/prot_set.bin"),
("cfgplane", 4, 3, "配置引擎", "net_cfg · ftp_cfg"),
("services", 4, 4, "TCP 服务面", ":21 / :2404"),
("files", 4, 5, "文件系统落点", "/log · /wave"),
("vollock", 4, 6, "卷锁 + 账本", "三路 try 协定"),
("disp", 4, 7, "一份显存三出", "屏板 · :7003"),
("ftp_task", 4, 8, "ftpTask", "lftpd 根 :21"),
("hosttest", 5, 2, "主机侧第三端", "直打生产 .c"),
("peer", 5, 3, "上位侧对端", "104 主站"),
("sim", 5, 7, "PC 模拟器", "SDL2 同源码"),
]
# ----------------------------- 边 -----------------------------
# (起点, 终点, 标签, 类别, 出框, 入框, 旋钮)
# 类别 d=数据/读写 c=命令/请求/配置 e=屏障·心跳·同源
# 出/入框 t b l r;出框轴与入框轴同向时自动加一段(水平用 gx,垂直用 my)
# 旋钮 f/g = 该边上的位置 0~1(默认 0.5);my/gx = 独占通道坐标;seg/t = 标签落点
EDGES = [
("tpafe", "drv_tpafe", "8 路帧", "d", "r", "l", {}),
("drv_adc", "adc1", "TRGO", "c", "l", "r", {}),
("drv_tpafe", "adc_input", "原子快照", "d", "r", "l", {}),
("drv_adc", "adc_input", "DMA 双缓冲", "d", "t", "b", {"f": 0.72, "g": 0.28}),
("adc_input", "phasor", "语义通道", "d", "b", "t", {}),
("phasor", "meas", "相量", "d", "b", "t", {}),
("adc_task", "protect_task", "1 kHz → 20 ms", "c", "b", "t", {}),
("meas", "iec104_task", "同一上游", "d", "b", "t", {"f": 0.3, "g": 0.5, "my": CHAN_B2}),
("protset", "protect_task", "定值", "d", "t", "b", {"f": 0.34, "g": 0.34}),
("iec104_task", "protset", "YK 请求", "c", "t", "b", {"f": 0.68, "g": 0.68}),
("iec104_task", "cfgplane", "脏段", "c", "r", "l", {}),
("protect_task", "soe", "边沿投递", "d", "r", "l", {}),
("default_task", "files", "唯一写者", "d", "r", "l", {}),
("default_task", "vollock", "try 语义", "c", "b", "t", {"f": 0.86, "g": 0.26}),
("eth", "drv_ch395", "SPI 事务", "d", "r", "l", {}),
("drv_ch395", "netmw", "集中读清", "d", "r", "l", {}),
("netmw", "net_task", "串行执行", "d", "r", "l", {}),
("net_task", "services", "单连接", "d", "r", "l", {}),
("services", "peer", "TCP 对端", "c", "r", "b", {"f": 0.26, "g": 0.6}),
("nandchip", "drv_nand", "页读写", "d", "r", "l", {}),
("drv_nand", "fs", "逻辑块", "d", "r", "l", {}),
("fs", "default_task", "挂载", "d", "r", "l", {}),
("rtc", "drv_rtc", "I2C1", "d", "r", "l", {}),
("drv_rtc", "timeproto", "墙钟", "d", "r", "l", {}),
("time_sync_task", "timeproto", "收包对时", "d", "l", "r", {}),
("lvgl", "sb_link", "帧 B", "d", "l", "r", {}),
("ui_task", "lvgl", "灌数", "d", "l", "r", {}),
("ui_task", "disp", "同一显存", "d", "r", "l", {}),
("sb_link", "panel", "700000", "d", "l", "r", {}),
("ui_task", "selfheal", "100 ms 喂心跳", "c", "b", "t", {"f": 0.26, "g": 0.32, "my": CHAN_78}),
("ui_task", "barrier", "等就绪", "c", "b", "t", {"f": 0.62}),
("barrier", "ftp_task", "WaitAll", "d", "r", "l", {}),
("disp", "sim", "同源像素", "e", "r", "l", {}),
("meas", "hosttest", "纯逻辑直跑", "e", "b", "b", {"f": 0.82, "g": 0.62, "my": CHAN_B}),
("barrier", "console", "唤醒发送", "e", "b", "b", {"my": CHAN_SLOTS}),
("cfgplane", "files", "唯一落盘者", "d", "r", "r", {"f": 0.72, "g": 0.5, "gx": GUT_C4C5}),
("rs485hw", "sb_link", "回调宿主", "d", "t", "b", {"f": 0.28, "g": 0.28, "my": CHAN_78}),
]
OVERFLOW = []
def emit(s):
"""控制台打印按目标编码替换,绝不因 GBK 终端遇上 → / × 之类字符而崩
(generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。"""
enc = getattr(sys.stdout, "encoding", None) or "ascii"
sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n")
def esc(s):
return html.escape(s, quote=True)
def text_w(s, fs):
"""估宽:CJK / 全角 / 箭头 ≈ 1em,其余 ≈ 0.58em(宁大勿小)。"""
w = 0.0
for ch in s:
o = ord(ch)
if o > 0x2E80 or ch in "→×·…—":
w += 1.0
else:
w += 0.58
return w * fs
def check_fit(s, fs, avail, where):
got = text_w(s, fs)
if got > avail:
OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, got, avail, s))
def wrap(s, fs, avail, max_lines, where):
"""按估宽折行;超行数截断并加 … 记一条 OVERFLOW。"""
lines, cur = [], ""
for ch in s:
if cur and text_w(cur + ch, fs) > avail:
lines.append(cur)
cur = ch
else:
cur += ch
if cur:
lines.append(cur)
if len(lines) > max_lines:
lines = lines[:max_lines]
lines[-1] = lines[-1].rstrip()[:-1] + "…"
OVERFLOW.append("%s: 折行超 %d 行 [%s]" % (where, max_lines, s))
return lines
def rect(x, y, w, h, fill, stroke, rx=7, sw=1.2, opacity=1.0):
o = ' fill-opacity="%.2f"' % opacity if opacity != 1.0 else ""
return ('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%g" '
'fill="%s" stroke="%s" stroke-width="%g"%s/>' % (x, y, w, h, rx, fill, stroke, sw, o))
def txt(x, y, s, fs, fill, anchor="start", weight="normal", opacity=1.0):
o = ' fill-opacity="%.2f"' % opacity if opacity != 1.0 else ""
return ('<text x="%.1f" y="%.1f" font-family="%s" font-size="%g" fill="%s" '
'text-anchor="%s" font-weight="%s"%s>%s</text>'
% (x, y, FONT, fs, fill, anchor, weight, o, esc(s)))
ECOL = {"d": E_DATA, "c": E_CTRL, "e": E_EVT}
EDASH = {"d": None, "c": "5 3", "e": "1.5 3"}
def poly(points, cls):
d = " ".join(("M" if i == 0 else "L") + "%.1f %.1f" % p for i, p in enumerate(points))
dash = EDASH[cls]
dd = ' stroke-dasharray="%s"' % dash if dash else ""
w = 1.35 if cls == "d" else 1.25
return ('<path d="%s" fill="none" stroke="%s" stroke-width="%g"%s '
'marker-end="url(#ar-%s)"/>' % (d, ECOL[cls], w, dd, cls))
def elabel(x, y, s, cls):
"""边标签:先铺白色底片,避免与线或其他标签叠字。"""
w = text_w(s, EDGE_FONT) + 7
h = EDGE_FONT + 5
return ('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="3" fill="#ffffff" '
'fill-opacity="0.94"/><text x="%.1f" y="%.1f" font-family="%s" font-size="%g" '
'fill="%s" text-anchor="middle">%s</text>'
% (x - w / 2, y - h / 2, w, h, x, y + EDGE_FONT * 0.36, FONT, EDGE_FONT,
ECOL[cls], esc(s)))
class Box(object):
def __init__(self, nid, col, row, label, sub):
self.id = nid
self.col = col
self.row = row
self.label = label
self.sub = sub
self.x = float(X0 + col * PX)
self.y = float(Y0 + row * PY)
self.w = float(CW)
self.h = float(CH)
def side(self, sd, f):
"""出框点:sd ∈ t b l r,f 沿该边位置(0 靠左/上,1 靠右/下)。"""
if sd == "t":
return (self.x + self.w * f, self.y)
if sd == "b":
return (self.x + self.w * f, self.y + self.h)
if sd == "l":
return (self.x, self.y + self.h * f)
return (self.x + self.w, self.y + self.h * f)
def route(a, b, ex, en, k):
"""折线点集:出框轴与入框轴垂直 → 3 点;同轴 → 4 点(加一段 gx 或 my)。"""
f = k.get("f", 0.5)
g = k.get("g", 0.5)
pa = a.side(ex, f)
pb = b.side(en, g)
ax = ex in "lr"
bx = en in "lr"
if ax != bx:
if ax: # 水平出、垂直入
return [pa, (pb[0], pa[1]), pb]
return [pa, (pa[0], pb[1]), pb]
if ax: # 都水平
if abs(pa[1] - pb[1]) < 0.6:
return [pa, pb] # 同一行:直连(别插退化折点,否则标签落点偏半格)
gx = k.get("gx", (pa[0] + pb[0]) / 2)
return [pa, (gx, pa[1]), (gx, pb[1]), pb]
if abs(pa[0] - pb[0]) < 0.6: # 同一列:直连
return [pa, pb]
my = k.get("my", (pa[1] + pb[1]) / 2) # 都垂直:走 my 水平通道
return [pa, (pa[0], my), (pb[0], my), pb]
def label_at(points, k):
"""标签落点:默认整条折线的弧长中点(不会退化成「第一半的中点」);
seg = 指定段序号、t = 该段(或整条线)上的比例。"""
if "seg" in k:
i = k["seg"]
t = k.get("t", 0.5)
x0, y0 = points[i]
x1, y1 = points[i + 1]
return (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)
lens = [abs(points[i + 1][0] - points[i][0]) + abs(points[i + 1][1] - points[i][1])
for i in range(len(points) - 1)]
total = sum(lens) or 1.0
want = total * k.get("t", 0.5)
acc = 0.0
for i, L in enumerate(lens):
if acc + L >= want or i == len(lens) - 1:
u = 0.5 if L <= 0 else min(1.0, max(0.0, (want - acc) / L))
x0, y0 = points[i]
x1, y1 = points[i + 1]
return (x0 + (x1 - x0) * u, y0 + (y1 - y0) * u)
acc += L
return points[0]
def build():
boxes = [Box(*n) for n in NODES]
nodes = dict((b.id, b) for b in boxes)
col_band = dict((c, cb) for c, _t, _s, cb in BANDS)
check_fit(TITLE, TITLE_FONT, W - PAD_L - PAD_R, "title")
p = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="%s">' % (W, H, W, H, FONT)]
defs = ['<defs>']
for cls in ("d", "c", "e"):
defs.append('<marker id="ar-%s" viewBox="0 0 10 10" refX="9" refY="5" '
'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
'<path d="M0 0 L10 5 L0 10 z" fill="%s"/></marker>' % (cls, ECOL[cls]))
defs.append("</defs>")
p.append("".join(defs))
p.append('<rect x="0" y="0" width="%d" height="%d" fill="#ffffff"/>' % (W, H))
p.append(txt(PAD_L, 34, TITLE, TITLE_FONT, "#0f172a", weight="bold"))
for i, ln in enumerate(wrap(SUBTITLE, SUB_FONT, W - PAD_L - PAD_R, 2, "subtitle")):
p.append(txt(PAD_L, 55 + i * 15, ln, SUB_FONT, "#475569"))
band_y = Y0 - BAND_PAD - BAND_TITLE_H
band_h = (ROWS - 1) * PY + CH + 2 * BAND_PAD + BAND_BOTTOM_PAD + BAND_TITLE_H
for col, title, sub, (fg, bg) in BANDS:
x = X0 + col * PX - BAND_PAD
w = CW + 2 * BAND_PAD
p.append(rect(x, band_y, w, band_h, bg, fg, rx=10, sw=1.1, opacity=0.5))
check_fit(title, BAND_FONT, w - 14, "band[%d]" % col)
p.append(txt(x + 10, band_y + 19, title, BAND_FONT, fg, weight="bold"))
for j, ln in enumerate(wrap(sub, BAND_SUB_FONT, w - 20, 1, "band-sub[%d]" % col)):
p.append(txt(x + 10, band_y + 33, ln, BAND_SUB_FONT, fg, opacity=0.85))
for src, dst, label, cls, ex, en, k in EDGES:
pts = route(nodes[src], nodes[dst], ex, en, k)
p.append(poly(pts, cls))
lx, ly = label_at(pts, k)
p.append(elabel(lx, ly, label, cls))
for b in boxes:
fg, _bg = col_band.get(b.col, C_EXT)
p.append(rect(b.x, b.y, b.w, b.h, "#ffffff", fg, rx=7, sw=1.3))
p.append(rect(b.x, b.y, 3.5, b.h, fg, fg, rx=1.75, sw=0))
check_fit(b.label, NODE_FONT, b.w - 16, "node[%s]" % b.id)
p.append(txt(b.x + 11, b.y + 24, b.label, NODE_FONT, "#0f172a", weight="bold"))
for j, ln in enumerate(wrap(b.sub, NODE_SUB_FONT, b.w - 18, 2, "node-sub[%s]" % b.id)):
p.append(txt(b.x + 11, b.y + 40 + j * 12, ln, NODE_SUB_FONT, "#475569"))
ly = H - LEGEND_H + 4
p.append('<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>'
% (PAD_L, band_y + band_h + 16, W - PAD_R, band_y + band_h + 16, GRID_LINE))
lx = PAD_L
for label, cls, kind in [("数据 / 读写", "d", None), ("命令 · 请求 · 配置", "c", "5 3"),
("屏障位 · 心跳 · 同源镜像", "e", "1.5 3")]:
dd = ' stroke-dasharray="%s"' % kind if kind else ""
p.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" '
'stroke-width="1.35"%s/>' % (lx, ly + 1, lx + 26, ly + 1, ECOL[cls], dd))
p.append(txt(lx + 31, ly + 5, label, FOOT_FONT, "#334155"))
lx += 31 + text_w(label, FOOT_FONT) + 24
for label, (fg, _bg) in [("片外硬件", C_HW), ("驱动", C_DRV), ("中间件与算法", C_MID),
("任务与握手", C_TASK), ("服务与落点", C_SVC), ("对外 / 同构端", C_EXT)]:
p.append(rect(lx, ly - 5, 11, 11, "#ffffff", fg, rx=2.5, sw=1.2))
p.append(txt(lx + 15, ly + 5, label, FOOT_FONT, "#334155"))
lx += 15 + text_w(label, FOOT_FONT) + 16
if lx > W - PAD_R:
OVERFLOW.append("legend: 图例排到 %.0f 超出右边界 %.0f" % (lx, W - PAD_R))
for j, ln in enumerate(wrap(FOOTNOTE, FOOT_FONT, W - PAD_L - PAD_R, 2, "footnote")):
p.append(txt(PAD_L, H - 26 + j * 12, ln, FOOT_FONT, "#64748b"))
p.append("</svg>")
svg = "\n".join(p) + "\n"
ET.fromstring(svg)
d = os.path.dirname(OUT)
if not os.path.isdir(d):
os.makedirs(d)
with open(OUT, "wb") as f:
f.write(svg.encode("utf-8"))
emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT))
if OVERFLOW:
for w in OVERFLOW:
emit("OVERFLOW WARN " + w)
emit("!! %d overflow warnings" % len(OVERFLOW))
if __name__ == "__main__":
build()