一、解决Overlay重叠问题的尝试
我在最近的开发工作中遇到了一个问题。我开发的项目需要给地图上的站点添加Tooltip提示框(即Overlay),但是由于地图上的部分站点比较密集,导致Tooltip的重叠比较严重,部分Tooltip的内容就会被遮挡。业主对此十分不满,要求我们进行优化。
一些解决的尝试
最初我使用了一些 "治标" 的方式进行优化,例如缩小Tooltip的尺寸、添加折叠功能(Tooltip默认只是小尺寸只展示部分数据,鼠标悬停上去后Tooltip展开显示全部的数据)、将当前聚焦的Tooltip移动到最上层等方法,当然也尝试过做分级展示(在默认的缩放级别下只展示部分重要的Tooltip,当层级增大到一定程度后再展示全部的Tooltip)。
可惜的上述的办法都无法彻底解决Tooltip重叠的问题,后来我参考了其它项目后,找到了一种"治本"的解决方案。
手动调整Tooltip的位置
之前Tooltip都被我放置在站点的上方:

但是实际上也可以将Tooltip放置在下方、左边或右边:



因此通过调整Tooltip的方向,就可以让两个邻近的站点的Tooltip错开,从而解决重叠的问题。

于是基于上面的这种思路,我制定了一个解决方案:只展示部分重要站点的Tooltip,然后手动去调整每个Tooltip的方向,尽可能的避免重叠。
我虽然使用了上面的这种方法暂时了应付了业主的需求,但是我对它并不智能。其实原本在我们的系统中是支持用户可以自主配置展示哪些站点的Tooltip,但是如果这么做地图上Tooltip的数量就是不固定的,这样我是没有办法手动调整方向避免重叠的。因此我只能废弃这一功能,只固定展示部分重要站点的Tooltip。......除非有一套自动调整tooltip布局的机制,于是便有了这篇文章 😊。
二、碰撞检测
之前的"手动布局调整"方案主要的步骤就是先人工识别哪些Tooltip出现了重叠,然后再手动对重叠的Tooltip进行位置的调整。现在想要实现"自动布局调整"我认为也要延续这样的思路,只不过要用程序来代替人工。因此第一步就是要实现自动识别哪些Tooltip出现了重叠,这其实就是要做碰撞检测。
由于我的目标Tooltip,它本质上是个Element元素,而Element都是矩形,因此我决定使用轴对齐边框算法来进行碰撞检测。
轴对齐边界框 (AABB) 算法
轴对齐边界框(Axis-Aligned Bounding Box,简称 AABB)是一种在计算机图形学、物理引擎和碰撞检测中广泛使用的算法。它的核心思想是用一个与坐标轴对齐的矩形(在 2D 中)或长方体(在 3D 中)来包裹一个物体,从而简化碰撞检测的计算。
算法步骤
构建 AABB
- 遍历物体的所有顶点,找到每个维度的最小值和最大值。
- 构建边界框:
-
- 在 2D 中:
minX = min(所有顶点的 x 坐标)
,maxX = max(所有顶点的 x 坐标)
,minY
和maxY
同理。 - 在 3D 中:
minX = min(所有顶点的 x 坐标)
,maxX = max(所有顶点的 x 坐标)
,minY
、maxY
、minZ
、maxZ
同理。
- 在 2D 中:
碰撞检测
- 判断两个 AABB 是否相交:
-
- 在 2D 中:如果
box1.maxX < box2.minX
或box1.minX > box2.maxX
或box1.maxY < box2.minY
或box1.minY > box2.maxY
,则两个边界框不相交。 - 在 3D 中:如果
box1.maxX < box2.minX
或box1.minX > box2.maxX
或box1.maxY < box2.minY
或box1.minY > box2.maxY
或box1.maxZ < box2.minZ
或box1.minZ > box2.maxZ
,则两个边界框不相交。
- 在 2D 中:如果
优缺点
优点
- 计算简单:AABB 的碰撞检测只需要比较边界框的坐标,计算量小,适合快速筛选。
- 内存占用小:只需要存储两个点(最小点和最大点),内存占用低。
- 适合动态物体:如果物体移动或旋转,可以快速更新边界框。
缺点
- 精度较低:AABB 是轴对齐的,对于旋转的物体,边界框会变得很大,导致碰撞检测的精度下降。
- 不适合复杂形状:对于不规则形状的物体,AABB 可能会包含大量空白区域,导致误判。
代码示例
2D AABB 碰撞检测
JavaScript
function isAABBColliding(box1, box2) {
return !(
box1.maxX < box2.minX ||
box1.minX > box2.maxX ||
box1.maxY < box2.minY ||
box1.minY > box2.maxY
);
}
3D AABB 碰撞检测
JavaScript
function isAABBColliding(box1, box2) {
return !(
box1.maxX < box2.minX ||
box1.minX > box2.maxX ||
box1.maxY < box2.minY ||
box1.minY > box2.maxY ||
box1.maxZ < box2.minZ ||
box1.minZ > box2.maxZ
);
}
对Tooltip使用AABB算法
Tooltip的边界框
想要使用AABB算法首先要找到Tooltip的边界框。由于Tooltip本质是Element元素因此就可以借助Element.getBoundingClientRect()
方法。
BoundingClientRect()
方法会返回一个 DOMRect
对象,是包含整个元素的最小矩形(包括 padding
和 border-width
)。
该对象使用 left
、top
、right
、bottom
、x
、y
、width
和 height
这几个以像素为单位的只读属性描述整个矩形的位置和大小。除了 width
和 height
以外的属性是相对于视图窗口的左上角来计算的。

