/**
* BeNomad BeMap JavaScript API - Map - Leaflet
*/
//bemap.require('leaflet.js');
/**
* @classdesc
* Base class for Leaflet.
* @public
* @constructor
* @extends {bemap.Map}
* @param {bemap.Context} context BeMap-JS-API Context.
* @param {string} target HTML element.
* @param options.
*/
bemap.LeafletMap = function (context, target, options) {
// Runtime dependency check — emits a loud console banner with the
// exact <script>/<link> tags to add when Leaflet is missing.
bemap.Map._checkDependencies('leaflet');
/**
* @protected
*/
this.target = target;
bemap.Map.call(this, context, options);
// Resolve native-attribution + zoom-control options (uniform across engines).
// bemap.Map._resolveControlOptions hides Leaflet's built-in attribution by
// default when the BeMap AttributionWidget is enabled (avoids duplication
// of the legal mentions). The zoom buttons stay on unless opted out, with
// a configurable position across all engines.
var _ctrl = bemap.Map._resolveControlOptions(options);
var _leafletOpts = {};
if (options) {
for (var _k in options) {
if (Object.prototype.hasOwnProperty.call(options, _k)) _leafletOpts[_k] = options[_k];
}
}
// Strip bemap-only keys that would confuse Leaflet's option parser.
delete _leafletOpts.attribution;
delete _leafletOpts.nativeAttribution;
delete _leafletOpts.keyboard;
_leafletOpts.attributionControl = _ctrl.nativeAttribution;
// Always disable the auto-added zoom — we add a positioned one below so
// the same `zoomControl: 'top-right'` API works across every engine.
_leafletOpts.zoomControl = false;
// Keyboard (default OFF — see bemap.Map._resolveControlOptions). Leaflet
// bundles arrow-pan and +/- zoom under one `keyboard` option, so the
// granular `{pan, zoom}` form collapses to "either-or" here: keyboard is
// active iff at least one of pan/zoom is requested. Setting
// `keyboardPanDelta: 0` makes the arrow-pan a no-op when only zoom was
// asked for — Leaflet's `+`/`-` zoom keys keep firing.
_leafletOpts.keyboard = _ctrl.keyboard.pan || _ctrl.keyboard.zoom;
if (_ctrl.keyboard.zoom && !_ctrl.keyboard.pan) _leafletOpts.keyboardPanDelta = 0;
this._keyboardEnabled = !!(_ctrl.keyboard.pan || _ctrl.keyboard.zoom);
this._keyboardOptions = _ctrl.keyboard;
/**
* @protected
*/
this.native = L.map(target, _leafletOpts).setView([0, 0], 2);
if (_ctrl.zoomControl) {
L.control.zoom({
position: bemap.Map._translateZoomPosition('leaflet', _ctrl.zoomPosition)
}).addTo(this.native);
}
// NOTE: this constructor used to rewrite bemap.Map.EventType.POINTERUP/
// POINTERDOWN/POINTERMOVE in place. See bemap.LeafletMap.EVENT_ALIAS below —
// the translation now happens per `on()` call, leaving the shared enum
// immutable.
// Attribution widget (v1.5.0) — auto-instantiated unless options.attribution === false
if (typeof bemap.AttributionWidget === 'function') {
var _attr = options ? options.attribution : undefined;
if (_attr !== false) {
var _attrOpts = (_attr && typeof _attr === 'object') ? _attr : {};
this._attributionWidget = new bemap.AttributionWidget(this, _attrOpts);
}
}
};
bemap.inherits(bemap.LeafletMap, bemap.Map);
/**
* Leaflet's native names for the three pointer events whose bemap constant has
* no Leaflet equivalent — Leaflet fires `mouseup`/`mousedown`/`mouseover`, never
* `pointerup`/`pointerdown`/`pointermove`.
*
* This used to be done by REWRITING `bemap.Map.EventType` inside the LeafletMap
* constructor. That enum is shared by all three engines, so on a page holding
* more than one map (the examples dashboard offers all three) whichever engine
* was constructed last redefined the constants for the others — and constructing
* a LeafletMap leaked into unrelated code, which is why Leaflet could not be
* loaded in the test harness at all. Translating per call fixes both.
* @public
* @since 2.0.2
*/
bemap.LeafletMap.EVENT_ALIAS = {
pointerup: 'mouseup',
pointerdown: 'mousedown',
pointermove: 'mouseover'
};
/**
* Translate a `bemap.Map.EventType` value to the event name Leaflet listens for.
* Unknown/other types pass through untouched.
* @private
* @param {String} eventType
* @return {String}
*/
bemap.LeafletMap._nativeEventName = function (eventType) {
var alias = bemap.LeafletMap.EVENT_ALIAS;
return (typeof eventType === 'string' && Object.prototype.hasOwnProperty.call(alias, eventType))
? alias[eventType]
: eventType;
};
/**
* Toggle keyboard navigation (arrow-pan + `+`/`-` zoom) at runtime.
* @public
* @param {Boolean} enabled
* @return {bemap.LeafletMap}
*/
bemap.LeafletMap.prototype.setKeyboard = function (enabled) {
if (this.native && this.native.keyboard) {
if (enabled) this.native.keyboard.enable();
else this.native.keyboard.disable();
}
this._keyboardEnabled = !!enabled;
return this;
};
/** @public @return {Boolean} */
bemap.LeafletMap.prototype.getKeyboard = function () { return !!this._keyboardEnabled; };
/**
* Add a layer to the Leaflet map.
* @public
* @param {bemap.Layer} layer
* @param {object} options
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.addLayer = function (layer, options) {
if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
if (bemap.inheritsof(layer, bemap.BemapLayer)) {
var layerOption = {
minZoom: layer.minZoom ? layer.minZoom : 2,
maxZoom: layer.maxZoom ? layer.maxZoom : 20,
'geoserver': layer.geoserver ? layer.geoserver : this.ctx.getGeoserver(),
'layers': layer.layers ? layer.layers : 'default',
'styles': layer.styles ? layer.styles : '',
'tiled': true,
'TRANSPARENT': layer.transparent === true ? true : false,
format: layer.format ? layer.format : 'image/png'
};
// Leaflet is the ONE engine that passes BeMap auth as layer OPTIONS (which
// L.tileLayer.wms turns into query params) instead of via
// ctx.getAuthUrlParams(). In proxy mode (EVMOVE-307) the credentials live on
// the proxy and must never reach the browser's wire, so this must be
// skipped explicitly — the getAuthUrlParams() guard does not cover it.
if (!(typeof this.ctx.getProxy === 'function' ? this.ctx.getProxy() : this.ctx.proxy)) {
if (this.ctx.login) {
layerOption.appid = this.ctx.login;
}
if (this.ctx.password) {
layerOption.appcode = this.ctx.password;
}
}
layer.native = L.tileLayer.wms(this.ctx.getBaseUrl() + 'wms', layerOption);
// Tag BeNomad WMS layers so setStyle('<theme>') re-styles ONLY these,
// never a customer's own bemap.WmsLayer (same L.tileLayer.wms constructor).
layer.native._bemapWms = true;
} else if (bemap.inheritsof(layer, bemap.WmsLayer)) {
layer.native = L.tileLayer.wms(layer.url, {
minZoom: layer.minZoom ? layer.minZoom : 2,
maxZoom: layer.maxZoom ? layer.maxZoom : 20,
'geoserver': layer.geoserver ? layer.geoserver : this.ctx.getGeoserver(),
'layers': layer.layers ? layer.layers : 'default',
'styles': layer.styles ? layer.styles : '',
'tiled': true,
'TRANSPARENT': layer.transparent === true ? true : false,
format: layer.format ? layer.format : 'image/png'
});
} else if (bemap.inheritsof(layer, bemap.OsmLayer)) {
console.warn("TODO OsmLayer");
} else if (bemap.inheritsof(layer, bemap.VectorLayer)) {
// Default-layer names (MARKER / POLYLINE / ROUTE / BACKGROUND) KEEP
// Leaflet's native pane behaviour: markers go into markerPane (z=600)
// and polylines/polygons/circles into overlayPane (z=400), so markers
// always render above paths — the convention every existing consumer
// (BFleet, EVMove5, zone-optimizer, BeMap-Doc) depends on.
//
// USER-named VectorLayers get a PAIR of private panes:
// `<layer>-mark` (z=600, markers + popups)
// `<layer>-path` (z=400, polylines / polygons / circles)
// Mirrors Leaflet's native z-split so markers always render above
// paths WITHIN the layer. zIndexLayer(layer, high) shifts both sub-
// panes together (high: 750/550, low: 250/100) so the whole layer
// can still be re-stacked up or down as one unit.
var dl = bemap.Map.DEFAULT_LAYER || {};
var isDefaultName = layer.name && (
layer.name === dl.MARKER || layer.name === dl.POLYLINE ||
layer.name === dl.ROUTE || layer.name === dl.BACKGROUND
);
if (isDefaultName) {
layer.native = L.layerGroup();
} else {
if (!bemap.LeafletMap._paneCounter) bemap.LeafletMap._paneCounter = 0;
var seq = ++bemap.LeafletMap._paneCounter;
var markPane = 'bemap-pane-' + seq + '-mark';
var pathPane = 'bemap-pane-' + seq + '-path';
try {
layer._paneMark = this.native.createPane(markPane);
layer._panePath = this.native.createPane(pathPane);
layer._paneNameMarker = markPane;
layer._paneNamePath = pathPane;
layer._paneMark.style.zIndex = 600; // same band as markerPane
layer._panePath.style.zIndex = 400; // same band as overlayPane
// The L.layerGroup itself doesn't render — only its children do.
// Children pick their pane from their own options (set in the
// addMarker / addPolyline / addPolygon / addCircle wrappers).
layer.native = L.layerGroup();
} catch (e) {
layer.native = L.layerGroup(); // fallback if pane API unavailable
}
}
} else if (bemap.inheritsof(layer, bemap.ClusterLayer)) {
layer.native = new L.MarkerClusterGroup({
showCoverageOnHover: false
//Icon Style
/*iconCreateFunction: function(cluster) {
return L.divIcon({
html: cluster.getChildCount(),
className: 'mycluster',
iconSize: null
});
}*/
});
} else {
console.warn("Unsupport layer");
}
if (layer.native !== null) {
// TODO
//bemap.OlMap.prototype._addOwnToProperties(layer);
layer.native.addTo(this.native);
bemap.Map.prototype.addLayer.call(this, layer);
}
}
if (layer.map === null) {
layer.map = this;
}
return this;
};
/**
* Re-stack a layer relative to the others. Leaflet has no native z-index on
* `L.layerGroup`, so we mutate the sub-layers individually:
* • Markers (`L.Marker`) → setZIndexOffset(±1000) so high vs low layers
* visually separate even when the DOM order is unchanged.
* • Paths (polyline / polygon / circle, `L.Path` subclasses) →
* bringToFront() / bringToBack().
*
* Threshold: zIndex ≥ 50 counts as "on top", everything else "behind".
* @public
* @param {bemap.Layer} layer
* @param {Number} zIndex
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.zIndexLayer = function (layer, zIndex) {
if (!layer || !layer.native) return this;
layer.zIndex = zIndex;
var high = (zIndex >= 50);
// Primary mechanism: mutate the layer's TWO custom panes (markers and
// paths) together so the whole layer moves as one band while preserving
// markers-above-paths order WITHIN the layer.
// Default Leaflet pane z-indexes for reference:
// tilePane=200, overlayPane=400, shadowPane=500, markerPane=600,
// tooltipPane=650, popupPane=700.
// high → marker pane 750, path pane 550 (above everything else)
// low → marker pane 350, path pane 250 (still above tilePane=200 so
// the polyline doesn't get hidden under the basemap tiles)
if (layer._paneMark) layer._paneMark.style.zIndex = high ? 750 : 350;
if (layer._panePath) layer._panePath.style.zIndex = high ? 550 : 250;
// Fallback for legacy layers without a custom pane: walk sub-layers and
// use Leaflet's built-in reorder hooks (works for same-pane stacking).
if (typeof layer.native.eachLayer === 'function') {
layer.native.eachLayer(function (sub) {
if (!sub) return;
if (typeof sub.setZIndexOffset === 'function') sub.setZIndexOffset(high ? 1000 : -1000);
if (typeof sub.bringToFront === 'function' && high) sub.bringToFront();
else if (typeof sub.bringToBack === 'function' && !high) sub.bringToBack();
});
}
return this;
};
/**
* Move map to new coordinate.
* @public
* @param {double} lon Longitude in degres decimal (WGS84).
* @param {double} lat Latitude in degres decimal (WGS84).
* @param {int} zoom Zoom level (optional).
* @param {Object} options Options (optional).
* @param {String} options.animate Enable the animation.
* @param {String} options.fly Enable the animation (same as animate).
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.move = function (lon, lat, zoom, options) {
if (zoom) {
this.native.setView([lat, lon], zoom);
} else {
this.native.setView([lat, lon]);
}
return this;
};
/**
* Fly map to new coordinate.
* @param {double} lon longitude
* @param {double} lat latitude
* @param {double} zoom zoom
* @param {Object} options options
*/
bemap.LeafletMap.prototype.flyTo = function (lon, lat, zoom, options) {
if (zoom) {
this.native.flyTo([lat, lon], zoom);
} else {
this.native.flyTo([lat, lon]);
}
return this;
};
/**
* Set the center and the zoom of the map to fit the bounding box.
* @param {bemap.BoundingBox} boundingBox the bounding box to fit.
* @return {bemap.LeafletMap} this.
*/
bemap.LeafletMap.prototype.moveToBoundingBox = function (boundingBox, options) {
if (boundingBox && bemap.inheritsof(boundingBox, bemap.BoundingBox) && boundingBox.maxLon && boundingBox.maxLat && boundingBox.minLon && boundingBox.minLat) {
var ext = L.latLngBounds(
L.latLng(boundingBox.minLat, boundingBox.minLon),
L.latLng(boundingBox.maxLat, boundingBox.maxLon)
);
if (options && (options.animate || options.fly)) {
this.native.flyToBounds(ext);
} else {
this.native.fitBounds(ext);
}
}
return this;
};
/**
* Zoom on map, set new zoom level.
* @public
* @param {int} zoom Zoom level (optional).
* @param {Object} options Options (optional).
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.zoom = function (zoom, options) {
this.native.setZoom(zoom);
return this;
};
/**
* Get current zoom level of map.
* @public
* @return {int} current zoom level of map.
*/
bemap.LeafletMap.prototype.getZoom = function () {
return this.native.getZoom();
};
/**
* Refresh map.
* @public
* @abstract
* @param {Object} options Options (optional).
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.refresh = function (options) {
this.native.invalidateSize();
return this;
};
/**
* Refresh all objects from a layer.
* @public
* @param {bemap.Layer} layer the layer object to refresh.
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.refreshLayer = function (layer) {
if (layer !== null && bemap.inheritsof(layer, bemap.Layer) && layer.native) {
// Only L.TileLayer (and subclasses) have redraw(). LayerGroup-backed
// bemap layers (markers, polylines, polygons, etc.) have no redraw —
// their visual state mutates as features are added/removed.
if (typeof layer.native.redraw === 'function') {
layer.native.redraw();
} else if (typeof layer.native.clearLayers === 'function' && typeof layer.native.eachLayer === 'function') {
// LayerGroup refresh = re-add visible layers, triggering DOM rerender.
var stash = [];
layer.native.eachLayer(function (l) { stash.push(l); });
layer.native.clearLayers();
for (var i = 0; i < stash.length; i++) layer.native.addLayer(stash[i]);
}
}
return this;
};
/**
* Remove all objects from a layer.
* @public
* @param {bemap.Layer} layer the layer object to clear.
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.clearLayer = function (layer) {
if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
layer.native.clearLayers();
}
return this;
};
/**
* Set the visibility of the layer.
* @public
* @param {bemap.Layer} layer the layer of wich to set the visibility.
* @param {boolean} visible.
* @return {bemap.LeafletMap} return this.
*/
bemap.LeafletMap.prototype.visibleLayer = function (layer, visible) {
if (visible !== null && layer !== null && layer.map !== null && bemap.inheritsof(layer, bemap.Layer)) {
if (visible) {
layer.native.addTo(this.native);
} else {
layer.native.remove();
}
layer.visible = visible;
}
return this;
};
/**
* Remove a layer from the map.
* @param {bemap.Layer} layer layer to remove from the map.
* @return {bemap.LeafletMap} this.
*/
bemap.LeafletMap.prototype.removeLayer = function (layer) {
if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
this.native.removeLayer(layer.native);
layer.map = null;
}
return this;
};
/**
* Set the listner when an specified eventType occur on bemap.LeafletMap.
* @public
* @param {bemap.Map.EventType} eventType Event type.
* @param {function} callback Function will be called when the specified eventType is occur.
* @param {object} options options.
* @return {bemap.Listener} listner.
*/
bemap.LeafletMap.prototype.on = function (eventType, callback, options) {
var opts = options ? options : {};
// 'error*' also reaches the map's error channel (BEMAP-1898 audit). Additive:
// the native binding below is unchanged.
this._subscribeErrorChannel(eventType, callback);
// Translate here, not by mutating the shared enum (see EVENT_ALIAS).
var nativeType = bemap.LeafletMap._nativeEventName(eventType);
var nativeListener = this.native.on(nativeType, function (evt) {
var mapEvent = new bemap.MapEvent({
native: evt,
bemapObject: this,
x: evt.containerPoint ? evt.containerPoint.x : undefined,
y: evt.containerPoint ? evt.containerPoint.y : undefined,
coordinate: evt.latlng ? new bemap.Coordinate(evt.latlng.lng, evt.latlng.lat) : undefined,
properties: options,
map: this
});
callback(mapEvent);
});
var listener = new bemap.Listener({
native: nativeListener,
callback: callback,
// `key` stays the BEMAP type (consumers and specs read it); `nativeKey`
// records what Leaflet was actually bound to, so an unbind can be exact.
key: eventType,
bemapObject: this
});
listener.nativeKey = nativeType;
return listener;
};
/**
* Remove listener.
* @protected
* @param {bemap.Listener} listener Function will be called when the specified eventType is occur.
* @param {object} options Options.
* @return {bemap.LeafletMap} this.
*/
bemap.LeafletMap.prototype.removeListener = function (listener, options) {
if (listener && bemap.inheritsof(listener, bemap.Listener) && listener.bemapObject) {
this.native.off(listener.native);
}
return this;
};
/**
* Get the center of the map in bemap.Coordinate.
* @return {bemap.Coordinate} the center of the map.
*/
bemap.LeafletMap.prototype.getCenter = function () {
var center = this.native.getCenter();
return new bemap.Coordinate(center.lng, center.lat);
};
/**
* Get the limits of the map on the current zoom.
* @return {bemap.BoundingBox} the bounding box containing the limits.
*/
bemap.LeafletMap.prototype.getBoundingBox = function () {
var bounds = this.native.getBounds();
var box = new bemap.BoundingBox(bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth());
return box;
};
/**
* Return drag pan status.
* @return {boolean}
*/
bemap.LeafletMap.prototype.isDragPan = function () {
return this.native.dragging ? this.native.dragging.enabled() : false;
};
/**
* Enable or disable drag pan. Caches the user's intent so
* `draggableMarker`'s internal mouseup-time `dragging.enable()` (in
* `bemap-map-leaflet-marker.js`) can restore it without overriding
* the customer's `setDragPan(false)` call.
* @param {boolean} active
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.setDragPan = function (active) {
this._userDragPanEnabled = !!active;
if (active) {
this.native.dragging.enable();
} else {
this.native.dragging.disable();
}
return this;
};
/* ============================================================
* Cross-engine camera methods (v2.0)
*
* Same names as on `bemap.MapLibreMap`. Pitch / bearing are not
* supported by Leaflet — silently ignored with a one-time warn
* via `_maplibreOnly()` so customer code can stay identical
* across all three engines.
* ============================================================ */
bemap.LeafletMap.prototype.easeTo = function (options) {
var opts = options || {};
if (opts.pitch || opts.bearing) this._maplibreOnly('easeTo:pitch/bearing');
var center = opts.center;
if (Array.isArray(center)) {
this.native.flyTo([center[1], center[0]], opts.zoom, { duration: (opts.duration || 1000) / 1000 });
} else if (typeof opts.zoom === 'number') {
this.native.setZoom(opts.zoom);
}
return this;
};
bemap.LeafletMap.prototype.jumpTo = function (options) {
var opts = options || {};
if (opts.pitch || opts.bearing) this._maplibreOnly('jumpTo:pitch/bearing');
var center = opts.center;
if (Array.isArray(center)) {
this.native.setView([center[1], center[0]], opts.zoom != null ? opts.zoom : this.native.getZoom(), { animate: false });
} else if (typeof opts.zoom === 'number') {
this.native.setZoom(opts.zoom, { animate: false });
}
return this;
};
bemap.LeafletMap.prototype.setBearing = function (deg) {
// Leaflet has no native rotation. Apply a CSS transform to the OUTER
// .leaflet-container (NOT the inner .leaflet-map-pane — Leaflet sets a
// `translate3d(...)` on the pane that we'd overwrite, snapping the map
// to (0,0) before rotating). The container's center is the visible
// center, so transform-origin 50% 50% pivots around the right point.
//
// We ALSO scale the container so its rotated bounding box always fills
// the parent cell — otherwise non-square cells show empty bars at the
// shorter dimension after rotation. Scale formula:
// max( |cos θ| + (H/W)|sin θ|, (W/H)|sin θ| + |cos θ| )
// verified by hand: θ=0 → 1, θ=90° W:H=5:6 → 1.2, θ=45° square → √2.
//
// Visual-only effect:
// - tiles, polylines, markers, popups, controls all rotate together
// - click events still hit unrotated pixel positions — click-to-coord
// is inaccurate at non-zero angles
// - tile prefetch still uses the unrotated bounds — at large rotations
// tiles get visually upscaled (~1.4x worst case at 45° square) so a
// slight blur is expected
// For full-fidelity rotation use bemap.MapLibreMap or bemap.OlMap.
this._rotationDeg = +deg || 0;
try {
var container = this.native.getContainer();
if (container) {
container.style.transformOrigin = '50% 50%';
container.style.transition = 'transform 250ms ease';
if (this._rotationDeg) {
var ang = Math.abs(this._rotationDeg) * Math.PI / 180;
var c = Math.abs(Math.cos(ang));
var s = Math.abs(Math.sin(ang));
var W = container.clientWidth || 1;
var H = container.clientHeight || 1;
var scale = Math.max(c + (H / W) * s, (W / H) * s + c);
// CSS rotates positive-CW; OL + MapLibre rotate the view positive-CW
// (visible content appears to rotate CCW). Negate so direction matches.
container.style.transform = 'rotate(' + (-this._rotationDeg) + 'deg) scale(' + scale + ')';
} else {
container.style.transform = '';
}
}
} catch (e) { /* ignore */ }
if (!bemap.LeafletMap._rotationCaveatWarned) {
bemap.LeafletMap._rotationCaveatWarned = true;
if (typeof console !== 'undefined' && console.info) {
console.info('bemap.LeafletMap.setBearing: visual rotation only — click coordinates remain in the unrotated frame. For full-fidelity rotation use bemap.MapLibreMap or bemap.OlMap.');
}
}
return this;
};
bemap.LeafletMap.prototype.getBearing = function () {
return this._rotationDeg || 0;
};
bemap.LeafletMap.prototype.setPitch = function (deg) {
this._maplibreOnly('setPitch');
return this;
};
/* --- Heatmap (Leaflet via leaflet.heat plugin when available) --- */
bemap.LeafletMap.prototype.addHeatmap = function (data, options) {
var hasData = Array.isArray(data) && data.length > 0;
if (!hasData || typeof L === 'undefined' || typeof L.heatLayer !== 'function') {
console.warn('addHeatmap() is only available with bemap.MapLibreMap');
return this;
}
var opts = options || {};
var id = opts.id || ('heatmap-' + (++this._layerCounter || (this._layerCounter = 1)));
var points = data.map(function (p) {
if (Array.isArray(p)) return [p[1], p[0], p[2] != null ? p[2] : 1];
if (p && p.lat != null && p.lng != null) return [p.lat, p.lng, p.value != null ? p.value : 1];
if (p && typeof p.getLatitude === 'function') return [p.getLatitude(), p.getLongitude(), p.value != null ? p.value : 1];
return null;
}).filter(Boolean);
var layer = L.heatLayer(points, opts).addTo(this.native);
this._heatmaps = this._heatmaps || {};
this._heatmaps[id] = layer;
this._lastHeatmapId = id;
return this;
};
bemap.LeafletMap.prototype.removeHeatmap = function (id) {
var key = id || this._lastHeatmapId;
if (this._heatmaps && this._heatmaps[key]) {
this.native.removeLayer(this._heatmaps[key]);
delete this._heatmaps[key];
}
return this;
};
/**
* (Removed here, BEMAP-1898 audit: a SECOND, superseded doc block sat above the
* one below, claiming "on Leaflet both methods warn and no-op identically".
* That stopped being true when the CSS-pane rotation below was implemented —
* `rotation()` really rotates, and it emits `console.info`, not a warning. Two
* stacked blocks also meant JSDoc only ever published the lower one, so the
* contradiction was invisible in the generated reference. It went unnoticed
* because the spec asserting the old behaviour could never run: Leaflet was not
* loaded in the Karma harness.)
*/
/**
* Visually rotate the Leaflet map by applying a CSS `transform: rotate` to
* the layer panes (NOT the whole container — that would rotate +/- controls
* and the attribution widget too). Leaflet has no native rotation, so this
* is a presentation-only effect:
* • Tiles, polylines, markers, popups all rotate together — looks right.
* • Click events still hit unrotated pixel positions, so click-to-coord
* becomes inaccurate at non-zero angles.
* • Tile prefetch still uses the unrotated bounds — corner tiles may be
* missing when angle is between rotations.
* For full-fidelity rotation with mouse/touch transforms and tile re-buffering,
* use bemap.MapLibreMap (setBearing) or bemap.OlMap (rotation), or replace
* leaflet.js with the Raruto/leaflet-rotate fork.
*
* @public
* @param {Number} deg
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.rotation = function (deg) {
this._rotationDeg = +deg || 0;
// Apply to map panes (the inner wrappers). The outer container holds the
// controls, attribution + zoom +/-, which should stay upright.
try {
var mapPane = this.native.getContainer().querySelector('.leaflet-map-pane');
if (mapPane) {
// CSS rotates the layer CLOCKWISE for positive degrees; OL + MapLibre
// both rotate the VIEW positive-CW (so visible content appears to
// rotate counter-clockwise). Negate the CSS angle to match.
mapPane.style.transformOrigin = '50% 50%';
mapPane.style.transition = 'transform 250ms ease';
mapPane.style.transform = (deg ? 'rotate(' + (-this._rotationDeg) + 'deg)' : '');
}
} catch (e) {}
// Warn ONCE per session so customers know about the interactivity caveat.
if (!bemap.LeafletMap._rotationCaveatWarned) {
bemap.LeafletMap._rotationCaveatWarned = true;
if (typeof console !== 'undefined' && console.info) {
console.info('bemap.LeafletMap.rotation: visual rotation only — click coordinates remain in the unrotated frame. For full-fidelity rotation use bemap.MapLibreMap (setBearing) or bemap.OlMap (rotation).');
}
}
return this;
};
bemap.LeafletMap.prototype.getRotation = function () {
return this._rotationDeg || 0;
};
/* ============================================================
* Cross-engine hillshade overlay (v2.0) — Leaflet implementation
*
* 2D hillshade only (Leaflet has no native tilt). Adds a raster
* `L.tileLayer` over the basemap, sourced from a `{z}/{x}/{y}`
* tile URL the customer provides. For Terrain-RGB shading the
* customer can pre-process server-side or use an XYZ hillshade
* tile service (e.g. ArcGIS World_Hillshade).
*
* For full 3D tilt, customers should switch to `bemap.MapLibreMap`
* and use `setTerrain` — this method is the cross-engine fallback.
* ============================================================ */
bemap.LeafletMap.prototype.setHillshade = function (options) {
var opts = options || {};
if (typeof L === 'undefined') return this;
// Default: ArcGIS World_Hillshade (works without API key, low-res).
var url = opts.url || 'https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}';
var opacity = opts.opacity != null ? opts.opacity : 0.35;
this.removeHillshade();
this._hillshadeLayer = L.tileLayer(url, { opacity: opacity, attribution: opts.attribution || '' }).addTo(this.native);
var self = this;
if (typeof self._registerEffect === 'function') {
self._registerEffect('hillshade', function () { self.removeHillshade(); });
}
return this;
};
bemap.LeafletMap.prototype.removeHillshade = function () {
if (this._hillshadeLayer) {
try { this.native.removeLayer(this._hillshadeLayer); } catch (e) {}
this._hillshadeLayer = null;
}
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('hillshade');
return this;
};
/* ============================================================
* Cross-engine camera tour (v2.0) — Leaflet pan-tour fallback
*
* Leaflet has no rotation (the GPL leaflet-rotate plugin can't be
* bundled), so the "camera tour" degrades to a chained `flyTo`
* between waypoints. Customers get the same API on every engine;
* MapLibre and OL provide a real orbit/animate, Leaflet provides
* sequential pans.
* ============================================================ */
bemap.LeafletMap.prototype.cameraTour = function (options) {
var opts = options || {};
var waypoints = opts.waypoints || [];
if (!waypoints.length) return { stop: function () {}, resume: function () {} };
var self = this;
var i = 0, stopped = false;
function next() {
if (stopped || i >= waypoints.length) {
if (i >= waypoints.length && opts.loop) i = 0;
else if (typeof opts.onComplete === 'function') opts.onComplete();
else return;
}
var wp = waypoints[i++];
self.native.flyTo([wp.lat, wp.lon], wp.zoom || self.native.getZoom(), {
duration: (wp.duration || 2000) / 1000
});
setTimeout(next, wp.duration || 2000);
}
next();
var controller = {
stop: function () { stopped = true; if (typeof self._unregisterEffect === 'function') self._unregisterEffect('cameraTour'); },
resume: function () { if (stopped) { stopped = false; next(); } }
};
if (typeof self._registerEffect === 'function') self._registerEffect('cameraTour', function () { controller.stop(); });
return controller;
};
/**
* Cross-engine `setStyle` for Leaflet. The MapLibre wrapper has the same name
* but a richer contract (vector style name / URL / object); on Leaflet/OL
* "style" means the WMS theme or the raster tile source. The argument is
* interpreted as:
* • bare NAME (no '/' or ':') → re-applied as the WMS `STYLES` on the BeNomad
* layer(s) — the dark/light theme switch (`'benomadGrayLevel'` /
* `'benomadLight'`). Vector style names are MapLibre-only, so the app passes
* the right name per engine.
* • URL string (contains '/' or ':') → swaps the raster tile-source URL.
* • inline style object → MapLibre-only; warn-once no-op here.
*
* @public
* @since 2.0.0
* @param {String|Object} urlOrSpec WMS style name (`'benomadGrayLevel'`),
* a raster URL template
* (`https://.../{z}/{x}/{y}.png`), or a
* MapLibre vector style object (no-op).
* @return {bemap.LeafletMap} this
*/
bemap.LeafletMap.prototype.setStyle = function (urlOrSpec) {
var self = this;
if (urlOrSpec && typeof urlOrSpec === 'object') { this._maplibreOnly('setStyle:vector-object'); return this; }
if (typeof urlOrSpec === 'string' && urlOrSpec && !/[:/]/.test(urlOrSpec)) {
var applied = 0;
try {
this.native.eachLayer(function (l) {
// Only BeNomad WMS layers (tagged in addLayer) — never a customer's own
// bemap.WmsLayer, which uses the identical L.tileLayer.wms constructor.
if (l && l._bemapWms && typeof l.setParams === 'function') { l.setParams({ styles: urlOrSpec }); applied++; }
});
} catch (e) { /* ignore */ }
if (!applied && typeof console !== 'undefined' && console.warn) {
console.warn('[bemap] LeafletMap.setStyle("' + urlOrSpec + '"): no BeNomad WMS layer to re-style yet — add a bemap.BemapLayer (or call defaultLayers()) first.');
}
return this;
}
// String with '/' or ':' → raster URL template. (Objects and bare names
// already returned above, so urlOrSpec here is a URL string or falsy.)
var url = (typeof urlOrSpec === 'string') ? urlOrSpec : null;
if (!url) { this._maplibreOnly('setStyle'); return this; }
var swapped = false;
this.native.eachLayer(function (layer) {
if (typeof layer.setUrl === 'function' && layer._url) {
layer.setUrl(url);
swapped = true;
}
});
if (!swapped) {
// No existing tile layer — create one.
L.tileLayer(url, {}).addTo(this.native);
}
if (typeof self._registerEffect === 'function') {
self._registerEffect('setStyle', function () { /* customer must call setStyle again to restore */ });
}
return this;
};
/* Worker tile-style discovery is MapLibre-only (Leaflet uses WMS). These stubs
* keep the API surface uniform so engine-agnostic code (bemap.createMap) never
* hits "not a function" after switching engines — they reject with a clear,
* typed MAPLIBRE_ONLY error the caller can branch on. */
bemap.LeafletMap.prototype._styleApiMaplibreOnly = function (label) {
return Promise.reject(new bemap.Error({
code: bemap.Error.MAPLIBRE_ONLY,
message: 'bemap.LeafletMap.' + label + ' is MapLibre-only (Leaflet uses WMS). Use bemap.MapLibreMap for vector tile styles.'
}));
};
bemap.LeafletMap.prototype.fetchAvailableStyles = function () { return this._styleApiMaplibreOnly('fetchAvailableStyles'); };
bemap.LeafletMap.prototype.fetchDefaultStyle = function () { return this._styleApiMaplibreOnly('fetchDefaultStyle'); };
bemap.LeafletMap.prototype.fetchAvailableMaps = function () { return this._styleApiMaplibreOnly('fetchAvailableMaps'); };
bemap.LeafletMap.prototype.fetchDefaultMap = function () { return this._styleApiMaplibreOnly('fetchDefaultMap'); };
/* ============================================================
* Cross-engine animation (v2.0) — Leaflet implementation
*
* `animateAlongRoute({ coordinates, speed, loop, onUpdate, onComplete })`
* walks a marker along a polyline. Uses requestAnimationFrame and
* native `L.Marker.setLatLng` updates (no external plugin needed —
* Leaflet.MovingMarker would add a dependency we don't otherwise have).
* `marker` option lets the customer pre-add a marker; if absent we
* create one at the first coordinate.
* ============================================================ */
bemap.LeafletMap.prototype.animateAlongRoute = function (options) {
var opts = options || {};
var route = opts.coordinates || [];
var speed = opts.speed || 0.005; // segments per frame
var loop = opts.loop !== false;
var onUpdate = opts.onUpdate;
var onComplete = opts.onComplete;
var self = this;
if (!route.length || typeof L === 'undefined') {
if (!route.length) console.warn('animateAlongRoute: opts.coordinates is empty');
return { stop: function(){}, resume: function(){} };
}
// Caller can pass a bemap.Marker via opts.marker; otherwise we make a
// plain Leaflet marker so the demo just shows movement.
var marker = opts.marker;
var native;
if (marker && marker.native && typeof marker.native.setLatLng === 'function') {
native = marker.native;
} else {
native = L.marker([route[0][1], route[0][0]]).addTo(this.native);
}
var pos = 0, animating = true, rafId = null;
var total = route.length - 1;
function step() {
if (!animating) return;
pos += speed;
if (pos >= total) {
if (loop) pos = 0;
else { animating = false; if (onComplete) onComplete(); return; }
}
var idx = Math.floor(pos);
var next = Math.min(idx + 1, route.length - 1);
var t = pos - idx;
var lon = route[idx][0] + (route[next][0] - route[idx][0]) * t;
var lat = route[idx][1] + (route[next][1] - route[idx][1]) * t;
native.setLatLng([lat, lon]);
if (onUpdate) onUpdate({ lon: lon, lat: lat, progress: pos / total });
rafId = requestAnimationFrame(step);
}
rafId = requestAnimationFrame(step);
var controller = {
stop: function () {
animating = false;
if (rafId) cancelAnimationFrame(rafId);
if (typeof self._unregisterEffect === 'function') self._unregisterEffect('animateAlongRoute');
},
resume: function () { if (!animating) { animating = true; rafId = requestAnimationFrame(step); } },
reset: function () { pos = 0; }
};
if (typeof self._registerEffect === 'function') {
self._registerEffect('animateAlongRoute', function () { controller.stop(); });
}
return controller;
};