import {extend} from '../util/util';
import {Event, Evented} from '../util/evented';
import {MapMouseEvent} from '../ui/events';
import {DOM} from '../util/dom';
import {LngLat} from '../geo/lng_lat';
import Point from '@mapbox/point-geometry';
import {smartWrap} from '../util/smart_wrap';
import {anchorTranslate, applyAnchorClass} from './anchor';
import type {PositionAnchor} from './anchor';
import type {Map} from './map';
import type {LngLatLike} from '../geo/lng_lat';
import type {PointLike} from './camera';
const defaultOptions = {
closeButton: true,
closeOnClick: true,
focusAfterOpen: true,
className: '',
maxWidth: '240px',
subpixelPositioning: false
};
/**
* A pixel offset specified as:
*
* - a single number specifying a distance from the location
* - a {@link PointLike} specifying a constant offset
* - an object of {@link Point}s specifying an offset for each anchor position
*
* Negative offsets indicate left and up.
*/
export type Offset = number | PointLike | {
[_ in PositionAnchor]: PointLike;
};
/**
* The {@link Popup} options object
*/
export type PopupOptions = {
/**
* If `true`, a close button will appear in the top right corner of the popup.
* @defaultValue true
*/
closeButton?: boolean;
/**
* If `true`, the popup will closed when the map is clicked.
* @defaultValue true
*/
closeOnClick?: boolean;
/**
* If `true`, the popup will closed when the map moves.
* @defaultValue false
*/
closeOnMove?: boolean;
/**
* If `true`, the popup will try to focus the first focusable element inside the popup.
* @defaultValue true
*/
focusAfterOpen?: boolean;
/**
* A string indicating the part of the Popup that should
* be positioned closest to the coordinate set via {@link Popup#setLngLat}.
* Options are `'center'`, `'top'`, `'bottom'`, `'left'`, `'right'`, `'top-left'`,
* `'top-right'`, `'bottom-left'`, and `'bottom-right'`. If unset the anchor will be
* dynamically set to ensure the popup falls within the map container with a preference
* for `'bottom'`.
*/
anchor?: PositionAnchor;
/**
* A pixel offset applied to the popup's location
*/
offset?: Offset;
/**
* Space-separated CSS class names to add to popup container
*/
className?: string;
/**
* A string that sets the CSS property of the popup's maximum width, eg `'300px'`.
* To ensure the popup resizes to fit its content, set this property to `'none'`.
* Available values can be found here: https://developer.mozilla.org/en-US/docs/Web/CSS/max-width
* @defaultValue '240px'
*/
maxWidth?: string;
/**
* If `true`, rounding is disabled for placement of the popup, allowing for
* subpixel positioning and smoother movement when the popup is translated.
* @defaultValue false
*/
subpixelPositioning?: boolean;
};
const focusQuerySelector = [
'a[href]',
'[tabindex]:not([tabindex=\'-1\'])',
'[contenteditable]:not([contenteditable=\'false\'])',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
].join(', ');
/**
* A popup component.
*
* @group Markers and Controls
*
*
* @example
* Create a popup
* ```ts
* let popup = new Popup();
* // Set an event listener that will fire
* // any time the popup is opened
* popup.on('open', () => {
* console.log('popup was opened');
* });
* ```
*
* @example
* Create a popup
* ```ts
* let popup = new Popup();
* // Set an event listener that will fire
* // any time the popup is closed
* popup.on('close', () => {
* console.log('popup was closed');
* });
* ```
*
* @example
* ```ts
* let markerHeight = 50, markerRadius = 10, linearOffset = 25;
* let popupOffsets = {
* 'top': [0, 0],
* 'top-left': [0,0],
* 'top-right': [0,0],
* 'bottom': [0, -markerHeight],
* 'bottom-left': [linearOffset, (markerHeight - markerRadius + linearOffset) * -1],
* 'bottom-right': [-linearOffset, (markerHeight - markerRadius + linearOffset) * -1],
* 'left': [markerRadius, (markerHeight - markerRadius) * -1],
* 'right': [-markerRadius, (markerHeight - markerRadius) * -1]
* };
* let popup = new Popup({offset: popupOffsets, className: 'my-class'})
* .setLngLat(e.lngLat)
* .setHTML("
Hello World!
")
* .setMaxWidth("300px")
* .addTo(map);
* ```
* @see [Display a popup](https://maplibre.org/maplibre-gl-js/docs/examples/popup/)
* @see [Display a popup on hover](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-hover/)
* @see [Display a popup on click](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-click/)
* @see [Attach a popup to a marker instance](https://maplibre.org/maplibre-gl-js/docs/examples/set-popup/)
*
* ## Events
*
* **Event** `open` of type {@link Event} will be fired when the popup is opened manually or programmatically.
*
* **Event** `close` of type {@link Event} will be fired when the popup is closed manually or programmatically.
*/
export class Popup extends Evented {
_map: Map;
options: PopupOptions;
_content: HTMLElement;
_container: HTMLElement;
_closeButton: HTMLButtonElement;
_tip: HTMLElement;
_lngLat: LngLat;
_trackPointer: boolean;
_pos: Point;
_flatPos: Point;
/**
* @param options - the options
*/
constructor(options?: PopupOptions) {
super();
this.options = extend(Object.create(defaultOptions), options);
}
/**
* Adds the popup to a map.
*
* @param map - The MapLibre GL JS map to add the popup to.
* @example
* ```ts
* new Popup()
* .setLngLat([0, 0])
* .setHTML("Null Island
")
* .addTo(map);
* ```
* @see [Display a popup](https://maplibre.org/maplibre-gl-js/docs/examples/popup/)
* @see [Display a popup on hover](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-hover/)
* @see [Display a popup on click](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-click/)
* @see [Show polygon information on click](https://maplibre.org/maplibre-gl-js/docs/examples/polygon-popup-on-click/)
*/
addTo(map: Map): this {
if (this._map) this.remove();
this._map = map;
if (this.options.closeOnClick) {
this._map.on('click', this._onClose);
}
if (this.options.closeOnMove) {
this._map.on('move', this._onClose);
}
this._map.on('remove', this.remove);
this._update();
this._focusFirstElement();
if (this._trackPointer) {
this._map.on('mousemove', this._onMouseMove);
this._map.on('mouseup', this._onMouseUp);
if (this._container) {
this._container.classList.add('maplibregl-popup-track-pointer');
}
this._map._canvasContainer.classList.add('maplibregl-track-pointer');
} else {
this._map.on('move', this._update);
}
this.fire(new Event('open'));
return this;
}
/**
* @returns `true` if the popup is open, `false` if it is closed.
*/
isOpen() {
return !!this._map;
}
/**
* Removes the popup from the map it has been added to.
*
* @example
* ```ts
* let popup = new Popup().addTo(map);
* popup.remove();
* ```
*/
remove = (): this => {
if (this._content) {
DOM.remove(this._content);
}
if (this._container) {
DOM.remove(this._container);
delete this._container;
}
if (this._map) {
this._map.off('move', this._update);
this._map.off('move', this._onClose);
this._map.off('click', this._onClose);
this._map.off('remove', this.remove);
this._map.off('mousemove', this._onMouseMove);
this._map.off('mouseup', this._onMouseUp);
this._map.off('drag', this._onDrag);
this._map._canvasContainer.classList.remove('maplibregl-track-pointer');
delete this._map;
this.fire(new Event('close'));
}
return this;
};
/**
* Returns the geographical location of the popup's anchor.
*
* The longitude of the result may differ by a multiple of 360 degrees from the longitude previously
* set by `setLngLat` because `Popup` wraps the anchor longitude across copies of the world to keep
* the popup on screen.
*
* @returns The geographical location of the popup's anchor.
*/
getLngLat(): LngLat {
return this._lngLat;
}
/**
* Sets the geographical location of the popup's anchor, and moves the popup to it. Replaces trackPointer() behavior.
*
* @param lnglat - The geographical location to set as the popup's anchor.
*/
setLngLat(lnglat: LngLatLike): this {
this._lngLat = LngLat.convert(lnglat);
this._pos = null;
this._flatPos = null;
this._trackPointer = false;
this._update();
if (this._map) {
this._map.on('move', this._update);
this._map.off('mousemove', this._onMouseMove);
if (this._container) {
this._container.classList.remove('maplibregl-popup-track-pointer');
}
this._map._canvasContainer.classList.remove('maplibregl-track-pointer');
}
return this;
}
/**
* Tracks the popup anchor to the cursor position on screens with a pointer device (it will be hidden on touchscreens). Replaces the `setLngLat` behavior.
* For most use cases, set `closeOnClick` and `closeButton` to `false`.
* @example
* ```ts
* let popup = new Popup({ closeOnClick: false, closeButton: false })
* .setHTML("Hello World!
")
* .trackPointer()
* .addTo(map);
* ```
*/
trackPointer(): this {
this._trackPointer = true;
this._pos = null;
this._flatPos = null;
this._update();
if (this._map) {
this._map.off('move', this._update);
this._map.on('mousemove', this._onMouseMove);
this._map.on('drag', this._onDrag);
if (this._container) {
this._container.classList.add('maplibregl-popup-track-pointer');
}
this._map._canvasContainer.classList.add('maplibregl-track-pointer');
}
return this;
}
/**
* Returns the `Popup`'s HTML element.
* @example
* Change the `Popup` element's font size
* ```ts
* let popup = new Popup()
* .setLngLat([-96, 37.8])
* .setHTML("Hello World!
")
* .addTo(map);
* let popupElem = popup.getElement();
* popupElem.style.fontSize = "25px";
* ```
* @returns element
*/
getElement(): HTMLElement {
return this._container;
}
/**
* Sets the popup's content to a string of text.
*
* This function creates a [Text](https://developer.mozilla.org/en-US/docs/Web/API/Text) node in the DOM,
* so it cannot insert raw HTML. Use this method for security against XSS
* if the popup content is user-provided.
*
* @param text - Textual content for the popup.
* @example
* ```ts
* let popup = new Popup()
* .setLngLat(e.lngLat)
* .setText('Hello, world!')
* .addTo(map);
* ```
*/
setText(text: string): this {
return this.setDOMContent(document.createTextNode(text));
}
/**
* Sets the popup's content to the HTML provided as a string.
*
* This method does not perform HTML filtering or sanitization, and must be
* used only with trusted content. Consider {@link Popup#setText} if
* the content is an untrusted text string.
*
* @param html - A string representing HTML content for the popup.
* @example
* ```ts
* let popup = new Popup()
* .setLngLat(e.lngLat)
* .setHTML("Hello World!
")
* .addTo(map);
* ```
* @see [Display a popup](https://maplibre.org/maplibre-gl-js/docs/examples/popup/)
* @see [Display a popup on hover](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-hover/)
* @see [Display a popup on click](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-click/)
* @see [Attach a popup to a marker instance](https://maplibre.org/maplibre-gl-js/docs/examples/set-popup/)
*/
setHTML(html: string): this {
const frag = document.createDocumentFragment();
const temp = document.createElement('body');
let child;
temp.innerHTML = html;
while (true) {
child = temp.firstChild;
if (!child) break;
frag.appendChild(child);
}
return this.setDOMContent(frag);
}
/**
* Returns the popup's maximum width.
*
* @returns The maximum width of the popup.
*/
getMaxWidth(): string {
return this._container?.style.maxWidth;
}
/**
* Sets the popup's maximum width. This is setting the CSS property `max-width`.
* Available values can be found here: https://developer.mozilla.org/en-US/docs/Web/CSS/max-width
*
* @param maxWidth - A string representing the value for the maximum width.
*/
setMaxWidth(maxWidth: string): this {
this.options.maxWidth = maxWidth;
this._update();
return this;
}
/**
* Sets the popup's content to the element provided as a DOM node.
*
* @param htmlNode - A DOM node to be used as content for the popup.
* @example
* Create an element with the popup content
* ```ts
* let div = document.createElement('div');
* div.innerHTML = 'Hello, world!';
* let popup = new Popup()
* .setLngLat(e.lngLat)
* .setDOMContent(div)
* .addTo(map);
* ```
*/
setDOMContent(htmlNode: Node): this {
if (this._content) {
// Clear out children first.
while (this._content.hasChildNodes()) {
if (this._content.firstChild) {
this._content.removeChild(this._content.firstChild);
}
}
} else {
this._content = DOM.create('div', 'maplibregl-popup-content', this._container);
}
// The close button should be the last tabbable element inside the popup for a good keyboard UX.
this._content.appendChild(htmlNode);
this._createCloseButton();
this._update();
this._focusFirstElement();
return this;
}
/**
* Adds a CSS class to the popup container element.
*
* @param className - Non-empty string with CSS class name to add to popup container
*
* @example
* ```ts
* let popup = new Popup()
* popup.addClassName('some-class')
* ```
*/
addClassName(className: string) {
if (this._container) {
this._container.classList.add(className);
}
return this;
}
/**
* Removes a CSS class from the popup container element.
*
* @param className - Non-empty string with CSS class name to remove from popup container
*
* @example
* ```ts
* let popup = new Popup()
* popup.removeClassName('some-class')
* ```
*/
removeClassName(className: string) {
if (this._container) {
this._container.classList.remove(className);
}
return this;
}
/**
* Sets the popup's offset.
*
* @param offset - Sets the popup's offset.
*/
setOffset (offset?: Offset): this {
this.options.offset = offset;
this._update();
return this;
}
/**
* Add or remove the given CSS class on the popup container, depending on whether the container currently has that class.
*
* @param className - Non-empty string with CSS class name to add/remove
*
* @returns if the class was removed return false, if class was added, then return true, undefined if there is no container
*
* @example
* ```ts
* let popup = new Popup()
* popup.toggleClassName('toggleClass')
* ```
*/
toggleClassName(className: string): boolean | undefined {
if (this._container) {
return this._container.classList.toggle(className);
}
}
/**
* Set the option to allow subpixel positioning of the popup by passing a boolean
*
* @param value - When boolean is true, subpixel positioning is enabled for the popup.
*
* @example
* ```ts
* let popup = new Popup()
* popup.setSubpixelPositioning(true);
* ```
*/
setSubpixelPositioning(value: boolean) {
this.options.subpixelPositioning = value;
}
_createCloseButton() {
if (this.options.closeButton) {
this._closeButton = DOM.create('button', 'maplibregl-popup-close-button', this._content);
this._closeButton.type = 'button';
this._closeButton.innerHTML = '×';
this._closeButton.addEventListener('click', this._onClose);
}
}
_onMouseUp = (event: MapMouseEvent) => {
this._update(event.point);
};
_onMouseMove = (event: MapMouseEvent) => {
this._update(event.point);
};
_onDrag = (event: MapMouseEvent) => {
this._update(event.point);
};
_update = (cursor?: Point) => {
const hasPosition = this._lngLat || this._trackPointer;
if (!this._map || !hasPosition || !this._content) { return; }
if (!this._container) {
this._container = DOM.create('div', 'maplibregl-popup', this._map.getContainer());
this._tip = DOM.create('div', 'maplibregl-popup-tip', this._container);
this._container.appendChild(this._content);
if (this.options.className) {
for (const name of this.options.className.split(' ')) {
this._container.classList.add(name);
}
}
if (this._closeButton) {
this._closeButton.setAttribute('aria-label', this._map._getUIString('Popup.Close'));
}
if (this._trackPointer) {
this._container.classList.add('maplibregl-popup-track-pointer');
}
}
if (this.options.maxWidth && this._container.style.maxWidth !== this.options.maxWidth) {
this._container.style.maxWidth = this.options.maxWidth;
}
if (this._map.transform.renderWorldCopies && !this._trackPointer) {
this._lngLat = smartWrap(this._lngLat, this._flatPos, this._map.transform);
} else {
this._lngLat = this._lngLat?.wrap();
}
if (this._trackPointer && !cursor) return;
const pos = this._flatPos = this._pos = this._trackPointer && cursor ? cursor : this._map.project(this._lngLat);
if (this._map.terrain) {
// flat position is saved because smartWrap needs non-elevated points
this._flatPos = this._trackPointer && cursor ? cursor : this._map.transform.locationPoint(this._lngLat);
}
let anchor = this.options.anchor;
const offset = normalizeOffset(this.options.offset);
if (!anchor) {
const width = this._container.offsetWidth;
const height = this._container.offsetHeight;
let anchorComponents;
if (pos.y + offset.bottom.y < height) {
anchorComponents = ['top'];
} else if (pos.y > this._map.transform.height - height) {
anchorComponents = ['bottom'];
} else {
anchorComponents = [];
}
if (pos.x < width / 2) {
anchorComponents.push('left');
} else if (pos.x > this._map.transform.width - width / 2) {
anchorComponents.push('right');
}
if (anchorComponents.length === 0) {
anchor = 'bottom';
} else {
anchor = (anchorComponents.join('-') as any);
}
}
let offsetedPos = pos.add(offset[anchor]);
if (!this.options.subpixelPositioning) {
offsetedPos = offsetedPos.round();
}
DOM.setTransform(this._container, `${anchorTranslate[anchor]} translate(${offsetedPos.x}px,${offsetedPos.y}px)`);
applyAnchorClass(this._container, anchor, 'popup');
};
_focusFirstElement() {
if (!this.options.focusAfterOpen || !this._container) return;
const firstFocusable = this._container.querySelector(focusQuerySelector) as HTMLElement;
if (firstFocusable) firstFocusable.focus();
}
_onClose = () => {
this.remove();
};
}
function normalizeOffset(offset?: Offset | null) {
if (!offset) {
return normalizeOffset(new Point(0, 0));
} else if (typeof offset === 'number') {
// input specifies a radius from which to calculate offsets at all positions
const cornerOffset = Math.round(Math.abs(offset) / Math.SQRT2);
return {
'center': new Point(0, 0),
'top': new Point(0, offset),
'top-left': new Point(cornerOffset, cornerOffset),
'top-right': new Point(-cornerOffset, cornerOffset),
'bottom': new Point(0, -offset),
'bottom-left': new Point(cornerOffset, -cornerOffset),
'bottom-right': new Point(-cornerOffset, -cornerOffset),
'left': new Point(offset, 0),
'right': new Point(-offset, 0)
};
} else if (offset instanceof Point || Array.isArray(offset)) {
// input specifies a single offset to be applied to all positions
const convertedOffset = Point.convert(offset);
return {
'center': convertedOffset,
'top': convertedOffset,
'top-left': convertedOffset,
'top-right': convertedOffset,
'bottom': convertedOffset,
'bottom-left': convertedOffset,
'bottom-right': convertedOffset,
'left': convertedOffset,
'right': convertedOffset
};
} else {
// input specifies an offset per position
return {
'center': Point.convert(offset['center'] || [0, 0]),
'top': Point.convert(offset['top'] || [0, 0]),
'top-left': Point.convert(offset['top-left'] || [0, 0]),
'top-right': Point.convert(offset['top-right'] || [0, 0]),
'bottom': Point.convert(offset['bottom'] || [0, 0]),
'bottom-left': Point.convert(offset['bottom-left'] || [0, 0]),
'bottom-right': Point.convert(offset['bottom-right'] || [0, 0]),
'left': Point.convert(offset['left'] || [0, 0]),
'right': Point.convert(offset['right'] || [0, 0])
};
}
}