/**
* BeNomad BeMap JavaScript API - Map - MapLibre GL JS v5
*/
// Event name translation: bemap → MapLibre
bemap.MapLibreMap_eventMap = {
'pointerup': 'mouseup',
'pointerdown': 'mousedown',
'pointerdrag': 'drag',
'pointermove': 'mousemove',
'singleclick': 'click',
'postrender': 'render',
'postcompose': 'render',
'precompose': 'render',
'propertychange': 'moveend',
'change': 'moveend',
'change:size': 'resize',
'change:view': 'moveend'
};
/**
* @classdesc
* Map implementation using MapLibre GL JS.
* @public
* @constructor
* @extends {bemap.Map}
*/
bemap.MapLibreMap = function(context, target, options) {
// Runtime dependency check FIRST — emits a loud console banner with
// the exact <script>/<link> tags to add when maplibregl / pmtiles /
// maplibre-gl.css are missing. Throws on missing critical JS deps.
var _hasTiles = !!(context && typeof context.hasTilesConfig === 'function' && context.hasTilesConfig());
bemap.Map._checkDependencies('maplibre', { tilesHost: _hasTiles });
this.target = target;
bemap.Map.call(this, context, options);
var opts = options || {};
this._layerCounter = 0;
// Tiles config is active when the Context exposes a tilesHost. This is the
// single switch that promotes the MapLibre background to BeNomad Tiles —
// explicit, surfaced through `ctx.hasTilesConfig()` so consumers can read it.
this._hasTilesConfig = !!(context && typeof context.hasTilesConfig === 'function' && context.hasTilesConfig());
this._hasCustomStyle = !!(opts.style || opts.tiles);
// Desktop-Chromium PMTiles Range no-store policy (bemap.RangeFetchPolicy).
// `rangeCacheMode` ('auto' | 'default' | 'no-store', default 'auto') is a
// page-level default read by BOTH the BeNomad-Tiles path (TilesAuth's shared
// policy) and the raw opts.tiles standalone installer. Set it BEFORE the
// TilesAuth interceptor below so its lazily-created policy picks it up.
if (opts.rangeCacheMode && typeof bemap.RangeFetchPolicy === 'function') {
bemap.RangeFetchPolicy.defaultMode = opts.rangeCacheMode;
}
// Self-healing pmtiles header/directory cache (bemap.RecoverablePromiseCache).
// `recoverableCache` (default true) is a page-level default: when on, the tiles
// archive is wired through a cache that evicts REJECTED header/dir promises so a
// failed read self-heals instead of leaving a permanent hole. `false` → pmtiles'
// stock (poisoning) SharedPromiseCache. Transparent: only failed reads differ.
if (opts.recoverableCache != null && typeof bemap.RecoverablePromiseCache === 'function') {
bemap.RecoverablePromiseCache.enabled = (opts.recoverableCache !== false);
}
// PMTiles fetch mode + resilience gate (bemap.PMTilesSliceSource / bemap.RangeGate).
// `tilesSliceMode` ('200' default | 'range'/'206') picks HOW the archive is read:
// '200' → `?r=` slices; the Worker returns cacheable 200s (browser HTTP cache;
// needs a Worker that serves the slice route — BeNomad Tiles does).
// 'range'→ classic HTTP Range (206) via pmtiles' stock source (today's path).
// `tilesSliceTimeoutMs` (pre-header TTFB) / `tilesSliceBodyTimeoutMs` (post-header
// safety cap) / `tilesSliceMaxRetries` / `tilesSliceRetryBackoffMs` /
// `tilesSliceConcurrency` tune the shared gate. All page-level defaults,
// applied here BEFORE the archive is wired.
bemap.MapLibreMap._applyTilesSliceOpts(opts);
// Optional callback fired by setTileGateActive() so an embedder's UI can
// reflect the live gate state (the SDK ships no UI — see the example page).
this._onTileGateChange = (typeof opts.onTileGateChange === 'function') ? opts.onTileGateChange : null;
// Debounce (ms) for the recoverable-tile-error refresh safety net installed
// below (`tilesErrorRefreshMs`, default 4000; 0 disables). MapLibre never
// re-requests an errored tile on its own — see the 'error' listener.
this._tilesErrorRefreshMs = (opts.tilesErrorRefreshMs != null) ? +opts.tilesErrorRefreshMs : 4000;
this._tileRefreshTimer = null;
this._featureRegistry = {}; // _bemapId → bemap object
this._markerElements = {}; // _bemapId → { element, bemapObject }
this._geoJsonLayerIds = []; // track all GeoJSON layer IDs for hit-testing
this._geoJsonData = {}; // sourceId → GeoJSON data (for drag updates)
// Per-instance overlay catalogue — used by setStyle() to replay overlays
// after the native style swap that would otherwise wipe markers/polylines.
if (typeof bemap.TilesOverlayCatalog === 'function') {
this._overlayCatalog = new bemap.TilesOverlayCatalog();
}
// Register the pmtiles:// MapLibre protocol once per page load.
if (typeof pmtiles !== 'undefined' && !bemap.MapLibreMap._pmtilesRegistered) {
var _proto = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', _proto.tile);
// Retain the (page-global) Protocol so the recoverable cache can be wired
// in per archive via `_proto.add(new pmtiles.PMTiles(url, cache))`.
bemap.MapLibreMap._pmtilesProtocol = _proto;
bemap.MapLibreMap._pmtilesRegistered = true;
}
// BeNomad Tiles auth — replaces the global window.fetch patch with a
// transformRequest that captures `getToken()` (not the token value) so
// proactive / reactive renewal flows are transparent.
var _self = this;
this._tilesAuth = null;
this._currentStyleSpec = null;
if (this._hasTilesConfig && typeof bemap.TilesAuth === 'function') {
this._tilesAuth = new bemap.TilesAuth(context, {
// Per-map auth override — merged field-by-field over context.tilesAuth,
// both normalised to a full { mode, credentials, tokenHeader, tokenParam }.
tilesAuth: opts.tilesAuth,
onError: function (err) { _self._fireError(err); },
onToken: function (info) {
// Only reload the style when the new token has actually been
// fetched (`reason: 'refresh'`). Doing it on `'expired'` would
// reload with the still-expired token and trigger another 401
// → refresh loop. We also route through the wrapper's setStyle
// (not native) so the overlay catalogue replay listener fires.
if (info && info.reason === 'refresh' && _self.native && _self._currentStyleSpec) {
// _internal: this re-applies the SAME current spec for token renewal —
// it is not the developer choosing a style, so it must not latch
// _userSetStyle (which would suppress the init default-style refresh).
try { _self.setStyle(_self._currentStyleSpec, { _internal: true }); } catch (e) { /* ignore */ }
}
}
});
// Install the scoped fetch interceptor BEFORE the native MapLibre
// map is constructed — pmtiles.js issues range requests via
// `fetch()` directly, bypassing MapLibre's `transformRequest`. The
// interceptor awaits the in-flight login (which stores the Worker's
// HttpOnly session cookie) and sends every ctx.tilesHost request with
// `credentials:'include'` — cookie auth, no X-Session-Token header, so
// Range GETs carry only the safelisted `Range` header → no CORS preflight.
bemap.TilesAuth.installFetchInterceptor(this._tilesAuth, context.tilesHost);
// Kick off login if no valid token is in storage. The interceptor
// is what actually waits for it — the constructor stays sync.
if (this._tilesAuth.getToken() == null || !this._tilesAuth.isValid()) {
this._tilesAuth.login().catch(function () { /* surfaced via onError */ });
}
}
// Build style. Precedence:
// opts.style → explicit style wins
// opts.tiles → custom PMTiles URL with optional style URL
// ctx.hasTilesConfig() → BeNomad Tiles default style
// none → empty background (legacy fallback)
var style;
// opts.style is EXPLICIT only as an inline object or a URL/path. A bare style
// NAME, the string 'default', or omitting it all flow to the tiny fallback
// below, then load/refresh from the Worker after login.
var _explicitStyle = opts.style && (typeof opts.style === 'object' || /[:/]/.test(opts.style));
if (_explicitStyle) {
style = opts.style;
} else if (opts.tiles) {
// Raw PMTiles URL — this origin bypasses TilesAuth's interceptor (TilesAuth
// only patches for registered tilesHost origins). Install the standalone
// RangeFetchPolicy ONCE per page so these Range requests also get the
// desktop-Chromium no-store fix. Gated on !_hasTilesConfig so it never
// stacks a SECOND global fetch patch on top of TilesAuth's (exactly one
// interceptor); the installer itself also no-ops if TilesAuth already owns
// the patch.
if (!this._hasTilesConfig && typeof bemap.RangeFetchPolicy === 'function' && !bemap.MapLibreMap._rangePolicyInstalled) {
new bemap.RangeFetchPolicy({ rangeCacheMode: (opts.rangeCacheMode || bemap.RangeFetchPolicy.defaultMode || 'auto') }).installFetchInterceptor();
bemap.MapLibreMap._rangePolicyInstalled = true;
}
// PMTiles URL — build a minimal style, then load real style async
var tilesUrl = opts.tiles.indexOf('pmtiles://') === 0 ? opts.tiles : 'pmtiles://' + opts.tiles;
style = {
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: { tiles: { type: 'vector', url: tilesUrl, maxzoom: 14 } },
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#f0f0f0' } }]
};
this._pendingTilesStyle = opts.tilesStyle || null;
this._pendingTilesUrl = tilesUrl;
// Raw opts.tiles is an ARBITRARY origin (S3/R2/presigned) that may not serve
// the Worker's ?r= slice route — default it to the always-safe Range path
// unless the caller EXPLICITLY opts into '200'. (200-slice is only assumed
// for the BeNomad-Tiles ctx.tilesHost path below, where the Worker serves it.)
bemap.MapLibreMap._addTilesArchive(
tilesUrl.replace('pmtiles://', ''),
opts.tilesSliceMode === '200' ? '200' : 'range'
);
} else if (this._hasTilesConfig && typeof bemap.fallbackStyle !== 'undefined' && typeof bemap.TilesStyle !== 'undefined') {
// BeNomad Tiles — clone the tiny font-free fallback style then resolve
// placeholders against the configured ctx/tilesFile. Precedence is
// opts.tilesFile -> ctx.tilesFile -> ctx.geoserver -> 'default', so a
// Context with `geoserver: 'osm'` (and no explicit tilesFile) loads the
// 'osm' tiles instead of the server default.
//
// The fallback has no symbol layers and no `glyphs`, so it requests no
// fonts. ctx.glyphsUrl (if set) is still applied by resolvePlaceholders;
// server styles bring their own (Worker) fonts when they swap in.
style = JSON.parse(JSON.stringify(bemap.fallbackStyle));
// Resolve this map's tiles name ONCE and store it on the instance — the
// SINGLE source of truth reused by version discovery, archive registration,
// and every setStyle, so their URLs (and the ?v= token) can never diverge
// and silently drop the slice source (EVMOVE-457 key-identity).
this._tilesFile = context.resolveTilesFile(opts.tilesFile);
// Remember HOW the name was chosen (same "'default' = not chosen" rule as
// resolveTilesFile): an EXPLICIT pin (opts.tilesFile / ctx.tilesFile) must
// keep surfacing its own 403/404 — a misspelled or unauthorised tileset is
// a configuration signal. Only a name merely INHERITED from ctx.geoserver
// may auto-fall back to 'default' when the Worker catalog doesn't know it.
this._tilesFileExplicit = !!((opts.tilesFile && opts.tilesFile !== 'default') ||
(context.tilesFile && context.tilesFile !== 'default'));
bemap.TilesStyle.resolvePlaceholders(style, context, this._tilesFile);
// Wire the self-healing cache for the BeNomad-Tiles archive. The URL is the
// SAME derivation resolvePlaceholders uses for the source (`pmtiles://` +
// getTilesFileUrl), so the Protocol will use this cache; a mismatch is a
// harmless no-op (Protocol auto-creates its default cache, as today).
if (context.getTilesFileUrl) {
// If the app pre-set a version token on the Context (before constructing
// the map), the url already carries ?v= — register the source as versioned
// so its header read stays immutable (same rule as setStyle / re-register).
var _preVersioned = !!(typeof context.getTilesVersion === 'function' && context.getTilesVersion(this._tilesFile));
bemap.MapLibreMap._addTilesArchive(
context.getTilesFileUrl(this._tilesFile), bemap.MapLibreMap._tilesSliceMode, _preVersioned);
}
if (typeof bemap.TilesStyle.hardenSymbolCollisions === 'function') {
bemap.TilesStyle.hardenSymbolCollisions(style);
}
// After construction (so setStyle has a target): a bare NAME swaps to
// /styles/<name>; omitted or 'default' refreshes to the Worker's live
// default style once login completes. The tiny fallback above is the
// instant first paint either way — no first-paint cost, and it stays if
// the server style ever fails to load.
if (typeof opts.style === 'string' && opts.style && opts.style !== 'default') {
this._pendingStyleName = opts.style;
} else {
this._refreshDefaultOnInit = true;
}
} else {
style = {
version: 8,
sources: {},
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#f0f0f0' } }]
};
}
this._currentStyleSpec = style;
// Set up token injection for authenticated PMTiles via opts.tilesToken.
// The v2 ctx.tilesHost path uses TilesAuth + transformRequest below (not
// the global fetch patch). This legacy block stays for opts.tiles users.
if (opts.tiles && opts.tilesToken) {
this._setupTilesAuth(opts.tiles, opts.tilesToken, opts.tilesTokenHeader || 'X-Session-Token');
}
// Projection is SDK-owned: the default is `mercator` (north-up, flat — the
// cross-engine contract; OpenLayers/Leaflet are mercator-only) and `globe`
// is opt-in only. Track the developer's choice so a *server* style that
// carries its own root `projection` can't silently switch it after load —
// MapLibre v5 applies a stylesheet's `projection` on every setStyle, and a
// globe curves the view at low zoom, reading as a map "at the wrong angle".
// This mirrors how the SDK already ignores a style's own `sources`
// (see bemap.TilesStyle.resolvePlaceholders). Re-asserted after each style
// swap via _applyDesiredProjection().
this._desiredProjection = (opts.projection === 'globe') ? 'globe' : 'mercator';
// Seed the FIRST style so the initial paint already matches the request.
if (this._desiredProjection === 'globe' && style && typeof style === 'object') {
style.projection = { type: 'globe' };
}
// Resolve native-attribution + zoom-control options (uniform across engines).
// bemap.Map._resolveControlOptions hides MapLibre's built-in attribution
// by default when the BeMap AttributionWidget is enabled. For zoom, we
// add a NavigationControl when zoomControl is enabled — gives MapLibre
// the same +/- buttons Leaflet + OL expose by default, at the same
// configurable position.
var _ctrl = bemap.Map._resolveControlOptions(opts);
// UX tuning for BeNomad Tiles (snappier than the MapLibre defaults):
// - fadeDuration: 200ms (default 300) — tiles snap in faster, less mid-pan grey
// - maxTileCacheSize: 500 — keep more recently-seen tiles in memory so
// pan-back is instant (default is viewport * ~5, often 50–100 on big screens)
// - refreshExpiredTiles: true (default) — keep stale tiles painted while
// re-fetching, avoids flashing transparent during cache-validation
// Consumers can override via opts.fadeDuration / opts.maxTileCacheSize.
var _nativeOpts = {
container: target,
style: style,
center: [0, 0],
zoom: opts.zoom || 2,
pitch: opts.pitch || 0,
bearing: opts.bearing || 0,
minZoom: opts.minZoom || 0,
maxZoom: opts.maxZoom || 22,
maxBounds: bemap.MapLibreMap._normalizeBounds(opts.maxBounds),
fadeDuration: opts.fadeDuration != null ? opts.fadeDuration : 200,
maxTileCacheSize: opts.maxTileCacheSize != null ? opts.maxTileCacheSize : 500,
refreshExpiredTiles: opts.refreshExpiredTiles !== false,
attributionControl: _ctrl.nativeAttribution,
// Keyboard interactions — default OFF (see bemap.Map._resolveControlOptions).
// MapLibre v5's `KeyboardHandler` is one bundled unit (arrow pan + `=`/`-`
// zoom + `Shift+arrow` rotate/tilt); granular `{pan, zoom}` collapses to
// "either-or" here. Consumers needing finer control can reach
// `map.native.keyboard.disableRotation()` etc. after construction.
keyboard: _ctrl.keyboard.pan || _ctrl.keyboard.zoom
};
this._keyboardEnabled = !!(_ctrl.keyboard.pan || _ctrl.keyboard.zoom);
this._keyboardOptions = _ctrl.keyboard;
// MapLibre accepts exactly ONE transformRequest, so tile auth and the proxy
// env header must be COMPOSED into a single function (EVMOVE-307 #7).
// getProxy() (not the raw field) so a pre-construction raw write is validated
// and normalised here, exactly as getBaseUrl() would.
if (this._tilesAuth || (context && (typeof context.getProxy === 'function' ? context.getProxy() : context.proxy))) {
_nativeOpts.transformRequest = bemap.MapLibreMap._buildTransformRequest(context, this._tilesAuth);
}
this.native = new maplibregl.Map(_nativeOpts);
// Default-style "bootstrap → refresh", or a deferred named style — run once on
// init, after the native map exists so setStyle() has a target to swap into.
// _refreshDefaultStyleFromServer() waits for login; setStyle(name) resolves it
// to /styles/<name>. The tiny fallback already painted, so no first-paint cost.
// EVMOVE-457: before the real style is applied, best-effort resolve this map's
// archive version token (from /api/maps, after login) and re-register the
// archive under its VERSIONED url — so the versioned style url matches the
// registered slice source (else pmtiles silently drops PMTilesSliceSource +
// the recoverable cache). Never blocks first paint (the fallback already
// painted) and never hangs (timeout + always-resolve); on any failure the
// UNVERSIONED style applies exactly as before. This is the ONLY site that
// consumes _refreshDefaultOnInit / _pendingStyleName, so both are gated here.
// (_self is declared once near the top of the constructor.)
function _applyInitStyle() {
if (_self._refreshDefaultOnInit) _self._refreshDefaultStyleFromServer();
else if (_self._pendingStyleName && !_self._userSetStyle) _self.setStyle(_self._pendingStyleName);
}
if ((this._refreshDefaultOnInit || this._pendingStyleName) && typeof this._discoverTilesVersion === 'function') {
this._versionReady = this._discoverTilesVersion();
this._versionReady.then(function () {
if (_self.ctx && typeof _self.ctx.getTilesFileUrl === 'function'
&& typeof _self.ctx.getTilesVersion === 'function'
&& _self.ctx.getTilesVersion(_self._tilesFile)) {
// Re-register the archive under its now-versioned url so the versioned
// style url has a matching slice source (else pmtiles auto-creates a
// stock Range source). Idempotent per url (a new ?v= url is a new entry).
// versioned:true → the offset-0 header read on this source stays immutable
// (the ?v= token already makes the cache entry rebuild-distinct).
bemap.MapLibreMap._addTilesArchive(
_self.ctx.getTilesFileUrl(_self._tilesFile), bemap.MapLibreMap._tilesSliceMode, true);
}
_applyInitStyle();
});
} else {
_applyInitStyle();
}
// Reload tiles on resize. MapLibre v5 already observes the container and
// resizes its canvas, but resize() updates the canvas + transform WITHOUT
// marking the sources dirty — so the newly-exposed strip never requests tiles
// and paints the style background (the grey/blank bar after a panel collapse).
// Nudge the camera once per resize to force the tile request: setCenter →
// jumpTo fires move/moveend → _sourcesDirty → _updateSources(transform). Skip
// while a camera animation runs (its own move events already do this). The
// listener lives on the native map, so native.remove() tears it down — no leak.
var _roSelf = this;
this.native.on('resize', function () {
if (_roSelf.native && !_roSelf.native.isMoving()) {
try { _roSelf.native.setCenter(_roSelf.native.getCenter()); } catch (e) {}
}
});
this._observeTargetResize(target);
// Browser-cache wiring — opts.browserCache (true | false | 'auto', default 'auto').
//
// Logging contract (so consumers know whether tile caching is active):
// - tilesHost set + SW registers → console.info "browser cache active"
// - tilesHost set + SW not supported → console.warn (HTTP without localhost, or no SW API)
// - tilesHost set + opt-out → console.info "browser cache disabled by opts.browserCache"
// - tilesHost set + register failed → console.warn with the underlying error
// - tilesHost set + bemap.BrowserCache class missing (script not loaded) → console.warn
// No tilesHost: silent — non-tiles consumers don't care.
this._browserCache = null;
// Under `tilesSliceMode:'200'` the browser HTTP cache stores the 200-slices
// natively, so the tile Service Worker is redundant → default it OFF on the
// slice path (kept for the 206-Range path, where it is the only client-side
// cache). Parametrable: `opts.serviceWorker` (true/false) forces it either way;
// legacy `opts.browserCache:false` still opts out everywhere.
var _swExplicit = (opts.serviceWorker != null) ? (opts.serviceWorker !== false)
: (opts.browserCache != null ? (opts.browserCache !== false) : null);
var _swWanted = (_swExplicit != null) ? _swExplicit : (bemap.MapLibreMap._tilesSliceMode !== '200');
if (this._hasTilesConfig) {
if (!_swWanted) {
var _swWhy;
if (bemap.MapLibreMap._tilesSliceMode === '200') {
// Under 200-slice the browser HTTP cache stores tiles natively, so the SW
// is redundant EITHER way — make that clear so a consumer carrying a
// legacy browserCache:false doesn't think caching is broken.
_swWhy = (_swExplicit == null)
? '200-slice mode uses the browser HTTP cache instead (SW not needed). Pass serviceWorker:true to force it on.'
: 'you set serviceWorker/browserCache off — but 200-slice mode still caches tiles via the browser HTTP cache.';
} else {
_swWhy = 'disabled by option — tiles will hit the network (206-Range is not browser-cached without the SW).';
}
console.info('[bemap] BeNomad Tiles: tile Service Worker OFF — ' + _swWhy);
// Remove any bemap tile SW left controlling this origin by a PRIOR deploy
// (e.g. an app upgraded from 206-range to the 200-slice default) — otherwise
// a stale worker keeps intercepting/serving 206 range data. Safe no-op if
// none exist.
if (typeof bemap.BrowserCache === 'function' && typeof bemap.BrowserCache.unregisterStale === 'function') {
bemap.BrowserCache.unregisterStale();
}
} else if (typeof bemap.BrowserCache !== 'function') {
console.warn('[bemap] BeNomad Tiles: bemap.BrowserCache is not loaded — tile caching is OFF. Verify dist/bemap-js-api.js is up to date.');
} else {
var _bcSelf = this;
// Only forward serviceWorkerPath when the consumer actually pinned it —
// BrowserCache treats any provided value as "explicit", which disables
// the auto-fallback chain. Defaulting it here would defeat discovery.
var _bcOpts = {
tilesHost: context && context.tilesHost,
onStats: function (stats) {
if (_bcSelf._cacheStatsListeners) {
for (var _csi = 0; _csi < _bcSelf._cacheStatsListeners.length; _csi++) {
try { _bcSelf._cacheStatsListeners[_csi](stats); } catch (e) {}
}
}
},
onError: function (err) { _bcSelf._fireError(err); }
};
if (opts.serviceWorkerPath != null) _bcOpts.serviceWorkerPath = opts.serviceWorkerPath;
this._browserCache = new bemap.BrowserCache(_bcOpts);
if (!this._browserCache.isSupported()) {
console.warn(
'[bemap] BeNomad Tiles: Service Worker not supported here — tile caching is OFF.\n' +
' Cause: page is served over plain HTTP (Service Workers require HTTPS, or localhost/127.0.0.1).\n' +
' Effect: every tile hits the network. First-load UX will feel slower than with caching.\n' +
' Fix: serve over HTTPS in production.'
);
} else {
var _bc = this._browserCache;
_bc.register().then(function (reg) {
// Success is silent; misplacement warnings come from BrowserCache.register.
if (!reg) {
var attempts = (typeof _bc.getRegistrationAttempts === 'function') ? _bc.getRegistrationAttempts() : [];
var bundleInfo = (typeof bemap.BrowserCache._findBemapScriptInfo === 'function')
? bemap.BrowserCache._findBemapScriptInfo() : null;
var origin = (typeof window !== 'undefined' && window.location) ? window.location.origin : '';
var pageUrl = (typeof window !== 'undefined' && window.location) ? window.location.href : '(unknown)';
var pageDir = (typeof window !== 'undefined' && window.location)
? window.location.pathname.replace(/[^/]*$/, '') : '/';
var bundleLine = bundleInfo
? ' Bundle URL: ' + bundleInfo.src
: ' Bundle URL: (no <script src="…bemap-js-api.js"> found — bundler-inlined?)';
var triedLines = attempts.length
? '\n Tried:\n - ' + attempts.map(function (a) {
var parts = a.split(' → ');
return origin + parts[0] + ' → ' + (parts[1] || '');
}).join('\n - ')
: '';
console.warn(
'[bemap] BeNomad Tiles: Service Worker could not be registered — tile caching is OFF.\n' +
bundleLine + '\n' +
' Page URL: ' + pageUrl + triedLines + '\n\n' +
' To fix, copy dist/bemap-sw-tiles.js to ONE of these URLs and reload:\n' +
' ' + origin + '/bemap-sw-tiles.js (site root — preferred)\n' +
(pageDir !== '/' ? ' ' + origin + pageDir + 'bemap-sw-tiles.js (app sub-root, next to your page)\n' : '') +
' …or pass an explicit path:\n' +
" new bemap.MapLibreMap(ctx, target, { serviceWorkerPath: 'bemap-sw-tiles.js', ... })"
);
}
}).catch(function (err) {
console.warn(
'[bemap] BeNomad Tiles: Service Worker registration failed — tile caching is OFF.\n' +
' Error: ' + (err && err.message ? err.message : err) + '\n' +
' Fix: verify bemap-sw-tiles.js is reachable (see ' + _bc.swPath + ').'
);
});
}
}
}
// Surface 401s / 403s from MapLibre's error channel into TilesAuth + the
// public error event. Without this, an expired token silently fails tiles.
if (this._tilesAuth) {
var _authSelf = this;
this.native.on('error', function (e) {
var err = e && e.error;
var status = err && (err.status || (err.statusText ? parseInt(err.statusText, 10) : 0));
if (status === 401) {
// Pass the WRAPPER (_authSelf), not _authSelf.native. handle401
// calls `arg.setStyle(spec)` internally — when arg is the wrapper,
// bemap.MapLibreMap.prototype.setStyle attaches the
// `once('idle', () => _overlayCatalog.replayOnto(self))`
// listener so every SOURCE/LAYER overlay (polyline / polygon / circle /
// raster / heatmap) registered before the 401 storm gets re-added on
// the new style. (DOM overlays — markers / popups — live in the map
// container, survive setStyle untouched, and are intentionally NOT in
// the replay set; replaying them would duplicate them — see
// TilesOverlayCatalog / PMT-28.)
// Previously we passed .native → wrapper.setStyle bypassed → no
// replay → 24 of 33 polylines silently dropped (the ones added
// before the post-401 setStyle wiped them).
_authSelf._tilesAuth.handle401(_authSelf, _authSelf._currentStyleSpec)
.catch(function () { /* surfaced via TilesAuth.onError */ });
} else if (status === 403) {
_authSelf._fireError(new bemap.Error({
code: bemap.Error.FORBIDDEN, status: 403,
message: 'BeNomad Tiles: forbidden — your account is not authorised for this resource'
}));
} else if (status === 429) {
_authSelf._fireError(new bemap.Error({
code: bemap.Error.RATE_LIMITED, status: 429,
message: 'BeNomad Tiles: rate limited'
}));
}
});
}
// Terminal safety net for RECOVERABLE tile failures. MapLibre does NOT
// re-request an errored tile on its own, so once the RangeGate's retries are
// exhausted (TTFB timeout → AbortError, the Worker's ~3 s deadline → 504,
// 429/5xx, a network blip) the tile would stay grey until the next zoom
// change. Schedule ONE debounced refresh of the tile-backed sources instead.
// 401/403 are auth — handled above by TilesAuth — and stay untouched. The
// smart-abort gate makes this rare (~0.1 % of tiles under heavy panning);
// this is the last line of defence, not the primary recovery. Policy proven
// in mp-tiles-demo: 0 permanent grey tiles.
if (this._hasTilesConfig || opts.tiles) {
var _healSelf = this;
this.native.on('error', function (e) {
var err = e && e.error;
if (!bemap.MapLibreMap._isRecoverableTileError(err)) return;
_healSelf._scheduleTileRefresh();
});
}
if (_ctrl.zoomControl && typeof maplibregl.NavigationControl === 'function') {
var _navCtrl = new maplibregl.NavigationControl({
showZoom: true,
showCompass: false,
visualizePitch: false
});
this.native.addControl(_navCtrl,
bemap.Map._translateZoomPosition('maplibre', _ctrl.zoomPosition));
}
// Suppress WMS tile decode errors (server returns XML/text error instead of image)
this.native.on('error', function(e) {
if (e && e.error && e.error.message && e.error.message.indexOf('could not be decoded') !== -1) {
return; // Silently ignore WMS tile decode errors
}
});
// Cursor pointer on hover over GeoJSON features
var _mapRef = this;
this.native.on('mousemove', function(evt) {
if (!_mapRef._geoJsonLayerIds.length) return;
var existingLayers = [];
for (var i = 0; i < _mapRef._geoJsonLayerIds.length; i++) {
try { if (_mapRef.native.getLayer(_mapRef._geoJsonLayerIds[i])) existingLayers.push(_mapRef._geoJsonLayerIds[i]); } catch(e) {}
}
if (!existingLayers.length) { _mapRef.native.getCanvas().style.cursor = ''; return; }
var features = _mapRef.native.queryRenderedFeatures(evt.point, { layers: existingLayers });
_mapRef.native.getCanvas().style.cursor = (features && features.length > 0) ? 'pointer' : '';
});
// If tiles option with a style URL, load the full style async after map init
if (this._pendingTilesStyle) {
var _mapInst = this;
var _tilesUrl = this._pendingTilesUrl;
var styleUrl = this._pendingTilesStyle;
var xhr2 = new XMLHttpRequest();
xhr2.open('GET', styleUrl, true);
if (opts.tilesToken) xhr2.setRequestHeader(opts.tilesTokenHeader || 'X-Session-Token', opts.tilesToken);
xhr2.onload = function() {
if (xhr2.status === 200) {
try {
var fullStyle = JSON.parse(xhr2.responseText);
var placeholder = (fullStyle.metadata && fullStyle.metadata.source_placeholder) || 'TILES_SOURCE';
fullStyle.sources['tiles'] = Object.assign({}, fullStyle.sources[placeholder] || {}, { type: 'vector', url: _tilesUrl, maxzoom: 14 });
if (placeholder !== 'tiles' && fullStyle.sources[placeholder]) delete fullStyle.sources[placeholder];
for (var si = 0; si < fullStyle.layers.length; si++) { if (fullStyle.layers[si].source === placeholder) fullStyle.layers[si].source = 'tiles'; }
if (!fullStyle.glyphs) fullStyle.glyphs = 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf';
_mapInst.native.setStyle(fullStyle);
} catch(e) {}
}
};
xhr2.send();
}
// Attribution widget (v1.5.0) — auto-instantiated unless options.attribution === false
if (typeof bemap.AttributionWidget === 'function') {
var _attr = opts.attribution;
if (_attr !== false) {
var _attrOpts = (_attr && typeof _attr === 'object') ? _attr : {};
this._attributionWidget = new bemap.AttributionWidget(this, _attrOpts);
}
}
};
bemap.inherits(bemap.MapLibreMap, bemap.Map);
/**
* MapLibre-only container-size self-healing.
*
* The broken cases here are not OpenLayers cases: MapLibre can latch its canvas
* size while an iframe/flex/tab layout is still settling. Window resize does
* not fire for those parent/container-only changes, so observe this map's
* target and call the MapLibre resize path directly.
* @private
*/
bemap.MapLibreMap.prototype._observeTargetResize = function (target) {
if (typeof ResizeObserver !== 'function') return this;
var el = target || this.target;
if (typeof el === 'string') {
if (typeof document === 'undefined') return this;
el = document.getElementById(el);
}
if (!el || el.nodeType !== 1) return this;
this._disconnectTargetResizeObserver();
var self = this;
var token = {};
var scheduled = false;
this._targetResizeObserverToken = token;
var run = function () {
scheduled = false;
if (self._targetResizeObserverToken !== token || !self.native) return;
try { self.refresh(); } catch (e) { /* map may be torn down */ }
};
var schedule = function () {
if (self._targetResizeObserverToken !== token || scheduled) return;
scheduled = true;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
else setTimeout(run, 0);
};
try {
this._targetResizeObserver = new ResizeObserver(schedule);
this._targetResizeObserver.observe(el);
// First render in the dashboard iframe/flex layout can settle just after
// construction. These kicks cover initial frame, post-layout frame, and
// short delayed iframe layout without requiring page-level hacks.
schedule();
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(function () { requestAnimationFrame(schedule); });
} else {
setTimeout(schedule, 0);
}
setTimeout(schedule, 100);
setTimeout(schedule, 500);
} catch (e) {
this._targetResizeObserver = null;
this._targetResizeObserverToken = null;
}
return this;
};
/** @private */
bemap.MapLibreMap.prototype._disconnectTargetResizeObserver = function () {
this._targetResizeObserverToken = null;
if (this._targetResizeObserver) {
try { this._targetResizeObserver.disconnect(); } catch (e) {}
this._targetResizeObserver = null;
}
return this;
};
/**
* Toggle keyboard navigation (arrow-pan + `=`/`-` zoom + `Shift+arrow`
* rotate/tilt — MapLibre's `KeyboardHandler` is one bundle) at runtime.
* @public
* @param {Boolean} enabled
* @return {bemap.MapLibreMap}
*/
bemap.MapLibreMap.prototype.setKeyboard = function (enabled) {
if (this.native && this.native.keyboard) {
if (enabled) this.native.keyboard.enable();
else this.native.keyboard.disable();
}
this._keyboardEnabled = !!enabled;
return this;
};
/** @public @return {Boolean} */
bemap.MapLibreMap.prototype.getKeyboard = function () { return !!this._keyboardEnabled; };
/**
* Normalize a bounds-shaped input into MapLibre's `[[west, south], [east, north]]`.
* Accepts MapLibre's own forms (passthrough), Leaflet LatLngBounds
* (getSouthWest/getNorthEast → {lat,lng}), bemap.BoundingBox
* ({minLon,minLat,maxLon,maxLat}), and `[west,south,east,north]` flat arrays.
* Returns the original value when it can't be classified — let MapLibre throw
* its own error rather than swallow a genuinely bad input.
*/
bemap.MapLibreMap._normalizeBounds = function (b) {
if (b == null) return undefined;
// Web Mercator's safe latitude limit is atan(sinh(π)) ≈ 85.05112878°.
// Any value at or beyond ±85.05113 pushes mercatorYfromLat() to NaN /
// Infinity, which propagates into vec4.transformMat4 inside MapLibre's
// _calcMatrices and crashes with "Cannot read properties of null
// (reading '0')". Use 85.0 (well inside the limit) as the conservative
// clamp so legacy callers passing L.latLngBounds(-90..90, -180..180)
// simply work.
var MAX_LAT = 85.0, MIN_LAT = -85.0;
var MAX_LON = 180, MIN_LON = -180;
// If the bounds cover essentially the whole world there's nothing to
// constrain — drop the option entirely so MapLibre doesn't bother
// computing constraint geometry that could still produce NaN at edges.
function isWholeWorld(swLng, swLat, neLng, neLat) {
return (neLng - swLng) >= 359.5 && (neLat - swLat) >= 169.5;
}
function cl(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function ok(swLng, swLat, neLng, neLat) {
if (isWholeWorld(swLng, swLat, neLng, neLat)) return undefined;
return [[cl(swLng, MIN_LON, MAX_LON), cl(swLat, MIN_LAT, MAX_LAT)],
[cl(neLng, MIN_LON, MAX_LON), cl(neLat, MIN_LAT, MAX_LAT)]];
}
if (typeof b.getSouthWest === 'function' && typeof b.getNorthEast === 'function') {
var sw = b.getSouthWest();
var ne = b.getNorthEast();
if (sw && ne && sw.lng != null && ne.lng != null) {
return ok(sw.lng, sw.lat, ne.lng, ne.lat);
}
}
if (typeof b.getWest === 'function' && typeof b.getNorth === 'function') {
// MapLibre LngLatBounds — already typed correctly. Still check for
// whole-world by reading the accessors, otherwise pass through.
var w = b.getWest(), s = b.getSouth(), e2 = b.getEast(), n = b.getNorth();
if (isWholeWorld(w, s, e2, n)) return undefined;
return b;
}
if (b.minLon != null && b.minLat != null && b.maxLon != null && b.maxLat != null) {
return ok(b.minLon, b.minLat, b.maxLon, b.maxLat);
}
if (Object.prototype.toString.call(b) === '[object Array]' && b.length === 4 &&
typeof b[0] === 'number' && typeof b[1] === 'number' &&
typeof b[2] === 'number' && typeof b[3] === 'number') {
return ok(b[0], b[1], b[2], b[3]);
}
// [[lng,lat],[lng,lat]] — already MapLibre-shaped; still check + clamp.
if (Object.prototype.toString.call(b) === '[object Array]' && b.length === 2 &&
Object.prototype.toString.call(b[0]) === '[object Array]' &&
Object.prototype.toString.call(b[1]) === '[object Array]') {
return ok(b[0][0], b[0][1], b[1][0], b[1][1]);
}
return b;
};
/**
* Run a callback when the map style is ready.
* If the style is already loaded, executes immediately; otherwise waits for the 'load' event
* with a safety timeout fallback.
*/
bemap.MapLibreMap.prototype._whenStyleReady = function(fn) {
var _this = this;
// `isStyleLoaded()` LIES during the post-setStyle gap: it returns true
// for the OLD style while MapLibre is parsing the new stylesheet. Any
// addSource / addLayer issued in that window hits the stale Style and
// gets wiped wholesale when MapLibre's Style#_load replaces it. That's
// the root cause of "5 of 33 polylines survive in evsmartrouting" — the
// first 5 polylines arrive during the gap, hit the fast path, run
// against the doomed Style, and get discarded.
//
// Use `'idle'` instead — MapLibre fires it once everything has settled:
// style fully loaded AND first render pass complete. After idle, addSource
// / addLayer reliably stick.
var inFlight = this._setStyleInFlight === true;
if (!inFlight && this.native.isStyleLoaded()) { fn(); return; }
var fired = false;
// After a setStyle, MapLibre fires 'style.load' once the new stylesheet is
// parsed but BEFORE everything is settled — chain to a fresh 'idle' after it.
// Guard on `fired`: if 'idle' already fired first, this must NOT register a
// second 'idle' listener (which would never fire and leak). Named so `done`
// can remove it up-front.
var onStyleLoad = function () { if (!fired) _this.native.once('idle', done); };
var done = function () {
if (fired) return;
fired = true;
try { _this.native.off('style.load', onStyleLoad); } catch (e) { /* ignore */ }
fn();
};
this.native.once('idle', done);
this.native.once('style.load', onStyleLoad);
// Single safety net for cached / synchronously-resolved styles where the
// events never fire (rare). Cut from 4 staggered polls to 1 — 33 polylines
// each with 4 timers = 132 timers, which felt "heavy" in the consumer app.
setTimeout(function () {
if (_this._setStyleInFlight !== true && _this.native.isStyleLoaded()) done();
}, 1500);
};
/* ------------------------------------------------------------
* Style-op coalescing — collect bursts of addSource/addLayer
* calls into a single requestAnimationFrame so the user sees ONE
* MapLibre render pass per render-cycle instead of 17–66.
*
* Eliminates the flicker between `polylineLayer.clear()` and the
* subsequent re-add loop in consumer apps that re-render routes
* on every selection change (e.g. evmove5's EV routing UI).
*
* Currently used only by addPolyline. Same pattern can be applied
* later to addPolygon / addCircle / addRasterLayer if those see
* similar burst patterns.
* ------------------------------------------------------------ */
bemap.MapLibreMap.prototype._enqueueStyleOp = function (id, fn) {
if (!this._pendingStyleOps) this._pendingStyleOps = [];
this._pendingStyleOps.push({ id: id, fn: fn });
if (this._styleOpsScheduled) return;
this._styleOpsScheduled = true;
var _this = this;
var flush = function () {
_this._styleOpsScheduled = false;
var ops = _this._pendingStyleOps || [];
_this._pendingStyleOps = [];
_this._flushStyleOps(ops);
};
// Prefer rAF for visual frame alignment; fall back to setTimeout(0) for
// environments where rAF is unavailable (Karma / headless tests, etc.).
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(flush);
} else {
setTimeout(flush, 0);
}
};
bemap.MapLibreMap.prototype._flushStyleOps = function (ops) {
if (!ops || !ops.length) return;
var _this = this;
var runAll = function () {
for (var i = 0; i < ops.length; i++) {
try { ops[i].fn(); } catch (e) { /* per-op error logged inside fn */ }
}
};
// ONE _whenStyleReady subscription for the whole batch instead of one
// per op — avoids 17 separate listeners + 17 timeouts.
if (this.native.isStyleLoaded() && this._setStyleInFlight !== true) {
runAll();
} else {
this._whenStyleReady(runAll);
}
};
/**
* Legacy auth path for the `opts.tiles` + `opts.tilesToken` usage shape.
* **Deprecated** in v2.0 — prefer `ctx.tilesHost` + `bemap.TilesAuth` (the
* v2 path uses MapLibre's `transformRequest` instead of patching the global
* `window.fetch`). Kept callable for backwards compatibility.
*
* @deprecated Since 2.0.0. Use `ctx.tilesHost` instead.
*/
bemap.MapLibreMap.prototype._setupTilesAuth = function(tilesUrl, token, headerName) {
// If the v2 `bemap.TilesAuth` interceptor is already patched on this
// page, stay out of its way. Stacking two interceptors would have the
// legacy wrapper capture the v2-patched fetch as its "original" and
// double-wrap every request.
if (typeof bemap.TilesAuth !== 'undefined' && bemap.TilesAuth._fetchPatched) {
console.warn('bemap.MapLibreMap._setupTilesAuth is deprecated; ctx.tilesHost (v2) is already active on this page, ignoring the legacy call.');
return;
}
bemap.MapLibreMap._tokenMap = bemap.MapLibreMap._tokenMap || {};
if (bemap.MapLibreMap._fetchIntercepted) {
var domain = tilesUrl.replace('pmtiles://', '').split('/')[2] || tilesUrl.split('/')[2];
bemap.MapLibreMap._tokenMap[domain] = { header: headerName, token: token };
return;
}
var domain2 = tilesUrl.replace('pmtiles://', '').split('/')[2] || tilesUrl.split('/')[2];
bemap.MapLibreMap._tokenMap[domain2] = { header: headerName, token: token };
// Capture the original fetch reference and wrap it. Uses a rest-parameter
// bridge to avoid the `arguments` re-binding bug present in the v1
// prototype (where reassigning `arguments[1]` did not actually change
// the outgoing call on some engines). Host comparison uses `new URL`
// host parsing — substring matching would leak the token to attacker
// URLs like `https://evil.com/?h=mptiles-api-beta.benomad.net`.
var _origFetch = window.fetch;
window.fetch = function (input, init) {
var url = (typeof input === 'string' ? input : (input && input.url)) || '';
var newInit = init;
var reqHost = null;
try { reqHost = new URL(url, (window.location && window.location.href) || 'http://localhost/').host; } catch (e) { /* malformed */ }
if (reqHost) {
for (var d in bemap.MapLibreMap._tokenMap) {
if (d === reqHost) {
var tokenInfo = bemap.MapLibreMap._tokenMap[d];
var headers = new Headers((init || {}).headers || {});
headers.set(tokenInfo.header, tokenInfo.token);
newInit = Object.assign({}, init || {}, { headers: headers });
break;
}
}
}
return _origFetch.call(this, input, newInit);
};
bemap.MapLibreMap._fetchIntercepted = true;
};
/**
* Add default layers. If no custom style was provided, adds WMS background
* from the BeMap server (same as OlMap). Always adds overlay layers.
*/
bemap.MapLibreMap.prototype.defaultLayers = function(options) {
var opts = options || {};
// Short-circuit WMS when an explicit style is present OR when BeNomad
// Tiles is configured on the Context — in both cases the background is
// already supplied (via the custom style or the bundled default).
if (!this._hasCustomStyle && !this._hasTilesConfig) {
this.addLayer(new bemap.BemapLayer({
name: bemap.Map.DEFAULT_LAYER.BACKGROUND,
styles: opts.styles ? opts.styles : ''
}));
}
bemap.Map.prototype.defaultOverlayLayers.call(this, opts);
return this;
};
/**
* No-op, kept so `.backgroundLayers(list).switchBackgroundLayer(name)` stays
* valid cross-engine. MapLibre needs no pre-declared layers.
*
* @public
* @param {Array.<String>} geoservers Ignored.
* @param {Object} [options] Ignored.
* @return {bemap.MapLibreMap} Return this.
*/
bemap.MapLibreMap.prototype.backgroundLayers = function(geoservers, options) { return this; };
/**
* Point the basemap at another geoserver's tiles, in place.
*
* Leaflet and OpenLayers toggle pre-created raster layers; MapLibre re-resolves
* the archive and re-applies the current style, so the map instance and its
* overlays survive. Was a stub, so a geoserver selector had no effect on any
* MapLibre map (BEMAP-1905).
*
* `setStyle` does the work — re-registers the slice source, re-resolves the
* placeholders, replays the overlays. The name is passed EXPLICITLY so it
* outranks `ctx.tilesFile`, and `_tilesFileExplicit` is cleared so a provider
* with no archive is treated as "no vector map" rather than a misconfiguration.
* Selecting a provider that HAS one restores the basemap.
*
* @public
* @since 2.0.4
* @param {String} geoserver Geoserver / tiles name, e.g. `'osm'`.
* @return {bemap.MapLibreMap} Return this.
*/
bemap.MapLibreMap.prototype.switchBackgroundLayer = function(geoserver) {
if (!geoserver || !this.ctx || typeof this.ctx.resolveTilesFile !== 'function') return this;
// No tiles config → the basemap is the WMS raster fallback (defaultLayers).
if (!this._hasTilesConfig) return this;
var name = this.ctx.resolveTilesFile(geoserver);
if (name === this._tilesFile) return this;
// A map blanked by the catalog check (a provider with no vector map) has the
// source-free style as its _currentStyleSpec, so re-applying THAT would stay
// blank. Restore the last real spec instead, and keep the current one on the
// way in so there is always something to come back to.
var wasMissing = !!this._tilesFileMissing;
if (!wasMissing) this._tilesLastStyleSpec = this._currentStyleSpec;
this._tilesFile = name;
this._tilesFileExplicit = false;
this._tilesFileMissing = false;
var spec = wasMissing
? (this._tilesLastStyleSpec || JSON.parse(JSON.stringify(bemap.fallbackStyle)))
: this._currentStyleSpec;
if (spec) {
this.setStyle(spec, { tilesFile: geoserver, _internal: true });
}
return this;
};
// =========================================================================
// Internal Helpers
// =========================================================================
bemap.MapLibreMap.prototype._uniqueId = function(prefix) {
return (prefix || 'bemap') + '_' + (++this._layerCounter);
};
bemap.MapLibreMap.prototype._colorToRgba = function(color) {
if (!color) return 'rgba(0,0,0,1)';
return 'rgba(' + color.getRed() + ',' + color.getGreen() + ',' + color.getBlue() + ',' + color.getAlpha() + ')';
};
bemap.MapLibreMap.prototype._addOwnToProperties = function(bemapObject) {
if (!bemapObject) return;
// Idempotent: reuse an existing id instead of minting a new one. When the
// overlay catalog replays an add after a setStyle (e.g. the live charte load
// on a tilesHost page), a fresh id would give a polyline/polygon/circle a NEW
// source/layer id that escapes addPolyline's `getLayer()` dedupe guard and
// paints a DUPLICATE layer over the original — the "two polylines, drag one
// and the other moves" symptom (PMT-28). Keeping the id stable makes the
// guard fire so replay is a no-op for an overlay that is already present.
var id = bemapObject._bemapId || this._uniqueId('obj');
bemapObject._bemapId = id;
this._featureRegistry[id] = bemapObject;
};
bemap.MapLibreMap.prototype._getOwnFromProperties = function(feature) {
if (feature && feature.properties && feature.properties._bemapId) {
return this._featureRegistry[feature.properties._bemapId] || null;
}
return null;
};
bemap.MapLibreMap.prototype._hitTestMarkerElements = function(point) {
var mapContainer = this.native.getContainer();
if (!mapContainer) return null;
var mapRect = mapContainer.getBoundingClientRect();
var px = point.x + mapRect.left;
var py = point.y + mapRect.top;
for (var id in this._markerElements) {
var entry = this._markerElements[id];
var el = entry.element;
if (!el) continue;
var rect = el.getBoundingClientRect();
if (px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom) {
return entry.bemapObject;
}
}
return null;
};
// =========================================================================
// Event System — mirrors OlMap's _onFeature architecture
// =========================================================================
bemap.MapLibreMap.prototype._checkModeOfEvent = function(mode, bemapObject, eventType) {
if (bemapObject && bemapObject.callback && bemapObject.callback[eventType] && typeof bemapObject.callback[eventType] === 'function') {
return bemapObject.callback[eventType];
}
if (this.events) {
if (this.events[eventType + 'markers'] && this.events[eventType + 'markers'].callback && typeof this.events[eventType + 'markers'].callback === 'function' && bemap.inheritsof(bemapObject, bemap.Marker)) {
return this.events[eventType + 'markers'].callback;
}
if (this.events[eventType + 'multiMarkers'] && this.events[eventType + 'multiMarkers'].callback && typeof this.events[eventType + 'multiMarkers'].callback === 'function' && bemap.inheritsof(bemapObject, bemap.MultiMarker)) {
return this.events[eventType + 'multiMarkers'].callback;
}
if (this.events[eventType + 'polylines'] && this.events[eventType + 'polylines'].callback && typeof this.events[eventType + 'polylines'].callback === 'function' && bemap.inheritsof(bemapObject, bemap.Polyline)) {
return this.events[eventType + 'polylines'].callback;
}
if (this.events[eventType + 'polygons'] && this.events[eventType + 'polygons'].callback && typeof this.events[eventType + 'polygons'].callback === 'function' && bemap.inheritsof(bemapObject, bemap.Polygon)) {
return this.events[eventType + 'polygons'].callback;
}
if (this.events[eventType + 'circles'] && this.events[eventType + 'circles'].callback && typeof this.events[eventType + 'circles'].callback === 'function' && bemap.inheritsof(bemapObject, bemap.Circle)) {
return this.events[eventType + 'circles'].callback;
}
}
if (bemapObject && bemapObject.events && bemapObject.events.draggable && bemapObject.callback && bemapObject.callback.draggable && typeof bemapObject.callback.draggable === 'function') {
return bemapObject.callback.draggable;
}
return null;
};
bemap.MapLibreMap.prototype._onFeature = function(declaredObj, eventType, callback, options, mode) {
var opts = options || {};
var mlEvent = bemap.MapLibreMap_eventMap[eventType] || eventType;
var _this = this;
var nativeListener;
if (!this.events[eventType]) {
nativeListener = function(evt) {
var bemapObject = null;
// Hit-test GeoJSON layers
if (evt.point && _this._geoJsonLayerIds.length > 0) {
try {
var existingLayers = [];
for (var i = 0; i < _this._geoJsonLayerIds.length; i++) {
if (_this.native.getLayer(_this._geoJsonLayerIds[i])) {
existingLayers.push(_this._geoJsonLayerIds[i]);
}
}
if (existingLayers.length > 0) {
var features = _this.native.queryRenderedFeatures(evt.point, { layers: existingLayers });
if (features && features.length > 0) {
bemapObject = _this._getOwnFromProperties(features[0]);
}
}
} catch(e) {}
}
// Hit-test HTML markers
if (!bemapObject && evt.point) {
bemapObject = _this._hitTestMarkerElements(evt.point);
}
// Fallback to map
if (!bemapObject) {
bemapObject = _this;
}
var coord = evt.lngLat ? new bemap.Coordinate(evt.lngLat.lng, evt.lngLat.lat) : new bemap.Coordinate();
var mapEvent = new bemap.MapEvent({
native: evt,
map: _this,
bemapObject: bemapObject,
x: evt.point ? evt.point.x : 0,
y: evt.point ? evt.point.y : 0,
coordinate: coord,
properties: opts
});
var cb = _this._checkModeOfEvent(mode, bemapObject, eventType);
if (cb) { cb(mapEvent); }
};
this.native.on(mlEvent, nativeListener);
this.events[eventType] = new bemap.Listener({
native: nativeListener,
callback: callback,
key: mlEvent,
bemapObject: declaredObj
});
} else {
nativeListener = this.events[eventType].native;
}
// Store callback routing
if (declaredObj && mode && mode.singleFeature && typeof callback === 'function') {
if (!declaredObj.callback) declaredObj.callback = {};
declaredObj.callback[eventType] = callback;
} else if (!declaredObj && mode && mode.markers && typeof callback === 'function') {
this.events[eventType + 'markers'] = new bemap.Listener({ callback: callback, key: eventType + 'markers' });
return this.events[eventType + 'markers'];
} else if (!declaredObj && mode && mode.multiMarkers && typeof callback === 'function') {
this.events[eventType + 'multiMarkers'] = new bemap.Listener({ callback: callback, key: eventType + 'multiMarkers' });
return this.events[eventType + 'multiMarkers'];
} else if (!declaredObj && mode && mode.polylines && typeof callback === 'function') {
this.events[eventType + 'polylines'] = new bemap.Listener({ callback: callback, key: eventType + 'polylines' });
return this.events[eventType + 'polylines'];
} else if (!declaredObj && mode && mode.polygons && typeof callback === 'function') {
this.events[eventType + 'polygons'] = new bemap.Listener({ callback: callback, key: eventType + 'polygons' });
return this.events[eventType + 'polygons'];
} else if (!declaredObj && mode && mode.circles && typeof callback === 'function') {
this.events[eventType + 'circles'] = new bemap.Listener({ callback: callback, key: eventType + 'circles' });
return this.events[eventType + 'circles'];
}
return this.events[eventType];
};
// Public event methods — all delegate to _onFeature
bemap.MapLibreMap.prototype.on = function(eventType, callback, options) {
// 'error*' also reaches the map's error channel (BEMAP-1898 audit). Additive:
// MapLibre's own native 'error' event still arrives via _onFeature below, so a
// consumer already listening for tile/style failures keeps getting them.
this._subscribeErrorChannel(eventType, callback);
return this._onFeature(this, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.onMarker = function(marker, eventType, callback, options) {
return this._onFeature(marker, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.onMarkers = function(eventType, callback, options) {
return this._onFeature(null, eventType, callback, options, { markers: true });
};
bemap.MapLibreMap.prototype.onMultiMarkers = function(eventType, callback, options) {
return this._onFeature(null, eventType, callback, options, { multiMarkers: true });
};
bemap.MapLibreMap.prototype.onMultiMarker = function(multimarker, eventType, callback, options) {
return this._onFeature(multimarker, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.onPolyline = function(polyline, eventType, callback, options) {
return this._onFeature(polyline, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.onPolylines = function(eventType, callback, options) {
return this._onFeature(null, eventType, callback, options, { polylines: true });
};
bemap.MapLibreMap.prototype.onPolygon = function(polygon, eventType, callback, options) {
return this._onFeature(polygon, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.onPolygons = function(eventType, callback, options) {
return this._onFeature(null, eventType, callback, options, { polygons: true });
};
bemap.MapLibreMap.prototype.onCircle = function(circle, eventType, callback, options) {
return this._onFeature(circle, eventType, callback, options, { singleFeature: true });
};
bemap.MapLibreMap.prototype.removeListener = function(listener, options) {
if (listener && listener.native && listener.key) {
this.native.off(listener.key, listener.native);
}
if (listener && listener.bemapObject && listener.bemapObject.callback) {
if (listener.key === 'dragFeature') {
listener.bemapObject.events.draggable = false;
listener.bemapObject.callback.draggable = null;
}
}
return this;
};
// =========================================================================
// Layer Management
// =========================================================================
bemap.MapLibreMap.prototype.addLayer = function(layer, options) {
if (layer === null || !bemap.inheritsof(layer, bemap.Layer)) return this;
var _this = this;
var layerId = this._uniqueId('layer');
layer._maplibreId = layerId;
if (bemap.inheritsof(layer, bemap.BemapLayer)) {
// Skip WMS when an alternate background is already in use — either
// an explicit `opts.style` (_hasCustomStyle) OR `ctx.tilesHost`
// (BeNomad Tiles, _hasTilesConfig). Without the tilesHost check, a
// legacy app that constructs MapLibreMap with tilesHost set AND then
// calls `addLayer(new BemapLayer({name:"normal"}))` (the same code
// it ran against LeafletMap) ends up stacking the WMS raster layer
// ON TOP of the loaded PMTiles — symptom: "PMTiles loads then snaps
// back to WMS". Register the layer logically and return.
if (this._hasCustomStyle || this._hasTilesConfig) {
layer.native = { id: layerId, type: 'raster' };
if (layer.map === null) layer.map = this;
bemap.Map.prototype.addLayer.call(this, layer);
return this;
}
var wmsUrl = this.ctx.getBaseUrl() + 'wms?' + this.ctx.getAuthUrlParams()
+ '&geoserver=' + (layer.geoserver ? layer.geoserver : this.ctx.getGeoserver())
+ '&LAYERS=' + (layer.layers ? layer.layers : 'default')
+ '&STYLES=' + (layer.styles ? layer.styles : '')
+ '&FORMAT=' + (layer.format ? layer.format : 'image/png')
+ '&TRANSPARENT=' + (layer.transparent ? 'true' : 'false')
+ '&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&SRS=EPSG:3857&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}';
var addWms = function() {
if (!_this.native.getSource(layerId)) {
_this.native.addSource(layerId, { type: 'raster', tiles: [wmsUrl], tileSize: 256 });
_this.native.addLayer({ id: layerId, type: 'raster', source: layerId });
}
};
this._whenStyleReady(addWms);
layer.native = { id: layerId, type: 'raster' };
} else if (bemap.inheritsof(layer, bemap.WmsLayer)) {
var wmsUrl2 = layer.url + '?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap'
+ '&LAYERS=' + (layer.layers || '') + '&STYLES=' + (layer.styles || '')
+ '&FORMAT=' + (layer.format || 'image/png') + '&SRS=EPSG:3857&WIDTH=256&HEIGHT=256&BBOX={bbox-epsg-3857}';
var addWms2 = function() {
if (!_this.native.getSource(layerId)) {
_this.native.addSource(layerId, { type: 'raster', tiles: [wmsUrl2], tileSize: 256 });
_this.native.addLayer({ id: layerId, type: 'raster', source: layerId });
}
};
this._whenStyleReady(addWms2);
layer.native = { id: layerId, type: 'raster' };
} else if (bemap.inheritsof(layer, bemap.ClusterLayer)) {
layer.native = { id: layerId, type: 'cluster', features: [], markers: [] };
layer._sourceId = layerId + '-src';
} else if (bemap.inheritsof(layer, bemap.VectorTileLayer)) {
var addVt = function() {
if (!_this.native.getSource(layerId)) {
_this.native.addSource(layerId, { type: 'vector', tiles: [layer.url], minzoom: layer.minZoom || 0, maxzoom: layer.maxZoom || 14 });
}
};
this._whenStyleReady(addVt);
layer.native = { id: layerId, type: 'vector' };
} else if (bemap.inheritsof(layer, bemap.VectorLayer)) {
layer.native = { id: layerId, type: 'vector', features: [], markers: [], geoJsonIds: [] };
} else if (bemap.inheritsof(layer, bemap.OsmLayer)) {
var addOsm = function() {
if (!_this.native.getSource(layerId)) {
_this.native.addSource(layerId, { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256 });
_this.native.addLayer({ id: layerId, type: 'raster', source: layerId });
}
};
this._whenStyleReady(addOsm);
layer.native = { id: layerId, type: 'raster' };
}
if (layer.map === null) layer.map = this;
bemap.Map.prototype.addLayer.call(this, layer);
return this;
};
/**
* Load PMTiles vector tiles with optional style and authentication.
* Registers the pmtiles:// protocol, sets up token injection, loads style,
* and applies it to the map. Requires pmtiles.js library.
*
* @public
* @param {bemap.PMTilesLayer} layer PMTilesLayer with url, style, token, etc.
* @param {function} callback Called when loading is complete (optional).
* @return {bemap.MapLibreMap} this
*/
// @todo WIP — PMTiles loading is functional but needs further production testing.
bemap.MapLibreMap.prototype.loadPMTiles = function(layer, callback) {
if (!layer || !bemap.inheritsof(layer, bemap.PMTilesLayer)) {
console.warn('loadPMTiles() requires a bemap.PMTilesLayer');
return this;
}
if (typeof pmtiles === 'undefined') {
console.error('PMTiles library not loaded. Add <script src="pmtiles.js"></script>');
return this;
}
if (!layer.url) {
console.error('PMTilesLayer.url is required');
return this;
}
var _this = this;
var tilesUrl = 'pmtiles://' + layer.url;
var sourceName = layer.sourceName || 'tiles';
// Register PMTiles protocol (safe to call multiple times)
if (!bemap.MapLibreMap._pmtilesRegistered) {
var protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
bemap.MapLibreMap._pmtilesProtocol = protocol;
bemap.MapLibreMap._pmtilesRegistered = true;
}
// Self-healing cache for this archive (no-op if disabled / URL unresolvable).
// layer.url is an arbitrary origin → keep it on the always-safe Range path
// unless the layer explicitly asks for 200-slice.
bemap.MapLibreMap._addTilesArchive(layer.url, layer.sliceMode === '200' ? '200' : 'range');
// Set up token injection for authenticated PMTiles
if (layer.token) {
this._setupTilesAuth(layer.url, layer.token, layer.tokenHeader || 'X-Session-Token');
}
// Load style
var applyStyle = function(style) {
// Inject PMTiles source into style
var placeholder = (style.metadata && style.metadata.source_placeholder) || 'TILES_SOURCE';
style.sources[sourceName] = Object.assign(
{}, style.sources[placeholder] || {},
{ type: 'vector', url: tilesUrl, maxzoom: layer.pmtilesMaxZoom || 14 }
);
if (placeholder !== sourceName && style.sources[placeholder]) {
delete style.sources[placeholder];
}
for (var i = 0; i < style.layers.length; i++) {
if (style.layers[i].source === placeholder) style.layers[i].source = sourceName;
}
if (!style.glyphs) style.glyphs = layer.glyphs || 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf';
// Register callback BEFORE setStyle so we catch the style.load event
if (callback) {
_this.native.once('style.load', function() { callback(_this); });
}
_this._normalizeStyleProjection(style); // SDK owns projection, not the style
_this.native.setStyle(style);
_this._hasCustomStyle = true;
};
if (typeof layer.style === 'string') {
// Style is a URL — fetch it
var xhr = new XMLHttpRequest();
xhr.open('GET', layer.style, true);
if (layer.token) {
xhr.setRequestHeader(layer.tokenHeader || 'X-Session-Token', layer.token);
}
xhr.onload = function() {
if (xhr.status === 200) {
try {
var style = JSON.parse(xhr.responseText);
applyStyle(style);
} catch(e) {
console.error('Failed to parse PMTiles style JSON:', e);
}
} else {
console.error('Failed to load PMTiles style:', xhr.status);
}
};
xhr.send();
} else if (layer.style && typeof layer.style === 'object') {
// Style is an object — use directly
applyStyle(JSON.parse(JSON.stringify(layer.style))); // deep clone
} else {
// No style — create minimal style with the PMTiles source
applyStyle({
version: 8,
glyphs: layer.glyphs || 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {},
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#f0f0f0' } }]
});
}
layer.map = this;
bemap.Map.prototype.addLayer.call(this, layer);
return this;
};
bemap.MapLibreMap._pmtilesRegistered = false;
bemap.MapLibreMap._pmtilesProtocol = null; // retained pmtiles.Protocol (page-global)
bemap.MapLibreMap._tilesArchives = {}; // archiveUrl → true (added once)
bemap.MapLibreMap._fetchIntercepted = false;
bemap.MapLibreMap._tokenMap = {};
/**
* Page-level tile fetch mode: `'200'` (default) reads the archive as
* browser-cacheable 200-slices via `bemap.PMTilesSliceSource`; `'range'` uses
* pmtiles' stock HTTP-Range (206) source (today's path). `?noslice` forces
* `'range'`. Set from `opts.tilesSliceMode` by `_applyTilesSliceOpts`.
* @private
*/
bemap.MapLibreMap._tilesSliceMode = (typeof location !== 'undefined' && location.search && /[?&]noslice\b/.test(location.search)) ? 'range' : '200';
/**
* Apply `opts.tilesSlice*` to the page-level slice mode + the shared RangeGate.
* Everything is read from `opts` with a default; nothing is hardcoded downstream.
* @private
*/
bemap.MapLibreMap._applyTilesSliceOpts = function (opts) {
opts = opts || {};
if (opts.tilesSliceMode === '200' || opts.tilesSliceMode === 'range' || opts.tilesSliceMode === '206') {
bemap.MapLibreMap._tilesSliceMode = (opts.tilesSliceMode === '206') ? 'range' : opts.tilesSliceMode;
}
if (typeof bemap.PMTilesSliceSource === 'function' && bemap.PMTilesSliceSource.defaultGate) {
var g = bemap.PMTilesSliceSource.defaultGate;
if (opts.tilesSliceTimeoutMs != null) g.timeoutMs = opts.tilesSliceTimeoutMs;
if (opts.tilesSliceBodyTimeoutMs != null) g.bodyTimeoutMs = opts.tilesSliceBodyTimeoutMs;
if (opts.tilesSliceMaxRetries != null) g.maxRetries = opts.tilesSliceMaxRetries;
if (Array.isArray(opts.tilesSliceRetryBackoffMs)) g.retryBackoffMs = opts.tilesSliceRetryBackoffMs.slice();
if (opts.tilesSliceConcurrency != null) g.concurrency = (opts.tilesSliceConcurrency > 0 ? opts.tilesSliceConcurrency : 0);
if (opts.tileGate != null) g.setActive(opts.tileGate !== false);
}
};
/**
* Compose the ONE `transformRequest` MapLibre accepts (EVMOVE-307 #7).
*
* MapLibre keeps only the last assignment, so tile auth and the proxy env header
* cannot each install their own. This wraps them:
* - every request first goes through `TilesAuth.buildTransformRequest()` (when
* tile auth exists), so `tilesHost` URLs keep their token / credentials;
* - a request whose URL belongs to `ctx.proxy` additionally gets the proxy
* headers (e.g. `X-BeMap-Env`).
*
* Proxy matching is by **parsed origin + path boundary**, never `startsWith`: a
* raw prefix test would also match `https://<proxy-host>.evil.com/…` and leak
* the header to an attacker-controlled origin.
*
* The proxy is resolved from the Context **per request** (memoised until it
* changes), not captured here, so a later `ctx.setProxy()` re-targets matching
* and headers together. One caveat remains, documented on `setProxy()`: a map
* built on a Context that had NO proxy gets no wrapper at all (see the retrocompat
* line below), so it cannot pick one up afterwards — set `proxy` before
* constructing the map.
*
* With no `ctx.proxy` the tile-auth function is returned UNWRAPPED, so existing
* consumers get byte-identical behaviour.
*
* @private
* @since 2.0.2
* @param {bemap.Context} ctx
* @param {bemap.TilesAuth} [tilesAuth]
* @return {Function|undefined}
*/
bemap.MapLibreMap._buildTransformRequest = function (ctx, tilesAuth) {
var inner = (tilesAuth && typeof tilesAuth.buildTransformRequest === 'function')
? tilesAuth.buildTransformRequest() : null;
var proxy = ctx && (typeof ctx.getProxy === 'function' ? ctx.getProxy() : ctx.proxy);
if (!proxy) return inner || undefined; // unchanged path (retrocompat)
// Resolve the proxy PER REQUEST (memoised until it changes) rather than
// capturing it here. `ctx.setProxy()` is a public runtime API, and the headers
// below are read live from the Context: a captured origin would then match the
// OLD proxy while stamping the NEW environment header on it, and requests to
// the new proxy would get no header at all. Cost is one string compare per
// request; re-parsing happens only when the value actually changes.
var cachedFor = null, pOrigin = null, pPath = '';
function resolveProxyOrigin() {
var cur;
// getProxy() re-validates on read and THROWS on a bad late write. MapLibre's
// transformRequest must never throw (it would break the request pipeline), so
// treat an unusable proxy as "no proxy" here — the same bad value surfaces
// loudly on the next getBaseUrl()/login call.
try { cur = (typeof ctx.getProxy === 'function') ? ctx.getProxy() : ctx.proxy; }
catch (e) { cur = null; }
if (cur !== cachedFor) {
cachedFor = cur;
pOrigin = null;
pPath = '';
if (cur) {
try {
var pu = new URL(cur);
pOrigin = pu.origin;
pPath = (pu.pathname || '').replace(/\/+$/, '');
} catch (e2) { /* normalisation already validated it; stay silent */ }
}
}
return pOrigin;
}
function isProxyUrl(url) {
if (!resolveProxyOrigin()) return false;
var parsed;
try {
parsed = new URL(url, (typeof window !== 'undefined' && window.location && window.location.href) || 'http://localhost/');
} catch (e) { return false; }
if (parsed.origin !== pOrigin) return false; // exact origin — blocks look-alike hosts
if (!pPath) return true; // proxy is a bare origin
var path = parsed.pathname || '';
return (path === pPath) || (path.indexOf(pPath + '/') === 0);
}
return function (url, resourceType) {
var out = inner ? inner(url, resourceType) : null;
if (!out) out = { url: url };
if (isProxyUrl(url)) {
// Same rule as resolveProxyOrigin(): never let a Context read throw out of
// transformRequest (getProxyHeaders → getProxy → re-validation).
var ph = {};
try { if (typeof ctx.getProxyHeaders === 'function') ph = ctx.getProxyHeaders(); }
catch (e) { ph = {}; }
var hasAny = false;
for (var k in ph) { if (Object.prototype.hasOwnProperty.call(ph, k)) { hasAny = true; break; } }
if (hasAny) {
var headers = out.headers || {};
for (var hk in ph) {
if (Object.prototype.hasOwnProperty.call(ph, hk)) headers[hk] = ph[hk];
}
out.headers = headers;
}
}
return out;
};
};
/**
* Wire one tiles archive URL into the (page-global) pmtiles Protocol, choosing
* the source by `_tilesSliceMode` and wrapping it in the self-healing
* `bemap.RecoverablePromiseCache` when enabled:
* - `'200'` → `bemap.PMTilesSliceSource` (browser-cacheable 200-slices).
* - `'range'` → stock source (HTTP Range 206), pmtiles' default.
* Idempotent per URL.
*
* Fully guarded — a no-op (leaving pmtiles' stock auto-create, i.e. today's
* behaviour) when there is nothing custom to add, pmtiles/the classes are
* absent, the Protocol wasn't retained, the URL is empty, or anything throws.
* The archive is `protocol.add`-ed under its exact URL key; a key mismatch just
* means the Protocol auto-creates its own default for the real URL — a mismatch
* cannot break tile loading. Construction is lazy (no eager fetch).
* @private
* @param {String} archiveUrl the pmtiles archive URL (no `pmtiles://` prefix).
* @param {String} [forceMode] override the page-level `_tilesSliceMode` for THIS
* archive (`'200'` | `'range'`). Used by the raw-`opts.tiles` / `loadPMTiles`
* paths to keep arbitrary origins (S3/R2/presigned that don't serve `?r=`) on
* the always-safe Range path unless the caller explicitly opts into `'200'`.
* @return {Boolean} true if a custom source/cache was wired for this URL.
*/
bemap.MapLibreMap._addTilesArchive = function (archiveUrl, forceMode, versioned) {
if (!archiveUrl) return false;
if (typeof pmtiles === 'undefined' || typeof pmtiles.PMTiles !== 'function') return false;
var proto = bemap.MapLibreMap._pmtilesProtocol;
if (!proto || typeof proto.add !== 'function') return false;
if (!bemap.MapLibreMap._tilesArchives) bemap.MapLibreMap._tilesArchives = {};
if (bemap.MapLibreMap._tilesArchives[archiveUrl]) return true; // already wired
var mode = forceMode || bemap.MapLibreMap._tilesSliceMode;
var useSlice = (mode === '200') && (typeof bemap.PMTilesSliceSource === 'function');
var cache;
if (typeof bemap.RecoverablePromiseCache === 'function' && bemap.RecoverablePromiseCache.enabled !== false
&& typeof pmtiles.SharedPromiseCache === 'function') {
cache = new bemap.RecoverablePromiseCache();
}
// Nothing custom to wire (stock range + stock cache) → let the Protocol
// auto-create exactly as today (avoids a redundant explicit instance).
if (!useSlice && !cache) return false;
// Mark BEFORE proto.add so a throwing/duplicate add is not re-attempted on
// every construction — a failed add safely falls back to the Protocol's own
// auto-create for that URL (today's behaviour), keeping the guard effective.
bemap.MapLibreMap._tilesArchives[archiveUrl] = true;
try {
var source = useSlice ? new bemap.PMTilesSliceSource(archiveUrl, { versioned: !!versioned }) : archiveUrl;
proto.add(new pmtiles.PMTiles(source, cache)); // cache undefined → pmtiles default
return true;
} catch (e) {
return false; // fell back to Protocol auto-create (no regression); stays marked
}
};
/**
* Return the resolved BeNomad-Tiles configuration for this map — the effective
* values after opts + Context + page-level defaults are merged. Useful for
* debugging ("what is actually in effect?"). All fields are read-only snapshots.
* @public
* @since 2.0.0
* @return {{tilesHost:(String|null), tilesSliceMode:String, recoverableCache:Boolean, rangeCacheMode:(String|null), tileGate:(Object|null), tilesAuth:(Object|null), serviceWorker:{enabled:Boolean, path:(String|null)}}}
*/
bemap.MapLibreMap.prototype.getTilesConfig = function () {
var gate = (typeof bemap.PMTilesSliceSource === 'function') ? bemap.PMTilesSliceSource.defaultGate : null;
var cfg = {
tilesHost: (this.ctx && this.ctx.tilesHost) || null,
tilesSliceMode: bemap.MapLibreMap._tilesSliceMode,
recoverableCache: (typeof bemap.RecoverablePromiseCache === 'function') ? (bemap.RecoverablePromiseCache.enabled !== false) : false,
rangeCacheMode: (typeof bemap.RangeFetchPolicy === 'function') ? (bemap.RangeFetchPolicy.defaultMode || 'auto') : null,
tileGate: gate ? {
active: gate.isActive(),
concurrency: gate.concurrency, // 0 = uncapped
timeoutMs: gate.timeoutMs, // pre-header (TTFB) timeout
bodyTimeoutMs: gate.bodyTimeoutMs, // post-header body safety cap
maxRetries: gate.maxRetries,
retryBackoffMs: (gate.retryBackoffMs || []).slice()
} : null,
tilesAuth: null,
serviceWorker: { enabled: false, path: null }
};
if (this._tilesAuth && this._tilesAuth._authConfig) {
cfg.tilesAuth = this._tilesAuth._authConfig;
} else if (this.ctx && typeof this.ctx.getTilesAuth === 'function') {
cfg.tilesAuth = this.ctx.getTilesAuth();
}
if (this._browserCache) {
cfg.serviceWorker = {
enabled: (typeof this._browserCache.isEnabled === 'function') ? this._browserCache.isEnabled() : true,
path: this._browserCache.swPath || null
};
}
return cfg;
};
/**
* Live-toggle the resilience gate (concurrency cap + timeout + retry) WITHOUT a
* reload — the slice source reads it per request. Fires `onTileGateChange` and
* any `'tilegatechange'` listeners. This is the SDK-supported way to build a
* diagnostic on/off button (the demo/bemaputnik call this).
* @public
* @since 2.0.0
* @param {Boolean} active
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype.setTileGateActive = function (active) {
var state = (active !== false);
if (typeof bemap.PMTilesSliceSource === 'function' && bemap.PMTilesSliceSource.defaultGate) {
bemap.PMTilesSliceSource.defaultGate.setActive(state);
state = bemap.PMTilesSliceSource.defaultGate.isActive();
}
if (typeof this._onTileGateChange === 'function') { try { this._onTileGateChange(state); } catch (e) {} }
return this;
};
/** @public @since 2.0.0 @return {Boolean} */
bemap.MapLibreMap.prototype.getTileGateActive = function () {
return (typeof bemap.PMTilesSliceSource === 'function' && bemap.PMTilesSliceSource.defaultGate)
? bemap.PMTilesSliceSource.defaultGate.isActive() : false;
};
/**
* Set the tile fetch mode (`'200'` | `'range'`) for archives wired AFTER this
* call. Archives already registered keep their source; call before loading a
* style, or reload the style, for a live A/B. Returns this.
* @public
* @since 2.0.0
* @param {String} mode `'200'` or `'range'` (`'206'` accepted as an alias for `'range'`)
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype.setTilesSliceMode = function (mode) {
if (mode === '200' || mode === 'range' || mode === '206') {
bemap.MapLibreMap._tilesSliceMode = (mode === '206') ? 'range' : mode;
}
return this;
};
/**
* Classify a MapLibre 'error' payload as a RECOVERABLE tile failure — one a
* debounced source refresh can heal. Auth statuses (401/403) are excluded
* (TilesAuth owns those); other definite HTTP statuses except 408/429/5xx
* (e.g. 404) are permanent and excluded too. Status-less errors count when
* they look like a timeout / abort / network blip.
* @private
* @param {Object} err the `e.error` from MapLibre's 'error' event
* @return {Boolean}
*/
bemap.MapLibreMap._isRecoverableTileError = function (err) {
if (!err) return false;
var status = err.status || (err.statusText ? parseInt(err.statusText, 10) : 0) || 0;
if (status === 401 || status === 403) return false; // auth — TilesAuth owns these
// Transient server: 429 (rate limit), 5xx (incl. the Worker's 3 s deadline
// → 504), 408 (request timeout). The gate already retried; refresh heals.
if (status === 408 || status === 429 || (status >= 500 && status <= 599)) return true;
if (status > 0) return false; // other definite HTTP status (404…) — permanent
var name = err.name || '';
if (name === 'AbortError' || name === 'TimeoutError') return true; // gate TTFB timeout / aborted fetch
if (name === 'TypeError') return true; // fetch network failure
var msg = String(err.message || '');
// Status-less wrapped messages (pmtiles / slice source): transient codes or
// timeout / abort / network wording.
return /\b(?:408|429|5\d\d)\b/.test(msg) ||
/\btime[d]?[\s-]?out\b|\btimeout\b|\baborted?\b|failed to fetch|networkerror|network error|load failed/i.test(msg);
};
/**
* Schedule ONE debounced (`tilesErrorRefreshMs`, default 4000 ms) refresh of
* the tile-backed sources — the terminal safety net after a recoverable tile
* failure. Repeated errors inside the window coalesce into the single pending
* refresh; `tilesErrorRefreshMs: 0` disables the net entirely.
* @private
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype._scheduleTileRefresh = function () {
if (!(this._tilesErrorRefreshMs > 0)) return this; // disabled by config
if (this._tileRefreshTimer) return this; // one pending refresh at a time
var self = this;
this._tileRefreshTimer = setTimeout(function () {
self._tileRefreshTimer = null;
self._refreshTileSources();
}, this._tilesErrorRefreshMs);
return this;
};
/**
* Re-request errored tiles on every tile-backed source of the current style:
* `map.refreshTiles(sourceId)` (MapLibre ≥ 5) with a `source.setUrl(url)`
* fallback. GeoJSON overlays are skipped — they cannot grey out. Best-effort:
* every step is guarded, a failure on one source never blocks the others.
* @private
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype._refreshTileSources = function () {
var map = this.native;
if (!map || typeof map.getStyle !== 'function') return this;
var style = null;
try { style = map.getStyle(); } catch (e) { return this; }
var sources = (style && style.sources) || {};
for (var id in sources) {
if (!Object.prototype.hasOwnProperty.call(sources, id)) continue;
var type = sources[id] && sources[id].type;
if (type !== 'vector' && type !== 'raster' && type !== 'raster-dem') continue;
var refreshed = false;
try {
if (typeof map.refreshTiles === 'function') { map.refreshTiles(id); refreshed = true; }
} catch (e2) { /* fall through to setUrl below */ }
if (refreshed) continue;
try {
var src = (typeof map.getSource === 'function') ? map.getSource(id) : null;
if (src && typeof src.setUrl === 'function' && src.url) src.setUrl(src.url);
} catch (e3) { /* best-effort only */ }
}
return this;
};
/**
* Load BeMap PMTiles vector tiles. Auto-authenticates using bemap.Context credentials.
*
* Usage:
* map.loadBeMapTiles(); // uses defaults + context credentials
* map.loadBeMapTiles({ url: 'https://mptiles-api.benomad.net', tilesFile: 'Europe.pmtiles', style: 'style_liberty.json' });
* map.loadBeMapTiles({ token: 'existing-jwt' }); // skip login
*
* **NOT SUPPORTED behind a `proxy`** (EVMOVE-307): this legacy path sends
* `Authorization: Basic` from the browser by design, which is exactly what
* `ctx.proxy` exists to prevent, and it errors without credentials. Use
* `ctx.tilesHost` (+ `ctx.proxy`) instead — no house app calls this helper.
*
* @deprecated Since 2.0.0 — prefer `ctx.tilesHost` on the Context, which
* routes through `bemap.TilesAuth` + the fetch interceptor
* instead of a one-shot login + setStyle.
* @public
* @param {Object} options
* @param {String} options.url Tiles API base URL — REQUIRED unless `ctx.tilesHost` is set.
* @param {String} options.tilesFile PMTiles map alias or tileset name (default: 'default' — the Worker chooses).
* @param {String} options.style Style name (default: 'openfreemap_graylevel.json').
* @param {String} options.token JWT token — if provided, skips login.
* @param {String} options.login Override context login.
* @param {String} options.password Override context password.
* @param {function} options.onSuccess callback(map, token).
* @param {function} options.onError callback(errorMessage).
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype.loadBeMapTiles = function(options) {
var opts = options || {};
var _this = this;
// Derive the tiles base from `ctx.tilesHost` when present — the
// staging hostname that lived here in the prototype has been removed
// because it leaked customer credentials to a non-production host.
var tilesApi = opts.url
|| (this.ctx && typeof this.ctx.getTilesBaseUrl === 'function' && this.ctx.getTilesBaseUrl())
|| null;
if (!tilesApi) {
var msg = 'loadBeMapTiles: no URL provided and ctx.tilesHost not set on Context. Pass options.url or set Context.tilesHost.';
console.error('[bemap] ' + msg);
if (typeof opts.onError === 'function') opts.onError(msg);
return this;
}
// Precedence opts.tilesFile -> ctx.tilesFile -> ctx.geoserver -> 'default';
// with no explicit map and no geoserver the Worker picks the tileset
// server-side (via _config/maps.json). HERE-licensed tilesets are gated
// server-side and would 403 for accounts without ACL.
var tilesFile = (this.ctx && typeof this.ctx.resolveTilesFile === 'function')
? this.ctx.resolveTilesFile(opts.tilesFile)
: (opts.tilesFile || 'default');
var styleName = opts.style || 'openfreemap_graylevel.json';
if (typeof pmtiles === 'undefined') {
console.error('[bemap] loadBeMapTiles: pmtiles.js not loaded');
if (opts.onError) opts.onError('PMTiles library not loaded');
return this;
}
var doLoad = function(token) {
// Tile caching is owned by bemap.BrowserCache (the tile Service Worker,
// gated by opts.serviceWorker/browserCache and auto-off under 200-slice) — not
// here. The old inline `navigator.serviceWorker.register('sw-tiles.js')` was
// removed (PMT-26): that filename was wrong (real file: bemap-sw-tiles.js) and
// path-less, so it always 404'd — "A bad HTTP response code (404) was received
// when fetching the script." — with no functional effect.
var tiles = new bemap.PMTilesLayer({
url: tilesApi + '/' + tilesFile,
style: tilesApi + '/styles/' + styleName,
token: token,
tokenHeader: 'X-Session-Token',
sourceName: 'tiles',
maxZoom: 14
});
_this.loadPMTiles(tiles, function(map) {
if (opts.onSuccess) opts.onSuccess(map, token);
});
};
// If token already provided, use it directly
if (opts.token) {
doLoad(opts.token);
return this;
}
// PROXY MODE (EVMOVE-307) — REFUSE before touching credentials. This legacy
// path posts `Authorization: Basic` straight from the browser, which would
// break the documented invariant ("with `proxy` set, no BeMap credential is
// sent on any request") for a mixed `{ proxy, login, password }` config.
// Documentation alone is not a security boundary, so this is enforced in code:
// no credential is read, no XHR is opened.
if (this.ctx && (typeof this.ctx.getProxy === 'function' ? this.ctx.getProxy() : this.ctx.proxy)) {
var proxyMsg = 'loadBeMapTiles is not supported when ctx.proxy is set — it would send '
+ 'Authorization: Basic from the browser. Use ctx.tilesHost (the tiles login then goes '
+ 'through your proxy). See docs/integration-proxy.md.';
if (typeof console !== 'undefined' && console.error) console.error('[bemap] ' + proxyMsg);
if (opts.onError) opts.onError(proxyMsg);
return this;
}
// Auto-login using context credentials
var login = opts.login || this.ctx.login;
var password = opts.password || this.ctx.password;
if (!login || !password) {
console.error('[bemap] loadBeMapTiles: no credentials — set login/password in Context or options');
if (opts.onError) opts.onError('No credentials');
return this;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', tilesApi + '/api/login', true);
xhr.setRequestHeader('Authorization', 'Basic ' + btoa(login + ':' + password));
xhr.onload = function() {
try {
var data = JSON.parse(xhr.responseText);
if (data.ok && data.token) {
doLoad(data.token);
} else {
if (opts.onError) opts.onError('Login failed');
}
} catch(e) {
if (opts.onError) opts.onError('Parse error');
}
};
xhr.onerror = function() {
if (opts.onError) opts.onError('Network error');
};
xhr.send();
return this;
};
bemap.MapLibreMap.prototype.removeLayer = function(layer) {
if (layer && bemap.inheritsof(layer, bemap.Layer) && layer._maplibreId) {
// 1. Clear the layer's CONTENTS first — HTML markers (DOM elements
// stored in layer.native.markers), GeoJSON sub-layers, feature
// registry entries. Without this, addMarker(m, {layer}) markers
// would remain visible after the parent layer was removed.
try { this.clearLayer(layer); } catch (e) {}
// 2. Remove the native layer + source for this layer's own
// raster/vector backing (e.g. WMS BemapLayer, RasterLayer).
try {
if (this.native.getLayer(layer._maplibreId)) this.native.removeLayer(layer._maplibreId);
if (this.native.getSource(layer._maplibreId)) this.native.removeSource(layer._maplibreId);
} catch(e) {}
// 3. Defensive sweep — clearLayer above usually handles these, but
// re-run to catch any GeoJSON IDs that escaped (e.g. polygons
// that registered both `-fill` and `-stroke` ids).
if (layer.native && layer.native.geoJsonIds) {
for (var i = 0; i < layer.native.geoJsonIds.length; i++) {
try {
var gid = layer.native.geoJsonIds[i];
if (this.native.getLayer(gid)) this.native.removeLayer(gid);
if (this.native.getSource(gid)) this.native.removeSource(gid);
} catch(e2) {}
}
}
layer.map = null;
}
return this;
};
bemap.MapLibreMap.prototype.clearLayer = function(layer) {
if (!layer || !layer.native) return this;
// Determine the catalog type for a given bemap feature so we can unregister
// on clear (otherwise the next setStyle replays cleared polylines/markers
// and they pop back up — root cause of "first polyline always on top in
// true colour even after clicking another route" in evsmartrouting).
var _cat = this._overlayCatalog;
function _catalogTypeFor(feat) {
if (!feat) return null;
if (bemap.Polyline && bemap.inheritsof(feat, bemap.Polyline)) return 'polyline';
if (bemap.Polygon && bemap.inheritsof(feat, bemap.Polygon)) return 'polygon';
if (bemap.Circle && bemap.inheritsof(feat, bemap.Circle)) return 'circle';
if (bemap.Popup && bemap.inheritsof(feat, bemap.Popup)) return 'popup';
if (bemap.MultiMarker && bemap.inheritsof(feat, bemap.MultiMarker)) return 'multimarker';
if (bemap.Marker && bemap.inheritsof(feat, bemap.Marker)) return 'marker';
return null;
}
// Clear HTML markers + registries
if (layer.native.markers) {
for (var i = 0; i < layer.native.markers.length; i++) {
var m = layer.native.markers[i];
if (m.remove) m.remove();
}
layer.native.markers = [];
}
// Clean up feature registry + catalog entries for features in this layer.
if (layer.native.features) {
for (var f = 0; f < layer.native.features.length; f++) {
var feat = layer.native.features[f];
if (feat && feat._bemapId) {
delete this._featureRegistry[feat._bemapId];
delete this._markerElements[feat._bemapId];
if (feat._maplibreSourceId) delete this._geoJsonData[feat._maplibreSourceId];
}
// Unregister from the overlay catalogue so a future setStyle replay
// doesn't resurrect this feature.
if (_cat && feat && feat.__catalogId) {
var _ct = _catalogTypeFor(feat);
if (_ct) _cat.unregister(_ct, feat.__catalogId);
}
}
}
// Clear GeoJSON layers/sources
if (layer.native.geoJsonIds) {
for (var j = 0; j < layer.native.geoJsonIds.length; j++) {
try {
var gid = layer.native.geoJsonIds[j];
if (this.native.getLayer(gid)) this.native.removeLayer(gid);
var srcId = gid.replace('-fill', '-src').replace('-layer', '-src');
if (this.native.getSource(srcId)) this.native.removeSource(srcId);
// Also clean the map-level tracking array
var idx = this._geoJsonLayerIds.indexOf(gid);
if (idx > -1) this._geoJsonLayerIds.splice(idx, 1);
} catch(e) {}
}
layer.native.geoJsonIds = [];
}
layer.native.features = [];
// Clear cluster source/layers if this is a ClusterLayer
if (layer._clusterLayerIds) {
for (var ci = 0; ci < layer._clusterLayerIds.length; ci++) {
try { if (this.native.getLayer(layer._clusterLayerIds[ci])) this.native.removeLayer(layer._clusterLayerIds[ci]); } catch(e3) {}
}
layer._clusterLayerIds = [];
}
if (layer._clusterSourceId) {
try { if (this.native.getSource(layer._clusterSourceId)) this.native.removeSource(layer._clusterSourceId); } catch(e4) {}
layer._clusterSourceId = null;
}
if (layer._clusterFeatures) layer._clusterFeatures = [];
if (layer._clusterMarkerNatives) layer._clusterMarkerNatives = {};
if (layer._clusterBubbles) {
for (var bi = 0; bi < layer._clusterBubbles.length; bi++) {
layer._clusterBubbles[bi].remove();
}
layer._clusterBubbles = [];
}
layer._lastClusterKey = null;
if (layer._clusterBemapObjects) layer._clusterBemapObjects = {};
return this;
};
bemap.MapLibreMap.prototype.visibleLayer = function(layer, visible) {
if (layer && bemap.inheritsof(layer, bemap.Layer)) {
layer.visible = visible;
if (layer._maplibreId && this.native.getLayer(layer._maplibreId)) {
try { this.native.setLayoutProperty(layer._maplibreId, 'visibility', visible ? 'visible' : 'none'); } catch(e) {}
}
if (layer.native && layer.native.markers) {
for (var i = 0; i < layer.native.markers.length; i++) {
var el = layer.native.markers[i].getElement ? layer.native.markers[i].getElement() : null;
if (el) el.style.display = visible ? '' : 'none';
}
}
if (layer.native && layer.native.geoJsonIds) {
for (var j = 0; j < layer.native.geoJsonIds.length; j++) {
try { this.native.setLayoutProperty(layer.native.geoJsonIds[j], 'visibility', visible ? 'visible' : 'none'); } catch(e2) {}
}
}
}
return this;
};
/**
* Re-stack a layer relative to the others. MapLibre keeps layers in an
* ordered array on the native style; later entries paint on top of earlier
* ones. To raise a bemap Layer, walk its tracked sub-layers (`geoJsonIds`)
* and `moveLayer` each to the top. To lower, move them before the first
* non-base layer. HTML markers (DOM elements, no style-array entry) get a
* matching CSS `z-index` so DOM stacking follows the same intent.
*
* Threshold: zIndex ≥ 50 counts as "on top", everything else "behind".
* @public
* @param {bemap.Layer} layer
* @param {Number} zIndex
* @return {bemap.MapLibreMap} this
*/
bemap.MapLibreMap.prototype.zIndexLayer = function(layer, zIndex) {
if (!layer) return this;
layer.zIndex = zIndex;
var high = (zIndex >= 50);
var nat = this.native;
if (!nat || !nat.getStyle) return this;
// --- GeoJSON sub-layers (polygons / polylines / circles / heatmaps) ---
var ids = (layer.native && layer.native.geoJsonIds) ? layer.native.geoJsonIds.slice() : [];
for (var i = 0; i < ids.length; i++) {
try {
if (!nat.getLayer(ids[i])) continue;
if (high) {
nat.moveLayer(ids[i]); // no beforeId = move to top
} else {
// Move BEFORE the first non-base layer (lowest possible without
// sinking into the base map layers). Best-effort.
var styleLayers = nat.getStyle().layers || [];
var beforeId = null;
for (var sl = 0; sl < styleLayers.length; sl++) {
var lid = styleLayers[sl].id;
if (this._geoJsonLayerIds && this._geoJsonLayerIds.indexOf(lid) > -1 && ids.indexOf(lid) === -1) {
beforeId = lid;
break;
}
}
if (beforeId) nat.moveLayer(ids[i], beforeId);
}
} catch (e) { /* layer might be mid-style-reload */ }
}
// --- HTML markers (maplibregl.Marker DOM elements) ---
// MapLibre markers are DOM siblings of the WebGL canvas. The canvas is
// OPAQUE (base-map tiles render with a non-transparent background), so
// a negative z-index would sink markers behind the canvas and they'd
// become invisible — not "below the polyline", just gone. Therefore
// markers always stay above the canvas; their CSS z-index only orders
// them relative to OTHER HTML markers. The polyline-vs-polyline canvas
// reordering above still works, but markers vs polyline is a hard
// architectural limit in MapLibre with HTML markers. Symbol-layer
// markers (canvas) would lift this, but that's a separate refactor.
var domZ = high ? '1000' : '500'; // both positive — never sink below canvas
var markers = (layer.native && layer.native.markers) ? layer.native.markers : [];
for (var mi = 0; mi < markers.length; mi++) {
var m = markers[mi];
if (m && typeof m.getElement === 'function') {
var el = m.getElement();
if (el) el.style.zIndex = domZ;
}
}
return this;
};
bemap.MapLibreMap.prototype.refreshLayer = function(layer) {
return this;
};
// =========================================================================
// GeoJSON Feature Dragging
// =========================================================================
bemap.MapLibreMap.prototype._draggableFeature = function(declaredObj, callback, options, mode) {
var _this = this;
var opts = options || {};
var dragging = false;
var dragFeature = null;
var dragBemapObj = null;
var startLngLat = null;
var prevLngLat = null;
var canvas = this.native.getCanvasContainer();
if (declaredObj) {
if (!declaredObj.events) declaredObj.events = {};
if (!declaredObj.callback) declaredObj.callback = {};
declaredObj.events.draggable = true;
declaredObj.callback.draggable = callback;
}
if (!this.events.dragFeature) {
var onMouseDown = function(evt) {
if (!_this._geoJsonLayerIds.length) return;
var existingLayers = [];
for (var i = 0; i < _this._geoJsonLayerIds.length; i++) {
try { if (_this.native.getLayer(_this._geoJsonLayerIds[i])) existingLayers.push(_this._geoJsonLayerIds[i]); } catch(e) {}
}
if (!existingLayers.length) return;
var features = _this.native.queryRenderedFeatures(evt.point, { layers: existingLayers });
if (!features || !features.length) return;
var bemapObj = _this._getOwnFromProperties(features[0]);
if (!bemapObj) return;
// Check if this object has draggable enabled
if (bemapObj.events && bemapObj.events.draggable) {
dragFeature = features[0];
dragBemapObj = bemapObj;
startLngLat = evt.lngLat;
prevLngLat = evt.lngLat;
dragging = true;
_this.native.dragPan.disable();
canvas.style.cursor = 'grabbing';
evt.preventDefault();
}
};
var onMouseMove = function(evt) {
if (!dragging || !dragBemapObj) return;
var dLng = evt.lngLat.lng - prevLngLat.lng;
var dLat = evt.lngLat.lat - prevLngLat.lat;
prevLngLat = evt.lngLat;
// Translate the GeoJSON geometry
var sourceId = dragBemapObj._maplibreSourceId;
if (!sourceId) return;
var src = _this.native.getSource(sourceId);
if (!src) return;
var storedData = _this._geoJsonData[sourceId];
if (!storedData) return;
var geojson = JSON.parse(JSON.stringify(storedData));
_translateGeojsonCoords(geojson.geometry, dLng, dLat);
_this._geoJsonData[sourceId] = geojson;
src.setData(geojson);
// Update bemap coordinates
if (bemap.inheritsof(dragBemapObj, bemap.Polyline) || bemap.inheritsof(dragBemapObj, bemap.Polygon)) {
var coords = dragBemapObj.getCoordinates();
for (var i = 0; i < coords.length; i++) {
coords[i].setLon(coords[i].getLon() + dLng).setLat(coords[i].getLat() + dLat);
}
} else if (bemap.inheritsof(dragBemapObj, bemap.Circle)) {
var c = dragBemapObj.getCoordinate();
c.setLon(c.getLon() + dLng).setLat(c.getLat() + dLat);
}
};
var onMouseUp = function(evt) {
if (!dragging) return;
dragging = false;
_this.native.dragPan.enable();
canvas.style.cursor = '';
if (dragBemapObj && dragBemapObj.callback && dragBemapObj.callback.draggable) {
var coord = evt.lngLat ? new bemap.Coordinate(evt.lngLat.lng, evt.lngLat.lat) : new bemap.Coordinate();
dragBemapObj.callback.draggable(new bemap.MapEvent({
native: evt,
bemapObject: dragBemapObj,
coordinate: coord,
startCoordinate: startLngLat ? new bemap.Coordinate(startLngLat.lng, startLngLat.lat) : new bemap.Coordinate(),
map: _this
}));
}
dragFeature = null;
dragBemapObj = null;
};
this.native.on('mousedown', onMouseDown);
this.native.on('mousemove', onMouseMove);
this.native.on('mouseup', onMouseUp);
this.events.dragFeature = new bemap.Listener({
native: { down: onMouseDown, move: onMouseMove, up: onMouseUp },
key: 'dragFeature',
bemapObject: declaredObj
});
}
return this.events.dragFeature;
};
// Helper: translate all coordinates in a GeoJSON geometry
function _translateGeojsonCoords(geometry, dLng, dLat) {
if (geometry.type === 'Point') {
geometry.coordinates[0] += dLng;
geometry.coordinates[1] += dLat;
} else if (geometry.type === 'LineString') {
for (var i = 0; i < geometry.coordinates.length; i++) {
geometry.coordinates[i][0] += dLng;
geometry.coordinates[i][1] += dLat;
}
} else if (geometry.type === 'Polygon') {
for (var r = 0; r < geometry.coordinates.length; r++) {
for (var j = 0; j < geometry.coordinates[r].length; j++) {
geometry.coordinates[r][j][0] += dLng;
geometry.coordinates[r][j][1] += dLat;
}
}
}
}
// =========================================================================
// Camera / Navigation
// =========================================================================
bemap.MapLibreMap.prototype.move = function(lon, lat, zoom, options) {
var opts = options || {};
var o = { center: [lon, lat] };
if (zoom) o.zoom = zoom;
if (opts.animate === false) {
this.native.jumpTo(o);
} else {
o.duration = opts.duration || 800;
o.essential = true;
this.native.easeTo(o);
}
return this;
};
bemap.MapLibreMap.prototype.flyTo = function(lon, lat, zoom, options) {
var opts = options || {};
this.native.flyTo({
center: [lon, lat],
zoom: zoom || this.native.getZoom(),
duration: opts.duration || 2000,
essential: true,
curve: 1.42,
speed: 1.2
});
return this;
};
bemap.MapLibreMap.prototype.moveToBoundingBox = function(boundingBox, options) {
if (boundingBox && bemap.inheritsof(boundingBox, bemap.BoundingBox)) {
var opts = options || {};
// Normalize: ensure min < max (callers may pass points in any order)
var west = Math.min(boundingBox.minLon, boundingBox.maxLon);
var south = Math.min(boundingBox.minLat, boundingBox.maxLat);
var east = Math.max(boundingBox.minLon, boundingBox.maxLon);
var north = Math.max(boundingBox.minLat, boundingBox.maxLat);
var fitOpts = { padding: opts.padding || 50 };
if (opts.fly) {
fitOpts.duration = opts.duration || 2000;
fitOpts.essential = true;
} else {
fitOpts.duration = opts.duration || 500;
}
this.native.fitBounds([[west, south], [east, north]], fitOpts);
}
return this;
};
bemap.MapLibreMap.prototype.moveToLayerData = function(layer, options) {
// Compute bounds from registered features
if (!layer || !layer.native || !layer.native.features) return this;
var minLon = 180, minLat = 90, maxLon = -180, maxLat = -90;
var found = false;
for (var i = 0; i < layer.native.features.length; i++) {
var obj = layer.native.features[i];
if (obj && obj.coordinate) {
var lon = obj.coordinate.getLon ? obj.coordinate.getLon() : obj.coordinate.lon;
var lat = obj.coordinate.getLat ? obj.coordinate.getLat() : obj.coordinate.lat;
if (lon < minLon) minLon = lon;
if (lat < minLat) minLat = lat;
if (lon > maxLon) maxLon = lon;
if (lat > maxLat) maxLat = lat;
found = true;
}
}
if (found) {
this.native.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 50 });
}
return this;
};
bemap.MapLibreMap.prototype.zoom = function(zoom, options) {
this.native.setZoom(zoom);
return this;
};
bemap.MapLibreMap.prototype.getZoom = function() {
return Math.round(this.native.getZoom());
};
/**
* @deprecated Since 2.0 — use setBearing(deg) for a cross-engine API.
* rotation() stays callable but emits a one-time warn so
* customer code can migrate without surprises.
*/
bemap.MapLibreMap.prototype.rotation = function(angle, options) {
bemap.MapLibreMap._rotationDeprecationWarned = bemap.MapLibreMap._rotationDeprecationWarned || false;
if (!bemap.MapLibreMap._rotationDeprecationWarned) {
bemap.MapLibreMap._rotationDeprecationWarned = true;
console.warn('bemap.MapLibreMap.rotation() is deprecated since 2.0 — use setBearing(deg) for the cross-engine API.');
}
this.native.setBearing(angle);
return this;
};
bemap.MapLibreMap.prototype.getRotation = function() {
return this.native.getBearing();
};
// resize() fires the native 'resize' event, whose listener (see constructor)
// nudges the camera so the resized viewport re-requests its tiles.
bemap.MapLibreMap.prototype.refresh = function(options) {
if (this.native) this.native.resize();
return this;
};
/**
* Fetch the Worker's map config — `GET /api/maps` — resolving with
* `{ default, aliases, tilesets }` so an app can build a map-picker. Requires
* `ctx.tilesHost`; the session token is attached automatically by the TilesAuth
* fetch interceptor (it waits for login to complete first).
* @public
* @since 2.0.0
* @return {Promise<Object>}
*/
bemap.MapLibreMap.prototype.fetchAvailableMaps = function() {
return this._fetchTilesApi(this.ctx && this.ctx.getTilesMapsUrl && this.ctx.getTilesMapsUrl(), 'fetchAvailableMaps');
};
/**
* Fetch the Worker's allow-listed styles — `GET /api/styles` — resolving with
* `{ styles: [...] }`. See {@link bemap.MapLibreMap#fetchAvailableMaps}.
* @public
* @since 2.0.0
* @return {Promise<Object>}
*/
bemap.MapLibreMap.prototype.fetchAvailableStyles = function() {
return this._fetchTilesApi(this.ctx && this.ctx.getTilesStylesUrl && this.ctx.getTilesStylesUrl(), 'fetchAvailableStyles');
};
/**
* Fetch the filename the `default` alias currently resolves to —
* `GET /api/default` — resolving with `{ default: '<file>'|null }`.
* @public
* @since 2.0.0
* @return {Promise<Object>}
*/
bemap.MapLibreMap.prototype.fetchDefaultMap = function() {
return this._fetchTilesApi(this.ctx && this.ctx.getTilesDefaultUrl && this.ctx.getTilesDefaultUrl(), 'fetchDefaultMap');
};
/**
* Fetch the name of the Worker's default MapLibre style — `GET /api/default-style`
* — resolving with `{ default: '<style-name>'|null }`. This is the style applied
* when the developer pins none; see {@link bemap.MapLibreMap#fetchAvailableStyles}.
* @public
* @since 2.0.0
* @return {Promise<Object>}
*/
bemap.MapLibreMap.prototype.fetchDefaultStyle = function() {
return this._fetchTilesApi(this.ctx && this.ctx.getTilesDefaultStyleUrl && this.ctx.getTilesDefaultStyleUrl(), 'fetchDefaultStyle');
};
/**
* Default-style "fallback then load": the constructor already painted the tiny
* `bemap.fallbackStyle` for an instant first frame; once login has completed
* this fetches the Worker's current default style name and swaps the live charte
* in via `setStyle()` (overlay-preserving). Failures are swallowed so the tiny
* fallback simply stays. Called once, on init, only when the developer pinned
* no `style`.
* @private
*/
bemap.MapLibreMap.prototype._refreshDefaultStyleFromServer = function() {
var self = this;
var url = this.ctx && typeof this.ctx.getTilesDefaultStyleUrl === 'function' && this.ctx.getTilesDefaultStyleUrl();
if (!url || typeof fetch !== 'function') return;
// _fetchTilesApi awaits whenTokenReady() → the call goes out AFTER login.
this._fetchTilesApi(url, 'defaultStyle')
.then(function(cfg) {
var name = cfg && (cfg.default || cfg.defaultStyle);
// Only swap if the server names a style AND the map wasn't re-styled in
// the meantime (developer called setStyle() during the async gap).
if (name && !self._userSetStyle) self.setStyle(name);
})
.catch(function() { /* keep the bundled bootstrap */ });
};
/**
* EVMOVE-457 — best-effort resolve THIS map's BeNomad-Tiles version token from
* the Worker's `/api/maps` (after login) and record it on the Context, so
* `getTilesFileUrl()` starts emitting `?v=<token>`. Strictly best-effort: it
* ALWAYS resolves (never rejects, never hangs) — a short timeout races the fetch
* so a slow/stalled `/api/maps` can never block the real style swap. Any failure
* (old Worker with no version fields, offline, 4xx, bad JSON) leaves the map
* unversioned, i.e. exactly today's behaviour.
* @private
* @return {Promise}
*/
bemap.MapLibreMap.prototype._discoverTilesVersion = function() {
var self = this;
var name = this._tilesFile;
var url = this.ctx && typeof this.ctx.getTilesMapsUrl === 'function' && this.ctx.getTilesMapsUrl();
if (!url || typeof fetch !== 'function' || !name
|| typeof this._fetchTilesApi !== 'function'
|| !(this.ctx && typeof this.ctx.setTilesVersion === 'function')) {
return Promise.resolve();
}
// Best-effort, and MUST always resolve (so the style swap is never blocked).
// TWO capped phases so slow authentication doesn't eat the /api/maps budget
// AND a hung login/fetch can't block forever:
// 1. wait for login (whenTokenReady) — capped, so a hung login still proceeds;
// 2. GET /api/maps — its OWN ~3.5s budget, starting AFTER login is ready.
// `settled` gates the write: a result arriving after either cap is ignored, so
// a late /api/maps can never desync getTilesFileUrl from the already-applied
// (unversioned) style.
var settled = false;
var auth = this._tilesAuth;
var ready = (auth && typeof auth.whenTokenReady === 'function') ? auth.whenTokenReady() : Promise.resolve();
var loginCap = new Promise(function(res) { setTimeout(res, 4000); });
return Promise.race([ready.then(null, function() {}), loginCap]).then(function() {
if (settled) return;
// Case-insensitive lookup — the Worker matches aliases case-insensitively,
// so validation and token lookup must too (an exact hit is tried first).
function ciGet(obj, key) {
if (!obj || typeof obj !== 'object') return undefined;
if (Object.prototype.hasOwnProperty.call(obj, key)) return obj[key];
var lk = String(key).toLowerCase();
for (var k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k) && k.toLowerCase() === lk) return obj[k];
}
return undefined;
}
var discovery = self._fetchTilesApi(url, 'discoverTilesVersion').then(function(cfg) {
if (settled) return;
// A geoserver name is not necessarily a PMTiles map — e.g. 'herehlp' is
// WMS-only, yet resolveTilesFile() (tilesFile → geoserver → 'default')
// faithfully asks the Worker for it and gets a guaranteed 403. When the
// name was merely INHERITED from ctx.geoserver (never for an explicit
// opts.tilesFile / ctx.tilesFile pin — those must keep surfacing their
// own 403) and the Worker's catalog says it does NOT exist, fall back to
// the server-resolved 'default' map so the background keeps rendering
// (the geoserver setting still applies to WMS + services). Old Workers
// without a catalog are left untouched (no false fallback).
var aliases = cfg && cfg.aliases;
var tilesets = cfg && cfg.tilesets;
if (name !== 'default' && !self._tilesFileExplicit &&
((aliases && typeof aliases === 'object') || (tilesets && tilesets.length))) {
var known = (ciGet(aliases, name) !== undefined) ||
(ciGet(cfg.aliasVersions, name) !== undefined) ||
(ciGet(cfg.versions, name) !== undefined);
if (!known && tilesets && tilesets.length) {
var lname = String(name).toLowerCase();
for (var i = 0; i < tilesets.length; i++) {
var t = String(tilesets[i]).toLowerCase();
if (t === lname || t === lname + '.pmtiles') { known = true; break; }
}
}
if (!known) {
// Only swap in the server-resolved 'default' map if the app asked for
// it (ctx.tilesFallbackToDefault, off by default): the user
// picked geoserver X, so quietly rendering ANOTHER provider's map is
// worse UX than rendering none. Default → render none.
var useDefault = !!(self.ctx && self.ctx.tilesFallbackToDefault);
try {
console.info('[bemap] BeNomad Tiles: no map named "' + name + '" on this Worker — ' +
(useDefault ? 'using the server default map instead' : 'showing an empty vector basemap') +
' (the geoserver setting still applies to WMS layers and services).');
} catch (e) { /* ignore */ }
if (useDefault) {
name = 'default';
self._tilesFile = 'default'; // registration + style follow this (key identity)
} else {
// A source-free style: nothing references the absent archive any
// more, so no slice is requested. The pending init style has to be
// cancelled too — _applyInitStyle() runs when this promise settles,
// and the server default would paint straight over the empty map.
// The last real spec is kept so switchBackgroundLayer() can restore
// it as soon as a provider that HAS a map is selected.
self._tilesFileMissing = true;
self._tilesLastStyleSpec = self._currentStyleSpec;
self._refreshDefaultOnInit = false;
self._pendingStyleName = null;
self.setStyle({ version: 8, sources: {}, layers: [] }, { _internal: true });
return; // no map → no version token to resolve
}
}
}
var token = cfg ? (ciGet(cfg.aliasVersions, name) || ciGet(cfg.versions, name) || null) : null;
if (typeof token === 'string' && token) self.ctx.setTilesVersion(name, token);
});
var fetchCap = new Promise(function(res) { setTimeout(res, 3500); });
return Promise.race([discovery.then(null, function() {}), fetchCap]);
}).then(function() { settled = true; });
};
/**
* Shared authenticated GET against a Worker `/api/*` discovery endpoint.
* @private
*/
bemap.MapLibreMap.prototype._fetchTilesApi = function(url, label) {
if (!url) {
return Promise.reject(new Error('bemap.MapLibreMap.' + label + ': no tilesHost configured on the Context.'));
}
if (typeof fetch !== 'function') {
return Promise.reject(new Error('bemap.MapLibreMap.' + label + ': fetch is not available in this environment.'));
}
// Wait for login so the first call after construction isn't unauthenticated;
// auth rides the Worker's HttpOnly session cookie (credentials:'include' below),
// not a header — no X-Session-Token means no CORS preflight.
var auth = this._tilesAuth;
var ready = (auth && typeof auth.whenTokenReady === 'function') ? auth.whenTokenReady() : Promise.resolve();
return ready.then(function() {
return fetch(url, { credentials: 'include' });
}).then(function(resp) {
if (!resp.ok) {
throw new Error('bemap.MapLibreMap.' + label + ': HTTP ' + resp.status + ' from ' + url);
}
return resp.json();
});
};
bemap.MapLibreMap.prototype.getCenter = function() {
var c = this.native.getCenter();
return new bemap.Coordinate(c.lng, c.lat);
};
bemap.MapLibreMap.prototype.getBoundingBox = function() {
var b = this.native.getBounds();
return new bemap.BoundingBox(b.getWest(), b.getSouth(), b.getEast(), b.getNorth());
};
bemap.MapLibreMap.prototype.isDragPan = function() {
return this.native.dragPan ? this.native.dragPan.isEnabled() : true;
};
bemap.MapLibreMap.prototype.setDragPan = function(active) {
if (active) { this.native.dragPan.enable(); } else { this.native.dragPan.disable(); }
return this;
};
// =========================================================================
// 3D Features (MapLibre-exclusive)
// =========================================================================
bemap.MapLibreMap.prototype.setPitch = function(pitch) { this.native.setPitch(pitch); return this; };
bemap.MapLibreMap.prototype.getPitch = function() { return this.native.getPitch(); };
bemap.MapLibreMap.prototype.setBearing = function(bearing) { this.native.setBearing(bearing); return this; };
bemap.MapLibreMap.prototype.getBearing = function() { return this.native.getBearing(); };
bemap.MapLibreMap.prototype.setTerrain = function(options) {
try { this.native.setTerrain(options); } catch(e) {}
var self = this;
if (typeof this._registerEffect === 'function') {
this._registerEffect('terrain', function () { self.removeTerrain(); });
}
return this;
};
bemap.MapLibreMap.prototype.removeTerrain = function() {
try { this.native.setTerrain(null); } catch(e) {}
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('terrain');
return this;
};
bemap.MapLibreMap.prototype.setProjection = function(type) {
// Remember the explicit choice so it survives later style swaps (the server
// default-style refresh, a customer setStyle, etc.) — see _applyDesiredProjection.
this._desiredProjection = (type === 'globe') ? 'globe' : 'mercator';
try { this.native.setProjection({ type: type }); } catch(e) {}
var self = this;
// Only register the 'globe' state as an effect — mercator is the
// default and has nothing to tear down.
if (typeof this._registerEffect === 'function') {
if (type === 'globe') {
this._registerEffect('globe', function () { self.setProjection('mercator'); });
} else {
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('globe');
}
}
return this;
};
/**
* Force a style object's root `projection` to the SDK-owned value
* (`this._desiredProjection`, default `mercator`; `globe` only when the
* developer opted in via `opts.projection` / `setProjection`). Called
* synchronously on every style spec before it reaches `native.setStyle`.
*
* MapLibre v5 applies a style's root `projection` on BOTH a full load AND a
* diff update (the diff emits `setProjection`, unlike camera keys which it
* skips), so a server/charte style shipping `"projection":{"type":"globe"}`
* would otherwise silently switch the map to a globe — which curves the view
* at low zoom and reads as a map "at the wrong angle". Overwriting the key
* here (rather than in a post-load event) is deterministic: it works for the
* diff path, where neither `style.load` nor `idle` reliably fires. Mirrors how
* the SDK already ignores a style's own `sources` (TilesStyle.resolvePlaceholders).
* @private
*/
bemap.MapLibreMap.prototype._normalizeStyleProjection = function(style) {
if (style && typeof style === 'object') {
var want = this._desiredProjection || 'mercator';
var declared = style.projection && style.projection.type;
// One-time, per-map heads-up when we actually override a projection the
// style asked for (e.g. a server charte shipping globe) — confirms the
// source of a "wrong angle" and points at the opt-in.
if (declared && declared !== want && !this._projectionOverrideNoted) {
this._projectionOverrideNoted = true;
if (typeof console !== 'undefined' && console.info) {
console.info("bemap.MapLibreMap: style requested projection '" + declared +
"' — kept the SDK default '" + want + "'. Call map.setProjection('" + declared +
"') to opt in.");
}
}
style.projection = { type: want };
}
return style;
};
bemap.MapLibreMap.prototype.setSky = function(options) {
try {
if (this.native.setSky) {
// Pass null/undefined/false straight through so MapLibre removes the
// sky entirely. The previous `options || {}` converted null → {} which
// sets an EMPTY sky config rather than disabling — the "Disable Sky"
// button was a no-op as a result.
this.native.setSky(options || undefined);
} else {
var s = this.native.getStyle();
if (options) { s.sky = options; } else { delete s.sky; }
this.native.setStyle(s);
}
} catch(e) {}
if (typeof this._requiresGlobe === 'function') this._requiresGlobe('setSky');
var self = this;
if (typeof this._registerEffect === 'function') {
if (options) {
this._registerEffect('setSky', function () {
try { if (self.native.setSky) self.native.setSky({}); } catch (e) {}
});
} else {
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('setSky');
}
}
return this;
};
// setStyle is defined later in this file as the overlay-preserving v2.0
// implementation (search for "// v2.0 — BeNomad Tiles surgical").
bemap.MapLibreMap.prototype.jumpTo = function(options) { this.native.jumpTo(options); return this; };
bemap.MapLibreMap.prototype.easeTo = function(options) { this.native.easeTo(options); return this; };
bemap.MapLibreMap.prototype.queryRenderedFeatures = function(point, options) {
return this.native.queryRenderedFeatures(point, options);
};
bemap.MapLibreMap.prototype.addGeoJsonSource = function(id, data) {
var _this = this;
var add = function() {
if (!_this.native.getSource(id)) {
_this.native.addSource(id, { type: 'geojson', data: data });
}
};
if (this.native.isStyleLoaded()) { add(); } else { this.native.on('load', add); }
return this;
};
bemap.MapLibreMap.prototype.updateGeoJsonSource = function(id, data) {
var src = this.native.getSource(id);
if (src) src.setData(data);
return this;
};
bemap.MapLibreMap.prototype.addImage = function(id, image) {
var _this = this;
var add = function() { if (!_this.native.hasImage(id)) _this.native.addImage(id, image); };
if (this.native.isStyleLoaded()) { add(); } else { this.native.on('load', add); }
return this;
};
bemap.MapLibreMap.prototype.removeImage = function(id) {
try { this.native.removeImage(id); } catch(e) {}
return this;
};
// =========================================================================
// Build Style Methods (translate bemap styles to MapLibre paint properties)
// =========================================================================
bemap.MapLibreMap.prototype.buildIcon = function(icon, options) { return this; };
bemap.MapLibreMap.prototype.buildLineStyle = function(style, options) { return this; };
bemap.MapLibreMap.prototype.buildTextStyle = function(textStyle, name, options) { return this; };
bemap.MapLibreMap.prototype.buildPolygonStyle = function(style, options) { return this; };
bemap.MapLibreMap.prototype.buildCircleStyle = function(style, options) { return this; };
// =========================================================================
// Light / Raster (MapLibre-exclusive advanced features)
// =========================================================================
// MapLibre's documented default light specification. Used as the reset
// target when setLight() is called with no argument / null / undefined —
// passing {} (the previous behaviour) overrode the active light with
// nothing rather than restoring the defaults.
bemap.MapLibreMap.DEFAULT_LIGHT = {
anchor: 'viewport',
color: 'white',
intensity: 0.5,
position: [1.15, 210, 30]
};
bemap.MapLibreMap.prototype.setLight = function(options) {
// Use the native MapLibre setLight API directly. The old impl did
// `setStyle(getStyle() + {light})` which wipes every user-added layer
// and source — devastating for live demos where the user just placed
// markers / polylines / heatmap.
// Also surface a one-time hint when called on a non-globe projection
// because setLight has no visible effect in mercator without pitch.
try {
if (typeof this.native.setLight === 'function') {
this.native.setLight(options || bemap.MapLibreMap.DEFAULT_LIGHT);
} else {
// Older MapLibre fallback — style mutation, but only the light
// field, not the whole style spec.
var style = this.native.getStyle();
style.light = options;
this.native.setStyle(style);
}
} catch(e) {}
if (typeof this._requiresGlobe === 'function') this._requiresGlobe('setLight');
if (typeof this._registerEffect === 'function') {
var self = this;
this._registerEffect('setLight', function () {
try { if (typeof self.native.setLight === 'function') self.native.setLight({}); } catch (e) {}
});
}
return this;
};
bemap.MapLibreMap.prototype.addRasterLayer = function(layer) {
if (!layer || !bemap.inheritsof(layer, bemap.RasterLayer)) return this;
var _this = this;
var layerId = this._uniqueId('raster');
layer._maplibreId = layerId;
var addToMap = function() {
if (!_this.native.getSource(layerId)) {
_this.native.addSource(layerId, {
type: 'raster',
tiles: [layer.url],
tileSize: layer.tileSize || 256,
attribution: layer.attribution || ''
});
_this.native.addLayer({
id: layerId,
type: 'raster',
source: layerId,
paint: { 'raster-opacity': layer.opacity !== undefined ? layer.opacity : 1 }
});
}
};
if (this.native.isStyleLoaded()) { addToMap(); } else { this.native.on('load', addToMap); }
layer.native = { id: layerId, type: 'raster' };
if (layer.map === null) layer.map = this;
bemap.Map.prototype.addLayer.call(this, layer);
return this;
};
// =========================================================================
// v2.0 — BeNomad Tiles surgical + camera + cache methods
// =========================================================================
/**
* Replace `bemap.Map.prototype.setStyle` for MapLibre — accepts either a
* URL or an inline style object, fetches + resolves placeholders via
* `bemap.TilesStyle`, calls native `setStyle`, and **replays the overlay
* catalogue** so user markers / polylines / polygons survive the swap.
*
* @public
* @since 2.0.0
*/
bemap.MapLibreMap.prototype.setStyle = function (urlOrObject, options) {
var self = this;
var opts = options || {};
// Mark that a style was chosen explicitly, so the init-time default-style
// refresh (_refreshDefaultStyleFromServer) won't overwrite it.
if (!opts._internal) this._userSetStyle = true;
// Same precedence as the constructor (opts.tilesFile -> ctx.tilesFile ->
// ctx.geoserver -> 'default') — without this the TILES_SOURCE placeholder
// stays a literal string when the customer calls `map.setStyle(obj)` without
// an explicit tilesFile. On the internal/refresh path (no opts.tilesFile),
// reuse this map's stored name so the style url === the registered archive
// key + ?v= token (EVMOVE-457 key-identity).
var tilesFile;
if (opts.tilesFile && this.ctx && typeof this.ctx.resolveTilesFile === 'function') {
tilesFile = this.ctx.resolveTilesFile(opts.tilesFile);
} else if (this._tilesFile) {
tilesFile = this._tilesFile;
} else if (this.ctx && typeof this.ctx.resolveTilesFile === 'function') {
tilesFile = this.ctx.resolveTilesFile();
} else {
tilesFile = opts.tilesFile || 'default';
}
// Register the (possibly versioned) slice source under the SAME url the style
// will reference, so pmtiles reuses PMTilesSliceSource + the recoverable cache
// instead of silently auto-creating a stock Range source. Idempotent per url;
// no-op for non-tiles maps (getTilesFileUrl → null).
if (this.ctx && typeof this.ctx.getTilesFileUrl === 'function') {
var _archiveUrl = this.ctx.getTilesFileUrl(tilesFile);
if (_archiveUrl) {
var _versioned = !!(typeof this.ctx.getTilesVersion === 'function' && this.ctx.getTilesVersion(tilesFile));
bemap.MapLibreMap._addTilesArchive(_archiveUrl, bemap.MapLibreMap._tilesSliceMode, _versioned);
}
}
// Inline style object → apply immediately and return `this` for chaining
// (matches the v1 contract and the cross-engine `.setStyle(...).move(...)`
// pattern customer apps rely on).
if (urlOrObject && typeof urlOrObject === 'object') {
var resolved = urlOrObject;
if (typeof bemap.TilesStyle !== 'undefined') {
try {
resolved = JSON.parse(JSON.stringify(urlOrObject));
bemap.TilesStyle.resolvePlaceholders(resolved, self.ctx, tilesFile);
bemap.TilesStyle.hardenSymbolCollisions(resolved);
} catch (e) { resolved = urlOrObject; }
}
this._normalizeStyleProjection(resolved); // SDK owns projection, not the style
this._currentStyleSpec = resolved;
// Flag the in-flight setStyle so _whenStyleReady can refuse the fast
// path (isStyleLoaded() returns true for the OLD style during the gap).
this._setStyleInFlight = true;
this.native.setStyle(resolved);
// 'idle' fires AFTER style.load AND after the first render pass — the
// canonical "everything settled" event. 'styledata' fires too early,
// before MapLibre's Style#_load swaps the stylesheet, so overlays
// re-added then get wiped.
this.native.once('idle', function () {
self._setStyleInFlight = false;
if (self._overlayCatalog) self._overlayCatalog.replayOnto(self);
});
return this;
}
// Resolve a Worker style reference against the tilesHost. This covers both a
// bare NAME ('openfreemap_graylevel') AND a Worker-relative path as returned
// by /api/styles ('styles/openfreemap_graylevel.json'); either way it must hit
// <tilesHost>/styles/…, NOT a page-relative URL. Only an ABSOLUTE URL
// (http(s):// or //host) is passed through untouched.
var styleUrl = urlOrObject;
if (typeof urlOrObject === 'string' && !/^(?:https?:)?\/\//i.test(urlOrObject)
&& this.ctx && typeof this.ctx.getTilesStyleUrl === 'function') {
styleUrl = this.ctx.getTilesStyleUrl(urlOrObject) || urlOrObject;
}
// URL → fetch + resolve asynchronously, but still return `this` sync so
// method chaining works. Errors flow to the map's error channel.
var getToken = this._tilesAuth ? function () { return self._tilesAuth.getToken(); } : null;
if (typeof bemap.TilesStyle !== 'undefined') {
bemap.TilesStyle.fetch(this.ctx, styleUrl, {
getToken: getToken,
// Use the auth's RESOLVED config so a per-map tilesAuth override applies to
// the style/glyph JSON fetch too (falls back to ctx.getTilesAuth()).
tilesAuth: self._tilesAuth ? self._tilesAuth._authConfig : null
})
.then(function (style) {
bemap.TilesStyle.resolvePlaceholders(style, self.ctx, tilesFile);
bemap.TilesStyle.hardenSymbolCollisions(style);
self._normalizeStyleProjection(style); // SDK owns projection, not the style
self._currentStyleSpec = style;
self._setStyleInFlight = true;
self.native.setStyle(style);
// 'idle' (not 'styledata') — see in-line setStyle path above.
self.native.once('idle', function () {
self._setStyleInFlight = false;
if (self._overlayCatalog) self._overlayCatalog.replayOnto(self);
});
})
.catch(function (err) {
self._fireError(err instanceof bemap.Error ? err : new bemap.Error({
code: bemap.Error.STYLE_LOAD_FAILED, message: 'setStyle failed: ' + (err && err.message)
}));
});
} else {
try { this.native.setStyle(styleUrl); } catch (e) {}
}
return this;
};
/**
* Get the current MapLibre style spec (the one resolved and applied by the
* library — same shape `getStyle()` would return).
* @public
* @since 2.0.0
* @return {Object}
*/
bemap.MapLibreMap.prototype.getStyle = function () { return this._currentStyleSpec || this.native.getStyle(); };
/* --- Token introspection --- */
bemap.MapLibreMap.prototype.getToken = function () { return this._tilesAuth ? this._tilesAuth.getToken() : null; };
bemap.MapLibreMap.prototype.isTokenValid = function () { return !!(this._tilesAuth && this._tilesAuth.isValid()); };
bemap.MapLibreMap.prototype.refreshToken = function () {
if (!this._tilesAuth) return Promise.resolve(null);
return this._tilesAuth.refresh();
};
/* --- Browser cache --- */
bemap.MapLibreMap.prototype.enableBrowserCache = function () {
if (this._browserCache) this._browserCache.enable();
return this;
};
bemap.MapLibreMap.prototype.disableBrowserCache = function () {
if (this._browserCache) this._browserCache.disable();
return this;
};
bemap.MapLibreMap.prototype.getBrowserCacheStats = function () {
return this._browserCache ? this._browserCache.getStats()
: { enabled: false, hits: 0, misses: 0, entries: 0, bytesEstimated: 0 };
};
bemap.MapLibreMap.prototype.clearBrowserCache = function () {
if (this._browserCache) return this._browserCache.clear();
return Promise.resolve();
};
bemap.MapLibreMap.prototype.onCacheStats = function (fn) {
if (typeof fn !== 'function') return;
this._cacheStatsListeners = this._cacheStatsListeners || [];
this._cacheStatsListeners.push(fn);
};
bemap.MapLibreMap.prototype.offCacheStats = function (fn) {
if (!this._cacheStatsListeners) return;
for (var i = this._cacheStatsListeners.length - 1; i >= 0; i--) {
if (!fn || this._cacheStatsListeners[i] === fn) this._cacheStatsListeners.splice(i, 1);
}
};
/* --- Surgical layer mutation --- */
bemap.MapLibreMap.prototype.setPaintProperty = function (layerId, name, value) {
try { this.native.setPaintProperty(layerId, name, value); } catch (e) { /* ignore */ }
return this;
};
bemap.MapLibreMap.prototype.setLayoutProperty = function (layerId, name, value) {
try { this.native.setLayoutProperty(layerId, name, value); } catch (e) { /* ignore */ }
return this;
};
bemap.MapLibreMap.prototype.setFilter = function (layerId, filter) {
try { this.native.setFilter(layerId, filter); } catch (e) { /* ignore */ }
return this;
};
bemap.MapLibreMap.prototype.setLayerZoomRange = function (layerId, minzoom, maxzoom) {
try { this.native.setLayerZoomRange(layerId, minzoom, maxzoom); } catch (e) { /* ignore */ }
return this;
};
/* ============================================================
* Cross-engine camera tour (v2.0) — MapLibre real-animation
*
* Same waypoint shape as the Leaflet and OL impls. MapLibre gets
* full pitch + bearing per waypoint.
* ============================================================ */
bemap.MapLibreMap.prototype.cameraTour = function (options) {
var opts = options || {};
var waypoints = opts.waypoints || [];
if (!waypoints.length) return { stop: function () {}, resume: function () {} };
var self = this;
var i = 0, stopped = false;
function next() {
if (stopped) return;
if (i >= waypoints.length) {
if (opts.loop) i = 0;
else { if (typeof opts.onComplete === 'function') opts.onComplete(); return; }
}
var wp = waypoints[i++];
self.native.easeTo({
center: [wp.lon, wp.lat],
zoom: wp.zoom,
pitch: wp.pitch,
bearing: wp.bearing,
duration: wp.duration || 2000
});
setTimeout(next, wp.duration || 2000);
}
next();
var controller = {
stop: function () { stopped = true; if (typeof self._unregisterEffect === 'function') self._unregisterEffect('cameraTour'); },
resume: function () { if (stopped) { stopped = false; next(); } }
};
if (typeof self._registerEffect === 'function') self._registerEffect('cameraTour', function () { controller.stop(); });
return controller;
};
/* --- Globe spinner --- */
bemap.MapLibreMap.prototype.spinGlobe = function (options) {
var self = this;
var opts = options || {};
var speed = typeof opts.speed === 'number' ? opts.speed : 0.05; // deg/s
this.stopSpinGlobe();
this._globeSpinTimer = setInterval(function () {
if (!self.native) return;
var c = self.native.getCenter();
self.native.setCenter([c.lng + speed, c.lat]);
}, 50);
if (typeof this._registerEffect === 'function') {
this._registerEffect('spinGlobe', function () { self.stopSpinGlobe(); });
}
return { stop: function () { self.stopSpinGlobe(); }, resume: function () { self.spinGlobe(opts); } };
};
bemap.MapLibreMap.prototype.stopSpinGlobe = function () {
if (this._globeSpinTimer) {
clearInterval(this._globeSpinTimer);
this._globeSpinTimer = null;
}
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('spinGlobe');
return this;
};
/* --- 3D buildings (real implementation) --- */
bemap.MapLibreMap.prototype.add3DBuildings = function (options) {
var self = this;
var opts = options || {};
var layerId = opts.layerId || 'bemap-3d-buildings';
// Per-add cancellation token. When `remove3DBuildings` fires while
// the queued `fn` is still pending (style hasn't loaded yet), the
// token is flipped so the deferred add bails out instead of
// resurrecting the layer the user just removed.
var token = { cancelled: false };
var fn = function () {
if (token.cancelled) return;
if (self.native.getLayer(layerId)) return;
var src = opts.source || 'tiles';
if (!self.native.getSource(src)) return;
self.native.addLayer({
id: layerId,
type: 'fill-extrusion',
source: src,
'source-layer': opts.sourceLayer || 'building',
minzoom: opts.minzoom || 13,
paint: {
'fill-extrusion-color': opts.color || '#bdbdbd',
'fill-extrusion-height': opts.height || ['coalesce', ['get', 'render_height'], ['get', 'height'], 10],
'fill-extrusion-base': opts.base || 0,
'fill-extrusion-opacity': opts.opacity != null ? opts.opacity : 0.7
}
});
};
self._3dBuildingsTokens = self._3dBuildingsTokens || {};
self._3dBuildingsTokens[layerId] = token;
if (this.native.isStyleLoaded()) fn();
else this.native.once('load', fn);
if (this._overlayCatalog) this._overlayCatalog.register('3dbuildings', layerId, function (map) {
if (!map.native.getLayer(layerId)) fn();
});
if (typeof this._registerEffect === 'function') {
this._registerEffect('3DBuildings', function () { self.remove3DBuildings({ layerId: layerId }); });
}
return this;
};
bemap.MapLibreMap.prototype.remove3DBuildings = function (options) {
var opts = options || {};
var layerId = opts.layerId || 'bemap-3d-buildings';
// Cancel any pending deferred add so it can't resurrect the layer.
if (this._3dBuildingsTokens && this._3dBuildingsTokens[layerId]) {
this._3dBuildingsTokens[layerId].cancelled = true;
delete this._3dBuildingsTokens[layerId];
}
try { if (this.native.getLayer(layerId)) this.native.removeLayer(layerId); } catch (e) {}
if (this._overlayCatalog) this._overlayCatalog.unregister('3dbuildings', layerId);
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('3DBuildings');
return this;
};
/* --- Tear-down extension --- */
var _origRemoveMapLibre = bemap.MapLibreMap.prototype.remove;
bemap.MapLibreMap.prototype.remove = function () {
// Tear down every active effect first — without this, timers
// (spinGlobe), animation frames (animateAlongRoute) and SW handles
// (browser cache) leak across SPA navigation.
try { this.clearAllEffects(); } catch (e) { /* keep going */ }
try { if (this._tilesAuth) this._tilesAuth.destroy(); } catch (e) {}
try { if (this._browserCache) this._browserCache.destroy(); } catch (e) {}
try { if (this._overlayCatalog) this._overlayCatalog.clear(); } catch (e) {}
// Pending recoverable-tile-error refresh — firing after removal would touch
// a dead map.
try { if (this._tileRefreshTimer) { clearTimeout(this._tileRefreshTimer); this._tileRefreshTimer = null; } } catch (e) {}
try { this._disconnectTargetResizeObserver(); } catch (e) {}
this.stopSpinGlobe();
if (typeof _origRemoveMapLibre === 'function') return _origRemoveMapLibre.apply(this, arguments);
if (this.native && typeof this.native.remove === 'function') this.native.remove();
};
// =========================================================================
// Popup clearPopup (real impl in popup sub-file)
// =========================================================================