Skip to main content

· 对齐吸附(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)、centerXright(maxX)
  • Y:top(minY)、centerYbottom(maxY)

三类对齐

  1. 边缘:source 的 left/right/top/bottom 对齐 target 的同/异侧边缘。
  2. 中心:source 的 centerX/centerY 对齐 target 的中心。
  3. 等间距: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%
边缘 + 中心 + 等间距 + 门控 8049%
边缘 + 中心(等间距关)+ 门控 8036%
仅边缘 + 门控 8023%

也就是说:旧默认下拖动时有约 3/4 的时间"贴在某条候选线上"(候选线沿拖动路径的中位间距只有 2px, 而阈值 6 屏幕 px 在 0.5× 缩放下等于 12 世界 px)。三条对策:

  1. 等间距:默认关 + 间隙门控。等间距的语义是「让源盒居中在 a、b 之间」, 所以只有源盒塞得进 a、b 之间那道空隙时才给候选。原实现不检查空隙,于是任意一对目标都贡献一条 中点线(O(n²)),密集版面里指针每挪一步都能撞上一条 —— 而"两个紧挨着的图元的中点"根本放不下当前元素。 门控让显式开启它的应用少一类无意义候选,但它不负责压低整体密度:工艺图上路径里平均每一步 仍有 183 条中点候选,门控只筛掉 5 条。要不要这类对齐由应用按场景选(需要"排匀一列元素"的编辑器 显式打开),密集工程图保持默认关闭。
  2. 阈值按版面密度收紧:阈值是吸附半径,必须明显小于候选线沿路径的间距。引擎默认 3; ice-entity-designer 这种密集版面用 2
  3. 相关性门控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/2411/2413/24
换线次数每步都在换812
相邻步最大位移变化18 世界 px6.16.1
单步最大修正8(含取整)4.44.4

即:抖动由"命中即锁定 + 去掉取整"兜住,与等间距开关无关;等间距开关影响的是 候选数量(吸附步数 11 → 13、换线 8 → 12)。

挂载与提示线

  • evtBusmousedown 记录被拖组件并一次性缓存所有候选目标的世界盒(拖拽期间目标不动, 不重复 composeMatrix);随后监听该组件自己的 mousemove(在引擎 moveGlobalPosition 之后执行), 用独立的「意图位置」累计鼠标位移,吸附只作为显示偏移,不污染意图位置;mouseup 解除监听并清线。
  • 提示线用 toolNodesICERect(细线),不参与序列化;拖拽结束清除。

验收

  • 单测:tests/control-panel/AlignmentGuideManager.test.tstests/control-panel/alignment-dense.test.ts(密集版面 7 例, 含「候选目标展开子树但不含自己」「等间距间隙门控」两条新旧对照)
  • 交互:e2e/visual/alignment.spec.ts
  • 示例:examples/alignment/alignment-snap.html