图编辑器的多选排版:如何同时保持顺序、对齐和可读间距
「左对齐」「水平分布」通常只占工具栏里的一个小图标。可一旦选中的节点大小不同、原本间距也不一致,简单改一个坐标就会让节点重叠,甚至把原有的阅读顺序打乱。
这个需求来自基于 AntV X6 构建的 XFlow 图编辑器:用户在画布中多选节点后,可从节点工具栏触发四向对齐,以及水平、垂直居中和等距分布;整组位置变更会被收敛为一次撤销操作。
这里不展开自动布局引擎,只看一次多选操作:用户框选几个节点后,编辑器怎样即时把它们摆好。多选排版会移动坐标,其中的排序、边界盒和交互取舍都在这一步发生。
下文的类型和代码均为抽象示例,对应 AntV X6 的
Node.getBBox()边界盒读取和node.position()坐标写回;重点仍是算法约束与工程取舍。
实际调用中,节点工具栏会在这批 node.position() 前后分别调用 startBatch() 和 stopBatch(),让多次坐标写回在撤销栈里作为一次操作记录。
从「移动节点」改写问题
把所有节点的 x 改成同一个值,左对齐就完成了;但原本上下排开的节点可能压在一起。反过来,若完全按传入数组逐个摆放,调用方的顺序一变,图上节点就会跟着跳。
多选排版处理的是一组带边界的矩形:
1 | type Box = { |
以左对齐为例,需要同时满足四项约束。
- 每个节点的左边界都等于整体最小
x; - 节点仍按原来的纵向顺序排列;
- 相邻节点之间至少保留一个最小间距;
- 节点宽高可以不同。
这些约束落到代码里,排版代码还要逐项处理。
排序,固定视觉顺序
左右对齐时按 y 排序,上下对齐时按 x 排序。排序负责固定「谁排在前面」这件事,结果好看只是附带效果。
1 | const sortByY = (nodes: Node[]) => |
先复制数组,是为了不改写调用方保存的选中顺序。对 n 个节点来说,排序的 O(n log n) 也是这套排版里最主要的成本。
用边界盒计算对齐和分布
节点位置通常表示左上角,但对齐和分布关注的是左右、上下边界以及中心点。先汇总选区的极值和尺寸和:
1 | const bounds = { |
有了这些量,左对齐的横坐标就是 minX;右对齐则是 maxX - node.width。不同尺寸的节点因此能共享同一条视觉边界。
最小间距防止重叠
左对齐并不意味着纵坐标保持不动。假设前一个节点的下边界为 previousBottom,当前节点原始纵坐标为 y,最小间距为 gap,新位置应为:
$$
nextY = previousBottom + \max(y - previousBottom, gap)
$$
这条式子有两个效果:原本间距足够时不改动;间距不足或发生重叠时,把节点推到安全位置。
1 | let previousBottom = -Infinity; |
空间不够时,选区会向外撑开,原外接盒也随之变化。这里优先保证节点不挤在一起,即使图会占得更宽或更高。
等距分布的公式
横向等距分布的难点是节点宽度不相同。可用空白宽度等于总跨度扣除全部节点宽度:
$$
freeSpace = maxX - minX - \sum width_i
$$
若有 n 个节点,共有 n - 1 个间隔:
$$
spacing = \frac{freeSpace}{n - 1}
$$
实际工程中通常还要设置下限:
1 | const spacing = Math.max( |
随后按 X 轴排序,依次把每个节点放到「前一节点右边界 + spacing」。纵向分布是同一公式的轴向替换。
为什么居中选择中位数
多节点垂直居中时,需要找到一条共同的竖直轴。取第一个节点中心会让第一个节点拥有不合理的支配权;取平均值容易被远离主体的离群节点拉偏。
这里取中位数作为默认对齐轴:先把全部中心 X 坐标排好序。节点数为奇数时,取正中间的值;为偶数时,取中间两个值的平均值。它不追求最小二乘意义上的平均误差,换来的是某个离得很远的节点不会把整组节点的对齐线拉偏。
1 | const sortedCenters = nodes |
nextY 沿用最小间距的排布规则:它由当前节点的原始纵坐标和前一节点下边界共同决定,既保留足够的原始间距,也避免垂直居中后发生重叠。选择中位数是为了让交互结果对极端节点更稳定。
完整示例:不同宽度与纵向重叠
设 minGap = 20,下面三个节点按 y 排序后为 A、B、C。B 的顶部落在 A 的内部,C 又与 B 重叠;三种操作均从这份原始输入独立计算。
1 | const nodes = [ |
横向边界为 minX = 100、maxX = 360。三个中心 X 坐标为 140、300、190,排序后得到 [140, 190, 300],因此居中轴 centerX = 190。纵坐标按最小间距依次计算:A 保持 100,B 从 140 推到 180,C 从 175 推到 250。
| 节点 | 原始坐标与尺寸 | 左对齐结果 | 右对齐结果 | 居中结果 |
|---|---|---|---|---|
| A | (100, 100),80 × 60 |
(100, 100) |
(280, 100) |
(150, 100) |
| B | (240, 140),120 × 50 |
(100, 180) |
(240, 180) |
(130, 180) |
| C | (160, 175),60 × 70 |
(100, 250) |
(300, 250) |
(160, 250) |
左对齐统一使用 x = minX;右对齐使用 x = maxX - width;居中使用 x = centerX - width / 2。三个结果中的 y 都来自同一轮 sortByY 与 nextY 计算,因此可以直接把表格中的目标坐标与位置写回代码对照。
复杂度与边界
| 阶段 | 时间复杂度 | 说明 |
|---|---|---|
| 求极值、尺寸和、中心坐标 | O(n) |
单次遍历 |
| 轴向排序 | O(n log n) |
主导成本 |
| 写回新位置 | O(n) |
每个节点一次 |
整体复杂度为 O(n log n),额外空间通常为 O(n)。对用户一次框选几十个节点的场景来说,这个成本很轻。
但不要忽略两个边界:
- 节点数少于 2:等距分布会出现
n - 1 = 0,入口层应直接禁用,或让函数提前返回。 - 尺寸异常:零宽高、负尺寸或不可用 bbox 应在底层图模型或排版入口处被过滤。
这类算法真正的价值
多选排版不需要复杂图论;实现时,要先明确视觉约束和固定顺序,再按节点尺寸计算位置,并提前处理边界条件。
这套排版按以下顺序处理。
1 | 交互意图 |