Source: bemap.js

/**
 * BeNomad BeMap JavaScript API
 * @projectname BeNomad BeMap JavaScript API
 */

var bemap = bemap || {};

bemap.version = '2.0.4';
bemap.olVersion = '10.8.0';
bemap.maplibreVersion = '5.24.0';

/**
 * Extends the class declaration with the parent class.
 * @param {Class} child
 * @param {Class} parent
 */
bemap.inherits = function (child, parent) {
  child.prototype = Object.create(parent.prototype);
  child.prototype.constructor = child;
};

/**
 * Test if the object is inherits of the class (type).
 * @param {Object} obj The object.
 * @param {Class} type The inherits class model.
 * @return {boolean}
 */
bemap.inheritsof = function (obj, type) {
  // return obj.prototype.isPrototypeOf(type);
  return (obj instanceof type);
};

/**
 * Fill all fields of target object with source object.
 * Only the feilds of target class model are filled.
 * @param {Object} source Source object.
 * @param {Object} target Target object.
 */
bemap.fillFields = function (source, target) {
  for (var key in target) {
    var value = source[key];
    if (value) {
      target[key] = value;
    }
  }
};

/**
 * Get absolute path of current script.
 * @return {String} Retun absolute path of current script.
 */
bemap._getCurrentPath_cache = null;
bemap.getCurrentPath = function () {
  if (bemap._getCurrentPath_cache === null) {
    var scripts = document.getElementsByTagName('script');
    var path = scripts[scripts.length - 1].src.split('?')[0];
    bemap._getCurrentPath_cache = path.split('/').slice(0, -1).join('/') + '/';
  }
  return bemap._getCurrentPath_cache;
};

/**
 * Perform a Ajax request.
 * @param {String} method HTTP method (GET, POST).
 * @param {String} url URL of request.
 * @param {String|Object} data POST data of request, can by a string or javascript object. The javascript object will be converted in JSON string.
 * @param {String} success Callback if the request is executed with success.
 * @param {String} faild Callback if the request was failled.
 * @param {Object}options Options object.*
 * @param {Object} options.headers List of HTTP headers.
 * @param {Object} options.requestFormat mime type of predefined value, json, xml or urlencoded.
 */
bemap.ajax = function (method, url, data, success, failled, options) {
  var opt = options || {};
  var xhr = new XMLHttpRequest();
  var called = false;

  xhr.open(method, url, true);

  if (data instanceof Object && !(data instanceof FormData)) {
    data = JSON.stringify(data);
    if (!opt.requestFormat) {
      opt.requestFormat = "json";
    }
  }

  if (opt.responseType) {
    xhr.responseType = opt.responseType;
  }

  if (opt.requestFormat) {
    if (!opt.headers) {
      opt.headers = [];
    }

    if (opt.requestFormat == 'json') {
      opt.headers.push({ "key": "Content-Type", "value": "application/json" });
    } else if (opt.requestFormat == 'xml') {
      opt.headers.push({ "key": "Content-Type", "value": "text/xml" });
    } else if (opt.requestFormat == 'urlencoded') {
      opt.headers.push({ "key": "Content-Type", "value": "application/x-www-form-urlencoded" });
    } else {
      opt.headers.push({ "key": "Content-Type", "value": opt.requestFormat });
    }
  }

  if (opt.headers) {
    for (var i = 0; i < opt.headers.length; i++) {
      var header = opt.headers[i];
      xhr.setRequestHeader(header.key, header.value);
    }
  }

  xhr.onreadystatechange = function () {
    if (!called && (!this.readyState || xhr.readyState === 4 || this.readyState === 'complete' || this.readyState === 'loaded')) {
      called = true;
      var doc = xhr.response || xhr.responseText || xhr.responseXML;
      if (xhr.status == 200) {
        success(xhr, doc);
      } else {
        if (typeof (failled) === 'function') {
          failled(xhr, doc);
        }
        console.error("Status " + xhr.status, url);
      }
    }
  };

  xhr.send(data);
  return xhr;
};

/**
 * Proxy headers for a `bemap.ajax` call site, in its `[{key,value}]` shape
 * (EVMOVE-307). Returns a FRESH array every time — `bemap.ajax` pushes
 * `Content-Type` onto the array it is handed, so a shared one would grow
 * unboundedly. `[]` (a no-op for `bemap.ajax`) when there is no proxy, no
 * Context, or an older Context without the helper.
 *
 * Single definition for all v1 service call sites so the rule cannot drift.
 * @private
 * @since 2.0.2
 * @param {bemap.Context} [ctx]
 * @return {Array.<{key: String, value: String}>}
 */
bemap._proxyHeaderList = function (ctx) {
  return (ctx && typeof ctx.getProxyHeadersList === 'function') ? ctx.getProxyHeadersList() : [];
};

/**
 * Load a JavaScript file.
 * @param {String} src Script name with path.
 * @param {Object} options
 */
bemap._require_cache = [];
bemap.require = function (src, options) {
  var key, inCacheSrc, found = false;

  for (key in bemap._require_cache) {
    inCacheSrc = bemap._require_cache[key];
    if (inCacheSrc == src) {
      found = true;
    }
  }

  if (!found) {
    var head = document.head || document.getElementsByTagName('HEAD').item(0) || document.documentElement;
    var tag = document.createElement('script');

    if (!head || !tag) {
      console.error("Unsupport browser!");
    }

    tag.type = 'text/javascript';
    if (src.charAt(0) == '/') {
      tag.src = src;
    } else {
      tag.src = this.getCurrentPath() + src;
    }

    var opt = options || {};
    if (typeof (opt.success) === 'function') {
      var called = false;
      tag.onload = tag.onreadystatechange = function () {
        if (!called && (!this.readyState || xhr.readyState === 4 || this.readyState === 'complete' || this.readyState === 'loaded')) {
          called = true;
          if (xhr.Status == 200) {
            opt.success(xhr, doc);
          } else {
            if (typeof (opt.faild) === 'function') {
              faild(xhr, doc);
            }
          }
        }
      };
    }

    tag.onerror = function () {
      console.error("Cannont load the library '" + src + "'");
    };

    head.appendChild(tag);
    bemap._require_cache.push(src);
  }
};

/**
 * @classdesc
 * Base class for Exception.
 *
 * @constructor
 * @param {String} message.
 * @param {bemap.Exception} cause.
 */
bemap.Exception = function (message, cause) {
  this.message = message;
  this.cause = cause ? cause : null;
};

/**
 * Return the message of exception.
 * @return {String} Return the message of exception.
 */
bemap.Exception.prototype.getMessage = function () {
  return this.message;
};

/**
 * Return the cause of exception.
 * @return {bemap.Exception} Return the cause of exception.
 */
bemap.Exception.prototype.getCause = function () {
  return this.cause;
};

