> **本文回答**：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（推荐）

```js
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 }
});
```

点击聚合圈展开：

```js
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）

```js
// 假设已经有 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（图标 + 数字）

```js
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 聚合统计

```js
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）。

```js
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 聚合点

```js
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()`。
