唯杰地图 logo唯杰地图
VJMAP
EN

cluster

vjmap/references/marker/cluster.md 6.8 KB205 行
原文

本文回答: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);
  });
});

九、常见坑

  1. GeoJSON 坐标没转经纬度 → 聚合位置全错。记得 map.toLngLat。
  2. cluster 改色没效 → step 表达式类型错;["step", ["get", "point_count"], ...] 里 key 必须是数字。
  3. cluster 显示成黑块 → sprite 里没有对应图标;map.loadImage 检查。
  4. setData 后 cluster 不刷新 → source 被重建;调用 map.getSource("pts").setData(newData) 即可。
  5. queryRenderedFeatures 返回空 → options 只有 {layers, filter, validate},没有 padding;要扩大点选容差就传矩形框 map.queryRenderedFeatures([[x-10,y-10],[x+10,y+10]], {layers})。
  6. 簇展开 API 是回调式且只有三个:getClusterLeaves(id, limit, offset, cb)、getClusterChildren(id, cb)、getClusterExpansionZoom(id, cb) 都是 (err, result) 回调不是 Promise;getClusterParent 不存在(要父簇自己维护层级或改用 children 反查)。cluster 源渲染后 properties.cluster=true、cluster_id 为数值。
  7. map.toLngLat 返回 {lng,lat} 对象不是数组——没有 .toArray()。聚合 GeoJSON 转经纬度后别再对结果调 .toArray()。