/**
 * Return stack of exceptions.
 * @return {String} Return stack of exceptions.
 */
bemap.Exception.prototype.getStack = function () {
  var exception = this,
    stack = "";
  while (true) {
    stack = exception.message + "\n";
    if (exception.cause === null) {
      break;
    } else {
      exception = exception.cause;
    }
  }
  return stack;
};

/**
 * Print stack of exceptions.
 * @return {String} Print stack of exceptions.
 */
bemap.Exception.prototype.printStack = function () {
  console.error(this.getStack());
};

/**
 * @classdesc
 * Base class for Context.
 *
 * @constructor
 * @param {object} options.
 */
bemap.Context = function (options) {
  var opts = options || {};

  /**
   * Define the protocol used. The HTTP or HTTPS, by default the HTTP.
   * @protected
   * @type {string}
   */
  this.protocol = opts.secure ? 'https' : 'http';

  /**
   * Define the host name of BeMap server. By default "bemap.benomad.com"
   * (production; the same service also answers at "bemap-prod.benomad.com").
   * @protected
   * @type {string}
   */
  this.host = opts.host || 'bemap.benomad.com';

  /**
   * Define the path of bgis context. The string must be start with / character. By default "/bgis/".
   * @protected
   * @type {string}
   */
  this.path = opts.path || '/bgis/';

  /**
   * Define the login.
   * @protected
   * @type {string}
   */
  this.login = opts.login;

  /**
   * Define the Password.
   * @protected
   * @type {string}
   */
  this.password = opts.password;

  /**
   * OPTIONAL — put your own proxy in front of BeMap so credentials never reach
   * the browser (EVMOVE-307). Written like `host`: a bare host is enough and the
   * `https://` prefix is **optional**, with a path prefix if your proxy lives
   * under one —
   * `'my-proxy.example.com'`, `'my-proxy.example.com/bemap'`,
   * `'https://my-proxy.example.com'`, `'localhost:8787'`.
   * When no scheme is written, the protocol comes from `secure` exactly as it
   * does for `host`. Normalised once here (canonical `origin[+path]`, trailing
   * slash stripped) and stored back, so every consumer reads the same form.
   *
   * When set: REST/WMS URLs (`getBaseUrl()`) and the tiles login
   * (`getTilesLoginUrl()`) go through the proxy, no BeMap credentials are sent
   * (`getAuthUrlParams()` → `''`, no `Authorization`) — even if `login`/
   * `password` are also configured. Tile BYTES still go direct to `tilesHost`
   * (a public host), so tile auth uses the token header wire.
   *
   * A query/fragment or embedded credentials are dropped with a one-shot console
   * warning; only a value with no usable host left (a non-`http(s)` scheme, or a
   * string that is not a host at all) throws — silently turning proxy mode OFF
   * would fall back to direct requests, i.e. leak the very credentials this
   * option exists to hide.
   * @public
   * @since 2.0.2
   * @type {string|null}
   */
  this.proxy = bemap.Context._normalizeProxy(opts.proxy, this.protocol + ':');

  /**
   * OPTIONAL — environment selector for a multi-environment proxy, sent as the
   * `X-BeMap-Env` header. Only a non-empty trimmed string is sent; anything
   * else omits the header entirely (a single-environment proxy needs none).
   * @public
   * @since 2.0.2
   * @type {string|null}
   */
  this.bemapEnv = (typeof opts.bemapEnv === 'string' && opts.bemapEnv.trim())
    ? opts.bemapEnv.trim() : null;

  /**
   * Heap cache of full URL to BeMap context.
   * @private
   * @type {string}
   */
  this.cacheBaseUrl = null;

  /**
   * Heap cache of base 64 authentication.
   * @private
   * @type {string}
   */
  this.cacheAuth = null;

  /**
   * Set the authentication parameters in POST data of request.
   * @private
   * @type {boolean}
   */
  this.authInPost = opts.authInPost ? true : false;

  /**
   * The default BeMap Geoserver
   * @private
   * @type {string}
   */
  this.geoserver = opts.geoserver ? opts.geoserver : undefined;

  /**
   * Default charging-station provider name (e.g. `'ecoMovement'`). When set,
   * `bemap.ChargingStations.search()` and `bemap.EvSmartRouting.calculate()`
   * fall back to this value when the request itself does not specify one.
   * Only names exposed by the user's ACL (see
   * `bemap.helpers.listChargingStationProviders`) are accepted server-side.
   * @public
   * @since 1.5.0
   * @type {string}
   */
  this.chargingStationProvider = opts.chargingStationProvider || null;

  /**
   * BeNomad Tiles Worker host, e.g. `'mptiles-api.benomad.net'`. When set,
   * `bemap.MapLibreMap` will route its background through PMTiles instead of
   * WMS. Leaflet and OpenLayers ignore this field and keep WMS unchanged.
   * @public
   * @since 2.0.0
   * @type {string|null}
   */
  this.tilesHost = opts.tilesHost || null;

  /**
   * Protocol used to reach `tilesHost`. Default `'https:'`.
   * @public
   * @since 2.0.0
   * @type {string}
   */
  this.tilesProtocol = opts.tilesProtocol || 'https:';

  /**
   * Optional URL path prefix on `tilesHost`. Empty by default.
   * @public
   * @since 2.0.0
   * @type {string}
   */
  this.tilesPath = opts.tilesPath || '';

  /**
   * PMTiles map served by the Worker — a bare name (the `.pmtiles` suffix is
   * optional): an alias (`'osm'`, `'here'`) or an exact tileset
   * (`'OSM_250901_WORLD'`). Defaults to `'default'`, which the Worker resolves
   * to the configured default map server-side (via `_config/maps.json`), so the
   * client never pins a map version. Aliases are case-insensitive.
   * @public
   * @since 2.0.0
   * @type {string}
   */
  this.tilesFile = opts.tilesFile || 'default';

  /**
   * What to do when `geoserver` was inherited as the PMTiles map name but the
   * Worker's `/api/maps` catalog has no such map — a BGIS-only provider
   * (`herehlp`, `addok`, `photon`, `nominatim`, `tomtom`) has no vector map.
   * `false` (the default) leaves the basemap empty, so a map never silently
   * shows a DIFFERENT provider's data than the one that was selected. `true`
   * loads the Worker's server-default map instead (the earlier behaviour).
   * An explicit `tilesFile` pin is unaffected either way — it keeps surfacing
   * its own 403/404, since a misspelled or unauthorised tileset is a
   * configuration error, not a provider without tiles.
   * @public
   * @since 2.0.4
   * @type {boolean}
   */
  this.tilesFallbackToDefault = opts.tilesFallbackToDefault === true;

  /**
   * BeNomad-Tiles archive version tokens, keyed by resolved map name
   * (EVMOVE-457). Populated at runtime from the Worker's `/api/maps`
   * (`aliasVersions`/`versions`). When a token is present, `getTilesFileUrl()`
   * appends `?v=<token>` so a rebuilt-under-the-same-name archive becomes a new
   * URL and the browser fetches fresh bytes instead of stale cached slices.
   * Empty by default → URLs are byte-identical to earlier releases.
   * @public
   * @since 2.0.1
   * @type {Object.<string,string>}
   */
  this.tilesVersions = {};

  /**
   * Optional override for the MapLibre glyphs (font) URL template, e.g.
   * `'https://my-host/fonts/{fontstack}/{range}.pbf'`. When set it wins over
   * every style's own `glyphs`. Leave unset and the library bundles no fonts:
   * the tiny fallback style requests none, and server styles fetch their own
   * glyphs from the Worker (a root-relative `/fonts/...` is absolutised to
   * `<tilesHost>/fonts/...` by `bemap.TilesStyle.resolvePlaceholders`).
   * @public
   * @since 2.0.0
   * @type {string|null}
   */
  this.glyphsUrl = opts.glyphsUrl || null;

  /**
   * Where the JWT lives between page loads: `'session'` (default), `'local'`,
   * or `'memory'` (no persistence).
   * @public
   * @since 2.0.0
   * @type {string}
   */
  this.tokenStorage = opts.tokenStorage || 'session';

  /**
   * Tile-auth wire configuration — HOW authenticated requests to `tilesHost`
   * carry their auth. A grouped object
   * `{ mode, credentials, tokenHeader, tokenParam }` (or the shorthand string
   * `'cookie'|'header'|'query'`), normalised by `bemap.Context._resolveTilesAuth`.
   * Unset ⇒ `mode:'auto'` — resolved per deployment (same site as `tilesHost`
   * ⇒ cookie, cross-site ⇒ header), so a consumer needs no auth config and never
   * silently breaks cross-site. Read via `getTilesAuth()`; `bemap.MapLibreMap`
   * lets a per-map `tilesAuth` opt override it field-by-field.
   * @public
   * @since 2.0.0
   * @type {Object|String|null}
   */
  this.tilesAuth = opts.tilesAuth || null;

  /**
   * INTERNAL — a function returning a `Promise` that resolves to a BeMap
   * **session id**, used instead of `login`/`password` when the host application
   * already holds a session and cannot ship a password. It is re-invoked on
   * EVERY login, including every renewal: a session captured once is already
   * stale by the time the first token renewal is due.
   *
   * Only a **function** enables the mode. Any other value is stored as `null`,
   * so a mis-typed option can never half-enable it (it falls through to the
   * normal credential path and its existing error).
   *
   * NOT part of the supported API — undocumented, unadvertised, and subject to
   * change without notice. Customers use `login` + `password`.
   * @private
   * @type {Function|null}
   */
  this.tilesSessionProvider = (typeof opts.tilesSessionProvider === 'function')
    ? opts.tilesSessionProvider : null;
  // Report the mistake HERE, where it was made. Normalising silently to `null`
  // would otherwise hide a mis-typed provider until the first tile fails to load,
  // with a "missing credentials" message pointing at the wrong fix.
  if (opts.tilesSessionProvider != null && typeof opts.tilesSessionProvider !== 'function') {
    bemap.Context._warnBadSessionProvider(opts.tilesSessionProvider);
  }
};

