Source: bemap-leaflet/bemap-map-leaflet.js

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