· 对齐吸附(AlignmentGuideManager)
目标与边界
拖拽组件时,把被拖组件的关键坐标与其他组件做最近匹配,命中阈值内自动吸附,并在工具层显示提示线。 默认禁用,应用层显式启用后才会生效:
ice.alignmentGuide.enable({
threshold: 3, // 磁吸像素(= 吸附半径)
hysteresis: 1, // 脱离余量(脱离阈值 = threshold + hysteresis)
edge: true,
center: true,
spacing: false, // 等间距候选(默认关;开启时带间隙门控,见下「密集版面」)
proximity: 0, // 相关性门控半径(屏幕像素),0 = 关闭
guideStyle: { fillStyle: '#EC4899' }, // 提示线样式(透传 canvas ctx)
guideWidth: 1, // 提示线宽(屏幕像素)
guideZIndex: 10000010,
});
ice.alignmentGuide.disable();
未启用时零开销、零副作用,因此不改变既有拖拽行为。
提示线外观通过 guideStyle / guideWidth / guideZIndex 参数化,应用层可自由定制颜色、线宽、层级等。
关键坐标
- X:
left(minX)、centerX、right(maxX) - Y:
top(minY)、centerY、bottom(maxY)
三类对齐
- 边缘:source 的 left/right/top/bottom 对齐 target 的同/异侧边缘。
- 中心:source 的 centerX/centerY 对齐 target 的中心。
- 等间距:source 中心位于两个目标中心的中点(source 需位于两者之间), 且源盒要真的塞得进这两者之间的空隙(间隙门控,见下)。
谁是"可对齐目标"
目标是除被拖组件自己以外、所有参与对齐的图元,并且会展开整棵树:
-
别人容器里的子节点算目标:编辑器里能自由拖动的图元大多长在容器里 —— BPMN 的池/泳道、 状态图的复合状态、二次回路的端子。旧实现只取
ice.childNodes(顶层),这些场景会一个可用目标都没有: 实测 BPMN 示例 18 个可拖图元里 15 个在泳道内,拖动它们时提示线数量恒为 0 ("发卡"拖到"申请结束"左边缘差 3.6px 也不吸附);顶层只剩包住整张图的池子,它的边永远不在阈值内。 -
被拖组件自己的子树不算目标:子组件和父组件一起平移,把它们当候选等于"自己对自己吸附" —— 命中后每帧给出同一个固定偏移(子树相对父级的固定间距),父组件会一直偏着指针走。
-
连线(
isLine)、控制面板、draggable: false/interactive: false的图元一律不参与。 -
X/Y 两轴分别计算:
computeSnap返回{ x, y },各自取最小 delta 并分别吸附; 不能只返回单轴,否则「本已对齐的轴」会以 delta=0 抢占另一轴的吸附。 -
阈值是屏幕像素:计算时按
1 / viewport.scale换算成世界坐标,保证缩放视口下磁吸 视觉距离一致。 -
命中即锁定(粘性目标):一旦吸附到某条线,只要源盒还在
threshold + hysteresis内就继续用它, 不会因为旁边出现更近的线就改主意 —— 这是"密集版面里引导线/图元乱跳"的根因修复。
密集版面:等间距的间隙门控 + 阈值收紧(2026-09-15)
现场:ice-smart-water 的工艺流程图有 34 个单元,拖动任一单元时引导线每步换一条、
图元被拽得正负交替(实测 24 步里 19 步在吸附、相邻步位移变化最大 18 世界 px)。量化后的根因:
| 候选种类 | 「离某条候选线 ≤4px」的概率(34 个单元、0.502× 缩放) |
|---|---|
| 边缘 + 中心 + 等间距、不限距离(旧默认) | 75% |
| 边缘 + 中心 + 等间距 + 门控 80 | 49% |
| 边缘 + 中心(等间距关)+ 门控 80 | 36% |
| 仅边缘 + 门控 80 | 23% |
也就是说:旧默认下拖动时有约 3/4 的时间"贴在某条候选线上"(候选线沿拖动路径的中位间距只有 2px, 而阈值 6 屏幕 px 在 0.5× 缩放下等于 12 世界 px)。三条对策:
- 等间距:默认关 + 间隙门控。等间距的语义是「让源盒居中在 a、b 之间」, 所以只有源盒塞得进 a、b 之间那道空隙时才给候选。原实现不检查空隙,于是任意一对目标都贡献一条 中点线(O(n²)),密集版面里指针每挪一步都能撞上一条 —— 而"两个紧挨着的图元的中点"根本放不下当前元素。 门控让显式开启它的应用少一类无意义候选,但它不负责压低整体密度:工艺图上路径里平均每一步 仍有 183 条中点候选,门控只筛掉 5 条。要不要这类对齐由应用按场景选(需要"排匀一列元素"的编辑器 显式打开),密集工程图保持默认关闭。
- 阈值按版面密度收紧:阈值是吸附半径,必须明显小于候选线沿路径的间距。引擎默认 3;
ice-entity-designer这种密集版面用 2。 - 相关性门控(
proximity,默认 0 = 关):只在另一轴上与源盒相距不超过它的目标之间找对齐。 它能砍半吸附概率(工艺图 80 屏幕 px:11/24 → 7/24),但会挡掉合法的远距离对齐 (引擎对齐示例需要 ≥120、设计器流程图回归需要 ≥105 屏幕 px),因此默认关闭。
实测效果(ice-smart-water 本地构建、34 个单元、阈值 2、0.502× 缩放,同一段真机 24 步拖动):
| 指标 | 旧行为(2.11.0,无粘性、无门控) | 2.11.1 起的默认(等间距关) | 显式开等间距(带间隙门控) |
|---|---|---|---|
| 吸附步数 | 19/24 | 11/24 | 13/24 |
| 换线次数 | 每步都在换 | 8 | 12 |
| 相邻步最大位移变化 | 18 世界 px | 6.1 | 6.1 |
| 单步最大修正 | 8(含取整) | 4.4 | 4.4 |
即:抖动由"命中即锁定 + 去掉取整"兜住,与等间距开关无关;等间距开关影响的是 候选数量(吸附步数 11 → 13、换线 8 → 12)。
挂载与提示线
evtBus的mousedown记录被拖组件并一次性缓存所有候选目标的世界盒(拖拽期间目标不动, 不重复composeMatrix);随后监听该组件自己的mousemove(在引擎moveGlobalPosition之后执行), 用独立的「意图位置」累计鼠标位移,吸附只作为显示偏移,不污染意图位置;mouseup解除监听并清线。- 提示线用
toolNodes的ICERect(细线),不参与序列化;拖拽结束清除。
验收
- 单测:
tests/control-panel/AlignmentGuideManager.test.ts、tests/control-panel/alignment-dense.test.ts(密集版面 7 例, 含「候选目标展开子树但不含自己」「等间距间隙门控」两条新旧对照) - 交互:
e2e/visual/alignment.spec.ts - 示例:
examples/alignment/alignment-snap.html