Source: bemap-ol/bemap-ol-v10/bemap-map-ol.js

/**
 * BeNomad BeMap JavaScript API - Map - OpenLayers v10
 */

/**
 * @classdesc
 * Base class for OpenLayers.
 * @public
 * @constructor
 * @extends {bemap.Map}
 * @param {bemap.Context} context BeMap-JS-API Context.
 * @param {string} target HTML element.
 * @param options.
 */
bemap.OlMap = function (context, target, options) {
  // Runtime dependency check — emits a loud console banner with the
  // exact <script>/<link> tags to add when OpenLayers is missing.
  bemap.Map._checkDependencies('ol');

  /**
    * @protected
    */
  this.target = target;

  bemap.Map.call(this, context, options);

  var opts = options || {};

  // Resolve native-attribution + zoom-control options (uniform across engines).
  // bemap.Map._resolveControlOptions hides OL's built-in attribution by
  // default when the BeMap AttributionWidget is enabled. The zoom buttons
  // are positioned via a CSS-class suffix (`.bemap-zoom-pos-<position>` —
  // styled in bemap-js-api.css) on a custom `ol.control.Zoom` instance.
  var _ctrl = bemap.Map._resolveControlOptions(opts);
  // OL v10 nests `defaults()` as a property of the namespace (`ol.control.defaults.defaults`);
  // older builds expose it as the namespace itself. Resolve both shapes.
  var _olDefaults = (ol.control.defaults && typeof ol.control.defaults.defaults === 'function')
    ? ol.control.defaults.defaults
    : (typeof ol.control.defaults === 'function' ? ol.control.defaults : null);
  // Always disable the auto-added zoom; we add a positioned custom one below.
  var _olControls = _olDefaults
    ? _olDefaults({ attribution: _ctrl.nativeAttribution, zoom: false })
    : undefined;
  var _zoomCtrl = null;
  if (_ctrl.zoomControl && _olControls && ol.control && ol.control.Zoom) {
    // IMPORTANT: do NOT override `className`. OL uses it as the BASE for
    // button classes (`<className>-in` / `-out`), so passing
    // `'ol-zoom bemap-zoom-pos-top-left'` makes the buttons end up as
    // `<basename>-in` with no matching CSS — they lose their sizing and
    // the container ends up covering the whole map. Instead, let OL
    // install its default `ol-zoom` / `ol-zoom-in` / `ol-zoom-out`
    // classes and tag the CONTAINER element with the position class
    // post-construction.
    _zoomCtrl = new ol.control.Zoom();
    _olControls.push(_zoomCtrl);
  }

  // Keyboard interactions — default OFF (see bemap.Map._resolveControlOptions).
  // We intentionally do NOT pre-build a custom interactions Collection here:
  // replacing OL's defaults Collection can drop critical interactions
  // (DragPan etc.) when the bundled defaults() function has a subtly
  // different signature across OL versions. Instead, let OL install its
  // full defaults and prune ONLY KeyboardPan / KeyboardZoom afterwards
  // when the consumer didn't request them.

  var _mapOpts = {
    target: target,
    view: new ol.View({
      projection: bemap.Map.PROJ.EPSG_MERCATOR,
      center: [0, 0],
      zoom: opts.zoom || 3,
      minZoom: opts.minZoom !== undefined ? opts.minZoom : 3,
      maxZoom: opts.maxZoom !== undefined ? opts.maxZoom : 20,
      constrainRotation: opts.constrainRotation !== undefined ? opts.constrainRotation : true,
      // Default to enabled — with enableRotation:false OL ignores
      // every setRotation call (programmatic or user). That's why
      // map.rotation(45) appeared to succeed but didn't visually
      // rotate. Customers who want to prevent user-driven rotation
      // can pass `enableRotation: false` AND accept that
      // setBearing/rotation become no-ops too.
      enableRotation: opts.enableRotation !== undefined ? opts.enableRotation : true
    })
  };
  if (_olControls) _mapOpts.controls = _olControls;
  this.native = new ol.Map(_mapOpts);

  // Tag the Zoom control container with the bemap position class — the
  // class-only positioning rules in bemap-js-api.css then move it to the
  // requested corner. OL's own `.ol-zoom-in` / `.ol-zoom-out` rules are
  // preserved (correct button sizing, no whole-map overlay bug).
  if (_zoomCtrl && _zoomCtrl.element && _zoomCtrl.element.classList) {
    _zoomCtrl.element.classList.add('bemap-zoom-pos-' +
      bemap.Map._translateZoomPosition('ol', _ctrl.zoomPosition));
  }

  // Post-construction keyboard pruning — remove KeyboardPan / KeyboardZoom
  // when the consumer didn't ask for the corresponding half (default off
  // for both). Done on the live Collection so DragPan, MouseWheelZoom, etc.
  // are untouched.
  var _interactions = this.native.getInteractions();
  if (_interactions && typeof _interactions.forEach === 'function') {
    var _toRemove = [];
    _interactions.forEach(function (it) {
      if (!_ctrl.keyboard.pan  && ol.interaction.KeyboardPan  && it instanceof ol.interaction.KeyboardPan)  _toRemove.push(it);
      if (!_ctrl.keyboard.zoom && ol.interaction.KeyboardZoom && it instanceof ol.interaction.KeyboardZoom) _toRemove.push(it);
    });
    for (var _i = 0; _i < _toRemove.length; _i++) this.native.removeInteraction(_toRemove[_i]);
  }
  this._keyboardEnabled = !!(_ctrl.keyboard.pan || _ctrl.keyboard.zoom);
  this._keyboardOptions = _ctrl.keyboard;

  // OL v5+ natively fires 'movestart' on the map — no hack needed

  // Attribution widget (v1.5.0) — auto-instantiated unless options.attribution === false
  if (typeof bemap.AttributionWidget === 'function') {
    var _attr = opts.attribution;
    if (_attr !== false) {
      var _attrOpts = (_attr && typeof _attr === 'object') ? _attr : {};
      this._attributionWidget = new bemap.AttributionWidget(this, _attrOpts);
    }
  }
};
bemap.inherits(bemap.OlMap, bemap.Map);

/**
 * Toggle keyboard navigation (arrow-pan + `+`/`-` zoom) at runtime by
 * adding / removing the `KeyboardPan` and `KeyboardZoom` interactions.
 * @public
 * @param {Boolean} enabled
 * @return {bemap.OlMap}
 */
