Source: bemap-map.js

/**
 * BeNomad BeMap JavaScript API - Map abstract class
 */

/**
 * @classdesc
 * Base class for map.
 * @public
 * @constructor
 * @abstract
 * @param {bemap.Context} context BeMap-JS-API Context.
 * @param {object} options.
 */
bemap.Map = function (context, options) {
  /**
   * @protected
   */
  this.ctx = context;

  /**
   * @protected
   */
  this.native = null;

  /**
   * @protected
   */
  this.layers = [];

  /**
   * @protected
   */
  this.events = [];

  if (context === undefined) {
    console.error("Context is required!");
    return;
  }

  /**
   * @protected
   */
  this.callback = [];

  /**
   * Whether the zoom (+/-) control is currently visible. Initialised from
   * the resolved `zoomControl` option (default OFF — opt-in) and flipped at
   * runtime by `setZoomControl()`. Kept in sync so `getZoomControl()` is
   * accurate before any runtime toggle.
   * @protected
   */
  this._zoomControlVisible = bemap.Map._resolveControlOptions(options).zoomControl;
};

/**
 * Reserved name used to store the bemap object into the native properties of OpenLayers.
 */
bemap.Map.OWNREF = '__bemap_own_ref__';

/**
 * Cross-engine zoom-control positions. Each engine has its own naming —
 * `bemap.Map._translateZoomPosition(engine, pos)` does the conversion.
 *
 * @public
 * @since 1.5.0
 * @enum {String}
 */
bemap.Map.ZoomPosition = Object.freeze({
  TOP_LEFT: 'top-left',
  TOP_RIGHT: 'top-right',
  BOTTOM_LEFT: 'bottom-left',
  BOTTOM_RIGHT: 'bottom-right'
});

/**
 * Translate a `bemap.Map.ZoomPosition` value to the engine-specific
 * convention.
 *   - 'leaflet'   →  `'topleft'` / `'topright'` / `'bottomleft'` / `'bottomright'`
 *   - 'ol'        →  the CSS-class suffix `'top-left'` (used as `bemap-zoom-pos-<suffix>`)
 *   - 'maplibre'  →  unchanged (`'top-left'` matches MapLibre's own convention)
 *
 * @public
 * @since 1.5.0
 * @param {String} engine 'leaflet' | 'ol' | 'maplibre'
 * @param {String} pos    One of the `bemap.Map.ZoomPosition` values.
 * @return {String}
 */
bemap.Map._translateZoomPosition = function (engine, pos) {
  if (engine === 'leaflet') {
    return (pos || 'top-left').replace(/-/g, '');
  }
  // OL and MapLibre both use the hyphenated form.
  return pos || 'top-left';
};

/**
 * Resolve the `nativeAttribution` and `zoomControl` map-constructor options
 * into concrete flags + an optional explicit position. Shared by every
 * engine adapter (Leaflet, OpenLayers, MapLibre) so the contract is
 * uniform across them.
 *
 * **`nativeAttribution`** — Boolean, controls whether the engine-native
 * attribution UI (Leaflet's `.leaflet-control-attribution`, OL's
 * `.ol-attribution`, MapLibre's `.maplibregl-ctrl-attrib`) is rendered.
 * When `bemap.AttributionWidget` is enabled (which is the default —
 * `options.attribution !== false`), the native UI is hidden by default
 * to avoid duplicating the legal mentions. When the BeMap widget is off,
 * the native UI is shown by default so customers never end up with no
 * attribution at all. Pass `nativeAttribution: true` to force-show, or
 * `nativeAttribution: false` to force-hide regardless of the widget.
 *
 * **`zoomControl`** — controls whether the zoom (+/-) buttons are
 * rendered and where. Accepted forms:
 *
 *   - omitted / `false` (default) — **hide the zoom buttons**. Zoom control
 *     is OFF unless the consumer explicitly opts in, matching the
 *     `keyboard` and `nativeAttribution` defaults (all controls opt-in).
 *   - `true` — show at the engine default position (`top-left`). On
 *     MapLibre this auto-adds a `NavigationControl` so the contract is
 *     consistent across engines.
 *   - `'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'` — show
 *     at that position. Same four values across every engine.
 *   - `{ position: '<position>', enabled?: boolean }` — long form. Set
 *     `enabled: false` to hide while still recording the chosen position.
 *
 * **`keyboard`** — controls whether the engine-native keyboard interactions
 * (arrow-key pan and `+`/`-` / arrow zoom on Leaflet, `KeyboardPan` +
 * `KeyboardZoom` on OL, `KeyboardHandler` on MapLibre) are active.
 * **Default: `false`** — keyboard navigation is OFF unless the consumer
 * explicitly opts in, because it surprises users who expect arrow keys to
 * scroll the page rather than the map. Accepted forms:
 *
 *   - omitted / `false` (default) — keyboard disabled (pan + zoom).
 *   - `true` — full keyboard support enabled (arrow pan + `+`/`-` zoom).
 *   - `{ pan?: boolean, zoom?: boolean }` — granular toggle; either omitted
 *     half defaults to `false`.
 *
 * @public
 * @since 1.5.0
 * @param {Object} [options] Map-constructor options.
 * @return {{nativeAttribution: Boolean, zoomControl: Boolean, zoomPosition: String, keyboard: {pan: Boolean, zoom: Boolean}}}
 *         `zoomPosition` is always one of the four `bemap.Map.ZoomPosition`
 *         values; defaults to `TOP_LEFT` when the user did not pick one.
 */
bemap.Map._resolveControlOptions = function (options) {
  var o = options || {};

  // Attribution
  var widgetEnabled = (o.attribution !== false);
  var nativeAttr = (o.nativeAttribution !== undefined)
    ? !!o.nativeAttribution
    : !widgetEnabled;

  // Zoom — four accepted forms, normalised here.
  var raw = o.zoomControl;
  var enabled = false;
  var position = bemap.Map.ZoomPosition.TOP_LEFT;

  if (raw === undefined) {
    // Not set → default OFF (opt-in). Pass `zoomControl: true` or a
    // position string to render the +/- buttons.
    enabled = false;
  } else if (!raw) {
    // Explicit falsy (`false`, `null`, `0`, `''`) → hide.
    enabled = false;
  } else if (typeof raw === 'string') {
    enabled = true;
    if (bemap.Map._isValidZoomPosition(raw)) position = raw;
  } else if (typeof raw === 'object') {
    // An object is an explicit opt-in (a position config) → enabled unless
    // the caller sets `enabled: false`.
    enabled = (raw.enabled !== false);
    if (typeof raw.position === 'string' && bemap.Map._isValidZoomPosition(raw.position)) {
      position = raw.position;
    }
  } else {
    // Truthy non-string / non-object (e.g. `true`, `1`) → enabled at default.
    enabled = true;
  }

  // Keyboard — three accepted forms.
  var kbRaw = o.keyboard;
  var kbPan = false, kbZoom = false;
  if (kbRaw === true) {
    kbPan = true; kbZoom = true;
  } else if (kbRaw && typeof kbRaw === 'object') {
    kbPan = !!kbRaw.pan;
    kbZoom = !!kbRaw.zoom;
  }
  // kbRaw === false / undefined / null / falsy → both stay false (default).

  return {
    nativeAttribution: nativeAttr,
    zoomControl: enabled,
    zoomPosition: position,
    keyboard: { pan: kbPan, zoom: kbZoom }
  };
};

