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

/**
 * BeNomad BeMap JavaScript API - MapLibre v5 - Marker
 */

bemap.MapLibreMap.prototype.addMarker = function(marker, options) {
  if (!marker || !bemap.inheritsof(marker, bemap.Marker)) return this;

  var opts = options || {};
  var coord = marker.getCoordinate();

  this._addOwnToProperties(marker);

  // Build marker element with icon
  var el = null;
  if (marker.icon && marker.icon.src) {
    el = document.createElement('div');
    // No cursor here: a plain (click-only) marker keeps the default arrow. The
    // finger/"hand" (pointer) cursor is applied in draggableMarker ONLY when the
    // marker is made draggable — matching the OpenLayers/Leaflet backends
    // (PMT-28). The old code forced 'pointer' on EVERY marker, so even
    // non-draggable ones looked grabbable.
    var img = document.createElement('img');
    img.src = marker.icon.src;

    // Calculate final pixel dimensions (like Leaflet: width * scale)
    var _ico = marker.icon;
    var _scale = _ico.scale || 1;
    var finalW = _ico.width ? Math.round(_ico.width * _scale) : null;
    var finalH = _ico.height ? Math.round(_ico.height * _scale) : null;

    if (finalW) img.style.width = finalW + 'px';
    if (finalH) img.style.height = finalH + 'px';
    // No CSS transform:scale() — image is already at final pixel size

    // Apply icon className if set (e.g. 'selected-station-marker')
    if (_ico.className) {
      el.classList.add(_ico.className);
    }

    // Shadow on element
    el.style.filter = 'drop-shadow(2px 2px 3px #222)';

    el.appendChild(img);

    if (marker.name && marker.textStyle) {
      var label = document.createElement('div');
      label.textContent = marker.name;
      label.style.fontSize = (marker.textStyle.size || 12) + 'px';
      label.style.textAlign = 'center';
      label.style.marginTop = (marker.textStyle.offsetY || 0) + 'px';
      if (marker.textStyle.color) label.style.color = this._colorToRgba(marker.textStyle.color);
      el.appendChild(label);
    }
  }

  // Compute marker anchor. Prefer MapLibre's named anchors when the
  // user's fractional anchor matches one of the 9 standard positions —
  // MapLibre positions named anchors from the actual rendered DOM size,
  // so they stay accurate at every zoom level even when icon.width /
  // icon.height were not specified. The previous pixel-offset path
  // silently fell back to anchor='center' when width/height were 0,
  // which made bottom-anchored pin icons drift up by ~half-icon-height
  // pixels — invisible at high zoom but ~50 km off at zoom 5.
  var ANCHOR_BY_FRAC = {
    '0,0':     'top-left',    '0.5,0':   'top',     '1,0':   'top-right',
    '0,0.5':   'left',        '0.5,0.5': 'center',  '1,0.5': 'right',
    '0,1':     'bottom-left', '0.5,1':   'bottom',  '1,1':   'bottom-right'
  };
  var markerOpts = {};
  if (el) markerOpts.element = el;
  if (marker.icon) {
    var _ic = marker.icon;
    var _sc = _ic.scale || 1;
    var w = _ic.width ? Math.round(_ic.width * _sc) : 0;
    var h = _ic.height ? Math.round(_ic.height * _sc) : 0;
    var fracX, fracY;

    if (_ic.anchorXUnits === 'pixels') {
      fracX = w > 0 ? (_ic.anchorX || 0) / w : 0.5;
    } else {
      fracX = _ic.anchorX !== undefined ? _ic.anchorX : 0.5;
    }
    if (_ic.anchorYUnits === 'pixels') {
      fracY = h > 0 ? (_ic.anchorY || 0) / h : 0.5;
    } else {
      fracY = _ic.anchorY !== undefined ? _ic.anchorY : 0.5;
    }

    var key = fracX + ',' + fracY;
    if (ANCHOR_BY_FRAC[key]) {
      // Standard 9-position anchor — MapLibre handles it from the DOM size.
      markerOpts.anchor = ANCHOR_BY_FRAC[key];
    } else if (w > 0 && h > 0) {
      // Non-standard fractional anchor + known icon size → pixel offset.
      // (anchor='center' is MapLibre's default; offset shifts from there.)
      var offX = Math.round((0.5 - fracX) * w);
      var offY = Math.round((0.5 - fracY) * h);
      if (offX !== 0 || offY !== 0) {
        markerOpts.offset = [offX, offY];
      }
    }
    // else: non-standard anchor + no width/height → fall back to MapLibre's
    //       default 'center'. In practice the demo icons use one of the 9
    //       standard anchors so this branch is unreachable.
  }

  marker.native = new maplibregl.Marker(markerOpts)
    .setLngLat([coord.getLon(), coord.getLat()]);

  if (marker.map === null) marker.map = this;

  // Register for hit-testing
  var markerEl = marker.native.getElement();
  markerEl.classList.add('bemap-marker');
  this._markerElements[marker._bemapId] = { element: markerEl, bemapObject: marker };

  // Leaflet compatibility: _icon alias for element access (used by evmove5 highlightMarker)
  marker.native._icon = markerEl;

  marker.native.addTo(this.native);

  // Forward DOM events on marker element to bemap event system.
  // `mousedown` is intentionally OMITTED — adding it broke MapLibre's
  // built-in drag detection (the marker's native `setDraggable(true)`
  // relies on mousedown reaching MapLibre's gestures handler).
  //
  // We DO call stopPropagation on `click` / `dblclick` — without that,
  // the canvas-level handler that `onMarker` wires via `_onFeature`
  // ALSO fires the same callback (its hit-test path finds the marker
  // and fires `marker.callback[eventType]`). Result: the callback ran
  // twice per click. mousedown / mouseup must propagate for drag.
  var _map = this;
  var domEvents = ['click', 'mouseup', 'dblclick'];
  for (var di = 0; di < domEvents.length; di++) {
    (function(domEvtType) {
      markerEl.addEventListener(domEvtType, function(domEvt) {
        if (domEvtType === 'click' || domEvtType === 'dblclick') {
          domEvt.stopPropagation();
        }
        var lnglat = marker.native.getLngLat();
        var coord = new bemap.Coordinate(lnglat.lng, lnglat.lat);
        var mapEvent = new bemap.MapEvent({
          native: domEvt,
          map: _map,
          bemapObject: marker,
          coordinate: coord,
          properties: marker.properties
        });

        // Try the DOM event name first (matches what `onMarker(marker,
        // bemap.Map.EventType.CLICK, fn)` stores: marker.callback['click']),
        // then fall back to any bemap event names that map to this DOM
        // event (e.g. 'singleclick' → 'click' in MapLibreMap_eventMap).
        var attempts = [domEvtType];
        for (var bemapKey in bemap.MapLibreMap_eventMap) {
          if (bemap.MapLibreMap_eventMap[bemapKey] === domEvtType && attempts.indexOf(bemapKey) === -1) {
            attempts.push(bemapKey);
          }
        }
        for (var ei = 0; ei < attempts.length; ei++) {
          var cb = _map._checkModeOfEvent({ singleFeature: true }, marker, attempts[ei]);
          if (cb) { cb(mapEvent); break; }
        }
      });
    })(domEvents[di]);
  }

  // Track in layer
  var l = null;
  if (opts.layer && bemap.inheritsof(opts.layer, bemap.Layer)) {
    l = opts.layer;
  } else {
    l = this.getLayerByName(bemap.Map.DEFAULT_LAYER.MARKER);
  }

  // If target is a ClusterLayer, keep the DOM marker but track for clustering
  if (l && bemap.inheritsof(l, bemap.ClusterLayer)) {
    // Hide initially — _refreshClusterVisibility will show unclustered ones
    marker.native.getElement().style.display = 'none';
    // Store cluster data — the DOM marker stays on the map
    if (!l._clusterFeatures) l._clusterFeatures = [];
    if (!l._clusterBemapObjects) l._clusterBemapObjects = {};
    if (!l._clusterMarkerNatives) l._clusterMarkerNatives = {};

    var feat = {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [coord.getLon(), coord.getLat()] },
      properties: { _bemapId: marker._bemapId }
    };
    l._clusterFeatures.push(feat);
    l._clusterBemapObjects[marker._bemapId] = marker;
    l._clusterMarkerNatives[marker._bemapId] = marker.native;
    if (l && l.native && l.native.markers) l.native.markers.push(marker.native);
    if (l && l.native && l.native.features) l.native.features.push(marker);
    if (marker.layer === null) marker.layer = l;

    // Debounced cluster source update
    if (l._clusterUpdateTimer) clearTimeout(l._clusterUpdateTimer);
    l._clusterUpdateTimer = setTimeout(function() {
      _map._updateClusterSource(l);
    }, 50);

    return this;
  }

  if (l && l.native && l.native.markers) l.native.markers.push(marker.native);
  if (l && l.native && l.native.features) l.native.features.push(marker);
  if (marker.layer === null) marker.layer = l;

  return this;
};

