第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 决定放置优先级 |