/**
 * @private
 * @param {String} pos
 * @return {Boolean}
 */
bemap.Map._isValidZoomPosition = function (pos) {
  return pos === 'top-left' || pos === 'top-right'
      || pos === 'bottom-left' || pos === 'bottom-right';
};

/**
 * Show or hide the engine's native zoom (+/-) buttons at runtime.
 * Uniform across Leaflet, OpenLayers and MapLibre — toggles a class on
 * the map container so the engine-specific selectors all hit at once.
 * @public
 * @since 1.5.0
 * @param {Boolean} enabled
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setZoomControl = function (enabled) {
  if (!this.native) return this;
  var container = null;
  if (typeof this.native.getContainer === 'function')        container = this.native.getContainer();
  else if (typeof this.native.getTargetElement === 'function') container = this.native.getTargetElement();
  if (!container && typeof this.target === 'string' && typeof document !== 'undefined') {
    container = document.getElementById(this.target);
  }
  if (!container) return this;
  if (enabled) container.classList.remove('bemap-zoomctrl-hidden');
  else         container.classList.add('bemap-zoomctrl-hidden');
  this._zoomControlVisible = !!enabled;
  return this;
};

/** @public @return {Boolean} */
bemap.Map.prototype.getZoomControl = function () { return this._zoomControlVisible !== false; };

/**
 * List of available layer name by default.
 * @public
 * @enum bemap.Map.DEFAULT_LAYER
 */
bemap.Map.DEFAULT_LAYER = {
  BACKGROUND: 'background',
  CIRCLE: 'circle',
  POLYGON: 'polygon',
  POLYLINE: 'polyline',
  MARKER: 'marker',
  ROUTE: 'route'
};

/**
 * List of available projections.
 * @public
 * @enum bemap.Map.PROJ
 */
bemap.Map.PROJ = {
  EPSG_WGS84: 'EPSG:4326',
  EPSG_MERCATOR: 'EPSG:3857'
};

/* ============================================================
 * Runtime dependency check (v2.0)
 *
 * Engine constructors call `bemap.Map._checkDependencies(engine, opts)`
 * to verify every required JS + CSS asset is loaded before the map is
 * instantiated. When something is missing, a styled multi-line console
 * banner is emitted with the exact <script>/<link> tags the customer
 * needs to add. The banner is loud (red background) and unmistakable.
 *
 * Each missing dependency also throws a `bemap.Error` so the call stack
 * points at the constructor call site, making it trivial to debug from
 * the customer's app code.
 * ============================================================ */

/**
 * Test whether a CSS file is loaded by looking for any <link> whose href
 * contains the given substring. Conservative — false negatives are fine
 * (we only emit a warning, not a hard fail, for missing CSS).
 *
 * @private
 * @param {String} fragment
 * @return {Boolean}
 */
bemap.Map._isCssLoaded = function (fragment) {
  if (typeof document === 'undefined') return true;
  var links = document.getElementsByTagName('link');
  for (var i = 0; i < links.length; i++) {
    var href = links[i].href || '';
    if (href.indexOf(fragment) !== -1) return true;
  }
  return false;
};

/**
 * Engine → required dependency manifest. JS deps are global identifiers
 * tested with `typeof X !== 'undefined'`. CSS deps are filename fragments
 * tested via `<link href>` substring match. Conditional deps (e.g.
 * pmtiles only needed when BeNomad Tiles is on) are flagged with `when`.
 * @private
 */
bemap.Map._DEPS = {
  maplibre: {
    js: [
      { name: 'maplibregl', tag: '<script src="dist/maplibre-gl.js"></script>', critical: true },
      { name: 'pmtiles',    tag: '<script src="dist/pmtiles.js"></script>',    critical: true, when: 'tilesHost' }
    ],
    css: [
      { fragment: 'maplibre-gl', tag: '<link rel="stylesheet" href="dist/maplibre-gl.css">', critical: false }
    ]
  },
  leaflet: {
    js: [
      { name: 'L', tag: '<script src="dist/leaflet.js"></script>', critical: true }
    ],
    css: [
      { fragment: 'leaflet.css', tag: '<link rel="stylesheet" href="dist/leaflet.css">', critical: false }
    ]
  },
  ol: {
    js: [
      { name: 'ol', tag: '<script src="dist/ol.js"></script>', critical: true }
    ],
    css: [
      { fragment: 'ol.css', tag: '<link rel="stylesheet" href="dist/ol.css">', critical: false }
    ]
  }
};

/**
 * Verify the runtime dependencies for an engine. Emits a loud console
 * banner and throws on missing critical (JS) deps. Missing CSS produces
 * a single warn line — non-fatal because rendering still kinda works.
 *
 * @public
 * @since 2.0.0
 * @param {String} engine `'maplibre'` | `'leaflet'` | `'ol'`
 * @param {Object} [opts]
 * @param {Boolean} [opts.tilesHost] True if `ctx.tilesHost` is set —
 *                                   gates conditional deps like pmtiles.
 */
bemap.Map._checkDependencies = function (engine, opts) {
  var manifest = bemap.Map._DEPS[engine];
  if (!manifest) return;
  opts = opts || {};

  var missingJs = [];
  var missingCss = [];

  // Probe JS globals.
  for (var i = 0; i < manifest.js.length; i++) {
    var dep = manifest.js[i];
    if (dep.when === 'tilesHost' && !opts.tilesHost) continue;
    var present = (typeof window !== 'undefined' && typeof window[dep.name] !== 'undefined');
    if (!present) missingJs.push(dep);
  }

  // Probe CSS via <link> tags.
  for (var j = 0; j < manifest.css.length; j++) {
    var cdep = manifest.css[j];
    if (!bemap.Map._isCssLoaded(cdep.fragment)) missingCss.push(cdep);
  }

  if (!missingJs.length && !missingCss.length) return;

  // Build the banner. Each missing dep produces one human-readable line.
  var engineLabel = engine.charAt(0).toUpperCase() + engine.slice(1);
  var lines = [];
  lines.push('bemap-js-api: missing runtime dependencies for bemap.' + engineLabel + 'Map');
  lines.push('');

  if (missingJs.length) {
    lines.push('Missing JavaScript libraries:');
    for (var k = 0; k < missingJs.length; k++) {
      lines.push('  - ' + missingJs[k].name + '   →   ' + missingJs[k].tag);
    }
    lines.push('');
  }
  if (missingCss.length) {
    lines.push('Missing CSS files (rendering may break):');
    for (var l = 0; l < missingCss.length; l++) {
      lines.push('  - ' + missingCss[l].fragment + '   →   ' + missingCss[l].tag);
    }
    lines.push('');
  }
  lines.push('Add the tag(s) above to your HTML BEFORE bemap-js-api.js.');
  lines.push('All required files ship in the dist/ folder of bemap-js-api.');

  var msg = lines.join('\n');

  // Loud styled banner — red background, bold, multi-line.
  if (typeof console !== 'undefined' && console.error) {
    console.error(
      '%c bemap-js-api dependency error %c\n\n' + msg.split('\n').slice(1).join('\n'),
      'background:#c0392b;color:#fff;padding:4px 10px;border-radius:3px;font-weight:bold;font-size:13px;',
      'color:#c0392b;'
    );
  }

  // Throw on the first missing critical (JS) dep so the stack points at
  // the constructor call. Missing CSS alone is a warning, no throw.
  if (missingJs.length) {
    var first = missingJs[0];
    throw new bemap.Error({
      code: 'MISSING_DEPENDENCY',
      message: 'bemap-js-api: ' + first.name + ' is required for bemap.' + engineLabel + 'Map. See console for the exact <script> tag to add.',
      context: { engine: engine, missing: missingJs.map(function (d) { return d.name; }) }
    });
  }
};