/**
 * Create or update the MapLibre cluster source for a ClusterLayer.
 * Strategy: GeoJSON cluster source determines which points are clustered.
 * Original DOM markers are shown/hidden based on cluster state.
 * Cluster bubbles are DOM elements with Leaflet CSS classes.
 */
bemap.MapLibreMap.prototype._updateClusterSource = function(clusterLayer) {
  if (!clusterLayer || !clusterLayer._clusterFeatures) return;

  var sourceId = clusterLayer._sourceId || clusterLayer._maplibreId + '-src';
  var data = { type: 'FeatureCollection', features: clusterLayer._clusterFeatures };
  var _this = this;

  // If source already exists, just update data
  if (this.native.getSource(sourceId)) {
    this.native.getSource(sourceId).setData(data);
    this._refreshClusterVisibility(clusterLayer, sourceId);
    return;
  }

  // Create cluster source (no visible layers — we render everything via DOM)
  try {
    this.native.addSource(sourceId, {
      type: 'geojson',
      data: data,
      cluster: true,
      clusterRadius: clusterLayer.distance || 50,
      clusterMaxZoom: 14
    });

    // Add invisible layer so MapLibre generates tiles and querySourceFeatures works
    var hiddenLayerId = sourceId + '-hidden';
    this.native.addLayer({
      id: hiddenLayerId,
      type: 'circle',
      source: sourceId,
      paint: { 'circle-radius': 0, 'circle-opacity': 0 }
    });

    clusterLayer._clusterSourceId = sourceId;
    clusterLayer._clusterLayerIds = [hiddenLayerId];
    if (!clusterLayer._clusterBubbles) clusterLayer._clusterBubbles = [];

    // Refresh visibility on zoom/move
    var _refreshTimer = null;
    var _doRefresh = function() {
      if (_refreshTimer) clearTimeout(_refreshTimer);
      _refreshTimer = setTimeout(function() {
        _this._refreshClusterVisibility(clusterLayer, sourceId);
      }, 100);
    };
    this.native.on('moveend', _doRefresh);
    this.native.on('zoomend', _doRefresh);
    this.native.on('sourcedata', function(e) {
      if (e.sourceId === sourceId) _doRefresh();
    });
    // Initial refresh
    setTimeout(function() { _this._refreshClusterVisibility(clusterLayer, sourceId); }, 200);

  } catch(e) { /* cluster source creation failed */ }
};