根据DOMRect
对象我们可以计算出Tooltip的边界框,因此在后面我会将DOMRect
对象就视为Tooltip的边界框。
JavaScript
const rect = tooltip.getElement().getBoundingClientRect()
// 最小X
const minX = rect.x
// 最大X
const maxX = rect.x + rect.width
// 最小Y
const minY = rect.y
// 最大Y
const maxY = rect.y + rect.width
但是注意,上面的代码只能获取某一个方位上Tooltip的边界框,而一个站点位置上我设计了上、下、左、右四个方位,在这四个方位上的Tooltip我都有可能去进行碰撞检测,因此我最终写了一个方法实现通过一个Tooltip来计算四个方位上的边界框。
JavaScript
// 计算提示框在上下左右四个位置的Rect
function getRects(tooltip) {
const positioning = tooltip.getPositioning();
const offset = 10;
const rect = tooltip.getElement().getBoundingClientRect();
let cx,
cy,
w = rect.width,
h = rect.height;
switch (positioning) {
case POSITIONINGS.TOP:
cx = rect.x + rect.width / 2;
cy = rect.y + rect.height + offset;
break;
case POSITIONINGS.RIGHT:
cx = rect.x - offset;
cy = rect.y + rect.height / 2;
break;
case POSITIONINGS.BOTTOM:
cx = rect.x + rect.width / 2;
cy = rect.y - offset;
break;
case POSITIONINGS.LEFT:
cx = rect.x + rect.width + offset;
cy = rect.y + rect.height / 2;
break;
}
const result = {
TOP: {
x: cx - w / 2,
y: cy - h - offset,
w,
h,
},
RIGHT: {
x: cx + offset,
y: cy - h / 2,
w,
h,
},
BOTTOM: {
x: cx - w / 2,
y: cy + offset,
w,
h,
},
LEFT: {
x: cx - w - offset,
y: cy - h / 2,
w,
h,
},
};
return result;
}
注意
由于我在四个方位上的Tooltip都有一段10像素的偏移,因此我在计算边界框时将offset
也加入了计算当中。注意要将可能的偏移加入到计算当中来,否则会导致碰撞检测的结果失真。


