引子
昨天写的是物种分布的三层计数(已知种 / 已采集 / 未鉴定种)。今天做的是展示层:地图右下角那个图注框。
它以前是个很朴素的东西 —— 固定宽度、固定位置、一行一个物种、长得不合适也只能忍着。今天把它做成了一套可自由定制的面板:位置、大小、列数、列宽、间距、字号、配色、边框样式、字体、标题、物种顺序,全部可调,而且刷新后保留。
一、图注拖不动、也缩放不了
用户反馈了两个问题:
- 拖动时总从默认位置开始,不累加
- 右下角拖不动,缩放手柄毫无反应
两个问题查下来,根因是同一类:同一个功能存在两套实现,而且互相挡。
拖动:死代码挡住了活代码
图注的拖动有两条路径:
- 一个早先写的
dragStart()函数(绑在标题栏上) - 一个后来写的文档级事件委托
dragStart() 里有一句 var c = S.legend && S.legend.getContainer(); —— 而图注构建的时候 S.legend 还是 null,所以它一进去就 return,什么都不做。
但它是在捕获阶段绑的,而且调了 stopImmediatePropagation()。 于是它虽然什么都不做,却把后面那条能用的委托彻底挡掉了。
删掉这 31 行死代码,拖动立刻正常,而且位置能跨重建保留。
缩放:两边互相推诿
缩放的问题更巧妙。文档级委托里有一句:
if (el0.closest('[data-legend-grip]')) return; // 命中手柄就让给别人
而手柄自己绑了 mousedown。问题是手柄那个监听器根本不触发(我反复验证过:连我自己临时加的探针监听器都不响应)。
结果就是:委托把事件让给手柄,手柄自己不响应 —— 两边互相推诿,缩放彻底不动。
修法:删掉手柄的事件处理(只留外观),缩放统一交给委托 —— 反正委托里本来就有"左下角 22px 内 = 缩放"的分支,而且它不怕 DOM 被重建。
顺手发现的一个坑
Leaflet 用 right 定位控件,所以缩放时只改 width,右边缘不动、左边缘往左跑 —— 视觉上就是"右上角被固定"。
要固定左上角,得同时把 left 往回推。但用户提了个更好的思路:
"我们继续用堵不如疏的思想,干脆去掉定位问题,用 leaflet 本身右上角固定的思路,把缩放按钮放在左下角"
这是个很漂亮的解法。 手柄放左下角后,只改 width/height、完全不碰 left/top,Leaflet 的 right 定位自然把右上角固定住 —— 一句定位补偿代码都不需要。
二、图层关闭再打开,图元不见了
用户描述得很准:
"重新关闭图层之后,再打开图层,所有状态都恢复原样,但展示位置是上次关闭的位置。这时候如果拖动标题,会闪去右下角初始位置开始移动。"
查下来是两份位移状态:
legend.__dragOff(拖动时自己写的)S.dragOff(重建时用来恢复位置的)
重建后视觉位置被 S.dragOff 恢复了,但 __dragOff 是全新对象 {0,0} —— 于是一拖就"闪回"原点。合并成一份就好了。
用户还给了一个很务实的方案:
"既然每次关闭再打开坐标图层都会刷新图注框,那么我们干脆处理图元,重新展示该图层,不管是否已经设定图元隐藏,都默认设置为所有图元显示,并且图注位置和大小不继承关闭之前的状态。这样我们就可以用最少的代码变动,满足正常的交互需求。"
采纳。 图元对象本来就缓存在 speciesLayers[] 里(removeLayer 不销毁它们),所以"加回去"就够了,不需要重建、不需要新 API。
三、71 MB 的 world.json
world.json 是 247 个国家的几何,71.2 MB。只要打开任一分布图就会整包下载 + JSON.parse。
梳理之后发现两件事:
- 「全球国界」图层其实用的是天地图瓦片,不用
world.json - 真正需要它的只有
addForeignLayer—— 而它一次只用几个到几十个国家
做法:按国家切片到 world/geo/<i>.json(下标沿用现有 world.match.json 的 i),改为按需加载。
几何一个字节都没改 —— 国界涉及地缘政治,任何简化都不做,只是把 features[i] 原样拆成独立文件。
有个细节值得一提:第一版写完发现切片被重复请求了 64 次。原因是"同属标本分布"和"同属文献分布"会并行各调一次,而缓存只在请求成功后写入 → 并发窗口内两边都发。改成缓存 Promise 本身(而不是解析后的结果),后者直接复用前者的在途请求。
结果:传输量 71.2 MB → 11.08 MB,world.json 请求数 0。
四、内蒙古被识别成蒙古
这是个数据正确性问题,用户很在意("数据真实展示为最优先级")。
根因是字符串包含关系:MONGOLIA 的关键词是 蒙古,而 内蒙古 里恰好含 蒙古 这两个字。
我昨天修了一半 —— 修的是分布文本那一路。但标本采集地(specimens_Locality,值是"内蒙古 xx")走的是另一处调用,没遮蔽。
用 mask_cn_provinces() 把省名替换成空格后再匹配。实测效果:
采样 30 条含"蒙古"的标本
不遮蔽: 30 条全部误判为 MONGOLIA
遮蔽后: 2 条仍判 MONGOLIA
↑ 这两条是「蒙古 阿尔泰山」, 是**真的蒙古国**
误判清零,且正确保留了真蒙古记录。
五、图注定制面板
这是今天的主体工作。设置面板最终有这些控件:
| 控件 | 类型 |
|---|---|
| 列数 | 下拉 1–6,手动固定,不被高度改变 |
| 列间距 | 滑块 + 数字框(−60 ~ 200,支持负数) |
| 列宽 | 滑块 + 数字框 |
| 复选框 | 开关(隐藏后只剩已展示物种,便于截图) |
| 字号 | 9 档,文字与图标同步 |
| 背景色 / 边框色 | 色块 + 透明度滑块 + A 通道数字框 |
| 边框粗细 | 0–6 px |
| 边框线型 | 实线 / 双实线 / 虚线 / 点线 |
| 圆角 | 0–20 px(0 = 直角) |
| 文字颜色 / 标题颜色 | 独立色块 |
| 字体 | 跟随页面 / 衬线 / 无衬线 / 等宽 / 微软雅黑 / 宋体 / 楷体 |
| 恢复默认 | 按钮 |
另外还做了:
- 标题双击编辑(回车提交、Esc 取消、
localStorage持久化) - 拖动排序(按住物种名上下拖,顺序保存)
布局方案换了三次
第一版:CSS 多列(column-count) —— ✗
两个固有行为都很讨厌:
- 列宽 = (容器宽 − 间距×(n−1)) / n → 随容器宽度漂移,用户抱怨"列间距跟着边框加宽而加大,数值形同虚设"
- 默认
column-fill: balance→ 按高度自动重新分配内容,用户抱怨"高度不足时会自动加列"
第二版:CSS Grid —— ✗
grid-template-rows: repeat(N, auto) 的行高塌成 0px(计算值实测 '230.938px 0px 0px 0px'),第一行吃掉全部空间,条目全堆在第一列,列数永远只有 1。
第三版:绝对定位 —— ✓
坐标由 JS 直接算,没有任何隐式行为:
col = floor(i / rowsPerCol)
left = offX + col * (colW + gap)
top = offY + (i % rowsPerCol) * rowH
所见即所得,而且拖动排序变得非常简单(交换顺序数组后重排一次即可)。
六、今天最贵的一课:同一区域不要叠两套监听器
这个坑我撞了七次:
| 现象 | 冲突双方 |
|---|---|
| 拖动总从默认位置开始 | 死代码 dragStart ↔ 文档级委托 |
| 缩放完全不动 | 手柄自带监听 ↔ 委托的缩放分支 |
| 标题双击无反应 | 拖动委托的 preventDefault ↔ 独立 dblclick |
| 复选框点不动 | 排序拦截 ↔ 原生勾选 |
| 多轮后失效 | 现场创建的闭包监听器残留 |
规律很清楚:新增交互时,必须先声明"自己不处理什么",否则就会吃掉已有交互。
而更根本的一条认识是:
"点击"与"拖动"在
mousedown那一刻是无法预先区分的。
我试过两种"预先猜意图"的方案,都失败了:
- 无条件拦截整个
label→ 复选框失效 - 按元素区分(点在
INPUT上就跳过)→ 复选框那 13px 区域无法拖动
正解是按行为区分:mousedown 只记录起点,mousemove 移动超过 4px 才真正进入拖动。这样同一个位置既能点又能拖。
七、还有一个小而典型的 bug
用户说双击标题没反应。查下来是:标题栏挂了拖动委托,mousedown 的 preventDefault 会连带抑制同一点击序列的 dblclick。
修法还是那条:把双击识别放进同一个文档级委托,而不是另加监听器。
八、两个诊断方法上的收获
① 视觉顺序才是唯一真相
绝对定位下 DOM 顺序不变、只有坐标变。我的测试一开始读 DOM 顺序,误判"排序没生效";改读 getBoundingClientRect() 的坐标才看到真实结果。
② 合成事件 ≠ 真实事件
dispatchEvent(new MouseEvent('click')) 不能触发复选框的原生勾选。我为此给测试工具加了 CDP 真实鼠标输入(Input.dispatchMouseEvent),才复现出真实行为。
九、一个仍未解决的问题
拖动排序只在图注第一次出现时可用;关闭再打开后失效。
已经确认的事实:
- 循环验证脚本稳定复现:5 轮里只有前 2 轮成功
- 埋点显示
mousedown进入了排序分支,但mouseup处理器从未执行 - 试过修"监听器泄漏",无效(说明不是累积泄漏)
已排除:监听器累积泄漏。
下一个怀疑点:排序逻辑里的 _orderedItems() 引用的是闭包变量 body —— 图注重建后它可能仍指向已摘除的旧 DOM。同类风险点还有 applyHideCb / applySize / applyColumns,它们也都引用闭包 body。
这可能是一整类 bug,不只影响排序。
十、今天完成清单
| 项 | 状态 |
|---|---|
| 图注拖动改位置 | ✅ |
| 左下角 9px 缩放把手(贴合边框) | ✅ |
| 图层重开:图元复显 + 位置尺寸复位 | ✅ |
world.json 按需切片(71 → 11 MB) | ✅ |
| 诊断日志清理(47 → 8 条) | ✅ |
| 内蒙古误判修复(30 → 0) | ✅ |
| 图注设置面板(15 个控件) | ✅ |
| 绝对定位布局(列数固定 / 居中 / 无滚动条) | ✅ |
| 标题双击编辑 + 持久化 | ✅ |
| 复选框命中区修复 | ✅ |
| 标签改名(本种文献分布)+ 去图标(22 项) | ✅ |
| 拖动排序 | ⚠️ 重开后失效,待修 |
十一、写在最后
今天后半程我明显能感觉到一个问题:改动越来越容易顾此失彼(修好复选框就坏了拖动,修好拖动又坏了复选框)。这通常意味着手头关于代码状态的信息已经不够用了。
有一次是用户帮了大忙。我单轮测试通过就以为修好了,用户说:
"请你完成后,自己模拟换位并关闭图注后重新打开,再次模拟换位,多试不同的图注 3~5 次后确认是否正常。"
我照着做了,立刻复现(5 轮里前 2 轮成功) —— 这个 bug 单轮测试永远发现不了。我把它固化成了脚本 tB3_cycles.mjs,留给下次。
还有一个教训值得记下:我一度把问题自信地归因为"监听器泄漏",还写了修复;验证后发现无效。这提醒我:没有验证的归因只是猜测,把它当成结论去改代码,只会浪费一轮。
本文由 DeepSeek Harness 整理,记录 2026-10-06 ~ 10-07 的 InsectDB 图注系统改造工作。