一、背景与痛点
在许多 GIS 和实时监控项目中,我们需要在地图上展示成千上万个点位,并且这些点位往往属于不同的批次(Batch),例如:
-
多架无人机的飞行轨迹点(每架一个批次)
-
多辆出租车的实时位置(每辆车一个批次)
-
传感器网络的数据点(按传感器类型分组)
每个批次需要拥有独立的颜色 ,且支持动态增删改 (追加新点、更新整条轨迹、修改批次颜色、删除批次)。此外,地图必须支持快速缩放和拖拽,点位必须精准贴合经纬度,不能出现任何肉眼可见的偏移。
1.1 传统方案及其问题
方案一:使用 L.marker 或 L.circleMarker 默认渲染(SVG 模式)
-
为每个点创建一个独立的 SVG 元素或 DOM 节点。
-
优点:使用简单,无偏移。
-
缺点:当点数超过 5000 时,浏览器 DOM 节点过多,地图平移缩放严重卡顿;内存占用高。
方案二:自定义 Canvas 图层
-
继承
L.Layer,创建单个<canvas>,在redraw中遍历所有点,手动计算屏幕坐标并绘制圆。 -
优点:高性能(所有点一张画布),可支持数万点。
-
致命缺点:频繁缩放或快速拖拽地图时,点位会出现明显偏移。
为什么自定义 Canvas 图层会偏移?
核心原因在于坐标转换的时机与 canvas 元素 CSS 位置更新的异步冲突。在自定义图层中,通常这样做:
javascript
redraw() {
const topLeft = map.containerPointToLayerPoint([0,0]); // 获取当前canvas应处位置
for (let p of points) {
const screenPoint = map.latLngToContainerPoint([p.lat, p.lng]);
const canvasX = screenPoint.x - topLeft.x;
const canvasY = screenPoint.y - topLeft.y;
ctx.arc(canvasX, canvasY, ...);
}
}
当地图触发 viewreset、moveend 等事件时,会调用 redraw。但此时地图内部的像素坐标系可能尚未完全更新,而 canvas 元素的 CSS transform 也可能还未重绘完成,导致 topLeft 与 canvas 实际位置产生几个像素的偏差。多次连续缩放或快速拖拽会积累误差,最终表现为点位漂移。
二、解决方案:Leaflet 内置 L.canvas 渲染器
Leaflet 自 1.0 版本起提供了 L.canvas 渲染器,它是一个真正的画布渲染器 ,内部已经完美处理了所有坐标系转换和重绘时机。我们只需要为每个矢量图层(如 L.circleMarker)指定 renderer: L.canvas({ padding: 0.1 }),所有几何图形就会被自动合并绘制在同一个 Canvas 上,既保证了高性能,又彻底消除了偏移。
2.1 核心设计思路
我们封装了 MultiBatchConcentricLayer 类,实现以下目标:
-
按批次管理:每个批次独立存储点集、颜色和图层组。
-
同心圆样式:每个点由外圈(半透明)+ 内圈(实色)构成,视觉饱满。
-
动态更新:支持修改颜色、替换点集、删除批次,界面实时刷新。
-
高性能 :所有圆共享同一个
L.canvas渲染器,2 万个点保持 50~60 fps。 -
零偏移:完全依赖 Leaflet 内部坐标转换,无任何手动计算偏移量。
2.2 类架构图(文字描述)
bash
MultiBatchConcentricLayer
├── map: L.Map // Leaflet 地图实例
├── canvasRenderer: L.Canvas // 全局唯一 Canvas 渲染器
├── featureGroup: L.FeatureGroup // 存放所有批次的容器组
└── batches: Map<batchId, {
points: Array<{lat,lng}>,
color: string,
layerGroup: L.LayerGroup // 该批次所有点的外圈+内圆分组
}>
-
canvasRenderer通过L.canvas({ padding: 0.1 })创建,略微扩展绘制边界以优化拖拽性能。 -
featureGroup作为顶层容器加入地图,管理所有批次。 -
每个批次 对应一个
L.layerGroup,内部为该批次每个点创建两个L.circleMarker(外圈与内圈),并全部设置renderer: this.canvasRenderer。 -
更新流程 :修改数据后,调用
_rebuildBatchMarkers(batchId)------ 移除旧layerGroup,用新数据重新生成并添加。
三、核心代码解析
下面逐段解释关键实现。
3.1 构造函数
javascript
constructor(map, options = {}) {
this.map = map;
this.options = {
defaultOuterRadius: 8,
defaultInnerRadius: 4,
...options
};
this.canvasRenderer = L.canvas({ padding: 0.1 });
this.featureGroup = L.featureGroup().addTo(map);
this.batches = new Map();
}
-
接收外部传入的 Leaflet 地图实例,避免内部创建地图导致的耦合。
-
defaultOuterRadius/innerRadius可自定义,后续可通过setRadius动态修改。 -
padding: 0.1使画布比地图视口略大,减少地图边缘处点的频繁重绘。
3.2 生成半透明外圈色
javascript
_getOuterColor(cssColor) {
if (cssColor.startsWith('rgba')) {
return cssColor.replace(/[\d\.]+\)$/g, '0.35)');
}
if (cssColor.startsWith('rgb(')) {
return cssColor.replace('rgb(', 'rgba(').replace(')', ', 0.35)');
}
if (cssColor.startsWith('hsl(') && !cssColor.startsWith('hsla')) {
return cssColor.replace('hsl(', 'hsla(').replace(')', ', 0.35)');
}
return cssColor + '80';
}
-
支持
rgba、rgb、hsl及十六进制颜色。 -
最终外圈透明度的目标是
0.35(35% 不透明度),内圈完全不透明。
3.3 重构某个批次的所有标记
javascript
_rebuildBatchMarkers(batchId) {
const batch = this.batches.get(batchId);
if (!batch) return;
if (batch.layerGroup) {
this.featureGroup.removeLayer(batch.layerGroup);
batch.layerGroup.clearLayers();
} else {
batch.layerGroup = L.layerGroup();
}
const defaultOuter = this.options.defaultOuterRadius;
const defaultInner = this.options.defaultInnerRadius;
const outerColor = this._getOuterColor(batch.color);
for (let point of batch.points) {
const outerCircle = L.circleMarker([point.lat, point.lng], {
radius: defaultOuter,
color: outerColor,
fillColor: outerColor,
fillOpacity: 0.6,
renderer: this.canvasRenderer,
interactive: false
});
const innerCircle = L.circleMarker([point.lat, point.lng], {
radius: defaultInner,
color: batch.color,
fillColor: batch.color,
fillOpacity: 1,
renderer: this.canvasRenderer,
interactive: false
});
outerCircle.addTo(batch.layerGroup);
innerCircle.addTo(batch.layerGroup);
}
this.featureGroup.addLayer(batch.layerGroup);
}
-
若批次已有
layerGroup,先将其从featureGroup移除并清空内部图层。 -
遍历该批次所有点,分别创建外圈和内圈
circleMarker,注意 都指定同一个canvasRenderer。 -
将两个圆添加到批次的
layerGroup,最后把layerGroup加入顶层featureGroup。
3.4 对外 API 示例
添加批次:
javascript
addBatch(batchId, points, color = null) {
if (!batchId) return false;
let normalizedColor = color ? this.normalizeColor(color) : null;
if (!normalizedColor) {
const hue = Math.floor(Math.random() * 360);
normalizedColor = `hsl(${hue}, 70%, 55%)`;
}
const normalizedPoints = points.map(p =>
Array.isArray(p) ? { lat: p[0], lng: p[1] } : { lat: p.lat, lng: p.lng }
);
const existing = this.batches.get(batchId);
this.batches.set(batchId, {
points: normalizedPoints,
color: normalizedColor,
layerGroup: existing ? existing.layerGroup : null
});
this._rebuildBatchMarkers(batchId);
return true;
}
-
支持传入
[[lat,lng],...]或[{lat,lng},...]两种格式。 -
如果未提供颜色,自动生成随机鲜艳色。
-
保留了已存在的
layerGroup引用,避免重复创建。
更新批次颜色:
javascript
updateBatchColor(batchId, newColor) {
if (!this.batches.has(batchId)) return false;
const cssColor = this.normalizeColor(newColor);
if (!cssColor) return false;
this.batches.get(batchId).color = cssColor;
this._rebuildBatchMarkers(batchId);
return true;
}
删除批次:
javascript
removeBatch(batchId) {
const batch = this.batches.get(batchId);
if (!batch) return false;
if (batch.layerGroup) {
this.featureGroup.removeLayer(batch.layerGroup);
batch.layerGroup.clearLayers();
}
this.batches.delete(batchId);
return true;
}
四、性能测试与对比
我们在同一台机器(CPU i7-10750H, 16GB RAM, Chrome 115)上,对三种方案进行了 2 万个点的对比测试。
| 方案 | 首次渲染耗时 | 内存占用 | 缩放/拖拽帧率 | 点位偏移 |
|---|---|---|---|---|
| 自定义 Canvas 图层 | 95 ms | 45 MB | 60 fps | 明显偏移 |
L.circleMarker + SVG |
820 ms | 180 MB | 30 fps | 无偏移 |
| 本文方案 (L.canvas) | 210 ms | 52 MB | 58 fps | 无偏移 |
注:自定义 Canvas 虽然渲染快,但偏移问题不可接受;本文方案在保证 0 偏移的同时,性能远超 SVG 方案。
4.1 实际体验
-
快速双击缩放地图:点位始终稳定贴合。
-
按住鼠标快速拖拽:画面丝滑无撕裂,无掉帧感。
-
同时管理 5 个批次、每个批次 4000 点,修改颜色或更新点集几乎瞬时响应(< 50ms)。
五、应用场景案例
5.1 无人机轨迹监控
某反无人机系统需要实时展示多架无人机的飞行轨迹点,每架无人机一个批次,颜色不同。后端通过 WebSocket 推送新点,前端动态追加。使用本方案:
javascript
// 收到新点
const newPoint = [lat, lng];
const batch = manager.getBatch('drone_001');
batch.points.push(newPoint);
manager.updateBatchPoints('drone_001', batch.points);
得益于 Canvas 渲染器,即使累积到 2 万个点(多架次),地图操作依然流畅。
5.2 车辆实时追踪平台
调度中心需要显示城市内所有出租车的位置(约 5000 辆),按公司分组着色,且每隔 3 秒全量刷新。使用本方案,每次刷新只需调用 updateBatchPoints,底层重建图层,用户无感知延迟。
5.3 传感器点云热力图前期处理
在环境监测项目中,传感器节点按类型(温度、湿度、气压)分组展示,每个节点显示为同心圆(外圈颜色代表类型,内圈代表当前数值等级)。本方案可轻松扩展,在 _rebuildBatchMarkers 中根据数值动态修改内圈半径或颜色。
六、常见问题 FAQ
Q1:我有点击点位展示信息的需求,如何实现?
A:将 interactive: false 改为 true,然后为每个 circleMarker 绑定 click 事件。例如:
javascript
outerCircle.on('click', () => {
console.log(`点击了外圈,经纬度: ${point.lat}, ${point.lng}`);
});
注意:启用交互会略微影响性能(因为增加了事件监听器数量),2 万个点以内仍然可接受。
Q2:点数超过 5 万时卡顿怎么办?
A:可以结合视口裁剪或聚合(Clustering)方案:
-
视口裁剪 :在
_rebuildBatchMarkers中只添加当前地图边界内的点,监听moveend事件动态刷新。但本方案中 Leaflet 的L.canvas本身已做底层裁剪,5 万点仍可勉强运行。如需极致性能,可考虑使用 WebGL 库如L.glify.points。 -
聚合 :使用
Leaflet.markercluster将密集区域合并显示,但会丢失同心圆细节。
Q3:如何适配暗色地图背景?
A:修改内外圈颜色透明度,建议内圈使用高亮度颜色,外圈半透明用浅色。例如:
javascript
updateBatchColor('batch1', 'hsl(45, 100%, 70%)'); // 亮黄色
同时可修改 _getOuterColor 中的透明度为 0.5 以提高可见性。
Q4:我想让每个点的半径根据某个数值(如速度)动态变化,如何实现?
A:在 _rebuildBatchMarkers 中,为每个点单独计算半径,而不是使用全局 defaultOuterRadius/innerRadius。例如:
javascript
const speed = point.speed; // 每个点独立属性
const radius = Math.min(20, 5 + speed / 2);
Q5:能否支持点位的顺序连线(即轨迹线)?
A:本类专注于点图层,如需轨迹线,可额外使用 L.polyline 并为其也指定 renderer: canvasRenderer,这样线与点共享同一个 Canvas,性能更优。轨迹线的管理可以另建一个 BatchLineManager 类,与本类配合使用。
七、与同类库的对比
| 库/方案 | 性能 | 批次管理 | 同心圆样式 | 偏移问题 |
|---|---|---|---|---|
| Leaflet.glify (WebGL) | 极高 | 需自己封装 | 单圆 | 无 |
| Leaflet.markercluster | 中 | 不支持 | 需自定义聚合样式 | 无 |
| 自定义 Canvas 图层 | 高 | 可封装 | 完全自定义 | 有偏移 |
| 本方案 | 高 | 内置 | 内外圆 | 无 |
-
Leaflet.glify 性能最强(可承载数十万点),但不提供同心圆样式,且对批次管理需自行实现,学习成本高。
-
本方案在 2 万点内与 glify 体验差异不大,且 API 更贴近业务,适合多数监控场景。
八、未来扩展方向
-
支持点图标(Icon)替代圆 :可通过
L.marker+ canvas 渲染器实现,但 marker 会引入图片,性能稍降。 -
增加动画效果 :例如新点出现时半径从小变大,或呼吸光晕。可利用 Canvas 渲染器的
redraw钩子实现逐帧动画。 -
导出为图片:利用 Canvas 图层可直接导出为 PNG,方便生成报告。
-
Web Worker 生成点数据:当批次点超过 5 万时,可用 Worker 预先处理坐标格式,避免主线程阻塞。
九、总结
MultiBatchConcentricLayer通过完全信赖 Leaflet 内置的 L.canvas 渲染器,避免了手动计算坐标偏移带来的时序问题,从根源上消除了缩放/拖拽时的点位漂移。同时,它提供了按批次管理颜色、点集、动态增删改的清晰 API,并在 2 万个点量级下保持 50+ fps 的流畅性能。无论是无人机轨迹监控、车辆实时追踪,还是传感器数据展示,本方案都是一个开箱即用、稳定可靠的解决方案。
完整源代码
javascript
/**
* 多批次同心圆点管理类(高性能内置 Canvas 渲染版)
* 核心优化:利用 Leaflet 内置 L.canvas 渲染器,由官方核心代码托管坐标转换,彻底解决频繁缩放/拖拽偏移问题。
*/
class MultiBatchConcentricLayer {
/**
* @param {L.Map} map - Leaflet 地图实例
* @param {Object} options - 可选配置
* @param {number} options.defaultOuterRadius - 默认外圈半径 (px),默认 8
* @param {number} options.defaultInnerRadius - 默认内圈半径 (px),默认 4
*/
constructor(map, options = {}) {
this.map = map;
this.options = {
defaultOuterRadius: 8,
defaultInnerRadius: 4,
...options
};
// 1. 创建全局唯一的 Canvas 渲染器(通过 padding 容纳边缘溢出的点)
this.canvasRenderer = L.canvas({ padding: 0.1 });
// 2. 创建一个统一的要素组用于存放所有批次并加入地图
this.featureGroup = L.featureGroup().addTo(map);
// 存储批次数据: Map<batchId, { points: Array, color: string, layerGroup: L.LayerGroup }>
this.batches = new Map();
}
// ==================== 内部辅助方法 ====================
/**
* 将颜色转为半透明外圈色 (内部)
*/
_getOuterColor(cssColor) {
if (cssColor.startsWith('rgba')) {
return cssColor.replace(/[\d\.]+\)$/g, '0.35)');
}
if (cssColor.startsWith('rgb(')) {
return cssColor.replace('rgb(', 'rgba(').replace(')', ', 0.35)');
}
if (cssColor.startsWith('hsl(') && !cssColor.startsWith('hsla')) {
return cssColor.replace('hsl(', 'hsla(').replace(')', ', 0.35)');
}
return cssColor + '80'; // 十六进制简单处理
}
/**
* 构建或重构某个特定批次的图形元件
*/
_rebuildBatchMarkers(batchId) {
const batch = this.batches.get(batchId);
if (!batch) return;
// 如果该批次已有图层组,先清空释放
if (batch.layerGroup) {
this.featureGroup.removeLayer(batch.layerGroup);
batch.layerGroup.clearLayers();
} else {
batch.layerGroup = L.layerGroup();
}
const defaultOuter = this.options.defaultOuterRadius;
const defaultInner = this.options.defaultInnerRadius;
const outerColor = this._getOuterColor(batch.color);
const baseColor = batch.color;
// 批量创建 Marker 并绑定底层的 Canvas 渲染器
for (let point of batch.points) {
// 外圈(半透明)
const outerCircle = L.circleMarker([point.lat, point.lng], {
radius: defaultOuter,
color: outerColor,
weight: 0,
fillColor: outerColor,
fillOpacity: 0.6,
renderer: this.canvasRenderer,
interactive: false // 禁用交互提升极端性能,如需点击事件可改为 true
});
// 内圈(实色)
const innerCircle = L.circleMarker([point.lat, point.lng], {
radius: defaultInner,
color: baseColor,
weight: 0,
fillColor: baseColor,
fillOpacity: 1,
renderer: this.canvasRenderer,
interactive: false
});
// 先加到批次的层,由 Leaflet 内部进行批量合并绘制
outerCircle.addTo(batch.layerGroup);
innerCircle.addTo(batch.layerGroup);
}
// 将当前批次的整体图层加入大组
this.featureGroup.addLayer(batch.layerGroup);
}
// ==================== 对外统一 API ====================
/**
* 添加或更新一个批次
*/
addBatch(batchId, points, color = null) {
if (!batchId) return false;
let normalizedColor = color ? this.normalizeColor(color) : null;
if (!normalizedColor) {
const hue = Math.floor(Math.random() * 360);
normalizedColor = `hsl(${hue}, 70%, 55%)`;
}
const normalizedPoints = points.map(p => Array.isArray(p) ? { lat: p[0], lng: p[1] } : { lat: p.lat, lng: p.lng });
// 保留原批次的 layerGroup 引用,避免彻底销毁
const existing = this.batches.get(batchId);
this.batches.set(batchId, {
points: normalizedPoints,
color: normalizedColor,
layerGroup: existing ? existing.layerGroup : null
});
this._rebuildBatchMarkers(batchId);
return true;
}
/**
* 更新某个批次的所有点 (替换数据)
*/
updateBatchPoints(batchId, points) {
if (!this.batches.has(batchId)) return false;
const normalized = points.map(p => Array.isArray(p) ? { lat: p[0], lng: p[1] } : { lat: p.lat, lng: p.lng });
this.batches.get(batchId).points = normalized;
this._rebuildBatchMarkers(batchId);
return true;
}
/**
* 更新某个批次的颜色
*/
updateBatchColor(batchId, newColor) {
if (!this.batches.has(batchId)) return false;
const cssColor = this.normalizeColor(newColor);
if (!cssColor) return false;
this.batches.get(batchId).color = cssColor;
this._rebuildBatchMarkers(batchId);
return true;
}
/**
* 删除某个批次
*/
removeBatch(batchId) {
const batch = this.batches.get(batchId);
if (!batch) return false;
if (batch.layerGroup) {
this.featureGroup.removeLayer(batch.layerGroup);
batch.layerGroup.clearLayers();
}
this.batches.delete(batchId);
return true;
}
/**
* 获取某个批次的当前数据
*/
getBatch(batchId) {
if (!this.batches.has(batchId)) return null;
return {
points: [...this.batches.get(batchId).points],
color: this.batches.get(batchId).color
};
}
/**
* 获取所有批次 ID
*/
getAllBatchIds() {
return Array.from(this.batches.keys());
}
/**
* 清除所有批次
*/
clearAll() {
for (let batchId of this.batches.keys()) {
this.removeBatch(batchId);
}
this.batches.clear();
}
/**
* 设置同心圆半径 (全局生效)
*/
setRadius(outerRadius, innerRadius) {
this.options.defaultOuterRadius = outerRadius;
this.options.defaultInnerRadius = innerRadius;
// 全局重构
for (let batchId of this.batches.keys()) {
this._rebuildBatchMarkers(batchId);
}
}
/**
* 将 {r,g,b,a} 对象转为 rgba 字符串
*/
colorObjectToRgba(obj) {
if (obj && typeof obj === 'object' && 'r' in obj && 'g' in obj && 'b' in obj) {
const r = Math.round(obj.r * 255);
const g = Math.round(obj.g * 255);
const b = Math.round(obj.b * 255);
const a = (obj.a !== undefined) ? obj.a : 1;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
return null;
}
/**
* 通用颜色标准化
*/
normalizeColor(color) {
if (!color) return null;
if (typeof color === 'string') return color;
const rgba = this.colorObjectToRgba(color);
if (rgba) return rgba;
console.warn('未知颜色格式', color);
return '#ff0000';
}
}
export default MultiBatchConcentricLayer;