/**
 * INTERNAL — one-shot flag for {@link bemap.Context._warnBadSessionProvider}.
 * @private
 */
bemap.Context._badSessionProviderWarned = false;

/**
 * INTERNAL — report, once per page, that `tilesSessionProvider` was given a
 * value that cannot be called. Shared by the constructor, the setter and
 * `TilesAuth.login()` so the same mistake reads the same way wherever it is
 * caught: the constructor/setter cover the normal path, while `login()` still
 * covers a RAW property write (`bemap.RuntimeConfig`'s `set` trap assigns
 * unknown keys straight onto the Context, bypassing both).
 * @private
 * @param {*} value the offending value — its TYPE is reported, never the value
 */
bemap.Context._warnBadSessionProvider = function (value) {
  if (bemap.Context._badSessionProviderWarned) return;
  bemap.Context._badSessionProviderWarned = true;
  if (typeof console !== 'undefined' && console.warn) {
    console.warn('[bemap.Context] `tilesSessionProvider` is set but is not a function (got ' +
      (typeof value) + ') — session mode is OFF.\n' +
      '   It must be a function returning a Promise that resolves to the session id.');
  }
};

/**
 * Default tile-auth wire config: `mode:'auto'` — resolve per deployment at
 * runtime (same registrable domain as `tilesHost` ⇒ `cookie`, else ⇒ `header`),
 * so a new consumer needs NO auth config and never silently breaks cross-site
 * (where a third-party cookie is blocked in incognito/Safari/Firefox).
 * Consumer-supplied fields are merged over these.
 * @public
 * @since 2.0.0
 */
bemap.Context.TILES_AUTH_DEFAULTS = {
  mode: 'auto',                   // 'auto' | 'cookie' | 'header' | 'query'
  credentials: 'include',         // cookie mode: 'include' | 'same-origin' | 'omit'
  tokenHeader: 'X-Session-Token', // header mode
  tokenParam: 'token'             // query mode
};

/**
 * Normalise a `tilesAuth` value into a fully-defaulted
 * `{ mode, credentials, tokenHeader, tokenParam }`. Mirrors
 * `bemap.Map._resolveControlOptions`: accepts `undefined` (→ defaults), a string
 * shorthand (`'auto'|'cookie'|'header'|'query'` → that mode + defaults), or a
 * partial object (missing fields filled). An unrecognised `mode` falls back to
 * `'auto'`. `raw` fields win over `base` fields win over the defaults — so a
 * per-map opt (`raw`) can override a Context config (`base`) field-by-field.
 * NOTE: this is a PURE normaliser — it may return `mode:'auto'`. The runtime
 * apply sites resolve `'auto'` → `cookie`/`header` via `_resolveAutoMode`.
 * @public
 * @since 2.0.0
 * @param {Object|String} [raw]
 * @param {Object|String} [base]
 * @return {{mode:String, credentials:String, tokenHeader:String, tokenParam:String}}
 */
bemap.Context._resolveTilesAuth = function (raw, base) {
  var D = bemap.Context.TILES_AUTH_DEFAULTS;
  function coerce(v) {
    if (v == null) return {};
    if (typeof v === 'string') return { mode: v };
    return (typeof v === 'object') ? v : {};
  }
  var b = coerce(base);
  var r = coerce(raw);
  var mode = r.mode || b.mode || D.mode;
  if (mode !== 'auto' && mode !== 'cookie' && mode !== 'header' && mode !== 'query') mode = D.mode;
  return {
    mode: mode,
    credentials: r.credentials || b.credentials || D.credentials,
    tokenHeader: r.tokenHeader || b.tokenHeader || D.tokenHeader,
    tokenParam: r.tokenParam || b.tokenParam || D.tokenParam
  };
};

