cluster
本文回答:Marker / Symbol 聚合(Cluster)—— 几千点的典型做法。
一、两种路径
| 路径 | 何时 | 产物 |
|---|---|---|
| A. GeoJSON cluster + circle 图层(WebGL) | 点数 > 500,不需自定义卡片 | 圆+数字 |
B. vjmap.Marker + noOverlap |
点数 < 500,需要自定义卡片 | DOM Marker |
| C. Symbol layer + cluster(文字 + 图标) | 要自定义图标 | WebGL symbol |
二、路径 A:纯 WebGL cluster(推荐)
map.addSource("pts", {
type: "geojson",
data: map.toLngLat(featureCollection),
cluster: true,
clusterMaxZoom: 14, // 超过这个 zoom 不聚合
clusterRadius: 50 // 聚合像素半径
});
// 聚合点
map.addLayer({
id: "clusters",
type: "circle",
source: "pts",
filter: ["has", "point_count"],
paint: {
"circle-color": [
"step", ["get", "point_count"],
"#57FF8C", 100, "#FFA0FD", 1000, "#FF420E"
],
"circle-radius": [
"step", ["get", "point_count"],
20, 100, 30, 1000, 40
]
}
});
// 数字
map.addLayer({
id: "cluster-count",
type: "symbol",
source: "pts",
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-size": 12
}
});
// 未聚合的点
map.addLayer({
id: "unclustered",
type: "circle",
source: "pts",
filter: ["!", ["has", "point_count"]],
paint: { "circle-color": "#0af", "circle-radius": 5 }
});
点击聚合圈展开:
map.on("click", "clusters", e => {
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] });
const clusterId = features[0].properties.cluster_id;
map.getSource("pts").getClusterExpansionZoom(clusterId, (err, zoom) => {
if (!err) map.easeTo({ center: features[0].geometry.coordinates, zoom });
});
});
三、路径 B:DOM Marker 重叠去重(noOverlap)
// 假设已经有 const markers = [marker1, marker2, ...];
function updateNoOverlap() {
const shown = new Set();
const ns = [];
for (const m of markers) {
const p = map.project(m.getLngLat()); // 屏幕坐标
const key = `${Math.round(p.x / 40)},${Math.round(p.y / 40)}`;
if (shown.has(key)) { m.hide(); continue; }
shown.add(key);
ns.push(m);
m.show();
}
}
map.on("zoomend", updateNoOverlap);
map.on("moveend", updateNoOverlap);
updateNoOverlap();
四、路径 C:Symbol Layer cluster(图标 + 数字)
map.addSource("pts", {
type: "geojson",
data: map.toLngLat(features),
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 60
});
// 加载图标
await map.loadImageEx("pin", "/icons/pin.png");
map.addLayer({
id: "unclustered",
type: "symbol",
source: "pts",
filter: ["!", ["has", "point_count"]],
layout: { "icon-image": "pin", "icon-size": 0.6 }
});
map.addLayer({
id: "clusters",
type: "symbol",
source: "pts",
filter: ["has", "point_count"],
layout: {
"icon-image": "pin",
"icon-size": 0.8,
"text-field": "{point_count_abbreviated}",
"text-size": 12,
"text-offset": [0, -0.6]
},
paint: { "text-color": "#fff" }
});
五、clusterProperties 聚合统计
map.addSource("pts", {
type: "geojson",
data,
cluster: true,
clusterRadius: 50,
clusterProperties: {
sumWeight: ["+", ["get", "weight"]]
}
});
// 样式中引用
"text-field": ["number-format", ["get", "sumWeight"], {}]
六、vjmap.MarkerCluster(DOM Marker 防重叠)
⚠️ MarkerCluster 不是 GeoJSON cluster 聚合——它是"DOM marker 重叠避让"类:marker 太多挤在一起时按规则隐藏/合并显示。真正的聚合用上面路径 A(GeoJSON source cluster: true + circle/symbol layer)。
const clusterer = new vjmap.MarkerCluster({
data: features, // FeatureCollection 或 {point, properties}[];坐标为 **CAD 坐标**
createMarker: (cur, all) => { // cur.point 是 CAD 点,all 是同组避让数据
const el = document.createElement("div");
el.innerHTML = `<div class="dot">${cur.properties?.name ?? ""}</div>`;
// ⚠️ cur.point 是 CAD 坐标,必须先 toLngLat——直接 setLngLat(cur.point) 会抛 Invalid LngLat
return new vjmap.Marker({ element: el }).setLngLat(map.toLngLat(cur.point));
},
updateMarker: (cur, all, marker) => { /* 复用更新;返回 undefined 保留旧 marker,返回新 Marker 则替换 */ },
allowOverlap: false, // 不允许重叠(默认 false)
allowOverlapMaxZoom: 4, // ≤4 级才做避让(默认 4),超过此级全部显示
markerWidth: 40, markerHeight: 40, // 避让计算用的像素宽高(默认 40,可用 properties.markerWidth/Height 按点覆盖)
cameraNearFront: false // 近相机的排前面(默认 false)
});
clusterer.addTo(map);
clusterer.updateData(newFeatures); // 更新数据;内部会 fire("updateData", data)
七、选型对照
| 点数 | 推荐 |
|---|---|
| < 200 | 直接 Marker |
| 200–1000 | 路径 A(circle cluster)或 路径 B(noOverlap) |
| 1000–50000 | 路径 A(cluster) |
| 50000+ | Symbol layer + cluster + 按 zoom 分层显示 |
八、hover / click 聚合点
map.on("click", "clusters", e => {
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] });
const clusterId = features[0].properties.cluster_id;
map.getSource("pts").getClusterLeaves(clusterId, 100, 0, (err, leaves) => {
console.log("聚合内包含的点:", leaves);
});
});
九、常见坑
- GeoJSON 坐标没转经纬度 → 聚合位置全错。记得
map.toLngLat。 - cluster 改色没效 → step 表达式类型错;
["step", ["get", "point_count"], ...]里 key 必须是数字。 - cluster 显示成黑块 → sprite 里没有对应图标;
map.loadImage检查。 - setData 后 cluster 不刷新 → source 被重建;调用
map.getSource("pts").setData(newData)即可。 - queryRenderedFeatures 返回空 → options 只有
{layers, filter, validate},没有padding;要扩大点选容差就传矩形框map.queryRenderedFeatures([[x-10,y-10],[x+10,y+10]], {layers})。 - 簇展开 API 是回调式且只有三个:
getClusterLeaves(id, limit, offset, cb)、getClusterChildren(id, cb)、getClusterExpansionZoom(id, cb)都是(err, result)回调不是 Promise;getClusterParent不存在(要父簇自己维护层级或改用 children 反查)。cluster 源渲染后properties.cluster=true、cluster_id为数值。 map.toLngLat返回{lng,lat}对象不是数组——没有.toArray()。聚合 GeoJSON 转经纬度后别再对结果调.toArray()。