bemap.OlMap.prototype.setKeyboard = function (enabled) {
  if (!this.native || !ol.interaction) { this._keyboardEnabled = !!enabled; return this; }
  var interactions = this.native.getInteractions();
  var hasPan = false, hasZoom = false;
  interactions.forEach(function (it) {
    if (it instanceof ol.interaction.KeyboardPan)  hasPan  = true;
    if (it instanceof ol.interaction.KeyboardZoom) hasZoom = true;
  });
  if (enabled) {
    if (!hasPan)  this.native.addInteraction(new ol.interaction.KeyboardPan());
    if (!hasZoom) this.native.addInteraction(new ol.interaction.KeyboardZoom());
  } else {
    var toRemove = [];
    interactions.forEach(function (it) {
      if (it instanceof ol.interaction.KeyboardPan || it instanceof ol.interaction.KeyboardZoom) {
        toRemove.push(it);
      }
    });
    for (var i = 0; i < toRemove.length; i++) this.native.removeInteraction(toRemove[i]);
  }
  this._keyboardEnabled = !!enabled;
  return this;
};

/** @public @return {Boolean} */
bemap.OlMap.prototype.getKeyboard = function () { return !!this._keyboardEnabled; };

/**
 * Sotre the bemap object into the native OpenLayers properties.
 * @protected
 * @param {Object} bemapObject a bemap object like bemap.marker, bemap.multimarker or bemap.polyline.
 */
bemap.OlMap.prototype._addOwnToProperties = function (bemapObject) {
  var props = bemapObject.native.getProperties() || {};
  props[bemap.Map.OWNREF] = bemapObject;
  bemapObject.native.setProperties(props);
};

/**
 * Get the bemap object from the native OpenLayers properties.
 * @protected
 * @param {Object} olObject native Openlayers object like ol.Feature.
 * @return {Object} a bemap object like bemap.marker, bemap.multimarker or bemap.polyline.
 */
bemap.OlMap.prototype._getOwnFromProperties = function (olObject) {
  if (!olObject || typeof olObject.getProperties !== 'function') return undefined;
  var props = olObject.getProperties();
  if (!props) return undefined;
  var ownRef = props[bemap.Map.OWNREF];
  if (ownRef === undefined) {
    // Cluster-source fallback: ol.source.Cluster exposes wrapped features as a
    // `.features` array on the cluster feature's properties. If neither OWNREF
    // nor a non-empty `.features` is present, this is a non-bemap feature (a
    // draw-interaction sketch, a modify-vertex handle, or a third-party layer
    // feature) — return undefined instead of dereferencing `features[0]` and
    // crashing on pointer events (PMT-53).
    if (props.features && props.features.length > 0
        && typeof props.features[0].getProperties === 'function') {
      var inner = props.features[0].getProperties();
      ownRef = inner ? inner[bemap.Map.OWNREF] : undefined;
    }
  }
  return ownRef;
};

/**
 * @protected
 * @param {double} lon Longitude in degres decimal (WGS84).
 * @param {double} lat Latitude in degres decimal (WGS84).
 */
bemap.OlMap.prototype._fromLonLat = function (lon, lat) {
  return ol.proj.transform([lon, lat], bemap.Map.PROJ.EPSG_WGS84, this.native.getView().getProjection());
};

/**
 * @protected
 * @param {array} coordinate Array contains the longitude and latitude in format of OpenLayers Map object.
 * @return {array} Array contains the longitude and latitude in degres decimal (WGS84).
 */
bemap.OlMap.prototype._fromNativeToLonLat = function (coordinate) {
  var coords = ol.proj.transform(coordinate, this.native.getView().getProjection(), bemap.Map.PROJ.EPSG_WGS84);
  return new bemap.Coordinate(coords[0], coords[1]);
};

/**
 * Add a layer to the OpenLayers map.
 * @public
 * @param {bemap.Layer} layer
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.addLayer = function (layer, options) {
  if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    if (bemap.inheritsof(layer, bemap.BemapLayer)) {
      layer.native = new ol.layer.Tile({
        source: new ol.source.TileWMS({
          url: this.ctx.getBaseUrl() + 'wms?' + this.ctx.getAuthUrlParams(),
          params: {
            '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'
          }
        })
      });
      // Tag BeNomad WMS sources so setStyle('<theme>') re-styles ONLY these,
      // never a customer's own bemap.WmsLayer (same ol.source.TileWMS).
      layer.native.getSource()._bemapWms = true;

    } else if (bemap.inheritsof(layer, bemap.WmsLayer)) {
      layer.native = new ol.layer.Tile({
        source: new ol.source.TileWMS({
          url: layer.url,
          params: {
            'LAYERS': layer.layers ? layer.layers : '',
            'STYLES': layer.styles ? layer.styles : '',
            'TILED': layer.tiled === true ? true : false,
            'TRANSPARENT': layer.transparent === true ? true : false,
            'FORMAT': layer.format ? layer.format : 'image/png'
          }
        })
      });

    } else if (bemap.inheritsof(layer, bemap.OsmLayer)) {
      layer.native = new ol.layer.Tile({
        source: new ol.source.OSM()
      });

    } else if (bemap.inheritsof(layer, bemap.VectorLayer)) {
      layer.native = new ol.layer.Vector({
        source: new ol.source.Vector({
          features: []
        })
      });

    } else if (bemap.inheritsof(layer, bemap.ClusterLayer)) {
      var source = new ol.source.Vector({
        features: []
      });

      var clusterSource = new ol.source.Cluster({
        distance: layer.distance,
        source: source
      });

      var c = layer.style;

      layer.native = new ol.layer.Vector({
        source: clusterSource,
        style: function (feature, resolution) {
          var size = feature.get('features').length;
          var style;
          if (size > 1) {
            style = new ol.style.Style({
              image: new ol.style.Circle({
                radius: c.size,
                stroke: new ol.style.Stroke({
                  width: c.borderSize,
                  color: c.borderColor.getRgbaArray()
                }),
                fill: new ol.style.Fill({
                  color: c.color.getRgbaArray()
                })
              }),
              text: new ol.style.Text({
                text: size.toString(),
                fill: new ol.style.Fill({
                  color: c.textColor.getRgbaArray()
                }),
                scale: c.textSize
              })
            });
          } else {
            style = new ol.style.Style({
              image: new ol.style.Icon({
                anchor: [c.icon.anchorX, c.icon.anchorY],
                anchorXUnits: c.icon.anchorXUnits,
                anchorYUnits: c.icon.anchorYUnits,
                src: c.icon.src,
                scale: c.icon.scale
              })
            });
          }
          return style;
        }
      });

      var _view = this.native.getView();
      _view.on('change:resolution', function () {
        if (_view.getResolution() <= 0.14929107086948487) {
          clusterSource.setDistance(0);
        } else {
          clusterSource.setDistance(40);
        }
      });

      // Click on cluster → zoom in
      var _nativeMap = this.native;
      var _clusterLayer = layer;
      this.native.on('click', function(evt) {
        _nativeMap.forEachFeatureAtPixel(evt.pixel, function(feature, clickedLayer) {
          if (clickedLayer === _clusterLayer.native) {
            var features = feature.get('features');
            if (features && features.length > 1) {
              var extent = ol.extent.createEmpty();
              for (var fi = 0; fi < features.length; fi++) {
                ol.extent.extend(extent, features[fi].getGeometry().getExtent());
              }
              _nativeMap.getView().fit(extent, { duration: 500, padding: [50, 50, 50, 50] });
            }
          }
        });
      });

    } else {
      console.warn("Unsupport layer");
    }

    if (layer.native !== null) {
      bemap.OlMap.prototype._addOwnToProperties(layer);
      this.native.addLayer(layer.native);
      bemap.Map.prototype.addLayer.call(this, layer);
    }
  }

  if (layer.map === null) {
    layer.map = this;
  }


  return this;
};

bemap.OlMap.prototype._getDragPan = function () {
  var dragPan = null;
  this.native.getInteractions().forEach(function (interaction) {
    if (interaction instanceof ol.interaction.DragPan) {
      dragPan = interaction;
    }
  });
  return dragPan;
};

/**
 * Return the satus of drag pan of map.
 * @public
 * @abstract
 * @param {Object} options.dragPan.
 * @return {boolean} true to enable the drag pan of map, otherwise false.
 */
