第15章 - 热力图聚合与符号可视化

15.1 三种”由点生成面”的路径

把几十万个点画到地图上,有三条路,代价差别极大:

路径 机制 适合 数量级
circle 图层 每个要素画一个圆,走 GPU 点位稀疏、需要精确位置 10 万以内流畅
heatmap 图层 GPU 累加密度场,一张贴图表达分布 看整体分布,不看单点 50 万以上
cluster 聚合 worker 端用 supercluster 把邻近点合成一个”簇要素” 需要交互、需要知道”这里有多少个” 100 万以内

三者可以叠加:底层 heatmap 看分布,中间 cluster 圆看数量,顶层 symbol 看具体标注。但同一份数据同时挂三个图层时,要素数不变而渲染遍数变三倍,10 万点以上要掂量。

15.2 Heatmap 图层

heatmap 的 paint 属性全部是全局量(除 heatmap-weight 可数据驱动),核心五个:

属性 默认值 单位/范围 作用
heatmap-radius 30 像素,≥1 单点的扩散半径,越大越糊
heatmap-weight 1 ≥0 单点权重,可用 ["get", "count"] 驱动
heatmap-intensity 1 ≥0 全局强度,常按 zoom 插值
heatmap-color 内置渐变(透明→royalblue→cyan→lime→yellow→red) 颜色表达式 用 ["heatmap-density"] 取值
heatmap-opacity 1 0–1 图层整体不透明度

最小配置:

map.addSource('orders', { type: 'geojson', data: ordersGeoJSON });
map.addLayer({
  id: 'orders-heat',
  type: 'heatmap',
  source: 'orders',
  paint: {
    // 半径随缩放放大:低缩放看分布,高缩放看细节
    'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 9, 20, 15, 40],
    'heatmap-weight': 1,
    'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 9, 3],
    'heatmap-opacity': 0.8,
    'heatmap-color': [
      'interpolate', ['linear'], ['heatmap-density'],
      0, 'rgba(33,102,172,0)',
      0.2, 'rgb(103,169,207)',
      0.4, 'rgb(209,229,240)',
      0.6, 'rgb(253,219,199)',
      0.8, 'rgb(239,138,98)',
      1, 'rgb(178,24,43)'
    ]
  }
});

heatmap-color 的第一档必须是完全透明的颜色(alpha 为 0),否则没有点的区域也会被染色,整屏变成一种颜色。这是最常见的 heatmap 配置错误。

15.2.1 高缩放切换样式

heatmap 在高缩放(z14+)会显得”糊”,因为半径拖出巨大的色块。标准做法是在 z13 附近把它淡出、换 circle 图层:

map.addLayer({
  id: 'orders-points',
  type: 'circle',
  source: 'orders',
  minzoom: 12,
  paint: {
    'circle-radius': ['interpolate', ['linear'], ['zoom'], 12, 2, 18, 12],
    'circle-color': '#d73027',
    'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0, 14, 0.9]
  }
});
// heatmap 同步淡出
map.setPaintProperty('orders-heat', 'heatmap-opacity',
  ['interpolate', ['linear'], ['zoom'], 12, 0.8, 14, 0]);

两个图层的透明度互补,视觉上是”分布图连续放大成点图”,没有跳变。

15.3 Cluster:把点合成簇

cluster 是 source 级能力,不是图层属性。开启后 worker 用 supercluster 生成一层新的点要素,每个簇带四个内置属性:

属性 类型 含义
cluster boolean 是否为簇
cluster_id number 簇 id,取子要素时用它
point_count number 簇内点数
point_count_abbreviated string 缩写点数,如 "1.2k"

source 参数(默认值来自 style spec):

参数 默认值 说明
cluster false 开启聚合
clusterRadius 50 簇半径(像素)
clusterMaxZoom maxzoom - 1(源 maxzoom 默认 18,即 17) 高于此级别不再聚合
clusterMinPoints 2 少于该数量不构成簇
clusterProperties 无 自定义聚合属性,如求和、取最大
map.addSource('sites', {
  type: 'geojson',
  data: 'https://example.com/sites.geojson',
  cluster: true,
  clusterRadius: 60,
  clusterMaxZoom: 14,
  clusterProperties: {
    // 累加每个点的 capacity 字段,簇上得到总容量
    total_capacity: ['+', ['get', 'capacity']]
  }
});

clusterProperties 的值是 [operator, map_expression] 或 [reduce_expression, map_expression]:前者用 +、max 这类二元运算符累加,后者可引用 ["accumulated"] 写更复杂的归约。

15.3.1 三个图层表达簇的层次

标准的三层写法:小簇、大簇、单点各用一个 filter 分开:

// 1) 小簇:一个圆 + 数字
map.addLayer({
  id: 'clusters',
  type: 'circle',
  source: 'sites',
  filter: ['has', 'point_count'],
  paint: {
    'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 100, '#f1f075', 750, '#f28cb1'],
    'circle-radius': ['step', ['get', 'point_count'], 18, 100, 24, 750, 30]
  }
});

// 2) 簇内数字
map.addLayer({
  id: 'cluster-count',
  type: 'symbol',
  source: 'sites',
  filter: ['has', 'point_count'],
  layout: {
    'text-field': ['get', 'point_count_abbreviated'],
    'text-size': 12,
    'text-allow-overlap': true
  }
});

// 3) 未聚合的单点
map.addLayer({
  id: 'unclustered',
  type: 'circle',
  source: 'sites',
  filter: ['!', ['has', 'point_count']],
  paint: { 'circle-color': '#11b4da', 'circle-radius': 4, 'circle-stroke-width': 1, 'circle-stroke-color': '#fff' }
});

关键在 filter: ['has', 'point_count']:聚合后的簇一定带这个属性,原始点没有。用 ['!', ['has', ...]] 取未聚合点。

15.3.2 点击散开的完整实现

supercluster 的 API 通过 GeoJSONSource 暴露,全部是异步的:

// 点击簇:放大到该簇散开的级别
map.on('click', 'clusters', async (e) => {
  const feature = e.features[0];
  const clusterId = feature.properties.cluster_id;
  const source = map.getSource('sites');

  // 1) 该簇完全散开所需的 zoom
  const zoom = await source.getClusterExpansionZoom(clusterId);
  map.easeTo({ center: feature.geometry.coordinates, zoom });

  // 2) 也可以先取子要素,自己决定怎么展示
  const children = await source.getClusterChildren(clusterId);
  console.log('子要素', children.length, children.map((c) => c.properties));
});

// 点击单点:列出簇内原始点
map.on('click', 'unclustered', async (e) => {
  const f = e.features[0];
  console.log('点位属性', f.properties);
});

其余两个方法:getClusterChildren(clusterId) 返回下一层的簇或点,getClusterLeaves(clusterId, limit, offset) 分页返回簇内原始点——后者适合”点开看列表”的详情面板:

const source = map.getSource('sites');
const { point_count } = clusterFeature.properties;
const leaves = await source.getClusterLeaves(clusterFeature.properties.cluster_id, point_count, 0);
console.log('原始点', leaves.map((f) => f.properties.name));

运行时改聚合参数用 setClusterOptions,它返回 Promise,改完要等重新聚合:

await map.getSource('sites').setClusterOptions({ cluster: true, clusterRadius: 80, clusterMaxZoom: 16 });
const opts = map.getSource('sites').getClusterOptions();  // {cluster, clusterMaxZoom, clusterRadius}

注意非整数 clusterMaxZoom 会被四舍五入到最近的整数,因为 supercluster 要求整数。

15.4 符号可视化:碰撞才是性能瓶颈

symbol 图层逐个要素做布局、碰撞检测、放置,比 circle 重一个数量级。它的关键默认值:

属性 默认值 说明
text-size 16 文字大小(像素)
text-font ["Open Sans Regular", "Arial Unicode MS Regular"] 字体栈,必须在 glyphs 里有
text-max-width 10 单位是 em,超过自动换行
text-line-height 1.2 行高
text-padding / icon-padding 2 碰撞包围盒外扩像素
symbol-spacing 250 沿线放置的间距(像素)
symbol-placement 'point' point / line / line-center
symbol-avoid-edges false 是否避开地图边缘
icon-size 1 图标缩放
icon-rotation-alignment / text-rotation-alignment 'auto' 与世界坐标或视口对齐

v6 的碰撞判定由 *-overlap(never / always / cooperative)控制,未设时回退到旧的 *-allow-overlap 布尔值(默认 false,即 never)。三个模式的含义:

  • never:参与碰撞,被压住就不画(默认)
  • always:不参与碰撞,全部画出来,互相压
  • cooperative:只在同一图层内避让,与其他图层不冲突
map.addLayer({
  id: 'poi-labels',
  type: 'symbol',
  source: 'poi',
  layout: {
    'text-field': ['get', 'name'],
    'text-size': ['interpolate', ['linear'], ['zoom'], 10, 11, 16, 15],
    'text-overlap': 'cooperative',     // 同类只避让自己人
    'text-ignore-placement': false,    // 允许其他图层压上来
    'symbol-sort-key': ['-', 0, ['get', 'rank']]   // rank 小的优先放置
  },
  paint: { 'text-color': '#222', 'text-halo-color': '#fff', 'text-halo-width': 1.2 }
});

