045ca7f688
把 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 警。
430 lines
20 KiB
Python
430 lines
20 KiB
Python
#!/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()
|