Source: bemap-maplibre/bemap-maplibre-v5/bemap-map-maplibre-clustering.js

/**
 * BeNomad BeMap JavaScript API - MapLibre v5 - Clustering
 *
 * Enhances ClusterLayer support for MapLibre with native GeoJSON clustering.
 *
 * Usage:
 *   var points = [
 *     new bemap.Coordinate(2.35, 48.85),
 *     new bemap.Coordinate(7.26, 43.71),
 *     // ... hundreds/thousands of points
 *   ];
 *
 *   var clusterLayer = new bemap.ClusterLayer({
 *     name: 'stations',
 *     distance: 50,  // cluster radius in pixels
 *     style: new bemap.clusterStyle({
 *       color: new bemap.Color(0, 150, 255, 1),
 *       borderColor: new bemap.Color(255, 255, 255, 1),
 *       textColor: new bemap.Color(255, 255, 255, 1),
 *       icon: new bemap.Icon({ src: 'marker.svg' })
 *     })
 *   });
 *   map.addLayer(clusterLayer);
 *   map.addClusterPoints(clusterLayer, points, { properties: [...] });
 */

bemap.MapLibreMap.prototype.addClusterPoints = function(clusterLayer, points, options) {
  if (!clusterLayer || !bemap.inheritsof(clusterLayer, bemap.ClusterLayer)) return this;

  var opts = options || {};
  var _this = this;
  var sourceId = clusterLayer._sourceId || clusterLayer._maplibreId + '-cluster-src';
  clusterLayer._clusterSourceId = sourceId;
  clusterLayer._clusterLayerIds = [];

  // Build GeoJSON features from coordinates
  var features = [];
  for (var i = 0; i < points.length; i++) {
    var pt = points[i];
    var lon = pt.getLon ? pt.getLon() : pt.lon;
    var lat = pt.getLat ? pt.getLat() : pt.lat;
    var props = (opts.properties && opts.properties[i]) ? opts.properties[i] : {};
    features.push({
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [lon, lat] },
      properties: props
    });
  }

  var cs = clusterLayer.style;
  var clusterColor = cs && cs.color ? this._colorToRgba(cs.color) : '#51bbd6';
  var borderColor = cs && cs.borderColor ? this._colorToRgba(cs.borderColor) : '#fff';
  var textColor = cs && cs.textColor ? this._colorToRgba(cs.textColor) : '#fff';
  var clusterSize = cs && cs.size ? cs.size : 20;

  var clustersId = sourceId + '-clusters';
  var countId = sourceId + '-count';
  var pointsId = sourceId + '-points';

  var addToMap = function() {
    if (_this.native.getSource(sourceId)) return;

    _this.native.addSource(sourceId, {
      type: 'geojson',
      data: { type: 'FeatureCollection', features: features },
      cluster: true,
      clusterRadius: clusterLayer.distance || 50,
      clusterMaxZoom: 14
    });

    // Cluster circles
    _this.native.addLayer({
      id: clustersId,
      type: 'circle',
      source: sourceId,
      filter: ['has', 'point_count'],
      paint: {
        'circle-color': clusterColor,
        'circle-radius': ['step', ['get', 'point_count'], clusterSize, 100, clusterSize * 1.5, 750, clusterSize * 2],
        'circle-stroke-width': cs && cs.borderSize ? cs.borderSize : 2,
        'circle-stroke-color': borderColor
      }
    });

    // Cluster count labels. Pick a font the map's glyphs endpoint actually
    // serves (see _resolveClusterFont): Noto Sans on BeNomad Tiles — MapLibre's
    // default "Open Sans Regular, Arial Unicode MS Regular" 404s there (PMT-43)
    // — but left UNSET on other/unknown glyphs (e.g. the demotiles fallback
    // serves Open Sans, not Noto) so a bare-PMTiles map does not regress.
    // Override via the cluster style `textFont`.
    var countGlyphs = null;
    try { countGlyphs = _this.native.getStyle && _this.native.getStyle().glyphs; } catch (e) { /* ignore */ }
    var countLayout = {
      'text-field': '{point_count_abbreviated}',
      'text-size': cs && cs.textSize ? cs.textSize * 6 : 12
    };
    var countFont = _this._resolveClusterFont(cs && cs.textFont, countGlyphs, _this.ctx && _this.ctx.tilesHost);
    if (countFont) countLayout['text-font'] = countFont;
    _this.native.addLayer({
      id: countId,
      type: 'symbol',
      source: sourceId,
      filter: ['has', 'point_count'],
      layout: countLayout,
      paint: {
        'text-color': textColor
      }
    });

    // Unclustered individual points
    _this.native.addLayer({
      id: pointsId,
      type: 'circle',
      source: sourceId,
      filter: ['!', ['has', 'point_count']],
      paint: {
        'circle-color': clusterColor,
        'circle-radius': 6,
        'circle-stroke-width': 1,
        'circle-stroke-color': borderColor
      }
    });

    // Click on cluster → zoom in
    _this.native.on('click', clustersId, function(e) {
      var feat = _this.native.queryRenderedFeatures(e.point, { layers: [clustersId] });
      if (!feat.length) return;
      var clusterId = feat[0].properties.cluster_id;
      _this.native.getSource(sourceId).getClusterExpansionZoom(clusterId).then(function(zoom) {
        _this.native.easeTo({ center: feat[0].geometry.coordinates, zoom: zoom });
      });
    });

    // Cursor pointer on clusters
    _this.native.on('mouseenter', clustersId, function() { _this.native.getCanvas().style.cursor = 'pointer'; });
    _this.native.on('mouseleave', clustersId, function() { _this.native.getCanvas().style.cursor = ''; });

    clusterLayer._clusterLayerIds = [clustersId, countId, pointsId];
  };

  if (this.native.isStyleLoaded()) { addToMap(); } else { this.native.on('load', addToMap); }

  return this;
};

/**
 * Resolve the cluster-count label font (PMT-43). An explicit `csTextFont`
 * (cluster style `textFont`) always wins. Otherwise pin ['Noto Sans Regular']
 * ONLY when the map's glyphs endpoint is the BeNomad tiles host (which serves
 * Noto Sans, not MapLibre's default Open Sans); for any other/unknown glyphs
 * endpoint (e.g. the demotiles fallback, which serves Open Sans) return null so
 * the cluster-count layer keeps MapLibre's implicit default and does not 404 —
 * avoiding a regression on the SDK's own fallback path.
 * @private
 * @param {Array<String>=} csTextFont Cluster style textFont override.
 * @param {String=} glyphs Resolved glyphs URL of the current style.
 * @param {String=} tilesHost ctx.tilesHost.
 * @return {Array<String>|null}
 */
bemap.MapLibreMap.prototype._resolveClusterFont = function (csTextFont, glyphs, tilesHost) {
  if (csTextFont) return csTextFont;
  if (glyphs && tilesHost && String(glyphs).indexOf(tilesHost) !== -1) return ['Noto Sans Regular'];
  return null;
};