迪图
发布于 2026-10-20 / 13 阅读
0
0

海量点数据可视化:从 5 万个点卡死到流畅的完整方案

海量点数据可视化:从 5 万个点卡死到流畅的完整方案

5 万个重叠的标记点会变成一坨没有意义的色块,并且拖垮浏览器。这篇文章讲怎么在保留信息的前提下让它跑起来。

问题有多真实

设想一个需求:把某城市过去一年的 5 万起报警事件按位置画在地图上。

最直觉的做法是循环创建 marker:

// ❌ 会卡死
for (const ev of events) {
  L.circleMarker([ev.lng, ev.lat], { radius: 5 }).addTo(map);
}

问题有两层:

1. 视觉层面:5 万个点堆在一起,中间密不透风,边缘稀疏成雾。信息完全丢失

2. 性能层面:每个 DOM 元素都要参与布局和绘制,浏览器直接崩溃

第二个问题好解决(换渲染方式),第一个问题才是根本。

决策路径:按数据量选方案

数据量

方案

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]]
}
  • 低缩放级别用小半径:点在屏幕上占的面积不变,视觉密度更高,不遮挡

  • 配合 circle-blur 或降低 opacity:密集区域自然融合成"热区"效果,比聚合更直观

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 叠加

首屏加载慢

服务端是否过滤了视口和数据

内存持续增长

是否频繁重建图层而非复用

参考资料

  • Supercluster — https://github.com/mapbox/supercluster

  • Leaflet.markercluster — https://github.com/Leaflet/Leaflet.markercluster

  • deck.gl — https://deck.gl/

  • MapLibre GL JS — https://maplibre.org/

  • Uber H3 地理网格 — https://h3geo.org/


评论