/**
* 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;
};