b49e226815
- 新增 tools/arch_diagram/gen_menu_tree_svg.py → docs/images/菜单树总览 .svg:43 节点 = 9 菜单 + 34 叶,30 已绑页 / 4 未绑 / 16 叶带密码门, 最深 4 级;读数由脚本现算,不在文档里另抄一份 - 生成器内嵌 NODE_SRC(= ui_menu_tree.txt 的 43 行节点):在仓库里跑会 与真值逐行互验、不一致 exit 1;generated-fresh 在临时目录重跑读不到 真值 → 用内嵌表,输出逐字节一致(实测临时目录产物同 sha256) - 产品需求 §8.2 原先挂的是级联框显示几何图(画的是示例级联,从来不含 整棵树的节点),换成这张树图,并删掉「该图仍是 42 节点时代所画」的 补丁注 - 图形绘制流程新增 §2.1:登记 generated-fresh 扫不到的四张图 —— 两张 tools/refgraph 产物(引用关系图 / _模块,不在 gen_*.py 命名面里)+ 两张手画稿(级联框总览、级联框几何计算)。钩子只扫 tools/arch_diagram/gen_*.py,这些图改了不会有人报警;再多一张先说清 为什么不能用生成器 - arch_diagram README 脚本一览加行
459 lines
16 KiB
Python
459 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""生成 docs/images/菜单树总览.svg —— 菜单树的层级、绑页与密码门鸟瞰图。
|
||
|
||
数据源 = ui_menu_tree.txt(真值,tools/menu_gen/gen_menu.py 的输入)。本脚本内嵌同一张
|
||
节点表 NODE_SRC,两种运行环境都要有确定行为:
|
||
· 在仓库里跑:先与 ui_menu_tree.txt 的节点行逐行互验,不一致直接 exit 1 打印差异
|
||
—— 改了菜单树必须同步内嵌表,图才会跟着变(漂移摆在台面上,而不是让 SVG 悄悄失真);
|
||
· 在临时目录跑(pre-commit 的 generated-fresh 钩子把脚本拷出去重跑):读不到输入
|
||
文件 → 退回内嵌表 → 输出逐字节一致。
|
||
"""
|
||
import html
|
||
import pathlib
|
||
import sys
|
||
import xml.etree.ElementTree as ET
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||
TREE_FILE = ROOT / "ui_menu_tree.txt"
|
||
OUT = ROOT / "docs" / "images" / "菜单树总览.svg"
|
||
|
||
# ui_menu_tree.txt 的节点行(缩进 2 空格 = 一层;#P = 需密码;#page=x = 已绑页面模块)。
|
||
# 改菜单树 → 同步这张表 → 重跑本脚本 → 脚本与产物同一笔提交。
|
||
NODE_SRC = [
|
||
"主界面",
|
||
" 装置信息 #page=device_info",
|
||
" 实时数据",
|
||
" 交流量 #page=ac",
|
||
" 直流量 #page=dc",
|
||
" 遥信量 #page=yx",
|
||
" 参数定值",
|
||
" 整定",
|
||
" 参数 #P #page=view_param",
|
||
" 定值 #P #page=view_set",
|
||
" 控制字 #P #page=view_word",
|
||
" 软压板 #P #page=view_plate",
|
||
" 查看",
|
||
" 参数 #page=view_param",
|
||
" 定值 #page=view_set",
|
||
" 控制字 #page=view_word",
|
||
" 软压板 #page=view_plate",
|
||
" 三遥设置",
|
||
" 遥测死区 #P #page=deadband",
|
||
" 遥测系数 #P #page=calib",
|
||
" 遥信类型 #P",
|
||
" 遥信防抖 #P",
|
||
" 双点遥信 #P",
|
||
" 装置维护",
|
||
" 时钟设置 #P #page=clock",
|
||
" 强制复归 #P #page=reset",
|
||
" 手动录波 #P #page=wave_manual",
|
||
" 清除记录 #P #page=clear",
|
||
" 通讯参数",
|
||
" 通讯设置 #P #page=comm",
|
||
" 网口设置 #P #page=net_cfg",
|
||
" SNTP设置 #P",
|
||
" 记录查询",
|
||
" SOE记录 #page=soe",
|
||
" 事故记录 #page=accident",
|
||
" 操作记录 #page=oper",
|
||
" 保护告警 #page=alarm",
|
||
" 保护启动 #page=start",
|
||
" 遥控记录 #page=yk",
|
||
" 自检记录 #page=selftest",
|
||
" 运行记录 #page=runlog",
|
||
" 运行报告 #page=runreport",
|
||
" 录波回放 #page=wave_play",
|
||
]
|
||
|
||
# ---- 版面 ----
|
||
FS_LEAF = 12
|
||
FS_MENU = 12
|
||
FS_ROOT = 13
|
||
FS_PAGE = 9
|
||
PITCH = 18
|
||
ROW_TOP = 96
|
||
X_COL = (28, 150, 306, 474)
|
||
TRUNK_OFF = 22
|
||
LEAF_TEXT = 14
|
||
PAD_X = 9
|
||
BAND_X = X_COL[1] - 12
|
||
BAND_R = 624
|
||
PANEL_X = 640
|
||
W = 990
|
||
TREE_LIMIT = 630
|
||
|
||
INK = "#0f172a"
|
||
MUTED = "#475569"
|
||
FAINT = "#94a3b8"
|
||
LINE = "#334155"
|
||
RULE = "#cbd5e1"
|
||
C_BLUE = "#1d4ed8"
|
||
BG_BLUE = "#eff6ff"
|
||
C_VIOLET = "#6d28d9"
|
||
BG_VIOLET = "#f5f3ff"
|
||
C_AMBER = "#b45309"
|
||
C_ROSE = "#be123c"
|
||
C_GREEN = "#15803d"
|
||
BG_GREEN = "#f0fdf4"
|
||
BG_GRAY = "#f8fafc"
|
||
FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
|
||
|
||
_over = []
|
||
|
||
|
||
def emit(msg):
|
||
"""控制台可能是 GBK:中文/符号打印不能把生成器打崩(假红 = 钩子重跑失败)。"""
|
||
enc = getattr(sys.stdout, "encoding", None) or "utf-8"
|
||
safe = str(msg).encode(enc, errors="replace").decode(enc, errors="replace")
|
||
sys.stdout.write(safe + "\n")
|
||
|
||
|
||
def tw(text, fs):
|
||
"""近似字宽:CJK 约 1.0 em,其余 0.58 em。"""
|
||
return sum(fs * (1.0 if ord(c) > 0x2E80 else 0.58) for c in str(text))
|
||
|
||
|
||
def esc(text):
|
||
return html.escape(str(text), quote=True)
|
||
|
||
|
||
def fit(x_right, limit, what):
|
||
if x_right > limit:
|
||
_over.append("%s: %.0f > %.0f" % (what, x_right, limit))
|
||
|
||
|
||
def node_lines(raw):
|
||
"""取节点行:丢空行与 # 指令行(指令改了不影响图的形状)。"""
|
||
out = []
|
||
for ln in raw.splitlines():
|
||
ln = ln.rstrip("\r")
|
||
if not ln.strip() or ln.strip().startswith("#"):
|
||
continue
|
||
out.append(ln)
|
||
return out
|
||
|
||
|
||
def verify_src():
|
||
"""仓库里跑 = 与真值互验;临时目录里跑 = 静默用内嵌表。"""
|
||
if not TREE_FILE.exists():
|
||
emit("note: 未找到 ui_menu_tree.txt(临时目录重跑场景)→ 用内嵌 NODE_SRC")
|
||
return 0
|
||
live = node_lines(TREE_FILE.read_text(encoding="utf-8-sig"))
|
||
if live == list(NODE_SRC):
|
||
emit("互验 OK:%d 行节点与 ui_menu_tree.txt 一致" % len(live))
|
||
return 0
|
||
emit("[FAIL] ui_menu_tree.txt 与本脚本内嵌 NODE_SRC 不一致 —— 图会失真,请同步内嵌表后重跑")
|
||
for i in range(max(len(live), len(NODE_SRC))):
|
||
a = live[i] if i < len(live) else "<缺>"
|
||
b = NODE_SRC[i] if i < len(NODE_SRC) else "<缺>"
|
||
if a != b:
|
||
emit(" 第 %d 行:真值 %r / 内嵌 %r" % (i + 1, a, b))
|
||
return 1
|
||
|
||
|
||
def parse(lines):
|
||
"""节点行 → (根, 文档序节点表)。"""
|
||
root = None
|
||
docs = []
|
||
stack = []
|
||
for ln in lines:
|
||
depth = (len(ln) - len(ln.lstrip(" "))) // 2
|
||
page = None
|
||
pwd = False
|
||
keep = []
|
||
for tok in ln.strip().split():
|
||
if tok == "#P":
|
||
pwd = True
|
||
elif tok.startswith("#page="):
|
||
page = tok[len("#page="):]
|
||
else:
|
||
keep.append(tok)
|
||
node = {"depth": depth, "label": " ".join(keep), "page": page,
|
||
"pwd": pwd, "kids": [], "y": 0.0, "w": 0.0}
|
||
docs.append(node)
|
||
while stack and stack[-1]["depth"] >= depth:
|
||
stack.pop()
|
||
if stack:
|
||
stack[-1]["kids"].append(node)
|
||
else:
|
||
root = node
|
||
stack.append(node)
|
||
return root, docs
|
||
|
||
|
||
def layout(root):
|
||
"""叶按文档序占行;菜单 y = 首末子节点 y 的中点。返回叶行数。"""
|
||
state = [0]
|
||
|
||
def walk(n):
|
||
if not n["kids"]:
|
||
n["y"] = ROW_TOP + state[0] * PITCH
|
||
state[0] += 1
|
||
return
|
||
for k in n["kids"]:
|
||
walk(k)
|
||
n["y"] = (n["kids"][0]["y"] + n["kids"][-1]["y"]) / 2.0
|
||
|
||
walk(root)
|
||
return state[0]
|
||
|
||
|
||
def yspan(n):
|
||
"""子树占用的 y 区间。"""
|
||
if not n["kids"]:
|
||
return n["y"], n["y"]
|
||
lo, _ = yspan(n["kids"][0])
|
||
_, hi = yspan(n["kids"][-1])
|
||
return lo, hi
|
||
|
||
|
||
def tally(docs):
|
||
menus = [n for n in docs if n["kids"]]
|
||
leaves = [n for n in docs if not n["kids"]]
|
||
unbound = [n for n in leaves if not n["page"]]
|
||
return {"total": len(docs), "menus": len(menus), "leaves": len(leaves),
|
||
"bound": len(leaves) - len(unbound), "unbound": unbound,
|
||
"pwd": len([n for n in leaves if n["pwd"]]),
|
||
"depth": max(n["depth"] for n in docs) + 1}
|
||
|
||
|
||
QA = chr(34) # 双引号:拼 SVG 属性值用,免得在字面量里嵌引号
|
||
|
||
|
||
def r1(v):
|
||
"""一位小数(决定输出逐字节稳定)。"""
|
||
return format(float(v), ".1f")
|
||
|
||
|
||
def attrs(pairs):
|
||
out = []
|
||
for key, val in pairs:
|
||
out.append(key + "=" + QA + str(val) + QA)
|
||
return " ".join(out)
|
||
|
||
|
||
def txt(x, y, body, fs, fill, weight="normal", anchor="start"):
|
||
head = attrs([("x", r1(x)), ("y", r1(y)), ("font-size", fs), ("fill", fill),
|
||
("font-weight", weight), ("text-anchor", anchor)])
|
||
return "<text " + head + ">" + esc(body) + "</text>"
|
||
|
||
|
||
def rrect(x, y, w, h, fill, stroke, rx=5, sw=1.2):
|
||
return "<rect " + attrs([("x", r1(x)), ("y", r1(y)), ("width", r1(w)),
|
||
("height", r1(h)), ("rx", rx), ("fill", fill),
|
||
("stroke", stroke), ("stroke-width", sw)]) + "/>"
|
||
|
||
|
||
def line(x1, y1, x2, y2, stroke, sw=1.2, dash=None):
|
||
pairs = [("x1", r1(x1)), ("y1", r1(y1)), ("x2", r1(x2)), ("y2", r1(y2)),
|
||
("stroke", stroke), ("stroke-width", sw)]
|
||
if dash:
|
||
pairs.append(("stroke-dasharray", dash))
|
||
return "<line " + attrs(pairs) + "/>"
|
||
|
||
|
||
def svg_header(h):
|
||
return ["<?xml version=" + QA + "1.0" + QA + " encoding=" + QA + "UTF-8" + QA + "?>",
|
||
"<svg " + attrs([("xmlns", "http://www.w3.org/2000/svg"),
|
||
("width", W), ("height", h),
|
||
("viewBox", "0 0 " + str(W) + " " + str(h)),
|
||
("font-family", FONT)]) + ">",
|
||
"<rect " + attrs([("width", W), ("height", h), ("fill", "#ffffff")]) + "/>"]
|
||
|
||
|
||
def draw_tree(docs, s):
|
||
"""分组带 -> 连接线 -> 方框与叶(后画的压在前面之上)。"""
|
||
band = 0
|
||
for n in docs:
|
||
if n["depth"] != 1:
|
||
continue
|
||
lo, hi = yspan(n)
|
||
fill = BG_GRAY if band % 2 == 0 else "#ffffff"
|
||
s.append(rrect(BAND_X, lo - 9, BAND_R - BAND_X, (hi - lo) + 18, fill,
|
||
"none", 6, 0))
|
||
band += 1
|
||
for n in docs:
|
||
if n["depth"] == 2 and n["kids"]:
|
||
lo, hi = yspan(n)
|
||
s.append(rrect(X_COL[2] - 10, lo - 9, BAND_R - (X_COL[2] - 10),
|
||
(hi - lo) + 18, BG_VIOLET, "none", 5, 0))
|
||
for n in docs:
|
||
if not n["kids"]:
|
||
continue
|
||
d = n["depth"]
|
||
tx = X_COL[d + 1] - TRUNK_OFF
|
||
s.append(line(tx, n["kids"][0]["y"], tx, n["kids"][-1]["y"], RULE))
|
||
s.append(line(X_COL[d] + n["w"], n["y"], tx, n["y"], RULE))
|
||
for k in n["kids"]:
|
||
kx = X_COL[k["depth"]] if k["kids"] else X_COL[k["depth"]] + 3
|
||
s.append(line(tx, k["y"], kx, k["y"], RULE))
|
||
for n in docs:
|
||
if n["kids"]:
|
||
d = n["depth"]
|
||
fs = FS_ROOT if d == 0 else FS_MENU
|
||
if d == 0:
|
||
ink, fill, stroke, bh = C_BLUE, BG_BLUE, C_BLUE, 26
|
||
elif d == 1:
|
||
ink, fill, stroke, bh = INK, "#ffffff", LINE, 22
|
||
else:
|
||
ink, fill, stroke, bh = C_VIOLET, "#ffffff", C_VIOLET, 20
|
||
s.append(rrect(X_COL[d], n["y"] - bh / 2.0, n["w"], bh, fill, stroke, 5))
|
||
s.append(txt(X_COL[d] + n["w"] / 2.0, n["y"] + fs * 0.36, n["label"],
|
||
fs, ink, "bold", "middle"))
|
||
if d + 1 < len(X_COL):
|
||
fit(X_COL[d] + n["w"], X_COL[d + 1] - TRUNK_OFF - 4,
|
||
"menu " + n["label"])
|
||
for n in docs:
|
||
if n["kids"]:
|
||
continue
|
||
x = X_COL[n["depth"]]
|
||
y = n["y"]
|
||
wave = n["label"] in ("手动录波", "录波回放")
|
||
bound = bool(n["page"])
|
||
lw = tw(n["label"], FS_LEAF)
|
||
if wave:
|
||
s.append(rrect(x + 1, y - 8.5, LEAF_TEXT + lw + 9, 17, BG_GREEN,
|
||
C_GREEN, 4, 0.9))
|
||
if bound:
|
||
s.append(line(x + 2, y, x + 9, y, RULE))
|
||
else:
|
||
s.append(line(x + 2, y, x + 9, y, C_ROSE, 1.2, "3,2"))
|
||
ink = INK
|
||
if not bound:
|
||
ink = C_ROSE
|
||
elif wave:
|
||
ink = C_GREEN
|
||
s.append(txt(x + LEAF_TEXT, y + 4, n["label"], FS_LEAF, ink))
|
||
cur = x + LEAF_TEXT + lw
|
||
if bound:
|
||
s.append(txt(cur + 10, y + 3.5, n["page"], FS_PAGE, FAINT))
|
||
cur += 10 + tw(n["page"], FS_PAGE)
|
||
if n["pwd"]:
|
||
s.append(txt(cur + 9, y + 3.5, "P", 9, C_AMBER, "bold"))
|
||
cur += 10 + tw("P", 9)
|
||
fit(cur, TREE_LIMIT, "leaf " + n["label"])
|
||
|
||
|
||
def panel_rows(c):
|
||
rows = [{"k": "h", "t": "本图读数"}]
|
||
rows.append({"k": "t", "t": "节点 %d = 菜单 %d + 叶 %d"
|
||
% (c["total"], c["menus"], c["leaves"])})
|
||
rows.append({"k": "t", "t": "已绑页 %d · 未绑页 %d · 密码门 %d 叶"
|
||
% (c["bound"], len(c["unbound"]), c["pwd"])})
|
||
rows.append({"k": "t", "t": "最深 %d 级(主界面 → 叶)" % c["depth"]})
|
||
rows.append({"k": "s", "t": ""})
|
||
rows.append({"k": "h", "t": "怎么看"})
|
||
rows.append({"k": "sw", "fill": BG_BLUE, "stroke": C_BLUE,
|
||
"t": "根:主界面本身,不是一个页面"})
|
||
rows.append({"k": "sw", "fill": "#ffffff", "stroke": LINE,
|
||
"t": "白框:一级菜单,可展开、不绑页"})
|
||
rows.append({"k": "sw", "fill": BG_VIOLET, "stroke": C_VIOLET,
|
||
"t": "紫底:二级菜单与它那一支的行带"})
|
||
rows.append({"k": "sw", "fill": BG_GREEN, "stroke": C_GREEN,
|
||
"t": "绿底:录波两叶,已绑页面模块"})
|
||
rows.append({"k": "sw", "fill": "#ffffff", "stroke": C_ROSE, "dash": "3,2",
|
||
"t": "红字 + 虚线:未绑页的叶,进去只有菜单"})
|
||
rows.append({"k": "t", "t": "灰色小字 = 页面模块名(页面注册表的键)"})
|
||
rows.append({"k": "t", "t": "行末的 P = 进这一叶要先过密码"})
|
||
rows.append({"k": "t", "t": "整定 与 查看 复用同一批页面模块"})
|
||
rows.append({"k": "s", "t": ""})
|
||
rows.append({"k": "h", "t": "未绑页的 %d 叶" % len(c["unbound"])})
|
||
for n in c["unbound"]:
|
||
par = n["parent"]["label"] if n["parent"] else "?"
|
||
rows.append({"k": "t", "t": "· " + n["label"] + "(" + par + " 下)"})
|
||
return rows
|
||
|
||
|
||
def draw_panel(c, s):
|
||
"""右侧读数与图例:数字全部现算,文档里不必再抄一份。"""
|
||
rows = panel_rows(c)
|
||
x = PANEL_X
|
||
pw = W - 30 - x
|
||
lh = 18.0
|
||
hh = 25.0
|
||
gap = 12.0
|
||
body_h = 0.0
|
||
for r in rows:
|
||
if r["k"] == "h":
|
||
body_h += hh
|
||
elif r["k"] == "s":
|
||
body_h += gap
|
||
else:
|
||
body_h += lh
|
||
y0 = ROW_TOP - 24
|
||
s.append(rrect(x - 16, y0, pw + 30, body_h + 44, "#ffffff", RULE, 8))
|
||
y = y0 + 26
|
||
for r in rows:
|
||
if r["k"] == "s":
|
||
y += gap
|
||
continue
|
||
if r["k"] == "h":
|
||
s.append(txt(x, y + 4, r["t"], 12.5, INK, "bold"))
|
||
fit(x + tw(r["t"], 12.5), W - 40, "panel " + r["t"])
|
||
y += hh
|
||
continue
|
||
tx = x
|
||
if r["k"] == "sw":
|
||
sw = r.get("dash")
|
||
s.append(rrect(x, y - 8, 15, 12, r["fill"], r["stroke"], 3, 1.0))
|
||
if sw:
|
||
s.append(line(x, y - 2, x + 15, y - 2, r["stroke"], 1.0, sw))
|
||
tx = x + 22
|
||
s.append(txt(tx, y + 2, r["t"], 10.5, MUTED))
|
||
fit(tx + tw(r["t"], 10.5), W - 40, "panel " + r["t"][:8])
|
||
y += lh
|
||
|
||
|
||
def main():
|
||
if verify_src():
|
||
return 1
|
||
root, docs = parse(NODE_SRC)
|
||
for n in docs:
|
||
n["parent"] = None
|
||
for n in docs:
|
||
for k in n["kids"]:
|
||
k["parent"] = n
|
||
leaf_rows = layout(root)
|
||
for n in docs:
|
||
if n["kids"]:
|
||
fs = FS_ROOT if n["depth"] == 0 else FS_MENU
|
||
n["w"] = 2 * PAD_X + tw(n["label"], fs)
|
||
c = tally(docs)
|
||
h = ROW_TOP + leaf_rows * PITCH + 76
|
||
s = svg_header(h)
|
||
s.append(txt(28, 40, "菜单树总览(层级 · 绑页 · 密码门)", 17, INK, "bold"))
|
||
s.append(txt(28, 61, "方框 = 菜单(可展开);细线 = 叶(对应一个页面模块,或一处空缺)",
|
||
11, MUTED))
|
||
draw_tree(docs, s)
|
||
draw_panel(c, s)
|
||
foot = ROW_TOP + leaf_rows * PITCH + 42
|
||
note = ("真值 = ui_menu_tree.txt(tools/menu_gen/gen_menu.py 的输入);本图由 "
|
||
"tools/arch_diagram/gen_menu_tree_svg.py 生成 —— 改菜单树要同步脚本内的 "
|
||
"NODE_SRC 重跑,勿手改 SVG")
|
||
s.append(txt(28, foot, note, 9.5, FAINT))
|
||
fit(28 + tw(note, 9.5), W - 20, "foot")
|
||
s.append("</svg>")
|
||
body = "\n".join(s) + "\n"
|
||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||
OUT.write_bytes(body.encode("utf-8"))
|
||
try:
|
||
ET.fromstring(body)
|
||
except ET.ParseError as exc:
|
||
emit("XML FAIL: %s" % exc)
|
||
return 1
|
||
emit("SVG %dx%d 节点 %d 菜单 %d 叶 %d 绑页 %d 未绑 %d 密码门 %d 最深 %d 级"
|
||
% (W, h, c["total"], c["menus"], c["leaves"], c["bound"],
|
||
len(c["unbound"]), c["pwd"], c["depth"]))
|
||
if _over:
|
||
for item in _over:
|
||
emit("OVERFLOW " + item)
|
||
emit("[FAIL] 版面溢出 %d 处 —— 调版面常量后重跑" % len(_over))
|
||
return 1
|
||
emit("OVERFLOW 0 → " + str(OUT.relative_to(ROOT)))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|