bemap.Map.EventType = {
  LOAD: 'load',
  CHANGE: 'change',
  CHANGE_SIZE: 'change:size',
  CHANGE_VIEW: 'change:view',
  RESIZE: 'resize',
  CLICK: 'click',
  SINGLECLICK: 'singleclick',
  DBLCLICK: 'dblclick',
  MOVESTART: 'movestart',
  MOVEEND: 'moveend',
  POINTERUP: 'pointerup',
  POINTERDOWN: 'pointerdown',
  POINTERDRAG: 'pointerdrag',
  POINTERMOVE: 'pointermove',
  POSTCOMPOSE: 'postcompose',
  POSTRENDER: 'postrender',
  PRECOMPOSE: 'precompose',
  PROPERTYCHANGE: 'propertychange',
  WHEEL: 'wheel',
  KEYDOWN: 'keydown',
  KEYPRESS: 'keypress',
  TOUCHSTART: 'touchstart',
  TOUCHMOVE: 'touchmove',
  TOUCHEND: 'touchend',
  DRAWSTART: 'drawstart',
  DRAWEND: 'drawend',
  DRAWABORT: 'drawabort'
};

/**
 * Add default layers.
 * @public
 * @param {object} options
 * @param {boolean} options.markerAsCluster
 * @return {bemap.Map} this
 */
bemap.Map.prototype.defaultLayers = function (options) {
  var opts = options || {};

  // Idempotent — calling defaultLayers() twice (or after defaultLayers()
  // was already called during construction) used to add a SECOND opaque
  // BACKGROUND tile layer on top of the existing overlay markers, hiding
  // them on OL. Skip when the BACKGROUND layer already exists.
  if (!this.getLayerByName(bemap.Map.DEFAULT_LAYER.BACKGROUND)) {
    this.addLayer(new bemap.BemapLayer({
      name: bemap.Map.DEFAULT_LAYER.BACKGROUND,
      styles: opts.styles ? opts.styles : ''
    }));
  }

  this.defaultOverlayLayers(opts);

  return this;
};

/**
 * Add default overlay layers, like dedicated for markers or polyline, etc.
 * @public
 * @param {object} options
 * @param {boolean} options.markerAsCluster
 * @return {bemap.Map} this
 */
bemap.Map.prototype.defaultOverlayLayers = function (options) {
  var opts = options || {};
  var self = this;

  // Idempotent — only add overlay layers that don't already exist.
  // Calling defaultLayers() or defaultOverlayLayers() multiple times used
  // to silently duplicate layers, which breaks getLayerByName lookups and
  // distorts the rendering stack.
  function ensure(name, factory) {
    if (!self.getLayerByName(name)) self.addLayer(factory());
  }

  ensure(bemap.Map.DEFAULT_LAYER.POLYGON,  function () { return new bemap.VectorLayer({ name: bemap.Map.DEFAULT_LAYER.POLYGON  }); });
  ensure(bemap.Map.DEFAULT_LAYER.CIRCLE,   function () { return new bemap.VectorLayer({ name: bemap.Map.DEFAULT_LAYER.CIRCLE   }); });
  ensure(bemap.Map.DEFAULT_LAYER.ROUTE,    function () { return new bemap.VectorLayer({ name: bemap.Map.DEFAULT_LAYER.ROUTE    }); });
  ensure(bemap.Map.DEFAULT_LAYER.POLYLINE, function () { return new bemap.VectorLayer({ name: bemap.Map.DEFAULT_LAYER.POLYLINE }); });
  ensure(bemap.Map.DEFAULT_LAYER.MARKER, function () {
    return opts.markerAsCluster
      ? new bemap.ClusterLayer({ name: bemap.Map.DEFAULT_LAYER.MARKER })
      : new bemap.VectorLayer({  name: bemap.Map.DEFAULT_LAYER.MARKER });
  });

  return this;
};

/**
 * Create a background layer for each geoserver name.
 * @public
 * @param {array} geoservers List of geoserver name.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.backgroundLayers = function (geoservers, options) {
  var opts = options ? options : {};
  for (var i = 0; i < geoservers.length; i++) {
    var geoserver = geoservers[i];
    var layer = new bemap.BemapLayer({
      name: bemap.Map.DEFAULT_LAYER.BACKGROUND + '_' + geoserver,
      styles: opts.styles ? opts.styles : '',
      geoserver: geoserver
    });

    this.addLayer(layer);

    if (i == 0) {
      layer.setVisible(true);
    } else {
      layer.setVisible(false);
    }
  }

  return this;
};

/**
 * Switch bytween several background layers.
 * @public
 * @param {String} geoserver Name of geoserver.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.switchBackgroundLayer = function (geoserver) {
  var filter = bemap.Map.DEFAULT_LAYER.BACKGROUND + '_' + geoserver;
  for (var j = 0; j < this.layers.length; j++) {
    var layer = this.layers[j];
    // An unnamed layer threw out of the loop, leaving the switch half-applied
    // — the wrong basemap on every other attempt (BEMAP-1905).
    if (!layer || !layer.name ||
        layer.name.indexOf(bemap.Map.DEFAULT_LAYER.BACKGROUND) != 0) {
      continue;
    }
    if (layer.name == filter) {
      layer.setVisible(true);
    } else {
      layer.setVisible(false);
    }
  }

  return this;
};

/**
 * Add a layer to the map
 * @public
 * @param {bemap.Layer} layer
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addLayer = function (layer, options) {
  if (layer !== null && bemap.inheritsof(layer, bemap.Layer)) {
    this.layers.push(layer);
  }
  return this;
};

/**
 * Search the layer by name.
 * @public
 * @param {String} name of layer.
 * @return {bemap.Layer} Return the layer or null if not found.
 */
bemap.Map.prototype.getLayerByName = function (name) {
  var key, l;
  for (key in this.layers) {
    l = this.layers[key];
    if (l.name == name) {
      return l;
    }
  }
  return null;
};

/**
 * 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.Map.prototype.isDragPan = function (options) {
  return false;
};

/**
 * Enable or disable 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.Map} this
 */
bemap.Map.prototype.setDragPan = function (active, options) {
  return this;
};


/**
 * Move map to new coordinate.
 * @public
 * @abstract
 * @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).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.move = function (lon, lat, zoom, options) {
  return this;
};

/**
 * Fly map to new coordinate.
 * @param {double} lon longitude
 * @param {double} lat latitude
 * @param {double} zoom zoom
 * @param {Object} options options
 */