bemap.OlMap.prototype.isDragPan = function (options) {
  var dragPan = options && options.dragPan ? options.dragPan : null;
  if (dragPan === null) {
    dragPan = this._getDragPan();
  }
  if (dragPan) {
    return dragPan.getActive();
  }
  return false;
};

/**
 * Enable or diable the drag pan of map.
 * @public
 * @abstract
 * @param {boolean} active true to enable the drag pan of map, otherwise false.
 * @param {Object} options.dragPan.
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.setDragPan = function (active, options) {
  var dragPan = options && options.dragPan ? options.dragPan : null;
  if (dragPan === null) {
    dragPan = this._getDragPan();
  }
  if (dragPan) {
    dragPan.setActive(active);
  }
  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.OlMap} this
 */
bemap.OlMap.prototype.move = function (lon, lat, zoom, options) {
  var view = this.native.getView();

  if (zoom) {
    view.setZoom(zoom);
  }

  if (options && (options.animate || options.fly)) {
    view.animate({
      center: this._fromLonLat(lon, lat),
      duration: 2000
    });
  } else {
    view.setCenter(this._fromLonLat(lon, lat));
  }

  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.OlMap} this.
 */
bemap.OlMap.prototype.moveToBoundingBox = function (boundingBox, options) {
  if (boundingBox && bemap.inheritsof(boundingBox, bemap.BoundingBox) && boundingBox.maxLon && boundingBox.maxLat && boundingBox.minLon && boundingBox.minLat) {
    var ext = [boundingBox.minLon, boundingBox.minLat, boundingBox.maxLon, boundingBox.maxLat];
    ext = ol.proj.transformExtent(ext, bemap.Map.PROJ.EPSG_WGS84, this.native.getView().getProjection());
    this.native.getView().fit(ext);
  }
  return this;
};

/**
 * Move map and zoom on data contains in layers.
 * @public
 * @abstract
 * @param {bemap.Layer} layer Layer.
 * @param {Object} options Options (optional).
 * @return {bemap.Map} this
 */
bemap.OlMap.prototype.moveToLayerData = function (layer, options) {
  var extent = null;
  if (layer && bemap.inheritsof(layer, bemap.ClusterLayer)) {
    extent = layer.native.getSource().getSource().getExtent();
  } else if (layer && bemap.inheritsof(layer, bemap.Layer)) {
    extent = layer.native.getSource().getExtent();
  }
  // OL v10 throws on an empty/infinite extent (a layer with no features
  // returns [Infinity, Infinity, -Infinity, -Infinity]). v4 silently
  // ignored. Guard before fit().
  if (extent && ol && ol.extent && typeof ol.extent.isEmpty === 'function' && !ol.extent.isEmpty(extent)) {
    this.native.getView().fit(extent);
  }
  return this;
};

/**
 * Zoom on map, set new zoom level.
 * @public
 * @param {int} zoom Zoom level (optional).
 * @param {Object} options Options (optional).
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.zoom = function (zoom, options) {
  this.native.getView().setZoom(zoom);
  return this;
};

/**
 * Get current zoom level of map.
 * @public
 * @return {int} current zoom level of map.
 */
bemap.OlMap.prototype.getZoom = function () {
  return this.native.getView().getZoom();
};

/**
 * Rotation of map, set new angle of map.
 *
 * @deprecated Since 2.0 — use `setBearing(deg)` for a cross-engine API
 *             consistent with MapLibre's compass-bearing convention.
 * @public
 * @param {int} angle in degrees, clockwise (matches MapLibre's
 *              setBearing convention; previously this method rotated
 *              counter-clockwise — that was the OL native direction
 *              but inconsistent with MapLibre, fixed in v2.0).
 * @param {Object} options Options (optional).
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.rotation = function (angle, options) {
  bemap.OlMap._rotationDeprecationWarned = bemap.OlMap._rotationDeprecationWarned || false;
  if (!bemap.OlMap._rotationDeprecationWarned) {
    bemap.OlMap._rotationDeprecationWarned = true;
    console.warn('bemap.OlMap.rotation() is deprecated since 2.0 — use setBearing(deg) for a cross-engine API.');
  }
  // Negate to match MapLibre's clockwise-positive bearing convention.
  // OL's native setRotation is counter-clockwise — without this, the
  // same call produced opposite visual results across engines.
  var angleRadian = -angle * (Math.PI / 180);
  this.native.getView().setRotation(angleRadian);
  return this;
};

/**
 * Get current rotation angle of map.
 * @public
 * @return {Number} current rotation in DEGREES (clockwise, matching
 *                  setBearing convention). Previously returned raw
 *                  radians — fixed in v2.0.
 */
bemap.OlMap.prototype.getRotation = function () {
  // OL's native getRotation returns radians, anti-clockwise positive.
  // Convert to degrees and negate the sign so the value matches what
  // rotation(deg) accepts.
  return -this.native.getView().getRotation() * 180 / Math.PI;
};