/**
 * Show/hide DOM markers for unclustered points in a ClusterLayer.
 * Clustered points are hidden; unclustered ones show their real marker icon.
 */
/**
 * Show/hide original DOM markers based on cluster state.
 * Clustered markers are hidden, unclustered are shown.
 * Cluster bubbles (circles with count) are created as separate DOM elements.
 */
bemap.MapLibreMap.prototype._refreshClusterVisibility = function(clusterLayer, sourceId) {
  if (!clusterLayer || !clusterLayer._clusterMarkerNatives) return;
  if (!this.native.getSource(sourceId)) return;

  var _this = this;

  // Query source features to find clusters and unclustered points
  var allFeatures = [];
  try {
    allFeatures = this.native.querySourceFeatures(sourceId);
  } catch(e) { return; }

  // If source not ready yet, show all markers and no bubbles
  if (!allFeatures || allFeatures.length === 0) return;

  // Separate clusters from singles
  var clusters = {};
  for (var r = 0; r < allFeatures.length; r++) {
    var f = allFeatures[r];
    var props = f.properties || {};
    if (props.cluster_id !== undefined && props.point_count) {
      if (!clusters[props.cluster_id]) {
        clusters[props.cluster_id] = { coords: f.geometry.coordinates, count: props.point_count };
      }
    }
  }

  // If there are clusters, find which individual points are NOT clustered
  // A point is clustered if it's NOT returned as an individual feature
  var visibleSingles = {};
  for (var r2 = 0; r2 < allFeatures.length; r2++) {
    var f2 = allFeatures[r2];
    var p2 = f2.properties || {};
    if (p2._bemapId && !p2.cluster_id) {
      visibleSingles[p2._bemapId] = true;
    }
  }

  // Check if state changed
  var key = Object.keys(clusters).sort().join(',') + '|' + Object.keys(visibleSingles).sort().join(',');
  if (clusterLayer._lastClusterKey === key) return;
  clusterLayer._lastClusterKey = key;

  // Show/hide individual markers
  var hasAnyClusters = Object.keys(clusters).length > 0;
  for (var bid in clusterLayer._clusterMarkerNatives) {
    var mk = clusterLayer._clusterMarkerNatives[bid];
    if (mk && mk.getElement) {
      if (!hasAnyClusters || visibleSingles[bid]) {
        mk.getElement().style.display = '';
      } else {
        mk.getElement().style.display = 'none';
      }
    }
  }

  // Remove old cluster bubbles
  if (clusterLayer._clusterBubbles) {
    for (var b = 0; b < clusterLayer._clusterBubbles.length; b++) {
      clusterLayer._clusterBubbles[b].remove();
    }
  }
  clusterLayer._clusterBubbles = [];

  // Create cluster bubbles with Leaflet CSS classes
  for (var cid in clusters) {
    var cl = clusters[cid];
    var count = cl.count;
    var sizeClass = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';

    var el = document.createElement('div');
    el.className = 'marker-cluster marker-cluster-' + sizeClass + ' bemap-cluster';
    var inner = document.createElement('div');
    var span = document.createElement('span');
    span.textContent = count;
    inner.appendChild(span);
    el.appendChild(inner);

    var bubble = new maplibregl.Marker({ element: el })
      .setLngLat(cl.coords)
      .addTo(_this.native);

    // Click cluster → zoom in
    (function(clusterId, bmk) {
      el.addEventListener('click', function(domEvt) {
        domEvt.stopPropagation();
        _this.native.getSource(sourceId).getClusterExpansionZoom(parseInt(clusterId)).then(function(zoom) {
          _this.native.easeTo({ center: bmk.getLngLat(), zoom: zoom });
        });
      });
    })(cid, bubble);

    clusterLayer._clusterBubbles.push(bubble);
  }
};

