DAEMON / CANVAS-OVERLAY

区域与海量设备点的地图可视化性能优化实践 百度 高德 腾讯

2026.09.22 · 22 MIN READ
CANVAS-OVERLAY · BROTLI-COMPRESSION · RBUSH · GEOJSON

针对城市级区域、网格及海量设备点的地图可视化,通过 GeoJSON Brotli 压缩、Canvas 批量渲染结合 KDBush/RBush 空间索引,以及视口裁剪与边界缓存,解决多边形命中计算与大规模点位交互的性能瓶颈。

基于 Brotli、WASM、RBush 与地图批量渲染,实现城市级区域、网格和设备点的高效加载与交互

功能大致是:地图根据选择的城市渲染当前城市的区县划分,再在其基础上绘制网格分布和设备点情况,在地图上展示和交互。目标是做到不卡顿、流畅,大致效果如图。

开发过程

1. 区域地图边界数据获取与压缩

最开始的一版本使用的是百度地图,但因为未付费,其自带的边界绘制基本不可用。后来从相关 POI 网站下载了对应的省市区 GeoJSON,直接载入地图并成功渲染了边界数据。

但是单个市的数据量都不小,放在本地显然不现实,因此决定放在服务器端通过接口获取。为了提高加载速度和性能,使用 Python 编写了一个转换脚本,对数据进行 Brotli 压缩。

压缩效果基本达到预期。由于请求粒度最大也就是市级,且采用按城市懒加载的策略,所以加载速度不成问题。

2. 前端解压方案调整

压缩成 BR 格式后,需要在前端进行解压缩。之前曾尝试让后台配置 Content-Encoding: br 以利用浏览器自动解压,但总是弄不好。最终改为使用 WASM(brotli-wasm)在前端自行处理解压。虽然理论上浏览器自动解压效率更好,但自研方案解决了兼容性问题。

拿到市区数据后,即可渲染边界,进而开始后续的业务开发,包括地图渲染和点的交互。


第一阶段:百度地图实现

第一版本使用百度地图,针对大量点的性能问题做了以下优化处理:

鼠标移动节流

鼠标每移动一个像素都会触发事件,将其限制为一定间隔执行,避免连续触发大量的区域命中计算。

视野过滤

先判断区域的预计算边界是否与当前地图可视范围相交;屏幕外区域不参与绘制或命中判断。

地图本身能获取当前屏幕可见的地理范围:

javascript
const mapBounds = this.map.getBounds();

然后将地图上的区域与当前屏幕范围比较:

javascript
const visiblePolys = allPolys.filter((poly) =>
  mapBounds.intersects(poly.bounds)
);

intersects() 的意思是:当前屏幕矩形是否和区域的外接矩形存在交集。

示意图:

text
当前地图视野 mapBounds
┌─────────────────────┐
│       当前屏幕       │
│    ┌────区域 A────┐ │  → 相交,保留
│    └──────────────┘ │
└─────────────────────┘

远处区域 B                  → 不相交,跳过

过滤后,只有 visiblePolys 才继续执行较贵的精确命中计算:

javascript
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), ...]

初始化区域时,计算它的最小/最大经纬度,得到能包住该区域的最小矩形:

text
minLng = 121.1
maxLng = 121.5
minLat = 31.2
maxLat = 31.6

百度地图用 SDK 提供的方法拿到这个矩形并缓存:

javascript
const polygon = new BMapGL.Polygon(points, options);
const bounds = polygon.getBounds();

return {
  poly: polygon,
  bounds,
};

之后 poly.bounds 就是已经计算好的外接矩形。

它避免的是:

变成:

视野过滤和边界缓存搭配一起,完整链路是:

  1. 创建区域 → 计算并缓存每个区域的 Bounds
  2. 鼠标移动 → 获取当前地图视野 mapBounds
  3. 用 mapBounds 与缓存的 poly.bounds 做快速相交判断
  4. 跳过屏幕外区域
  5. 对屏幕内少量候选区域执行 Point-in-Polygon 精确判断

一句话区分:

Canvas Overlay 与 KDBush 优化

把多个网格或点位合并画到一个 Canvas 上,减少 SDK 覆盖物/DOM 节点数量。交互命中可通过 Canvas 的路径检测实现。

Grid(网格)和 Point(设备点)都用这个思路。Point 因为点位更多,还用到了 KDBush 检索提高效率。

将大量地图对象从“多个 SDK Overlay/Marker”收敛为“一个自定义 Overlay + 一张 Canvas”,由浏览器一次批量绘制。

流程:

text
地图数据
→ 经纬度转屏幕像素
→ Canvas 批量绘制
→ 自己接管 hover / click
→ 地图移动或缩放后重绘

这意味着地图里不会再有数千个 Marker 或 Polygon 对象,只有一个自定义 Overlay。性能收益主要来自:

Point:为什么要引入 KDBush

设备点比网格更容易达到很大的数量级。若每次鼠标移动都遍历全部点:

javascript
this._points.find((item) => {
  const p = this._map.pointToOverlayPixel(item.point);
  return Math.hypot(p.x - x, p.y - y) <= radius;
});

问题有两个:

因此 Point 版本做了两层优化:

1. 缓存像素坐标

javascript
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 建立点位空间索引

javascript
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 查询由“全量遍历”变成“查询鼠标附近半径内的点”:

javascript
const foundIndices = this._index.within(
  x,
  y,
  this._options.radius
);

const hoveredItem = foundIndices.length > 0
  ? this._pixelPoints[foundIndices[0]].originalItem
  : null;

可以把它理解为:

text
鼠标位置 (x, y)
  → KDBush 查询 radius 范围内候选点
  → 找到点则更新高亮、显示提示
  → 找不到则清除 hover

坐标转换前置

源数据是 WGS84,而百度地图使用 BD09;尽量在数据进入渲染流程时集中转换,避免交互时再计算。

基本做百度地图的时候就是第一版本,性能也能够达标,效果也不错。后面百度提示要 5 万一年收费,因为主观观念觉得百度和高德用的多,网上资料也多,看了下迁移难度高德改动不算大,所以后面改用了高德。因为有了百度的经验,所以很快就迁移过去了。


第二阶段:高德地图实现

高德地图大致流程差不多,视口裁剪、边界缓存等核心逻辑一致。核心也是单 Canvas 批量绘制海量设备点,使用 KDBush,并自行处理 hover 和 click。

高德 API 不一样,他们不是 overlay,是 customLayer。

javascript
// 创建 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:

javascript
this._pixelPoints = this._points.map((item) => {
  const pixel = this._map.pointToOverlayPixel(item.point);
  return {
    px: pixel.x,
    py: pixel.y,
    originalItem: item,
  };
});

但绘制时才过滤可视点:

javascript
const visiblePoints = this._points.filter((item) =>
  bounds.containsPoint(item.point)
);

高德版则先过滤:

javascript
const visiblePoints = this._points.filter((item) =>
  bounds.contains(item.point)
);

再只为可见点转换像素坐标和建立 KDBush:

javascript
this._pixelPoints = visiblePoints.map(...);
const index = new KDBush(this._pixelPoints.length);

因此高德版更节省:

2. Click 也走 KDBush

百度点击点位时:

javascript
const clickedItem = this._points.find((item) => {
  const point = this._map.pointToOverlayPixel(item.point);
  return Math.hypot(point.x - x, point.y - y) <= radius;
});

这在设备多时仍然是全量扫描。

高德点击时:

javascript
const found = this._index.within(x, y, searchRadius);
if (found.length > 0) {
  const item = this._pixelPoints[found[0]].originalItem;
}

高德把 hover 与 click 都统一进 KDBush 查询,交互链路更一致。

3. Retina 高分屏适配

高德版:

javascript
const width = retina ? size.width * 2 : size.width;
const height = retina ? size.height * 2 : size.height;

canvas.width = width;
canvas.height = height;

并同步放大点位像素坐标、点半径、鼠标坐标:

javascript
pixel = pixel.multiplyBy(2);
const x = (e.clientX - rect.left) * 2;
const y = (e.clientY - rect.top) * 2;

百度版没有这层处理,普通屏幕没问题,但在高分屏上 Canvas 可能显得模糊。

阶段总结

百度与高德阶段都采用 Canvas 自定义图层替代大量设备 Marker,并通过 KDBush 完成设备点 hover 查询。两者的共同核心是“地图坐标转屏幕像素、单 Canvas 批量绘制、空间索引快速命中”。

高德版本在此基础上进一步优化:

因此,高德版本在大规模点位下具有更低的无效计算和更完整的交互链路。

还有一点,百度当时 Grid 和 Point 都是自定义的 Overlay 统一处理了点击和交互。迁移过程中 Grid 有点复杂,高德这边的 Grid 还是用的原生的 Polygon。考虑到网格数量不多,高德原生 Polygon 已结合 Bounds 缓存、视野过滤和精确几何判断满足网格交互性能,高德阶段保留了原生网格渲染。


第三阶段:腾讯地图实现

本来以为到这里就 OK 了,偷懒还是不行,结果高德没用几天也说要收费 5 万一年,逃不过,后面没办法改成腾讯地图了。公司因为其他业务用到了腾讯地图的已经付过费了,又开始改腾讯地图。

腾讯地图完全不一样,他的设计和百度高德 API 方面差异巨大。

1. 架构模型不同

百度和高德:单对象 Overlay 模型。百度、高德的基本思路相近:一个业务对象通常对应一个地图对象。

腾讯:批量 Geometry 模型。腾讯最终用的是 GL 版的批量图层:

区域不是一个个 Polygon,而是一组 geometry:

javascript
const geometries = [
  {
    id: "area_1",
    styleId: "area_default",
    paths,
  },
  {
    id: "area_2",
    styleId: "area_default",
    paths,
  },
];

样式单独维护:

javascript
const styles = {
  area_default: new TMap.PolygonStyle({...}),
  area_hover: new TMap.PolygonStyle({...}),
};

最后一次提交给地图:

javascript
const areaLayer = new TMap.MultiPolygon({
  map,
  geometries,
  styles,
});

所以腾讯的关系变成:

text
业务区域
→ 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 的维护成本,同时保留了城市级地图数据的批量渲染与细粒度交互能力。