/**
 * Refresh map.
 * @public
 * @abstract
 * @param {Object} options Options (optional).
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.refresh = function (options) {
  this.native.updateSize();
  this.native.render();
  return this;
};

/**
 * Build icon resource.
 * @public
 * @param {bemap.Icon} icon
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.buildIcon = function (icon, options) {
  if (icon !== null && bemap.inheritsof(icon, bemap.Icon)) {
    icon.native = new ol.style.Icon({
      anchor: [icon.anchorX, icon.anchorY],
      anchorXUnits: (icon.anchorXUnits && icon.anchorXUnits !== '') ? icon.anchorXUnits : 'fraction',
      anchorYUnits: (icon.anchorYUnits && icon.anchorYUnits !== '') ? icon.anchorYUnits : 'fraction',
      src: icon.src,
      opacity: icon.opacity ? icon.opacity : 1,
      scale: icon.scale ? icon.scale : 1
    });
  }
  return this;
};

/**
 * Build text style resource.
 * @public
 * @param {bemap.TextStyle} text style object.
 * @param {String} name Name (label) of marker.
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.buildTextStyle = function (textStyle, name, options) {
  if (textStyle && bemap.inheritsof(textStyle, bemap.TextStyle)) {
    var nativeOpts = {
      text: name,
      offsetX: textStyle.getOffsetX(),
      offsetY: textStyle.getOffsetY(),
      scale: textStyle.getSize(),
      fill: new ol.style.Fill({
        color: textStyle.getColor().getRgbArray()
      })
    };

    if (textStyle.getBorderWidth() > 0) {
      nativeOpts.stroke = new ol.style.Stroke({
        color: textStyle.getBorderColor().getRgbArray(),
        width: textStyle.getBorderWidth()
      });
    }

    textStyle.native = new ol.style.Text(nativeOpts);
  }
  return this;
};

/**
 * Build lineStyle resource.
 * @public
 * @param {bemap.LineStyle} lineStyle
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.buildLineStyle = function (style, options) {
  if (style !== null && bemap.inheritsof(style, bemap.LineStyle)) {
    style.native = new ol.style.Style({
      stroke: new ol.style.Stroke({
        color: [style.color.getRed(), style.color.getGreen(), style.color.getBlue(), style.color.getAlpha()],
        width: style.width,
        lineDash: style.type === bemap.LineStyle.TYPE.DASH ? [10] : style.type === bemap.LineStyle.TYPE.DOT_DASH ? [0.1, 10, 10] : style.type === bemap.LineStyle.TYPE.DOT ? [0.1, 10] : []
      }),
    });
  }
  return this;
};

/**
 * Build polygonStyle resource.
 * @public
 * @param {bemap.PolygonStyle} polygonStyle
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.buildPolygonStyle = function (style, options) {
  if (style !== null && bemap.inheritsof(style, bemap.PolygonStyle)) {
    var fc = style.fillColor;
    var bc = style.borderColor;
    style.native = new ol.style.Style({
      fill: new ol.style.Fill({
        color: [fc.getRed(), fc.getGreen(), fc.getBlue(), fc.getAlpha()]
      }),
      stroke: new ol.style.Stroke({
        color: [bc.getRed(), bc.getGreen(), bc.getBlue(), bc.getAlpha()],
        width: style.borderWidth,
        lineDash: style.borderType === bemap.PolygonStyle.TYPE.DASH ? [10] : style.borderType === bemap.PolygonStyle.TYPE.DOT_DASH ? [0.1, 10, 10] : style.borderType === bemap.PolygonStyle.TYPE.DOT ? [0.1, 10] : []
      }),
    });
  }
  return this;
};

/**
 * Build circleStyle resource.
 * @public
 * @param {bemap.CircleStyle} circleStyle
 * @param {object} options
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.buildCircleStyle = function (style, options) {
  if (style !== null && bemap.inheritsof(style, bemap.CircleStyle)) {
    var fc = style.fillColor;
    var bc = style.borderColor;
    style.native = new ol.style.Style({
      fill: new ol.style.Fill({
        color: [fc.getRed(), fc.getGreen(), fc.getBlue(), fc.getAlpha()]
      }),
      stroke: new ol.style.Stroke({
        color: [bc.getRed(), bc.getGreen(), bc.getBlue(), bc.getAlpha()],
        width: style.borderWidth,
        lineDash: style.borderType === bemap.CircleStyle.TYPE.DASH ? [10] : style.borderType === bemap.CircleStyle.TYPE.DOT_DASH ? [0.1, 10, 10] : style.borderType === bemap.CircleStyle.TYPE.DOT ? [0.1, 10] : []
      }),
    });
  }
  return this;
};

/**
 * Refresh all objects from a layer.
 * @public
 * @param {bemap.Layer} layer the layer object to refresh.
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.refreshLayer = function (layer) {
  if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    if (layer.native.getSource && layer.native.getSource()) {
      layer.native.getSource().changed();
    }
  }
  return this;
};

/**
 * Remove all objects from a layer.
 * @public
 * @param {bemap.Layer} layer the layer object to clear.
 * @return {bemap.OlMap} this
 */
bemap.OlMap.prototype.clearLayer = function (layer) {
  if (layer !== null && bemap.inheritsof(layer, bemap.ClusterLayer)) {
    layer.native.getSource().getSource().clear();
  } else if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    layer.native.getSource().clear();
  }
  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.OlMap} return this.
 */
bemap.OlMap.prototype.visibleLayer = function (layer, visible) {
  if (visible !== null && layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    layer.native.setVisible(visible);
    layer.visible = visible;
  }
  return this;
};

/**
 * Set the zIndex of the layer.
 * @public
 * @param {bemap.Layer} layer the layer of wich to set the zIndex.
 * @param {boolean} zIndex.
 * @return {bemap.OlMap} return this.
 */
bemap.OlMap.prototype.zIndexLayer = function (layer, zIndex) {
  if (zIndex !== null && layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    layer.native.setZIndex(zIndex);
    layer.zIndex = zIndex;
  }
  return this;
};

/**
 * Remove a layer from the map.
 * @param {bemap.Layer} layer layer to remove from the map.
 * @return {bemap.OlMap} this.
 */
bemap.OlMap.prototype.removeLayer = function (layer) {
  if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    this.native.removeLayer(layer.native);
    layer.map = null;
  }
  return this;
};

/**
 * Check the event mode.
 * @protected
 * @param {object} mode enable modes.
 * @param {object} bemapObject bemap object like bemap.Marker, bemap.Polyline, etc.
 * @param {object} declaredObj bemap object like bemap.Marker, bemap.Polyline, etc.
 * @return {function}
 */