bemap.MapLibreMap.prototype.removeMarker = function(marker) {
  if (marker && marker.native) {
    marker.native.remove();
    if (marker._bemapId) {
      delete this._markerElements[marker._bemapId];
      delete this._featureRegistry[marker._bemapId];
    }
    marker.map = null;
    marker.layer = null;
  }
  return this;
};

bemap.MapLibreMap.prototype.setCoordinateMarker = function(marker) {
  if (marker && marker.native) {
    var c = marker.getCoordinate();
    marker.native.setLngLat([c.getLon(), c.getLat()]);
  }
  return this;
};

bemap.MapLibreMap.prototype.draggableMarker = function(marker, callback, options) {
  if (marker && marker.native) {
    marker.native.setDraggable(true);
    // Draggable markers show the finger/"hand" (pointer) cursor; plain markers
    // keep the default arrow (addMarker sets no cursor). Matches OL/Leaflet (PMT-28).
    var _dragEl = (typeof marker.native.getElement === 'function') ? marker.native.getElement() : null;
    if (_dragEl) _dragEl.style.cursor = 'pointer';
    if (!marker.events) marker.events = {};
    if (!marker.callback) marker.callback = {};
    marker.events.draggable = true;
    marker.callback.draggable = callback;
    var _this = this;

    // Capture the container-pixel position where the drag started.
    // Consumers (e.g. bfleet's gps-marker drop) gate their drag handling on
    // the MapEvent's `startX`/`startY` and compare them against `x`/`y` —
    // exactly like the OpenLayers backend's drag MapEvent. Without these
    // pixel fields the drop is silently ignored and no recalculation runs.
    var startPixel = null;
    var startLngLat = null;
    marker.native.on('dragstart', function() {
      startLngLat = marker.native.getLngLat();
      startPixel = _this.native.project([startLngLat.lng, startLngLat.lat]);
    });

    marker.native.on('drag', function() {
      var ll = marker.native.getLngLat();
      marker.coordinate.setLon(ll.lng).setLat(ll.lat);
    });

    marker.native.on('dragend', function() {
      var ll = marker.native.getLngLat();
      marker.coordinate.setLon(ll.lng).setLat(ll.lat);
      if (callback) {
        var endPixel = _this.native.project([ll.lng, ll.lat]);
        var sPix = startPixel || endPixel;
        var sLL = startLngLat || ll;
        callback(new bemap.MapEvent({
          bemapObject: marker,
          coordinate: new bemap.Coordinate(ll.lng, ll.lat),
          startCoordinate: new bemap.Coordinate(sLL.lng, sLL.lat),
          x: endPixel.x,
          y: endPixel.y,
          startX: sPix.x,
          startY: sPix.y,
          map: _this
        }));
      }
      startPixel = null;
      startLngLat = null;
    });
  }
  return new bemap.Listener({ native: marker ? marker.native : null, bemapObject: marker, key: 'dragFeature' });
};

