图编辑器的多选排版:如何同时保持顺序、对齐和可读间距

「左对齐」「水平分布」通常只占工具栏里的一个小图标。可一旦选中的节点大小不同、原本间距也不一致,简单改一个坐标就会让节点重叠,甚至把原有的阅读顺序打乱。

这个需求来自基于 AntV X6 构建的 XFlow 图编辑器:用户在画布中多选节点后,可从节点工具栏触发四向对齐,以及水平、垂直居中和等距分布;整组位置变更会被收敛为一次撤销操作。

这里不展开自动布局引擎,只看一次多选操作:用户框选几个节点后,编辑器怎样即时把它们摆好。多选排版会移动坐标,其中的排序、边界盒和交互取舍都在这一步发生。

下文的类型和代码均为抽象示例,对应 AntV X6 的 Node.getBBox() 边界盒读取和 node.position() 坐标写回;重点仍是算法约束与工程取舍。

实际调用中,节点工具栏会在这批 node.position() 前后分别调用 startBatch() 和 stopBatch(),让多次坐标写回在撤销栈里作为一次操作记录。

从「移动节点」改写问题

把所有节点的 x 改成同一个值,左对齐就完成了;但原本上下排开的节点可能压在一起。反过来,若完全按传入数组逐个摆放,调用方的顺序一变,图上节点就会跟着跳。

多选排版处理的是一组带边界的矩形:

1
2
3
4
5
6
type Box = {
x: number;
y: number;
width: number;
height: number;
};

以左对齐为例,需要同时满足四项约束。

  1. 每个节点的左边界都等于整体最小 x;
  2. 节点仍按原来的纵向顺序排列;
  3. 相邻节点之间至少保留一个最小间距;
  4. 节点宽高可以不同。

这些约束落到代码里,排版代码还要逐项处理。

排序,固定视觉顺序

左右对齐时按 y 排序,上下对齐时按 x 排序。排序负责固定「谁排在前面」这件事,结果好看只是附带效果。

1
2
const sortByY = (nodes: Node[]) =>
[...nodes].sort((a, b) => a.getBBox().y - b.getBBox().y);

先复制数组,是为了不改写调用方保存的选中顺序。对 n 个节点来说,排序的 O(n log n) 也是这套排版里最主要的成本。

用边界盒计算对齐和分布

节点位置通常表示左上角,但对齐和分布关注的是左右、上下边界以及中心点。先汇总选区的极值和尺寸和:

1
2
3
4
5
const bounds = {
minX: Math.min(...nodes.map((n) => n.x)),
maxX: Math.max(...nodes.map((n) => n.x + n.width)),
widthSum: nodes.reduce((sum, n) => sum + n.width, 0),
};

有了这些量,左对齐的横坐标就是 minX;右对齐则是 maxX - node.width。不同尺寸的节点因此能共享同一条视觉边界。

最小间距防止重叠

左对齐并不意味着纵坐标保持不动。假设前一个节点的下边界为 previousBottom,当前节点原始纵坐标为 y,最小间距为 gap,新位置应为:

$$
nextY = previousBottom + \max(y - previousBottom, gap)
$$

这条式子有两个效果:原本间距足够时不改动;间距不足或发生重叠时,把节点推到安全位置。

1
2
3
4
5
6
7
let previousBottom = -Infinity;
for (const node of sortByY(nodes)) {
const box = node.getBBox();
const nextY = Math.max(box.y, previousBottom + minGap);
node.position(minX, nextY);
previousBottom = nextY + box.height;
}

空间不够时,选区会向外撑开,原外接盒也随之变化。这里优先保证节点不挤在一起,即使图会占得更宽或更高。

等距分布的公式

横向等距分布的难点是节点宽度不相同。可用空白宽度等于总跨度扣除全部节点宽度:

$$
freeSpace = maxX - minX - \sum width_i
$$

若有 n 个节点,共有 n - 1 个间隔:

$$
spacing = \frac{freeSpace}{n - 1}
$$

实际工程中通常还要设置下限:

1
2
3
4
const spacing = Math.max(
(maxX - minX - widthSum) / (nodes.length - 1),
minGap,
);

随后按 X 轴排序,依次把每个节点放到「前一节点右边界 + spacing」。纵向分布是同一公式的轴向替换。

为什么居中选择中位数

多节点垂直居中时,需要找到一条共同的竖直轴。取第一个节点中心会让第一个节点拥有不合理的支配权;取平均值容易被远离主体的离群节点拉偏。

这里取中位数作为默认对齐轴:先把全部中心 X 坐标排好序。节点数为奇数时,取正中间的值;为偶数时,取中间两个值的平均值。它不追求最小二乘意义上的平均误差,换来的是某个离得很远的节点不会把整组节点的对齐线拉偏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const sortedCenters = nodes
.map((node) => node.getBBox().getCenterX())
.sort((a, b) => a - b);
const middle = Math.floor(sortedCenters.length / 2);
const centerX =
sortedCenters.length % 2 === 0
? (sortedCenters[middle - 1] + sortedCenters[middle]) / 2
: sortedCenters[middle];

let previousBottom = -Infinity;
for (const node of sortByY(nodes)) {
const box = node.getBBox();
const nextY = Math.max(box.y, previousBottom + minGap);
node.position(centerX - box.width / 2, nextY);
previousBottom = nextY + box.height;
}

nextY 沿用最小间距的排布规则:它由当前节点的原始纵坐标和前一节点下边界共同决定,既保留足够的原始间距,也避免垂直居中后发生重叠。选择中位数是为了让交互结果对极端节点更稳定。

完整示例:不同宽度与纵向重叠

设 minGap = 20,下面三个节点按 y 排序后为 A、B、C。B 的顶部落在 A 的内部,C 又与 B 重叠;三种操作均从这份原始输入独立计算。

1
2
3
4
5
const nodes = [
{ id: 'A', x: 100, y: 100, width: 80, height: 60 },
{ id: 'B', x: 240, y: 140, width: 120, height: 50 },
{ id: 'C', x: 160, y: 175, width: 60, height: 70 },
];

横向边界为 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
2
3
4
5
6
交互意图
→ 明确几何不变量
→ 排序固定顺序
→ 计算边界与中心轴
→ 逐个写回位置并维持最小间距
→ 处理单节点与空间不足