构建判断方法
之后我们就可以构建一个检测Tooltip是否重叠的方法。
JavaScript
/**
* @abstract 判断两个矩形是否重叠 (碰撞检测- 使用aabb算法)
* @param {{x: number, y: number, w: number, h: number}} a 矩形a
* @param {{x: number, y: number, w: number, h: number}} b 矩形b
* @returns {boolean} 是否重叠
*/
function isRectOverlay_aabb(a, b) {
return !(
a.x + a.w <= b.x ||
b.x + b.w <= a.x ||
a.y + a.h <= b.y ||
b.y + b.h <= a.y
);
}
三、自动布局优化(四个固定位置中选择 + 贪心算法)
在实现了碰撞检测之后,接下来就要进行自动布局优化,也就是我希望可以用程序来自动判断Tooltip放在哪个方位会更好。这本质上其实是一个求最优解的问题,因此我决定使用最简单的贪心算法来实现。
贪心算法(贪心选择)
定义
贪心算法是一种在每一步选择中都力图使局部最优解能够逐步达到全局最优解的算法。与其他算法不同的是,贪心算法在选择过程中不进行回溯,选择的过程往往基于某种局部最优策略。在一些特定的问题中,贪心算法可以通过逐步构建最优解来实现全局最优。
基本思想
贪心算法的核心思想是通过一系列局部最优选择来构建全局最优解。
优缺点
✅ 优点
- 简单直观,易于实现
- 通常时间复杂度较低(O(n log n)常见)
❌ 缺点
- 不能保证所有问题都得到最优解
- 需要严格证明其正确性
使用贪心算法进行布局优化
既然贪心算法的思路是在每次抉择时都追求局部最优解,最终达到全局最优解。因此我指定了如下的使用方式:
遍历Tooltip列表,每个Tooltip的每个位置都去检测其与其它已添加的Tooltip是否重叠,选择最优的位置(局部最优解)。
局部最优解是什么?
这时候我就遇到了一个问题,我如何去判断哪个位置是最优位置?
我进行了一些尝试,在最初我将重叠数最少作为局部最优解。但是我很快就发现这样明显是不对的,因为很可能就会出现一种情况:
在A位置虽然只与一个Tooltip重叠,但是却完全把这个Tooltip给遮挡住了;在B位置虽然与好几个Tooltp,但是都只是擦边。
上述的情形下明显B位置更符合我们的需求,但根据重叠数最少的原则A位置才是最优的😅。
之后我又尝试将中心点距离最大作为局部最优解,我会去计算某个Tooltip中心点与其它Tooltip中心点之间的总距离,最后选择总距离最大的位置。但是最后我发现这样也不准确,因为我Tooltip是个矩形不是圆形,对于矩形来说它的中心点到各个边的距离是不一样的,因此用中心点距离来表示重叠程度是有误差的。
最后我还是选择用重叠面积最小作为局部最优解。这样最能够反应重叠的程度。
JavaScript
/**
* 计算两个矩形之间的重叠面积
* @param {{x: number, y: number, w: number, h: number}} a 矩形a
* @param {{x: number, y: number, w: number, h: number}} b 矩形b
* @returns {number} 重叠面积
*/
function calculateOverlapArea(a, b) {
const dx = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
const dy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
return dx * dy;
}
使用算法
JavaScript
const POSITIONINGS = {
TOP: "bottom-center",
RIGHT: "center-left",
BOTTOM: "top-center",
LEFT: "center-right",
};
// overlay 布局优化
function layoutOptimized(overlays) {
const overlaysRects = []; // 所有overlay的矩形
const chosenPositions = []; // 所有overlay的最终位置
// 遍历所有overlay
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i]; // 当前overlay
const rects = getRects(overlay); // 当前overlay的矩形 (上下左右四个位置的矩形)
let maxWeight = -Infinity; // 最大权重
let bestPos = "TOP"; // 最佳位置
// 遍历所有位置
for (let posIdx = 0; posIdx < Object.keys(POSITIONINGS).length; posIdx++) {
const posKey = Object.keys(POSITIONINGS)[posIdx];
const rect = rects[posKey]; // 当前位置的矩形
let overlayArea = 0; // 当前位置下的重叠面积
// 遍历所有已布局的overlay的矩形,计算重叠面积
for (let j = 0; j < overlaysRects.length; j++) {
overlayArea += isRectOverlay_aabb(rect, overlaysRects[j])
? calculateOverlapArea(rect, overlaysRects[j])
: 0;
}
// 根据重叠面积计算权重
const posWeight = -overlayArea;
// 更新最佳位置
if (posWeight > maxWeight) {
maxWeight = posWeight;
bestPos = posKey;
}
if (posWeight === 0) break;
}
overlaysRects.push(rects[bestPos]);
chosenPositions.push(bestPos);
}
overlays.forEach((overlay, idx) => {
overlay.setPositioning(POSITIONINGS[chosenPositions[idx]]);
});
}
基本思路

