地图标签重叠优化:从"一团乱麻"到"井然有序"
本文基于一个真实的 Three.js 地图可视化项目,讲解当屏幕上同时出现几十个标签时,如何通过碰撞检测 + 扇区螺旋搜索,让标签自动找到不重叠的位置。
一、问题场景:标签打架了
想象你在看一张地图,上面分布着 20 多个风电场和光伏电站。每个场站都有一个图标(小圆点),旁边还要挂一个标签显示场站名称。
如果直接把标签堆在图标旁边,大概率会变成这样:
- 标签 A 盖住标签 B
- 标签 C 盖住图标 D
- 缩放地图后,标签位置全乱了,越叠越厚
核心矛盾:标签要离图标近(让人知道是谁的),但又不能互相重叠(要看得清)。
二、整体思路:四步走策略
我们的解决思路可以概括为四步:
1. 统一坐标系(把 3D 世界坐标转成屏幕像素坐标)
2. 初始化布局(先给每个标签一个"默认座位")
3. 碰撞检测 + 避让(有人坐错了,就换个位置)
4. 画连线(用折线把标签和图标连起来)
下面逐个拆解。
三、第一步:统一坐标系
Three.js 里的物体活在世界坐标系(xyz),而标签是 HTML DOM 元素,活在屏幕坐标系(px)。要做碰撞检测,必须先让它们在同一个坐标系里对话。
实现:世界坐标 → 屏幕坐标
function worldToScreen(
worldPos: THREE.Vector3,
camera: THREE.Camera,
width: number,
height: number
): { x: number; y: number } {
const vec = worldPos.clone().project(camera);
return {
x: ((vec.x + 1) / 2) * width, // [-1,1] → [0, width]
y: ((-vec.y + 1) / 2) * height, // [-1,1] → [0, height],Y 轴翻转
};
}
通俗解释:Three.js 的相机把 3D 世界"拍扁"成了一张照片。project() 就是这个拍照过程,拍完后物体的位置在 -1 到 1 之间,我们再把它映射成屏幕上的像素坐标。
为什么 Y 轴要翻转?因为 Three.js 的屏幕坐标系原点在左下角,而 DOM 的原点在左上角。
四、第二步:初始化布局——先占个座
每个标签都有两个关键属性:
- 图标矩形(iconRect):场站图标在屏幕上的占位
- 标签矩形(labelRect):标签在屏幕上的占位
默认座位规则
我们先让所有标签坐在图标的正右侧,垂直居中:
function initLayoutNodes(points, config) {
const offX = config.iconSize / 2 + config.visualGap; // 图标右边缘 + 10px 间距
const offY = -config.labelHeight / 2; // 垂直居中
return points.map((p) => {
const iconRect = {
x: p.screen.x - config.iconSize / 2,
y: p.screen.y - config.iconSize / 2,
w: config.iconSize,
h: config.iconSize,
};
const labelRect = {
x: p.screen.x + offX, // 默认在右侧
y: p.screen.y + offY,
w: config.labelWidth,
h: config.labelHeight,
};
return { ...p, iconRect, labelRect, labelOffX: offX, labelOffY: offY };
});
}
通俗解释:就像电影院找座位,我们先给每个人都发一张"右侧邻座"的票。如果这个位置没人坐,那就坐下了;如果有人,再换位置。
五、第三步:碰撞检测——怎么知道位置被占了?
AABB 碰撞检测
判断两个矩形是否重叠,游戏开发里最常用的就是 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)。
原理非常简单:
两个矩形不重叠的条件(满足任意一条即可):
- 矩形 A 的右边 < 矩形 B 的左边
- 矩形 B 的右边 < 矩形 A 的左边
- 矩形 A 的底边 < 矩形 B 的顶边
- 矩形 B 的底边 < 矩形 A 的顶边
如果上面四条都不满足,那就是重叠了。
代码实现:
interface Rect {
x: number; // 左上角 X
y: number; // 左上角 Y
w: number; // 宽度
h: number; // 高度
}
function isColliding(r1: Rect, r2: Rect): boolean {
return !(
r1.x + r1.w < r2.x || // A 在 B 左边
r2.x + r2.w < r1.x || // B 在 A 左边
r1.y + r1.h < r2.y || // A 在 B 上边
r2.y + r2.h < r1.y // B 在 A 上边
);
}
通俗解释:想象两个快递盒子放在桌上。如果 A 盒子的右边缘还没碰到 B 盒子的左边缘,那它们肯定不撞。只要四个"不撞条件"都不满足,那就是撞上了。
检测谁?
一个标签不能撞上两类东西:
- 所有图标(不管这个图标有没有被处理过)
- 已经放好的标签(按顺序处理,已放置的标签位置已经确定)
function checkNodeCollision(node, placed, allNodes): boolean {
// 1. 撞上图标?
for (const n of allNodes) {
if (isColliding(node.labelRect, n.iconRect)) return true;
}
// 2. 撞上已放置的标签?
for (const other of placed) {
if (isColliding(node.labelRect, other.labelRect)) return true;
}
return false;
}
注意:我们检测的是所有图标,而不仅仅是"已处理"的图标。因为图标本身不会动,即使还没轮到它,它的位置也是固定的。
六、第四步:扇区螺旋搜索——撞了怎么办?
如果默认位置(正右侧)撞了,就需要找新位置。
核心策略:分左右两组
我们先算一下所有图标的屏幕 X 坐标范围,取中位数,把场站分成两组:
- 左组(屏幕偏左的场站):标签默认放图标左侧
- 右组(屏幕偏右的场站):标签默认放图标右侧
这样从全局看,标签会均匀分布在图标群的两侧,而不是全部挤在一边。
let minX = Infinity, maxX = -Infinity;
for (const node of nodes) {
minX = Math.min(minX, node.screen.x);
maxX = Math.max(maxX, node.screen.x);
}
const cx = (minX + maxX) / 2; // 中位数分界线
const left: LayoutNode[] = [];
const right: LayoutNode[] = [];
for (const node of nodes) {
if (node.screen.x >= cx) right.push(node);
else left.push(node);
}
排序:从上到下依次落座
为了避免"下面的人把上面的路堵死",我们按 Y 坐标从小到大排序(从上到下):
left.sort((a, b) => a.screen.y - b.screen.y);
right.sort((a, b) => a.screen.y - b.screen.y);
扇区螺旋搜索
当默认位置发生碰撞时,我们不在 360° 乱找,而是限制在一个合理的扇区内搜索:
- 左组:在图标左上方到左下方的扇区搜索(角度
135° ~ 225°) - 右组:在图标右上方到右下方的扇区搜索(角度
-45° ~ 45°)
搜索方式是螺旋式外扩:先在内圈尝试各个角度,不行就扩大半径到下一圈继续试。
function searchInSector(
node, placed, allNodes,
rangeStart, rangeEnd, // 角度范围(弧度)
baseDist, // 起始半径
distStep, // 每圈半径增加量
angleStep, // 每步角度增量
vCenter // 垂直居中偏移
): void {
let ring = 0;
while (true) {
const radius = baseDist + ring * distStep; // 当前圈半径
const steps = Math.ceil((rangeEnd - rangeStart) / angleStep);
for (let i = 0; i <= steps; i++) {
const angle = rangeStart + i * angleStep;
// 根据极坐标算出新位置的偏移量
node.labelOffX = Math.cos(angle) * radius;
node.labelOffY = Math.sin(angle) * radius + vCenter;
// 更新标签矩形位置
node.labelRect.x = node.screen.x + node.labelOffX;
node.labelRect.y = node.screen.y + node.labelOffY;
// 不碰撞就坐下!
if (!checkNodeCollision(node, placed, allNodes)) return;
}
ring++; // 扩大一圈继续找
}
}
通俗解释:想象你在一个圆形广场上找空位。默认座位被占了,你就以图标为中心,先在内圈(半径小)沿着左半弧(或右半弧)走一圈看看有没有空位;没有就退到外层圈继续找,直到找到为止。
完整布局流程
function resolveLayout(nodes, config) {
// ... 分左右组、排序 ...
const placed: LayoutNode[] = [];
// 左组:默认放左侧,撞了就在左扇区搜索
for (const node of left) {
node.labelOffX = -baseDistLeft; // 左侧
node.labelOffY = -halfLabelH; // 垂直居中
node.labelRect.x = node.screen.x + node.labelOffX;
node.labelRect.y = node.screen.y + node.labelOffY;
if (checkNodeCollision(node, placed, nodes)) {
searchInSector(node, placed, nodes,
135 * Math.PI / 180, 225 * Math.PI / 180, // 左扇区
baseDistLeft, distStep, angleStep, vCenter);
}
placed.push(node); // 落座成功,加入已放置列表
}
// 右组:默认放右侧,撞了就在右扇区搜索
for (const node of right) {
node.labelOffX = baseDistRight; // 右侧
node.labelOffY = -halfLabelH;
node.labelRect.x = node.screen.x + node.labelOffX;
node.labelRect.y = node.screen.y + node.labelOffY;
if (checkNodeCollision(node, placed, nodes)) {
searchInSector(node, placed, nodes,
-45 * Math.PI / 180, 45 * Math.PI / 180, // 右扇区
baseDistRight, distStep, angleStep, vCenter);
}
placed.push(node);
}
}
这里
placed数组很关键:它记录了"已经安排好位置"的标签。后处理的标签只会检测与先处理标签的碰撞,而不会反过来影响前者——这保证了布局的确定性。
七、第五步:画连线——标签属于谁?
标签被扇区搜索推远后,用户可能不知道它属于哪个图标。所以我们用 SVG 画一条折线,把图标中心和标签中心连起来。
连线策略:水平优先 or 垂直优先?
根据标签相对图标的位置,选择不同的折线风格:
- 水平方向差异大(标签在很左或很右):先水平走一段,再拐向标签
- 垂直方向差异大(标签在上或下):先垂直走一段,再拐向标签
function createLabelDom(name, type, offX, offY, showLeader, ...) {
// ... 标签 DOM 创建 ...
if (showLeader) {
const labelCx = offX + labelW / 2; // 标签中心 X(相对图标中心)
const labelCy = offY + labelH / 2; // 标签中心 Y(相对图标中心)
// 计算 SVG 画布大小
const svgLeft = Math.min(0, labelCx);
const svgTop = Math.min(0, labelCy);
const svgWidth = Math.max(1, Math.max(0, labelCx) - svgLeft);
const svgHeight = Math.max(1, Math.max(0, labelCy) - svgTop);
// 创建 SVG
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
// ... 设置宽高、位置 ...
const startX = -svgLeft; // 图标中心在 SVG 内坐标
const startY = -svgTop;
const endX = labelCx - svgLeft; // 标签中心在 SVG 内坐标
const endY = labelCy - svgTop;
const path = document.createElementNS(svgNS, 'path');
// 水平差异更大:先水平走,再拐过去
if (Math.abs(endX - startX) > Math.abs(endY - startY) * 1.2) {
const midX1 = startX + (endX - startX) * 0.2;
const midX2 = startX + (endX - startX) * 0.8;
path.setAttribute('d',
`M ${startX} ${startY} L ${midX1} ${startY} L ${midX2} ${endY} L ${endX} ${endY}`);
} else {
// 垂直差异更大:先垂直走,再拐过去
const midY1 = startY + (endY - startY) * 0.2;
const midY2 = startY + (endY - startY) * 0.8;
path.setAttribute('d',
`M ${startX} ${startY} L ${startX} ${midY1} L ${endX} ${midY2} L ${endX} ${endY}`);
}
path.setAttribute('stroke', '#357739'); // 深绿色
path.setAttribute('stroke-width', '2.5');
svg.appendChild(path);
}
}
通俗解释:标签被推远后,我们需要一根"绳子"把它和图标拴在一起。如果标签在左边很远,绳子就先横着走一段再拐过去;如果标签在上方,绳子就先竖着走再拐。这样看起来更自然,不会斜着乱穿。
八、第六步:生命周期管理——别重复渲染
地图可以缩放、平移,每次相机变化,屏幕坐标都会变,标签布局也需要重新计算。但如果直接重新渲染,会出现两个问题:
- 旧标签没删掉,新标签叠上去,越叠越厚
- 事件监听器重复绑定,缩放一次触发 N 次重绘
清理旧标签
const oldGroup = tp.scene.getObjectByName('auto-labels');
if (oldGroup) {
oldGroup.traverse((child: any) => {
if (child.element && child.element.parentNode) {
child.element.parentNode.removeChild(child.element); // 从 DOM 移除
}
});
tp.scene.remove(oldGroup); // 从 Three.js 场景移除
}
解绑旧事件 + 防抖
// 解绑上一次的事件
const prev = tp.__labelListeners;
if (prev) {
tp.mitter.off('resize', prev.onResize);
tp.container.removeEventListener('wheel', prev.reRenderOnChange);
tp.container.removeEventListener('pointerup', prev.reRenderOnChange);
tp.controls.removeEventListener('change', prev.reRenderOnChange);
}
// 防抖:相机变化很频繁,150ms 内只重绘一次
const reRenderOnChange = () => {
if (timers.changeTimer) clearTimeout(timers.changeTimer);
timers.changeTimer = window.setTimeout(() => {
// 清旧 → 重新布局渲染
doLayoutAndRender(tp);
}, 150);
};
通俗解释:每次相机动了,不要立刻重绘(那样会卡),而是等用户停手 150ms 后再重绘。同时记得把上一次的"监听耳朵"摘掉,否则耳朵越长越多,最后动一下相机就会触发几十次重绘。
九、完整流程图
开始
│
▼
世界坐标 ──→ 屏幕坐标
│
▼
初始化标签矩形(默认在图标右侧)
│
▼
按屏幕 X 坐标分左组 / 右组
│
▼
每组按 Y 坐标从上到下排序
│
▼
依次落座:
├─ 默认位置(左组左侧,右组右侧)
├─ 检测碰撞(撞图标?撞已落座标签?)
├─ 碰撞 → 扇区螺旋搜索新位置
└─ 不碰撞 → 落座成功,加入已放置列表
│
▼
为每个标签创建 DOM + SVG 连线
│
▼
加入 Three.js 场景
│
▼
绑定事件(resize / wheel / 相机变化)
│
▼
触发重绘时:清旧 → 重新执行上述流程
十、效果对比
| 优化前 | 优化后 |
|---|---|
| 标签全部堆在图标右侧,互相重叠 | 标签按左右分组分布,碰撞后自动扇区避让 |
| 不知道标签属于哪个图标 | SVG 折线清晰连接标签与图标 |
| 缩放后标签越叠越厚 | 旧标签完全清除,重新布局 |
十一、关键设计决策
1. 为什么用 AABB 而不是圆形碰撞?
标签和图标都是矩形(DOM 元素),用矩形碰撞检测更精确。圆形碰撞虽然计算更快,但会漏掉矩形角落的重叠。
2. 为什么分左右两组,而不是 360° 乱搜?
如果允许标签出现在任意方向,最终效果会很乱——有的标签在上、有的在下、有的交叉。限制在水平方向的扇区内,视觉上更整齐,也减少了搜索空间。
3. 为什么按 Y 坐标排序?
从上到下依次落座,可以让上方的标签优先占据好位置,下方的标签自然往空隙里填。如果反过来从下到上,上面的标签会被迫推到更远的地方。
4. 为什么只检测"已放置"标签,不检测"未放置"标签?
未放置的标签位置还没确定,检测它们没有意义。而且按顺序处理天然避免了循环依赖。
十二、可改进的方向
- 力导向布局:当节点很多时,可以用物理引擎(斥力/引力)替代螺旋搜索,效果更自然。
- 标签聚合:当缩放级别很小时,把附近的标签聚合成一个数字气泡(如"+5")。
- 优先级排序:重要的场站先落座,不重要的场站可以被隐藏或推到更远的位置。
- 缓存布局:相机小幅度平移时,可以直接平移标签 DOM,不需要重新做碰撞检测。
总结
标签重叠优化的本质是一个**"找座位"**问题:
- 把所有人放到同一个坐标系里(世界坐标 → 屏幕坐标)
- 给每个人一个默认座位(初始化布局)
- 检查座位是否被占(AABB 碰撞检测)
- 被占了就在附近扇区一圈圈找(螺旋搜索)
- 用绳子把人和座位连起来(SVG 折线)
- 场景变化时,清空重来,记得摘旧耳朵(生命周期管理)
这套方案不需要引入庞大的物理引擎,几百行代码就能实现一个确定性强、可预测、性能可控的标签自动布局系统。