从 wxPython 桌面标注器到静态交互网页:一个空间展示工具的源码剖析
项目要解决什么问题
设想一个装修公司的需求:拿到一张仓储空间的整体效果图,要在图上标出「办公区」「休息区」「入口门厅」等位置,每个位置配一张实拍照片和一段文字说明,最后做成一个网页发给客户 —— 客户点哪块区域,就看到那块区域的照片和介绍。
这个需求有两个特点决定了技术选型:
- 标注工作是一次性的,由设计师在本机完成,需要图形界面(拖拽、点击、即时预览)
- 成果要交付给客户,客户不会装 Python,最好是双击就能看的网页
于是自然分成两半:
| 部分 | 技术栈 | 职责 |
|---|---|---|
| 编辑器 | Python + wxPython | 图形化标注,产出结构化数据 |
| 展示页 | 静态 HTML + JS + CSS | 消费数据,渲染交互 |
中间用一个 JSON 作为契约。整个项目约 950 行,没有任何第三方运行时依赖(不用 three.js、不用打包工具),发布产物纯静态、可离线打开。
本文剖析这两部分的实现,重点分析几个关键技术节点是怎么落地的。
“C:\myApp\LUMI_Space_Designer_V3_1\LUMI_Space_Designer_V3_1_Complete\editor\lumi_designer.py”
“C:\myApp\LUM_Test\LUMI_Showroom\index.html”
一、整体架构与数据契约
1.1 数据结构
一切从数据结构开始。项目文件长这样:
def new_project():
return {
"projectName": "LUMI WAREHOUSE",
"views": {
"overview": {"image": "", "hotspots": []},
"exploded": {"image": "", "hotspots": []},
},
}
单个热点:
{
"name": "办公区",
"x": 0.28, # 归一化坐标
"y": 0.42,
"image": "...office.png",
"description": "玻璃隔断,6个工位",
}
这里有两个设计决定值得说明。
为什么用 views 而不是一个扁平的 hotspots 数组?
因为业务上存在「整体视图」和「分拆视图」两张图(分拆图是把建筑按楼层/结构层炸开的示意图),两张图上的标注点位置完全不同,必须各自独立成套。把视图作为数据结构的一级维度,后面无论是编辑器切换还是网页切换,都只是换一个 key 的事:
def current_view(self):
return self.project["views"][self.view]
def hotspots(self):
return self.current_view()["hotspots"]
编辑器里所有涉及热点的代码都通过 self.hotspots() 访问,不直接碰 self.project。切视图时只改 self.view 这一个字段,其余逻辑零改动。
为什么 x/y 是 0–1 的小数而不是像素?
这是整个项目最关键的技术决定,下一节详细展开。
1.2 编辑期路径 vs 发布期路径
项目文件里存的是绝对路径(方便设计师在本机反复修改),但发布出去的网页必须用相对路径(否则换台电脑图片全裂)。这个转换发生在发布阶段,不污染编辑期的数据。
后面第四节会看到这个转换的完整实现。
二、核心技术点:坐标归一化
2.1 问题:像素坐标是不可移植的
假设用像素记录位置,会发生什么?
编辑器里,一张 2048×1152 的图要显示在一个 800×600 的面板上,必然经过缩放。你在缩放后的图上点击,拿到的是面板坐标;而图片的真实像素位置是另一套数字。
到了网页端,CSS 这样写让图片自适应:
#overview{
display:block;
max-width:100%;
height:auto;
border-radius:6px;
}
图片宽度随浏览器窗口变化。如果热点用 left: 512px 定位,窗口一缩放,图片变小了,热点还钉在 512px 处 —— 标记和图片彻底脱节。
根因是:像素永远相对于某个具体尺寸。 只要图片在任何环节被缩放(编辑器面板、浏览器视口、不同分辨率显示器),像素坐标就失效。
2.2 解法:存比例,不存像素
改成记录「在宽度的百分之几处」:
- 编辑器:把点击位置除以图片显示尺寸,得到 0–1 的比例
- 网页:把比例乘 100 写成 CSS 百分比
比例是无量纲的,与任何具体尺寸无关,因此跨设备、跨缩放都成立。
2.3 编辑器侧实现:_fit_rect
要把点击坐标换算成比例,前提是知道图片在面板上实际画在哪个矩形里。这就是 _fit_rect() 的职责:
def _fit_rect(self):
"""保持宽高比,返回图片居中绘制的矩形。"""
if not self.image:
return None
cw, ch = self.GetClientSize()
iw, ih = self.image.GetWidth(), self.image.GetHeight()
if cw < 1 or ch < 1 or iw < 1 or ih < 1:
return None
scale = min(cw / iw, ch / ih)
w, h = max(1, int(iw * scale)), max(1, int(ih * scale))
return wx.Rect((cw - w) // 2, (ch - h) // 2, w, h)
三个细节:
min(cw / iw, ch / ih) —— 这是 contain 式缩放的标准做法。分别算出「宽度方向要缩多少」和「高度方向要缩多少」,取较小者,保证图片两个方向都不超出面板。若取 max 就变成 cover 式(填满但裁切),若各方向独立缩放就是拉伸变形。这行等价于 CSS 的 object-fit: contain。
(cw - w) // 2 —— 缩放后剩余空间对半分,实现居中。
max(1, ...) 和 cw < 1 检查 —— wxPython 在窗口初始化或最小化时,GetClientSize() 可能返回 0 甚至负值,Scale(0, 0) 会抛异常。这类边界保护在 GUI 编程里必不可少。
2.4 反向换算与命中检测
有了绘制矩形,点击处理就直白了:
def on_click(self, event):
rect = self._rect
if not rect:
return
mx, my = event.GetPosition()
if not rect.Contains(mx, my):
return # 点在图片外的留白上,忽略
# 先看是否点中已有热点
for i, spot in enumerate(self.hotspots):
px = rect.x + int(spot.get("x", 0) * rect.width)
py = rect.y + int(spot.get("y", 0) * rect.height)
if (mx - px) ** 2 + (my - py) ** 2 <= HIT_RADIUS ** 2:
self.on_select(i)
return
rx = (mx - rect.x) / rect.width
ry = (my - rect.y) / rect.height
self.on_add(round(min(max(rx, 0.0), 1.0), 4),
round(min(max(ry, 0.0), 1.0), 4))
rect.Contains(mx, my) 这个判断不能省。 图片居中绘制后四周有留白,点在留白上会算出负数或大于 1 的比例,热点就飘到图片外面去了。
命中检测用平方距离比较:
if (mx - px) ** 2 + (my - py) ** 2 <= HIT_RADIUS ** 2:
不开平方根,两边同时平方比较。省掉 math.sqrt 调用,在遍历大量热点时更划算,而且避免了浮点开方的精度问题 —— 这是碰撞检测的常规写法。
这段逻辑还实现了一个交互上的取舍:点击优先判定「选中已有热点」,只有没命中任何热点时才新建。否则用户想修改某个点,一点就变成叠加了一个新点。HIT_RADIUS = 12 比绘制半径 DOT_RADIUS = 9 略大,给手抖留了容差。
round(..., 4) 把坐标截到 4 位小数。0.0001 的比例在 2000px 宽的图上是 0.2 像素,远超肉眼精度,同时让 JSON 文件干净可读。
min(max(rx, 0.0), 1.0) 是双重保险 —— 即使前面的 Contains 判断因边界舍入漏过一两个像素,也不会写出越界数据。
2.5 网页侧实现:百分比定位
网页端把比例还原成 CSS 百分比:
const dot = document.createElement("button");
dot.type = "button";
dot.className = "hot";
// toFixed 避免浮点误差产生 28.000000000000004%
dot.style.left = (x * 100).toFixed(3) + "%";
dot.style.top = (y * 100).toFixed(3) + "%";
toFixed(3) 处理的是 IEEE 754 浮点误差:0.28 * 100 在二进制浮点下等于 28.000000000000004。浏览器能正确解析,但生成的 DOM 属性很脏,调试时看着糟心。
要让百分比生效,CSS 有两个前置条件:
/* stage 紧贴图片边界,热点百分比才准确 */
#stage{
position:relative;
display:inline-block;
line-height:0;
max-width:100%;
}
#hotspots{
position:absolute;
inset:0;
}
position: relative —— 绝对定位的子元素以最近的定位祖先为参照。这个容器必须紧紧包裹图片,否则 50% 算的是容器的一半,不是图片的一半。
display: inline-block —— 让容器宽度收缩包裹内容(shrink-to-fit),而不是像 block 那样撑满父级宽度。这是「紧贴图片」的关键。
line-height: 0 —— <img> 是 inline 元素,会参与行内布局,底部留下几像素的基线间隙。置零消除,避免容器比图片高出一点导致 y 方向百分比轻微偏移。
2.6 一个容易忽略的细节:圆心 vs 左上角
CSS 的 left/top 定位的是元素左上角。如果热点是个 28×28 的圆点,直接定位会让整个圆往右下偏移 14px:
.hot{
position:absolute;
/* 让圆心落在标注坐标上,而不是左上角 */
transform:translate(-50%,-50%);
width:28px;
height:28px;
padding:0;
background:var(--gold);
border:2px solid #fff;
border-radius:50%;
cursor:pointer;
transition:.15s;
}
translate(-50%, -50%) 里的百分比是相对元素自身尺寸的,所以无论圆点多大都自动居中。这是绝对定位居中的经典技巧。
hover 时要注意保留这个位移,否则一悬停圆点就会跳位:
.hot:hover,
.hot:focus-visible{
transform:translate(-50%,-50%) scale(1.25);
box-shadow:0 0 0 10px rgba(213,170,90,.25);
outline:none;
}
transform 是单一属性,后写的会覆盖前写的,必须把 translate 一起带上 —— 只写 scale(1.25) 会丢掉居中位移,导致悬停瞬间跳位。
三、编辑器实现要点(wxPython)
3.1 自定义绘制画布
图片显示和热点标记都靠自绘完成。核心是 EVT_PAINT:
def __init__(self, parent, on_add, on_select):
super().__init__(parent)
self.SetBackgroundStyle(wx.BG_STYLE_PAINT)
...
self.Bind(wx.EVT_PAINT, self.on_paint)
self.Bind(wx.EVT_SIZE, lambda e: (self.Refresh(), e.Skip()))
self.Bind(wx.EVT_LEFT_DOWN, self.on_click)
SetBackgroundStyle(wx.BG_STYLE_PAINT) 告诉 wx「背景由我自己在 EVT_PAINT 里画」,配合下面的 AutoBufferedPaintDC 消除闪烁。若不设置,系统会先擦除背景再触发绘制,视觉上就是闪。
EVT_SIZE 里 e.Skip() —— 处理完自己的逻辑后要把事件继续传下去,让 sizer 的默认布局逻辑照常执行。漏掉 Skip() 是 wxPython 新手的常见坑,表现为控件布局莫名失效。
绘制主体:
def on_paint(self, event):
dc = wx.AutoBufferedPaintDC(self)
dc.SetBackground(wx.Brush(wx.Colour(40, 40, 40)))
dc.Clear()
rect = self._fit_rect()
self._rect = rect
if not rect:
dc.SetTextForeground(wx.Colour(150, 150, 150))
dc.DrawText("请先选择图片", 20, 20)
return
dc.DrawBitmap(self._scaled_bitmap(rect.width, rect.height), rect.x, rect.y)
AutoBufferedPaintDC 是双缓冲绘制上下文:所有绘制先在内存位图上完成,最后一次性拷到屏幕。在有大图和多个图元的场景下,这是避免撕裂/闪烁的标准手段。
注意 self._rect = rect 这行 —— 绘制时算出的矩形被缓存下来,供点击处理复用。绘制与命中检测共用同一套几何计算,这样就不可能出现「画在一处、点在另一处」的不一致。
3.2 位图缩放缓存
wx.Image.Scale() 对 3MB 的大图是个重操作。而 EVT_PAINT 触发得非常频繁(窗口移动、遮挡恢复、每次 Refresh()),每次都重新缩放会明显卡顿:
def _scaled_bitmap(self, w, h):
if not self._cache or self._cache[0] != (w, h):
bmp = wx.Bitmap(self.image.Scale(w, h, wx.IMAGE_QUALITY_HIGH))
self._cache = ((w, h), bmp)
return self._cache[1]
用目标尺寸 (w, h) 作为缓存键:尺寸没变就直接复用位图,只有窗口真正改变大小时才重算。换图时 load_image 里把 self._cache = None 显式清空,防止用旧图的位图。
这是典型的空间换时间,用一个元组比较换掉一次昂贵的重采样。
3.3 热点标记的绘制
for i, spot in enumerate(self.hotspots):
px = rect.x + int(spot.get("x", 0) * rect.width)
py = rect.y + int(spot.get("y", 0) * rect.height)
if i == self.selected:
dc.SetPen(wx.Pen(wx.Colour(255, 80, 80), 3)) # 选中:红色粗边
else:
dc.SetPen(wx.Pen(wx.Colour(255, 255, 255), 2)) # 常态:白色细边
dc.SetBrush(wx.Brush(wx.Colour(213, 170, 90)))
dc.DrawCircle(px, py, DOT_RADIUS)
label = str(i + 1)
tw, th = dc.GetTextExtent(label)
dc.SetTextForeground(wx.Colour(20, 20, 20))
dc.DrawText(label, px - tw // 2, py - th // 2)
比例乘以矩形尺寸再加偏移,就还原出屏幕坐标 —— 与 2.4 节的换算严格互逆。
GetTextExtent(label) 先量出文字的实际宽高,再减去一半做偏移,这样序号才真正居中在圆点里。GUI 里凡是要居中文字,都得先测量 —— 因为不同字号、不同字体、单双位数字的宽度都不一样,硬编码偏移量必然歪。
3.4 回调解耦
ImageCanvas 不认识 Designer,只持有两个回调:
def __init__(self, parent, on_add, on_select):
self.on_add = on_add
self.on_select = on_select
构造时注入:
self.canvas = ImageCanvas(panel, self.add_hotspot, self.select_hotspot)
画布只负责「把鼠标事件翻译成语义事件」(新增一个点 / 选中第 i 个点),不关心数据存哪、怎么刷新列表。这让 _fit_rect 之类的几何逻辑可以完全脱离主窗口单独测试。
3.5 wxPython 的一个陷阱:SetValue vs ChangeValue
右侧面板要双向绑定:改输入框写回数据,切换热点时把数据填进输入框。第二步很容易写出无限循环:
def refresh_fields(self):
spot = self.current_spot()
enabled = spot is not None
for ctrl in (self.name_ctrl, self.desc_ctrl):
ctrl.Enable(enabled)
# ChangeValue 不触发 EVT_TEXT,避免回写循环
self.name_ctrl.ChangeValue(spot.get("name", "") if spot else "")
self.desc_ctrl.ChangeValue(spot.get("description", "") if spot else "")
wx.TextCtrl.SetValue() 会触发 EVT_TEXT,而 EVT_TEXT 的处理器 on_name_change 又会写回数据。切换热点时就形成「填充 → 触发事件 → 写回 → …」的连锁反应,轻则数据错乱(把上一个热点的名字写进新热点),重则递归。
ChangeValue() 设值但不触发事件,正是为这种场景准备的。Qt 里对应的做法是 blockSignals(True),思路一致:程序化赋值不应被当作用户输入。
另一处相关的取舍在 on_name_change 里:
def on_name_change(self, event):
spot = self.current_spot()
if spot is None:
return
spot["name"] = self.name_ctrl.GetValue()
# 只改列表文字,避免重建列表打断输入
self.listbox.SetString(self.selected,
"%d. %s" % (self.selected + 1, spot["name"] or "未命名"))
这里刻意不调用 refresh_list()。整表重建会导致 ListBox 选中状态丢失、焦点跳走,用户打字打一半就被打断。SetString 只改一行文字,代价小且无副作用。
而 refresh_list 本身用 Freeze/Thaw 包住批量操作:
def refresh_list(self):
self.listbox.Freeze()
self.listbox.Clear()
for i, spot in enumerate(self.hotspots()):
self.listbox.Append("%d. %s" % (i + 1, spot.get("name") or "未命名"))
if 0 <= self.selected < self.listbox.GetCount():
self.listbox.SetSelection(self.selected)
self.listbox.Thaw()
Freeze() 暂停控件重绘,Thaw() 恢复并一次性刷新,避免逐条 Append 时的逐行闪烁。
3.6 状态集中刷新
编辑器有多处状态(画布、列表、输入框、状态栏),任何数据变更都要同步。做法是收敛到一个方法:
def refresh_all(self):
self.canvas.load_image(self.current_view()["image"])
self.refresh_list()
self.refresh_fields()
self.canvas.set_hotspots(self.hotspots(), self.selected)
self.update_status()
切视图、删热点、打开项目都调用 refresh_all();只有新增和选中走精细刷新(避免重载图片这种重操作)。这种「一个入口全量刷新」的策略在中小型 GUI 里性价比很高 —— 牺牲一点性能,换来「不会漏刷某个控件」的确定性。
四、发布:生成自包含静态站点
4.1 模板定位不能用相对路径
发布要把 template/ 拷出去,那么怎么找到它?
EDITOR_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = EDITOR_DIR.parent / "template"
用 __file__ 而非 Path("../template")。 相对路径是相对当前工作目录解析的,而 cwd 取决于用户从哪里启动程序 —— 双击运行、从 IDE 运行、cd 到别处运行,结果都不同。
__file__ 指向源文件自身位置,.resolve() 展开成绝对路径(同时解掉符号链接)。凡是定位「随代码一起分发的资源」,都应该用这个模式。 相对路径只适合处理用户输入的路径。
4.2 资源收集:复制 + 改写 + 去重
发布的核心是把散落在用户硬盘各处的图片收进 assets/,并把路径改写成相对形式:
used = {} # 源路径 -> 相对路径,同一张图只复制一次
def publish_asset(src):
"""把图片复制进 assets/,返回网页可用的相对路径。"""
if not src:
return ""
if src in used:
return used[src]
if not os.path.exists(src):
return ""
name = slugify(os.path.basename(src))
stem, ext = os.path.splitext(name)
n = 1
while (assets / name).exists():
name = "%s_%d%s" % (stem, n, ext)
n += 1
shutil.copy2(src, assets / name)
rel = "assets/" + name
used[src] = rel
return rel
这个闭包同时解决了四件事:
- 去重 ——
used字典记录已处理的源路径。多个热点共用同一张照片时只复制一次,返回同一个相对路径 - 重名消歧 —— 不同目录下的同名文件(两个
1.png)会自动变成1.png、1_1.png - 失效容错 —— 源文件已被删除时返回空串,网页端会把该区域当作「无照片」处理,不会整页崩掉
- 路径改写 —— 返回的是
assets/xxx.png这种网页可用的相对形式
调用点用列表推导一次性处理整个视图:
data["views"][key] = {
"image": publish_asset(view["image"]),
"hotspots": [
{
"name": spot.get("name", ""),
"x": spot.get("x", 0),
"y": spot.get("y", 0),
"description": spot.get("description", ""),
"image": publish_asset(spot.get("image", "")),
}
for spot in view["hotspots"]
],
}
注意这里是重新构造一个干净的字典,而不是深拷贝再改字段。好处是发布数据只包含网页需要的字段,编辑器内部的临时状态不会泄漏出去。
4.3 文件名规整
def slugify(name):
"""把文件名规整成 URL 安全的形式(去空格、去中文)。"""
stem, ext = os.path.splitext(name)
stem = unicodedata.normalize("NFKD", stem).encode("ascii", "ignore").decode()
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("_")
return (stem or "image") + ext.lower()
逐步拆解:
unicodedata.normalize("NFKD", ...)—— Unicode 兼容分解。把é拆成e+ 组合音标符,全角字符转半角,这样后面的 ASCII 过滤能保住基本字母.encode("ascii", "ignore").decode()—— 丢弃所有非 ASCII 字节。中文会被整体丢掉re.sub(r"[^A-Za-z0-9._-]+", "_", ...)—— 剩余的特殊字符(空格、括号、#、%)统一换成下划线。#和%在 URL 里有特殊含义,不处理会导致图片 404or "image"—— 这个兜底是必需的:纯中文文件名经过 ASCII 过滤后变成空串,会生成一个只有扩展名的.png(在类 Unix 系统上还是隐藏文件)ext.lower()—— 统一小写。Windows 文件系统大小写不敏感,但网页服务器通常敏感,.PNG写进 HTML 后在 Linux 服务器上会 404
4.4 数据注入:为什么用 JS 而不是 JSON
发布数据写成一个 JS 文件:
with open(out / "project-data.js", "w", encoding="utf-8") as f:
f.write("window.LUMI_PROJECT=" +
json.dumps(data, ensure_ascii=False, indent=2) + ";\n")
产物形如:
window.LUMI_PROJECT={
"projectName": "LUMI 示范仓储空间",
"views": { ... }
};
为什么不写成 .json 然后用 fetch() 读?
因为浏览器的同源策略:用 file:// 协议直接双击打开 HTML 时,fetch('project-data.json') 会被 CORS 拦截报错。而 <script src> 加载不受此限制。
交付要求是「双击就能看」,所以选择把数据包成 JS 赋值语句 —— 用一行代码换掉了「必须起个本地 HTTP 服务器」的部署负担。这是静态站点生成里的常见权衡。
ensure_ascii=False 保证中文以原文写入而非 \uXXXX 转义,文件体积更小也便于人工排查。
4.5 覆盖发布的顺序
def _publish_to(self, out):
if out.exists():
shutil.rmtree(out)
shutil.copytree(TEMPLATE_DIR, out)
assets = out / "assets"
assets.mkdir(parents=True, exist_ok=True)
先删后拷,保证不会残留上一次发布的废弃图片(比如某个热点删掉了,它的照片不该还留在 assets/ 里)。
assets.mkdir(exist_ok=True) 是一层兜底。因为 template/assets/ 在版本库里是个空目录,而 git 不跟踪空目录 —— 别人 clone 下来这个目录不存在,shutil.copy2 就会失败。仓库里放了个 .gitkeep 占位,代码里再补一层 mkdir,双保险。
五、网页端实现要点
五.1 用 <button> 而不是 <div> 做热点
const dot = document.createElement("button");
dot.type = "button";
dot.className = "hot";
dot.textContent = i + 1;
dot.setAttribute("aria-label", spot.name || ("区域 " + (i + 1)));
dot.addEventListener("click", () => showDetail(spot, dot));
选 <button> 免费获得三件事:
- 键盘可达 —— 自动进入 Tab 顺序,Enter/Space 触发 click,不需要手写
tabindex和keydown处理 - 语义正确 —— 屏幕阅读器会播报为「按钮」,
aria-label提供区域名称而非无意义的序号 - 焦点样式 —— 可以用
:focus-visible给键盘用户单独描边,鼠标点击时不出现难看的轮廓
type="button" 必须显式写。<button> 的默认 type 是 submit,虽然此处没有 <form> 不会有实际影响,但显式声明是好习惯。
闭包 () => showDetail(spot, dot) 捕获了当前迭代的 spot 和 dot。forEach 的回调每次迭代都创建新的作用域,所以不存在经典的「循环变量共享」问题(那是 var + for 才有的坑)。
5.2 等图片加载完再放热点
// 需要原始尺寸做旧数据换算,等图片加载完再放点
if (el.img.complete && el.img.naturalWidth) place();
else el.img.addEventListener("load", place, { once: true });
为什么需要等?因为旧版数据的兼容换算依赖图片原始尺寸 naturalWidth,图片没加载完这个值是 0。
两个细节:
el.img.complete检查 —— 图片可能已在缓存中,src赋值后立刻就是 complete 状态,此时load事件不会再触发。只监听load会导致热点永远不出现(这类 Bug 在开发时不易察觉,因为首次加载正常,刷新后才出问题){ once: true }—— 监听器触发一次后自动移除。切换视图会多次赋值src,不用 once 就会累积一堆监听器,造成热点重复渲染
5.3 状态清理:容易漏掉的残留
function showDetail(h, dot) {
el.zone.textContent = h.name || "未命名区域";
const text = (h.description || "").trim();
el.desc.textContent = text || "(暂无说明)";
el.desc.classList.toggle("hint", !text);
// 关键:无图时必须隐藏,否则会残留上一个区域的照片
if (h.image) {
el.photo.src = h.image;
el.photo.alt = h.name || "";
el.photo.hidden = false;
} else {
el.photo.hidden = true;
el.photo.removeAttribute("src");
}
el.box.querySelectorAll(".hot.active").forEach(d => d.classList.remove("active"));
if (dot) dot.classList.add("active");
}
三处「必须处理反面情况」的地方:
图片 —— 如果只写 if (h.image) el.photo.src = ...,那么从有照片的区域切到没照片的区域时,<img> 会保留上一张图。用户看到的是错误信息,比看到空白严重得多。else 分支里既要 hidden = true 也要 removeAttribute("src"),前者管显示,后者释放引用。
说明文字 —— .trim() 之后判空,把只含空格的说明也当作空处理,避免看起来像渲染失败。classList.toggle("hint", !text) 用第二个参数强制指定增删,比 if/else 加 add/remove 更简洁,也不会因分支漏写导致状态残留。
高亮态 —— 先清除所有 .active 再给当前点加上。这里用 querySelectorAll 全量清除而非记录「上一个选中的元素」,逻辑更健壮:即使因为重渲染导致引用失效,也不会留下多个高亮点。
5.4 视图切换与渐进增强
// 只有确实有分拆图时才显示切换按钮
const hasExploded = !!(views.exploded && views.exploded.image);
el.views.hidden = !hasExploded;
if (hasExploded) {
el.views.querySelectorAll(".view-btn").forEach(btn =>
btn.addEventListener("click", () => renderView(btn.dataset.view))
);
}
两点值得注意:
双向显式赋值。 el.views.hidden = !hasExploded 两个方向都设置,不依赖 HTML 里预先写的 hidden 属性。依赖初始标记的写法很脆 —— 有人改了 HTML,JS 这边就静默失效了。
按钮用 data-view 携带目标视图:
<button type="button" class="view-btn" data-view="overview">整体视图</button>
<button type="button" class="view-btn" data-view="exploded">分拆视图</button>
btn.dataset.view 直接读出 "overview" / "exploded",与数据结构里的 key 完全对应。不需要 if-else 判断点了哪个按钮,加第三个视图也只是加一行 HTML。
renderView 里同步按钮激活态:
el.views.querySelectorAll(".view-btn").forEach(b =>
b.classList.toggle("active", b.dataset.view === name)
);
一次遍历,用比较结果直接决定增删,不需要先清空再设置。
5.5 向后兼容层
// 兼容旧版扁平结构 {overview, exploded, hotspots}
function normalize(raw) {
if (raw.views) return raw.views;
return {
overview: { image: raw.overview || "", hotspots: raw.hotspots || [] },
exploded: { image: raw.exploded || "", hotspots: [] },
};
}
// 旧项目存的是像素坐标,按图片原始尺寸换算成 0-1 比例
function toRatio(h, w, ht) {
let { x, y } = h;
if (x > 1 || y > 1) {
x = w ? x / w : 0;
y = ht ? y / ht : 0;
}
return { x, y };
}
x > 1 这个判据朴素但有效:归一化坐标不可能超过 1,超过的一定是像素值。
这种「在数据入口处做一次规整,下游只面对统一格式」的模式很值得推广。渲染逻辑完全不知道旧格式的存在,兼容代码集中在两个小函数里,将来要淘汰旧格式只需删掉它们。
Python 侧有个对称的实现 _migrate(),额外承担相对路径解析:
def _migrate(self, raw, base=None):
def resolve(p):
if not p:
return ""
path = Path(p)
if base and not path.is_absolute():
path = (base / path).resolve()
return str(path)
...
for view in project["views"].values():
for spot in view["hotspots"]:
spot.setdefault("name", "新区域")
spot.setdefault("description", "")
spot["image"] = resolve(spot.get("image", ""))
spot["x"] = float(spot.get("x", 0) or 0)
spot["y"] = float(spot.get("y", 0) or 0)
base 参数让示例项目可以用 ../assets/office.png 这样的相对路径书写,打开时按项目文件所在目录解析成绝对路径。float(spot.get("x", 0) or 0) 里的 or 0 同时兜住了 None 和 "" 两种脏数据。
(raw["views"] or {}).get(key) or {} 这种连续 or {} 看着啰嗦,但能兜住 {"views": null}、{"views": {"overview": null}} 这类手工编辑 JSON 时产生的结构。
六、响应式布局
main{
display:flex;
gap:24px;
align-items:flex-start;
padding:12px 24px 32px;
max-width:1500px;
margin:0 auto;
}
#viewer{flex:1 1 auto;min-width:0}
#detail{
flex:0 0 360px;
background:var(--panel);
border:1px solid #2c2c2c;
border-radius:8px;
padding:20px;
}
@media(max-width:900px){
main{flex-direction:column}
#detail{flex:1 1 auto;width:100%}
}
左图右详情,窄屏改为上下堆叠。
min-width: 0 这行容易被忽略但很重要:flex 项目的默认 min-width 是 auto,意味着它不会收缩到小于内容的固有尺寸。图片很宽时,#viewer 会拒绝收缩,把右侧面板挤出视口。设为 0 才允许正常收缩。
七、总结:几个可复用的经验
坐标用归一化比例,不用像素。 只要显示尺寸可能变化(响应式、多分辨率、缩放),比例都比像素健壮。代价只是存取时各做一次乘除。
绘制与命中检测共用几何计算。 把 _fit_rect() 的结果缓存给点击处理复用,从结构上排除了「画在一处、点在另一处」的可能。
定位随代码分发的资源用 Path(__file__).resolve().parent。 相对路径依赖 cwd,而 cwd 不可控。
程序化赋值不应触发用户输入事件。 wxPython 用 ChangeValue,Qt 用 blockSignals,前端框架里对应「受控组件的静默更新」。这是双向绑定的通用陷阱。
在数据入口做一次规整。 兼容层集中在少数几个函数里,下游只面对统一格式,将来删除旧格式支持时改动面极小。
处理反面情况和正面情况一样重要。 「有图片就显示」必须配上「没图片就隐藏」,否则残留的是错误信息而非空白。
离线交付的静态页用 <script> 注数据,不用 fetch 读 JSON。 后者在 file:// 下会被 CORS 拦截。
用语义化元素换取免费的可访问性。 <button> 相比 <div> 自带键盘导航、焦点管理和屏幕阅读器支持。
附:项目结构
LUMI_Space_Designer_V3_1_Complete/
├── editor/
│ └── lumi_designer.py # wxPython 编辑器(约 560 行)
├── template/ # 发布模板,会被整体拷贝
│ ├── index.html
│ ├── css/style.css
│ ├── js/app.js # 展示逻辑(约 125 行)
│ └── assets/.gitkeep # 占位,保证空目录进版本库
├── demo_project/project.json # 示例项目
└── assets/ # 测试素材
发布产物:
LUMI_Showroom/
├── index.html # 双击即可打开
├── project-data.js # 标注数据
├── assets/ # 所有图片自动收集到此
└── css/ js/
自包含、纯静态、可离线打开,整个目录压缩后即可交付。
更多推荐

所有评论(0)