bemap.OlMap.prototype._checkModeOfEvent = function (mode, bemapObject, eventType) {
  if (bemapObject && bemapObject.callback && bemapObject.callback[eventType] && typeof bemapObject.callback[eventType] === "function") {
    return bemapObject.callback[eventType];
  }
  if (this.events) {
    if (this.events[eventType + "markers"] && this.events[eventType + "markers"].callback && typeof this.events[eventType + "markers"].callback === "function" && bemap.inheritsof(bemapObject, bemap.Marker)) {
      return this.events[eventType + "markers"].callback;
    }
    if (this.events[eventType + "multiMarkers"] && this.events[eventType + "multiMarkers"].callback && typeof this.events[eventType + "multiMarkers"].callback === "function" && bemap.inheritsof(bemapObject, bemap.MultiMarker)) {
      return this.events[eventType + "multiMarkers"].callback;
    }
    if (this.events[eventType + "polylines"] && this.events[eventType + "polylines"].callback && typeof this.events[eventType + "polylines"].callback === "function" && bemap.inheritsof(bemapObject, bemap.Polyline)) {
      return this.events[eventType + "polylines"].callback;
    }
  }
  if (bemapObject && bemapObject.events.draggable && bemapObject.callback.draggable && typeof bemapObject.callback.draggable === "function") {
    return bemapObject.callback.draggable;
  }
  return null;
};

/**
 * Set the listner when a specified eventType occur on OpenLayers Feature object.
 * @protected
 * @param {bemap.Marker} marker
 * @param {bemap.Map.EventType} eventType Event type.
 * @param {function} callback Function will be called when the specified eventType is occur.
 * @param {object} options options.
 * @param {object} mode Mode of slection feature.
 * @return {bemap.Listener} listener.
 */
bemap.OlMap.prototype._onFeature = function (declaredObj, eventType, callback, options, mode) {
  var opts = options ? options : {};

  var layerFilterCallback = function (layer) {
    if (opts.layerFilter) {
      return layer === opts.layerFilter.native;
    } else {
      return true;
    }
  };

  var nativeListener;
  var _this = this;

  if (!this.events[eventType]) {
    nativeListener = this.native.on(eventType, function (evt) {
      var feature = null;
      if (evt.pixel !== undefined)
        feature = _this.native.forEachFeatureAtPixel(evt.pixel, function (feature, layer) {
          return feature;
        }, { layerFilter: layerFilterCallback });
      var bemapObject;
      if (feature) {
        bemapObject = bemap.OlMap.prototype._getOwnFromProperties(feature);
      } else {
        bemapObject = _this;
      }
      var mapEvent = new bemap.MapEvent({
        native: evt,
        bemapObject: bemapObject,
        x: evt.pixel !== undefined ? evt.pixel[0] : undefined,
        y: evt.pixel !== undefined ? evt.pixel[1] : undefined,
        coordinate: evt.coordinate !== undefined ? _this._fromNativeToLonLat(evt.coordinate) : undefined,
        properties: options,
        map: _this
      });
      callback = _this._checkModeOfEvent(mode, bemapObject, eventType);
      if (callback) {
        return callback(mapEvent);
      }
    });
  } else {
    nativeListener = this.events[eventType].native;
  }

  var listener = new bemap.Listener({
    native: nativeListener,
    callback: callback,
    key: eventType,
    bemapObject: declaredObj
  });

  this.events[eventType] = listener;

  if (declaredObj && mode.singleFeature && typeof callback === "function") {
    declaredObj.callback[eventType] = callback;
  } else if (!declaredObj && mode.markers && typeof callback === "function") {
    this.events[eventType + "markers"] = new bemap.Listener({
      callback: callback,
      key: eventType + "markers"
    });
    return this.events[eventType + "markers"];
  } else if (!declaredObj && mode.multiMarkers && typeof callback === "function") {
    this.events[eventType + "multiMarkers"] = new bemap.Listener({
      callback: callback,
      key: eventType + "multiMarkers"
    });
    return this.events[eventType + "multiMarkers"];
  } else if (!declaredObj && mode.polylines && typeof callback === "function") {
    this.events[eventType + "polylines"] = new bemap.Listener({
      callback: callback,
      key: eventType + "polylines"
    });
    return this.events[eventType + "polylines"];
  }

  return listener;
};

/**
 * Set the listner when an specified eventType occur on bemap.OlMap.
 * @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.OlMap.prototype.on = function (eventType, callback, options) {
  // 'error*' also reaches the map's error channel — the documented behaviour that
  // this override used to drop (BEMAP-1898 audit). Additive: the _onFeature call
  // below still happens exactly as before.
  this._subscribeErrorChannel(eventType, callback);
  return this._onFeature(this, eventType, callback, options, {
    singleFeature: true
  });
};

/**
 * Resolve the WMS "GetFeatureInfo URL" method of an OL source, across OL versions.
 *
 * OpenLayers 10 dropped the doubled "Get" on BOTH `TileWMS` and `ImageWMS`
 * (`getGetFeatureInfoUrl` → `getFeatureInfoUrl`); the argument list is unchanged.
 * The SDK still called the OL 4/5/6 name, so on OL 10 every SINGLECLICK inside
 * `onGetFeatureInfo`'s listener threw `getGetFeatureInfoUrl is not a function` —
 * click-to-query was entirely dead, and no spec caught it because the existing
 * coverage never attaches a map and so never fires the click (BEMAP-1898 audit).
 *
 * Kept as a testable static rather than inlined, so a spec can exercise the real
 * resolution instead of re-implementing it.
 * @private
 * @param {ol.source.Source} source
 * @return {Function|null} the method, unbound — call it with `.call(source, …)`
 */
bemap.OlMap._featureInfoUrlGetter = function (source) {
  if (!source) return null;
  var fn = source.getFeatureInfoUrl || source.getGetFeatureInfoUrl;
  return (typeof fn === 'function') ? fn : null;
};

/**
 * Set get feature info call back.
 * @param {bemap.Layer} layer set the bemap layer.
 * @param {object} options options.
 * @param {function} options.beforeCallback callback called at data reception and before display the popup.
 * @param {function} options.afterCallback callback called after display the popup.
 * @param {function} options.panningMap enable the map panning animation. move map from the current position to the popup anchor at the center of map.
 * @return {bemap.Listener} listner;
 */
