区域与海量设备点的地图可视化性能优化实践 百度 高德 腾讯
针对城市级区域、网格及海量设备点的地图可视化,通过 GeoJSON Brotli 压缩、Canvas 批量渲染结合 KDBush/RBush 空间索引,以及视口裁剪与边界缓存,解决多边形命中计算与大规模点位交互的性能瓶颈。
基于 Brotli、WASM、RBush 与地图批量渲染,实现城市级区域、网格和设备点的高效加载与交互
功能大致是:地图根据选择的城市渲染当前城市的区县划分,再在其基础上绘制网格分布和设备点情况,在地图上展示和交互。目标是做到不卡顿、流畅,大致效果如图。
开发过程
1. 区域地图边界数据获取与压缩
最开始的一版本使用的是百度地图,但因为未付费,其自带的边界绘制基本不可用。后来从相关 POI 网站下载了对应的省市区 GeoJSON,直接载入地图并成功渲染了边界数据。
但是单个市的数据量都不小,放在本地显然不现实,因此决定放在服务器端通过接口获取。为了提高加载速度和性能,使用 Python 编写了一个转换脚本,对数据进行 Brotli 压缩。
压缩效果基本达到预期。由于请求粒度最大也就是市级,且采用按城市懒加载的策略,所以加载速度不成问题。
2. 前端解压方案调整
压缩成 BR 格式后,需要在前端进行解压缩。之前曾尝试让后台配置 Content-Encoding: br 以利用浏览器自动解压,但总是弄不好。最终改为使用 WASM(brotli-wasm)在前端自行处理解压。虽然理论上浏览器自动解压效率更好,但自研方案解决了兼容性问题。
拿到市区数据后,即可渲染边界,进而开始后续的业务开发,包括地图渲染和点的交互。
第一阶段:百度地图实现
第一版本使用百度地图,针对大量点的性能问题做了以下优化处理:
鼠标移动节流
鼠标每移动一个像素都会触发事件,将其限制为一定间隔执行,避免连续触发大量的区域命中计算。
视野过滤
先判断区域的预计算边界是否与当前地图可视范围相交;屏幕外区域不参与绘制或命中判断。
地图本身能获取当前屏幕可见的地理范围:
const mapBounds = this.map.getBounds();
然后将地图上的区域与当前屏幕范围比较:
const visiblePolys = allPolys.filter((poly) =>
mapBounds.intersects(poly.bounds)
);
intersects() 的意思是:当前屏幕矩形是否和区域的外接矩形存在交集。
示意图:
当前地图视野 mapBounds
┌─────────────────────┐
│ 当前屏幕 │
│ ┌────区域 A────┐ │ → 相交,保留
│ └──────────────┘ │
└─────────────────────┘
远处区域 B → 不相交,跳过
过滤后,只有 visiblePolys 才继续执行较贵的精确命中计算:
for (const poly of visiblePolys) {
const polygonPoints = poly.poly.getPath();
if (isPointInPolygon(mousePoint, polygonPoints)) {
targetPoly = poly;
break;
}
}
边界缓存
区域的 Bounds、坐标转换结果等只计算一次,避免在每次缩放、移动、鼠标事件里重复计算。
提前保存每个区域的外接矩形。一个区域可能长这样:
不规则多边形顶点:[(121.1, 31.2), (121.5, 31.3), (121.4, 31.6), ...]
初始化区域时,计算它的最小/最大经纬度,得到能包住该区域的最小矩形:
minLng = 121.1
maxLng = 121.5
minLat = 31.2
maxLat = 31.6
百度地图用 SDK 提供的方法拿到这个矩形并缓存:
const polygon = new BMapGL.Polygon(points, options);
const bounds = polygon.getBounds();
return {
poly: polygon,
bounds,
};
之后 poly.bounds 就是已经计算好的外接矩形。
它避免的是:
- 每一次鼠标移动
- → 再遍历区域全部顶点
- → 再计算 min/max 经纬度
- → 再得到区域范围
变成:
- 创建区域时计算一次 Bounds
- → 以后直接使用
poly.bounds
视野过滤和边界缓存搭配一起,完整链路是:
- 创建区域 → 计算并缓存每个区域的 Bounds
- 鼠标移动 → 获取当前地图视野
mapBounds - 用
mapBounds与缓存的poly.bounds做快速相交判断 - 跳过屏幕外区域
- 对屏幕内少量候选区域执行 Point-in-Polygon 精确判断
一句话区分:
- 边界缓存:提前准备好每个区域的“快速筛选依据”。
- 视野过滤:使用这个依据,排除当前不可能命中的屏幕外区域。
Canvas Overlay 与 KDBush 优化
把多个网格或点位合并画到一个 Canvas 上,减少 SDK 覆盖物/DOM 节点数量。交互命中可通过 Canvas 的路径检测实现。
Grid(网格)和 Point(设备点)都用这个思路。Point 因为点位更多,还用到了 KDBush 检索提高效率。
将大量地图对象从“多个 SDK Overlay/Marker”收敛为“一个自定义 Overlay + 一张 Canvas”,由浏览器一次批量绘制。
流程:
地图数据
→ 经纬度转屏幕像素
→ Canvas 批量绘制
→ 自己接管 hover / click
→ 地图移动或缩放后重绘
这意味着地图里不会再有数千个 Marker 或 Polygon 对象,只有一个自定义 Overlay。性能收益主要来自:
- 降低地图 SDK Overlay 实例数量;
- 避免大量覆盖物各自维护样式和事件;
- 绘制由 Canvas 批量完成;
- 点击、hover 等交互在一个统一入口处理。
Point:为什么要引入 KDBush
设备点比网格更容易达到很大的数量级。若每次鼠标移动都遍历全部点:
this._points.find((item) => {
const p = this._map.pointToOverlayPixel(item.point);
return Math.hypot(p.x - x, p.y - y) <= radius;
});
问题有两个:
- 每次 hover 都要遍历全部点;
- 每个点都要重新执行经纬度转像素坐标。
因此 Point 版本做了两层优化:
1. 缓存像素坐标
this._pixelPoints = this._points.map((item, index) => {
const pixel = this._map.pointToOverlayPixel(item.point);
return {
px: pixel.x,
py: pixel.y,
originalItem: item,
index,
};
});
这里把设备点在当前地图视图下的位置缓存为:屏幕 x / 屏幕 y / 原始业务数据。
2. 用 KDBush 建立点位空间索引
const index = new KDBush(this._pixelPoints.length);
for (const point of this._pixelPoints) {
index.add(point.px, point.py);
}
index.finish();
this._index = index;
KDBush 是为静态二维点集设计的空间索引。它适用于这里,因为设备点位置在一次地图视图内不会变化,只有地图移动或缩放后才需要重建。
hover 查询由“全量遍历”变成“查询鼠标附近半径内的点”:
const foundIndices = this._index.within(
x,
y,
this._options.radius
);
const hoveredItem = foundIndices.length > 0
? this._pixelPoints[foundIndices[0]].originalItem
: null;
可以把它理解为:
鼠标位置 (x, y)
→ KDBush 查询 radius 范围内候选点
→ 找到点则更新高亮、显示提示
→ 找不到则清除 hover
坐标转换前置
源数据是 WGS84,而百度地图使用 BD09;尽量在数据进入渲染流程时集中转换,避免交互时再计算。
基本做百度地图的时候就是第一版本,性能也能够达标,效果也不错。后面百度提示要 5 万一年收费,因为主观观念觉得百度和高德用的多,网上资料也多,看了下迁移难度高德改动不算大,所以后面改用了高德。因为有了百度的经验,所以很快就迁移过去了。
第二阶段:高德地图实现
高德地图大致流程差不多,视口裁剪、边界缓存等核心逻辑一致。核心也是单 Canvas 批量绘制海量设备点,使用 KDBush,并自行处理 hover 和 click。
高德 API 不一样,他们不是 overlay,是 customLayer。
// 创建 CustomLayer(v1)
this._customLayer = new AMap.CustomLayer(this._canvas, {
zooms: this._options.zooms,
zIndex: this._options.zIndex,
pane: 'overlayPane',
});
// 绑定 render 方法(v1 核心)
this._customLayer.render = this._render.bind(this);
// 添加到地图
this._customLayer.setMap(map);
高德还在基础上做了一些优化:
1. 先裁剪,再建索引
百度版会先把全部点转换成像素坐标并建 KDBush:
this._pixelPoints = this._points.map((item) => {
const pixel = this._map.pointToOverlayPixel(item.point);
return {
px: pixel.x,
py: pixel.y,
originalItem: item,
};
});
但绘制时才过滤可视点:
const visiblePoints = this._points.filter((item) =>
bounds.containsPoint(item.point)
);
高德版则先过滤:
const visiblePoints = this._points.filter((item) =>
bounds.contains(item.point)
);
再只为可见点转换像素坐标和建立 KDBush:
this._pixelPoints = visiblePoints.map(...);
const index = new KDBush(this._pixelPoints.length);
因此高德版更节省:
- 屏幕外点位的像素计算
- 屏幕外点位的索引内存
- 屏幕外点位的索引构建时间
2. Click 也走 KDBush
百度点击点位时:
const clickedItem = this._points.find((item) => {
const point = this._map.pointToOverlayPixel(item.point);
return Math.hypot(point.x - x, point.y - y) <= radius;
});
这在设备多时仍然是全量扫描。
高德点击时:
const found = this._index.within(x, y, searchRadius);
if (found.length > 0) {
const item = this._pixelPoints[found[0]].originalItem;
}
高德把 hover 与 click 都统一进 KDBush 查询,交互链路更一致。
3. Retina 高分屏适配
高德版:
const width = retina ? size.width * 2 : size.width;
const height = retina ? size.height * 2 : size.height;
canvas.width = width;
canvas.height = height;
并同步放大点位像素坐标、点半径、鼠标坐标:
pixel = pixel.multiplyBy(2);
const x = (e.clientX - rect.left) * 2;
const y = (e.clientY - rect.top) * 2;
百度版没有这层处理,普通屏幕没问题,但在高分屏上 Canvas 可能显得模糊。
阶段总结
百度与高德阶段都采用 Canvas 自定义图层替代大量设备 Marker,并通过 KDBush 完成设备点 hover 查询。两者的共同核心是“地图坐标转屏幕像素、单 Canvas 批量绘制、空间索引快速命中”。
高德版本在此基础上进一步优化:
- 先进行视口裁剪,仅为可见点构建像素数据与 KDBush 索引;
- click 和 hover 均复用空间索引;
- 同时增加 Retina 高分屏适配;
- 并利用 CustomLayer 管理图层渲染生命周期。
因此,高德版本在大规模点位下具有更低的无效计算和更完整的交互链路。
还有一点,百度当时 Grid 和 Point 都是自定义的 Overlay 统一处理了点击和交互。迁移过程中 Grid 有点复杂,高德这边的 Grid 还是用的原生的 Polygon。考虑到网格数量不多,高德原生 Polygon 已结合 Bounds 缓存、视野过滤和精确几何判断满足网格交互性能,高德阶段保留了原生网格渲染。
第三阶段:腾讯地图实现
本来以为到这里就 OK 了,偷懒还是不行,结果高德没用几天也说要收费 5 万一年,逃不过,后面没办法改成腾讯地图了。公司因为其他业务用到了腾讯地图的已经付过费了,又开始改腾讯地图。
腾讯地图完全不一样,他的设计和百度高德 API 方面差异巨大。
1. 架构模型不同
百度和高德:单对象 Overlay 模型。百度、高德的基本思路相近:一个业务对象通常对应一个地图对象。
- 一个行政区 → 一个 Polygon
- 一个网格 → 一个 Polygon
- 一个设备 → 一个 Marker / Overlay
腾讯:批量 Geometry 模型。腾讯最终用的是 GL 版的批量图层:
- 多个业务对象
- → 多个 Geometry
- → 共享一个 MultiPolygon / MultiMarker 图层
区域不是一个个 Polygon,而是一组 geometry:
const geometries = [
{
id: "area_1",
styleId: "area_default",
paths,
},
{
id: "area_2",
styleId: "area_default",
paths,
},
];
样式单独维护:
const styles = {
area_default: new TMap.PolygonStyle({...}),
area_hover: new TMap.PolygonStyle({...}),
};
最后一次提交给地图:
const areaLayer = new TMap.MultiPolygon({
map,
geometries,
styles,
});
所以腾讯的关系变成:
业务区域
→ geometry 元数据
→ geometry id
→ styleId
→ 共享 MultiPolygon 图层
2. 区域和网格的交互方式不同
百度和高德:遍历独立 Polygon。百度、高德都可以保存多个 Polygon 实例。
腾讯的区域/网格共享 MultiPolygon,不能再依赖多个 Polygon 实例逐个处理。因此需要在前端维护每个 geometry 的路径和 bbox。
3. 设备点渲染方式不同
腾讯把大量点位绘制和点位命中交还给 SDK,遵守 MultiMarker 的 geometry/style 数据结构。
腾讯这套架构更简单,只用到了 RBush 用于区域和网格的 hover 命中。
阶段总结
腾讯地图阶段采用 SDK 提供的批量图层能力:区域和网格使用 MultiPolygon,设备点使用 MultiMarker。前端不再需要自定义 Canvas Overlay,而是将业务数据组织为 geometry、styleId 和样式表后批量提交给地图 SDK。
对于区域和网格交互,前端使用 RBush 对 geometry 外接矩形建立空间索引,再通过 Point-in-Polygon 完成精确命中;hover 时仅更新对应 geometry 的样式。该方案显著减少了大量 Polygon、Marker 和自定义 Canvas 的维护成本,同时保留了城市级地图数据的批量渲染与细粒度交互能力。