/**
* BeNomad BeMap JavaScript API - AttributionWidget
*
* A single, consistent attributions widget shipped across all three map
* engines (Leaflet, OpenLayers, MapLibre). Visually it is a small ⓘ info
* icon sitting in a corner of the map; clicking or focussing it expands
* a popover panel listing every legal mention that applies to the
* current view (data source, rendering library, BeNomad copyright, any
* customer-added line).
*
* Engine detection is internal — the same constructor works for any
* `bemap.LeafletMap`, `bemap.OlMap` / `bemap.Ol3Map`, or `bemap.MapLibreMap`.
*
* @since 1.5.0
*/
/**
* @classdesc
* AttributionWidget — cross-engine map credits and legal mentions panel.
*
* Customers don't usually instantiate this directly; it is created
* automatically by each map constructor when `options.attribution`
* is omitted or set to a truthy value. Power users who want a fully
* customised setup can construct it explicitly.
*
* **Position above a bottom overlay (CSS).** When the host app has a bar or
* panel over the bottom of the map (timeline, drawer, toolbar), set the CSS
* custom property `--bemap-attribution-bottom-offset` on the map container — or
* any ancestor — to lift the widget by that many pixels; no JavaScript required.
* It defaults to `0px` (other apps are unaffected), applies to the `bottom-*`
* positions only, and shifts both the ⓘ icon and its popover. Example:
* `#map { --bemap-attribution-bottom-offset: 70px; }`
*
* @public
* @constructor
* @since 1.5.0
* @param {bemap.Map} map The bemap map instance the widget attaches to.
* @param {Object} [options]
* @param {String} [options.position='bottom-right'] One of 'top-left' / 'top-right' / 'bottom-left' / 'bottom-right'.
* @param {Array.<String|{text:String,href:String}>} [options.customLines] Extra attribution lines (brand mention, overlay data licence, etc.). Each entry may be a plain string or a `{ text, href }` object.
* @param {String} [options.noticesUrl] URL to the full open-source notices document (typically `OPEN_SOURCE_NOTICES.md` or `.txt`). When set, the popover shows a "Open source notices" footer link that opens the URL in a new tab.
* @param {String} [options.noticesLabel='Open source notices'] Label of the notices footer link.
* @param {Boolean} [options.enabled=true] When false, the widget is constructed but not rendered.
*/
bemap.AttributionWidget = function(map, options) {
var opts = options || {};
if (!map) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('bemap.AttributionWidget: a map instance is required.');
}
return;
}
/**
* @type {bemap.Map}
* @protected
*/
this.map = map;
/**
* @type {String}
* @protected
*/
this.position = bemap.AttributionWidget._normalisePosition(opts.position);
/**
* @type {Array.<String|{text:String,href:String}>}
* @protected
*/
this.customLines = (opts.customLines && opts.customLines.slice) ? opts.customLines.slice() : [];
/**
* @type {String|null}
* @protected
*/
this.noticesUrl = (typeof opts.noticesUrl === 'string' && opts.noticesUrl) ? opts.noticesUrl : null;
/**
* @type {String}
* @protected
*/
this.noticesLabel = (typeof opts.noticesLabel === 'string' && opts.noticesLabel) ? opts.noticesLabel : 'Open source notices';
/**
* @type {Boolean}
* @protected
*/
this.enabled = opts.enabled !== false;
/**
* Off by default — the popover is for legal mentions, not UI controls.
* Opt-in via `new bemap.AttributionWidget(map, { keyboardToggle: true })`.
* @type {Boolean}
* @protected
*/
this.showKeyboardToggle = (opts.keyboardToggle === true);
/**
* Engine identifier: 'leaflet' | 'openlayers' | 'maplibre' | 'unknown'.
* @type {String}
* @protected
*/
this._engine = bemap.AttributionWidget._detectEngine(map);
/**
* @type {HTMLElement|null}
* @private
*/
this._iconElement = null;
/**
* @type {HTMLElement|null}
* @private
*/
this._popoverElement = null;
/**
* @type {Function|null}
* @private
*/
this._documentClickHandler = null;
if (this.enabled) {
this._render();
}
};
/* ============================================================
* Static helpers
* ============================================================ */
bemap.AttributionWidget._VALID_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
bemap.AttributionWidget._normalisePosition = function(value) {
if (typeof value !== 'string') return 'bottom-right';
var idx = bemap.AttributionWidget._VALID_POSITIONS.indexOf(value);
return idx >= 0 ? bemap.AttributionWidget._VALID_POSITIONS[idx] : 'bottom-right';
};
bemap.AttributionWidget._detectEngine = function(map) {
if (typeof bemap.LeafletMap !== 'undefined' && map instanceof bemap.LeafletMap) {
return 'leaflet';
}
if (typeof bemap.MapLibreMap !== 'undefined' && map instanceof bemap.MapLibreMap) {
return 'maplibre';
}
if ((typeof bemap.OlMap !== 'undefined' && map instanceof bemap.OlMap) ||
(typeof bemap.Ol3Map !== 'undefined' && map instanceof bemap.Ol3Map)) {
return 'openlayers';
}
return 'unknown';
};
/* ============================================================
* Public methods
* ============================================================ */
/**
* Render the widget if it is not already attached to the DOM.
* @public
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.show = function() {
this.enabled = true;
if (!this._iconElement) {
this._render();
}
if (this._iconElement) this._iconElement.style.display = '';
return this;
};
/**
* Hide the widget (icon and popover) without destroying it.
* @public
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.hide = function() {
this.enabled = false;
if (this._iconElement) this._iconElement.style.display = 'none';
if (this._popoverElement) this._popoverElement.style.display = 'none';
return this;
};
/**
* Move the icon to a different corner of the map.
* @public
* @param {String} corner 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'.
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.setPosition = function(corner) {
var normalised = bemap.AttributionWidget._normalisePosition(corner);
this.position = normalised;
if (this._iconElement) {
this._iconElement.className = 'bemap-attribution-icon bemap-attribution-' + normalised;
}
if (this._popoverElement) {
this._popoverElement.className = 'bemap-attribution-popover bemap-attribution-popover-' + normalised;
}
return this;
};
/**
* Replace the customer-supplied extra attribution lines.
* @public
* @param {Array.<String>} lines
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.setCustomLines = function(lines) {
this.customLines = (lines && lines.slice) ? lines.slice() : [];
this.refresh();
return this;
};
/**
* Re-compute the attribution line set and re-render the popover content.
* @public
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.refresh = function() {
if (this._popoverElement) {
this._refreshPopoverContent(this._popoverElement);
}
return this;
};
/**
* Detach the widget from the DOM and clean up event listeners.
* @public
* @return {bemap.AttributionWidget} this.
*/
bemap.AttributionWidget.prototype.destroy = function() {
if (this._documentClickHandler && typeof document !== 'undefined') {
document.removeEventListener('click', this._documentClickHandler);
this._documentClickHandler = null;
}
if (this._iconElement && this._iconElement.parentNode) {
this._iconElement.parentNode.removeChild(this._iconElement);
}
if (this._popoverElement && this._popoverElement.parentNode) {
this._popoverElement.parentNode.removeChild(this._popoverElement);
}
this._iconElement = null;
this._popoverElement = null;
return this;
};
/**
* @public
* @return {String} The detected engine identifier.
*/
bemap.AttributionWidget.prototype.getEngine = function() {
return this._engine;
};
/**
* @public
* @return {Array.<String>} The current list of attribution lines that the popover would show.
*/
bemap.AttributionWidget.prototype.getLines = function() {
return this._computeLines();
};
/* ============================================================
* Internal — line composition
* ============================================================ */
/**
* Compose the popover line entries as objects (text + optional href).
* Used internally by the renderer; the public getLines() returns only text.
* @private
* @return {Array.<{text:String, href:?String}>}
*/
bemap.AttributionWidget.prototype._computeLineEntries = function() {
var entries = [];
var seen = {};
var year = new Date().getFullYear();
function add(text, href) {
if (!text || seen[text]) return;
seen[text] = true;
entries.push({ text: text, href: href || null });
}
// 1. BeNomad copyright
add('© BeNomad ' + year, 'https://benomad.com');
// 2. Rendering engine notice — short text + link to the project page.
// The detailed BSD / ISC / MIT licence text lives in OPEN_SOURCE_NOTICES.txt
// shipped with the library; we do not duplicate it here.
if (this._engine === 'leaflet') {
add('Leaflet', 'https://leafletjs.com');
} else if (this._engine === 'openlayers') {
add('OpenLayers', 'https://openlayers.org');
} else if (this._engine === 'maplibre') {
add('MapLibre', 'https://maplibre.org');
}
// 3. Data source attribution, derived from the Context's geoserver setting.
// This is a legal obligation for the data licence (OSM ODbL / HERE terms).
var geoserver = this._getGeoserver();
if (geoserver === 'here') {
add('© HERE', 'https://legal.here.com/terms');
} else {
// Default to OSM contributors — the WMS background and the public
// BeNomad Tiles distribution are both derived from OpenStreetMap.
add('© OpenStreetMap contributors', 'https://www.openstreetmap.org/copyright');
}
// 4. Per-layer attributions surfaced via the base Map / Layer API.
// Deduplicated against the auto-added lines above.
var layerEntries = this._collectLayerAttributions();
for (var i = 0; i < layerEntries.length; i++) {
add(layerEntries[i].text, layerEntries[i].href);
}
// 5. Customer-supplied custom lines (string or { text, href } object).
for (var j = 0; j < this.customLines.length; j++) {
var line = this.customLines[j];
if (typeof line === 'string') {
add(line, null);
} else if (line && typeof line === 'object' && line.text) {
add(line.text, line.href || null);
}
}
return entries;
};
/**
* Read the geoserver hint from the map's Context, used to drive the
* automatic data-source attribution line. Returns a lowercased string
* such as 'osm', 'here', or null when no Context is attached.
* @private
* @return {String|null}
*/
bemap.AttributionWidget.prototype._getGeoserver = function() {
var map = this.map;
if (!map || !map.ctx) return null;
var gs = map.ctx.geoserver;
return (typeof gs === 'string') ? gs.toLowerCase() : null;
};
/**
* Compose the popover lines as plain strings — kept for backward compatibility
* with the public `getLines()` accessor.
* @private
* @return {Array.<String>}
*/
bemap.AttributionWidget.prototype._computeLines = function() {
var entries = this._computeLineEntries();
var lines = [];
for (var i = 0; i < entries.length; i++) lines.push(entries[i].text);
return lines;
};
/**
* Pull attribution from every layer attached to the map.
* Each layer can return either a string (text only) or an object
* `{ text, href }` (clickable line). Duplicates are eliminated by `text`.
* @private
* @return {Array.<{text:String, href:?String}>}
*/
bemap.AttributionWidget.prototype._collectLayerAttributions = function() {
var result = [];
var seen = {};
var map = this.map;
if (map && typeof map.getLayers === 'function') {
var layers;
try { layers = map.getLayers(); } catch (e) { layers = null; }
if (layers && layers.length) {
for (var i = 0; i < layers.length; i++) {
var layer = layers[i];
if (!layer) continue;
var raw = null;
if (typeof layer.getAttribution === 'function') {
try { raw = layer.getAttribution(); } catch (e) { raw = null; }
} else if (layer.attribution) {
raw = layer.attribution;
}
if (!raw) continue;
var entry = (typeof raw === 'string')
? { text: raw, href: null }
: (raw && raw.text ? { text: raw.text, href: raw.href || null } : null);
if (entry && !seen[entry.text]) {
seen[entry.text] = true;
result.push(entry);
}
}
}
}
return result;
};
/* ============================================================
* Internal — DOM rendering
* ============================================================ */
bemap.AttributionWidget.prototype._getMapContainer = function() {
var map = this.map;
if (!map) return null;
var native = map.native;
if (native) {
if (this._engine === 'leaflet' && typeof native.getContainer === 'function') {
return native.getContainer();
}
if (this._engine === 'openlayers' && typeof native.getTargetElement === 'function') {
return native.getTargetElement();
}
if (this._engine === 'maplibre' && typeof native.getContainer === 'function') {
return native.getContainer();
}
}
// Fallback: target attribute on the bemap Map instance
if (map.target) {
if (typeof map.target === 'string') {
return (typeof document !== 'undefined') ? document.getElementById(map.target) : null;
}
return map.target;
}
return null;
};
/**
* Inject minimal inline CSS so the widget renders even when the consumer
* page never linked `bemap-js-api.css`. Without this, downstream apps
* (evmove5, BeMap-Doc, …) that only include the JS bundle see the icon
* as a zero-size unstyled div. The full stylesheet still wins on cascade
* when linked — we only declare the layout / visibility essentials.
* @private
*/
bemap.AttributionWidget._ensureBaseStyles = function() {
if (typeof document === 'undefined') return;
if (bemap.AttributionWidget._stylesInjected) return;
if (document.getElementById('bemap-attribution-base-styles')) {
bemap.AttributionWidget._stylesInjected = true;
return;
}
// Visibility-first defaults: opacity 1 (vs the linked stylesheet's 0.65)
// so the widget is obvious by default in apps that have never linked
// bemap-js-api.css. Apps that DO link the CSS will see the linked
// 0.65 win on cascade (link tag in head precedes runtime-injected
// <style> in document order, but equal-specificity-later-wins means
// the injected rules WOULD win — that's why we keep the injected
// values aligned with the desired final UX, not the legacy 0.65).
var css = [
'.bemap-attribution-icon{position:absolute;width:24px;height:24px;border-radius:50%;',
'background:rgba(255,255,255,0.95);box-shadow:0 1px 3px rgba(0,0,0,0.25);',
'display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:1000;',
'opacity:1;transition:opacity .15s,background .15s,box-shadow .15s;',
'font-family:Arial,Helvetica,sans-serif;color:#333;-webkit-user-select:none;',
'-moz-user-select:none;-ms-user-select:none;user-select:none;}',
'.bemap-attribution-icon:hover{background:#fff;box-shadow:0 1px 6px rgba(0,0,0,0.3);}',
'.bemap-attribution-icon svg{width:14px;height:14px;}',
'.bemap-attribution-bottom-right{bottom:calc(var(--bemap-attribution-bottom-offset, 0px) + 8px);right:8px;}',
'.bemap-attribution-bottom-left{bottom:calc(var(--bemap-attribution-bottom-offset, 0px) + 8px);left:8px;}',
'.bemap-attribution-top-right{top:8px;right:8px;}',
'.bemap-attribution-top-left{top:8px;left:8px;}',
'.bemap-attribution-popover{position:absolute;background:#fff;border:1px solid #ccc;',
'border-radius:4px;padding:8px 10px;font:12px/1.4 Arial,Helvetica,sans-serif;color:#333;',
'box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:1001;max-width:280px;min-width:160px;}',
'.bemap-attribution-popover-bottom-right{bottom:calc(var(--bemap-attribution-bottom-offset, 0px) + 44px);right:8px;}',
'.bemap-attribution-popover-bottom-left{bottom:calc(var(--bemap-attribution-bottom-offset, 0px) + 44px);left:8px;}',
'.bemap-attribution-popover-top-right{top:44px;right:8px;}',
'.bemap-attribution-popover-top-left{top:44px;left:8px;}',
'.bemap-attribution-line{padding:3px 0;}',
'.bemap-attribution-line a{color:#0066cc;text-decoration:none;}',
'.bemap-attribution-line a:hover{text-decoration:underline;}',
'.bemap-attribution-notices{margin-top:6px;padding-top:6px;border-top:1px solid #eee;font-size:11px;}',
'.bemap-attribution-kbd-row{display:flex;align-items:center;justify-content:space-between;',
'margin-top:8px;padding-top:8px;border-top:1px solid #eee;}',
'.bemap-attribution-kbd-label{font-size:11px;color:#555;}',
'.bemap-attribution-kbd-toggle{position:relative;width:32px;height:18px;border:0;padding:0;',
'background:transparent;cursor:pointer;flex-shrink:0;}',
'.bemap-attribution-kbd-track{position:absolute;inset:0;background:#ccc;border-radius:9px;',
'transition:background .15s;}',
'.bemap-attribution-kbd-toggle.on .bemap-attribution-kbd-track{background:#0066cc;}',
'.bemap-attribution-kbd-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;',
'background:#fff;border-radius:50%;box-shadow:0 1px 2px rgba(0,0,0,0.25);',
'transition:left .15s;}',
'.bemap-attribution-kbd-toggle.on .bemap-attribution-kbd-thumb{left:16px;}',
'.bemap-attribution-kbd-toggle:focus{outline:2px solid #0066cc;outline-offset:2px;}'
].join('');
var style = document.createElement('style');
style.id = 'bemap-attribution-base-styles';
style.type = 'text/css';
if (style.styleSheet) {
// IE legacy — bemap-js-api still claims IE11 in some retro tests.
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
(document.head || document.getElementsByTagName('head')[0] || document.body).appendChild(style);
bemap.AttributionWidget._stylesInjected = true;
};
bemap.AttributionWidget.prototype._render = function() {
if (typeof document === 'undefined') return;
bemap.AttributionWidget._ensureBaseStyles();
var container = this._getMapContainer();
if (!container) return;
// Anchor absolute-positioned children (the icon / popover) to the map
// container itself rather than the document, in case the host page
// did not give the map div an explicit `position: relative`.
if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
var currentPos = window.getComputedStyle(container).position;
if (!currentPos || currentPos === 'static') {
container.style.position = 'relative';
}
}
var icon = document.createElement('div');
icon.className = 'bemap-attribution-icon bemap-attribution-' + this.position;
icon.setAttribute('role', 'button');
icon.setAttribute('aria-label', 'Map attributions');
icon.setAttribute('tabindex', '0');
icon.innerHTML = this._getIconSvg();
var popover = document.createElement('div');
popover.className = 'bemap-attribution-popover bemap-attribution-popover-' + this.position;
popover.style.display = 'none';
var self = this;
function togglePopover(event) {
if (event && event.stopPropagation) event.stopPropagation();
if (popover.style.display === 'none') {
self._refreshPopoverContent(popover);
popover.style.display = 'block';
} else {
popover.style.display = 'none';
}
}
icon.addEventListener('click', togglePopover);
icon.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePopover(e);
}
});
// Click anywhere else to close
this._documentClickHandler = function(e) {
if (popover.style.display !== 'block') return;
if (icon.contains(e.target) || popover.contains(e.target)) return;
popover.style.display = 'none';
};
document.addEventListener('click', this._documentClickHandler);
container.appendChild(icon);
container.appendChild(popover);
this._iconElement = icon;
this._popoverElement = popover;
};
bemap.AttributionWidget.prototype._refreshPopoverContent = function(popoverEl) {
var entries = this._computeLineEntries();
var html = '';
var esc = bemap.AttributionWidget._escapeHtml;
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (entry.href) {
html += '<div class="bemap-attribution-line">'
+ '<a href="' + esc(entry.href) + '" target="_blank" rel="noopener noreferrer">'
+ esc(entry.text)
+ '</a></div>';
} else {
html += '<div class="bemap-attribution-line">' + esc(entry.text) + '</div>';
}
}
// Keyboard-arrow navigation toggle. Available iff the engine adapter
// implements `setKeyboard()` (Leaflet, OL v10, MapLibre v5 all do).
// Hidden when neither the map nor the toggle was opted in via the
// widget option, so apps that have no keyboard concern stay clean.
if (this.showKeyboardToggle && this.map && typeof this.map.setKeyboard === 'function') {
var on = (typeof this.map.getKeyboard === 'function') ? this.map.getKeyboard() : false;
html += '<div class="bemap-attribution-kbd-row">'
+ '<span class="bemap-attribution-kbd-label">Keyboard arrows</span>'
+ '<button type="button" class="bemap-attribution-kbd-toggle' + (on ? ' on' : '') + '"'
+ ' role="switch" aria-checked="' + (on ? 'true' : 'false') + '"'
+ ' aria-label="Toggle keyboard arrow navigation">'
+ '<span class="bemap-attribution-kbd-track"></span>'
+ '<span class="bemap-attribution-kbd-thumb"></span>'
+ '</button>'
+ '</div>';
}
if (this.noticesUrl) {
html += '<div class="bemap-attribution-footer">'
+ '<a href="' + esc(this.noticesUrl) + '" target="_blank" rel="noopener noreferrer">'
+ esc(this.noticesLabel)
+ ' ›</a>'
+ '</div>';
}
popoverEl.innerHTML = html;
// Wire the toggle (after innerHTML so the element exists).
var toggleEl = popoverEl.querySelector('.bemap-attribution-kbd-toggle');
if (toggleEl) {
var widget = this;
toggleEl.addEventListener('click', function (e) {
e.stopPropagation();
if (!widget.map || typeof widget.map.setKeyboard !== 'function') return;
var nextOn = !widget.map.getKeyboard();
widget.map.setKeyboard(nextOn);
toggleEl.classList.toggle('on', nextOn);
toggleEl.setAttribute('aria-checked', nextOn ? 'true' : 'false');
});
}
};
bemap.AttributionWidget._escapeHtml = function(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
bemap.AttributionWidget.prototype._getIconSvg = function() {
return '<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<circle cx="12" cy="12" r="10" fill="rgba(255,255,255,0.95)" stroke="#0066cc" stroke-width="1.5"/>' +
'<text x="12" y="17" text-anchor="middle" font-family="Georgia,serif" font-size="14" font-weight="bold" fill="#0066cc">i</text>' +
'</svg>';
};