bemap.OlMap.prototype.onGetFeatureInfo = function (layer, options) {
  var opts = options ? options : {};
  var _this = this;
  var popup = new bemap.Popup({
    information: "<p>onGetFeatureInfo</p>",
    coordinate: new bemap.Coordinate(0, 0),
    visible: false
  });
  this.addPopup(popup);

  var listener = this.on(bemap.Map.EventType.SINGLECLICK, function (evt) {
    var source = layer.native.getSource();
    var getUrl = bemap.OlMap._featureInfoUrlGetter(source);
    if (!getUrl) {
      _this._fireError(new bemap.Error({
        code: bemap.Error.INVALID_ARGUMENT,
        message: 'onGetFeatureInfo: the layer source exposes no getFeatureInfoUrl() — it must be a WMS source (ol.source.TileWMS / ol.source.ImageWMS)'
      }));
      return;
    }
    var url = getUrl.call(
      source,
      evt.native.coordinate,
      evt.native.map.getView().getResolution(),
      evt.native.map.getView().getProjection(), {
        'INFO_FORMAT': 'text/xml',
        'propertyName': 'NAME,AREA_CODE,DESCRIPTIO'
      }
    );

    bemap.ajax('GET', url, null, function (xhr, data) {
      evt.listener = listener;
      evt.popup = popup;
      evt.xhr = xhr;
      evt.data = data;
      evt.showPopup = true;

      var info = data;
      if (opts.beforeCallback) {
        info = opts.beforeCallback(evt, opts);
      }

      if (evt.showPopup) {
        popup.setInformation(info).setCoordinate(evt.coordinate, opts);

        if (opts.afterCallback) {
          opts.afterCallback(evt, opts);
        }
      }
    });
  });

  listener.popup = popup;
  return listener;
};

/**
 * Define the draggable capability for bemap object (like bemap.Marker,  bemap.MultiMarker, bemap.Polyline, etc.).
 * @protected
 * @param {Object} declaredObj bemap object like bemap.Marker,  bemap.MultiMarker, bemap.Polyline, etc.
 * @param {function} callback Function will be called when the specified eventType is occur.
 * @param {object} options Options.
 * @param {bemap.Layer} options.layerFilter set the bemap layer used as filter.
 * @param {object} mode Mode of slection feature.
 * @return {bemap.Listener} Listener.
 */
bemap.OlMap.prototype._draggableFeature = function (declaredObj, callback, options, mode) {
  var _this = this,
    opts = options ? options : {},
    startPixel, startCoordinate, previousCoordinate,
    cursor = 'pointer',
    previousCursor, feature = null;

  var layerFilterCallback = function (layer) {
    if (opts.layerFilter) {
      return layer === opts.layerFilter.native;
    } else {
      return true;
    }
  };

  if (declaredObj && !declaredObj.events.draggable) {
    declaredObj.events.draggable = true;
    declaredObj.callback.draggable = callback;
  }

  if (!this.events.dragFeature) {
    var downFunc = function (evt) {
      var foundFeature = evt.map.forEachFeatureAtPixel(evt.pixel, function (feature, layer) {
        var bemapObject = _this._getOwnFromProperties(feature);
        if (_this._checkModeOfEvent(mode, bemapObject, 'draggable')) {
          return feature;
        }
      }, { layerFilter: layerFilterCallback });

      if (foundFeature) {
        startPixel = evt.pixel;
        startCoordinate = evt.coordinate;
        previousCoordinate = evt.coordinate;
        feature = foundFeature;
        return true;
      }

      return false;
    },
      dragFunc = function (evt) {
        if (feature) {
          feature.getGeometry().translate(evt.coordinate[0] - previousCoordinate[0], evt.coordinate[1] - previousCoordinate[1]);
          previousCoordinate = evt.coordinate;

          var bemapObject = bemap.OlMap.prototype._getOwnFromProperties(feature);
          if (bemapObject) {
            var newCoords = feature.getGeometry().getCoordinates(),
              newCoord;

            if (bemap.inheritsof(bemapObject, bemap.Marker)) {
              newCoord = _this._fromNativeToLonLat(newCoords);
              bemapObject.setCoordinate(bemapObject.getCoordinate().setLon(newCoord.getLon()).setLat(newCoord.getLat()));

            } else if (bemap.inheritsof(bemapObject, bemap.MultiMarker) || bemap.inheritsof(bemapObject, bemap.Polyline)) {
              var bemapObjCoords = bemapObject.getCoordinates();
              for (var i = 0; i < newCoords.length; i++) {
                newCoord = _this._fromNativeToLonLat(newCoords[i]);
                bemapObjCoords[i].setLon(newCoord.getLon()).setLat(newCoord.getLat());
              }
            }
          }
        }
      },
      moveFunc = function (evt) {
        if (cursor) {
          var nativeMap = evt.map;
          var foundFeature = nativeMap.forEachFeatureAtPixel(evt.pixel, function (feature, layer) {
            var bemapObject = _this._getOwnFromProperties(feature);
            if (_this._checkModeOfEvent(mode, bemapObject, 'draggable')) {
              return feature;
            }
          }, { layerFilter: layerFilterCallback });

          var element = nativeMap.getTargetElement();
          if (foundFeature) {
            if (element.style.cursor != cursor) {
              previousCursor = element.style.cursor;
              element.style.cursor = cursor;
            }
          } else if (previousCursor !== undefined) {
            element.style.cursor = previousCursor;
            previousCursor = undefined;
          }
        }
      },
      upFunc = function (evt) {
        if (evt.pixel[0] == startPixel[0] && evt.pixel[1] == startPixel[1]) {
          return false;
        }
        var mapEvent = new bemap.MapEvent({
          native: evt,
          bemapObject: bemap.OlMap.prototype._getOwnFromProperties(feature),
          x: evt.pixel[0],
          y: evt.pixel[1],
          coordinate: _this._fromNativeToLonLat(evt.coordinate),
          startX: startPixel[0],
          startY: startPixel[1],
          // Was `evt.coordinate` — the SAME expression as `coordinate` above —
          // so startCoordinate always equalled the END of the drag and every
          // consumer computing a delta got zero. The captured mouse-down value
          // was dead code. (BEMAP-1898 audit; startX/startY were already right.)
          startCoordinate: _this._fromNativeToLonLat(startCoordinate),
          properties: options
        });

        feature = null;
        callback = _this._checkModeOfEvent(mode, mapEvent.bemapObject, 'draggable');
        if (callback) {
          callback(mapEvent);
        }

        return false;
      };

    var pointerInteraction = new ol.interaction.Pointer({
      handleDownEvent: downFunc,
      handleDragEvent: dragFunc,
      handleMoveEvent: moveFunc,
      handleUpEvent: upFunc
    });

    var nativeListener = this.native.addInteraction(pointerInteraction);

    this.events.dragFeature = new bemap.Listener({
      native: pointerInteraction,
      key: "dragFeature",
      bemapObject: declaredObj
    });

    return this.events.dragFeature;
  }
  return this.events.dragFeature;
};

/**
 * Remove listener.
 * @protected
 * @param {bemap.Listener} listener Function will be called when the specified eventType is occur.
 * @param {object} options Options.
 * @return {bemap.OlMap} this.
 */
