数据量 | 方案 |
|---|
100万 | 服务端聚合 + 分级加载,或考虑栅格化 |
方案一:聚合(Clustering)聚合是点数据可视化的默认解。它的核心思想:把邻近的点合并成一个代表,显示数量。 const cluster = L.markerClusterGroup({
maxClusterRadius: 50, // 聚合半径,像素
disableClusteringAtZoom: 16, // 超过此缩放级别不聚合
showCoverageOnHover: false, // 不显示聚合区域的覆盖轮廓
chunkedLoading: true // 分块加载,避免主线程阻塞
});
for (const ev of events) {
cluster.addLayer(L.circleMarker([ev.lng, ev.lat], { radius: 4 }));
}
map.addLayer(cluster);
关键参数 chunkedLoading: true:一次加入几万个图层会阻塞主线程数秒。开启后聚合层分批构建,页面保持响应。 maxClusterRadius 是视觉密度的调节阀。50 像素是常见起点——太小则聚合效果不明显,太大则失去空间细节感。
disableClusteringAtZoom 很重要:缩放到一定级别后,用户需要看到单个点而不是聚合数字。设成 15 或 16 是常见选择。
Supercluster:更快的方案Leaflet.markercluster 依赖 Supercluster 算法,它在浏览器里构建一个层次化空间索引,支持客户端即时重新聚合。 import Supercluster from 'supercluster';
const index = new Supercluster({
radius: 40, // 聚合半径(像素)
maxZoom: 16,
minPoints: 2 // 少于几个点不聚合
});
index.load(points.map(p => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
properties: p
})));
// 查询视口内的聚合结果
const bounds = map.getBounds();
const clusters = index.getClusters(
[bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()],
Math.round(map.getZoom())
);
性能差异很显著:Supercluster 在查询阶段是纯计算,不涉及 DOM 操作,10 万点可以在几毫秒内完成聚合。而 Leaflet.markercluster 为每个聚合结果创建一个 DOM marker,聚合结果上千个时就开始吃力。 注意:radius 的单位是像素且依赖缩放级别,getClusters 的第二个参数 zoom 必须传,否则结果不对。 方案二:WebGL 渲染当聚合仍不够(或者你需要展示个体但不想有 DOM 开销),直接用 WebGL 画点。 MapLibre GL JS 的 circle layer{
"id": "events",
"type": "circle",
"source": "events",
"paint": {
"circle-radius": [
"interpolate", ["linear"], ["zoom"],
8, 2,
14, 6,
18, 10
],
"circle-color": [
"match", ["get", "level"],
"critical", "#e74c3c",
"warning", "#f39c12",
"info", "#3498db",
"#95a5a6"
],
"circle-opacity": 0.7,
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff"
}
}
5 万个点在 WebGL 里是轻量级负载。 整个图层只有一次 draw call,与要素数量基本无关。 优化手段: "circle-radius": {
"stops": [[8, 1], [12, 3], [16, 6]]
}
deck.gl如果需要更复杂的可视化图层(热力图、六边形分箱、路径动画),deck.gl 是专业选择: new deck.gl.HeatmapLayer({
data: events,
getPosition: d => [d.lng, d.lat],
getWeight: d => d.severity,
radiusPixels: 60,
intensity: 1.2,
threshold: 0.05
});
六边形分箱(H3 / hexagon)适合行政区划统计: new deck.gl.HexagonLayer({
data: events,
getPosition: d => [d.lng, d.lat],
radius: 500,
elevationScale: 20,
extruded: true // 3D 柱状
});
方案三:栅格化(超大规模)超过百万级点,WebGL 也会有压力。此时的正确做法不是继续优化前端,而是改变数据形态: 1. 服务端把点数据聚合到固定网格(H3 六边形或等距方格) 2. 每格存一个计数/统计值 3. 前端渲染的不是原始点,而是聚合结果 这不是妥协,是正确的架构。 用户在地图上从来不关心单个点的精确位置——单个点在这个数量级下是无法辨认的。他们关心的是"哪个区域密度高"。 用 H3 做分级: SELECT h3_cell, count(*) AS cnt
FROM events
GROUP BY h3_cell;
H3 是六边形分层网格,同一分辨率下的格子面积相等,避免了传统四边形网格在高纬度处的形变问题。 简化:别忘了最便宜的优化在引入任何复杂方案之前,先考虑减少数据量: 服务端过滤-- 只返回当前视口内的数据
SELECT * FROM events
WHERE geom && ST_MakeEnvelope($w, $s, $e, $n, 3857);
-- 时间过滤同样重要
WHERE occurred_at >= NOW() - INTERVAL '30 days'
采样展示 10 万点时,可以先服务端随机采样 1 万——视觉上几乎无差别,性能提升 10 倍。 精度截断// 6 位小数约 0.1 米精度,对可视化完全够用
const round6 = v => Math.round(v * 1e6) / 1e6;
对百万点数据集,这个改动能把 GeoJSON 体积砍掉 20-30%。 视觉设计:数据密度也是设计技术之外,有几个视觉决策直接影响可读性: 聚合圈的气泡大小编码数量"circle-radius": ["step", ["get", "point_count"], 10, 100, 15, 750, 25]
用阶梯而非连续映射。点数的分布在长尾的情况下,连续映射会让 99% 的聚合圈看起来一样大。 颜色映射顺序热力色带遵循"感知均匀"原则:低值浅、高值深且暖。经典方案是蓝→青→绿→黄→红,或者用 viridis、magma 这类科学配色。 反例:彩虹色带(红绿蓝全用)会制造出视觉上不存在的分类边界,而且对色觉障碍用户不友好。 透明度是密度的主要编码点的 opacity 随缩放降低,随着点变密,自然叠加成热区效果。这比做聚合更轻量,且保留了"这里有数据"的信息。 完整方案示例一个 5 万点的实时告警地图,兼顾性能和可读性: 1. 服务端
- PostGIS 空间索引过滤视口范围
- 时间范围过滤(默认近 30 天)
- 返回 GeoJSON,精度截断到 6 位小数
2. 传输
- gzip 压缩
- 只在视口变化超过阈值时才请求新数据(防抖)
3. 客户端
- zoom = 12:MapLibre circle layer 直接渲染个体点
- circle-radius / opacity 随 zoom 插值
4. 视觉
- 颜色按告警等级(match 表达式)
- 低缩放级别小半径 + 低透明度,形成热区感
- 聚合圈用 step 映射半径
5. 交互
- 点击聚合圈 → 放大到该区域
- 点击个体点 → 右侧面板显示详情
- 地图移动 300ms 防抖后才发请求
这个方案在任何普通笔记本上都能跑满 60fps。 排查清单现象 | 排查方向 |
|---|
卡顿且 CPU 占用高 | 是否用了 DOM marker?换 WebGL | 加入图层时页面假死 | 是否开启 chunkedLoading | 缩放时聚合跳动 | maxClusterRadius 是否过大
| 密集区看不清 | 提高低缩放级别半径,启用 opacity 叠加 | 首屏加载慢 | 服务端是否过滤了视口和数据 | | |
|