symbol-sort-key 决定谁先抢占位置。不设时按瓦片内的要素顺序,结果不稳定;按重要度排序能保证重要标注优先生效。

15.4.1 性能取舍

手段 效果 代价
text-overlap: 'always' 跳过碰撞检测,最快 文字互相重叠,可读性差
text-allow-overlap: true 等价于上一条的旧写法 同上
限制 minzoom 低缩放不布局文字 低缩放无标注
减少 symbol 图层数 每层独立碰撞 样式表达力下降
text-optional: true 图标放下时不强求文字也放下 部分要素缺文字
sprite 图标代替字体图标 单次请求 + 图集采样 需要维护图集

text-optional 默认 false,意思是”要么图标文字都放,要么都不放”;设为 true 允许只放图标。POI 密集时打开它,标注数量能降一截。

15.5 三套数据的组合顺序

图层顺序决定视觉层次,也决定交互优先级。staticmethod 推荐顺序(自下而上):

heatmap   ← 分布底色,无交互
clusters  ← 簇圆,可点击
cluster-count ← 簇数字,不让位
unclustered   ← 单点
poi-labels    ← 名称标注,最上层

交互绑定时用图层过滤,而不是在全局 click 里自己判断:

map.on('click', 'clusters', handlerA);       // 只对簇生效
map.on('click', 'unclustered', handlerB);    // 只对单点生效
// 鼠标样式
map.on('mouseenter', 'clusters', () => { map.getCanvas().className = 'maplibregl-canvas-pointing'; });
map.on('mouseleave', 'clusters', () => { map.getCanvas().className = 'maplibregl-canvas'; });

map.getCanvas().className 这一行要注意:它会把 canvas 上原有的类名(maplibregl-canvas)一起覆盖掉,所以要么复原时写回完整类名,要么改用 classList.add/remove。

15.6 排错

现象 原因 修法
热力图整屏一片蓝 heatmap-color 第一档不透明 第一档 alpha 设 0
热力图层完全看不到 数据不是点要素,或 heatmap-weight 全 0 heatmap 只处理点;确认权重表达式
簇不出现,全是单点 忘了 cluster: true,或在 setData 后重建了源 源上开 cluster;setData 会保留聚合配置
getClusterExpansionZoom 返回 17 却没散开 clusterMaxZoom 限制了上限 调大 clusterMaxZoom
点击簇没反应 交互绑定写成了 map.on('click', handler) 全局形式 用 map.on('click', 'clusters', handler)
文字重叠一片 设了 text-overlap: 'always' 又不设 symbol-sort-key 加排序键,或改回 never
文字不显示、控制台报字体 404 glyphs 缺失或字体栈里的字体不存在 提供 glyphs,字体用服务端真实存在的名字

最后一条的报错通常是 AJAXError: ... 404 配 glyphs 路径,或者干脆静默无文字。text-font 的默认栈要求服务端有 Open Sans Regular 和 Arial Unicode MS Regular;用 demotiles 的 glyphs 服务时,常见可用字体是 Noto Sans Regular,需要显式改 text-font。

15.7 生产建议

heatmap 不替代统计。 它表达的是”视觉密度”,与数量不是线性对应;要显示”每个网格多少单”,用聚合后的 circle + text-field,数字才可信。

cluster 的 input 必须排序稳定。 supercluster 在相同参数下结果稳定,但 clusterMaxZoom 改一位,整个聚类形态就会变。UI 上不要暴露连续滑杆,给 3–4 个固定档位。

10 万点以上切矢量瓦片。 GeoJSON + cluster 在 100 万点时会卡在 worker 的初次聚合(几秒级)。数据量到这个级别,服务端出瓦片、客户端只用 symbol/circle,初始加载能降到 1 秒内。

heatmap 只在低缩放开启。 它是全屏后处理,GPU 上每像素一次密度采样,叠在 raster 底图上对移动端 GPU 压力明显;z12 以上关掉。

point_count_abbreviated 优先于 point_count。 4 位数会把圆撑大,缩写形式(1.2k)在视觉上更稳。

15.8 本章小结

要点 事实
heatmap 默认值 radius 30、weight 1、intensity 1、opacity 1,颜色默认内置渐变
颜色第一档 必须 alpha 0,否则全屏染色
cluster 默认 cluster: false、clusterRadius: 50、clusterMaxZoom: maxzoom-1、clusterMinPoints: 2
簇属性 cluster、cluster_id、point_count、point_count_abbreviated
聚合 API getClusterExpansionZoom / getClusterChildren / getClusterLeaves 均为 Promise
碰撞控制 v6 用 *-overlap(never/always/cooperative),旧 *-allow-overlap 作回退
性能瓶颈 symbol 的碰撞检测,不是绘制;symbol-sort-key 决定放置优先级