bemap.OlMap.prototype.removeListener = function (listener, options) {
  if (listener && bemap.inheritsof(listener, bemap.Listener)) {
    if (listener.bemapObject) {
      if (listener.key == "dragFeature") {
        listener.bemapObject.events.draggable = false;
        listener.bemapObject.callback.draggable = null;
      } else {
        listener.bemapObject.callback[listener.key] = null;
      }
    }
    // ol.Observable.unByKey(listener.native);
    // this.native.removeInteraction(listener.native);
    // this.events[listener.key] = null;
  }
  return this;
};

/**
 * Get the center of the map in bemap.Coordinate.
 * @return {bemap.Coordinate} the center of the map.
 */
bemap.OlMap.prototype.getCenter = function () {
  var center = this.native.getView().getCenter();
  center = this._fromNativeToLonLat(center);
  return center;
};

/**
 * Get the limits of the map on the current zoom.
 * @return {bemap.BoundingBox} the bounding box containing the limits.
 */
bemap.OlMap.prototype.getBoundingBox = function () {
  var extent = this.native.getView().calculateExtent(this.native.getSize());
  extent = ol.proj.transformExtent(extent, this.native.getView().getProjection(), bemap.Map.PROJ.EPSG_WGS84);
  return new bemap.BoundingBox(extent[0], extent[1], extent[2], extent[3]);
};

/**
 * Get the pixel coordinate from bemap coordinate.
 * @return {Array} the corresponding XY coords.
 */
bemap.OlMap.prototype.getXYFromCoordinate = function (coordinate) {
  return this.native.getPixelFromCoordinate(
    ol.proj.transform(coordinate.getLonLatArray(), bemap.Map.PROJ.EPSG_WGS84, this.native.getView().getProjection())
  );
};

/**
 * Fly map to new coordinate.
 * @param {double} lon longitude
 * @param {double} lat latitude
 * @param {double} zoom zoom
 * @param {Object} options options
 */
bemap.OlMap.prototype.flyTo = function (lon, lat, zoom, options) {
  var view = this.native.getView();
  var duration = 2000;
  if (options && options.duration) {
    duration = options.duration;
  }
  var parts = 2;
  var called = false;
  function callback(complete) {
    --parts;
    if (called) {
      return;
    }
    if (parts === 0 || !complete) {
      called = true;
      if (options && options.listener) {
        options.listener(complete);
      }
    }
  }
  view.animate(
    {
      center: this._fromLonLat(lon, lat),
      duration: duration,
    },
    callback
  );
  view.animate(
    {
      zoom: zoom - 1,
      duration: duration / 2,
    },
    {
      zoom: zoom,
      duration: duration / 2,
    },
    callback
  );
}

/* ============================================================
 * Cross-engine camera methods (v2.0)
 *
 * OL handles `bearing` natively (`view.setRotation`) but ignores
 * `pitch` (no native 3D camera).
 * ============================================================ */

bemap.OlMap.prototype.easeTo = function (options) {
  var opts = options || {};
  if (opts.pitch) this._maplibreOnly('easeTo:pitch');
  var view = this.native.getView();
  var anim = { duration: opts.duration != null ? opts.duration : 1000 };
  if (Array.isArray(opts.center)) anim.center = this._fromLonLat(opts.center[0], opts.center[1]);
  if (opts.zoom != null) anim.zoom = opts.zoom;
  if (opts.bearing != null) anim.rotation = -opts.bearing * Math.PI / 180;
  view.animate(anim);
  return this;
};

bemap.OlMap.prototype.jumpTo = function (options) {
  var opts = options || {};
  if (opts.pitch) this._maplibreOnly('jumpTo:pitch');
  var view = this.native.getView();
  if (Array.isArray(opts.center)) view.setCenter(this._fromLonLat(opts.center[0], opts.center[1]));
  if (opts.zoom != null) view.setZoom(opts.zoom);
  if (opts.bearing != null) view.setRotation(-opts.bearing * Math.PI / 180);
  return this;
};

bemap.OlMap.prototype.setBearing = function (deg) {
  var view = this.native.getView();
  view.setRotation(-deg * Math.PI / 180);
  return this;
};
bemap.OlMap.prototype.getBearing = function () {
  return -this.native.getView().getRotation() * 180 / Math.PI;
};

bemap.OlMap.prototype.setPitch = function (deg) {
  this._maplibreOnly('setPitch');
  return this;
};

/* --- Heatmap (OL via ol.layer.Heatmap when available) --- */
bemap.OlMap.prototype.addHeatmap = function (data, options) {
  // Preserve the v1 warn-and-no-op contract when no input is provided or
  // when the OL Heatmap layer type is unavailable in the page bundle.
  var hasData = Array.isArray(data) && data.length > 0;
  if (!hasData || typeof ol === 'undefined' || !ol.layer || typeof ol.layer.Heatmap !== 'function') {
    console.warn('addHeatmap() is only available with bemap.MapLibreMap');
    return this;
  }
  var opts = options || {};
  var id = opts.id || ('heatmap-' + (++this._heatCounter || (this._heatCounter = 1)));
  var self = this;
  var features = data.map(function (p) {
    var lon, lat, weight = 1;
    if (Array.isArray(p)) { lon = p[0]; lat = p[1]; weight = p[2] != null ? p[2] : 1; }
    else if (p && p.lat != null && p.lng != null) { lon = p.lng; lat = p.lat; weight = p.value != null ? p.value : 1; }
    else if (p && typeof p.getLongitude === 'function') { lon = p.getLongitude(); lat = p.getLatitude(); weight = p.value != null ? p.value : 1; }
    else return null;
    var f = new ol.Feature({ geometry: new ol.geom.Point(self._fromLonLat(lon, lat)) });
    f.set('weight', weight);
    return f;
  }).filter(Boolean);
  var layer = new ol.layer.Heatmap({
    source: new ol.source.Vector({ features: features }),
    weight: function (f) { return f.get('weight'); },
    blur: opts.blur != null ? opts.blur : 15,
    radius: opts.radius != null ? opts.radius : 8
  });
  this.native.addLayer(layer);
  this._heatmaps = this._heatmaps || {};
  this._heatmaps[id] = layer;
  this._lastHeatmapId = id;
  return this;
};
bemap.OlMap.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;
};

/* ============================================================
 * Cross-engine hillshade overlay (v2.0) — OpenLayers implementation
 *
 * Uses an `ol.layer.Tile` with the customer's hillshade XYZ URL.
 * For a true Terrain-RGB shaded relief the customer can supply an
 * `ol.source.Raster` directly via opts.source — that path is
 * passed through unchanged for power users.
 * ============================================================ */