/**
 * Registrable domain of a host — the last two dot-labels (`a.b.benomad.net` →
 * `benomad.net`; `benomadsas.github.io` → `github.io`; `localhost` → `localhost`).
 * A deliberately dependency-free heuristic (no Public Suffix List): it is used
 * ONLY to compare an app origin against `tilesHost`, and the tiles host is always
 * a normal two-label domain, so a match can only ever mean a genuine same-site
 * pair (the `*.github.io`-vs-`*.github.io` public-suffix trap cannot arise here).
 * Accepts a bare host or a full URL; strips scheme / path / query / hash / port.
 * @private
 * @param {String} host
 * @return {String}
 */
bemap.Context._regDomain = function (host) {
  if (!host) return '';
  host = String(host).toLowerCase()
    .replace(/^[a-z][a-z0-9+.\-]*:\/\//, '')                    // scheme://
    .split('/')[0].split('?')[0].split('#')[0].split(':')[0];  // path / query / hash / port
  var labels = host.split('.').filter(function (p) { return p.length > 0; });
  return (labels.length <= 2) ? labels.join('.') : labels.slice(-2).join('.');
};

/**
 * `true` when `appHost` and `tilesHost` share the same registrable domain — i.e.
 * the browser treats the tile request as first-party, so the `SameSite=None`
 * session cookie is sent (works in incognito/Safari/Firefox). Different domains
 * ⇒ third-party cookie ⇒ blocked ⇒ the caller must carry the token itself.
 * @public
 * @since 2.0.0
 * @param {String} appHost   e.g. `window.location.hostname`
 * @param {String} tilesHost e.g. `ctx.tilesHost`
 * @return {Boolean}
 */
bemap.Context._sameSite = function (appHost, tilesHost) {
  var a = bemap.Context._regDomain(appHost);
  var t = bemap.Context._regDomain(tilesHost);
  return !!a && !!t && a === t;
};

/**
 * Resolve a `mode:'auto'` config to a concrete `cookie` (same-site) or `header`
 * (cross-site) mode for the current page. A no-op for any explicit mode. Header
 * is the safe fallback — it works in every browser and deployment — so we only
 * pick `cookie` on a confident same-site match, never breaking cross-site. This
 * is the ONE place `'auto'` is turned into a wire mode, called by the fetch
 * interceptor / `TilesStyle.fetch`. `appHost` defaults to the current
 * `window.location.hostname` (pass it explicitly in tests / non-browser hosts).
 * @public
 * @since 2.0.0
 * @param {Object} cfg resolved config from `_resolveTilesAuth` (may be `mode:'auto'`)
 * @param {String} tilesHost
 * @param {String} [appHost]
 * @return {Object} `cfg` unchanged, or a clone with a concrete `mode`
 */
bemap.Context._resolveAutoMode = function (cfg, tilesHost, appHost) {
  if (!cfg || cfg.mode !== 'auto') return cfg;
  if (appHost == null) {
    appHost = (typeof window !== 'undefined' && window.location && window.location.hostname) || '';
  }
  var out = {};
  for (var k in cfg) { if (Object.prototype.hasOwnProperty.call(cfg, k)) out[k] = cfg[k]; }
  out.mode = bemap.Context._sameSite(appHost, tilesHost) ? 'cookie' : 'header';
  return out;
};

/**
 * Matches an EXPLICIT scheme prefix (`https://`, `http://`, `ftp://`, …).
 *
 * The `://` is required, and detection MUST be done on this prefix rather than
 * by trying to parse: `new URL('localhost:8787')` does **not** throw — it parses
 * as scheme `localhost:` with path `8787`. Sniffing "does it parse?" would
 * therefore turn a bare `host:port` into silent garbage.
 * @private
 * @since 2.0.2
 */
bemap.Context._PROXY_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\//;

/**
 * One-shot console notices emitted by `_normalizeProxy`, keyed by kind, so a
 * config mistake is reported once per page instead of on every read (the proxy
 * is re-normalised on every `getProxy()`).
 * @private
 * @since 2.0.2
 */
bemap.Context._proxyWarned = {};

/**
 * Emit `message` at most once per `key` for the lifetime of the page.
 * @private
 * @since 2.0.2
 * @param {String} key
 * @param {String} message
 * @param {String} [level] `'warn'` (default) or `'error'`
 */
bemap.Context._warnProxyOnce = function (key, message, level) {
  if (bemap.Context._proxyWarned[key]) return;
  bemap.Context._proxyWarned[key] = true;
  if (typeof console === 'undefined') return;
  if (level === 'error' && console.error) { console.error(message); return; }
  if (console.warn) console.warn(message);
};

/**
 * Is a WORKING WHATWG `URL` constructor available? Feature-*tested*, not sniffed:
 * IE11 exposes `window.URL` (for `createObjectURL`) but `new URL(...)` throws, so
 * `typeof URL` alone would be wrong. Memoised on first call.
 * @private
 * @since 2.0.2
 * @return {boolean}
 */
bemap.Context._hasNativeUrl = function () {
  if (bemap.Context._nativeUrlOk === undefined) {
    var ok = false;
    try { ok = (new URL('http://a.example/b').host === 'a.example'); } catch (e) { ok = false; }
    bemap.Context._nativeUrlOk = ok;
  }
  return bemap.Context._nativeUrlOk;
};

/**
 * Parse an ABSOLUTE `http(s)` URL into the subset of the `URL` interface
 * `_normalizeProxy` reads (`protocol`, `username`, `password`, `origin`,
 * `pathname`, `search`, `hash`). Uses the native `URL` when there is one.
 *
 * The fallback exists because `proxy` is the ONLY modern-API dependency on the
 * legacy path: a Leaflet/OpenLayers app calling v1 services through a proxy uses
 * `<img>` tiles and `XMLHttpRequest` — no `fetch`, no `Promise` — so requiring
 * `URL` here would have locked those consumers out of proxy mode alone. It is a
 * deliberately CONSERVATIVE parser: `scheme://[user:pass@]host[:port][/path]`,
 * where `host` is a DNS name, an IPv4 literal, or a **bracketed** IPv6 literal.
 *
 * Where it matches the native `URL` exactly: lower-casing the host, dropping a
 * default port (`:80`/`:443`), preserving path case and percent-encoding, and
 * stripping userinfo/query/fragment from the origin. Two canonicalisations it
 * cannot do in ES5, and how each is handled so the result is never a silent
 * divergence:
 *   - **non-ASCII host** — a modern browser would punycode it (IDNA). REJECTED
 *     here with a message naming the fix (write the `xn--…` form).
 *   - **abbreviated IPv4 (`127.1` → `127.0.0.1`) and a non-canonical IPv6
 *     literal** — passed through as written rather than expanded/compressed. The
 *     request still resolves (the browser re-parses the URL itself); only the
 *     string differs, and the one place that string-compares origins
 *     (MapLibre's `transformRequest`) needs a modern browser anyway.
 * @private
 * @since 2.0.2
 * @param {String} candidate An absolute URL (scheme already prefixed).
 * @return {Object} A `URL`-like object.
 */
bemap.Context._parseProxyUrl = function (candidate) {
  if (bemap.Context._hasNativeUrl()) return new URL(candidate);   // throws on a bad URL
  // The host alternation takes a BRACKETED IPv6 literal first: '[::1]:8787' would
  // otherwise stop the host at '[' (the class excludes ':'), leaving the rest in
  // the path — which re-joins to the right string but breaks port handling, so
  // `[::1]:443` would keep its default port where a browser drops it.
  var m = /^(https?):\/\/(?:([^\/?#@]*)@)?(\[[0-9A-Fa-f:.]+\]|[^\/?#:@]+)(?::(\d+))?([^?#]*)(\?[^#]*)?(#[\s\S]*)?$/i.exec(candidate);
  if (!m) throw new Error('bemap.Context: cannot parse `proxy` URL');
  var scheme = m[1].toLowerCase();
  var userinfo = m[2] || '';
  var host = m[3].toLowerCase();
  var port = m[4] || '';
  var colon = userinfo.indexOf(':');
  // No IDNA in ES5: a Unicode host would stay Unicode here while a modern browser
  // would punycode it. Say so instead of producing a quietly different origin.
  if (/[^\x00-\x7F]/.test(host)) {
    // Flagged so `_normalizeProxy` re-throws THIS message instead of replacing it
    // with the generic "must be a host or an http(s) URL" one — the actionable
    // part is *why* it was refused (write punycode), which the generic text loses.
    var idnaErr = new Error('bemap.Context: `proxy` host must be ASCII on a browser without the URL API — write the punycode form (e.g. "xn--bcher-kva.example")');
    idnaErr.bemapProxyDetail = true;
    throw idnaErr;
  }
  // Drop a default port so the origin matches what `URL` would produce.
  if ((scheme === 'http' && port === '80') || (scheme === 'https' && port === '443')) port = '';
  return {
    protocol: scheme + ':',
    username: (colon >= 0) ? userinfo.substring(0, colon) : userinfo,
    password: (colon >= 0) ? userinfo.substring(colon + 1) : '',
    origin: scheme + '://' + host + (port ? (':' + port) : ''),
    pathname: m[5] || '',
    search: m[6] || '',
    hash: m[7] || ''
  };
};

/**
 * Normalise + validate a `proxy` option (EVMOVE-307) into the canonical
 * `origin[+path]` form, with any trailing slash stripped — so composing it with
 * `this.path` (which starts with `/`) can never produce `//`.
 *
 * **The scheme is optional.** `proxy` is written like `host`: a bare host is
 * enough and inherits its protocol from `secure`, so
 * `{ secure: true, proxy: 'my-proxy.example.com' }` and
 * `{ proxy: 'https://my-proxy.example.com' }` are the same thing. Accepted:
 * `my-proxy.example.com`, `my-proxy.example.com/prefix`, `localhost:8787`,
 * `//my-proxy.example.com`, and every `http(s)://…` form (unchanged).
 *
 * Forgiving where a human's intent is unambiguous: a `?query`/`#fragment` or
 * embedded `user:pass@` credentials are dropped with a one-shot console notice
 * rather than refusing to boot. It still THROWS when nothing usable is left (a
 * non-`http(s)` scheme, or a string that is not a host) — silently turning proxy
 * mode off is the dangerous outcome, because the SDK would then fall back to
 * direct requests and send the very credentials `proxy` exists to hide.
 * A falsy input returns `null` (proxy mode off, no throw).
 *
 * @public
 * @since 2.0.2
 * @param {String} [raw]
 * @param {String} [defaultProtocol] `'https:'` (default) or `'http:'` — the
 *        Context passes its own `protocol` (i.e. `secure`) here.
 * @return {String|null}
 */
bemap.Context._normalizeProxy = function (raw, defaultProtocol) {
  if (raw === undefined || raw === null || raw === '') return null;
  if (typeof raw !== 'string') {
    throw new Error('bemap.Context: `proxy` must be a host or an http(s) URL string, got ' + (typeof raw));
  }
  var s = raw.trim();
  if (!s) return null;
  // NEVER interpolate the raw value into an error or a warning: a URL carrying
  // userinfo (`https://user:pass@host`) would put a password into an Error
  // message that an uncaught-error handler or crash reporter then captures.
  var shown = bemap.Context._redactForMessage(s);
  // Whitespace is never legitimate in a host, and engines disagree on what to do
  // with it: Node/Ada rejects `https://not a url`, Chrome percent-encodes it into
  // a garbage host instead of throwing. Decide it here so the behaviour is the
  // same in every browser.
  if (/\s/.test(s)) {
    throw new Error('bemap.Context: `proxy` must not contain whitespace — got "' + shown + '"');
  }
  // A scheme-like prefix that is NOT `http://` or `https://` must be REJECTED,
  // never re-read as a bare host. 'https:/host' (one slash) would otherwise take
  // the bare-host branch, get a second prefix, and normalise to the host "https"
  // with path "/host" — a request to a nonexistent host instead of a clear error.
  // The one legitimate colon in a bare value is a numeric port ('localhost:8787').
  if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s) &&
      !bemap.Context._PROXY_SCHEME_RE.test(s) &&
      !/^[^:\/?#]+:\d+(?:[\/?#]|$)/.test(s)) {
    throw new Error('bemap.Context: `proxy` looks like a malformed URL — write "https://host", "http://host", or just "host" (optionally "host:port") — got "' + shown + '"');
  }
  // A ROOT-RELATIVE value ('/api/bemap') must be rejected BEFORE the bare-host
  // branch: the URL parser skips any run of slashes after a special scheme
  // ('https:///api/bemap' resolves to host "api", path "/bemap"), so it would
  // silently point every request at a nonsense host. A same-origin proxy is a
  // separate feature — the origin matcher used by MapLibre's transformRequest
  // works on absolute origins — so say so instead of guessing.
  if (/^[\/\\]/.test(s) && s.indexOf('//') !== 0) {
    throw new Error('bemap.Context: `proxy` must name a HOST (e.g. "my-proxy.example.com" or "https://my-proxy.example.com") — a root-relative path like "' + shown + '" is not supported');
  }
  // An unknown/absent protocol falls back to https: — the safe default, and what
  // any proxy reachable from a production page must speak anyway.
  var proto = (defaultProtocol === 'http:' || defaultProtocol === 'https:') ? defaultProtocol : 'https:';
  var candidate;
  if (bemap.Context._PROXY_SCHEME_RE.test(s)) candidate = s;      // explicit scheme wins, as written
  else if (s.indexOf('//') === 0) candidate = proto + s;          // protocol-relative '//host'
  else candidate = proto + '//' + s;                              // bare 'host[:port][/prefix]'
  var u;
  try {
    u = bemap.Context._parseProxyUrl(candidate);
  } catch (e) {
    // A parser error that already explains itself (and never echoes the raw value)
    // survives; only an opaque parse failure is replaced by the generic guidance.
    if (e && e.bemapProxyDetail) throw e;
    throw new Error('bemap.Context: `proxy` must be a host (e.g. "my-proxy.example.com") or an http(s) URL (e.g. "https://my-proxy.example.com"), got "' + shown + '"');
  }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
    throw new Error('bemap.Context: `proxy` must use http:// or https:// — or no scheme at all, which follows `secure` — got "' + shown + '"');
  }
  // `u.origin` already excludes userinfo, query and fragment, so these are
  // dropped by construction; warn so the author learns it rather than wondering.
  if (u.username || u.password) {
    bemap.Context._warnProxyOnce('userinfo',
      '[bemap.Context] `proxy` embeds credentials (user:pass@host) — they are IGNORED.\n' +
      '   The proxy holds the BeMap credentials server-side; the browser must carry none.', 'error');
  }
  if (u.search || u.hash) {
    bemap.Context._warnProxyOnce('querystring',
      '[bemap.Context] `proxy` carries a query string or fragment — it is IGNORED.\n' +
      '   Expected a host or an origin plus an optional path prefix, got "' + shown + '".');
  }
  // A proxy reached over http:// from an https:// page is blocked as mixed
  // content by every browser, with an error that does not name the cause.
  if (u.protocol === 'http:' && typeof window !== 'undefined' && window.location &&
      window.location.protocol === 'https:') {
    bemap.Context._warnProxyOnce('mixedcontent',
      '[bemap.Context] `proxy` resolves to http:// on an https:// page — the browser\n' +
      '   will BLOCK every request to it (mixed content). Set `secure: true` (which\n' +
      '   also drives `host`), or write the scheme explicitly: "https://…".');
  }
  // Collapse any repeated slashes inside the path (`https://h//pre` → `/pre`)
  // and strip the trailing one, so composing with `this.path` (which begins
  // with '/') can never produce '//'.
  return (u.origin + (u.pathname || '').replace(/\/{2,}/g, '/')).replace(/\/+$/, '');
};

/**
 * Redact a URL-ish string for safe inclusion in an error message: drops any
 * `user:pass@` userinfo and everything from `?`/`#` onward, and caps the length.
 * @private
 * @since 2.0.2
 * @param {String} s
 * @return {String}
 */
bemap.Context._redactForMessage = function (s) {
  var out = String(s).split('#')[0].split('?')[0].replace(/\/\/[^/@]*@/, '//***@');
  return (out.length > 120) ? (out.substring(0, 120) + '…') : out;
};

/**
 * Return `true` when this Context has a `tilesHost` configured, meaning
 * `bemap.MapLibreMap` should route the background through BeNomad Tiles.
 * @public
 * @since 2.0.0
 * @return {boolean}
 */
bemap.Context.prototype.hasTilesConfig = function () {
  return !!this.tilesHost;
};

/**
 * Resolved tile-auth wire config `{ mode, credentials, tokenHeader, tokenParam }`
 * — fully defaulted; `mode` is `'auto'` when unset (the fetch interceptor and
 * `TilesStyle.fetch` resolve `'auto'` → `cookie`/`header` per deployment via
 * `bemap.Context._resolveAutoMode`). See `this.tilesAuth`.
 * @public
 * @since 2.0.0
 * @return {{mode:String, credentials:String, tokenHeader:String, tokenParam:String}}
 */
bemap.Context.prototype.getTilesAuth = function () {
  return bemap.Context._resolveTilesAuth(this.tilesAuth);
};

/**
 * Return the base URL of the BeNomad Tiles Worker, e.g.
 * `'https://mptiles-api.benomad.net'`.
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesBaseUrl = function () {
  if (!this.tilesHost) return null;
  return this.tilesProtocol + '//' + this.tilesHost + (this.tilesPath || '');
};

/**
 * INTERNAL — attach (or clear) the tiles session provider on a live Context.
 * Anything that is not a function clears it, so a mis-typed value can never
 * half-enable session mode. See `this.tilesSessionProvider`.
 * @private
 * @param {Function|null} fn
 * @return {bemap.Context} this
 */
bemap.Context.prototype.setTilesSessionProvider = function (fn) {
  this.tilesSessionProvider = (typeof fn === 'function') ? fn : null;
  if (fn != null && typeof fn !== 'function') bemap.Context._warnBadSessionProvider(fn);
  return this;
};

/**
 * URL of the tiles login endpoint. Normally the Worker's `/api/login` on
 * `tilesHost`; in **proxy mode** (`ctx.proxy`, EVMOVE-307) it is
 * `<proxy>/tiles/login` instead, because the login is the one tiles call that
 * needs BeMap credentials and the proxy injects them server-side.
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesLoginUrl = function () {
  // Proxy mode (EVMOVE-307): the login is the ONE tiles call that needs
  // credentials, so it goes through the proxy (which injects them). Tile BYTES
  // and the /api/* discovery calls stay direct to the public tilesHost.
  var proxy = this.getProxy();
  if (proxy) return proxy + '/tiles/login';
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/login' : null;
};

/**
 * URL of the Worker status endpoint (`/api/status`).
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesStatusUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/status' : null;
};

/**
 * URL of the Worker logout endpoint (`/api/logout`).
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesLogoutUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/logout' : null;
};

/**
 * URL of the Worker map-config discovery endpoint (`/api/maps`) — returns the
 * default map, aliases, and the allow-listed tileset filenames for this env.
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesMapsUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/maps' : null;
};

/**
 * URL of the Worker style-list endpoint (`/api/styles`) — returns the
 * allow-listed style files for this env.
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesStylesUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/styles' : null;
};

/**
 * URL of the Worker default-STYLE pointer (`/api/default-style`) — returns the
 * name of the MapLibre style applied when the developer pins no style. Used for
 * the "load the live charte from the server after login" default path.
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesDefaultStyleUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/default-style' : null;
};

/**
 * URL of a named MapLibre style JSON served by the Worker. Accepts either a
 * bare style name (`'openfreemap_graylevel'` → `<base>/styles/openfreemap_graylevel`)
 * or a Worker-relative path as returned by `/api/styles`
 * (`'styles/openfreemap_graylevel.json'` → `<base>/styles/openfreemap_graylevel.json`).
 * A leading slash is stripped; a value that already carries a path segment is
 * resolved against the tilesHost root rather than re-nested under `/styles/`.
 * Returns `null` when there is no tilesHost or no name.
 * @public
 * @since 2.0.0
 * @param {String} name
 * @return {string|null}
 */
bemap.Context.prototype.getTilesStyleUrl = function (name) {
  var base = this.getTilesBaseUrl();
  if (!base || !name) return null;
  var n = String(name).replace(/^\/+/, '');           // strip leading slash(es)
  // Already a path (e.g. 'styles/foo.json' from /api/styles) → prefix the host
  // root; a bare name → the /styles/ collection.
  return (n.indexOf('/') > -1) ? (base + '/' + n) : (base + '/styles/' + n);
};

/**
 * URL of the Worker default-map endpoint (`/api/default`) — returns the
 * filename the `default` alias currently resolves to (or `null` server-side).
 * @public
 * @since 2.0.0
 * @return {string|null}
 */
bemap.Context.prototype.getTilesDefaultUrl = function () {
  var base = this.getTilesBaseUrl();
  return base ? base + '/api/default' : null;
};

/**
 * Build the URL of a PMTiles archive (or `{z}/{x}/{y}.pbf` directory) served
 * by the Worker. Resolves to `https://{tilesHost}{tilesPath}/{file}`.
 * @public
 * @since 2.0.0
 * @param {string} [file] Archive file name; defaults to `Context.tilesFile`.
 * @return {string|null}
 */
bemap.Context.prototype.getTilesFileUrl = function (file) {
  var base = this.getTilesBaseUrl();
  if (!base) return null;
  // An explicit `file` is honoured as-is; otherwise apply the full precedence
  // (ctx.tilesFile -> geoserver -> 'default'). The Worker resolves bare names
  // and the 'default' alias server-side (via _config/maps.json), so the client
  // never pins a map version. Map names are bare (no `.pmtiles`).
  var f = file || this.resolveTilesFile();
  var name = f.replace(/^\//, '');
  var url = base + '/' + name;
  // EVMOVE-457: when a version token is known for this map, append `?v=<token>`
  // so a rebuilt archive is a distinct URL (fresh bytes) instead of stale
  // immutable cache. No token → identical URL to earlier releases.
  var token = this.getTilesVersion(name);
  if (token) url += (url.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(token);
  return url;
};

/**
 * Decide which tiles map (PMTiles alias / tileset) MapLibre should load.
 * Precedence, highest first:
 *   1. an explicit per-call tile parameter (`explicit`, e.g. `opts.tilesFile`)
 *   2. an explicit `ctx.tilesFile` (any value other than the 'default' sentinel)
 *   3. `ctx.geoserver`, when set — so `geoserver: 'osm'` loads the 'osm' tiles
 *   4. the literal 'default' — the Worker resolves it server-side
 *
 * The string `'default'` is treated as "not explicitly chosen", so a configured
 * `geoserver` outranks the implicit default that `ctx.tilesFile` carries when
 * the caller never set a tiles map.
 *
 * @public
 * @since 2.0.0
 * @param {String} [explicit] A per-call tilesFile that outranks the ctx values.
 * @return {String}
 */
bemap.Context.prototype.resolveTilesFile = function (explicit) {
  if (explicit && explicit !== 'default') return explicit;
  if (this.tilesFile && this.tilesFile !== 'default') return this.tilesFile;
  if (this.geoserver) return this.geoserver;
  return 'default';
};

/**
 * Version token currently known for a BeNomad-Tiles map (EVMOVE-457), or null.
 * @public
 * @since 2.0.1
 * @param {String} [name] Resolved map name; defaults to `resolveTilesFile()`.
 * @return {String|null}
 */
bemap.Context.prototype.getTilesVersion = function (name) {
  if (!this.tilesVersions) return null;
  var key = String(name || this.resolveTilesFile()).replace(/^\/+/, '');
  return this.tilesVersions[key] || null;
};

/**
 * Record (or clear) the version token for a BeNomad-Tiles map (EVMOVE-457).
 * A non-empty string sets it; anything else clears it. Keyed by resolved map
 * name, so several maps sharing one Context can each carry their own version.
 * @public
 * @since 2.0.1
 * @param {String} name Resolved map name.
 * @param {String} [token] Opaque version token from `/api/maps`.
 * @return {bemap.Context}
 */
bemap.Context.prototype.setTilesVersion = function (name, token) {
  if (!this.tilesVersions) this.tilesVersions = {};
  var key = String(name || this.resolveTilesFile()).replace(/^\/+/, '');
  if (typeof token === 'string' && token) this.tilesVersions[key] = token;
  else delete this.tilesVersions[key];
  return this;
};

/**
 * Return the configured default charging-station provider, or `null` when none
 * is set. Mirrors the convenience shape of `getGeoserver()`.
 * @public
 * @since 1.5.0
 * @return {String|null}
 */
bemap.Context.prototype.getChargingStationProvider = function () {
  return this.chargingStationProvider || null;
};

/**
 * Base URL of the BeMap REST/WMS endpoints — `protocol://host + path`.
 *
 * In **proxy mode** (`ctx.proxy`, EVMOVE-307) the consumer's own proxy origin
 * replaces `protocol://host`, i.e. `<proxy> + path`, so every REST/WMS call is
 * routed through it (the proxy adds the credentials server-side).
 * @public
 * @return {String} BeMap URL
 */
bemap.Context.prototype.getBaseUrl = function () {
  // getProxy() re-validates + invalidates cacheBaseUrl if `proxy` changed since
  // the last read, so a late write (RuntimeConfig's Proxy `set` trap assigns
  // straight onto the Context, bypassing the constructor) can neither skip
  // validation nor be masked by a stale memo.
  var proxy = this.getProxy();
  if (!this.cacheBaseUrl) {
    // Proxy mode (EVMOVE-307): the consumer's own proxy replaces the BeMap
    // origin. `proxy` is normalised without a trailing slash and `path` starts
    // with '/', so this can never yield '//'.
    this.cacheBaseUrl = proxy
      ? (proxy + this.path)
      : (this.protocol + "://" + this.host + this.path);
  }
  return this.cacheBaseUrl;
};

/**
 * The effective, NORMALISED proxy for this Context (EVMOVE-307), or `null` when
 * proxy mode is off.
 *
 * Normalises **on read** and re-checks whenever the stored `proxy` value has
 * changed since the last call: `bemap.RuntimeConfig`'s Proxy `set` trap writes
 * unknown keys straight onto the shared Context, so a runtime
 * `ctx.proxy = '…'` never passes through the constructor. Reading through this
 * getter means such a write is still validated (it throws on a bad URL) and the
 * `getBaseUrl()` memo is invalidated instead of serving the old origin.
 * @public
 * @since 2.0.2
 * @return {String|null}
 */
bemap.Context.prototype.getProxy = function () {
  if (this.proxy !== this._proxyChecked) {
    // Pass our own protocol so a scheme-less late write ('my-proxy.example.com')
    // follows `secure`, exactly like one passed to the constructor.
    this._proxyResolved = bemap.Context._normalizeProxy(this.proxy, this.protocol + ':');
    this._proxyChecked = this.proxy;
    this.cacheBaseUrl = null;          // origin changed → rebuild on next read
  }
  return this._proxyResolved || null;
};

/**
 * Set (or clear with `null`) the proxy at runtime, validating + normalising it
 * and invalidating the base-URL memo. Accepts the same shapes as the constructor
 * option, including a bare host (the protocol follows `secure`).
 *
 * **Set `proxy` BEFORE constructing a map.** This exists for config layers that
 * mutate a live Context, and everything URL-shaped follows immediately
 * (`getBaseUrl()`, `getAuthUrlParams()`, `getTilesLoginUrl()`, all v1/v2 service
 * calls, and — for a map that already had a proxy — MapLibre's per-request env
 * header). What it CANNOT retrofit:
 *   - a `bemap.MapLibreMap` built on a proxy-less Context has no proxy
 *     `transformRequest` wrapper installed at all, so it will never send
 *     `X-BeMap-Env` (its tile auth is untouched, and services still switch);
 *   - **no URL already built into a live map is rewritten** — a MapLibre WMS/raster
 *     source added to the style, and any Leaflet/OpenLayers layer, keep the URL
 *     they were created with, so their requests keep going to the OLD proxy (only
 *     the env header follows the new one). Rebuild the source/layer — or re-apply
 *     the style — to move that traffic;
 *   - a tiles JWT already minted for the previous proxy stays in use until it
 *     expires or `TilesAuth.logout()` clears it.
 * @public
 * @since 2.0.2
 * @param {String|null} raw
 * @return {bemap.Context} this
 */
bemap.Context.prototype.setProxy = function (raw) {
  var before = this.proxy || null;
  this.proxy = bemap.Context._normalizeProxy(raw, this.protocol + ':');
  this._proxyChecked = this.proxy;
  this._proxyResolved = this.proxy;
  this.cacheBaseUrl = null;
  // Swapping one live proxy for another is the case that looks like it worked but
  // only half did: URLs already baked into a map (a MapLibre WMS source, a Leaflet
  // or OpenLayers layer) still point at the OLD proxy, and nothing rewrites them.
  // Say it once rather than let it surface as half the traffic on the old origin.
  if (before && this.proxy && before !== this.proxy) {
    bemap.Context._warnProxyOnce('switch',
      '[bemap.Context] `proxy` changed after it was already set. URLs already built\n' +
      '   into existing map sources/layers (MapLibre WMS sources, Leaflet/OpenLayers\n' +
      '   layers) still point at the previous proxy — rebuild the layer or the style\n' +
      '   to move them. Services, WMS URLs built from now on, and the tiles login\n' +
      '   follow the new value immediately.');
  }
  return this;
};

/**
 * The environment selector sent as `X-BeMap-Env` on proxy requests, or `null`.
 * @public
 * @since 2.0.2
 * @return {String|null}
 */
bemap.Context.prototype.getBemapEnv = function () {
  return (typeof this.bemapEnv === 'string' && this.bemapEnv.trim()) ? this.bemapEnv.trim() : null;
};

/**
 * URL auth parameters (`appid=…&appcode=…`) for REST/WMS URLs.
 *
 * Returns `''` when there are no credentials, and **always `''` in proxy mode**
 * (`ctx.proxy`, EVMOVE-307) — credentials never travel to a proxy, even when
 * `login`/`password` are also configured. (Before 2.0.2 the credential-less case
 * returned `null`, which string-concatenated into URLs as a literal `"null"`.)
 * @public
 * @return {String} `appid=…&appcode=…`, or `''`
 */
bemap.Context.prototype.getAuthUrlParams = function () {
  // Proxy mode (EVMOVE-307): credentials NEVER travel to a proxy — the proxy
  // injects them server-side. Answered before the memo is touched, so a stale
  // cacheAuth can neither be read nor written on this path.
  if (this.getProxy()) return '';
  if (this.login && this.password && !this.cacheAuth) {
    this.cacheAuth = "appid=" + this.login + "&appcode=" + this.password;
  }
  // No credentials → '' (not null): the value is string-concatenated into WMS
  // URLs, where null produced a literal "wms?null&…". @since 2.0.2
  return this.cacheAuth || '';
};

/**
 * Extra HTTP headers required by the configured proxy (EVMOVE-307), as a plain
 * object for `fetch`-based call sites. Currently `X-BeMap-Env` when `bemapEnv`
 * is a non-empty string; `{}` otherwise (no proxy, or no env selector).
 *
 * Returns a FRESH object on every call — callers merge/mutate it.
 * @public
 * @since 2.0.2
 * @return {Object}
 */
bemap.Context.prototype.getProxyHeaders = function () {
  var out = {};
  var env = this.getBemapEnv();
  if (this.getProxy() && env) out['X-BeMap-Env'] = env;
  return out;
};

/**
 * Same header set as {@link bemap.Context#getProxyHeaders}, in the
 * `[{key, value}]` shape `bemap.ajax` (and `evmove5`'s dao) consumes. Derived
 * from `getProxyHeaders()`, so a future header appears in both automatically.
 *
 * Returns a FRESH array on every call — `bemap.ajax` PUSHES onto the array it
 * is given (Content-Type), so a shared array would grow without bound.
 * @public
 * @since 2.0.2
 * @return {Array.<{key: String, value: String}>}
 */
bemap.Context.prototype.getProxyHeadersList = function () {
  var obj = this.getProxyHeaders();
  var list = [];
  for (var k in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, k)) list.push({ key: k, value: obj[k] });
  }
  return list;
};

/**
 * Set to true if the authentication parameters will be write in the POST data of request.
 */
bemap.Context.prototype.setAuthInPost = function (authInPost) {
  this.authInPost = authInPost;
};

/**
 * Return true if the authentication parameters will be write in the POST data of request.
 * @return {boolean} true if the authentication parameters will be write in the POST data of request..
 */
bemap.Context.prototype.isAuthInPost = function () {
  return this.authInPost;
};

/**
 * Return the default BaMap Geoserver of context.
 * @return {String} BeMap Geoserver
 */
bemap.Context.prototype.getGeoserver = function () {
  return this.geoserver;
};

/**
 * Perform a HTTP Post request.
 * @param {Object} options
 */
bemap.Context.prototype.post = function (options) {
  var opt = options || {};
  return this;
};