最后整体呈现出来的效果还可以
优化
我在使用的过程中发现当前的布局调整还存在明显的问题,比如下面的这个例子中 Tooltip3与Tooltip20、Tooltip2与Tooltip17 就明显不是最优解。

造成这种现象主要是因为,我在统计Tooltip的重叠面积的过程中并没有将所有的Tooltip都加入计算,而只将已优化的Tooltip加入了计算。假设一共有20个Tooltip,第一个Tooltip的重叠面积一定为零,因为此时没有其它已优化的Tooltip;在计算第二个Tooltip的重叠面积时,会将第一个Tooltip加入重叠面积的计算;而计算第二十个Tooltip的重叠面积时则会将其它十九个Tooltip加入重叠面积的计算。
了解了问题的根源就可以进行优化了,我准备在每个Tooltip计算重叠面积时都将其它的Tooltip都加入计算,此时的基本流程就是这样:

调整后的代码如下:
ini
const POSITIONINGS = {
TOP: "bottom-center",
RIGHT: "center-left",
BOTTOM: "top-center",
LEFT: "center-right",
};
function layoutOptimized2(overlays) {
const overlaysRects = []; // 所有overlay的矩形
const chosenPositions = []; // 所有overlay的最终位置
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i];
const rects = getRects(overlay);
overlaysRects.push(rects);
chosenPositions.push("TOP");
}
// 遍历所有overlay
for (let i = 0; i < overlays.length; i++) {
const overlay = overlays[i];
const rects = overlaysRects[i]; // 当前overlay的矩形 (上下左右四个位置的矩形)
let maxWeight = -Infinity; // 最大权重
let bestPos = "TOP"; // 最佳位置
// 遍历所有位置
for (let posIdx = 0; posIdx < Object.keys(POSITIONINGS).length; posIdx++) {
const posKey = Object.keys(POSITIONINGS)[posIdx];
const rect = rects[posKey]; // 当前位置的矩形
let overlayArea = 0; // 当前位置下的重叠面积
// 遍历所有已布局的overlay的矩形,计算重叠面积
for (let j = 0; j < chosenPositions.length; j++) {
if (i !== j) {
const rect2 = overlaysRects[j][chosenPositions[j]];
overlayArea += isRectOverlay_aabb(rect, rect2)
? calculateOverlapArea(rect, rect2)
: 0;
}
}
// 根据重叠面积计算权重
const posWeight = -overlayArea;
// 更新最佳位置
if (posWeight > maxWeight) {
maxWeight = posWeight;
bestPos = posKey;
}
if (posWeight === 0) break;
}
chosenPositions[i] = bestPos;
}
overlays.forEach((overlay, idx) => {
overlay.setPositioning(POSITIONINGS[chosenPositions[idx]]);
});
}
使用调整后的方法进行布局优化后,可以看到之前例子中 Tooltip3与Tooltip20、Tooltip2与Tooltip17 的布局更加合理了。

计划
1.探索使用"退火算法"追求全局最优解