bemap.MapLibreMap.prototype.draggableMarkers = function(callback, options) {
  var layer = this.getLayerByName(bemap.Map.DEFAULT_LAYER.MARKER);
  if (layer && layer.native && layer.native.features) {
    for (var i = 0; i < layer.native.features.length; i++) {
      var obj = layer.native.features[i];
      if (bemap.inheritsof(obj, bemap.Marker)) {
        this.draggableMarker(obj, callback, options);
      }
    }
  }
  return new bemap.Listener();
};

bemap.MapLibreMap.prototype.addMultiMarker = function(multimarker, options) {
  if (!multimarker) return this;
  this._addOwnToProperties(multimarker);
  multimarker._childMarkers = [];
  var coords = multimarker.getCoordinates();
  for (var i = 0; i < coords.length; i++) {
    var m = new bemap.Marker(coords[i], { icon: multimarker.icon, id: multimarker.id + '_' + i });
    this.addMarker(m, options);
    multimarker._childMarkers.push(m);
  }
  multimarker.map = this;
  return this;
};

bemap.MapLibreMap.prototype.removeMultimarker = function(multimarker) {
  if (multimarker && multimarker._childMarkers) {
    for (var i = 0; i < multimarker._childMarkers.length; i++) {
      this.removeMarker(multimarker._childMarkers[i]);
    }
    multimarker._childMarkers = [];
  }
  if (multimarker) multimarker.map = null;
  return this;
};

bemap.MapLibreMap.prototype.draggableMultiMarkers = function(callback, options) {
  return new bemap.Listener();
};