bemap.OlMap.prototype.setHillshade = function (options) {
  var opts = options || {};
  if (typeof ol === 'undefined') return this;
  this.removeHillshade();
  if (opts.source instanceof ol.source.Source) {
    this._hillshadeLayer = new ol.layer.Tile({ source: opts.source, opacity: opts.opacity != null ? opts.opacity : 0.35 });
  } else {
    var url = opts.url || 'https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}';
    this._hillshadeLayer = new ol.layer.Tile({
      source: new ol.source.XYZ({ url: url, attributions: opts.attribution || '' }),
      opacity: opts.opacity != null ? opts.opacity : 0.35
    });
  }
  this.native.addLayer(this._hillshadeLayer);
  var self = this;
  if (typeof self._registerEffect === 'function') {
    self._registerEffect('hillshade', function () { self.removeHillshade(); });
  }
  return this;
};
bemap.OlMap.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) — OL real-animation implementation
 *
 * Uses `view.animate(...)` chained per waypoint, honouring zoom +
 * rotation. Same API shape as the Leaflet / MapLibre versions.
 * ============================================================ */
bemap.OlMap.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) return;
    if (i >= waypoints.length) {
      if (opts.loop) i = 0;
      else { if (typeof opts.onComplete === 'function') opts.onComplete(); return; }
    }
    var wp = waypoints[i++];
    self.native.getView().animate({
      center: self._fromLonLat(wp.lon, wp.lat),
      zoom: wp.zoom != null ? wp.zoom : self.native.getView().getZoom(),
      rotation: wp.bearing != null ? -wp.bearing * Math.PI / 180 : self.native.getView().getRotation(),
      duration: wp.duration || 2000
    }, function () { setTimeout(next, 0); });
  }
  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 OL. 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
 *     TileWMS source(s) — the dark/light theme switch (`'benomadGrayLevel'` /
 *     `'benomadLight'`). Vector style names are MapLibre-only.
 *   • URL string (contains '/' or ':') → swaps the XYZ raster source URL.
 *   • inline style object → MapLibre-only; warn-once no-op here.
 *
 * @public
 * @since 2.0.0
 * @param {String|Object} urlOrSpec  WMS style name, a raster URL template, or a
 *                                   MapLibre vector style object (no-op).
 * @return {bemap.OlMap} this
 */
bemap.OlMap.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)) {
    // getAllLayers() flattens ol.layer.Group children (getLayers() is top-level
    // only) so a BeNomad WMS layer nested in a group is still re-themed; fall
    // back to the top-level collection on older OL builds.
    var allLayers = (typeof this.native.getAllLayers === 'function')
      ? this.native.getAllLayers()
      : this.native.getLayers().getArray();
    var applied = 0;
    try {
      allLayers.forEach(function (layer) {
        var s = layer && typeof layer.getSource === 'function' ? layer.getSource() : null;
        // Only BeNomad WMS sources (tagged in addLayer) — never a customer's own
        // bemap.WmsLayer, which uses the identical ol.source.TileWMS.
        if (s && s._bemapWms && typeof s.updateParams === 'function') {
          s.updateParams({ STYLES: urlOrSpec }); applied++;
        }
      });
    } catch (e) { /* ignore */ }
    if (!applied && typeof console !== 'undefined' && console.warn) {
      console.warn('[bemap] OlMap.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; }
  // Only swap pure XYZ sources via setUrl; TileWMS / VectorTile sources
  // refuse a bare setUrl (they need format / featureLoader). Falling
  // through to addLayer is safer than throwing.
  var swapped = false;
  // Top-level layers only here (unchanged from prior behavior): the raster URL
  // swap deliberately does not descend into ol.layer.Group children.
  this.native.getLayers().forEach(function (layer) {
    var src = layer && typeof layer.getSource === 'function' ? layer.getSource() : null;
    if (!src) return;
    if (typeof ol !== 'undefined' && ol.source && ol.source.XYZ && src instanceof ol.source.XYZ && typeof src.setUrl === 'function') {
      src.setUrl(url);
      swapped = true;
    }
  });
  if (!swapped) {
    var newLayer = new ol.layer.Tile({ source: new ol.source.XYZ({ url: url }) });
    this.native.addLayer(newLayer);
  }
  if (typeof self._registerEffect === 'function') {
    self._registerEffect('setStyle', function () { /* customer-restorable */ });
  }
  return this;
};

/* Worker tile-style discovery is MapLibre-only (OL uses WMS). Uniform-surface
 * stubs so engine-agnostic code never hits "not a function" after an engine
 * switch — they reject with a typed MAPLIBRE_ONLY error. */
bemap.OlMap.prototype._styleApiMaplibreOnly = function (label) {
  return Promise.reject(new bemap.Error({
    code: bemap.Error.MAPLIBRE_ONLY,
    message: 'bemap.OlMap.' + label + ' is MapLibre-only (OpenLayers uses WMS). Use bemap.MapLibreMap for vector tile styles.'
  }));
};
bemap.OlMap.prototype.fetchAvailableStyles = function () { return this._styleApiMaplibreOnly('fetchAvailableStyles'); };
bemap.OlMap.prototype.fetchDefaultStyle    = function () { return this._styleApiMaplibreOnly('fetchDefaultStyle'); };
bemap.OlMap.prototype.fetchAvailableMaps   = function () { return this._styleApiMaplibreOnly('fetchAvailableMaps'); };
bemap.OlMap.prototype.fetchDefaultMap      = function () { return this._styleApiMaplibreOnly('fetchDefaultMap'); };

/* ============================================================
 * Cross-engine animation (v2.0) — OpenLayers implementation
 *
 * Uses `requestAnimationFrame` + `ol.Feature.getGeometry().setCoordinates`
 * to move a Point feature along the route. The customer can supply the
 * `feature` via opts.feature, or we create one on a dedicated vector
 * layer.
 * ============================================================ */
bemap.OlMap.prototype.animateAlongRoute = function (options) {
  var opts = options || {};
  var route = opts.coordinates || [];
  var speed = opts.speed || 0.005;
  var loop = opts.loop !== false;
  var onUpdate = opts.onUpdate;
  var onComplete = opts.onComplete;
  var self = this;
  if (!route.length || typeof ol === 'undefined') {
    if (!route.length) console.warn('animateAlongRoute: opts.coordinates is empty');
    return { stop: function(){}, resume: function(){} };
  }

  // Use the supplied feature or build one on an internal vector layer.
  var feature = opts.feature;
  var ownedLayer = null;
  if (!feature) {
    feature = new ol.Feature({
      geometry: new ol.geom.Point(this._fromLonLat(route[0][0], route[0][1]))
    });
    ownedLayer = new ol.layer.Vector({ source: new ol.source.Vector({ features: [feature] }) });
    this.native.addLayer(ownedLayer);
  }

  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;
    feature.getGeometry().setCoordinates(self._fromLonLat(lon, lat));
    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 (ownedLayer) self.native.removeLayer(ownedLayer);
      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;
};