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