bemap.Map.prototype.flyTo = function (lon, lat, zoom, options) {
  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.
 * @param {Object} options Options (optional).
 * @return {bemap.Map} this.
 */
bemap.Map.prototype.moveToBoundingBox = function (boundingBox, options) {
  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.Map.prototype.moveToLayerData = function (layer, options) {
  return this;
};

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

/**
 * Get current zoom of map.
 * @public
 * @return {int} -1 if not supported.
 */
bemap.Map.prototype.getZoom = function () {
  return -1;
};

/**
 * Rotation of map, set new angle of map.
 * @public
 * @abstract
 * @param {int} angle in degrees.
 * @param {Object} options Options (optional).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.rotation = function (angle, options) {
  return this;
};

/**
 * Get current rotation angle of map.
 * @public
 * @return {int} -1 if not supported.
 */
bemap.Map.prototype.getRotation = function () {
  return -1;
};

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

/**
 * Build icon resource.
 * @public
 * @abstract
 * @param {bemap.Icon} icon
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.buildIcon = function (icon, options) {
  return this;
};

/**
 * Add a marker to the layer
 * @public
 * @abstract
 * @param {bemap.Marker} marker
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addMarker = function (marker, options) {
  return this;
};

/**
 * Remove a marker from the layer
 * @public
 * @abstract
 * @param {bemap.Marker} marker
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removeMarker = function (marker) {
  return this;
};

/**
 * Add a multipoint to the layer
 * @public
 * @abstract
 * @param {bemap.MultiPoint} multipoint
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addMultiMarker = function (multipoint, options) {
  return this;
};

/**
 * Remove a multimarker from the layer
 * @public
 * @abstract
 * @param {bemap.MultiMarker} multimarker
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removeMultimarker = function (multimarker) {
  return this;
};

/**
 * Build LineStyle resource.
 * @public
 * @abstract
 * @param {bemap.LineStyle} LineStyle
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.buildLineStyle = function (style, options) {
  return this;
};

/**
 * Add a Polyline to the layer
 * @public
 * @abstract
 * @param {bemap.Polyline} Polyline
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addPolyline = function (multiline, options) {
  return this;
};

/**
 * Remove a polyline from the layer
 * @public
 * @abstract
 * @param {bemap.Polyline} polyline
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removePolyline = function (polyline) {
  return this;
};

/**
 * Refresh all objects from a layer.
 * @public
 * @param {bemap.Layer} layer the layer object to refresh.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.refreshLayer = function (layer) {
  return this;
};

/**
 * Remove all objects from a layer.
 * @public
 * @abstract
 * @param {bemap.Layer} layer the layer object to clear.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.clearLayer = function (layer) {
  return this;
};

/**
 * Set the visibility of the layer.
 * @public
 * @abstract
 * @param {bemap.Layer} layer the layer of wich to set the visibility.
 * @param {boolean} visible.
 * @return {bemap.Map} return this.
 */
bemap.Map.prototype.visibleLayer = function (layer, visible) {
  return this;
};

/**
 * Set the zIndex of the layer.
 * @public
 * @abstract
 * @param {bemap.Layer} layer the layer of wich to set the zIndex.
 * @param {boolean} zIndex.
 * @return {bemap.Map} return this.
 */
bemap.Map.prototype.zIndexLayer = function (layer, zIndex) {
  return this;
};

/**
 * Remove a layer from the map.
 * @public
 * @abstract
 * @param {bemap.Layer} layer layer to remove from the map.
 * @return {bemap.Map} this.
 */
bemap.Map.prototype.removeLayer = function (layer) {
  return this;
};

/**
 * Set the listner when an specified eventType occur on bemap.Map.
 * @public
 * @abstract
 * @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} this.
 */
bemap.Map.prototype.on = function (eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * Set the listner when an specified eventType occur on bemap.Marker.
 * @public
 * @abstract
 * @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.
 * @return {bemap.Listener} this.
 */
bemap.Map.prototype.onMarker = function (marker, eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * Set the listner when an specified eventType occur on all bemap.Marker.
 * @public
 * @abstract
 * @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} this.
 */
bemap.Map.prototype.onMarkers = function (eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * Set the listner when an specified eventType occur on all bemap.MultiMarker.
 * @public
 * @abstract
 * @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} this.
 */
bemap.Map.prototype.onMultiMarkers = function (eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * Set the listner when an specified eventType occur on bemap.Polyline.
 * @public
 * @abstract
 * @param {bemap.Polyline} polyline
 * @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} this.
 */
bemap.Map.prototype.onPolyline = function (polyline, eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * Set the listner when an specified eventType occur on all bemap.Polyline.
 * @public
 * @abstract
 * @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} this.
 */
bemap.Map.prototype.onPolylines = function (eventType, callback, options) {
  return new bemap.Listener();
};

/**
 * 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.
 * @return {bemap.Listener} listener;
 */
bemap.Map.prototype.onGetFeatureInfo = function (layer, options) {
  console.warn("onGetFeatureInfo is not supported by this browser");
  return new bemap.Listener();
};

/**
 * Define the draggable capability for bemap.Marker.
 * @protected
 * @param {bemap.Marker} marker bemap object.
 * @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.
 * @return {bemap.Listener} bemap.listener.
 */
bemap.Map.prototype.draggableMarker = function (marker, callback, options) {
  return new bemap.Listener();
};

/**
 * Define the draggable capability for all bemap.Marker.
 * @protected
 * @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.
 * @return {bemap.Listener} bemap.listener.
 */
bemap.Map.prototype.draggableMarkers = function (callback, options) {
  return new bemap.Listener();
};

/**
 * Define the draggable capability for all bemap.MultiMarkers.
 * @protected
 * @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.
 * @return {bemap.Listener} bemap.listener.
 */
bemap.Map.prototype.draggableMultiMarkers = function (callback, options) {
  return new bemap.Listener();
};

/**
 * Define the draggable capability for bemap.Polyline.
 * @protected
 * @param {bemap.Polyline} polyline bemap object.
 * @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.
 * @return {bemap.Listener} bemap.listener.
 */
bemap.Map.prototype.draggablePolyline = function (polyline, callback, options) {
  return new bemap.Listener();
};

/**
 * Define the draggable capability for all bemap.Polyline.
 * @protected
 * @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.
 * @return {bemap.Listener} bemap.listener.
 */
bemap.Map.prototype.draggablePolylines = function (callback, options) {
  return new bemap.Listener();
};

/**
 * Add a popup to the map
 * @public
 * @param {bemap.Popup} popup
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addPopup = function (popup, options) {
  return this;
};

/**
 * Remove a popup from the map.
 * @public
 * @param {bemap.Popup} popup the popup to remove from the map.
 * @return {bemap.Map} this;
 */
bemap.Map.prototype.removePopup = function (popup) {
  return this;
};

/**
 * Set the visiblility of the popup.
 * @param {bemap.Popup} popup the popup.
 * @param {Boolean} visible true for visible and false for hidden.
 * @return {bemap.Popup} this.
 */
bemap.Map.prototype.setVisiblePopup = function (popup, visible) {
  return this;
};

/**
 * Remove all the popups from the map.
 * @return {bemap.Map} this;
 */
bemap.Map.prototype.clearPopup = function () {
  return this;
};

/**
 * Set the coordinate of the popup.
 * @param {bemap.Popup} popup the popup of wich to set the coordinate.
 * @param {bemap.Coordinate} coordinate the new coordinate.
 * @param {object} options Options.
 * @param {bemap.Layer} options.panningMap enable the map panning animation. move map from the current position to the popup anchor at the center of map.
 * @return {bemap.Map} this.
 */
bemap.Map.prototype.setCoordinatePopup = function (popup, coordinate, options) {
  return this;
};

/**
 * Get the center of the map in bemap.Coordinate.
 * @return {bemap.Coordinate} the center of the map.
 */
bemap.Map.prototype.getCenter = function () {
  return new bemap.Coordinate();
};

/**
 * Get the limits of the map on the current zoom.
 * @return {bemap.BoundingBox} the bounding box containing the limits.
 */
bemap.Map.prototype.getBoundingBox = function () {
  return new bemap.BoundingBox();
};

// --- MapLibre-exclusive features (no-op defaults for OlMap/LeafletMap) ---

/**
 * Set the pitch (tilt) angle of the map in degrees. MapLibre only.
 * @param {number} pitch Pitch angle (0-85).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setPitch = function (pitch) { return this; };

/**
 * Get the current pitch angle. MapLibre only.
 * @return {number} pitch in degrees (0 if not supported).
 */
bemap.Map.prototype.getPitch = function () { return 0; };

/**
 * Set the bearing (compass rotation) of the map. MapLibre only.
 * @param {number} bearing Bearing in degrees (0-360).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setBearing = function (bearing) { return this; };

/**
 * Get the current bearing. MapLibre only.
 * @return {number} bearing in degrees (0 if not supported).
 */
bemap.Map.prototype.getBearing = function () { return 0; };

/**
 * Enable 3D terrain rendering. MapLibre only.
 * @param {object} options terrain options (source, exaggeration).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setTerrain = function (options) { return this; };

/**
 * Disable 3D terrain. MapLibre only.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removeTerrain = function () { return this; };

/**
 * Set map projection ('mercator' or 'globe'). MapLibre only.
 * @param {string} type projection type.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setProjection = function (type) { return this; };

/**
 * Set sky/atmosphere effects. MapLibre only.
 * @param {object} options sky options.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setSky = function (options) { return this; };

/**
 * Set the map style URL or object. MapLibre only.
 * @param {string|object} style Style URL or JSON object.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setStyle = function (style) { return this; };

/**
 * Add a GeoJSON data source. MapLibre only.
 * @param {string} id Source identifier.
 * @param {object} data GeoJSON data.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addGeoJsonSource = function (id, data) { return this; };

/**
 * Update an existing GeoJSON source data. MapLibre only.
 * @param {string} id Source identifier.
 * @param {object} data New GeoJSON data.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.updateGeoJsonSource = function (id, data) { return this; };

/**
 * Register a custom image for use in symbol layers. MapLibre only.
 * @param {string} id Image identifier.
 * @param {object} image Image data.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addImage = function (id, image) { return this; };

/**
 * Remove a registered image. MapLibre only.
 * @param {string} id Image identifier.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removeImage = function (id) { return this; };

/**
 * Instant camera change with pitch/bearing support. MapLibre only.
 * @param {object} options center, zoom, pitch, bearing.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.jumpTo = function (options) { return this; };

/**
 * Smooth animated camera transition. MapLibre only.
 * @param {object} options center, zoom, pitch, bearing, duration.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.easeTo = function (options) { return this; };

/**
 * Query rendered features at a point or in a box. MapLibre only.
 * @param {object} point Screen point or bounding box.
 * @param {object} options Query options (layers, filter).
 * @return {Array} Array of feature objects.
 */
bemap.Map.prototype.queryRenderedFeatures = function (point, options) { return []; };

/**
 * Add polygon to the map.
 * @param {bemap.Polygon} polygon
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addPolygon = function (polygon, options) { return this; };

/**
 * Remove polygon from the map.
 * @param {bemap.Polygon} polygon
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removePolygon = function (polygon) { return this; };

/**
 * Add circle to the map.
 * @param {bemap.Circle} circle
 * @param {object} options
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addCircle = function (circle, options) { return this; };

/**
 * Remove circle from the map.
 * @param {bemap.Circle} circle
 * @return {bemap.Map} this
 */
bemap.Map.prototype.removeCircle = function (circle) { return this; };

// --- Advanced MapLibre features (no-ops for OlMap/LeafletMap) ---

/**
 * Add 3D extruded buildings. MapLibre only.
 * @param {object} options Building configuration.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.add3DBuildings = function (options) { this._maplibreOnly('add3DBuildings'); return this; };
bemap.Map.prototype.remove3DBuildings = function () { this._maplibreOnly('remove3DBuildings'); return this; };
bemap.Map.prototype.addHeatmap = function (layer) { this._maplibreOnly('addHeatmap'); return this; };
bemap.Map.prototype.removeHeatmap = function (layer) { this._maplibreOnly('removeHeatmap'); return this; };
bemap.Map.prototype.updateHeatmap = function (layer, data) { this._maplibreOnly('updateHeatmap'); return this; };
bemap.Map.prototype.animateAlongRoute = function (options) { this._maplibreOnly('animateAlongRoute'); return { stop: function(){}, resume: function(){} }; };

/**
 * Set 3D light position and properties. MapLibre only.
 * @param {object} options Light options (position, color, intensity, anchor).
 * @return {bemap.Map} this
 */
bemap.Map.prototype.setLight = function (options) { this._maplibreOnly('setLight'); return this; };
bemap.Map.prototype.loadPMTiles = function (layer, callback) { this._maplibreOnly('loadPMTiles'); return this; };
bemap.Map.prototype.loadBeMapTiles = function (options) { this._maplibreOnly('loadBeMapTiles'); return this; };

/**
 * Draw a polygon interactively. User clicks vertices, double-clicks to finish.
 * @public
 * @param {Object} options See below.
 * @param {bemap.PolygonStyle} options.style Style for the preview polygon.
 * @param {function} callback Called with bemap.MapEvent when drawing is complete. evt.bemapObject = bemap.Polygon.
 * @return {bemap.Listener}
 */
bemap.Map.prototype.drawPolygon = function (options, callback) { return new bemap.Listener(); };
bemap.Map.prototype.drawPolyline = function (options, callback) { return new bemap.Listener(); };
bemap.Map.prototype.drawRectangle = function (options, callback) { return new bemap.Listener(); };
bemap.Map.prototype.drawCircle = function (options, callback) { return new bemap.Listener(); };
bemap.Map.prototype.drawMarker = function (options, callback) { return new bemap.Listener(); };
bemap.Map.prototype.cancelDraw = function () { return this; };
bemap.Map.prototype.editPolygon = function (polygon, callback) { return new bemap.Listener(); };

/**
 * Add a drawing toolbar to the map.
 * @public
 * @param {Object} options See below.
 * @param {boolean} options.polygon Enable polygon drawing (default: true).
 * @param {boolean} options.polyline Enable polyline drawing (default: false).
 * @param {boolean} options.rectangle Enable rectangle drawing (default: false).
 * @param {boolean} options.circle Enable circle drawing (default: false).
 * @param {boolean} options.marker Enable marker drawing (default: false).
 * @param {String} options.position Toolbar position: 'topright','topleft','bottomright','bottomleft' (default: 'topright').
 * @param {function} options.onDrawEnd Callback when a shape is drawn.
 * @return {bemap.Map} this
 */
bemap.Map.prototype.addDrawControl = function(options) {
  var opts = options || {};
  var _this = this;

  // Get the map container
  var container = typeof this.target === 'string' ? document.getElementById(this.target) : this.target;
  if (!container) return this;

  // Remove existing toolbar
  if (this._drawToolbar) {
    this._drawToolbar.parentNode.removeChild(this._drawToolbar);
    this._drawToolbar = null;
  }

  var pos = opts.position || 'topright';
  var toolbar = document.createElement('div');
  toolbar.className = 'bemap-draw-toolbar bemap-draw-' + pos;
  toolbar.style.cssText = 'position:absolute;z-index:1000;display:flex;flex-direction:column;gap:2px;padding:4px;background:rgba(255,255,255,0.95);border-radius:4px;box-shadow:0 2px 6px rgba(0,0,0,0.3);';

  if (pos.indexOf('top') > -1) toolbar.style.top = '10px';
  else toolbar.style.bottom = '10px';
  if (pos.indexOf('right') > -1) toolbar.style.right = '10px';
  else toolbar.style.left = '10px';

  // Track drawn shapes so the Clear button can remove them
  if (!this._drawnShapes) this._drawnShapes = [];
  var _self = this;
  var userCallback = opts.onDrawEnd || function() {};
  var drawCallback = function(evt) {
    if (evt && evt.bemapObject && _self._drawnShapes.indexOf(evt.bemapObject) === -1) {
      _self._drawnShapes.push(evt.bemapObject);
    }
    userCallback(evt);
  };

  var btnStyle = 'border:none;padding:6px 10px;border-radius:3px;cursor:pointer;font-size:12px;font-weight:600;display:flex;align-items:center;gap:4px;';

  // Helper: prevents button click from bubbling to the map
  var stopBubble = function(handler) {
    return function(evt) {
      if (evt) { evt.stopPropagation(); evt.preventDefault(); }
      // Delay so the map click doesn't fire in the same cycle
      setTimeout(handler, 0);
    };
  };

  if (opts.polygon !== false) {
    var btnPoly = document.createElement('button');
    btnPoly.innerHTML = '&#9650; Polygon';
    btnPoly.style.cssText = btnStyle + 'background:#27ae60;color:#fff;';
    btnPoly.title = 'Draw polygon';
    btnPoly.onclick = stopBubble(function() { _this.drawPolygon(opts, drawCallback); });
    toolbar.appendChild(btnPoly);
  }

  if (opts.polyline) {
    var btnLine = document.createElement('button');
    btnLine.innerHTML = '&#9135; Line';
    btnLine.style.cssText = btnStyle + 'background:#3498db;color:#fff;';
    btnLine.title = 'Draw polyline';
    btnLine.onclick = stopBubble(function() { _this.drawPolyline(opts, drawCallback); });
    toolbar.appendChild(btnLine);
  }

  if (opts.rectangle) {
    var btnRect = document.createElement('button');
    btnRect.innerHTML = '&#9633; Rectangle';
    btnRect.style.cssText = btnStyle + 'background:#9b59b6;color:#fff;';
    btnRect.title = 'Draw rectangle';
    btnRect.onclick = stopBubble(function() { _this.drawRectangle(opts, drawCallback); });
    toolbar.appendChild(btnRect);
  }

  if (opts.circle) {
    var btnCirc = document.createElement('button');
    btnCirc.innerHTML = '&#9675; Circle';
    btnCirc.style.cssText = btnStyle + 'background:#e67e22;color:#fff;';
    btnCirc.title = 'Draw circle';
    btnCirc.onclick = stopBubble(function() { _this.drawCircle(opts, drawCallback); });
    toolbar.appendChild(btnCirc);
  }

  if (opts.marker) {
    var btnMark = document.createElement('button');
    btnMark.innerHTML = '&#128204; Marker';
    btnMark.style.cssText = btnStyle + 'background:#e74c3c;color:#fff;';
    btnMark.title = 'Place marker';
    btnMark.onclick = stopBubble(function() { _this.drawMarker(opts, drawCallback); });
    toolbar.appendChild(btnMark);
  }

  // Clear button — removes all drawn shapes and cancels current draw
  var btnClear = document.createElement('button');
  btnClear.innerHTML = '&#128465; Clear';
  btnClear.style.cssText = btnStyle + 'background:#95a5a6;color:#fff;';
  btnClear.title = 'Clear all drawn shapes';
  btnClear.onclick = stopBubble(function() {
    _this.cancelDraw();
    if (_this._drawnShapes) {
      _this._drawnShapes.forEach(function(shape) {
        try {
          if (bemap.inheritsof(shape, bemap.Polygon)) _this.removePolygon(shape);
          else if (bemap.inheritsof(shape, bemap.Polyline)) _this.removePolyline(shape);
          else if (bemap.inheritsof(shape, bemap.Circle)) _this.removeCircle(shape);
          else if (bemap.inheritsof(shape, bemap.Marker)) _this.removeMarker(shape);
        } catch(e) {}
      });
      _this._drawnShapes = [];
    }
  });
  toolbar.appendChild(btnClear);

  container.style.position = 'relative';
  container.appendChild(toolbar);
  this._drawToolbar = toolbar;
  return this;
};
bemap.Map.prototype.addRasterLayer = function (layer) { this._maplibreOnly('addRasterLayer'); return this; };
bemap.Map.prototype.addClusterPoints = function (layer, points, options) { this._maplibreOnly('addClusterPoints'); return this; };
bemap.Map.prototype.animateLine = function (options) { this._maplibreOnly('animateLine'); return { stop: function(){}, resume: function(){} }; };
bemap.Map.prototype.animateCameraOrbit = function (options) { this._maplibreOnly('animateCameraOrbit'); return { stop: function(){}, resume: function(){} }; };
bemap.Map.prototype.animatePulse = function (options) { this._maplibreOnly('animatePulse'); return { stop: function(){}, remove: function(){} }; };
bemap.Map.prototype.spinGlobe = function (options) { this._maplibreOnly('spinGlobe'); return { stop: function(){}, resume: function(){} }; };

/* ============================================================
 * MapLibre-only declarations — warn-and-no-op fallback (v2.0)
 *
 * `_maplibreOnly(methodName)` warns exactly once per method per
 * instance and fires a `bemap.Error` with code `MAPLIBRE_ONLY`
 * on the map's error channel.  Listeners can subscribe via
 * `map.on('error', fn)` or the filtered channel
 * `map.on('error:maplibre-only', fn)`.
 * ============================================================ */

/**
 * Report that the ACTIVE engine does not implement `methodName`, then no-op.
 *
 * The base class is a hand-maintained stub list, and it was only half populated
 * (BEMAP-1898 audit). The same defect — an engine not implementing an overlay
 * method — therefore failed two different ways: silently (`return this` for the
 * names that were listed) or with `TypeError: … is not a function` (for the ones
 * that were not). Neither told the developer anything useful, and the silent half
 * is how several Leaflet gaps shipped unnoticed.
 *
 * Every previously-absent name now lands here: one console.warn per method per
 * map, plus a `bemap.Error` on the map's error channel, then a harmless no-op.
 * A TypeError becomes a visible warning; a silent nothing becomes a visible
 * warning. Nothing that already worked changes.
 * @protected
 * @since 2.0.2
 * @param {String} methodName
 * @return {bemap.Map} this
 */
bemap.Map.prototype._notSupportedByEngine = function (methodName) {
  // `this.constructor.name` is NOT usable here: `bemap.OlMap = function(){}` is a
  // member-expression assignment, which gets no name inference, and uglify
  // mangles what little there is. Resolve by instanceof against whichever
  // engines were actually loaded.
  var engine = 'this map engine';
  if (typeof bemap.LeafletMap === 'function' && this instanceof bemap.LeafletMap) engine = 'bemap.LeafletMap';
  else if (typeof bemap.MapLibreMap === 'function' && this instanceof bemap.MapLibreMap) engine = 'bemap.MapLibreMap';
  else if (typeof bemap.OlMap === 'function' && this instanceof bemap.OlMap) engine = 'bemap.OlMap';
  if (!this._unsupportedWarned) this._unsupportedWarned = {};
  if (!this._unsupportedWarned[methodName]) {
    this._unsupportedWarned[methodName] = true;
    if (typeof console !== 'undefined' && console.warn) {
      console.warn('bemap: ' + methodName + '() is not implemented by ' + engine +
        ' — the call did nothing. Use another engine for this feature, or file it as a gap.');
    }
  }
  if (typeof bemap.Error === 'function' && typeof this._fireError === 'function') {
    this._fireError(new bemap.Error({
      code: bemap.Error.INVALID_ARGUMENT,
      message: methodName + '() is not implemented by ' + engine,
      context: { method: methodName, engine: engine }
    }));
  }
  return this;
};

/**
 * Overlay methods that some engines implement and others do not. Declaring the
 * surface in ONE place is the point: previously nothing in the codebase knew
 * which methods were supposed to exist, so a gap was undetectable. The
 * cross-engine parity spec (src-test/test-engine-parity.js) reads this list.
 * @protected
 * @since 2.0.2
 */
bemap.Map.ENGINE_SURFACE = [
  'onPolygons', 'onCircles', 'onMultiMarker',
  'draggableCircles', 'draggablePolygons',
  'setCoordinateMarker', 'setCoordinateCircle', 'setRadiusCircle',
  'updateCircleCenter', 'updatePolygonCoordinates',
  'buildTextStyle', 'buildPolygonStyle', 'buildCircleStyle',
  'getXYFromCoordinate'
];

/* Install a warn-and-no-op fallback for every name in ENGINE_SURFACE that the
   base class did not define. Engines that DO implement a name override it, so
   this only ever runs where the alternative was a TypeError. */
(function () {
  var names = bemap.Map.ENGINE_SURFACE;
  for (var i = 0; i < names.length; i++) {
    var name = names[i];
    if (typeof bemap.Map.prototype[name] !== 'function') {
      /* jshint -W083 */
      bemap.Map.prototype[name] = (function (methodName) {
        return function () {
          return this._notSupportedByEngine(methodName);
        };
      })(name);
    }
  }
})();

bemap.Map.prototype._maplibreOnly = function (methodName) {
  if (!this._maplibreOnlyWarned) this._maplibreOnlyWarned = {};
  if (!this._maplibreOnlyWarned[methodName]) {
    this._maplibreOnlyWarned[methodName] = true;
    console.warn(methodName + '() is only available with bemap.MapLibreMap');
  }
  if (typeof bemap.Error === 'function') {
    var err = new bemap.Error({
      code: bemap.Error.MAPLIBRE_ONLY,
      message: methodName + '() is only available with bemap.MapLibreMap',
      context: { method: methodName }
    });
    if (typeof this._fireError === 'function') {
      this._fireError(err);
    }
  }
};

/**
 * Fire an error onto the map's error channel(s). Used by the warn-and-no-op
 * fallback and by the new auth/tile/cache error paths.
 * @protected
 * @since 2.0.0
 * @param {bemap.Error} err
 */
bemap.Map.prototype._fireError = function (err) {
  if (!err) return;
  this._errorListeners = this._errorListeners || { all: [], auth: [], network: [], maplibreOnly: [] };
  var bucket = this._errorListeners;
  var fire = function (list) {
    for (var i = 0; i < list.length; i++) {
      try { list[i](err); } catch (e) { /* swallow listener errors */ }
    }
  };
  fire(bucket.all);
  var code = err.code || (err.getCode && err.getCode());
  if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN') fire(bucket.auth);
  else if (code === 'NETWORK' || code === 'OFFLINE' || code === 'RATE_LIMITED' || code === 'TILE_LOAD_FAILED' || code === 'STYLE_LOAD_FAILED') fire(bucket.network);
  else if (code === 'MAPLIBRE_ONLY') fire(bucket.maplibreOnly);
};

/**
 * Subscribe to the map's error channel. Accepts the unfiltered channel
 * `'error'` and three filtered channels:
 *   - `'error:auth'`         — `UNAUTHORIZED` / `FORBIDDEN`
 *   - `'error:network'`      — `NETWORK` / `OFFLINE` / `RATE_LIMITED` / `TILE_LOAD_FAILED` / `STYLE_LOAD_FAILED`
 *   - `'error:maplibre-only'`— `MAPLIBRE_ONLY`
 *
 * The library version of `on()` calls this when the event name starts with
 * `'error'`; for other event names it delegates to the engine-specific
 * implementation (see `LeafletMap.on`, `OlMap.on`, `MapLibreMap.on`).
 *
 * @public
 * @since 2.0.0
 */
/**
 * Wire `on('error' | 'error:auth' | 'error:network' | 'error:maplibre-only')`
 * through to the error channel, and report whether it did.
 *
 * The class doc for `_onError` has always claimed "the library version of `on()`
 * calls this when the event name starts with 'error'" — but the base `on()` is an
 * abstract `return new bemap.Listener()`, and all three engines override it and
 * route straight to `_onFeature` / the native map. So the DOCUMENTED way to
 * observe auth, tile and network failures silently subscribed to nothing on
 * every engine (BEMAP-1898 audit).
 *
 * Deliberately ADDITIVE: each engine calls this first and then continues with
 * whatever it did before, so a consumer relying on an engine's native `'error'`
 * event (MapLibre fires one) keeps receiving it — it now simply also receives the
 * `bemap.Error` objects that `_fireError` produces.
 * @protected
 * @since 2.0.2
 * @param {String} eventType
 * @param {Function} callback
 * @return {Boolean} true when the error channel was subscribed
 */
bemap.Map.prototype._subscribeErrorChannel = function (eventType, callback) {
  if (typeof eventType === 'string' && eventType.indexOf('error') === 0 &&
      typeof callback === 'function') {
    this._onError(eventType, callback);
    return true;
  }
  return false;
};

bemap.Map.prototype._onError = function (channel, callback) {
  if (typeof callback !== 'function') return;
  this._errorListeners = this._errorListeners || { all: [], auth: [], network: [], maplibreOnly: [] };
  if (channel === 'error') this._errorListeners.all.push(callback);
  else if (channel === 'error:auth') this._errorListeners.auth.push(callback);
  else if (channel === 'error:network') this._errorListeners.network.push(callback);
  else if (channel === 'error:maplibre-only') this._errorListeners.maplibreOnly.push(callback);
};

bemap.Map.prototype._offError = function (channel, callback) {
  if (!this._errorListeners) return;
  var key = channel === 'error' ? 'all'
    : channel === 'error:auth' ? 'auth'
    : channel === 'error:network' ? 'network'
    : channel === 'error:maplibre-only' ? 'maplibreOnly'
    : null;
  if (!key) return;
  var arr = this._errorListeners[key];
  for (var i = arr.length - 1; i >= 0; i--) {
    if (!callback || arr[i] === callback) arr.splice(i, 1);
  }
};

/* --- Style / paint / layer mutation (MapLibre-only) --- */
bemap.Map.prototype.setPaintProperty = function (layerId, name, value) { this._maplibreOnly('setPaintProperty'); return this; };
bemap.Map.prototype.setLayoutProperty = function (layerId, name, value) { this._maplibreOnly('setLayoutProperty'); return this; };
bemap.Map.prototype.setFilter = function (layerId, filter) { this._maplibreOnly('setFilter'); return this; };
bemap.Map.prototype.setLayerZoomRange = function (layerId, minzoom, maxzoom) { this._maplibreOnly('setLayerZoomRange'); return this; };

/* --- Globe spinner (MapLibre-only) --- */
bemap.Map.prototype.stopSpinGlobe = function () { this._maplibreOnly('stopSpinGlobe'); return this; };

/* --- BeNomad Tiles token introspection (MapLibre-only) --- */
bemap.Map.prototype.getToken = function () { this._maplibreOnly('getToken'); return null; };
bemap.Map.prototype.isTokenValid = function () { this._maplibreOnly('isTokenValid'); return false; };
bemap.Map.prototype.refreshToken = function () { this._maplibreOnly('refreshToken'); return Promise.resolve(null); };

/* --- Cross-engine hillshade + camera tour (v2.0) — base fallback ---
   Real implementations live on each engine class. The fallback below
   only fires when neither MapLibreMap, LeafletMap nor OlMap overrode
   the method — should not happen in practice. */
bemap.Map.prototype.setHillshade = function () { this._maplibreOnly('setHillshade'); return this; };
bemap.Map.prototype.removeHillshade = function () { this._maplibreOnly('removeHillshade'); return this; };
bemap.Map.prototype.cameraTour = function () { this._maplibreOnly('cameraTour'); return { stop: function () {}, resume: function () {} }; };

/* --- Browser cache (MapLibre-only) --- */
bemap.Map.prototype.enableBrowserCache = function () { this._maplibreOnly('enableBrowserCache'); return this; };
bemap.Map.prototype.disableBrowserCache = function () { this._maplibreOnly('disableBrowserCache'); return this; };
bemap.Map.prototype.getBrowserCacheStats = function () { this._maplibreOnly('getBrowserCacheStats'); return { enabled: false, hits: 0, misses: 0, entries: 0, bytesEstimated: 0 }; };
bemap.Map.prototype.clearBrowserCache = function () { this._maplibreOnly('clearBrowserCache'); return this; };

/* ============================================================
 * Active-effects registry (v2.0)
 *
 * Methods like add3DBuildings / setTerrain / spinGlobe / setSky /
 * addHeatmap / animateAlongRoute / setStyle leave the map in a
 * non-default state. The dashboard's active-effects bar needs to
 * know what's active AND be able to tear it down with one button.
 *
 * Public API:
 *   map.getActiveEffects(): string[]
 *   map.clearAllEffects(): bemap.Map
 *   map.on('bemap:effect:change', fn)   // fn receives { name, active }
 *
 * Internal API (called by add* / set* methods):
 *   map._registerEffect(name, teardownFn)
 *   map._unregisterEffect(name)
 *
 * `_effects` is `{ name → teardownFn }`. Re-registering the same
 * name replaces the previous teardown. Iframe dashboards listen to
 * `bemap:effect:change` for live updates.
 *
 * Event channel name is namespaced (`bemap:effect:change`) to avoid
 * collision with the existing `'change'` event in
 * MapLibreMap_eventMap.
 * ============================================================ */

bemap.Map.prototype._registerEffect = function (name, teardownFn) {
  if (!name || typeof teardownFn !== 'function') return;
  if (!this._effects) this._effects = {};
  this._effects[name] = teardownFn;
  this._fireEffectChange(name, true);
};

bemap.Map.prototype._unregisterEffect = function (name) {
  if (!name || !this._effects || !this._effects[name]) return;
  delete this._effects[name];
  this._fireEffectChange(name, false);
};

bemap.Map.prototype._fireEffectChange = function (name, active) {
  if (!this._effectListeners) return;
  for (var i = 0; i < this._effectListeners.length; i++) {
    try { this._effectListeners[i]({ name: name, active: active }); } catch (e) { /* swallow */ }
  }
};

/**
 * Subscribe to active-effect changes. Fires `{name, active}` whenever
 * an effect is registered (`active: true`) or torn down (`active:
 * false`). The dashboard's active-effects bar consumes this.
 * @public
 * @since 2.0.0
 */
bemap.Map.prototype.onEffectChange = function (callback) {
  if (typeof callback !== 'function') return;
  this._effectListeners = this._effectListeners || [];
  this._effectListeners.push(callback);
};

bemap.Map.prototype.offEffectChange = function (callback) {
  if (!this._effectListeners) return;
  for (var i = this._effectListeners.length - 1; i >= 0; i--) {
    if (!callback || this._effectListeners[i] === callback) this._effectListeners.splice(i, 1);
  }
};

/**
 * Names of every currently-active effect. Snapshot — does not auto-
 * update; subscribe via `onEffectChange` for live notification.
 * @public
 * @since 2.0.0
 * @return {Array<String>}
 */
bemap.Map.prototype.getActiveEffects = function () {
  if (!this._effects) return [];
  var out = [];
  for (var k in this._effects) {
    if (Object.prototype.hasOwnProperty.call(this._effects, k)) out.push(k);
  }
  return out;
};

/**
 * Tear down every registered effect. Invokes each teardown in
 * registration order, swallowing errors per-teardown so one failure
 * doesn't block the rest. Each teardown is expected to call
 * `_unregisterEffect` itself via the matching `remove*`/`stop*` path;
 * the registry is cleared at the end regardless.
 * @public
 * @since 2.0.0
 * @return {bemap.Map}
 */
bemap.Map.prototype.clearAllEffects = function () {
  if (!this._effects) return this;
  var names = this.getActiveEffects();
  for (var i = 0; i < names.length; i++) {
    var teardown = this._effects[names[i]];
    if (typeof teardown === 'function') {
      try { teardown.call(this); } catch (e) { /* keep going */ }
    }
  }
  // Belt-and-braces: ensure the registry is empty even if a teardown
  // forgot to call _unregisterEffect. Fire one change event per name
  // that was still present.
  var leftover = this.getActiveEffects();
  this._effects = {};
  for (var j = 0; j < leftover.length; j++) this._fireEffectChange(leftover[j], false);
  return this;
};

/**
 * Emit a one-time `info`-level error on the map's error channel when
 * a `setLight` / `setSky` / etc. is called on a non-globe projection
 * where the effect won't be visible. NOT auto-enabling globe — too
 * surprising for a public API. The customer's error subscriber sees
 * the hint; dev console gets a one-time `console.info`.
 * @protected
 * @since 2.0.0
 */
bemap.Map.prototype._requiresGlobe = function (methodName) {
  // Best-effort: if `getProjection` exists and returns 'globe', no-op.
  try {
    if (typeof this.getProjection === 'function') {
      var p = this.getProjection();
      if (p && (p === 'globe' || p.name === 'globe')) return;
    } else if (this.native && typeof this.native.getProjection === 'function') {
      var nat = this.native.getProjection();
      if (nat && (nat.name === 'globe' || nat.type === 'globe')) return;
    }
  } catch (e) { /* projection check failed — fall through */ }
  this._requiresGlobeWarned = this._requiresGlobeWarned || {};
  if (this._requiresGlobeWarned[methodName]) return;
  this._requiresGlobeWarned[methodName] = true;
  console.info(methodName + '() has no visible effect on the mercator projection. Call map.setProjection("globe") first to see it.');
  if (typeof bemap.Error === 'function' && typeof this._fireError === 'function') {
    this._fireError(new bemap.Error({
      code: 'REQUIRES_GLOBE',
      message: methodName + '() has no visible effect outside the globe projection.',
      context: { method: methodName }
    }));
  }
};