地图标签排布与碰撞检测逻辑文档
本文档详细描述了 3D 地图场景中 HTML 标签(CSS2DObject)的自动排布、碰撞检测及响应式适配逻辑。
1. 核心设计思想
地图标签系统旨在解决高密度点位下的可视化问题。当多个场站图标聚集在一起时,系统通过**"就近探测"和"边缘重分布"**两套策略,确保标签既不互相重叠,也不遮挡地图关键点位,并自动在小屏幕下切换紧凑布局。
2. 响应式与全局配置
系统通过文件顶部的配置对象进行全局控制:
2.1 RESPONSIVE_CONFIG (响应式配置)
根据 window.innerWidth < 2000px (1K屏幕) 自动切换参数:
- labelHeight / labelBaseWidth:定义标签在碰撞计算中占用的矩形尺寸。
- visualGap:视觉上的偏移间距(标签离图标多远)。
- collisionGap:碰撞检测时的敏感间距(用于判定是否重叠)。
- marginX / marginY:边缘分布时,标签离地图包围盒的距离(即连线长度)。
- getStep():动态计算标签排队的步长,标签越多,间距越紧凑。
2.2 COLLISION_CONFIG (碰撞策略)
- checkIconCollision:布尔值。若开启,标签会避开地图上的图标;若关闭,标签只在互相之间避让,从而减少连线外排的情况。
3. 标签排布流程
第一阶段:多方位就近尝试 (Local Probing)
对于每一个场站,程序按以下优先级尝试四个方位:
- 右侧 -> 2. 左侧 -> 3. 下方 -> 4. 上方
检测逻辑:
- 在每个方位构建一个虚拟的"探测矩形"(基于
collisionGap)。 - 执行碰撞检测(见第 4 节)。
- 若不冲突:直接在此方位渲染标签,不显示指引连线。
- 若全部冲突:进入第二阶段。
第二阶段:边缘重分布 (Edge Redistribution)
当就近位置均不可用时,标签会被分配到地图的四个边缘区域:
- 象限划分:根据场站相对于地图中心的角度,将其划分为 左、右、上、下 四个组。
- 抗交叉排序:对每个组内的标签按坐标排序,确保指引连线在从内向外拉伸时互不交叉。
- 对齐渲染 :按照
getStep()计算出的动态间距,将标签均匀排列在marginX/marginY划定的边界线上,并绘制折线。
4. 碰撞检测算法 (Collision Algorithm)
系统采用 AABB 矩形碰撞检测 算法。判定冲突的条件如下:
- 标签 vs 图标:当前标签矩形是否覆盖了地图上其他任何一个场站图标的"敏感区域"。
- 标签 vs 标签:当前标签矩形是否与已经确定位置的(已渲染的)其他标签矩形重叠。
5. 交互与同步
- 自动重绘 (Resize) :系统监听
window.resize事件,采用200ms防抖,在窗口大小变化后自动重新计算全图标签的排布。 - 视觉微调 :通过 CSS
transform进行1px的向上微调,以在视觉上修正由于文字垂直重心导致的对齐偏差,该微调不参与物理碰撞计算。
6. 参数微调指南
若需调整表现,建议按以下顺序操作:
- 若连线太长 :减小
marginX或marginY。 - 若标签排得太挤 :调大
getStep中的base值。 - 若想让更多标签留在地图内 :
- 调小
collisionGap。 - 将
checkIconCollision设为false。 - 调小
RESPONSIVE_CONFIG中的labelHeight。
- 调小
7. 核心逻辑实现 (伪代码)
以下展示了 setLabel 方法内部的核心处理逻辑:
typescript
function setLabel(data) {
// 1. 初始化与投影
const projectedPoints = data.map(p => project(p.position));
const occupiedRects = []; // 存储已确定的标签位置
const iconRects = projectedPoints.map(p => getIconRect(p));
// 2. 遍历所有点位进行排布尝试
projectedPoints.forEach(p => {
const labelSize = getResponsiveLabelSize(p.name);
let found = false;
// 策略 A: 多方位就近尝试 (右、左、下、上)
const probeDirections = [
{ dx: visualGap + labelW/2, dy: 0, collisionDx: collisionGap + ... },
{ dx: -(visualGap + labelW/2), dy: 0, ... },
...
];
for (const dir of probeDirections) {
const tryRect = createRect(p.x + dir.collisionDx, p.y + dir.collisionDy, labelSize);
// 执行碰撞检测
if (!isColliding(tryRect, iconRects) && !isColliding(tryRect, occupiedRects)) {
renderLabel(p, dir.offset); // 原地渲染
occupiedRects.push(tryRect);
found = true;
break;
}
}
// 策略 B: 若就近全部碰撞,则存入待外排列表
if (!found) fallbackPoints.push(p);
});
// 3. 处理外排标签 (边缘分布逻辑)
const quadrants = splitToQuadrants(fallbackPoints); // 划分为 左/右/上/下 四个组
quadrants.forEach(group => {
group.sort(); // 排序防止连线交叉
const step = getDynamicStep(group.length); // 获取压缩后的间距
group.forEach((p, index) => {
const finalPos = calculateEdgePos(index, step);
renderLabelWithLine(p, finalPos); // 渲染带指引线的标签
});
});
}
/**
* 矩形碰撞检测 AABB
*/
function isColliding(r1, r2) {
return !(r1.x + r1.w < r2.x || r2.x + r2.w < r1.x ||
r1.y + r1.h < r2.y || r2.y + r2.h < r1.y);
}